From aa64e6f8c9cfca2f031b2f91da3c4e3fdd462efc Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 21 Mar 2026 19:06:03 +0800 Subject: [PATCH 01/20] feat(core): replace rooms with conversations Migrate legacy room data into a conversation runtime with archive support, and update chat, memory, and adapter flows to resolve conversations directly. --- packages/adapter-dify/src/requester.ts | 34 +- packages/core/package.json | 3 +- packages/core/src/chains/index.ts | 1 - packages/core/src/chains/rooms.ts | 780 -------- packages/core/src/command.ts | 14 +- packages/core/src/commands/chat.ts | 144 +- packages/core/src/commands/conversation.ts | 549 ++++++ packages/core/src/commands/memory.ts | 10 + packages/core/src/commands/room.ts | 206 -- packages/core/src/config.ts | 34 +- packages/core/src/index.ts | 92 +- packages/core/src/llm-core/chat/app.ts | 128 +- packages/core/src/llm-core/chat/default.ts | 47 +- .../src/llm-core/chat/infinite_context.ts | 30 +- .../src/llm-core/memory/authors_note/index.ts | 24 +- .../src/llm-core/memory/lore_book/index.ts | 24 +- .../memory/message/database_history.ts | 122 +- .../core/src/llm-core/platform/service.ts | 11 +- .../src/llm-core/prompt/context_manager.ts | 17 +- packages/core/src/locales/en-US.schema.yml | 25 +- packages/core/src/locales/en-US.yml | 434 ++--- packages/core/src/locales/zh-CN.schema.yml | 22 +- packages/core/src/locales/zh-CN.yml | 430 ++--- packages/core/src/middleware.ts | 44 +- .../auth/add_user_to_auth_group.ts | 4 +- .../src/middlewares/auth/create_auth_group.ts | 2 +- .../auth/kick_user_form_auth_group.ts | 4 +- .../src/middlewares/auth/list_auth_group.ts | 2 +- .../core/src/middlewares/auth/mute_user.ts | 80 - .../src/middlewares/auth/set_auth_group.ts | 2 +- .../core/src/middlewares/chat/allow_reply.ts | 18 +- packages/core/src/middlewares/chat/censor.ts | 2 +- .../middlewares/chat/chat_time_limit_check.ts | 47 +- .../middlewares/chat/chat_time_limit_save.ts | 13 +- .../src/middlewares/chat/message_delay.ts | 56 +- .../src/middlewares/chat/read_chat_message.ts | 79 +- .../src/middlewares/chat/rollback_chat.ts | 150 +- .../core/src/middlewares/chat/stop_chat.ts | 67 +- .../request_conversation.ts} | 106 +- .../conversation/resolve_conversation.ts | 86 + .../middlewares/model/list_all_embeddings.ts | 2 +- .../src/middlewares/model/list_all_model.ts | 2 +- .../src/middlewares/model/list_all_tool.ts | 2 +- .../middlewares/model/list_all_vectorstore.ts | 2 +- .../src/middlewares/model/resolve_model.ts | 94 +- .../src/middlewares/model/search_model.ts | 2 +- .../model/set_default_embeddings.ts | 2 +- .../model/set_default_vectorstore.ts | 2 +- .../core/src/middlewares/model/test_model.ts | 2 +- .../core/src/middlewares/preset/add_preset.ts | 2 +- .../src/middlewares/preset/clone_preset.ts | 2 +- .../src/middlewares/preset/delete_preset.ts | 12 +- .../src/middlewares/preset/list_all_preset.ts | 2 +- .../core/src/middlewares/preset/set_preset.ts | 2 +- .../core/src/middlewares/room/check_room.ts | 76 - .../core/src/middlewares/room/clear_room.ts | 69 - .../src/middlewares/room/compress_room.ts | 83 - .../core/src/middlewares/room/create_room.ts | 423 ---- .../core/src/middlewares/room/delete_room.ts | 79 - .../core/src/middlewares/room/invite_room.ts | 75 - .../core/src/middlewares/room/join_room.ts | 97 - .../core/src/middlewares/room/kick_member.ts | 68 - .../core/src/middlewares/room/leave_room.ts | 75 - .../core/src/middlewares/room/list_room.ts | 119 -- .../core/src/middlewares/room/resolve_room.ts | 334 ---- .../core/src/middlewares/room/room_info.ts | 68 - .../src/middlewares/room/room_permission.ts | 99 - .../middlewares/room/set_auto_update_room.ts | 70 - .../core/src/middlewares/room/set_room.ts | 415 ---- .../core/src/middlewares/room/switch_room.ts | 39 - .../src/middlewares/room/transfer_room.ts | 84 - .../src/middlewares/system/clear_balance.ts | 2 +- .../middlewares/system/conversation_manage.ts | 1093 +++++++++++ .../core/src/middlewares/system/lifecycle.ts | 10 +- .../src/middlewares/system/query_balance.ts | 2 +- .../src/middlewares/system/set_balance.ts | 2 +- packages/core/src/middlewares/system/wipe.ts | 83 +- packages/core/src/migration/legacy_tables.ts | 45 + .../src/migration/room_to_conversation.ts | 478 +++++ packages/core/src/migration/validators.ts | 421 ++++ packages/core/src/services/chat.ts | 1163 ++++++----- packages/core/src/services/conversation.ts | 1719 +++++++++++++++++ .../core/src/services/conversation_runtime.ts | 492 +++++ .../core/src/services/conversation_types.ts | 195 ++ packages/core/src/services/types.ts | 96 +- packages/core/src/types.ts | 36 - packages/core/src/utils/archive.ts | 62 + packages/core/src/utils/chat_request.ts | 26 + packages/core/src/utils/compression.ts | 36 + packages/core/src/utils/error.ts | 11 +- packages/core/src/utils/koishi.ts | 16 +- packages/core/src/utils/lock.ts | 6 +- packages/core/src/utils/message_content.ts | 58 + packages/core/src/utils/model.ts | 7 + packages/core/src/utils/queue.ts | 16 +- packages/core/src/utils/string.ts | 23 +- .../core/test/conversation-runtime.test.ts | 1606 +++++++++++++++ packages/extension-agent/src/cli/service.ts | 18 +- .../src/service/permissions.ts | 19 +- .../extension-agent/src/service/skills.ts | 15 +- .../src/plugins/add_memory.ts | 33 +- .../src/plugins/clear_memory.ts | 30 +- .../src/plugins/delete_memory.ts | 30 +- .../src/plugins/edit_memory.ts | 32 +- .../src/plugins/search_memory.ts | 40 +- .../src/service/memory.ts | 25 +- .../src/utils/conversation.ts | 40 + packages/extension-tools/src/plugins/todos.ts | 15 +- 108 files changed, 9223 insertions(+), 5124 deletions(-) delete mode 100644 packages/core/src/chains/rooms.ts create mode 100644 packages/core/src/commands/conversation.ts delete mode 100644 packages/core/src/commands/room.ts delete mode 100644 packages/core/src/middlewares/auth/mute_user.ts rename packages/core/src/middlewares/{model/request_model.ts => conversation/request_conversation.ts} (85%) create mode 100644 packages/core/src/middlewares/conversation/resolve_conversation.ts delete mode 100644 packages/core/src/middlewares/room/check_room.ts delete mode 100644 packages/core/src/middlewares/room/clear_room.ts delete mode 100644 packages/core/src/middlewares/room/compress_room.ts delete mode 100644 packages/core/src/middlewares/room/create_room.ts delete mode 100644 packages/core/src/middlewares/room/delete_room.ts delete mode 100644 packages/core/src/middlewares/room/invite_room.ts delete mode 100644 packages/core/src/middlewares/room/join_room.ts delete mode 100644 packages/core/src/middlewares/room/kick_member.ts delete mode 100644 packages/core/src/middlewares/room/leave_room.ts delete mode 100644 packages/core/src/middlewares/room/list_room.ts delete mode 100644 packages/core/src/middlewares/room/resolve_room.ts delete mode 100644 packages/core/src/middlewares/room/room_info.ts delete mode 100644 packages/core/src/middlewares/room/room_permission.ts delete mode 100644 packages/core/src/middlewares/room/set_auto_update_room.ts delete mode 100644 packages/core/src/middlewares/room/set_room.ts delete mode 100644 packages/core/src/middlewares/room/switch_room.ts delete mode 100644 packages/core/src/middlewares/room/transfer_room.ts create mode 100644 packages/core/src/middlewares/system/conversation_manage.ts create mode 100644 packages/core/src/migration/legacy_tables.ts create mode 100644 packages/core/src/migration/room_to_conversation.ts create mode 100644 packages/core/src/migration/validators.ts create mode 100644 packages/core/src/services/conversation.ts create mode 100644 packages/core/src/services/conversation_runtime.ts create mode 100644 packages/core/src/services/conversation_types.ts create mode 100644 packages/core/src/utils/archive.ts create mode 100644 packages/core/src/utils/chat_request.ts create mode 100644 packages/core/src/utils/compression.ts create mode 100644 packages/core/src/utils/message_content.ts create mode 100644 packages/core/src/utils/model.ts create mode 100644 packages/core/test/conversation-runtime.test.ts create mode 100644 packages/extension-long-memory/src/utils/conversation.ts diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index 461f64ab9..e1d0f453e 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -91,7 +91,7 @@ export class DifyRequester extends ModelRequester { config ) } else { - iter = this._workflowStream(params, config) + iter = this._workflowStream(params, conversationId, config) } for await (const chunk of iter) { @@ -148,7 +148,12 @@ export class DifyRequester extends ModelRequester { } = { query, response_mode: 'streaming', - inputs: this.buildInputs(params, lastMessage, chatlunaMultimodal), + inputs: this.buildInputs( + params, + conversationId, + lastMessage, + chatlunaMultimodal + ), user: difyUser, conversation_id: difyConversationId == null ? '' : difyConversationId @@ -229,6 +234,7 @@ export class DifyRequester extends ModelRequester { private async *_workflowStream( params: ModelRequestParams, + conversationId: string | undefined, config: { apiKey: string; workflowName: string; workflowType: string } ): AsyncGenerator { const lastMessage = params.input[params.input.length - 1] as @@ -249,7 +255,12 @@ export class DifyRequester extends ModelRequester { files?: InputFileObject[] } = { response_mode: 'streaming', - inputs: this.buildInputs(params, lastMessage, chatlunaMultimodal), + inputs: this.buildInputs( + params, + conversationId, + lastMessage, + chatlunaMultimodal + ), user: difyUser } @@ -316,13 +327,14 @@ export class DifyRequester extends ModelRequester { private buildInputs( params: ModelRequestParams, + conversationId: string | undefined, lastMessage?: BaseMessage, chatlunaMultimodal?: string ): Record { const inputs = { input: getMessageContent(lastMessage?.content ?? ''), chatluna_history: this.buildChatlunaHistory(params.input ?? []), - chatluna_conversation_id: params.id, + chatluna_conversation_id: conversationId, ...Object.keys(params.variables ?? {}).reduce((acc, key) => { acc[`chatluna_${key}`] = params.variables?.[key] return acc @@ -397,15 +409,11 @@ export class DifyRequester extends ModelRequester { } private resolveDifyUser(params: ModelRequestParams): string { - if (this.ctx.chatluna.config.autoCreateRoomFromUser === true) { - return ( - (params.variables?.['user_id'] as string) || - (params.variables?.['user'] as string) || - 'chatluna' - ) - } else { - return 'chatluna' - } + return ( + (params.variables?.['user_id'] as string) || + (params.variables?.['user'] as string) || + 'chatluna' + ) } private async prepareFiles( diff --git a/packages/core/package.json b/packages/core/package.json index d584d62d8..7250fcc68 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -225,7 +225,8 @@ "url": "https://github.com/ChatLunaLab/chatluna/issues" }, "scripts": { - "build": "atsc -b" + "build": "atsc -b", + "test": "node --import tsx --test test/**/*.test.ts" }, "engines": { "node": ">=18.0.0" diff --git a/packages/core/src/chains/index.ts b/packages/core/src/chains/index.ts index a14f1d0f0..bbfb40bd0 100644 --- a/packages/core/src/chains/index.ts +++ b/packages/core/src/chains/index.ts @@ -1,2 +1 @@ export * from './chain' -export * from './rooms' diff --git a/packages/core/src/chains/rooms.ts b/packages/core/src/chains/rooms.ts deleted file mode 100644 index 632061b6a..000000000 --- a/packages/core/src/chains/rooms.ts +++ /dev/null @@ -1,780 +0,0 @@ -import { randomInt } from 'crypto' -import { $, Context, Session, User } from 'koishi' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' -import { - ChatLunaError, - ChatLunaErrorCode -} from 'koishi-plugin-chatluna/utils/error' -import { Config } from '../config' -import { chunkArray } from 'koishi-plugin-chatluna/llm-core/utils/chunk' -import { ConversationRoom, ConversationRoomGroupInfo } from '../types' - -export async function queryJoinedConversationRoom( - ctx: Context, - session: Session, - name?: string -) { - if (name != null) { - const joinedRooms = await getAllJoinedConversationRoom(ctx, session) - - return joinedRooms.find( - (it) => it.roomName === name || it.roomId === parseInt(name) - ) - } - - const userRoomInfoList = await ctx.database.get('chathub_user', { - userId: session.userId, - groupId: session.isDirect ? '0' : session.guildId - }) - - if (userRoomInfoList.length > 1) { - throw new ChatLunaError( - ChatLunaErrorCode.UNKNOWN_ERROR, - new Error('User has multiple default rooms, this is impossible!') - ) - } else if (userRoomInfoList.length === 0) { - return undefined - } - const userRoomInfo = userRoomInfoList[0] - return await resolveConversationRoom(ctx, userRoomInfo.defaultRoomId) -} - -export function queryPublicConversationRooms( - ctx: Context, - session: Session -): Promise { - // 如果是私聊,直接返回 null - - if (session.isDirect) { - return Promise.resolve([]) - } - - // 如果是群聊,那么就查询群聊的公共房间 - - return ctx.database.get('chathub_room_group_member', { - groupId: session.guildId, - roomVisibility: { - // TODO: better type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - $in: ['template_clone', 'public'] as unknown as any - // $in: ['template_clone', 'public'] - } - }) -} - -export async function queryPublicConversationRoom( - ctx: Context, - session: Session -) { - const groupRoomInfoList = await queryPublicConversationRooms(ctx, session) - // 优先加入模版克隆房间 - const templateCloneRoom = groupRoomInfoList.find( - (it) => it.roomVisibility === 'template_clone' - ) - - let roomId: number - - if (templateCloneRoom != null) { - roomId = templateCloneRoom.roomId - } else if (groupRoomInfoList.length < 1) { - return undefined - } else if (groupRoomInfoList.length === 1) { - roomId = groupRoomInfoList[0].roomId - } else { - const groupRoomInfo = - groupRoomInfoList[randomInt(groupRoomInfoList.length)] - roomId = groupRoomInfo.roomId - } - - const room = await resolveConversationRoom(ctx, roomId) - - if (room == null && roomId !== 0) { - // why? - await deleteConversationRoomByRoomId(ctx, roomId) - return undefined - } - - await joinConversationRoom(ctx, session, room) - return room -} - -export async function checkConversationRoomAvailability( - ctx: Context, - room: ConversationRoom -): Promise { - const platformService = ctx.chatluna.platform - const presetService = ctx.chatluna.preset - - // check model - - if (room.model == null) { - return false - } - - const [platformName, modelName] = parseRawModelName(room.model) - - if (platformName == null || modelName == null) { - return false - } - - const platformModels = platformService.listPlatformModels( - platformName, - ModelType.llm - ).value - - if (platformModels.length < 1) { - return false - } - - if (!platformModels.some((it) => it.name === modelName)) { - return false - } - - if (!presetService.getPreset(room.preset, false).value) { - return false - } - - return true -} - -export async function fixConversationRoomAvailability( - ctx: Context, - config: Config, - room: ConversationRoom -) { - const platformService = ctx.chatluna.platform - const presetService = ctx.chatluna.preset - - // check model - - const [platformName, modelName] = parseRawModelName(room.model) - - const platformModels = platformService.listPlatformModels( - platformName, - ModelType.llm - ).value - - if (platformModels.length < 1) { - // 直接使用模版的房间 - room.model = (await getTemplateConversationRoom(ctx, config)).model - } else if (!platformModels.some((it) => it.name === modelName)) { - room.model = platformName + '/' + platformModels[0].name - } - - if (!presetService.getPreset(room.preset, false).value) { - room.preset = presetService.getDefaultPreset().value.triggerKeyword[0] - } - - await ctx.database.upsert('chathub_room', [room]) - - return await checkConversationRoomAvailability(ctx, room) -} - -export async function getTemplateConversationRoom( - ctx: Context, - config: Config -): Promise { - const models = ctx.chatluna.platform.listAllModels(ModelType.llm) - - config = ctx.scope.parent.config - - const selectModelAndPreset = async () => { - if ( - config.defaultModel === '无' || - config.defaultModel == null || - config.defaultModel === '' - ) { - const model = - models.value.find( - (model) => - model.name.includes('nano') || - model.name.includes('flash') || - model.name.includes('mini') - ) ?? models.value[0] - - config.defaultModel = model?.toModelName() - } else { - const [platformName, modelName] = parseRawModelName( - config.defaultModel - ) - - const platformModels = ctx.chatluna.platform.listPlatformModels( - platformName, - ModelType.llm - ).value - - if (platformModels.length < 1) { - const model = - models.value.find( - (model) => - model.name.includes('nano') || - model.name.includes('flash') || - model.name.includes('mini') - ) ?? models.value[0] - - config.defaultModel = model?.toModelName() - } else if ( - !platformModels.some((model) => model.name === modelName) - ) { - const modelInfo = platformModels.find( - (model) => model.name === modelName - ) - - if (modelInfo == null) { - // make re-set model - config.defaultModel = null - } else { - const model = platformName + '/' + modelInfo.name - - config.defaultModel = model - } - } - } - - if (config.defaultPreset == null) { - const preset = ctx.chatluna.preset.getDefaultPreset().value - - config.defaultPreset = preset.triggerKeyword[0] - } - - ctx.scope.parent.scope.update(config, true) - } - - if ( - config.defaultChatMode == null || - config.defaultModel === '无' || - config.defaultModel == null || - config.defaultPreset == null - ) { - if (config.defaultChatMode == null) { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_TEMPLATE_INVALID) - } - - await selectModelAndPreset() - - // throw new ChatLunaError(ChatLunaErrorCode.INIT_ROOM) - } - - let room: ConversationRoom = { - roomId: 0, - roomName: '模板房间', - roomMasterId: '0', - preset: config.defaultPreset, - conversationId: '0', - chatMode: config.defaultChatMode, - password: '', - model: config.defaultModel, - visibility: 'public', - autoUpdate: true, - updatedTime: new Date() - } - - if (!(await checkConversationRoomAvailability(ctx, room))) { - await selectModelAndPreset() - // select new model and preset - room = { - roomId: 0, - roomName: '模板房间', - roomMasterId: '0', - preset: config.defaultPreset, - conversationId: '0', - chatMode: config.defaultChatMode, - password: '', - model: config.defaultModel, - visibility: 'public', - autoUpdate: true, - updatedTime: new Date() - } - } - - return room -} - -export async function getConversationRoomCount(ctx: Context) { - const count = await ctx.database - .select('chathub_room') - .execute((row) => $.max(row.roomId)) - - return count -} - -export async function transferConversationRoom( - ctx: Context, - session: Session, - room: ConversationRoom, - userId: string -) { - const memberList = await ctx.database.get('chathub_room_member', { - roomId: room.roomId, - userId - }) - - if (memberList.length === 0) { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_NOT_FOUND) - } - - await ctx.database.upsert('chathub_room', [ - { roomId: room.roomId, roomMasterId: userId } - ]) - - // 搜索原来的房主,降级为成员 - - const oldMaster = await ctx.database.get('chathub_room_member', { - roomId: room.roomId, - roomPermission: 'owner' - }) - - if (oldMaster.length === 1) { - await ctx.database.upsert('chathub_room_member', [ - { - userId: oldMaster[0].userId, - roomId: room.roomId, - roomPermission: 'member' - } - ]) - } else { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_NOT_FOUND_MASTER) - } - - await ctx.database.upsert('chathub_room_member', [ - { userId, roomId: room.roomId, roomPermission: 'owner' } - ]) - - await ctx.database.upsert('chathub_user', [ - { - userId, - defaultRoomId: room.roomId, - groupId: session.isDirect ? '0' : session.guildId - } - ]) -} - -export async function switchConversationRoom( - ctx: Context, - session: Session, - id: string | number -) { - let joinedRoom = await getAllJoinedConversationRoom(ctx, session) - - const parsedId = typeof id === 'number' ? id : parseInt(id) - - let room = joinedRoom.find((it) => it.roomId === parsedId) - - if (room != null) { - await ctx.database.upsert('chathub_user', [ - { - userId: session.userId, - defaultRoomId: room.roomId, - groupId: session.isDirect ? '0' : session.guildId - } - ]) - - return room - } - - joinedRoom = joinedRoom.filter((it) => it.roomName === id) - - if (joinedRoom.length > 1) { - throw new ChatLunaError( - ChatLunaErrorCode.THE_NAME_FIND_IN_MULTIPLE_ROOMS - ) - } else if (joinedRoom.length === 0) { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_NOT_FOUND) - } else { - room = joinedRoom[0] - } - - await ctx.database.upsert('chathub_user', [ - { - userId: session.userId, - defaultRoomId: room.roomId, - groupId: session.isDirect ? '0' : session.guildId - } - ]) - - return room -} - -export async function getAllJoinedConversationRoom( - ctx: Context, - session: Session, - queryAll: boolean = false -) { - // 这里分片进行 chunk 然后用 in 查询,这么做的好处是可以减少很多的查询次数 - const conversationRoomList = chunkArray( - await ctx.database.get('chathub_room_member', { - userId: session.userId - }), - 35 - ) - - const rooms: ConversationRoom[] = [] - - for (const conversationRoomChunk of conversationRoomList) { - const roomIds = conversationRoomChunk.map((it) => it.roomId) - const roomList = await ctx.database.get('chathub_room', { - roomId: { $in: roomIds } - }) - - let memberList: ConversationRoomGroupInfo[] = [] - - if (queryAll === false) { - memberList = await ctx.database.get('chathub_room_group_member', { - roomId: { $in: roomIds }, - // 设置 undefined 来全量搜索 - groupId: session.guildId ?? undefined - }) - } - - for (const room of roomList) { - const memberOfTheRoom = memberList.some( - (it) => it.roomId === room.roomId - ) - - if ( - // 模版克隆房间或者公共房间需要指定房间的范围不能干预到私聊的 - (!session.isDirect && memberOfTheRoom) || - // 同上 - (session.isDirect && room.visibility !== 'template_clone') || - // 私有房间跨群 - room.visibility === 'private' || - // 模版克隆房间需要指定非群聊 - (room.visibility === 'template_clone' && - !session.isDirect && - !memberOfTheRoom) || - queryAll === true - ) { - rooms.push(room) - } - } - } - - return rooms -} - -export async function leaveConversationRoom( - ctx: Context, - session: Session, - room: ConversationRoom -) { - await ctx.database.remove('chathub_room_member', { - userId: session.userId, - roomId: room.roomId - }) - - await ctx.database.remove('chathub_user', { - userId: session.userId, - defaultRoomId: room.roomId, - groupId: session.isDirect ? '0' : session.guildId - }) -} - -export async function queryConversationRoom( - ctx: Context, - session: Session, - name: string | number -) { - const roomId = typeof name === 'number' ? name : parseInt(name) - const roomName = typeof name === 'string' ? name : undefined - - const roomList = Number.isNaN(roomId) - ? await ctx.database.get('chathub_room', { roomName }) - : await ctx.database.get('chathub_room', { roomId }) - - if (roomList.length === 1) { - return roomList[0] as ConversationRoom - } else if (roomList.length > 1) { - // 在限定搜索到群里一次。 - - if (session.isDirect || Number.isNaN(roomId)) { - throw new ChatLunaError( - ChatLunaErrorCode.THE_NAME_FIND_IN_MULTIPLE_ROOMS - ) - } - - const groupRoomList = await ctx.database.get( - 'chathub_room_group_member', - { - groupId: session.guildId, - roomId: { $in: roomList.map((it) => it.roomId) } - } - ) - - if (groupRoomList.length === 1) { - return roomList.find((it) => it.roomId === groupRoomList[0].roomId) - } else if (groupRoomList.length > 1) { - throw new ChatLunaError( - ChatLunaErrorCode.THE_NAME_FIND_IN_MULTIPLE_ROOMS - ) - } - } else if (roomList.length === 0) { - return undefined - } -} - -export async function resolveConversationRoom(ctx: Context, roomId: number) { - const roomList = await ctx.database.get('chathub_room', { roomId }) - - if (roomList.length > 1) { - throw new ChatLunaError( - ChatLunaErrorCode.THE_NAME_FIND_IN_MULTIPLE_ROOMS - ) - } else if (roomList.length === 0) { - return undefined - } - - return roomList[0] as ConversationRoom -} - -export async function deleteConversationRoom( - ctx: Context, - room: ConversationRoom -) { - const chatBridger = ctx.chatluna.queryInterfaceWrapper(room, false) - - await chatBridger?.clearChatHistory(room) - - await deleteConversationRoomByRoomId(ctx, room.roomId) - - await ctx.database.remove('chathub_message', { - conversation: room.conversationId - }) - - await ctx.database.remove('chathub_conversation', { - id: room.conversationId - }) -} - -export async function deleteConversationRoomByRoomId( - ctx: Context, - roomId: number -) { - await ctx.database.remove('chathub_room', { roomId }) - - await ctx.database.remove('chathub_room_member', { roomId }) - - await ctx.database.remove('chathub_room_group_member', { roomId }) - - await ctx.database.remove('chathub_user', { defaultRoomId: roomId }) -} - -export async function joinConversationRoom( - ctx: Context, - session: Session, - roomId: number | ConversationRoom, - isDirect: boolean = session.isDirect, - userId: string = session.userId -) { - // 接下来检查房间的权限和当前所处的环境 - - const room = - typeof roomId === 'number' - ? await resolveConversationRoom(ctx, roomId) - : roomId - - await ctx.database.upsert('chathub_user', [ - { - userId, - defaultRoomId: room.roomId, - groupId: session.isDirect ? '0' : session.guildId - } - ]) - - if (isDirect === false) { - // 如果是群聊,那么就需要检查群聊的权限 - - const groupMemberList = await ctx.database.get( - 'chathub_room_group_member', - { groupId: session.guildId, roomId: room.roomId } - ) - - if (groupMemberList.length === 0) { - await ctx.database.create('chathub_room_group_member', { - groupId: session.guildId, - roomId: room.roomId, - roomVisibility: room.visibility - }) - } - } - - const memberList = await ctx.database.get('chathub_room_member', { - userId, - roomId: room.roomId - }) - - if (memberList.length === 0) { - await ctx.database.create('chathub_room_member', { - userId, - roomId: room.roomId, - roomPermission: userId === room.roomMasterId ? 'owner' : 'member' - }) - } -} - -export async function getConversationRoomUser( - ctx: Context, - session: Session, - roomId: number | ConversationRoom, - userId: string = session.userId -) { - const room = - typeof roomId === 'number' - ? await resolveConversationRoom(ctx, roomId) - : roomId - - const memberList = await ctx.database.get('chathub_room_member', { - roomId: room.roomId, - userId - }) - - return memberList?.[0] -} - -export async function setUserPermission( - ctx: Context, - session: Session, - roomId: number | ConversationRoom, - permission: 'member' | 'admin', - userId: string = session.userId -) { - const room = - typeof roomId === 'number' - ? await resolveConversationRoom(ctx, roomId) - : roomId - - const memberList = await ctx.database.get('chathub_room_member', { - roomId: room.roomId, - userId - }) - - if (memberList.length === 0) { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_NOT_FOUND) - } - - await ctx.database.upsert('chathub_room_member', [ - { userId, roomId: room.roomId, roomPermission: permission } - ]) -} - -export async function addConversationRoomToGroup( - ctx: Context, - session: Session, - roomId: number | ConversationRoom, - groupId: string = session.guildId -) { - const room = - typeof roomId === 'number' - ? await resolveConversationRoom(ctx, roomId) - : roomId - - const memberList = await ctx.database.get('chathub_room_group_member', { - roomId: room.roomId, - groupId - }) - - if (memberList.length === 0) { - await ctx.database.create('chathub_room_group_member', { - roomId: room.roomId, - groupId, - roomVisibility: room.visibility - }) - } -} - -export async function muteUserFromConversationRoom( - ctx: Context, - session: Session, - roomId: number | ConversationRoom, - userId: string -) { - const room = - typeof roomId === 'number' - ? await resolveConversationRoom(ctx, roomId) - : roomId - - const memberList = await ctx.database.get('chathub_room_member', { - roomId: room.roomId, - userId - }) - - if (memberList.length === 0) { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_NOT_JOINED) - } - - await ctx.database.upsert('chathub_room_member', [ - { userId, roomId: room.roomId, mute: memberList[0].mute !== true } - ]) -} - -export async function kickUserFromConversationRoom( - ctx: Context, - session: Session, - roomId: number | ConversationRoom, - userId: string -) { - const room = - typeof roomId === 'number' - ? await resolveConversationRoom(ctx, roomId) - : roomId - - const memberList = await ctx.database.get('chathub_room_member', { - roomId: room.roomId, - userId - }) - - if (memberList.length === 0) { - throw new ChatLunaError(ChatLunaErrorCode.ROOM_NOT_JOINED) - } - - await ctx.database.remove('chathub_room_member', { - roomId: room.roomId, - userId - }) - - await ctx.database.remove('chathub_user', { - userId, - defaultRoomId: room.roomId - }) -} - -export async function checkAdmin(session: Session) { - const tested = await session.app.permissions.test('chatluna:admin', session) - - if (tested) { - return true - } - - const user = await session.getUser(session.userId, [ - 'authority' - ]) - - return user?.authority >= 3 -} - -export async function updateChatTime(ctx: Context, room: ConversationRoom) { - await ctx.database.upsert('chathub_room', [ - { roomId: room.roomId, updatedTime: new Date() } - ]) -} - -export async function createConversationRoom( - ctx: Context, - session: Session, - room: ConversationRoom -) { - // 先向 room 里面插入表 - - await ctx.database.create('chathub_room', room) - - // 将创建者加入到房间成员里 - - await ctx.database.create('chathub_room_member', { - userId: session.userId, - roomId: room.roomId, - roomPermission: - session.userId === room.roomMasterId ? 'owner' : 'member' - }) - - await joinConversationRoom(ctx, session, room) -} diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 175da7168..56f0d09d8 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -4,12 +4,12 @@ import { Config } from './config' // import start import { apply as auth } from './commands/auth' import { apply as chat } from './commands/chat' +import { apply as conversation } from './commands/conversation' import { apply as mcp } from './commands/mcp' import { apply as memory } from './commands/memory' import { apply as model } from './commands/model' import { apply as preset } from './commands/preset' import { apply as providers } from './commands/providers' -import { apply as room } from './commands/room' import { apply as tool } from './commands/tool' // import end export async function command(ctx: Context, config: Config) { @@ -21,7 +21,17 @@ export async function command(ctx: Context, config: Config) { const middlewares: Command[] = // middleware start - [auth, chat, mcp, memory, model, preset, providers, room, tool] // middleware end + [ + auth, + chat, + conversation, + mcp, + memory, + model, + preset, + providers, + tool + ] // middleware end for (const middleware of middlewares) { await middleware(ctx, config, ctx.chatluna.chatChain) diff --git a/packages/core/src/commands/chat.ts b/packages/core/src/commands/chat.ts index c98f4dbab..181c39387 100644 --- a/packages/core/src/commands/chat.ts +++ b/packages/core/src/commands/chat.ts @@ -1,9 +1,63 @@ -import { Context, h } from 'koishi' +import { Command, Context, h } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' import { RenderType } from '../types' +function normalizeTarget(value?: string | null) { + return value == null || value.trim().length < 1 ? undefined : value.trim() +} + +function setOptionChoices(cmd: Command, name: string, values: string[]) { + if (cmd._options[name] != null) { + cmd._options[name].type = values + } +} + +async function completeConversationTarget( + ctx: Context, + session: import('koishi').Session, + target?: string, + presetLane?: string, + includeArchived = true +) { + const value = normalizeTarget(target) + if (value == null) { + return undefined + } + + const conversations = await ctx.chatluna.conversation.listConversations( + session, + { + presetLane, + includeArchived + } + ) + const expect = Array.from( + new Set( + conversations.flatMap((conversation) => [ + conversation.id, + String(conversation.seq ?? ''), + conversation.title + ]) + ) + ).filter((item) => item.length > 0) + + if (expect.length === 0) { + return value + } + + return session.suggest({ + actual: value, + expect, + suffix: session.text('commands.chatluna.chat.text.options.conversation') + }) +} + export function apply(ctx: Context, config: Config, chain: ChatChain) { + const presets = ctx.chatluna.preset + .getAllPreset(true) + .value.flatMap((entry) => entry.split(',').map((item) => item.trim())) + ctx.command('chatluna', { authority: 1 }).alias('chatluna') @@ -12,11 +66,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { authority: 1 }) - ctx.command('chatluna.chat.text ') - .option('room', '-r ') + const chatCommand = ctx + .command('chatluna.chat ') + .alias('chatluna.chat.text') + .option('conversation', '-c ') + .option('preset', '-p ') .option('type', '-t ') .action(async ({ options, session }, message) => { const renderType = options.type ?? config.outputMode + const presetLane = normalizeTarget(options.preset) if ( !ctx.chatluna.renderer.rendererTypeList.some( @@ -32,9 +90,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { '', { message: elements, - room_resolve: { - name: options.room - }, + targetConversation: await completeConversationTarget( + ctx, + session, + options.conversation, + presetLane, + false + ), + presetLane, renderOptions: { session, split: config.splitMessage, @@ -44,9 +107,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) + setOptionChoices(chatCommand, 'preset', presets) ctx.command('chatluna.chat.rollback [message:text]') - .option('room', '-r ') + .option('conversation', '-c ') .option('i', '-i ') .action(async ({ options, session }, message) => { const elements = message ? h.parse(message) : undefined @@ -55,9 +119,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'rollback', { message: elements, - room_resolve: { - name: options.room - }, + targetConversation: await completeConversationTarget( + ctx, + session, + options.conversation, + undefined, + false + ), renderOptions: { session, split: config.splitMessage, @@ -70,39 +138,50 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) ctx.command('chatluna.chat.stop') - .option('room', '-r ') - .action(async ({ options, session }, message) => { + .option('conversation', '-c ') + .action(async ({ options, session }) => { await chain.receiveCommand( session, 'stop_chat', { - room_resolve: { - name: options.room - } + targetConversation: await completeConversationTarget( + ctx, + session, + options.conversation, + undefined, + false + ) }, ctx ) }) ctx.command('chatluna.chat.compress') - .option('room', '-r ') + .option('conversation', '-c ') .action(async ({ options, session }) => { await chain.receiveCommand( session, - 'compress_room', + 'conversation_compress', { force: true, - i18n_base: 'commands.chatluna.chat.compress.messages', - room_resolve: { - name: options.room - } + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + options.conversation, + undefined, + false + ) + }, + i18n_base: 'commands.chatluna.chat.compress.messages' }, ctx ) }) - ctx.command('chatluna.chat.voice ') - .option('room', '-r ') + const voiceCommand = ctx + .command('chatluna.chat.voice ') + .option('conversation', '-c ') .option('speaker', '-s ', { authority: 1 }) .action(async ({ options, session }, message) => { const elements = message ? h.parse(message) : undefined @@ -111,6 +190,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { '', { message: elements, + targetConversation: await completeConversationTarget( + ctx, + session, + options.conversation, + undefined, + false + ), renderOptions: { split: config.splitMessage, type: 'voice', @@ -118,9 +204,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { speakerId: options.speaker }, session - }, - room_resolve: { - name: options.room } }, ctx @@ -133,6 +216,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) + ctx.command('chatluna.admin.purge-legacy', { authority: 3 }).action( + async ({ session }) => { + await chain.receiveCommand(session, 'purge_legacy') + } + ) + ctx.command('chatluna.restart').action(async ({ options, session }) => { await chain.receiveCommand(session, 'restart') }) @@ -141,5 +230,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { declare module '../chains/chain' { interface ChainMiddlewareContextOptions { message?: h[] + conversationId?: string + targetConversation?: string + presetLane?: string } } diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts new file mode 100644 index 000000000..6119e0319 --- /dev/null +++ b/packages/core/src/commands/conversation.ts @@ -0,0 +1,549 @@ +import { Command, Context } from 'koishi' +import { Config } from '../config' +import { ChatChain } from '../chains/chain' +import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' + +function normalizeTarget(value?: string | null) { + return value == null || value.trim().length < 1 ? undefined : value.trim() +} + +function setChoices(cmd: Command, index: number, values: string[]) { + if (cmd._arguments[index] != null) { + cmd._arguments[index].type = values + } +} + +function setOptionChoices(cmd: Command, name: string, values: string[]) { + if (cmd._options[name] != null) { + cmd._options[name].type = values + } +} + +async function completeConversationTarget( + ctx: Context, + session: import('koishi').Session, + target?: string, + presetLane?: string, + includeArchived = true +) { + const value = normalizeTarget(target) + if (value == null) { + return undefined + } + + const conversations = await ctx.chatluna.conversation.listConversations( + session, + { + presetLane, + includeArchived + } + ) + const expect = Array.from( + new Set( + conversations.flatMap((conversation) => [ + conversation.id, + String(conversation.seq ?? ''), + conversation.title + ]) + ) + ).filter((item) => item.length > 0) + + if (expect.length === 0) { + return value + } + + return session.suggest({ + actual: value, + expect, + suffix: session.text( + 'commands.chatluna.conversation.options.conversation' + ) + }) +} + +export function apply(ctx: Context, config: Config, chain: ChatChain) { + const presets = ctx.chatluna.preset + .getAllPreset(true) + .value.flatMap((entry) => entry.split(',').map((item) => item.trim())) + const modes = ['chat', 'plugin', 'browsing'] + const shares = ['personal', 'shared', 'reset'] + const locks = ['on', 'off', 'toggle', 'reset'] + const models = ctx.chatluna.platform + .listAllModels(ModelType.llm) + .value.map((item) => item.name) + + ctx.command('chatluna.conversation', { + authority: 1 + }).alias('chatluna.session') + + ctx.command('chatluna.rule', { + authority: 3 + }) + + ctx.command('chatluna.use', { + authority: 1 + }) + + const newCommand = ctx.command('chatluna.new [title:text]', { + authority: 1 + }) + newCommand + .alias('chatluna.chat.new') + .alias('chatluna.clear') + .option('preset', '-p ') + .option('model', '-m ') + .option('chatMode', '-c ') + .action(async ({ options, session }, title) => { + await chain.receiveCommand( + session, + 'conversation_new', + { + conversation_create: { + title: normalizeTarget(title), + preset: normalizeTarget(options.preset), + model: normalizeTarget(options.model), + chatMode: normalizeTarget(options.chatMode) + } + }, + ctx + ) + }) + setOptionChoices(newCommand, 'preset', presets) + setOptionChoices(newCommand, 'model', models) + setOptionChoices(newCommand, 'chatMode', modes) + + const switchCommand = ctx.command('chatluna.switch ', { + authority: 1 + }) + switchCommand + .alias('chatluna.chat.switch') + .option('preset', '-p ') + .action(async ({ options, session }, conversation) => { + const presetLane = normalizeTarget(options.preset) + await chain.receiveCommand( + session, + 'conversation_switch', + { + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + presetLane, + false + ), + presetLane + } + }, + ctx + ) + }) + setOptionChoices(switchCommand, 'preset', presets) + + ctx.command('chatluna.list', { + authority: 1 + }) + .alias('chatluna.chat.list') + .option('page', '-p ') + .option('limit', '-l ') + .option('archived', '-a') + .option('all', '--all') + .option('preset', '-P ') + .action(async ({ options, session }) => { + await chain.receiveCommand( + session, + 'conversation_list', + { + page: options.page ?? 1, + limit: options.limit ?? 5, + conversation_manage: { + includeArchived: + options.archived === true || options.all === true, + presetLane: normalizeTarget(options.preset) + } + }, + ctx + ) + }) + + const currentCommand = ctx.command('chatluna.current', { + authority: 1 + }) + currentCommand + .alias('chatluna.chat.current') + .option('preset', '-p ') + .action(async ({ options, session }) => { + await chain.receiveCommand( + session, + 'conversation_current', + { + conversation_manage: { + presetLane: normalizeTarget(options.preset) + } + }, + ctx + ) + }) + setOptionChoices(currentCommand, 'preset', presets) + + const archiveCommand = ctx.command( + 'chatluna.archive [conversation:string]', + { + authority: 1 + } + ) + archiveCommand + .alias('chatluna.chat.archive') + .option('preset', '-p ') + .action(async ({ options, session }, conversation) => { + const presetLane = normalizeTarget(options.preset) + await chain.receiveCommand( + session, + 'conversation_archive', + { + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + presetLane + ), + presetLane + } + }, + ctx + ) + }) + setOptionChoices(archiveCommand, 'preset', presets) + + const restoreCommand = ctx.command( + 'chatluna.restore [conversation:string]', + { + authority: 1 + } + ) + restoreCommand + .alias('chatluna.chat.restore') + .option('preset', '-p ') + .action(async ({ options, session }, conversation) => { + const presetLane = normalizeTarget(options.preset) + await chain.receiveCommand( + session, + 'conversation_restore', + { + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + presetLane + ), + presetLane + } + }, + ctx + ) + }) + setOptionChoices(restoreCommand, 'preset', presets) + + const exportCommand = ctx.command('chatluna.export [conversation:string]', { + authority: 1 + }) + exportCommand + .alias('chatluna.chat.export') + .option('preset', '-p ') + .action(async ({ options, session }, conversation) => { + const presetLane = normalizeTarget(options.preset) + await chain.receiveCommand( + session, + 'conversation_export', + { + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + presetLane + ), + presetLane + } + }, + ctx + ) + }) + setOptionChoices(exportCommand, 'preset', presets) + + const compressCommand = ctx.command( + 'chatluna.compress [conversation:string]', + { + authority: 1 + } + ) + compressCommand + .alias('chatluna.chat.compress') + .alias('chatluna.chat.compress-current') + .option('preset', '-p ') + .action(async ({ options, session }, conversation) => { + const presetLane = normalizeTarget(options.preset) + await chain.receiveCommand( + session, + 'conversation_compress', + { + force: true, + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + presetLane + ), + presetLane + }, + i18n_base: 'commands.chatluna.compress.messages' + }, + ctx + ) + }) + setOptionChoices(compressCommand, 'preset', presets) + + const renameCommand = ctx + .command('chatluna.rename ', { + authority: 1 + }) + .option('preset', '-p ') + .action(async ({ options, session }, title) => { + await chain.receiveCommand( + session, + 'conversation_rename', + { + conversation_manage: { + title: normalizeTarget(title), + presetLane: normalizeTarget(options.preset) + } + }, + ctx + ) + }) + setOptionChoices(renameCommand, 'preset', presets) + + const deleteCommand = ctx + .command('chatluna.delete [conversation:string]', { + authority: 1 + }) + .option('preset', '-p ') + .action(async ({ options, session }, conversation) => { + const presetLane = normalizeTarget(options.preset) + await chain.receiveCommand( + session, + 'conversation_delete', + { + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + presetLane + ), + presetLane + } + }, + ctx + ) + }) + setOptionChoices(deleteCommand, 'preset', presets) + + const useModelCommand = ctx + .command('chatluna.use.model ', { + authority: 1 + }) + .option('preset', '-p ') + .action(async ({ options, session }, model) => { + await chain.receiveCommand( + session, + 'conversation_use_model', + { + conversation_manage: { + presetLane: normalizeTarget(options.preset) + }, + conversation_use: { + model: normalizeTarget(model) + } + }, + ctx + ) + }) + setChoices(useModelCommand, 0, models) + setOptionChoices(useModelCommand, 'preset', presets) + + const usePresetCommand = ctx + .command('chatluna.use.preset ', { + authority: 1 + }) + .option('lane', '-p ') + .action(async ({ options, session }, preset) => { + await chain.receiveCommand( + session, + 'conversation_use_preset', + { + conversation_manage: { + presetLane: normalizeTarget(options.lane) + }, + conversation_use: { + preset: normalizeTarget(preset) + } + }, + ctx + ) + }) + setChoices(usePresetCommand, 0, presets) + setOptionChoices(usePresetCommand, 'lane', presets) + + const useModeCommand = ctx + .command('chatluna.use.mode ', { + authority: 1 + }) + .option('preset', '-p ') + .action(async ({ options, session }, mode) => { + await chain.receiveCommand( + session, + 'conversation_use_mode', + { + conversation_manage: { + presetLane: normalizeTarget(options.preset) + }, + conversation_use: { + chatMode: normalizeTarget(mode) + } + }, + ctx + ) + }) + setChoices(useModeCommand, 0, modes) + setOptionChoices(useModeCommand, 'preset', presets) + + const ruleModelCommand = ctx + .command('chatluna.rule.model ', { + authority: 3 + }) + .action(async ({ session }, model) => { + await chain.receiveCommand( + session, + 'conversation_rule_model', + { + conversation_rule: { + model: normalizeTarget(model) + } + }, + ctx + ) + }) + setChoices(ruleModelCommand, 0, [...models, 'reset']) + + const rulePresetCommand = ctx + .command('chatluna.rule.preset ', { + authority: 3 + }) + .action(async ({ session }, preset) => { + await chain.receiveCommand( + session, + 'conversation_rule_preset', + { + conversation_rule: { + preset: normalizeTarget(preset) + } + }, + ctx + ) + }) + setChoices(rulePresetCommand, 0, [...presets, 'reset']) + + const ruleModeCommand = ctx + .command('chatluna.rule.mode ', { + authority: 3 + }) + .action(async ({ session }, mode) => { + await chain.receiveCommand( + session, + 'conversation_rule_mode', + { + conversation_rule: { + chatMode: normalizeTarget(mode) + } + }, + ctx + ) + }) + setChoices(ruleModeCommand, 0, [...modes, 'reset']) + + const ruleShareCommand = ctx + .command('chatluna.rule.share ', { + authority: 3 + }) + .action(async ({ session }, mode) => { + await chain.receiveCommand( + session, + 'conversation_rule_share', + { + conversation_rule: { + share: normalizeTarget(mode) + } + }, + ctx + ) + }) + setChoices(ruleShareCommand, 0, shares) + + const ruleLockCommand = ctx + .command('chatluna.rule.lock [state:string]', { + authority: 3 + }) + .action(async ({ session }, state) => { + await chain.receiveCommand( + session, + 'conversation_rule_lock', + { + conversation_rule: { + lock: normalizeTarget(state) ?? 'toggle' + } + }, + ctx + ) + }) + setChoices(ruleLockCommand, 0, locks) + + ctx.command('chatluna.rule.show', { + authority: 3 + }).action(async ({ session }) => { + await chain.receiveCommand(session, 'conversation_rule_show', {}, ctx) + }) +} + +declare module '../chains/chain' { + interface ChainMiddlewareContextOptions { + conversation_create?: { + title?: string + preset?: string + model?: string + chatMode?: string + } + conversation_manage?: { + targetConversation?: string + presetLane?: string + includeArchived?: boolean + title?: string + } + conversation_use?: { + model?: string + preset?: string + chatMode?: string + } + conversation_rule?: { + model?: string + preset?: string + chatMode?: string + share?: string + lock?: string + } + i18n_base?: string + } +} diff --git a/packages/core/src/commands/memory.ts b/packages/core/src/commands/memory.ts index 6dfadc50b..60d3c2869 100644 --- a/packages/core/src/commands/memory.ts +++ b/packages/core/src/commands/memory.ts @@ -7,12 +7,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx.command('chatluna.memory', { authority: 1 }) ctx.command('chatluna.memory.search ') + .option('preset', '-P ') .option('type', '-t ') .option('limit', '-l ') .option('page', '-p ') .option('view', '-v ') .action(async ({ options, session }, query) => { await chain.receiveCommand(session, 'search_memory', { + presetLane: options.preset, type: options.type, page: options.page ?? 1, limit: options.limit ?? 6, @@ -22,31 +24,37 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) ctx.command('chatluna.memory.delete <...ids>') + .option('preset', '-p ') .option('type', '-t ') .option('view', '-v ') .action(async ({ session, options }, ...ids) => { await chain.receiveCommand(session, 'delete_memory', { ids, + presetLane: options.preset, type: options.type, view: options.view }) }) ctx.command('chatluna.memory.clear') + .option('preset', '-p ') .option('type', '-t ') .option('view', '-v ') .action(async ({ session, options }) => { await chain.receiveCommand(session, 'clear_memory', { + presetLane: options.preset, type: options.type, view: options.view }) }) ctx.command('chatluna.memory.add ') + .option('preset', '-p ') .option('type', '-t ') .option('view', '-v ') .action(async ({ session, options }, content) => { await chain.receiveCommand(session, 'add_memory', { + presetLane: options.preset, type: options.type, view: options.view, content @@ -54,12 +62,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) ctx.command('chatluna.memory.edit ') + .option('preset', '-p ') .option('type', '-t ') .option('view', '-v ') .action(async ({ session, options }, id, content) => { await chain.receiveCommand(session, 'edit_memory', { memoryId: id, content, + presetLane: options.preset, type: options.type, view: options.view }) diff --git a/packages/core/src/commands/room.ts b/packages/core/src/commands/room.ts deleted file mode 100644 index 933fa0e52..000000000 --- a/packages/core/src/commands/room.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../config' -import { ChatChain } from '../chains/chain' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - ctx.command('chatluna.room') - - ctx.command('chatluna.room.create') - .option('name', '-n ') - .option('preset', '-p ') - .option('model', '-m ') - .option('chatMode', '-c ') - .option('password', '-w ') - .option('visibility', '-v ') - .action(async ({ session, options }) => { - await chain.receiveCommand(session, 'create_room', { - room_resolve: { - name: options.name ?? undefined, - preset: options.preset ?? undefined, - model: options.model ?? undefined, - chatMode: options.chatMode ?? undefined, - password: options.password ?? undefined, - visibility: options.visibility ?? undefined - } - }) - }) - - ctx.command('chatluna.room.delete [room:text]').action( - async ({ session }, room) => { - await chain.receiveCommand(session, 'delete_room', { - room_resolve: { - name: room - } - }) - } - ) - - ctx.command('chatluna.room.auto-update ') - .option('room', '-r ') - .action(async ({ session, options }, status) => { - if ( - status.toLowerCase() !== 'true' && - status.toLowerCase() !== 'false' - ) { - return session.text('.messages.invalid-status') - } - - await chain.receiveCommand(session, 'set_auto_update_room', { - room_resolve: { - name: options.room - }, - auto_update_room: status.toLowerCase() === 'true' - }) - }) - - ctx.command('chatluna.room.kick <...arg:user>').action( - async ({ session }, ...user) => { - const users = user.map((u) => u.split(':')[1]) - await chain.receiveCommand(session, 'kick_member', { - resolve_user: { - id: users - } - }) - } - ) - - ctx.command('chatluna.room.invite <...arg:user>').action( - async ({ session }, ...user) => { - const users = user.map((u) => u.split(':')[1]) - await chain.receiveCommand(session, 'invite_room', { - resolve_user: { - id: users - } - }) - } - ) - - ctx.command('chatluna.room.join ').action( - async ({ session }, name) => { - await chain.receiveCommand(session, 'join_room', { - room_resolve: { - name - } - }) - } - ) - - ctx.command('chatluna.room.leave [room:text]').action( - async ({ session, options }, room) => { - await chain.receiveCommand(session, 'leave_room', { - room_resolve: { - name: room, - id: room - } - }) - } - ) - - ctx.command('chatluna.room.clear [room:text]') - .alias('chatluna.chat.clear') - .action(async ({ session }, room) => { - await chain.receiveCommand(session, 'clear_room', { - room_resolve: { - name: room - } - }) - }) - - ctx.command('chatluna.room.compress [room:text]').action( - async ({ session }, room) => { - await chain.receiveCommand(session, 'compress_room', { - force: true, - i18n_base: 'commands.chatluna.room.compress.messages', - room_resolve: { - name: room - } - }) - } - ) - - ctx.command('chatluna.room.set') - .option('name', '-n ') - .option('preset', '-p ') - .option('model', '-m ') - .option('chatMode', '-c ') - .option('password', '-w ') - .option('visibility', '-v ') - .action(async ({ session, options }) => { - await chain.receiveCommand(session, 'set_room', { - room_resolve: { - name: options.name ?? undefined, - preset: options.preset ?? undefined, - model: options.model ?? undefined, - chatMode: options.chatMode ?? undefined, - password: options.password ?? undefined, - visibility: options.visibility ?? undefined - } - }) - }) - - ctx.command('chatluna.room.list') - .option('page', '-p ') - .option('limit', '-l ') - .option('all', '-a') - .action(async ({ options, session }) => { - await chain.receiveCommand(session, 'list_room', { - page: options.page ?? 1, - limit: options.limit ?? 2, - all_room: options.all ?? false - }) - }) - - ctx.command('chatluna.room.transfer ').action( - async ({ session }, user) => { - await chain.receiveCommand(session, 'transfer_room', { - resolve_user: { - id: user.split(':')[1] - } - }) - } - ) - - ctx.command('chatluna.room.info [room:text]').action( - async ({ session }, room) => { - await chain.receiveCommand(session, 'room_info', { - room_resolve: { - name: room - } - }) - } - ) - - ctx.command('chatluna.room.switch ').action( - async ({ session }, name) => { - await chain.receiveCommand(session, 'switch_room', { - room_resolve: { - name, - id: name - } - }) - } - ) - - ctx.command('chatluna.room.permission ').action( - async ({ session }, user) => { - await chain.receiveCommand(session, 'room_permission', { - resolve_user: { - id: user.split(':')[1] - } - }) - } - ) - - ctx.command('chatluna.room.mute <...user:user>') - .option('room', '-r ') - .action(async ({ session, options }, ...user) => { - await chain.receiveCommand(session, 'mute_user', { - room_resolve: { - name: options.room - }, - resolve_user: { - id: user.map((u) => u.split(':')[1]) - } - }) - }) -} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a3d383d8d..5185815bd 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -7,7 +7,7 @@ export interface Config { allowPrivate: boolean isForwardMsg: boolean forwardMsgMinLength: number - allowChatWithRoomName: boolean + allowConversationTriggerPrefix: boolean msgCooldown: number randomReplyFrequency: Computed> includeQuoteReply: boolean @@ -26,14 +26,16 @@ export interface Config { splitMessage: boolean blackList: Computed> censor: boolean - autoDelete: boolean - autoDeleteTimeout: number + autoArchive: boolean + autoArchiveTimeout: number + autoPurgeArchive: boolean + autoPurgeArchiveTimeout: number messageQueue: boolean messageQueueDelay: number infiniteContext: boolean infiniteContextThreshold: number rawOnCensor: boolean - autoUpdateRoomMode: 'disable' | 'all' | 'manual' + defaultGroupRouteMode: 'shared' | 'personal' privateChatWithoutCommand: boolean allowAtReply: boolean @@ -46,8 +48,6 @@ export interface Config { defaultModel: string defaultPreset: string - autoCreateRoomFromUser: boolean - authUserDefaultGroup: Computed> authSystem: boolean @@ -68,7 +68,7 @@ export const Config: Schema = Schema.intersect([ allowAtReply: Schema.boolean().default(true), allowQuoteReply: Schema.boolean().default(false), privateChatWithoutCommand: Schema.boolean().default(true), - allowChatWithRoomName: Schema.boolean().default(false), + allowConversationTriggerPrefix: Schema.boolean().default(false), includeQuoteReply: Schema.boolean().default(true), randomReplyFrequency: Schema.percent() .min(0) @@ -150,9 +150,13 @@ export const Config: Schema = Schema.intersect([ .max(0.95) .step(0.01) .default(0.85), - autoDelete: Schema.boolean().default(false), - autoDeleteTimeout: Schema.number() + autoArchive: Schema.boolean().default(false), + autoArchiveTimeout: Schema.number() .default((Time.day * 10) / Time.second) + .min(Time.hour / Time.second), + autoPurgeArchive: Schema.boolean().default(false), + autoPurgeArchiveTimeout: Schema.number() + .default((Time.day * 30) / Time.second) .min(Time.hour / Time.second) }), @@ -162,15 +166,13 @@ export const Config: Schema = Schema.intersect([ }), Schema.object({ - autoCreateRoomFromUser: Schema.boolean().default(false), + defaultGroupRouteMode: Schema.union([ + Schema.const('shared'), + Schema.const('personal') + ]).default('shared'), defaultChatMode: Schema.dynamic('chat-mode').default('plugin'), defaultModel: Schema.dynamic('model').default('无'), - defaultPreset: Schema.dynamic('preset').default('sydney'), - autoUpdateRoomMode: Schema.union([ - Schema.const('all'), - Schema.const('manual'), - Schema.const('disable') - ]).default('manual') + defaultPreset: Schema.dynamic('preset').default('sydney') }), Schema.object({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index edcefdb1d..9a39c7f7a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { Context, Logger, Time, User } from 'koishi' +import fs from 'fs/promises' import { ChatLunaService } from 'koishi-plugin-chatluna/services/chat' import { forkScopeToDisposable } from 'koishi-plugin-chatluna/utils/koishi' import { @@ -15,9 +16,9 @@ import { Config } from './config' import { defaultFactory } from './llm-core/chat/default' import { apply as loreBook } from './llm-core/memory/lore_book' import { apply as authorsNote } from './llm-core/memory/authors_note' +import { ensureMigrationValidated } from './migration/room_to_conversation' import { middleware } from './middleware' -import { deleteConversationRoom } from 'koishi-plugin-chatluna/chains' -import { ConversationRoom } from './types' +import { purgeArchivedConversation } from './utils/archive' export * from './config' export * from './render' @@ -101,11 +102,13 @@ function setupEntryPoint( } async function initializeComponents(ctx: Context, config: Config) { + await ensureMigrationValidated(ctx, config) await defaultFactory(ctx, ctx.chatluna.platform) await middleware(ctx, config) await command(ctx, config) await ctx.chatluna.preset.init() - await setupAutoDelete(ctx, config) + await setupAutoArchive(ctx, config) + await setupAutoPurgeArchive(ctx, config) loreBook(ctx, config) authorsNote(ctx, config) } @@ -187,8 +190,8 @@ function setupPermissions(ctx: Context, disposables: PromiseLikeDisposable[]) { }) } -async function setupAutoDelete(ctx: Context, config: Config) { - if (!config.autoDelete) { +async function setupAutoArchive(ctx: Context, config: Config) { + if (!config.autoArchive) { return } @@ -196,33 +199,36 @@ async function setupAutoDelete(ctx: Context, config: Config) { if (!ctx.scope.isActive) { return } - const rooms = await ctx.database.get('chathub_room', { - updatedTime: { - $lt: new Date(Date.now() - config.autoDeleteTimeout * 1000) - } + const conversations = await ctx.database.get('chatluna_conversation', { + updatedAt: { + $lt: new Date(Date.now() - config.autoArchiveTimeout * 1000) + }, + status: 'active' }) - if (rooms.length === 0) { + if (conversations.length === 0) { return } - logger.info('Auto delete task running') + logger.info('Auto archive task running') - const success: ConversationRoom[] = [] + const success: string[] = [] - for (const room of rooms) { + for (const conversation of conversations) { try { - await deleteConversationRoom(ctx, room) - success.push(room) + await ctx.chatluna.conversation.archiveConversationById( + conversation.id + ) + success.push(conversation.title) } catch (e) { logger.error(e) } } logger.success( - `Successfully deleted %d rooms: %s`, - rooms.length, - success.map((room) => room.roomName).join(',') + `Successfully archived %d conversations: %s`, + success.length, + success.join(',') ) } @@ -232,3 +238,53 @@ async function setupAutoDelete(ctx: Context, config: Config) { await execute() }, Time.minute * 5) } + +async function setupAutoPurgeArchive(ctx: Context, config: Config) { + if (!config.autoPurgeArchive) { + return + } + + async function execute() { + if (!ctx.scope.isActive) { + return + } + + const conversations = await ctx.database.get('chatluna_conversation', { + archivedAt: { + $lt: new Date( + Date.now() - config.autoPurgeArchiveTimeout * 1000 + ) + }, + status: 'archived' + }) + + if (conversations.length === 0) { + return + } + + logger.info('Auto purge archive task running') + + const success: string[] = [] + + for (const conversation of conversations) { + try { + await purgeArchivedConversation(ctx, conversation) + success.push(conversation.title) + } catch (e) { + logger.error(e) + } + } + + logger.success( + `Successfully purged %d archived conversations: %s`, + success.length, + success.join(',') + ) + } + + await execute() + + ctx.setInterval(async () => { + await execute() + }, Time.minute * 10) +} diff --git a/packages/core/src/llm-core/chat/app.ts b/packages/core/src/llm-core/chat/app.ts index d1d444496..b630df0b8 100644 --- a/packages/core/src/llm-core/chat/app.ts +++ b/packages/core/src/llm-core/chat/app.ts @@ -12,7 +12,6 @@ import { ChatLunaChatModel } from 'koishi-plugin-chatluna/llm-core/platform/mode import { ModelInfo } from 'koishi-plugin-chatluna/llm-core/platform/types' import { PresetTemplate } from 'koishi-plugin-chatluna/llm-core/prompt' import { getMessageContent } from 'koishi-plugin-chatluna/utils/string' -import { ConversationRoom } from '../../types' import type { HandlerResult } from '../../utils/types' import { ChatLunaError, @@ -27,6 +26,10 @@ import { } from './helper' import type { CompressContextResult } from './infinite_context' import { InfiniteContextManager } from './infinite_context' +import type { + ArchiveRecord, + ConversationRecord +} from '../../services/conversation_types' export class ChatInterface { private _input: ChatInterfaceInput @@ -44,12 +47,13 @@ export class ChatInterface { input: ChatInterfaceInput ) { this._input = input - ctx.on('dispose', () => { - this._chain = undefined - this._embeddings = undefined - this._historyMemory = undefined - this._infiniteContextManager = undefined - }) + } + + dispose() { + this._chain = undefined + this._embeddings = undefined + this._historyMemory = undefined + this._infiniteContextManager = undefined } private async handleChatError( @@ -151,7 +155,13 @@ export class ChatInterface { try { if (this.ctx.chatluna.currentConfig.infiniteContext) { const manager = this._ensureInfiniteContextManager() - await manager?.compressIfNeeded(wrapper) + const result = await manager?.compressIfNeeded(wrapper) + if (result?.compressed) { + await this.ctx.chatluna.conversation.recordCompression( + this._input.conversationId, + result + ) + } } } catch (error) { logger.error('Error compressing context:', error) @@ -348,45 +358,11 @@ export class ChatInterface { return this._input.preset } - async delete(ctx: Context, room: ConversationRoom): Promise { - await this.clearChatHistory() - - this._chain = undefined - - await ctx.database.remove('chathub_conversation', { - id: room.conversationId - }) - - await ctx.database.remove('chathub_room', { - roomId: room.roomId - }) - await ctx.database.remove('chathub_room_member', { - roomId: room.roomId - }) - await ctx.database.remove('chathub_room_group_member', { - roomId: room.roomId - }) - - await ctx.database.remove('chathub_user', { - defaultRoomId: room.roomId - }) - - await ctx.database.remove('chathub_message', { - conversation: room.conversationId - }) - } - async clearChatHistory(): Promise { if (this._chatHistory == null) { await this._createChatHistory() } - await this.ctx.root.parallel( - 'chatluna/clear-chat-history', - this._input.conversationId, - this - ) - await this._chatHistory.clear() await this._chain?.value?.model.clearContext(this._input.conversationId) @@ -402,7 +378,14 @@ export class ChatInterface { ) } - return manager.compressIfNeeded(wrapper, force) + const result = await manager.compressIfNeeded(wrapper, force) + if (result.compressed) { + await this.ctx.chatluna.conversation.recordCompression( + this._input.conversationId, + result + ) + } + return result } private async _createChatHistory(): Promise { @@ -486,10 +469,69 @@ declare module 'koishi' { chatInterface: ChatInterface, session: Session ) => Promise + 'chatluna/conversation-before-create': (payload: { + conversation: ConversationRecord + bindingKey: string + }) => Promise + 'chatluna/conversation-after-create': (payload: { + conversation: ConversationRecord + bindingKey: string + }) => Promise + 'chatluna/conversation-before-switch': (payload: { + bindingKey: string + conversation: ConversationRecord + previousConversation?: ConversationRecord | null + }) => Promise + 'chatluna/conversation-after-switch': (payload: { + bindingKey: string + conversation: ConversationRecord + previousConversation?: ConversationRecord | null + }) => Promise + 'chatluna/conversation-before-archive': (payload: { + conversation: ConversationRecord + }) => Promise + 'chatluna/conversation-after-archive': (payload: { + conversation: ConversationRecord + archive: ArchiveRecord + path: string + }) => Promise + 'chatluna/conversation-before-restore': (payload: { + conversation: ConversationRecord + archive: ArchiveRecord + }) => Promise + 'chatluna/conversation-after-restore': (payload: { + conversation: ConversationRecord + archive: ArchiveRecord + }) => Promise + 'chatluna/conversation-before-delete': (payload: { + conversation: ConversationRecord + }) => Promise + 'chatluna/conversation-after-delete': (payload: { + conversation: ConversationRecord + }) => Promise + 'chatluna/conversation-before-clear-history': (payload: { + conversation: ConversationRecord + chatInterface: ChatInterface + }) => Promise 'chatluna/clear-chat-history': ( conversationId: string, chatInterface: ChatInterface ) => Promise + 'chatluna/conversation-after-clear-history': (payload: { + conversation: ConversationRecord + chatInterface: ChatInterface + }) => Promise + 'chatluna/conversation-before-cache-clear': (payload: { + conversation: ConversationRecord + chatInterface?: ChatInterface + }) => Promise + 'chatluna/conversation-after-cache-clear': (payload: { + conversation: ConversationRecord + }) => Promise + 'chatluna/conversation-compressed': (payload: { + conversation: ConversationRecord + result: CompressContextResult + }) => Promise 'chatluna/after-chat-error': ( error: Error, conversationId: string, diff --git a/packages/core/src/llm-core/chat/default.ts b/packages/core/src/llm-core/chat/default.ts index 37ecbce88..90fe93957 100644 --- a/packages/core/src/llm-core/chat/default.ts +++ b/packages/core/src/llm-core/chat/default.ts @@ -18,48 +18,41 @@ export async function defaultFactory(ctx: Context, service: PlatformService) { embeddingsSchema(ctx) chatChainSchema(ctx) - ctx.on('chatluna/model-removed', (service, platform) => { - const wrapper = ctx.chatluna.getCachedInterfaceWrapper() - - if (wrapper == null) { - return - } - - wrapper + ctx.on('chatluna/model-removed', (_service, platform) => { + ctx.chatluna.conversationRuntime .getCachedConversations() .filter( - ([_, conversation]) => - conversation.room && - parseRawModelName(conversation.room.model)[0] === platform + ([_, entry]) => + parseRawModelName(entry.conversation.model)[0] === platform ) - .forEach(async ([id, info]) => { - const result = await wrapper.clearCache(info.room) + .forEach(async ([id, entry]) => { + const result = + await ctx.chatluna.conversationRuntime.clearConversationInterface( + entry.conversation + ) if (result) { - logger?.debug(`Cleared cache for room ${id}`) + logger?.debug(`Cleared cache for conversation ${id}`) } }) }) ctx.on('chatluna/tool-updated', () => { - const wrapper = ctx.chatluna.getCachedInterfaceWrapper() - - if (wrapper == null) { - return - } - - wrapper + ctx.chatluna.conversationRuntime .getCachedConversations() .filter( - ([_, conversation]) => - conversation?.chatInterface?.chatMode === 'plugin' || - conversation?.chatInterface?.chatMode === 'browsing' + ([_, entry]) => + entry?.chatInterface?.chatMode === 'plugin' || + entry?.chatInterface?.chatMode === 'browsing' ) - .forEach(async ([id, info]) => { - const result = await wrapper.clearCache(info.room) + .forEach(async ([id, entry]) => { + const result = + await ctx.chatluna.conversationRuntime.clearConversationInterface( + entry.conversation + ) if (result) { - logger?.debug(`Cleared cache for room ${id}`) + logger?.debug(`Cleared cache for conversation ${id}`) } }) }) diff --git a/packages/core/src/llm-core/chat/infinite_context.ts b/packages/core/src/llm-core/chat/infinite_context.ts index bdfee9bb1..08bc78034 100644 --- a/packages/core/src/llm-core/chat/infinite_context.ts +++ b/packages/core/src/llm-core/chat/infinite_context.ts @@ -15,6 +15,8 @@ export interface CompressContextResult { reducedTokens: number reducedPercent: number compressed: boolean + originalMessageCount: number + remainingMessageCount: number } function formatTranscript(messages: BaseMessage[]) { @@ -52,7 +54,9 @@ export class InfiniteContextManager { outputTokens: 0, reducedTokens: 0, reducedPercent: 0, - compressed: false + compressed: false, + originalMessageCount: 0, + remainingMessageCount: 0 } } @@ -64,7 +68,9 @@ export class InfiniteContextManager { outputTokens: 0, reducedTokens: 0, reducedPercent: 0, - compressed: false + compressed: false, + originalMessageCount: 0, + remainingMessageCount: 0 } } @@ -85,7 +91,9 @@ export class InfiniteContextManager { outputTokens: inputTokens, reducedTokens: 0, reducedPercent: 0, - compressed: false + compressed: false, + originalMessageCount: messages.length, + remainingMessageCount: messages.length } } @@ -109,7 +117,9 @@ export class InfiniteContextManager { outputTokens: inputTokens, reducedTokens: 0, reducedPercent: 0, - compressed: false + compressed: false, + originalMessageCount: messages.length, + remainingMessageCount: messages.length } } @@ -134,7 +144,9 @@ export class InfiniteContextManager { outputTokens: inputTokens, reducedTokens: 0, reducedPercent: 0, - compressed: false + compressed: false, + originalMessageCount: messages.length, + remainingMessageCount: messages.length } } @@ -151,7 +163,9 @@ export class InfiniteContextManager { outputTokens: inputTokens, reducedTokens: 0, reducedPercent: 0, - compressed: false + compressed: false, + originalMessageCount: messages.length, + remainingMessageCount: messages.length } } @@ -191,7 +205,9 @@ export class InfiniteContextManager { outputTokens, reducedTokens, reducedPercent, - compressed: true + compressed: true, + originalMessageCount: messages.length, + remainingMessageCount: 1 } } diff --git a/packages/core/src/llm-core/memory/authors_note/index.ts b/packages/core/src/llm-core/memory/authors_note/index.ts index 97879aeee..4704cc1d1 100644 --- a/packages/core/src/llm-core/memory/authors_note/index.ts +++ b/packages/core/src/llm-core/memory/authors_note/index.ts @@ -55,13 +55,23 @@ export function apply(ctx: Context, config: Config): void { authorsNoteCache.chatCount++ }) - ctx.on( - 'chatluna/clear-chat-history', - async (conversationId, chatInterface) => { - cache.delete(conversationId) - ctx.chatluna.contextManager.clearConversation(conversationId) - } - ) + const clear = (conversationId: string) => { + cache.delete(conversationId) + ctx.chatluna.contextManager.clearConversation(conversationId) + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-archive', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-restore', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-delete', async (payload) => { + clear(payload.conversation.id) + }) } interface AuthorsNoteCache { diff --git a/packages/core/src/llm-core/memory/lore_book/index.ts b/packages/core/src/llm-core/memory/lore_book/index.ts index 575c1eced..51a5f0383 100644 --- a/packages/core/src/llm-core/memory/lore_book/index.ts +++ b/packages/core/src/llm-core/memory/lore_book/index.ts @@ -60,13 +60,23 @@ export function apply(ctx: Context, config: Config): void { } ) - ctx.on( - 'chatluna/clear-chat-history', - async (conversationId, chatInterface) => { - cache.clear() - ctx.chatluna.contextManager.clearConversation(conversationId) - } - ) + const clear = (conversationId: string) => { + cache.clear() + ctx.chatluna.contextManager.clearConversation(conversationId) + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-archive', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-restore', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-delete', async (payload) => { + clear(payload.conversation.id) + }) } export class LoreBookMatcher { diff --git a/packages/core/src/llm-core/memory/message/database_history.ts b/packages/core/src/llm-core/memory/message/database_history.ts index 5bb9a13ca..1f2dcd6c7 100644 --- a/packages/core/src/llm-core/memory/message/database_history.ts +++ b/packages/core/src/llm-core/memory/message/database_history.ts @@ -17,12 +17,13 @@ import { } from 'koishi-plugin-chatluna/utils/string' import { randomUUID } from 'crypto' import type { AgentStep } from '../../agent/types' +import type { MessageRecord } from '../../../services/conversation_types' async function serializeMessage( message: BaseMessage, conversationId: string, - parent?: string | null -): Promise { + parentId?: string | null +): Promise { let additionalArgs = Object.assign({}, message.additional_kwargs) delete additionalArgs['preset'] @@ -38,7 +39,7 @@ async function serializeMessage( content: await gzipEncode(JSON.stringify(message.content)).then((buf) => bufferToArrayBuffer(buf) ), - parent: parent ?? null, + parentId: parentId ?? null, role: message.getType(), name: message.name, tool_calls: message['tool_calls'], @@ -50,7 +51,8 @@ async function serializeMessage( ) : null, rawId: message.id ?? null, - conversation: conversationId + conversationId, + createdAt: new Date() } } @@ -86,7 +88,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { private _ctx: Context private _latestId: string | null - private _serializedChatHistory: ChatLunaMessage[] + private _serializedChatHistory: MessageRecord[] private _chatHistory: BaseMessage[] // eslint-disable-next-line @typescript-eslint/naming-convention private _additional_kwargs: Record @@ -144,20 +146,20 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { await this.loadConversation() - const serializedMessages: ChatLunaMessage[] = [] - let parent = this._latestId + const serializedMessages: MessageRecord[] = [] + let parentId = this._latestId for (const message of messages) { const serializedMessage = await serializeMessage( message, this.conversationId, - parent + parentId ) serializedMessages.push(serializedMessage) - parent = serializedMessage.id + parentId = serializedMessage.id } - await this._ctx.database.upsert('chathub_message', serializedMessages) + await this._ctx.database.upsert('chatluna_message', serializedMessages) this._serializedChatHistory.push(...serializedMessages) this._chatHistory.push(...messages) @@ -181,14 +183,15 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { } async clear(): Promise { - await this._ctx.database.remove('chathub_message', { - conversation: this.conversationId + await this._ctx.database.remove('chatluna_message', { + conversationId: this.conversationId }) - await this._ctx.database.upsert('chathub_conversation', [ + await this._ctx.database.upsert('chatluna_conversation', [ { id: this.conversationId, - latestId: null + latestMessageId: null, + updatedAt: new Date() } ]) @@ -198,7 +201,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { } async delete(): Promise { - await this._ctx.database.remove('chathub_conversation', { + await this._ctx.database.remove('chatluna_conversation', { id: this.conversationId }) } @@ -239,7 +242,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { const messageIds = toolAndFunctionMessages.map((msg) => msg.id) - await this._ctx.database.remove('chathub_message', { + await this._ctx.database.remove('chatluna_message', { id: messageIds }) @@ -251,20 +254,16 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { const currentMsg = this._serializedChatHistory[i] const prevMsg = this._serializedChatHistory[i - 1] - if (prevMsg) { - currentMsg.parent = prevMsg.id - } else { - currentMsg.parent = null - } + currentMsg.parentId = prevMsg?.id ?? null } if (this._serializedChatHistory.length > 0) { const updatedMessages = this._serializedChatHistory.map((msg) => ({ id: msg.id, - parent: msg.parent, + parentId: msg.parentId, content: msg.content, role: msg.role, - conversation: msg.conversation, + conversationId: msg.conversationId, name: msg.name, tool_call_id: msg.tool_call_id, tool_calls: msg.tool_calls, @@ -272,7 +271,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { rawId: msg.rawId })) - await this._ctx.database.upsert('chathub_message', updatedMessages) + await this._ctx.database.upsert('chatluna_message', updatedMessages) this._latestId = this._serializedChatHistory[ @@ -297,7 +296,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { private async getLatestUpdateTime(): Promise { const conversation = ( await this._ctx.database.get( - 'chathub_conversation', + 'chatluna_conversation', { id: this.conversationId }, @@ -309,11 +308,11 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { } private async _loadMessages(): Promise { - const queried = await this._ctx.database.get('chathub_message', { - conversation: this.conversationId + const queried = await this._ctx.database.get('chatluna_message', { + conversationId: this.conversationId }) - const sorted: ChatLunaMessage[] = [] + const sorted: MessageRecord[] = [] let currentMessageId = this._latestId @@ -335,7 +334,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { sorted.unshift(currentMessage) - currentMessageId = currentMessage.parent + currentMessageId = currentMessage.parentId } if (isBad) { @@ -382,7 +381,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { content, id: item.rawId ?? undefined, name: item.name ?? undefined, - tool_calls: item.tool_calls ?? undefined, + tool_calls: (item.tool_calls as AIMessage['tool_calls']) ?? undefined, tool_call_id: item.tool_call_id ?? undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any additional_kwargs: args as any @@ -407,20 +406,36 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { private async _loadConversation() { const conversation = ( - await this._ctx.database.get('chathub_conversation', { + await this._ctx.database.get('chatluna_conversation', { id: this.conversationId }) )?.[0] if (conversation) { - this._latestId = conversation.latestId + this._latestId = conversation.latestMessageId ?? null this._additional_kwargs = conversation.additional_kwargs != null ? JSON.parse(conversation.additional_kwargs) : {} } else { - await this._ctx.database.create('chathub_conversation', { - id: this.conversationId + await this._ctx.database.create('chatluna_conversation', { + id: this.conversationId, + bindingKey: this.conversationId, + title: 'Conversation', + model: '', + preset: '', + chatMode: '', + createdBy: 'system', + createdAt: new Date(), + updatedAt: new Date(), + status: 'active', + latestMessageId: null, + additional_kwargs: null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: null, + legacyMeta: null }) } @@ -456,7 +471,7 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { } } - await this._ctx.database.remove('chathub_message', { + await this._ctx.database.remove('chatluna_message', { id: toDeleted.map((item) => item.id) }) @@ -467,9 +482,9 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { ]?.id ?? null if (firstMessage) { - firstMessage.parent = null + firstMessage.parentId = null - await this._ctx.database.upsert('chathub_message', [ + await this._ctx.database.upsert('chatluna_message', [ firstMessage ]) } @@ -483,10 +498,10 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { this._additional_kwargs && Object.keys(this._additional_kwargs).length > 0 - await this._ctx.database.upsert('chathub_conversation', [ + await this._ctx.database.upsert('chatluna_conversation', [ { id: this.conversationId, - latestId: this._latestId, + latestMessageId: this._latestId, additional_kwargs: hasKwargs ? JSON.stringify(this._additional_kwargs) : null, @@ -495,32 +510,3 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { ]) } } - -declare module 'koishi' { - interface Tables { - chathub_conversation: ChatLunaConversation - chathub_message: ChatLunaMessage - } -} - -export interface ChatLunaMessage { - text?: MessageContent - content?: ArrayBuffer - id: string - rawId?: string - role: MessageType - conversation: string - name?: string - tool_call_id?: string - tool_calls?: AIMessage['tool_calls'] - additional_kwargs?: string - additional_kwargs_binary?: ArrayBuffer - parent?: string -} - -export interface ChatLunaConversation { - id: string - latestId?: string - additional_kwargs?: string - updatedAt?: Date -} diff --git a/packages/core/src/llm-core/platform/service.ts b/packages/core/src/llm-core/platform/service.ts index 90a5fa671..c8a9e57de 100644 --- a/packages/core/src/llm-core/platform/service.ts +++ b/packages/core/src/llm-core/platform/service.ts @@ -53,9 +53,16 @@ export class PlatformService { }) constructor(private ctx: Context) { - this.ctx.on('chatluna/clear-chat-history', async (conversationId) => { + const clear = async () => { this._tmpVectorStores.clear() - }) + } + + this.ctx.on('chatluna/conversation-after-clear-history', clear) + this.ctx.on('chatluna/conversation-after-cache-clear', clear) + this.ctx.on('chatluna/conversation-after-archive', clear) + this.ctx.on('chatluna/conversation-after-restore', clear) + this.ctx.on('chatluna/conversation-after-delete', clear) + this.ctx.on('chatluna/conversation-compressed', clear) } registerClient( diff --git a/packages/core/src/llm-core/prompt/context_manager.ts b/packages/core/src/llm-core/prompt/context_manager.ts index 73d409510..cd5bb0a02 100644 --- a/packages/core/src/llm-core/prompt/context_manager.ts +++ b/packages/core/src/llm-core/prompt/context_manager.ts @@ -294,9 +294,22 @@ export class ChatLunaContextManagerService { } constructor(ctx: Context) { - ctx.on('chatluna/clear-chat-history', async (conversationId) => { + const clear = (conversationId: string) => { this.clearConversation(conversationId) - }) + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => + clear(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-archive', async (payload) => + clear(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-restore', async (payload) => + clear(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-delete', async (payload) => + clear(payload.conversation.id) + ) } // ----------------------------------------------------------------------- diff --git a/packages/core/src/locales/en-US.schema.yml b/packages/core/src/locales/en-US.schema.yml index 2bd91180a..a475a7a4b 100644 --- a/packages/core/src/locales/en-US.schema.yml +++ b/packages/core/src/locales/en-US.schema.yml @@ -4,13 +4,12 @@ $inner: isNickname: Allow matching bot nickname at the beginning of a message to trigger a conversation. isNickNameWithContent: Allow matching bot nickname anywhere in the message content to trigger a conversation. - - $desc: Conversation Behavior allowPrivate: Enable private chat conversations. allowAtReply: Enable @mention triggering. allowQuoteReply: Enable quote triggering. privateChatWithoutCommand: Enable direct conversation in private chats without commands. - allowChatWithRoomName: 'Enable room name prefix triggering. Note: May impact performance significantly. Recommended for use with filters in specific groups only.' + allowConversationTriggerPrefix: 'Enable conversation title prefix triggering. Note: May impact performance significantly. Recommended for use with filters in specific groups only.' randomReplyFrequency: Set random reply frequency (0-100, where 0 means never and 100 means always). includeQuoteReply: Include quoted message content in requests sent to the model. attachForwardMsgIdToContext: Attach forward message record IDs to context. When enabled, a "[聊天记录]" marker message will be appended if forward records are detected. @@ -27,7 +26,7 @@ $inner: thinkingMessage: Customize waiting message content. msgCooldown: Set global message cooldown (seconds) to limit adapter calls. messageQueue: Enable message queue. When enabled, if multiple messages are sent and the model has not responded to the initial message, the messages will be cached and merged into one message, and will be sent after waiting for the model response. - messageQueueDelay: Set message queue delay time (seconds). Set to 0 to disable delay. When delay is enabled, the system will wait for the specified time before sending messages to the model. If there are unprocessed messages during this period, they will be cached and merged into multiple messages to send to the model. + messageQueueDelay: Set message queue delay time (seconds). Set to 0 to disable delay. When delay is enabled, the system will wait for the specified time before sending messages to the model. If there are unprocessed messages during this period, they will be cached and merged into multiple messages to send to the model. showThoughtMessage: Display thinking message in plugin mode or reasoner model. - $desc: Message Rendering @@ -45,24 +44,24 @@ $inner: - $desc: History Management infiniteContext: Enable automatic Infinite Context compression when history nears the model limit. Preserves critical topics and instructions while discarding noise. infiniteContextThreshold: Set the threshold (percentage) for triggering Infinite Context compression. Compression will be triggered when the token count of conversation history reaches this percentage of the model's context limit. - autoDelete: Enable automatic deletion of inactive rooms. - autoDeleteTimeout: Set inactivity threshold for room deletion (seconds). + autoArchive: Enable automatic archiving of inactive conversations instead of keeping legacy room state alive. + autoArchiveTimeout: Set inactivity threshold for conversation archiving in seconds. + autoPurgeArchive: Enable automatic deletion of archived conversations after the purge timeout. + autoPurgeArchiveTimeout: Set how long archived conversations are retained before automatic purge in seconds. - $desc: Model Configuration defaultEmbeddings: Set default embedding model. defaultVectorStore: Set default vector database. - - $desc: Template Room Configuration - autoCreateRoomFromUser: Enable automatic room creation per user. + - $desc: Conversation Routing Defaults + defaultGroupRouteMode: + $desc: Set the default conversation route mode for group chats. This controls whether group members share one conversation route or get personal routes, including preset-lane routes. + $inner: + - Shared group conversation + - Personal conversation per member defaultChatMode: Set default chat mode. defaultModel: Set default chat model. defaultPreset: Set default chat preset. - autoUpdateRoomMode: - $desc: Automatic room configuration update mode. When triggered, room settings will follow template room configuration. - $inner: - - Update all rooms - - Update auto-created rooms only - - Disable updates - $desc: Miscellaneous authSystem: diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 964e0ebce..623648031 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -1,227 +1,101 @@ commands: chatluna: description: 'ChatLuna related commands.' - room: - description: 'ChatLuna room management.' - create: - description: 'Create a new room.' - options: - name: 'Room name' - preset: 'Room preset' - model: 'Room model' - chatMode: 'Chat mode' - password: 'Room password' - visibility: 'Room visibility' - messages: - confirm_create: 'Basic parameters provided. Create room directly? Y: Create, N: Interactive creation, Any other: Cancel.' - timeout: 'Response timeout. Room creation cancelled.' - cancelled: 'Room creation cancelled.' - enter_name: 'Enter room name (e.g., My Room). Q to exit.' - change_or_keep: '{0} {1}: {2}. Change? New {1} to change, N to keep. Q to exit.' - enter_model: 'Enter model (e.g., openai/gpt-3.5-turbo). Q to exit.' - model_not_found: 'Model not found: {0}. Please retry.' - enter_preset: 'Enter preset (e.g., chatgpt). N for default. Q to exit.' - preset_not_found: 'Preset not found: {0}. Please retry.' - enter_visibility: 'Enter visibility (public/private). N for default. Q to exit.' - visibility_not_recognized: 'Unrecognized visibility: {0}. Please retry.' - enter_chat_mode: 'Enter chat mode. N for default. Q to exit.' - enter_password: 'Enter password. N for no password. Q to exit.' - invalid_chat_mode: 'Unable to recognize chat mode: {0}. Available chat modes are: {1}. Please try again. Q to exit.' - template_room_created: 'Template room created successfully.' - room_created: 'Room created. ID: {0}, Name: {1}.' - action: - input: 'Input' - set: 'Set' - select: 'Select' - field: - name: 'Room name' - model: 'Model' - preset: 'Preset' - visibility: 'Visibility' - chat_mode: 'Chat mode' - password: 'Password' - delete: - description: 'Delete a room.' + conversation: + description: ChatLuna conversation lifecycle commands. + options: + conversation: Target conversation by seq, ID, or title. + page: Page number. + limit: Items per page. + archived: Include archived conversations. + preset: Preset lane. + new: + description: Create and switch to a new conversation. arguments: - room: 'Target room' - messages: - room_not_found: 'Room not found.' - not_room_master: 'Insufficient permissions: Not room owner.' - confirm_delete: 'Confirm deletion of room {0}? All messages and members will be removed. Y to confirm.' - timeout: 'Operation timed out. Automatically cancelled.' - cancelled: 'Operation cancelled.' - success: 'Room {0} deleted.' - auto-update: - description: 'Set auto-update for template clone rooms.' - options: - room: 'Specify room' - messages: - room_not_found: 'Room not found.' - not_template_clone: 'Not a template clone room. Cannot set auto-update.' - not_admin: 'Insufficient permissions: Not room owner.' - success: 'Auto-update for room {0} set to {1}.' - invalid-status: 'Invalid parameter. Use true or false.' - kick: - description: 'Kick user from current room.' - messages: - no_room_specified: 'No room specified. Use chatluna.room.switch to select a room.' - not_admin: 'Insufficient permissions: Not room admin.' - success: 'Users kicked from room {0}: {1}' - invite: - description: 'Invite user to room.' - messages: - no_room_specified: 'No room specified. Use chatluna.room.switch to select a room.' - not_admin: 'Insufficient permissions: Not room admin.' - success: 'User {0} invited to room {1}.' - join: - description: 'Join a room.' - arguments: - id: 'Room ID or name' - messages: - room_not_found: 'Room not found.' - not_in_group: 'Room not in current group chat.' - private_no_password: 'Private room. Owner invitation required.' - private_group_join: 'Private room. Cannot join in group chat.' - enter_password: 'Enter password for room {0}.' - timeout: 'Operation timed out. Automatically cancelled.' - wrong_password: 'Incorrect password. Operation cancelled.' - success: 'Joined room {0}' - leave: - description: 'Leave current room.' - arguments: - room: 'Target room' - messages: - room_not_found: 'Room not found.' - confirm_delete: 'Room owner detected. Leaving will delete the room. Y to confirm.' - timeout: 'Operation timed out. Automatically cancelled.' - cancelled: 'Operation cancelled.' - success: 'Left room {0}. Rejoin or switch rooms if needed.' - clear: - description: 'Clear room chat history.' + title: Conversation title. + switch: + description: Switch the active conversation. arguments: - room: 'Target room' - messages: - success: 'Chat history cleared for room {0}.' - no-room: 'Room not found.' + conversation: Target conversation by seq, ID, or title. + list: + description: List conversations in the current route. + current: + description: Show the current active conversation. + rename: + description: Rename the current or target conversation. + arguments: + title: New conversation title. + archive: + description: Archive a conversation. + arguments: + conversation: Target conversation by seq, ID, or title. + restore: + description: Restore an archived conversation. + arguments: + conversation: Target conversation by seq, ID, or title. + export: + description: Export a conversation as a markdown transcript. + arguments: + conversation: Target conversation by seq, ID, or title. compress: - description: 'Manually compress room chat history using Infinite Context.' + description: Compress a conversation without referencing rooms. arguments: - room: 'Target room' - messages: - success: '{0} -> {1}, {2}%' - skipped: 'Compression skipped. {0} -> {1}, {2}%' - no_room: 'Room not found.' - failed: 'Failed to compress chat history for room {0}: {1}' - set: - description: 'Set room properties.' - options: - name: 'Room name' - preset: 'Room preset' - model: 'Room model' - chatMode: 'Chat mode' - password: 'Room password' - visibility: 'Room visibility' - messages: - room_not_found: 'Room not found.' - not_room_master: 'Insufficient permissions: Not room owner.' - confirm_update: 'Update room properties? Y: Update, N: Interactive update, Any other: Cancel.' - timeout: 'Response timeout. Update cancelled.' - cancelled: 'Update cancelled.' - no_password_in_public: 'Cannot set password for non-private room or group chat.' - change_or_keep: '{0}: {1}. Change? New value to change, N to keep. Q to exit.' - model_not_found: 'Model not found: {0}. Please retry.' - preset_not_found: 'Preset not found: {0}. Please retry.' - invalid_visibility: 'Invalid visibility: {0}. Please retry.' - enter_password: 'Enter password. N for no password. Q to exit.' - success_with_clear: 'Room {0} updated. Chat history cleared.' - invalid_chat_mode: 'Unable to recognize chat mode: {0}. Available chat modes are: {1}. Please try again.' - failed: 'Room {0} update failed. Please check your settings and try again.' - success: 'Room {0} updated.' - field: - name: 'Room name' - model: 'Model' - preset: 'Preset' - visibility: 'Visibility' - chat_mode: 'Chat mode' - password: 'Password' - list: - description: 'List joined rooms.' - options: - page: 'Page number' - limit: 'Items per page' - messages: - header: 'Joined rooms:' - footer: 'Use chatluna.room.switch [name/id] to change default room.' - pages: 'Page: [page] / [total]' - room_name: 'Name: {0}' - room_id: 'ID: {0}' - room_preset: 'Preset: {0}' - room_model: "Model: {0} \nModel Availability: {1}" - room_visibility: 'Visibility: {0}' - room_chat_mode: 'Chat mode: {0}' - room_master_id: 'Creator ID: {0}' - room_availability: 'Availability: {0}' - transfer: - description: 'Transfer room ownership.' - messages: - room_not_found: 'Room not found.' - not_room_master: 'Insufficient permissions: Not room owner.' - confirm_transfer: 'Transfer room {0} to user {1}? You will lose owner privileges. Y to confirm.' - timeout: 'Operation timed out. Automatically cancelled.' - cancelled: 'Operation cancelled.' - success: 'Room {0} transferred to user {1}.' - info: - description: 'View current room information.' - arguments: - room: 'Target room' - messages: - room_not_found: 'Room not found.' - header: 'Current room information:' - room_name: 'Name: {0}' - room_id: 'ID: {0}' - room_preset: 'Preset: {0}' - room_model: "Model: {0} \nModel Availability: {1}" - room_visibility: 'Visibility: {0}' - room_chat_mode: 'Chat mode: {0}' - room_master_id: 'Creator ID: {0}' - switch: - description: 'Switch to a joined room.' + conversation: Target conversation by seq, ID, or title. + delete: + description: Delete the current or target conversation. arguments: - name: 'Room name or ID' - messages: - success: 'Switched to room {0}.' - room_not_found: 'Room not found.' - permission: - description: 'Modify user permissions in room.' - options: - room: 'Specify room' - user: 'Target user' - messages: - room_not_found: 'Room not found.' - not_admin: 'Insufficient permissions: Not room owner.' - confirm_set: 'Set permissions for user {0} in room {1}? Available: member, admin. Enter permission or first letter. Any other: Cancel.' - timeout: 'Operation timed out. Automatically cancelled.' - invalid_permission: 'Invalid permission. Operation cancelled.' - success: 'Permission for user {0} in room {1} set to {2}' - mute: - description: 'Mute a user in the room.' + conversation: Target conversation by seq, ID, or title. + use: + description: Update the active conversation usage settings. + model: + description: Switch the conversation model. + arguments: + model: Target model name. + preset: + description: Switch the conversation preset. + arguments: + preset: Target preset keyword. options: - room: 'Specify room' - messages: - room_not_found: 'Room not found.' - not_admin: 'Insufficient permissions: Not room admin.' - success: 'User {0} muted/unmuted in room {1}.' + lane: Preset lane to operate on. + mode: + description: Switch the conversation chat mode. + arguments: + mode: Target chat mode. + rule: + description: Manage route-level conversation rules. + model: + description: Fix or reset the model for the current route. + arguments: + model: Target model or `reset`. + preset: + description: Fix or reset the preset for the current route. + arguments: + preset: Target preset or `reset`. + mode: + description: Fix or reset the chat mode for the current route. + arguments: + mode: Target chat mode or `reset`. + share: + description: Set the route sharing mode for the current route. + arguments: + mode: '`shared`, `personal`, or `reset`.' + lock: + description: Lock or unlock conversation management for the current route. + arguments: + state: '`on`, `off`, `toggle`, or `reset`.' + show: + description: Show the current route rule state. chat: description: ChatLuna conversation commands. text: description: Initiate a text conversation with the AI model. options: - room: Target conversation room. + conversation: Target conversation by seq, ID, or title. type: Message rendering type. examples: - chatluna chat text -t text Hello, world! - chatluna chat text -t voice Hello, world! - - chatluna chat text -r Genshin -t text Hello, world! + - chatluna chat text -c 123e4567-e89b-12d3-a456-426614174000 -t text Hello, world! arguments: message: Message content to send. messages: @@ -229,37 +103,37 @@ commands: rollback: description: Regenerate last conversation content. options: - room: Target room for operation. + conversation: Target conversation by seq, ID, or title. arguments: message: New message content. messages: - room_not_found: 'Room not found.' - conversation_not_exist: 'Room does not exist.' + conversation_not_found: 'Conversation not found.' + conversation_not_exist: 'Conversation does not exist.' no_chat_history: 'Chat history not found.' invalid_chat_history: 'Invalid chat history. Clear history and retry.' - rollback_success_1: 'Successfully rolled back to {0} rounds ago.' + rollback_success: 'Successfully rolled back to {0} rounds ago.' stop: description: Immediately terminate ongoing conversation. options: - room: Target room to stop conversation. + conversation: Target conversation by seq, ID, or title. messages: - room_not_found: 'Room not found.' - no_active_chat: 'No active conversation in current room.' + conversation_not_found: 'Conversation not found.' + no_active_chat: 'No active conversation is running.' stop_failed: 'Failed to stop conversation.' success: 'Conversation stopped successfully.' compress: description: Manually compress chat history using Infinite Context. options: - room: Target room for compression. + conversation: Target conversation by seq, ID, or title. messages: success: '{0} -> {1}, {2}%' skipped: 'Compression skipped. {0} -> {1}, {2}%' - no_room: 'Room not found.' - failed: 'Failed to compress chat history for room {0}: {1}' + conversation_not_found: 'Conversation not found.' + failed: 'Failed to compress chat history for conversation {0}: {1}' voice: description: Converse with AI model and receive voice output. options: - room: Target conversation room. + conversation: Target conversation by seq, ID, or title. speaker: Character ID for voice service. arguments: message: Message content to send. @@ -434,7 +308,7 @@ commands: limit: Items per page messages: header: 'Available models:' - footer: 'Set default model: chatluna.room.set -m [model]' + footer: 'Set default model per conversation with chatluna.new -p [preset] or chatluna.switch.' pages: 'Page: [page] / [total]' search: description: 'Search available models.' @@ -446,7 +320,7 @@ commands: limit: 'Items per page' messages: header: 'Search results:' - footer: 'Set default model: chatluna.room.set -m [model]' + footer: 'Set default model per conversation with chatluna.new -p [preset] or chatluna.switch.' pages: 'Page: [page] / [total]' test: description: Test if a specified model or adapter is available. @@ -546,6 +420,7 @@ commands: options: page: 'Page number.' limit: 'Items per page.' + preset: 'Preset lane.' type: 'Memory preset.' view: 'Memory layer.' messages: @@ -565,6 +440,7 @@ commands: arguments: ids: 'Memory ID list.' options: + preset: 'Preset lane.' type: 'Memory preset.' view: 'Memory layer.' messages: @@ -575,6 +451,7 @@ commands: clear: description: 'Clear memory.' options: + preset: 'Preset lane.' type: 'Memory preset.' view: 'Memory layer.' messages: @@ -587,6 +464,7 @@ commands: arguments: content: 'Memory content.' options: + preset: 'Preset lane.' type: 'Memory preset.' view: 'Memory layer.' messages: @@ -599,6 +477,7 @@ commands: arguments: id: 'Memory ID.' options: + preset: 'Preset lane.' type: 'Memory preset.' view: 'Memory layer.' messages: @@ -616,7 +495,7 @@ commands: limit: 'Items per page' messages: header: 'Available presets:' - footer: 'Set default preset: chatluna.room.set -p [preset]' + footer: 'Set default preset per conversation with chatluna.new -p [preset] or chatluna.switch.' pages: 'Page: [page] / [total]' preset_keyword: 'Keyword: {0}' preset_content: 'Content: {0}' @@ -720,16 +599,107 @@ chatluna: middleware_error: 'Error in {0}: {1}' not_available_model: 'No models are currently available. Please check your configuration to ensure model adapters are properly installed and configured.' chat_limit_exceeded: 'Daily chat limit reached. Please try again in {0} minutes.' - room: - random_switch: 'No room specified. Switched to room {0}.' - not_joined: 'No room joined. Please join a room first.' - not_in_room: 'Not a member of this room. Please join room {0} first.' - muted: 'You are muted in room {0}.' - unavailable: 'The model {0} for this room is unavailable. Please contact an administrator to configure the API.' - auto_switch: 'User {0} switched to room {1}.' - auto_create: 'Room {1} created for user {0}.' - auto_create_template: 'Template clone room {1} created for user {0}.' - room_name: '{0} room' - template_clone_room_name: '{0} template clone room' - config_changed: 'Configuration updated for template room {0}.' + conversation: + default_title: 'New Conversation' + active: 'active' + status_value: + active: 'active' + archived: 'archived' + deleted: 'deleted' + broken: 'broken' + messages: + unavailable: 'The model {0} for this conversation is unavailable. Please contact an administrator to configure the API.' + new_success: 'Created conversation {0} (#{1}, ID: {2}).' + new_forbidden: 'Creating new conversations is disabled in the current route.' + admin_required: 'Conversation management requires administrator permission in the current route.' + target_required: 'Please provide a conversation target by seq, ID, or title.' + target_not_found: 'Conversation not found in the current route or shared ACL scope.' + target_ambiguous: 'More than one conversation matched. Please use the seq or ID.' + target_outside_route: 'The target conversation is outside the current route and has no permission grant.' + title_required: 'Conversation title is required.' + action_locked: 'Conversation {0} is locked by the current rule.' + action_disabled: 'Conversation {0} is disabled in the current route.' + action_failed: 'Conversation {0} failed: {1}' + fixed_model: 'The current route fixes the model to {0}.' + fixed_preset: 'The current route fixes the preset to {0}.' + fixed_chat_mode: 'The current route fixes the chat mode to {0}.' + switch_success: 'Switched to conversation {0} (#{1}, ID: {2}).' + switch_failed: 'Failed to switch conversation: {0}' + list_header: 'Conversations in the current route:' + list_pages: 'Page [page] / [total]' + list_empty: 'No conversations were found in the current route.' + current_header: 'Current conversation:' + current_empty: 'No active conversation is bound to the current route.' + rename_success: 'Renamed conversation {0} (#{1}, ID: {2}).' + rename_failed: 'Failed to rename conversation: {0}' + delete_success: 'Deleted conversation {0} (#{1}, ID: {2}).' + delete_failed: 'Failed to delete conversation: {0}' + use_model_success: 'Using model {0} for {1} ({2}).' + use_model_failed: 'Failed to switch model: {0}' + use_preset_success: 'Using preset {0} for {1} ({2}).' + use_preset_failed: 'Failed to switch preset: {0}' + use_mode_success: 'Using chat mode {0} for {1} ({2}).' + use_mode_failed: 'Failed to switch chat mode: {0}' + archive_empty: 'No conversation is available to archive.' + archive_success: 'Archived conversation {0} (#{1}, ID: {2}). Archive ID: {3}' + archive_failed: 'Failed to archive conversation: {0}' + restore_empty: 'No conversation is available to restore.' + restore_success: 'Restored conversation {0} (#{1}, ID: {2}).' + restore_failed: 'Failed to restore conversation: {0}' + export_empty: 'No conversation is available to export.' + export_success: 'Exported markdown transcript for conversation {0} (#{1}, ID: {2}) to {3}. Size: {4} bytes. SHA256: {5}' + export_failed: 'Failed to export conversation: {0}' + rule_model_success: 'Rule fixed model set to {0}.' + rule_preset_success: 'Rule fixed preset set to {0}.' + rule_mode_success: 'Rule fixed chat mode set to {0}.' + rule_share_success: 'Rule route mode set to {0}.' + rule_share_failed: 'Failed to update route mode: {0}' + rule_lock_success: 'Rule lock state set to {0}.' + rule_lock_failed: 'Failed to update lock state: {0}' + rule_show_header: 'Current conversation rules:' + action: + create: 'creation' + switch: 'switch' + archive: 'archive' + restore: 'restore' + export: 'export' + rename: 'rename' + delete: 'delete' + update: 'update' + compress: 'compression' + conversation_seq: 'Seq: {0}' + conversation_scope: 'Scope: {0}' + conversation_base_scope: 'Base scope: {0}' + conversation_route_mode: 'Route mode: {0}' + conversation_active: 'Active conversation: {0}' + conversation_last: 'Previous conversation: {0}' + conversation_title: 'Title: {0}' + conversation_id: 'ID: {0}' + conversation_status: 'Status: {0}' + conversation_model: 'Model: {0}' + conversation_preset: 'Preset: {0}' + conversation_chat_mode: 'Chat mode: {0}' + conversation_effective_model: 'Effective model: {0}' + conversation_effective_preset: 'Effective preset: {0}' + conversation_effective_chat_mode: 'Effective chat mode: {0}' + conversation_default_model: 'Route default model: {0}' + conversation_default_preset: 'Route default preset: {0}' + conversation_default_chat_mode: 'Route default chat mode: {0}' + conversation_fixed_model: 'Route fixed model: {0}' + conversation_fixed_preset: 'Route fixed preset: {0}' + conversation_fixed_chat_mode: 'Route fixed chat mode: {0}' + conversation_lock: 'Lock: {0}' + conversation_allow_new: 'Allow new: {0}' + conversation_allow_switch: 'Allow switch: {0}' + conversation_allow_archive: 'Allow archive: {0}' + conversation_allow_export: 'Allow export: {0}' + conversation_manage_mode: 'Manage mode: {0}' + conversation_preset_lane: 'Preset lane: {0}' + conversation_compression_count: 'Compression count: {0}' + conversation_updated_at: 'Updated at: {0}' + rule_share: 'Route rule: {0}' + rule_model: 'Fixed model: {0}' + rule_preset: 'Fixed preset: {0}' + rule_mode: 'Fixed chat mode: {0}' + rule_lock: 'Lock rule: {0}' cooldown_wait_message: 'Message rate limit reached. Please wait {0}s before sending another message.' diff --git a/packages/core/src/locales/zh-CN.schema.yml b/packages/core/src/locales/zh-CN.schema.yml index a6b758515..c223ab0c7 100644 --- a/packages/core/src/locales/zh-CN.schema.yml +++ b/packages/core/src/locales/zh-CN.schema.yml @@ -9,7 +9,7 @@ $inner: allowAtReply: 是否允许通过 @ bot 来触发对话。 allowQuoteReply: 是否允许通过引用 bot 的消息来触发对话。 privateChatWithoutCommand: 在私聊中是否允许无需命令直接与 bot 对话。 - allowChatWithRoomName: 是否允许使用房间名前缀触发对话。注意:启用此选项可能会显著影响 ChatLuna 的性能,建议配合过滤器仅在特定群组中启用。 + allowConversationTriggerPrefix: 是否允许使用会话标题前缀触发对话。注意:启用此选项可能会显著影响 ChatLuna 的性能,建议配合过滤器仅在特定群组中启用。 randomReplyFrequency: 设置随机回复的频率。 includeQuoteReply: 是否在向模型的请求内容中添加被引用的消息内容 attachForwardMsgIdToContext: 是否将合并转发聊天记录消息的 ID 附加到上下文中。启用后,检测到聊天记录会额外追加一条 [聊天记录] 消息。 @@ -45,24 +45,24 @@ $inner: - $desc: 历史记录选项 infiniteContext: 启用「无限上下文」,当对话接近模型的上下文上限时自动压缩旧消息,保留关键话题和指令并丢弃无关内容。 infiniteContextThreshold: 设置无限上下文的触发阈值(百分比)。当对话历史的 token 数量达到模型上下文上限的该百分比时,将触发压缩。 - autoDelete: 是否自动删除长期未使用的房间。 - autoDeleteTimeout: 设置自动删除未使用房间的时间阈值(单位:秒)。 + autoArchive: 是否自动归档长期未使用的会话,而不是继续保留旧的房间状态。 + autoArchiveTimeout: 设置自动归档未使用会话的时间阈值(单位:秒)。 + autoPurgeArchive: 是否在归档会话超过保留时间后自动彻底删除。 + autoPurgeArchiveTimeout: 设置归档会话被自动删除前的保留时间(单位:秒)。 - $desc: 模型选项 defaultEmbeddings: 设置默认使用的嵌入模型。 defaultVectorStore: 设置默认使用的向量数据库。 - - $desc: 模板房间选项 - autoCreateRoomFromUser: 是否为每个用户自动创建专属房间。 + - $desc: 会话路由默认配置 + defaultGroupRouteMode: + $desc: 设置群聊场景下默认的会话路由模式,决定群成员是共享同一路由还是各自拥有独立路由,也会影响预设分流。 + $inner: + - 群共享会话 + - 成员独立会话 defaultChatMode: 设置默认的聊天模式。 defaultModel: 设置默认使用的聊天模型。 defaultPreset: 设置默认使用的聊天预设。 - autoUpdateRoomMode: - $desc: 自动更新房间配置模式。触发后,相关房间的配置将跟随模版房间的配置。 - $inner: - - 所有房间更新 - - 仅自动创建房间更新 - - 禁用更新 - $desc: 杂项 authSystem: diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 752d060c2..6adf81c2b 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -1,227 +1,101 @@ commands: chatluna: description: 'ChatLuna 相关指令。' - room: - description: 'ChatLuna 房间管理。' - create: - description: '创建一个新房间。' - options: - name: '房间名字。' - preset: '房间预设。' - model: '房间模型。' - chatMode: '房间聊天模式。' - password: '房间密码。' - visibility: '房间可见性。' - messages: - confirm_create: '你目前已提供基础参数,是否直接创建房间?如需直接创建房间请回复 Y,如需进入交互式创建请回复 N,其他回复将视为取消。' - timeout: '你超时未回复,已取消创建房间。' - cancelled: '你已取消创建房间。' - enter_name: '请输入你需要使用的房间名,如:我的房间,回复 Q 退出创建。' - change_or_keep: '你已经{0}{1}:{2},是否需要更换?如需更换请回复更换后的{1},否则回复 N。回复 Q 退出创建。' - enter_model: '请输入你需要使用的模型,如:openai/gpt-3.5-turbo,回复 Q 退出创建。' - model_not_found: '无法找到模型:{0},请重新输入。' - enter_preset: '请输入你需要使用的预设,如:chatgpt。如果不输入预设请回复 N(则使用默认 chatgpt 预设)。否则回复你需要使用的预设。回复 Q 退出创建。' - preset_not_found: '无法找到预设:{0},请重新输入。' - enter_visibility: '请输入你需要使用的可见性,如:private。如果不输入可见性请回复 N(则使用默认 private 可见性)。否则回复需要使用的可见性。(目支持 public, private),回复 Q 退出创建。' - visibility_not_recognized: '无法识别可见性:{0},请重新输入。' - enter_chat_mode: '请输入你需要使用的聊天模式,如:chat。如果不输入聊天模式请回复 N(则使用默认 chat 聊天模式)。否则回复你需要使用的聊天模式。回复 Q 退出创建。' - enter_password: '请输入你需要使用的密码,如:123456。如果不输入密码请回复 N(则不设置密码)。否则回复你需要使用的密码。回复 Q 退出设置。' - template_room_created: '模板房间创建成功。' - room_created: '房间创建成功,房间号为:{0},房间名为:{1}。' - invalid_chat_mode: '无法识别聊天模式:{0},目前可用的聊天模式有:{1}。请重新输入。回复 Q 退出创建。' - action: - input: '输入' - set: '设置' - select: '选择' - field: - name: '房间名' - model: '模型' - preset: '预设' - visibility: '可见性' - chat_mode: '聊天模式' - password: '密码' - delete: - description: '删除一个房间。' + conversation: + description: ChatLuna 会话生命周期指令。 + options: + conversation: 目标会话,可使用序号、ID 或标题。 + page: 页码。 + limit: 每页数量。 + archived: 包含已归档会话。 + preset: 预设分流。 + new: + description: 创建并切换到一个新会话。 arguments: - room: '目标房间。' - messages: - room_not_found: '未找到指定的房间。' - not_room_master: '你不是房间的房主,无法删除房间。' - confirm_delete: '你确定要删除房间 {0} 吗?这将会删除房间内的所有消息。并且成员也会被移除。如果你确定要删除,请输入 Y 来确认。' - timeout: '操作超时未确认,已自动取消。' - cancelled: '已为你取消操作。' - success: '已删除房间 {0}。' - auto-update: - description: '设置模版克隆房间的自动更新属性。' - options: - room: '指定房间。' - messages: - room_not_found: '未找到指定的房间。' - not_template_clone: '该房间不是模板克隆房间,无法设置自动更新属性。' - not_admin: '你不是房间的房主,无法设置自动更新房间。' - success: '已设置房间 {0} 的自动更新属性为 {1}。' - invalid-status: '您输入的参数不合法,参数只能为 true 或者 false。' - kick: - description: '踢出某个人员在你当前的房间。' - messages: - no_room_specified: '你没有在当前环境里指定房间。请使用 chatluna.room.switch 命令来切换房间' - not_admin: '你不是房间 {0} 的管理员,无法踢出用户。' - success: '已将以下用户踢出房间 {0}:{1}' - invite: - description: '邀请进入房间。' - messages: - no_room_specified: '你没有在当前环境里指定房间。请使用 chatluna.room.switch 命令来切换房间' - not_admin: '你不是房间 {0} 的管理员,无法邀请用户加入。' - success: '已邀请用户 {0} 加入房间 {1}。' - join: - description: '加入某个房间。' - arguments: - id: '房间 ID 或名称。' - messages: - room_not_found: '未找到指定的房间。' - not_in_group: '该房间不在当前群聊中。' - private_no_password: '该房间为私密房间。房主未设置密码加入,只能由房主邀请进入,无法加入。' - private_group_join: '该房间为私密房间。由于需要输入密码,你无法在群聊中加入。' - enter_password: '请输入密码来加入房间 {0}。' - timeout: '操作超时未确认,已自动取消。' - wrong_password: '密码错误,已为你取消操作。' - success: '已加入房间 {0}' - leave: - description: '离开当前房间。' + title: 会话标题。 + switch: + description: 切换当前活跃会话。 arguments: - room: '目标房间。' - messages: - room_not_found: '未找到指定的房间。' - confirm_delete: '检测到你为房主,当你退出房间时,房间将会被删除。如果你确定要删除,请输入 Y 来确认。' - timeout: '操作超时未确认,已自动取消。' - cancelled: '已为你取消操作。' - success: '已退出房间 {0}。您可能需要重新加入或者切换房间。' - clear: - description: '清除指定房间的聊天记录。' + conversation: 目标会话,可使用序号、ID 或标题。 + list: + description: 列出当前路由下的会话。 + current: + description: 查看当前活跃会话。 + rename: + description: 重命名当前会话或目标会话。 arguments: - room: '目标房间。' - messages: - success: '已清除房间 {0} 的聊天记录。' - no-room: '未找到指定的房间。' + title: 新的会话标题。 + archive: + description: 归档一个会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + restore: + description: 恢复一个已归档会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + export: + description: 将会话导出为 Markdown 记录。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 compress: - description: '手动压缩房间的聊天记录(使用无限上下文)。' + description: 在不引用房间的情况下压缩一个会话。 arguments: - room: '目标房间。' - messages: - success: '{0} -> {1}, {2}%' - skipped: '未执行压缩。{0} -> {1}, {2}%' - no_room: '未找到指定的房间。' - failed: '压缩房间 {0} 的聊天记录失败:{1}' - set: - description: '设置房间的属性。' - options: - name: '房间名字。' - preset: '房间预设。' - model: '房间模型。' - chatMode: '房间聊天模式。' - password: '房间密码。' - visibility: '房间可见性。' - messages: - room_not_found: '未找到指定的房间。' - not_room_master: '你不是房间的房主,无法设置房间的属性。' - confirm_update: '你目前已设置参数,是否直接更新房间属性?如需直接更新请回复 Y,如需进入交互式创建请回复 N,其他回复将视为取消。' - timeout: '你超时未回复,已取消设置房间属性。' - cancelled: '你已取消设置房间属性。' - no_password_in_public: '你无法在非私有房间或群聊中设置密码。' - change_or_keep: '你已经选择了{0}:{1},是否需要更换?如无须更改请回复 N,否则回复更换后的{0}。回复 Q 退出设置。' - model_not_found: '无法找到模型:{0},请重新输入。回复 Q 退出设置。' - preset_not_found: '无法找到预设:{0},请重新输入。回复 Q 退出设置。' - invalid_visibility: '无法识别可见性:{0},请重新输入。回复 Q 退出设置。' - enter_password: '请输入你需要使用的密码,如:123456。如果不输入密码请回复 N(则不设置密码)。否则回复你需要使用的密码。回复 Q 退出设置。' - success_with_clear: '房间 {0} 已更新,聊天记录已被清空。' - invalid_chat_mode: '无法识别聊天模式:{0},目前可用的聊天模式有:{1}。请重新输入。' - failed: '房间 {0} 更新失败。请检查你的设置并重新尝试。' - success: '房间 {0} 已更新。' - field: - name: '房间名' - model: '模型' - preset: '预设' - visibility: '可见性' - chat_mode: '聊天模式' - password: '密码' - list: - description: '列出所有你加入的房间。' - options: - page: '页码。' - limit: '每页数量。' - messages: - header: '以下是查询到你加入的房间列表:' - footer: '你可以使用 chatluna.room.switch [name/id] 来切换当前环境里你的默认房间。' - pages: '当前为第 [page] / [total] 页' - room_name: '房间名: {0}' - room_id: '房间ID: {0}' - room_preset: '房间预设: {0}' - room_model: "房间模型: {0} \n模型是否可用:{1}" - room_visibility: '房间可见性: {0}' - room_chat_mode: '房间聊天模式: {0}' - room_master_id: '房间创建者ID: {0}' - room_availability: '房间可用性:{0}' - transfer: - description: '转移房间的房主。' - messages: - room_not_found: '未找到指定的房间。' - not_room_master: '你不是房间的房主,无法转移房间给他人。' - confirm_transfer: '你确定要把房间 {0} 转移给用户 {1} 吗?转移后ta将成为房间的房主,你将失去房主权限。如果你确定要转移,请输入 Y 来确认。' - timeout: '操作超时未确认,已自动取消。' - cancelled: '已为你取消操作。' - success: '已将房间 {0} 转移给用户 {1}。' - info: - description: '查看当前房间的信息。' - arguments: - room: '目标房间。' - messages: - room_not_found: '未找到指定的房间。' - header: '以下是你目前所在的房间信息' - room_name: '房间名: {0}' - room_id: '房间ID: {0}' - room_preset: '房间预设: {0}' - room_model: "房间模型: {0} \n模型是否可用:{1}" - room_visibility: '房间可见性: {0}' - room_chat_mode: '房间聊天模式: {0}' - room_master_id: '房间创建者ID: {0}' - switch: - description: '切换到你已经加入了的房间。' + conversation: 目标会话,可使用序号、ID 或标题。 + delete: + description: 删除当前会话或目标会话。 arguments: - name: '房间名称或 ID。' - messages: - success: '已切换到房间 {0}。' - room_not_found: '未找到指定的房间。' - permission: - description: '修改房间里某人的权限。' - options: - room: '指定房间。' - user: '目标用户。' - messages: - room_not_found: '未找到指定的房间。' - not_admin: '你不是房间的房主,无法为用户设置权限。' - confirm_set: '你确定要为用户 {0} 设置房间 {1} 的权限吗?目前可以设置的权限为 member 和 admin。如果你确定要设置,请输入设置权限的值或首字母大写,其他输入均视为取消。' - timeout: '操作超时未确认,已自动取消。' - invalid_permission: '你输入的权限值不正确,已自动取消。' - success: '已为用户 {0} 设置房间 {1} 的权限为 {2}' - mute: - description: '禁言某个用户,不让其发言。' + conversation: 目标会话,可使用序号、ID 或标题。 + use: + description: 调整当前会话的使用配置。 + model: + description: 切换当前会话使用的模型。 + arguments: + model: 目标模型名称。 + preset: + description: 切换当前会话使用的预设。 + arguments: + preset: 目标预设关键词。 options: - room: '指定房间。' - messages: - room_not_found: '未找到指定的房间。' - not_admin: '你不是房间 {0} 的管理员,无法禁言用户。' - success: '已将用户 {0} 在房间 {1} 禁言或解除禁言。' + lane: 要操作的预设分流。 + mode: + description: 切换当前会话使用的聊天模式。 + arguments: + mode: 目标聊天模式。 + rule: + description: 管理当前路由的会话规则。 + model: + description: 固定或重置当前路由的模型。 + arguments: + model: 目标模型或 `reset`。 + preset: + description: 固定或重置当前路由的预设。 + arguments: + preset: 目标预设或 `reset`。 + mode: + description: 固定或重置当前路由的聊天模式。 + arguments: + mode: 目标聊天模式或 `reset`。 + share: + description: 设置当前路由的共享模式。 + arguments: + mode: '`shared`、`personal` 或 `reset`。' + lock: + description: 锁定或解锁当前路由的会话管理。 + arguments: + state: '`on`、`off`、`toggle` 或 `reset`。' + show: + description: 查看当前路由的规则状态。 chat: description: ChatLuna 对话相关指令。 text: description: 与大语言模型进行文本对话。 options: - room: 指定对话的目标房间。 + conversation: 指定目标会话,可使用序号、ID 或标题。 type: 设置消息的渲染类型。 examples: - chatluna chat text -t text 你好,世界! - chatluna chat text -t voice 你好,世界! - - chatluna chat text -r 原神 -t text 你好,世界! + - chatluna chat text -c 123e4567-e89b-12d3-a456-426614174000 -t text 你好,世界! arguments: message: 要发送的消息内容。 messages: @@ -229,37 +103,37 @@ commands: rollback: description: 重新生成上一次的对话内容。 options: - room: 指定要操作的房间。 + conversation: 指定目标会话,可使用序号、ID 或标题。 arguments: message: 新的消息内容。 messages: - room_not_found: '未找到指定的房间。' - conversation_not_exist: '房间不存在。' + conversation_not_found: '未找到指定的会话。' + conversation_not_exist: '会话不存在。' no_chat_history: '找不到对话记录。' invalid_chat_history: '错误的聊天记录,请尝试清空聊天记录后重试。' rollback_success: '已成功回滚到 {0} 轮前的对话,请等待模型回复。' stop: description: 立即停止当前正在进行的对话。 options: - room: 指定要停止对话的房间。 + conversation: 指定目标会话,可使用序号、ID 或标题。 messages: - room_not_found: '未找到指定的房间。' - no_active_chat: '当前未在房间中对话。' + conversation_not_found: '未找到指定的会话。' + no_active_chat: '当前没有正在运行的会话。' stop_failed: '停止对话失败。' success: '已成功停止当前对话。' compress: description: 手动压缩聊天记录(使用无限上下文)。 options: - room: 指定要压缩的目标房间。 + conversation: 指定目标会话,可使用序号、ID 或标题。 messages: success: '{0} -> {1}, {2}%' skipped: '未执行压缩。{0} -> {1}, {2}%' - no_room: '未找到指定的房间。' - failed: '压缩房间 {0} 的聊天记录失败:{1}' + conversation_not_found: '未找到指定的会话。' + failed: '压缩会话 {0} 的聊天记录失败:{1}' voice: description: 与模型进行对话并将回复转换为语音输出。 options: - room: 指定对话的目标房间。 + conversation: 指定目标会话,可使用序号、ID 或标题。 speaker: 设置语音服务使用目标角色 ID。 arguments: message: 要发送的消息内容。 @@ -435,7 +309,7 @@ commands: limit: 每页显示的数量。 messages: header: '以下是目前可用的模型列表:' - footer: '你可以使用 chatluna.room.set -m [model] 来设置默认使用的模型' + footer: '你可以通过 chatluna.new -p [preset] 或 chatluna.switch 为会话设置默认模型。' pages: '当前为第 [page] / [total] 页' search: description: 搜索可用的模型。 @@ -447,7 +321,7 @@ commands: limit: 每页显示的数量。 messages: header: '以下是目前搜索到的模型列表:' - footer: '你可以使用 chatluna.room.set -m [model] 来设置默认使用的模型' + footer: '你可以通过 chatluna.new -p [preset] 或 chatluna.switch 为会话设置默认模型。' pages: '当前为第 [page] / [total] 页' test: description: 测试指定的模型或适配器是否可用。 @@ -546,6 +420,7 @@ commands: options: page: '页码。' limit: '每页显示的数量。' + preset: '预设分流。' type: '记忆所属的预设。' view: '记忆所属的层级。' messages: @@ -565,6 +440,7 @@ commands: arguments: ids: '记忆 ID 列表。' options: + preset: '预设分流。' type: '记忆所属的预设。' view: '记忆所属的层级。' messages: @@ -575,6 +451,7 @@ commands: clear: description: '清空记忆。' options: + preset: '预设分流。' type: '记忆所属的预设。' view: '记忆所属的层级。' messages: @@ -587,6 +464,7 @@ commands: arguments: content: '记忆内容。' options: + preset: '预设分流。' type: '记忆所属的预设。' view: '记忆所属的层级。' messages: @@ -599,6 +477,7 @@ commands: arguments: id: '记忆 ID。' options: + preset: '预设分流。' type: '记忆所属的预设。' view: '记忆所属的层级。' messages: @@ -616,7 +495,7 @@ commands: limit: '设置每页显示数量' messages: header: '以下是目前可用的预设列表:' - footer: '你可以使用 chatluna.room.set -p [preset] 来设置默认使用的预设' + footer: '你可以通过 chatluna.new -p [preset] 或 chatluna.switch 为会话设置默认预设。' pages: '当前为第 [page] / [total] 页' preset_keyword: '预设关键词: {0}' preset_content: '预设内容: {0}' @@ -720,17 +599,108 @@ chatluna: middleware_error: '执行 {0} 时出现错误: {1}' not_available_model: '当前没有可用的模型,请检查你的配置,是否正常安装了模型适配器并配置。' chat_limit_exceeded: '你的聊天次数已经用完了喵,还需要等待 {0} 分钟才能继续聊天喵 >_<' - room: - random_switch: '检测到你没有指定房间,已为你随机切换到房间 {0}。' - not_joined: '你还没有加入任何房间,请先加入房间。' - not_in_room: '你没有加入此房间,请先加入房间 {0}。' - muted: '你已被禁言,无法在房间 {0} 发言。' - unavailable: '当前房间对应的模型 {0} 不可用,请联系管理员配置 API。' - auto_switch: '已为用户 {0} 自动切换到房间 {1}。' - auto_create: '已为用户 {0} 自动创建房间 {1}。' - auto_create_template: '已为用户 {0} 自动创建模版克隆房间 {1}。' - room_name: '{0} 的房间' - template_clone_room_name: '{0} 的模版克隆房间' - config_changed: '检测到模版房间 {0} 的配置变更,已更新到数据库。' + conversation: + default_title: '新会话' + active: '当前活跃' + status_value: + active: '活跃' + archived: '已归档' + deleted: '已删除' + broken: '已损坏' + messages: + unavailable: '当前会话对应的模型 {0} 不可用,请联系管理员配置 API。' + new_success: '已创建会话 {0}(#{1},ID:{2})。' + new_forbidden: '当前路由已禁止创建新会话。' + admin_required: '当前路由要求管理员权限才能管理会话。' + target_required: '请提供目标会话,可使用序号、ID 或标题。' + target_not_found: '当前路由或共享授权范围内未找到目标会话。' + target_ambiguous: '匹配到多个会话,请改用序号或 ID。' + target_outside_route: '目标会话不在当前路由内,且没有额外授权。' + title_required: '必须提供会话标题。' + action_locked: '当前规则已锁定会话{0}。' + action_disabled: '当前路由已禁用会话{0}。' + action_failed: '会话{0}失败:{1}' + fixed_model: '当前路由已将模型固定为 {0}。' + fixed_preset: '当前路由已将预设固定为 {0}。' + fixed_chat_mode: '当前路由已将聊天模式固定为 {0}。' + switch_success: '已切换到会话 {0}(#{1},ID:{2})。' + switch_failed: '切换会话失败:{0}' + list_header: '当前路由下的会话列表:' + list_pages: '第 [page] / [total] 页' + list_empty: '当前路由下没有找到任何会话。' + current_header: '当前会话:' + current_empty: '当前路由未绑定活跃会话。' + rename_success: '已重命名会话 {0}(#{1},ID:{2})。' + rename_failed: '重命名会话失败:{0}' + delete_success: '已删除会话 {0}(#{1},ID:{2})。' + delete_failed: '删除会话失败:{0}' + use_model_success: '会话 {1}({2})已切换为模型 {0}。' + use_model_failed: '切换模型失败:{0}' + use_preset_success: '会话 {1}({2})已切换为预设 {0}。' + use_preset_failed: '切换预设失败:{0}' + use_mode_success: '会话 {1}({2})已切换为聊天模式 {0}。' + use_mode_failed: '切换聊天模式失败:{0}' + archive_empty: '没有可归档的会话。' + archive_success: '已归档会话 {0}(#{1},ID:{2})。归档 ID:{3}' + archive_failed: '归档会话失败:{0}' + restore_empty: '没有可恢复的会话。' + restore_success: '已恢复会话 {0}(#{1},ID:{2})。' + restore_failed: '恢复会话失败:{0}' + export_empty: '没有可导出的会话。' + export_success: '已将会话 {0}(#{1},ID:{2})导出为 Markdown 记录到 {3}。大小:{4} 字节。SHA256:{5}' + export_failed: '导出会话失败:{0}' + rule_model_success: '规则固定模型已设置为 {0}。' + rule_preset_success: '规则固定预设已设置为 {0}。' + rule_mode_success: '规则固定聊天模式已设置为 {0}。' + rule_share_success: '规则路由模式已设置为 {0}。' + rule_share_failed: '更新路由模式失败:{0}' + rule_lock_success: '规则锁定状态已设置为 {0}。' + rule_lock_failed: '更新锁定状态失败:{0}' + rule_show_header: '当前会话规则:' + action: + create: '创建' + switch: '切换' + archive: '归档' + restore: '恢复' + export: '导出' + rename: '重命名' + delete: '删除' + update: '更新设置' + compress: '压缩' + conversation_seq: '序号:{0}' + conversation_scope: '作用域:{0}' + conversation_base_scope: '基础作用域:{0}' + conversation_route_mode: '路由模式:{0}' + conversation_active: '当前活跃会话:{0}' + conversation_last: '上一个会话:{0}' + conversation_title: '标题:{0}' + conversation_id: 'ID:{0}' + conversation_status: '状态:{0}' + conversation_model: '模型:{0}' + conversation_preset: '预设:{0}' + conversation_chat_mode: '聊天模式:{0}' + conversation_effective_model: '生效模型:{0}' + conversation_effective_preset: '生效预设:{0}' + conversation_effective_chat_mode: '生效聊天模式:{0}' + conversation_default_model: '路由默认模型:{0}' + conversation_default_preset: '路由默认预设:{0}' + conversation_default_chat_mode: '路由默认聊天模式:{0}' + conversation_fixed_model: '路由固定模型:{0}' + conversation_fixed_preset: '路由固定预设:{0}' + conversation_fixed_chat_mode: '路由固定聊天模式:{0}' + conversation_lock: '锁定状态:{0}' + conversation_allow_new: '允许新建:{0}' + conversation_allow_switch: '允许切换:{0}' + conversation_allow_archive: '允许归档:{0}' + conversation_allow_export: '允许导出:{0}' + conversation_manage_mode: '管理模式:{0}' + conversation_preset_lane: '预设分流:{0}' + conversation_compression_count: '压缩次数:{0}' + conversation_updated_at: '更新时间:{0}' + rule_share: '路由规则:{0}' + rule_model: '固定模型:{0}' + rule_preset: '固定预设:{0}' + rule_mode: '固定聊天模式:{0}' + rule_lock: '锁定规则:{0}' cooldown_wait_message: '不要发这么快喵,等 {0}s 后我们再聊天喵。' diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index 2889e9a16..566850fcf 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -8,7 +8,6 @@ import { apply as black_list } from './middlewares/auth/black_list' import { apply as create_auth_group } from './middlewares/auth/create_auth_group' import { apply as kick_user_form_auth_group } from './middlewares/auth/kick_user_form_auth_group' import { apply as list_auth_group } from './middlewares/auth/list_auth_group' -import { apply as mute_user } from './middlewares/auth/mute_user' import { apply as set_auth_group } from './middlewares/auth/set_auth_group' import { apply as allow_reply } from './middlewares/chat/allow_reply' import { apply as censor } from './middlewares/chat/censor' @@ -22,11 +21,12 @@ import { apply as rollback_chat } from './middlewares/chat/rollback_chat' import { apply as stop_chat } from './middlewares/chat/stop_chat' import { apply as thinking_message_recall } from './middlewares/chat/thinking_message_recall' import { apply as thinking_message_send } from './middlewares/chat/thinking_message_send' +import { apply as request_conversation } from './middlewares/conversation/request_conversation' +import { apply as resolve_conversation } from './middlewares/conversation/resolve_conversation' import { apply as list_all_embeddings } from './middlewares/model/list_all_embeddings' import { apply as list_all_model } from './middlewares/model/list_all_model' import { apply as list_all_tool } from './middlewares/model/list_all_tool' import { apply as list_all_vectorstore } from './middlewares/model/list_all_vectorstore' -import { apply as request_model } from './middlewares/model/request_model' import { apply as resolve_model } from './middlewares/model/resolve_model' import { apply as search_model } from './middlewares/model/search_model' import { apply as set_default_embeddings } from './middlewares/model/set_default_embeddings' @@ -37,23 +37,7 @@ import { apply as clone_preset } from './middlewares/preset/clone_preset' import { apply as delete_preset } from './middlewares/preset/delete_preset' import { apply as list_all_preset } from './middlewares/preset/list_all_preset' import { apply as set_preset } from './middlewares/preset/set_preset' -import { apply as check_room } from './middlewares/room/check_room' -import { apply as clear_room } from './middlewares/room/clear_room' -import { apply as compress_room } from './middlewares/room/compress_room' -import { apply as create_room } from './middlewares/room/create_room' -import { apply as delete_room } from './middlewares/room/delete_room' -import { apply as invite_room } from './middlewares/room/invite_room' -import { apply as join_room } from './middlewares/room/join_room' -import { apply as kick_member } from './middlewares/room/kick_member' -import { apply as leave_room } from './middlewares/room/leave_room' -import { apply as list_room } from './middlewares/room/list_room' -import { apply as resolve_room } from './middlewares/room/resolve_room' -import { apply as room_info } from './middlewares/room/room_info' -import { apply as room_permission } from './middlewares/room/room_permission' -import { apply as set_auto_update_room } from './middlewares/room/set_auto_update_room' -import { apply as set_room } from './middlewares/room/set_room' -import { apply as switch_room } from './middlewares/room/switch_room' -import { apply as transfer_room } from './middlewares/room/transfer_room' +import { apply as conversation_manage } from './middlewares/system/conversation_manage' import { apply as clear_balance } from './middlewares/system/clear_balance' import { apply as lifecycle } from './middlewares/system/lifecycle' import { apply as query_balance } from './middlewares/system/query_balance' @@ -76,7 +60,6 @@ export async function middleware(ctx: Context, config: Config) { create_auth_group, kick_user_form_auth_group, list_auth_group, - mute_user, set_auth_group, allow_reply, censor, @@ -90,11 +73,12 @@ export async function middleware(ctx: Context, config: Config) { stop_chat, thinking_message_recall, thinking_message_send, + request_conversation, + resolve_conversation, list_all_embeddings, list_all_model, list_all_tool, list_all_vectorstore, - request_model, resolve_model, search_model, set_default_embeddings, @@ -105,23 +89,7 @@ export async function middleware(ctx: Context, config: Config) { delete_preset, list_all_preset, set_preset, - check_room, - clear_room, - compress_room, - create_room, - delete_room, - invite_room, - join_room, - kick_member, - leave_room, - list_room, - resolve_room, - room_info, - room_permission, - set_auto_update_room, - set_room, - switch_room, - transfer_room, + conversation_manage, clear_balance, lifecycle, query_balance, diff --git a/packages/core/src/middlewares/auth/add_user_to_auth_group.ts b/packages/core/src/middlewares/auth/add_user_to_auth_group.ts index ee27e9408..41cc4bd4c 100644 --- a/packages/core/src/middlewares/auth/add_user_to_auth_group.ts +++ b/packages/core/src/middlewares/auth/add_user_to_auth_group.ts @@ -1,7 +1,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin } from '../../chains/rooms' +import { checkAdmin } from '../../utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain @@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/auth/create_auth_group.ts b/packages/core/src/middlewares/auth/create_auth_group.ts index 112233d6a..5aefe635f 100644 --- a/packages/core/src/middlewares/auth/create_auth_group.ts +++ b/packages/core/src/middlewares/auth/create_auth_group.ts @@ -334,7 +334,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } async function checkAuthGroupName(service: ChatLunaAuthService, name: string) { diff --git a/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts b/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts index 21d47d45a..f6af6e502 100644 --- a/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts +++ b/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts @@ -1,7 +1,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin } from '../../chains/rooms' +import { checkAdmin } from '../../utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain @@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/auth/list_auth_group.ts b/packages/core/src/middlewares/auth/list_auth_group.ts index 17545c41c..804e0f3cd 100644 --- a/packages/core/src/middlewares/auth/list_auth_group.ts +++ b/packages/core/src/middlewares/auth/list_auth_group.ts @@ -44,7 +44,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } function formatAuthGroup(session: Session, group: ChatHubAuthGroup) { diff --git a/packages/core/src/middlewares/auth/mute_user.ts b/packages/core/src/middlewares/auth/mute_user.ts deleted file mode 100644 index 3d43c8ad0..000000000 --- a/packages/core/src/middlewares/auth/mute_user.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - getAllJoinedConversationRoom, - getConversationRoomUser, - muteUserFromConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('mute_user', async (session, context) => { - let { - command, - options: { room } - } = context - - if (command !== 'mute_user') return ChainMiddlewareRunStatus.SKIPPED - - if (room == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - room = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (room == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - const userInfo = await getConversationRoomUser( - ctx, - session, - room, - session.userId - ) - - if ( - userInfo.roomPermission === 'member' && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_admin', [room.roomName]) - return ChainMiddlewareRunStatus.STOP - } - - const targetUser = context.options.resolve_user.id as string[] - - for (const user of targetUser) { - await muteUserFromConversationRoom(ctx, session, room, user) - } - - context.message = session.text('.success', [ - targetUser.join(','), - room.roomName - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - mute_user: never - } -} diff --git a/packages/core/src/middlewares/auth/set_auth_group.ts b/packages/core/src/middlewares/auth/set_auth_group.ts index 9217020f3..d1db51c60 100644 --- a/packages/core/src/middlewares/auth/set_auth_group.ts +++ b/packages/core/src/middlewares/auth/set_auth_group.ts @@ -390,7 +390,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } async function checkAuthGroupName(service: ChatLunaAuthService, name: string) { diff --git a/packages/core/src/middlewares/chat/allow_reply.ts b/packages/core/src/middlewares/chat/allow_reply.ts index 70c6a9306..a93403e74 100644 --- a/packages/core/src/middlewares/chat/allow_reply.ts +++ b/packages/core/src/middlewares/chat/allow_reply.ts @@ -2,6 +2,7 @@ import { Context, h } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' +import { parsePresetLaneInput } from '../../utils/message_content' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain @@ -80,8 +81,21 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE } - // 房间名称匹配检查 - if (config.allowChatWithRoomName) { + // 会话标题前缀匹配检查 + if ( + parsePresetLaneInput( + content, + ctx.chatluna.preset + .getAllPreset(true) + .value.flatMap((entry) => + entry.split(',').map((item) => item.trim()) + ) + ) != null + ) { + return ChainMiddlewareRunStatus.CONTINUE + } + + if (config.allowConversationTriggerPrefix) { return ChainMiddlewareRunStatus.CONTINUE } diff --git a/packages/core/src/middlewares/chat/censor.ts b/packages/core/src/middlewares/chat/censor.ts index b1fd19669..163d03496 100644 --- a/packages/core/src/middlewares/chat/censor.ts +++ b/packages/core/src/middlewares/chat/censor.ts @@ -38,7 +38,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) .before('lifecycle-send') - .after('request_model') + .after('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/chat/chat_time_limit_check.ts b/packages/core/src/middlewares/chat/chat_time_limit_check.ts index 738f6fc3d..89493d2a1 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_check.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_check.ts @@ -23,9 +23,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return await oldChatLimitCheck(session, context) } - const { - room: { model } - } = context.options + const target = await resolveConversationTarget(session, context) + + if (target == null) { + return ChainMiddlewareRunStatus.CONTINUE + } + + const { model } = target // check account balance const authUser = await authService.getUser(session) @@ -98,19 +102,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE }) .after('resolve_model') - .before('request_model') + .before('lifecycle-request_conversation') async function oldChatLimitCheck( session: Session, context: ChainMiddlewareContext ) { - if (context.options.room == null) { + const target = await resolveConversationTarget(session, context) + + if (target == null) { return } - const { - room: { model, conversationId } - } = context.options + const { model, conversationId } = target // 为什么会是无 @@ -199,6 +203,33 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE } + + async function resolveConversationTarget( + session: Session, + context: ChainMiddlewareContext + ) { + const conversationId = context.options.conversationId + + if (conversationId == null) { + return null + } + + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { + conversationId + } + ) + + if (resolved.conversation == null) { + return null + } + + return { + model: resolved.effectiveModel ?? resolved.conversation.model, + conversationId: resolved.conversation.id + } + } } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/chat/chat_time_limit_save.ts b/packages/core/src/middlewares/chat/chat_time_limit_save.ts index 0bd26579e..d8dfd791b 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_save.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_save.ts @@ -25,17 +25,18 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) .after('render_message') - // .before("lifecycle-request_model") + // .before("lifecycle-request_conversation") async function oldChatLimitSave( session: Session, context: ChainMiddlewareContext ) { - const { - chatLimit, - chatLimitCache, - room: { conversationId } - } = context.options + const { chatLimit, chatLimitCache } = context.options + const conversationId = context.options.conversationId + + if (conversationId == null || chatLimit == null || chatLimitCache == null) { + return ChainMiddlewareRunStatus.CONTINUE + } /* console.log( await ctx.chatluna_auth._selectCurrentAuthGroup( diff --git a/packages/core/src/middlewares/chat/message_delay.ts b/packages/core/src/middlewares/chat/message_delay.ts index d5e43caaf..54d3590f9 100644 --- a/packages/core/src/middlewares/chat/message_delay.ts +++ b/packages/core/src/middlewares/chat/message_delay.ts @@ -1,7 +1,7 @@ /* eslint-disable operator-linebreak */ import { Context, Disposable, Logger, Session } from 'koishi' import { Config } from '../../config' -import { ConversationRoom, Message } from '../../types' +import { Message } from '../../types' import { createLogger } from 'koishi-plugin-chatluna/utils/logger' import { ChainMiddlewareContext, @@ -10,6 +10,7 @@ import { } from '../../chains/chain' import { randomUUID } from 'crypto' import { HumanMessage, MessageContentComplex } from '@langchain/core/messages' +import type { ConversationRecord } from '../../services/conversation_types' let logger: Logger @@ -45,17 +46,33 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.options.messageId = randomUUID() - const { room, inputMessage } = context.options - const conversationId = room.conversationId + const { inputMessage } = context.options + const conversationId = context.options.conversationId + + if (conversationId == null) { + return ChainMiddlewareRunStatus.CONTINUE + } + + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { + conversationId + } + ) + const resolvedConversation = resolved.conversation const userName = inputMessage.name || 'unknown' const messageId = context.options.messageId if ( - room.chatMode === 'plugin' && - (await ctx.chatluna.appendPendingMessage( + resolvedConversation?.chatMode === 'plugin' && + (await ctx.chatluna.conversationRuntime.appendPendingMessage( conversationId, - createPendingMessage(session, room, inputMessage), - room.chatMode + createPendingMessage( + session, + resolvedConversation, + inputMessage + ), + resolvedConversation.chatMode )) ) { logger.debug( @@ -110,7 +127,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { tryStartHeadTurn(conversationId, conversation) return await statusPromise }) - .after('check_room') .after('read_chat_message') .before('lifecycle-handle_command') @@ -151,7 +167,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { completeTurn(conversationId) ) - ctx.on('chatluna/clear-chat-history', async (conversationId) => { + const clearTurn = async (conversationId: string) => { const conversation = queues.get(conversationId) if (conversation) { const stoppedWaiters = conversation.turns.filter( @@ -174,7 +190,23 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { queues.delete(conversationId) } - }) + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => + clearTurn(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-cache-clear', async (payload) => + clearTurn(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-archive', async (payload) => + clearTurn(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-restore', async (payload) => + clearTurn(payload.conversation.id) + ) + ctx.on('chatluna/conversation-after-delete', async (payload) => + clearTurn(payload.conversation.id) + ) } function awaitTurnStart( @@ -302,7 +334,7 @@ function mergeMessages(messages: Message[]): Message { function createPendingMessage( session: Session, - room: ConversationRoom, + conversation: Pick, inputMessage: Message ) { return new HumanMessage({ @@ -315,7 +347,7 @@ function createPendingMessage( id: session.userId, additional_kwargs: { ...inputMessage.additional_kwargs, - preset: room.preset + preset: conversation.preset } }) } diff --git a/packages/core/src/middlewares/chat/read_chat_message.ts b/packages/core/src/middlewares/chat/read_chat_message.ts index b4da9e773..5aae8fcd8 100644 --- a/packages/core/src/middlewares/chat/read_chat_message.ts +++ b/packages/core/src/middlewares/chat/read_chat_message.ts @@ -22,6 +22,7 @@ import { import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import { getBase64EncodedSize } from 'koishi-plugin-chatluna/utils/base64' import type { QQ } from '@koishijs/plugin-adapter-qq' +import { parsePresetLaneInput } from '../../utils/message_content' const CHATLUNA_DOWNLOAD_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' @@ -56,13 +57,85 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { message = [h.text(message)] } - const room = context.options.room + if (context.command == null) { + const text = h.select(message as h[], 'text').join('') + const parsed = parsePresetLaneInput( + text, + ctx.chatluna.preset + .getAllPreset(true) + .value.flatMap((entry) => + entry.split(',').map((item) => item.trim()) + ) + ) + + if (parsed?.preset != null) { + const preset = ctx.chatluna.preset.getPreset( + parsed.preset, + false + ).value + + if (preset != null) { + context.options.presetLane = preset.triggerKeyword[0] + + if (parsed.queryOnly) { + context.command = 'conversation_current' + context.options.conversation_manage = { + ...context.options.conversation_manage, + presetLane: preset.triggerKeyword[0] + } + context.message = null + return ChainMiddlewareRunStatus.CONTINUE + } + + message = [h.text(parsed.content)] + } + } + } + + let conversationId = context.options.conversationId + + if ( + conversationId == null && + context.options.targetConversation != null + ) { + const target = + await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + targetConversation: + context.options.targetConversation, + presetLane: context.options.presetLane + } + ) + + if (target == null) { + context.message = session.text( + 'commands.chatluna.chat.messages.conversation_not_exist' + ) + return ChainMiddlewareRunStatus.STOP + } + + conversationId = target.id + context.options.conversationId = target.id + context.options.resolvedConversation = target + } + + const resolved = + context.options.resolvedConversationContext ?? + (await ctx.chatluna.conversation.resolveContext(session, { + conversationId, + presetLane: context.options.presetLane + })) + + context.options.resolvedConversationContext = resolved + context.options.resolvedConversation = + context.options.resolvedConversation ?? resolved.conversation const transformedMessage = await ctx.chatluna.messageTransformer.transform( session, message as h[], - room?.model ?? '', + resolved.effectiveModel ?? '', undefined, { quote: false, @@ -100,7 +173,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE }) - .after('resolve_room') + .after('lifecycle-prepare') ctx.chatluna.messageTransformer.before(async (session, elements) => { appendQQAttachments(session, elements) diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 6bee1d752..b48b43556 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -1,10 +1,23 @@ import { Context, h } from 'koishi' +import { gzipDecode } from 'koishi-plugin-chatluna/utils/string' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { getAllJoinedConversationRoom } from '../../chains/rooms' -import { ChatLunaMessage } from '../../llm-core/memory/message/database_history' +import { MessageRecord } from '../../services/conversation_types' +import { getMessageContent } from '../../utils/string' import { logger } from '../..' +async function decodeMessageContent(message: MessageRecord) { + try { + return JSON.parse( + message.content + ? await gzipDecode(message.content) + : (message.text ?? '""') + ) + } catch { + return message.text ?? '' + } +} + export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('rollback_chat', async (session, context) => { @@ -12,100 +25,103 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (command !== 'rollback') return ChainMiddlewareRunStatus.SKIPPED - let room = context.options.room - const rollbackRound = context.options.rollback_round ?? 1 + const resolved = + context.options.resolvedConversation !== undefined + ? { + conversation: context.options.resolvedConversation + } + : context.options.conversationId != null || + context.options.targetConversation != null + ? { + conversation: + await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: + context.options.conversationId, + targetConversation: + context.options.targetConversation, + presetLane: context.options.presetLane, + permission: 'manage' + } + ) + } + : await ctx.chatluna.conversation.getCurrentConversation( + session + ) + const conversation = resolved.conversation - if (room == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - room = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (room == null) { - context.message = session.text('.room_not_found') + if (conversation == null) { + context.message = session.text('.conversation_not_exist') return ChainMiddlewareRunStatus.STOP } - // clear cache - - await ctx.chatluna.clearCache(room) - - // get messages - const conversation = ( - await ctx.database.get('chathub_conversation', { - id: room.conversationId - }) - )?.[0] - - if (conversation === null) { + if ( + ( + await ctx.chatluna.conversation.resolveContext(session, { + conversationId: conversation.id, + presetLane: context.options.presetLane + }) + ).constraint.lockConversation + ) { context.message = session.text('.conversation_not_exist') return ChainMiddlewareRunStatus.STOP } - let parentId = conversation.latestId - const messages: ChatLunaMessage[] = [] + context.options.conversationId = conversation.id + + await ctx.chatluna.conversationRuntime.clearConversationInterface( + conversation + ) + + let parentId = conversation.latestMessageId + const messages: MessageRecord[] = [] - // 获取 (轮数*2) 条消息,一轮对话 两条消息 - while (messages.length < rollbackRound * 2) { - const message = await ctx.database.get('chathub_message', { - conversation: room.conversationId, + while (messages.length < rollbackRound * 2 && parentId != null) { + const message = await ctx.database.get('chatluna_message', { + conversationId: conversation.id, id: parentId }) - parentId = message[0]?.parent + const currentMessage = message[0] - messages.unshift(...message) - - if (parentId == null) { + if (currentMessage == null) { break } + + parentId = currentMessage.parentId + messages.unshift(currentMessage) } - // 小于目标轮次,就是没有 if (messages.length < rollbackRound * 2) { context.message = session.text('.no_chat_history') return ChainMiddlewareRunStatus.STOP } - // 最后一条消息 - - const lastMessage = - parentId == null - ? undefined - : await ctx.database - .get('chathub_message', { - conversation: room.conversationId, - id: parentId - }) - .then((message) => message?.[0]) - + const previousLatestId = parentId ?? null const humanMessage = messages[messages.length - 2] - await ctx.database.upsert('chathub_conversation', [ + + await ctx.database.upsert('chatluna_conversation', [ { - id: room.conversationId, - latestId: parentId == null ? null : lastMessage.id + id: conversation.id, + latestMessageId: previousLatestId, + updatedAt: new Date() } ]) if ((context.options.message?.length ?? 0) < 1) { + const reResolved = + await ctx.chatluna.conversation.resolveContext(session, { + conversationId: conversation.id + }) + const humanContent = await decodeMessageContent(humanMessage) + context.options.inputMessage = await ctx.chatluna.messageTransformer.transform( session, - [h.text(humanMessage.text)], - room.model, + [h.text(getMessageContent(humanContent))], + reResolved.effectiveModel ?? conversation.model, undefined, { quote: false, @@ -114,7 +130,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) } - await ctx.database.remove('chathub_message', { + await ctx.database.remove('chatluna_message', { id: messages.map((message) => message.id) }) @@ -123,13 +139,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) logger.debug( - `rollback chat ${room.roomName} ${context.options.inputMessage}` + `rollback chat ${conversation.id} ${context.options.inputMessage}` ) return ChainMiddlewareRunStatus.CONTINUE }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index 1d7b31972..0e03c08b0 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -1,8 +1,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { getAllJoinedConversationRoom } from '../../chains/rooms' -import { getRequestId } from '../model/request_model' +import { getRequestId } from '../../utils/chat_request' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain @@ -11,39 +10,59 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (command !== 'stop_chat') return ChainMiddlewareRunStatus.SKIPPED - let room = context.options.room + const resolved = + context.options.resolvedConversation !== undefined + ? { + conversation: context.options.resolvedConversation + } + : context.options.conversationId != null || + context.options.targetConversation != null + ? { + conversation: + await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: + context.options.conversationId, + targetConversation: + context.options.targetConversation, + presetLane: context.options.presetLane, + permission: 'manage' + } + ) + } + : await ctx.chatluna.conversation.getCurrentConversation( + session + ) + const conversation = resolved.conversation - if (room == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - room = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) + if (conversation == null) { + context.message = session.text('.no_active_chat') + return ChainMiddlewareRunStatus.STOP } - if (room == null) { - context.message = session.text('.room_not_found') + if ( + ( + await ctx.chatluna.conversation.resolveContext(session, { + conversationId: conversation.id, + presetLane: context.options.presetLane + }) + ).constraint.lockConversation + ) { + context.message = session.text('.stop_failed') return ChainMiddlewareRunStatus.STOP } - const requestId = getRequestId(session, room) + context.options.conversationId = conversation.id + const requestId = getRequestId(session, conversation.id) if (requestId == null) { context.message = session.text('.no_active_chat') return ChainMiddlewareRunStatus.STOP } - const status = await ctx.chatluna.stopChat(room, requestId) + const status = + await ctx.chatluna.conversationRuntime.stopRequest(requestId) if (status === null) { context.message = session.text('.no_active_chat') @@ -54,7 +73,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/request_model.ts b/packages/core/src/middlewares/conversation/request_conversation.ts similarity index 85% rename from packages/core/src/middlewares/model/request_model.ts rename to packages/core/src/middlewares/conversation/request_conversation.ts index 29d54960c..563cd3477 100644 --- a/packages/core/src/middlewares/model/request_model.ts +++ b/packages/core/src/middlewares/conversation/request_conversation.ts @@ -12,7 +12,7 @@ import { ChatChain } from 'koishi-plugin-chatluna/chains' import { Config } from '../../config' -import { ConversationRoom, Message } from '../../types' +import { Message } from '../../types' import { renderMessage } from '../chat/render_message' import { formatToolCall, @@ -21,7 +21,7 @@ import { getSystemPromptVariables, PresetPostHandler } from 'koishi-plugin-chatluna/utils/string' -import { updateChatTime } from '../../chains/rooms' +import type { ConversationRecord } from '../../services/conversation_types' import { MessageEditQueue, sendInitialMessage, @@ -32,27 +32,38 @@ import { MessageContent, MessageContentComplex } from '@langchain/core/messages' -import { randomUUID } from 'crypto' +import { createRequestId } from '../../utils/chat_request' import { AgentAction } from 'koishi-plugin-chatluna/llm-core/agent' let logger: Logger -const requestIdCache = new Map() - export function apply(ctx: Context, config: Config, chain: ChatChain) { logger = createLogger(ctx) chain - .middleware('request_model', async (session, context) => { - const { room, inputMessage } = context.options + .middleware('request_conversation', async (session, context) => { + const { inputMessage } = context.options + const resolved = + await ctx.chatluna.conversation.ensureActiveConversation( + session, + { + conversationId: context.options.conversationId, + presetLane: context.options.presetLane + } + ) + const conversation = resolved.conversation + + context.options.conversationId = conversation.id + context.options.resolvedConversation = conversation + context.options.resolvedConversationContext = resolved const presetTemplate = ctx.chatluna.preset.getPreset( - room.preset + conversation.preset ).value if (presetTemplate == null) { throw new ChatLunaError( ChatLunaErrorCode.PRESET_NOT_FOUND, - new Error(`Preset ${room.preset} not found`) + new Error(`Preset ${conversation.preset} not found`) ) } @@ -64,7 +75,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { presetTemplate, session, inputMessage.content, - room + conversation ) } @@ -108,13 +119,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { let responseMessage: Message - inputMessage.conversationId = room.conversationId + inputMessage.conversationId = conversation.id inputMessage.name = session.author?.name ?? session.author?.id ?? session.username const requestId = createRequestId( session, - room, + conversation.id, context.options.messageId ) @@ -122,20 +133,25 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context, session, config, - bufferText + bufferText, + conversation ) try { ;[responseMessage] = await Promise.all([ - ctx.chatluna.chat( + ctx.chatluna.conversationRuntime.chat( session, - room, + conversation, inputMessage, chatCallbacks, config.streamResponse, { prompt: getMessageContent(originContent), - ...getSystemPromptVariables(session, config, room) + ...getSystemPromptVariables( + session, + config, + conversation + ) }, postHandler, requestId @@ -159,46 +175,21 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.message = null } - await updateChatTime(ctx, room) + await ctx.chatluna.conversation.touchConversation(conversation.id, { + lastChatAt: new Date() + }) return ChainMiddlewareRunStatus.CONTINUE }) - .after('lifecycle-request_model') -} - -export function getRequestId(session: Session, room: ConversationRoom) { - const userKey = - session.userId + - '-' + - (session.guildId ?? '') + - '-' + - room.conversationId - - return requestIdCache.get(userKey) -} - -export function createRequestId( - session: Session, - room: ConversationRoom, - requestId: string = randomUUID() -) { - const userKey = - session.userId + - '-' + - (session.guildId ?? '') + - '-' + - room.conversationId - - requestIdCache.set(userKey, requestId) - - return requestId + .after('lifecycle-request_conversation') } function createChatCallbacks( context: ChainMiddlewareContext, session: Session, config: Config, - bufferText: StreamingBufferText + bufferText: StreamingBufferText, + conversation: Pick ) { return { 'llm-new-chunk': createChunkHandler(context, bufferText), @@ -207,7 +198,8 @@ function createChatCallbacks( 'llm-used-token-count': createTokenCountHandler( context, session, - config + config, + conversation ) } } @@ -288,7 +280,8 @@ function createToolCallHandler( function createTokenCountHandler( context: ChainMiddlewareContext, session: Session, - config: Config + config: Config, + conversation: Pick ) { return async (tokens: number) => { if (config.authSystem !== true) { @@ -297,7 +290,7 @@ function createTokenCountHandler( const balance = await context.ctx.chatluna_auth.calculateBalance( session, - parseRawModelName(context.options.room.model)[0], + parseRawModelName(conversation.model)[0], tokens ) @@ -310,7 +303,10 @@ async function processUserPrompt( presetTemplate: PresetTemplate, session: Session, originContent: MessageContent, - room: ConversationRoom + conversation: Pick< + ConversationRecord, + 'preset' | 'id' | 'updatedAt' | 'lastChatAt' + > ) { if (typeof originContent === 'string') { return await formatUserPromptString( @@ -318,7 +314,7 @@ async function processUserPrompt( presetTemplate, session, originContent, - room + conversation ).then((result) => result.text) } @@ -333,7 +329,7 @@ async function processUserPrompt( presetTemplate, session, message.text, - room + conversation ).then((result) => result.text) } : message @@ -358,7 +354,6 @@ function setupRegularMessageStream( config: Config, textStream: ReadableStream ) { - // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { const reader = textStream.getReader() try { @@ -384,7 +379,6 @@ function setupEditMessageStream( bufferText: StreamingBufferText ) { const cachedStream = bufferText.getCached() - // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve) => { const { ctx } = context let messageId: string | null = null @@ -488,7 +482,7 @@ async function sendRenderedMessage( declare module '../../chains/chain' { interface ChainMiddlewareName { - request_model: never + request_conversation: never } interface ChainMiddlewareContextOptions { diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts new file mode 100644 index 000000000..a264d34d0 --- /dev/null +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -0,0 +1,86 @@ +import { Context } from 'koishi' +import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' +import { Config } from '../../config' +import type { + ConversationRecord, + ResolvedConversationContext +} from '../../services/conversation_types' + +function getPresetLane( + context: import('../../chains/chain').ChainMiddlewareContext +) { + return ( + context.options.conversation_manage?.presetLane ?? + context.options.presetLane + ) +} + +function getTargetConversation( + context: import('../../chains/chain').ChainMiddlewareContext +) { + return ( + context.options.conversation_manage?.targetConversation ?? + context.options.targetConversation + ) +} + +export function apply(ctx: Context, config: Config, chain: ChatChain) { + chain + .middleware('resolve_conversation', async (session, context) => { + const presetLane = getPresetLane(context) + const targetConversation = getTargetConversation(context) + + context.options.presetLane = presetLane + + if ( + context.options.conversationId == null && + targetConversation != null + ) { + const conversation = + await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + targetConversation, + presetLane + } + ) + + if (conversation == null) { + context.message = session.text( + 'commands.chatluna.chat.messages.conversation_not_exist' + ) + return ChainMiddlewareRunStatus.STOP + } + + context.options.conversationId = conversation.id + context.options.resolvedConversation = conversation + } + + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { + conversationId: context.options.conversationId, + presetLane + } + ) + + context.options.resolvedConversation = + context.options.resolvedConversation ?? resolved.conversation + context.options.resolvedConversationContext = resolved + + return ChainMiddlewareRunStatus.CONTINUE + }) + .after('read_chat_message') + .before('resolve_model') +} + +declare module '../../chains/chain' { + interface ChainMiddlewareName { + resolve_conversation: never + } + + interface ChainMiddlewareContextOptions { + resolvedConversation?: ConversationRecord | null + resolvedConversationContext?: ResolvedConversationContext + } +} diff --git a/packages/core/src/middlewares/model/list_all_embeddings.ts b/packages/core/src/middlewares/model/list_all_embeddings.ts index 63df83d67..7c8a0ce2d 100644 --- a/packages/core/src/middlewares/model/list_all_embeddings.ts +++ b/packages/core/src/middlewares/model/list_all_embeddings.ts @@ -43,7 +43,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/list_all_model.ts b/packages/core/src/middlewares/model/list_all_model.ts index 46df7928b..92c5a239c 100644 --- a/packages/core/src/middlewares/model/list_all_model.ts +++ b/packages/core/src/middlewares/model/list_all_model.ts @@ -44,7 +44,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/list_all_tool.ts b/packages/core/src/middlewares/model/list_all_tool.ts index dd9ebc055..00b293e8b 100644 --- a/packages/core/src/middlewares/model/list_all_tool.ts +++ b/packages/core/src/middlewares/model/list_all_tool.ts @@ -45,7 +45,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/list_all_vectorstore.ts b/packages/core/src/middlewares/model/list_all_vectorstore.ts index baac3fdc5..201ed7575 100644 --- a/packages/core/src/middlewares/model/list_all_vectorstore.ts +++ b/packages/core/src/middlewares/model/list_all_vectorstore.ts @@ -38,7 +38,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/resolve_model.ts b/packages/core/src/middlewares/model/resolve_model.ts index 4eb833f93..6501c7e21 100644 --- a/packages/core/src/middlewares/model/resolve_model.ts +++ b/packages/core/src/middlewares/model/resolve_model.ts @@ -1,66 +1,76 @@ import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkConversationRoomAvailability, - fixConversationRoomAvailability -} from '../../chains/rooms' +import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' +import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' +import { ChatChain, ChainMiddlewareRunStatus } from '../../chains/chain' import { logger } from '../..' +import { Config } from '../../config' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('resolve_model', async (session, context) => { - const { room } = context.options + const conversationId = context.options.conversationId if ((context.command?.length ?? 0) > 1) { - // 强制继续 return ChainMiddlewareRunStatus.CONTINUE } - let isAvailable: boolean - - try { - isAvailable = await checkConversationRoomAvailability(ctx, room) - } catch (e) { - logger.error(e) - return ChainMiddlewareRunStatus.STOP - } - - if (isAvailable) { + if (conversationId == null) { return ChainMiddlewareRunStatus.CONTINUE } - const modelName = - room?.model == null || - room.model.trim().length < 1 || - room.model === '无' || - room.model === 'empty' - ? 'empty' - : room.model - - await context.send( - session.text('chatluna.room.unavailable', [modelName]) - ) - try { - const success = await fixConversationRoomAvailability( - ctx, - config, - room - ) + const conversation = + context.options.resolvedConversation ?? + (await ctx.chatluna.conversation.getConversation( + conversationId + )) - if (!success) { + if (conversation == null) { return ChainMiddlewareRunStatus.STOP } - } catch (error) { - logger.error(error) + + const modelName = + conversation.model == null || + conversation.model.trim().length < 1 || + conversation.model === '无' || + conversation.model === 'empty' + ? 'empty' + : conversation.model + + const [platformName, rawModelName] = + parseRawModelName(modelName) + const platformModels = ctx.chatluna.platform.listPlatformModels( + platformName, + ModelType.llm + ).value + const presetExists = + ctx.chatluna.preset.getPreset(conversation.preset, false) + .value != null + + if ( + modelName !== 'empty' && + platformName != null && + rawModelName != null && + platformModels.length > 0 && + platformModels.some((it) => it.name === rawModelName) && + presetExists + ) { + return ChainMiddlewareRunStatus.CONTINUE + } + + await context.send( + session.text('chatluna.conversation.messages.unavailable', [ + modelName + ]) + ) + return ChainMiddlewareRunStatus.STOP + } catch (e) { + logger.error(e) return ChainMiddlewareRunStatus.STOP } - - return ChainMiddlewareRunStatus.CONTINUE }) - .before('check_room') - .after('resolve_room') + .before('lifecycle-request_conversation') + .after('lifecycle-prepare') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/search_model.ts b/packages/core/src/middlewares/model/search_model.ts index 7b72788bd..7aee74049 100644 --- a/packages/core/src/middlewares/model/search_model.ts +++ b/packages/core/src/middlewares/model/search_model.ts @@ -48,7 +48,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/set_default_embeddings.ts b/packages/core/src/middlewares/model/set_default_embeddings.ts index 73f1c1435..601754ac0 100644 --- a/packages/core/src/middlewares/model/set_default_embeddings.ts +++ b/packages/core/src/middlewares/model/set_default_embeddings.ts @@ -70,7 +70,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } export interface EmbeddingsInfo { diff --git a/packages/core/src/middlewares/model/set_default_vectorstore.ts b/packages/core/src/middlewares/model/set_default_vectorstore.ts index 6a1aaad25..f699da2dc 100644 --- a/packages/core/src/middlewares/model/set_default_vectorstore.ts +++ b/packages/core/src/middlewares/model/set_default_vectorstore.ts @@ -67,7 +67,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/model/test_model.ts b/packages/core/src/middlewares/model/test_model.ts index d3e300955..fff71fea5 100644 --- a/packages/core/src/middlewares/model/test_model.ts +++ b/packages/core/src/middlewares/model/test_model.ts @@ -137,7 +137,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/preset/add_preset.ts b/packages/core/src/middlewares/preset/add_preset.ts index 907e256d9..b29b4c74b 100644 --- a/packages/core/src/middlewares/preset/add_preset.ts +++ b/packages/core/src/middlewares/preset/add_preset.ts @@ -55,7 +55,7 @@ export function apply(ctx: Context, _: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/preset/clone_preset.ts b/packages/core/src/middlewares/preset/clone_preset.ts index 566ced6f0..902a26092 100644 --- a/packages/core/src/middlewares/preset/clone_preset.ts +++ b/packages/core/src/middlewares/preset/clone_preset.ts @@ -58,7 +58,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/preset/delete_preset.ts b/packages/core/src/middlewares/preset/delete_preset.ts index 21e9db935..13cafba01 100644 --- a/packages/core/src/middlewares/preset/delete_preset.ts +++ b/packages/core/src/middlewares/preset/delete_preset.ts @@ -65,22 +65,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - const roomList = await ctx.database.get('chathub_room', { - preset: presetName - }) - - for (const room of roomList) { - room.preset = defaultPreset.triggerKeyword[0] - } - - await ctx.database.upsert('chathub_room', roomList) - context.message = session.text('.success', [presetName]) return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/preset/list_all_preset.ts b/packages/core/src/middlewares/preset/list_all_preset.ts index 9fea04ff1..2cc1ed70d 100644 --- a/packages/core/src/middlewares/preset/list_all_preset.ts +++ b/packages/core/src/middlewares/preset/list_all_preset.ts @@ -43,7 +43,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } async function formatPreset( diff --git a/packages/core/src/middlewares/preset/set_preset.ts b/packages/core/src/middlewares/preset/set_preset.ts index 3677d23a4..97085d298 100644 --- a/packages/core/src/middlewares/preset/set_preset.ts +++ b/packages/core/src/middlewares/preset/set_preset.ts @@ -51,7 +51,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/room/check_room.ts b/packages/core/src/middlewares/room/check_room.ts deleted file mode 100644 index 2d37e459a..000000000 --- a/packages/core/src/middlewares/room/check_room.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' - -import { - getAllJoinedConversationRoom, - getConversationRoomUser, - leaveConversationRoom, - switchConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('check_room', async (session, context) => { - let room = context.options.room - - const rooms = await getAllJoinedConversationRoom(ctx, session) - - // 检查当前用户是否在房间 - if (room == null && rooms.length > 0) { - room = rooms[Math.floor(Math.random() * rooms.length)] - await switchConversationRoom(ctx, session, room.roomId) - await context.send( - session.text('chatluna.room.random_switch', [room.roomName]) - ) - } else if (room == null && rooms.length === 0) { - if ((context.command?.length ?? 0) > 1) { - // 新群如果需要执行命令,则先继续 - return ChainMiddlewareRunStatus.CONTINUE - } - - context.message = session.text('chatluna.room.not_joined') - return ChainMiddlewareRunStatus.STOP - } else if ( - !rooms.some( - (searchRoom) => - searchRoom.roomName === room.roomName || - searchRoom.roomId === room.roomId - ) - ) { - // 可能进行退出房间 - try { - await leaveConversationRoom(ctx, session, room) - } catch (e) { - // 忽略 - } - context.message = session.text('chatluna.room.not_in_room', [ - room.roomName - ]) - return ChainMiddlewareRunStatus.STOP - } - - // 检查是否被禁言 - - const user = await getConversationRoomUser(ctx, session, room) - - if (user?.mute === true) { - context.message = session.text('chatluna.room.muted', [ - room.roomName - ]) - return ChainMiddlewareRunStatus.STOP - } - - context.options.room = room - - return ChainMiddlewareRunStatus.CONTINUE - }) - .before('request_model') - .after('resolve_room') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - check_room: never - } -} diff --git a/packages/core/src/middlewares/room/clear_room.ts b/packages/core/src/middlewares/room/clear_room.ts deleted file mode 100644 index 60df33843..000000000 --- a/packages/core/src/middlewares/room/clear_room.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { getAllJoinedConversationRoom } from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('clear_room', async (session, context) => { - const { command } = context - - if (command !== 'clear_room') - return ChainMiddlewareRunStatus.SKIPPED - - let targetRoom = context.options.room - - if (targetRoom == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - targetRoom = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (targetRoom == null) { - context.message = session.text('.no-room') - return ChainMiddlewareRunStatus.STOP - } - - /* - const userInfo = await getConversationRoomUser( - ctx, - session, - targetRoom, - session.userId - ) - - if ( - userInfo.roomPermission === 'member' && - !(await checkAdmin(session)) - ) { - context.message = `你不是房间 ${targetRoom.roomName} 的管理员,无法清除聊天记录。` - return ChainMiddlewareRunStatus.STOP - } */ - - await ctx.chatluna.clearChatHistory(targetRoom) - - context.message = session.text('.success', [targetRoom.roomName]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - clear_room: never - } -} diff --git a/packages/core/src/middlewares/room/compress_room.ts b/packages/core/src/middlewares/room/compress_room.ts deleted file mode 100644 index bcb280451..000000000 --- a/packages/core/src/middlewares/room/compress_room.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { getAllJoinedConversationRoom } from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('compress_room', async (session, context) => { - const { command } = context - - if (command !== 'compress_room') - return ChainMiddlewareRunStatus.SKIPPED - - let targetRoom = context.options.room - - if (targetRoom == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - targetRoom = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (targetRoom == null) { - const key = - context.options.i18n_base ?? - 'commands.chatluna.room.compress.messages' - - context.message = session.text(`${key}.no_room`) - return ChainMiddlewareRunStatus.STOP - } - - try { - const key = - context.options.i18n_base ?? - 'commands.chatluna.room.compress.messages' - const result = await ctx.chatluna.compressContext( - targetRoom, - context.options.force === true - ) - const args = [ - result.inputTokens, - result.outputTokens, - result.reducedPercent.toFixed(2) - ] - - context.message = session.text( - result.compressed ? `${key}.success` : `${key}.skipped`, - args - ) - } catch (error) { - ctx.logger.error(error) - const key = - context.options.i18n_base ?? - 'commands.chatluna.room.compress.messages' - - context.message = session.text(`${key}.failed`, [ - targetRoom.roomName, - error.message - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - compress_room: never - } -} diff --git a/packages/core/src/middlewares/room/create_room.ts b/packages/core/src/middlewares/room/create_room.ts deleted file mode 100644 index e17443913..000000000 --- a/packages/core/src/middlewares/room/create_room.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { randomUUID } from 'crypto' -import { Context, Session } from 'koishi' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { - ChainMiddlewareContext, - ChainMiddlewareContextOptions, - ChainMiddlewareRunStatus, - ChatChain -} from '../../chains/chain' -import { - createConversationRoom, - getConversationRoomCount -} from '../../chains/rooms' -import { Config } from '../../config' -import { ConversationRoom } from '../../types' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const service = ctx.chatluna.platform - - chain - .middleware('create_room', async (session, context) => { - const { - command, - options: { room_resolve: roomResolve } - } = context - - if (command !== 'create_room') - return ChainMiddlewareRunStatus.SKIPPED - - if (!roomResolve) return ChainMiddlewareRunStatus.SKIPPED - - let { model, preset, name, chatMode, password, visibility } = - roomResolve - - if ( - Object.values(roomResolve).filter((value) => value != null) - .length > 0 && - visibility !== 'template' - ) { - await context.send(session.text('.confirm_create')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } - - if (result === 'Y') { - roomResolve.preset = - roomResolve.preset ?? config.defaultPreset - roomResolve.name = roomResolve.name ?? 'Unnamed Room' - roomResolve.chatMode = - roomResolve.chatMode ?? config.defaultChatMode - roomResolve.password = roomResolve.password ?? null - roomResolve.visibility = roomResolve.visibility ?? 'private' - roomResolve.model = roomResolve.model ?? config.defaultModel - - await createRoom(ctx, context, session, context.options) - - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - } - - const allModel = service.listAllModels(ModelType.llm) - - // 交互式创建 - - // 1. 输入房间名 - - if (name == null) { - await context.send(session.text('.enter_name')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - - name = result.trim() - roomResolve.name = name - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.name'), - name - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - name = result.trim() - roomResolve.name = name - } - } - - // 2. 选择模型 - - while (true) { - let preModel = model - if (preModel == null) { - await context.send(session.text('.enter_model')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - - preModel = result.trim() - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.select'), - session.text('.field.model'), - preModel - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - preModel = result.trim() - } - } - - const findModel = allModel.value.find( - (searchModel) => - searchModel.toModelName() === preModel || - searchModel.name === preModel - ) - - if (findModel == null) { - await context.send( - session.text('.model_not_found', [preModel]) - ) - preModel = null - roomResolve.model = null - continue - } else { - model = preModel - roomResolve.model = model - break - } - } - - // 3. 选择预设 - - const presetInstance = ctx.chatluna.preset - while (true) { - let prePreset = preset - if (preset == null) { - await context.send(session.text('.enter_preset')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - prePreset = 'chatgpt' - } else { - prePreset = result.trim() - } - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.select'), - session.text('.field.preset'), - prePreset - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - prePreset = result.trim() - } - } - - try { - await presetInstance.getPreset(prePreset) - preset = prePreset - roomResolve.preset = preset - break - } catch { - await context.send( - session.text('.preset_not_found', [prePreset]) - ) - roomResolve.preset = null - continue - } - } - - // 4. 可见性 - while (true) { - if (visibility == null) { - await context.send(session.text('.enter_visibility')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - roomResolve.visibility = 'private' - } else { - roomResolve.visibility = result.trim() - } - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.select'), - session.text('.field.visibility'), - visibility - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - roomResolve.visibility = result.trim() - } - } - - visibility = roomResolve.visibility - - if (visibility === 'private' || visibility === 'public') { - break - } - - await context.send( - session.text('.visibility_not_recognized', [visibility]) - ) - } - - // 5. 聊天模式 - - while (true) { - if (chatMode == null) { - await context.send(session.text('.enter_chat_mode')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - roomResolve.chatMode = 'chat' - } else { - roomResolve.chatMode = result.trim() - } - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.field.chat_mode'), - chatMode - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - roomResolve.chatMode = result.trim() - } - } - - chatMode = roomResolve.chatMode - - const availableChatModes = - ctx.chatluna.platform.chatChains.value.map( - (chain) => chain.name - ) - - if (availableChatModes.includes(chatMode)) { - break - } - - await context.send( - session.text('.invalid_chat_mode', [ - chatMode, - availableChatModes.join(', ') - ]) - ) - } - - // 6. 密码 - if ( - session.isDirect && - visibility === 'private' && - password == null - ) { - await context.send(session.text('.enter_password')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - roomResolve.password = null - } else { - roomResolve.password = result.trim() - } - } - - // 7. 创建房间 - await createRoom(ctx, context, session, context.options) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -async function createRoom( - ctx: Context, - context: ChainMiddlewareContext, - session: Session, - options: ChainMiddlewareContextOptions -) { - const { model, preset, name, chatMode, password, visibility } = - options.room_resolve - - const createRoom: ConversationRoom = { - conversationId: randomUUID(), - model, - preset, - roomName: name ?? 'Unnamed Room', - roomMasterId: session.userId, - roomId: (await getConversationRoomCount(ctx)) + 1, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - visibility: visibility as any, - chatMode, - password: password ?? null, - updatedTime: new Date() - } - - await createConversationRoom(ctx, session, createRoom) - - if (visibility === 'template') { - context.message = session.text('.template_room_created') - } else { - context.message = session.text('.room_created', [ - createRoom.roomId, - createRoom.roomName - ]) - } -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - create_room: never - } - - interface ChainMiddlewareContextOptions { - room_resolve?: { - conversationId?: string - model?: string - preset?: string - name?: string - chatMode?: string - id?: string - password?: string - visibility?: string - } - } -} diff --git a/packages/core/src/middlewares/room/delete_room.ts b/packages/core/src/middlewares/room/delete_room.ts deleted file mode 100644 index 2bca68bf3..000000000 --- a/packages/core/src/middlewares/room/delete_room.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - deleteConversationRoom, - getAllJoinedConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('delete_room', async (session, context) => { - const { command } = context - - if (command !== 'delete_room') - return ChainMiddlewareRunStatus.SKIPPED - - let targetRoom = context.options.room - - if (targetRoom == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - targetRoom = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (targetRoom == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - if ( - targetRoom.roomMasterId !== session.userId && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_room_master') - return ChainMiddlewareRunStatus.STOP - } - - await context.send( - session.text('.confirm_delete', [targetRoom.roomName]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'Y') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - - await deleteConversationRoom(ctx, targetRoom) - - context.message = session.text('.success', [targetRoom.roomName]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - delete_room: never - } -} diff --git a/packages/core/src/middlewares/room/invite_room.ts b/packages/core/src/middlewares/room/invite_room.ts deleted file mode 100644 index 394f78617..000000000 --- a/packages/core/src/middlewares/room/invite_room.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - getConversationRoomUser, - joinConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('invite_room', async (session, context) => { - const { command } = context - - if (command !== 'invite_room') - return ChainMiddlewareRunStatus.SKIPPED - - const targetRoom = context.options.room - - if (targetRoom == null) { - context.message = session.text('.no_room_specified') - return ChainMiddlewareRunStatus.STOP - } - - const userInfo = await getConversationRoomUser( - ctx, - session, - targetRoom, - session.userId - ) - - if ( - userInfo.roomPermission === 'member' && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_admin', [ - targetRoom.roomName - ]) - return ChainMiddlewareRunStatus.STOP - } - - const targetUser = context.options.resolve_user.id as string[] - - for (const user of targetUser) { - await joinConversationRoom( - ctx, - session, - targetRoom, - session.isDirect, - user - ) - } - - context.message = session.text('.success', [ - targetUser.join(','), - targetRoom.roomName - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - invite_room: never - } - - interface ChainMiddlewareContextOptions { - resolve_user?: { - id: string | string[] - } - } -} diff --git a/packages/core/src/middlewares/room/join_room.ts b/packages/core/src/middlewares/room/join_room.ts deleted file mode 100644 index ba79c94ec..000000000 --- a/packages/core/src/middlewares/room/join_room.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - joinConversationRoom, - queryConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('join_room', async (session, context) => { - const { command } = context - - if (command !== 'join_room') return ChainMiddlewareRunStatus.SKIPPED - - const targetRoom = await queryConversationRoom( - ctx, - session, - context.options.room_resolve.name - ) - - if (targetRoom == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - // 检查房间是否可加入 - - // 如果为私聊的话,可随意加入,为群聊的话就需要检查该房间是否被添加为群聊。 - - if (!session.isDirect && targetRoom.visibility === 'public') { - // 接下来检查该房间是否被添加到当前的群里 - - const roomInGroup = - ( - await ctx.database.get('chathub_room_group_member', { - groupId: session.guildId, - roomId: targetRoom.roomId - }) - ).length === 1 - - if (!roomInGroup) { - context.message = session.text('.not_in_group') - return ChainMiddlewareRunStatus.STOP - } - } - - // 检查房间是否有权限加入。 - - if (await checkAdmin(session)) { - // 空的是因为 - } else if ( - targetRoom.visibility === 'private' && - targetRoom.password == null - ) { - context.message = session.text('.private_no_password') - return ChainMiddlewareRunStatus.STOP - } else if ( - targetRoom.visibility === 'private' && - targetRoom.password != null && - !session.isDirect - ) { - context.message = session.text('.private_group_join') - return ChainMiddlewareRunStatus.STOP - } - - if (targetRoom.password) { - await context.send( - session.text('.enter_password', [targetRoom.roomName]) - ) - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result !== targetRoom.password) { - context.message = session.text('.wrong_password') - return ChainMiddlewareRunStatus.STOP - } - } - - await joinConversationRoom(ctx, session, targetRoom) - - context.message = session.text('.success', [targetRoom.roomName]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - join_room: never - } -} diff --git a/packages/core/src/middlewares/room/kick_member.ts b/packages/core/src/middlewares/room/kick_member.ts deleted file mode 100644 index a380bf9ba..000000000 --- a/packages/core/src/middlewares/room/kick_member.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - getConversationRoomUser, - kickUserFromConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('kick_member', async (session, context) => { - const { command } = context - - if (command !== 'kick_member') - return ChainMiddlewareRunStatus.SKIPPED - - const targetRoom = context.options.room - - if (targetRoom == null) { - context.message = session.text('.no_room_specified') - return ChainMiddlewareRunStatus.STOP - } - - const userInfo = await getConversationRoomUser( - ctx, - session, - targetRoom, - session.userId - ) - - if ( - userInfo.roomPermission === 'member' && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_admin', [ - targetRoom.roomName - ]) - return ChainMiddlewareRunStatus.STOP - } - - const targetUser = context.options.resolve_user.id as string[] - - for (const user of targetUser) { - await kickUserFromConversationRoom( - ctx, - session, - targetRoom, - user - ) - } - - context.message = session.text('.success', [ - targetRoom.roomName, - targetUser.join(',') - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - kick_member: never - } -} diff --git a/packages/core/src/middlewares/room/leave_room.ts b/packages/core/src/middlewares/room/leave_room.ts deleted file mode 100644 index 8ab3bf208..000000000 --- a/packages/core/src/middlewares/room/leave_room.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - deleteConversationRoom, - getAllJoinedConversationRoom, - leaveConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('leave_room', async (session, context) => { - const { command } = context - - if (command !== 'leave_room') - return ChainMiddlewareRunStatus.SKIPPED - - let targetRoom = context.options.room - - if (targetRoom == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - targetRoom = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (targetRoom == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - if (targetRoom.roomMasterId === session.userId) { - await context.send(session.text('.confirm_delete')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'Y') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - } - - await leaveConversationRoom(ctx, session, targetRoom) - - if (targetRoom.roomMasterId === session.userId) { - await deleteConversationRoom(ctx, targetRoom) - } - - context.message = session.text('.success', [targetRoom.roomName]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - leave_room: never - } -} diff --git a/packages/core/src/middlewares/room/list_room.ts b/packages/core/src/middlewares/room/list_room.ts deleted file mode 100644 index 88657f319..000000000 --- a/packages/core/src/middlewares/room/list_room.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Context, Session } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkConversationRoomAvailability, - getAllJoinedConversationRoom, - queryConversationRoom, - queryPublicConversationRooms -} from '../../chains/rooms' -import { ConversationRoom } from '../../types' -import { Pagination } from 'koishi-plugin-chatluna/utils/pagination' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const pagination = new Pagination({ - formatItem: (value) => '', - formatString: { - top: '', - bottom: '', - pages: '' - } - }) - - chain - .middleware('list_room', async (session, context) => { - const { - command, - // eslint-disable-next-line @typescript-eslint/naming-convention - options: { page, limit, all_room } - } = context - - if (command !== 'list_room') return ChainMiddlewareRunStatus.SKIPPED - - pagination.updateFormatString({ - top: session.text('.header') + '\n', - bottom: '\n' + session.text('.footer'), - pages: '\n' + session.text('.pages') - }) - - pagination.updateFormatItem((value) => - formatRoomInfo(ctx, session, value) - ) - - const rooms = await getAllJoinedConversationRoom(ctx, session) - - if (all_room) { - const publicRooms = await queryPublicConversationRooms( - ctx, - session - ) - for (const room of publicRooms) { - if (!rooms.find((r) => r.roomId === room.roomId)) { - rooms.push( - await queryConversationRoom( - ctx, - session, - room.roomId - ) - ) - } - } - } - - const key = session.isDirect - ? session.userId - : session.guildId + '-' + session.userId - - await pagination.push(rooms, key) - - context.message = await pagination.getFormattedPage( - page, - limit, - key - ) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -async function formatRoomInfo( - ctx: Context, - session: Session, - room: ConversationRoom -) { - const buffer = [] - - buffer.push(session.text('.room_name', [room.roomName])) - buffer.push(session.text('.room_id', [room.roomId])) - buffer.push(session.text('.room_preset', [room.preset])) - buffer.push( - session.text('.room_model', [ - room.model, - await checkConversationRoomAvailability(ctx, room) - ]) - ) - buffer.push(session.text('.room_visibility', [room.visibility])) - buffer.push(session.text('.room_chat_mode', [room.chatMode])) - buffer.push(session.text('.room_master_id', [room.roomMasterId])) - buffer.push( - session.text('.room_availability', [ - await checkConversationRoomAvailability(ctx, room) - ]) - ) - - buffer.push('\n') - - return buffer.join('\n') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - list_room: never - } - - interface ChainMiddlewareContextOptions { - all_room?: boolean - } -} diff --git a/packages/core/src/middlewares/room/resolve_room.ts b/packages/core/src/middlewares/room/resolve_room.ts deleted file mode 100644 index e9f3ea551..000000000 --- a/packages/core/src/middlewares/room/resolve_room.ts +++ /dev/null @@ -1,334 +0,0 @@ -/* eslint-disable operator-linebreak */ -import { Context, h, Logger, Session } from 'koishi' -import { Config } from '../../config' - -import { ConversationRoom } from '../../types' -import { createLogger } from 'koishi-plugin-chatluna/utils/logger' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - createConversationRoom, - getAllJoinedConversationRoom, - getConversationRoomCount as getMaxConversationRoomId, - getTemplateConversationRoom, - queryJoinedConversationRoom, - queryPublicConversationRoom, - switchConversationRoom -} from '../../chains/rooms' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { randomUUID } from 'crypto' - -let logger: Logger - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - logger = createLogger(ctx) - const selectRoomForSession = async ( - session: Session, - joinedRooms: ConversationRoom[] - ) => pickContextualRoom(ctx, session, config, joinedRooms) - - chain - .middleware('resolve_room', async (session, context) => { - let joinRoom = await queryJoinedConversationRoom( - ctx, - session, - context.options?.room_resolve?.name - ) - - if (config.allowChatWithRoomName) { - const needContinue = context.command == null - - const rawMessageContent = context.message - - const messageContent = - typeof rawMessageContent === 'string' - ? rawMessageContent - : h - .select(rawMessageContent as h[], 'text') - .join('') - .trimStart() - - // split the chat content - const splitContent = messageContent.split(' ') - - let matchedRoom: ConversationRoom - - // the first word is the room name - if (splitContent.length > 1) { - matchedRoom = await queryJoinedConversationRoom( - ctx, - session, - splitContent.shift() - ) - } - - if (matchedRoom == null || !needContinue) { - return ChainMiddlewareRunStatus.STOP - } - - if (matchedRoom != null) { - joinRoom = matchedRoom - - context.options.inputMessage = - await ctx.chatluna.messageTransformer.transform( - session, - [h.text(splitContent.concat(' '))], - matchedRoom.model, - undefined, - { - quote: false, - includeQuoteReply: config.includeQuoteReply - } - ) - } - } - - if (joinRoom == null) { - const joinedRooms = await getAllJoinedConversationRoom( - ctx, - session - ) - - if (joinedRooms.length > 0) { - joinRoom = await selectRoomForSession(session, joinedRooms) - } - - if (joinRoom != null) { - await switchConversationRoom(ctx, session, joinRoom.roomId) - - logger.success( - session.text('chatluna.room.auto_switch', [ - session.userId, - joinRoom.roomName - ]) - ) - } - } - - if ( - joinRoom == null && - config.autoCreateRoomFromUser !== true && - !session.isDirect && - (context.command?.length ?? 0) < 1 - ) { - joinRoom = await queryPublicConversationRoom(ctx, session) - if (joinRoom != null) { - logger.success( - session.text('chatluna.room.auto_switch', [ - session.userId, - joinRoom.roomName - ]) - ) - } - } - - if (joinRoom == null && (context.command?.length ?? 0) < 1) { - // 尝试基于模板房间创建模版克隆房间 - - if ( - (config.defaultModel === '无' || - (config.defaultModel?.trim()?.length || 0) < 1) && - ctx.chatluna.platform.listAllModels(ModelType.llm).value - .length < 1 - ) { - return session.text('chatluna.not_available_model') - } - - const templateRoom = await getTemplateConversationRoom( - ctx, - config - ) - - if (templateRoom == null) { - // 没有就算了。后面需要房间的中间件直接报错就完事。 - return ChainMiddlewareRunStatus.SKIPPED - } - - const cloneRoom = structuredClone(templateRoom) - - cloneRoom.conversationId = randomUUID() - - // 私聊或者总是主动创建 - if (config.autoCreateRoomFromUser || session.isDirect) { - // 如果是群聊的公共房间,那么就房主直接设置为聊天者,否则就是私聊 - cloneRoom.roomMasterId = session.userId - - cloneRoom.visibility = 'private' - - cloneRoom.roomId = (await getMaxConversationRoomId(ctx)) + 1 - - cloneRoom.roomName = session.text( - 'chatluna.room.room_name', - [ - session.isDirect - ? `${session.username ?? session.userId}` - : `${ - session.event.guild.name ?? - session.username ?? - session.event.guild.id.toString() - }` - ] - ) - - logger.success( - session.text('chatluna.room.auto_create', [ - session.userId, - cloneRoom.roomName - ]) - ) - } else { - // 如果是群聊的公共房间,那么就房主直接设置为聊天者,否则就是私聊 - cloneRoom.roomMasterId = session.userId - - cloneRoom.visibility = 'template_clone' - - cloneRoom.roomId = (await getMaxConversationRoomId(ctx)) + 1 - - cloneRoom.roomName = session.text( - 'chatluna.room.template_clone_room_name', - [ - session.isDirect - ? `${session.username ?? session.userId}` - : `${ - session.event.guild.name ?? - session.username ?? - session.event.guild.id.toString() - }` - ] - ) - - logger.success( - session.text('chatluna.room.auto_create_template', [ - session.userId, - cloneRoom.roomName - ]) - ) - } - - // 如果设置不关闭,则跟随更新 - cloneRoom.autoUpdate = - config.autoUpdateRoomMode !== 'disable' || - cloneRoom.visibility === 'template_clone' - - await createConversationRoom(ctx, session, cloneRoom) - - joinRoom = cloneRoom - } - - if ( - joinRoom != null && - joinRoom.autoUpdate !== true && - config.autoUpdateRoomMode === 'all' - ) { - joinRoom.autoUpdate = true - } - - if (joinRoom?.autoUpdate === true) { - // 直接从配置里面复制 - - // 对于 preset,chatModel 的变更,我们需要写入数据库 - let needUpdate = false - if ( - joinRoom.preset !== config.defaultPreset || - joinRoom.chatMode !== config.defaultChatMode || - joinRoom.model !== config.defaultModel - ) { - needUpdate = true - } - - joinRoom.model = config.defaultModel - joinRoom.preset = config.defaultPreset - joinRoom.chatMode = config.defaultChatMode - - if (joinRoom.preset !== config.defaultPreset) { - logger.debug( - `The room ${joinRoom.roomName} preset changed to ${joinRoom.preset}. Clearing chat history.` - ) - // 需要提前清空聊天记录 - await ctx.chatluna.clearChatHistory(joinRoom) - } - - if (needUpdate) { - await ctx.chatluna.clearCache(joinRoom) - - await ctx.database.upsert('chathub_room', [joinRoom]) - - logger.debug( - session.text('chatluna.room.config_changed', [ - joinRoom.roomName - ]) - ) - } - } - - context.options.room = joinRoom - - return ChainMiddlewareRunStatus.CONTINUE - }) - .after('lifecycle-prepare') - // .before("lifecycle-request_model") -} - -export type ChatMode = 'plugin' | 'chat' | 'browsing' -async function pickContextualRoom( - ctx: Context, - session: Session, - config: Config, - joinedRooms: ConversationRoom[] -) { - if (joinedRooms.length === 0) { - return undefined - } - - if (!session.isDirect && config.autoCreateRoomFromUser !== true) { - // Only consider rooms scoped to the current guild to avoid leaking DM rooms. - const groupRooms = await ctx.database.get('chathub_room_group_member', { - groupId: session.guildId, - roomId: { $in: joinedRooms.map((room) => room.roomId) } - }) - - if (groupRooms.length > 0) { - const scopedRoomIds = new Set(groupRooms.map((room) => room.roomId)) - - const findScopedRoom = ( - matcher?: (room: ConversationRoom) => boolean - ) => - joinedRooms.find((room) => { - if (!scopedRoomIds.has(room.roomId)) { - return false - } - return matcher ? matcher(room) : true - }) - - return ( - findScopedRoom( - (room) => room.visibility === 'template_clone' - ) ?? findScopedRoom() - ) - } - - // No group-specific rooms exist yet. Let the caller create a clone for this guild. - return undefined - } - - // DM sessions (and auto-create mode) still prefer private rooms owned by the user. - return ( - joinedRooms.find( - (room) => - room.visibility === 'private' && - room.roomMasterId === session.userId - ) ?? - joinedRooms.find((room) => room.visibility === 'private') ?? - joinedRooms.find((room) => room.visibility !== 'template_clone') ?? - joinedRooms[0] - ) -} - -declare module '../../chains/chain' { - export interface ChainMiddlewareContextOptions { - room?: ConversationRoom - } - - interface ChainMiddlewareName { - resolve_room: never - } -} diff --git a/packages/core/src/middlewares/room/room_info.ts b/packages/core/src/middlewares/room/room_info.ts deleted file mode 100644 index f3beb3ecc..000000000 --- a/packages/core/src/middlewares/room/room_info.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkConversationRoomAvailability, - getAllJoinedConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('room_info', async (session, context) => { - const { command } = context - - if (command !== 'room_info') return ChainMiddlewareRunStatus.SKIPPED - - let room = context.options.room - - if (room == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - room = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (room == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - const buffer = [session.text('.header') + '\n'] - - buffer.push(session.text('.room_name', [room.roomName])) - buffer.push(session.text('.room_id', [room.roomId])) - buffer.push(session.text('.room_preset', [room.preset])) - buffer.push( - session.text('.room_model', [ - room.model, - await checkConversationRoomAvailability(ctx, room) - ]) - ) - buffer.push(session.text('.room_visibility', [room.visibility])) - buffer.push(session.text('.room_chat_mode', [room.chatMode])) - buffer.push(session.text('.room_master_id', [room.roomMasterId])) - - context.message = buffer.join('\n') - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - room_info: never - } -} diff --git a/packages/core/src/middlewares/room/room_permission.ts b/packages/core/src/middlewares/room/room_permission.ts deleted file mode 100644 index 987484c4c..000000000 --- a/packages/core/src/middlewares/room/room_permission.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - getAllJoinedConversationRoom, - setUserPermission -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('room_permission', async (session, context) => { - const { command } = context - - if (command !== 'room_permission') - return ChainMiddlewareRunStatus.SKIPPED - - let targetRoom = context.options.room - - if (targetRoom == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - targetRoom = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (targetRoom == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - if ( - targetRoom.roomMasterId !== session.userId && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_admin') - return ChainMiddlewareRunStatus.STOP - } - - const user = context.options.resolve_user.id as string - - await context.send( - session.text('.confirm_set', [user, targetRoom.roomName]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if ( - ['admin', 'member', 'a', 'm'].every( - (text) => result.toLowerCase() !== text - ) - ) { - context.message = session.text('.invalid_permission') - return ChainMiddlewareRunStatus.STOP - } - - const currentPermission = result.startsWith('a') - ? 'admin' - : 'member' - - await setUserPermission( - ctx, - session, - targetRoom, - currentPermission, - user - ) - - context.message = session.text('.success', [ - user, - targetRoom.roomName, - currentPermission - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - room_permission: never - } -} diff --git a/packages/core/src/middlewares/room/set_auto_update_room.ts b/packages/core/src/middlewares/room/set_auto_update_room.ts deleted file mode 100644 index 26f168fe1..000000000 --- a/packages/core/src/middlewares/room/set_auto_update_room.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin, getAllJoinedConversationRoom } from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('set_auto_update_room', async (session, context) => { - const { command } = context - - if (command !== 'set_auto_update_room') - return ChainMiddlewareRunStatus.SKIPPED - - let { room: targetRoom, auto_update_room: autoUpdateRoom } = - context.options - - if (targetRoom == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - targetRoom = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (targetRoom == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - if ( - targetRoom.roomMasterId !== session.userId && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_admin') - return ChainMiddlewareRunStatus.STOP - } - - targetRoom.autoUpdate = context.options.auto_update_room - - await ctx.database.upsert('chathub_room', [targetRoom]) - - context.message = session.text('.success', [ - targetRoom.roomName, - autoUpdateRoom - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - set_auto_update_room: never - } - interface ChainMiddlewareContextOptions { - auto_update_room?: boolean - } -} diff --git a/packages/core/src/middlewares/room/set_room.ts b/packages/core/src/middlewares/room/set_room.ts deleted file mode 100644 index 94e938597..000000000 --- a/packages/core/src/middlewares/room/set_room.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { Context, Session } from 'koishi' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { - ChainMiddlewareContext, - ChainMiddlewareRunStatus, - ChatChain -} from '../../chains/chain' -import { checkAdmin, getAllJoinedConversationRoom } from '../../chains/rooms' -import { Config } from '../../config' -import { ConversationRoom } from '../../types' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const service = ctx.chatluna.platform - - chain - .middleware('set_room', async (session, context) => { - let { - command, - // eslint-disable-next-line @typescript-eslint/naming-convention - options: { room_resolve, room } - } = context - - if (command !== 'set_room') return ChainMiddlewareRunStatus.SKIPPED - - if (room == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - room = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (room == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - if ( - room.roomMasterId !== session.userId && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_room_master') - return ChainMiddlewareRunStatus.STOP - } - - const oldPreset = room.preset - - if ( - Object.values(room_resolve).filter((value) => value != null) - .length > 0 && - room_resolve.visibility !== 'template' - ) { - await context.send(session.text('.confirm_update')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } - - if (result === 'Y') { - if ( - (!session.isDirect || room.visibility !== 'private') && - room_resolve.password != null - ) { - context.message = session.text('.no_password_in_public') - return ChainMiddlewareRunStatus.STOP - } - room.preset = room_resolve.preset ?? room.preset - room.roomName = room_resolve.name ?? room.roomName - room.chatMode = room_resolve.chatMode ?? room.chatMode - room.password = room_resolve.password ?? room.password - room.visibility = - (room_resolve.visibility as ConversationRoom['visibility']) ?? - room.visibility - room.model = room_resolve.model ?? room.model - - if ( - !(await checkRoomAvailability( - context, - session, - ctx, - room - )) - ) { - context.message = session.text('.failed', [ - room.roomName - ]) - return ChainMiddlewareRunStatus.STOP - } - - await ctx.database.upsert('chathub_room', [room]) - - if (room.preset !== oldPreset) { - await ctx.chatluna.clearChatHistory(room) - context.message = session.text('.success_with_clear', [ - room.roomName - ]) - } else { - await ctx.chatluna.clearCache(room) - context.message = session.text('.success', [ - room.roomName - ]) - } - - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - } - - // 交互式创建 - - let { - model, - preset, - roomName: name, - chatMode, - password, - visibility - } = room - - // 1. 输入房间名 - - await context.send( - session.text('.change_or_keep', [ - session.text('.field.name'), - name - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - name = result.trim() - room.roomName = name - } - - // 2. 选择模型 - - while (true) { - await context.send( - session.text('.change_or_keep', [ - session.text('.field.model'), - model - ]) - ) - - const result = (await session.prompt(1000 * 30))?.trim() - - if (!result) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - break - } - - const findModel = service - .listAllModels(ModelType.llm) - .value.find( - (searchModel) => searchModel.toModelName() === result - ) - - if (findModel == null) { - await context.send( - session.text('.model_not_found', [result]) - ) - continue - } - - model = result - room.model = model - - break - } - - // 3. 选择预设 - - const presetInstance = ctx.chatluna.preset - - while (true) { - await context.send( - session.text('.change_or_keep', [ - session.text('.field.preset'), - preset - ]) - ) - - const result = (await session.prompt(1000 * 30))?.trim() - - if (!result) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - break - } - - try { - await presetInstance.getPreset(result) - room.preset = preset = result - break - } catch (e) { - await context.send( - session.text('.preset_not_found', [result]) - ) - continue - } - } - - // 4. 可见性 - while (true) { - await context.send( - session.text('.change_or_keep', [ - session.text('.field.visibility'), - visibility - ]) - ) - - const result = (await session.prompt(1000 * 30))?.trim() - - if (!result) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - break - } - - if (result === 'private' || result === 'public') { - visibility = room.visibility = result - break - } - - await context.send( - session.text('.invalid_visibility', [result]) - ) - } - - // 5. 聊天模式 - - while (true) { - if (chatMode == null) { - await context.send(session.text('.enter_chat_mode')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - room.chatMode = 'chat' - } else { - room.chatMode = result.trim() - } - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.field.chat_mode'), - chatMode - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - room.chatMode = result.trim() - } - } - - chatMode = room.chatMode - - const availableChatModes = - ctx.chatluna.platform.chatChains.value.map( - (chain) => chain.name - ) - - if (availableChatModes.includes(chatMode)) { - break - } - - await context.send( - session.text('.invalid_chat_mode', [ - visibility, - availableChatModes.join(', ') - ]) - ) - } - - // 6. 密码 - if ( - session.isDirect && - visibility === 'private' && - password == null - ) { - await context.send(session.text('.enter_password')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - room.password = null - } else { - room.password = result.trim() - } - } - - // 7. 更新房间 - - await ctx.database.upsert('chathub_room', [room]) - - if (room.preset !== oldPreset) { - await ctx.chatluna.clearChatHistory(room) - context.message = session.text('.success_with_clear', [ - room.roomName - ]) - } else { - await ctx.chatluna.clearCache(room) - context.message = session.text('.success', [room.roomName]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -async function checkRoomAvailability( - context: ChainMiddlewareContext, - session: Session, - ctx: Context, - room: ConversationRoom -) { - const availableChatModes = ctx.chatluna.platform.chatChains.value.map( - (chain) => chain.name - ) - - if (!availableChatModes.includes(room.chatMode)) { - await context.send( - session.text('.invalid_chat_mode', [ - room.chatMode, - availableChatModes.join(', ') - ]) - ) - return false - } - - const findModel = ctx.chatluna.platform - .listAllModels(ModelType.llm) - .value.find((searchModel) => searchModel.toModelName() === room.model) - - if (findModel == null) { - await context.send(session.text('.model_not_found', [room.model])) - return false - } - - try { - await ctx.chatluna.preset.getPreset(room.preset) - } catch (e) { - await context.send(session.text('.preset_not_found', [room.preset])) - return false - } - - const visibility = room.visibility - if (visibility === 'private' || visibility === 'public') { - return true - } - - await context.send(session.text('.invalid_visibility', [visibility])) -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - set_room: never - } -} diff --git a/packages/core/src/middlewares/room/switch_room.ts b/packages/core/src/middlewares/room/switch_room.ts deleted file mode 100644 index abeaa74ae..000000000 --- a/packages/core/src/middlewares/room/switch_room.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { switchConversationRoom } from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('switch_room', async (session, context) => { - const { command } = context - - if (command !== 'switch_room') - return ChainMiddlewareRunStatus.SKIPPED - - const targetConversationRoom = await switchConversationRoom( - ctx, - session, - context.options.room_resolve?.name - ) - - if (!targetConversationRoom) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - context.message = session.text('.success', [ - targetConversationRoom.roomName - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - switch_room: never - } -} diff --git a/packages/core/src/middlewares/room/transfer_room.ts b/packages/core/src/middlewares/room/transfer_room.ts deleted file mode 100644 index 349298673..000000000 --- a/packages/core/src/middlewares/room/transfer_room.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { - checkAdmin, - getAllJoinedConversationRoom, - transferConversationRoom -} from '../../chains/rooms' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('transfer_room', async (session, context) => { - const { command } = context - - if (command !== 'transfer_room') - return ChainMiddlewareRunStatus.SKIPPED - - let room = context.options.room - - if (room == null && context.options.room_resolve != null) { - // 尝试完整搜索一次 - - const rooms = await getAllJoinedConversationRoom( - ctx, - session, - true - ) - - const roomId = parseInt(context.options.room_resolve?.name) - - room = rooms.find( - (room) => - room.roomName === context.options.room_resolve?.name || - room.roomId === roomId - ) - } - - if (room == null) { - context.message = session.text('.room_not_found') - return ChainMiddlewareRunStatus.STOP - } - - if ( - room.roomMasterId !== session.userId && - !(await checkAdmin(session)) - ) { - context.message = session.text('.not_room_master') - return ChainMiddlewareRunStatus.STOP - } - - const targetUser = context.options.resolve_user.id as string - - await context.send( - session.text('.confirm_transfer', [room.roomName, targetUser]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'Y') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - - await transferConversationRoom(ctx, session, room, targetUser) - - context.message = session.text('.success', [ - room.roomName, - targetUser - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_model') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - transfer_room: never - } -} diff --git a/packages/core/src/middlewares/system/clear_balance.ts b/packages/core/src/middlewares/system/clear_balance.ts index cbb5692a9..2766208ef 100644 --- a/packages/core/src/middlewares/system/clear_balance.ts +++ b/packages/core/src/middlewares/system/clear_balance.ts @@ -24,7 +24,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts new file mode 100644 index 000000000..cb07ab117 --- /dev/null +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -0,0 +1,1093 @@ +import { Context } from 'koishi' +import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' +import { Config } from '../../config' +import { + ConversationCompressionRecord, + ConversationRecord, + ResolvedConversationContext +} from '../../services/conversation_types' +import { Pagination } from '../../utils/pagination' +import { checkAdmin } from '../../utils/koishi' + +function pickConversationTarget( + context: import('../../chains/chain').ChainMiddlewareContext, + current?: ConversationRecord | null +) { + return ( + context.options.conversation_manage?.targetConversation ?? + context.options.conversationId ?? + current?.id ?? + undefined + ) +} + +async function resolveManagedConversation( + ctx: Context, + session: import('koishi').Session, + context: import('../../chains/chain').ChainMiddlewareContext +) { + const presetLane = context.options.conversation_manage?.presetLane + const resolved = await ctx.chatluna.conversation.resolveContext(session, { + presetLane, + conversationId: context.options.conversationId + }) + const targetConversation = pickConversationTarget( + context, + resolved.conversation + ) + + return { + presetLane, + resolved, + targetConversation, + conversation: + targetConversation != null + ? await ctx.chatluna.conversation.resolveTargetConversation( + session, + { + presetLane, + targetConversation, + conversationId: context.options.conversationId, + permission: 'manage', + includeArchived: + context.options.conversation_manage + ?.includeArchived === true + } + ) + : null + } +} + +function formatConversationStatus( + session: import('koishi').Session, + conversation: ConversationRecord, + activeConversationId?: string | null +) { + const labels = [session.text('.status_value.' + conversation.status)] + + if (conversation.id === activeConversationId) { + labels.push(session.text('.active')) + } + + return labels.join(' · ') +} + +function parseCompression(value?: string | null) { + if (value == null || value.length === 0) { + return null + } + + try { + return JSON.parse(value) as ConversationCompressionRecord + } catch { + return null + } +} + +function formatRouteScope(bindingKey: string) { + const [mode, platform, selfId, scope, userId] = bindingKey.split(':') + + if (bindingKey.includes(':preset:')) { + return bindingKey + } + + if (mode === 'shared') { + return `${mode} ${platform}/${selfId}/${scope}` + } + + return `${mode} ${platform}/${selfId}/${scope}/${userId}` +} + +function formatRuleState(value?: string | null, fallback = 'reset') { + return value ?? fallback +} + +function formatConversationError( + session: import('koishi').Session, + error: Error, + action?: string +) { + if (error.message === 'Conversation not found.') { + return session.text('.messages.target_not_found') + } + + if (error.message === 'Conversation target is ambiguous.') { + return session.text('.messages.target_ambiguous') + } + + if (error.message === 'Conversation does not belong to current route.') { + return session.text('.messages.target_outside_route') + } + + if ( + error.message === + 'Conversation management requires administrator permission.' + ) { + return session.text('.messages.admin_required') + } + + const locked = error.message.match( + /^Conversation (.+) is locked by constraint\.$/ + ) + if (locked) { + return session.text('.messages.action_locked', [ + session.text(`.action.${locked[1]}`) + ]) + } + + const disabled = error.message.match( + /^Conversation (.+) is disabled by constraint\.$/ + ) + if (disabled) { + return session.text('.messages.action_disabled', [ + session.text(`.action.${disabled[1]}`) + ]) + } + + const fixedModel = error.message.match(/^Model is fixed to (.+)\.$/) + if (fixedModel) { + return session.text('.messages.fixed_model', [fixedModel[1]]) + } + + const fixedPreset = error.message.match(/^Preset is fixed to (.+)\.$/) + if (fixedPreset) { + return session.text('.messages.fixed_preset', [fixedPreset[1]]) + } + + const fixedMode = error.message.match(/^Chat mode is fixed to (.+)\.$/) + if (fixedMode) { + return session.text('.messages.fixed_chat_mode', [fixedMode[1]]) + } + + if (action != null) { + return session.text('.messages.action_failed', [ + session.text(`.action.${action}`), + error.message + ]) + } + + return error.message +} + +function formatConversationBlock( + session: import('koishi').Session, + resolved: ResolvedConversationContext, + conversation: ConversationRecord +) { + const compression = parseCompression(conversation.compression) + const updatedAt = conversation.lastChatAt ?? conversation.updatedAt + const effectiveModel = + resolved.constraint.fixedModel ?? + conversation.model ?? + resolved.constraint.defaultModel ?? + '-' + const effectivePreset = + resolved.presetLane ?? + resolved.constraint.fixedPreset ?? + conversation.preset ?? + resolved.constraint.defaultPreset ?? + '-' + const effectiveChatMode = + resolved.constraint.fixedChatMode ?? + conversation.chatMode ?? + resolved.constraint.defaultChatMode ?? + '-' + + return [ + session.text('.conversation_scope', [ + formatRouteScope(resolved.bindingKey) + ]), + session.text('.conversation_base_scope', [ + formatRouteScope(resolved.constraint.baseKey) + ]), + session.text('.conversation_route_mode', [ + resolved.constraint.routeMode + ]), + session.text('.conversation_active', [ + resolved.binding?.activeConversationId ?? '-' + ]), + session.text('.conversation_last', [ + resolved.binding?.lastConversationId ?? '-' + ]), + session.text('.conversation_seq', [conversation.seq ?? '-']), + session.text('.conversation_title', [conversation.title]), + session.text('.conversation_id', [conversation.id]), + session.text('.conversation_status', [ + formatConversationStatus( + session, + conversation, + resolved.binding?.activeConversationId + ) + ]), + session.text('.conversation_model', [conversation.model]), + session.text('.conversation_preset', [conversation.preset]), + session.text('.conversation_chat_mode', [conversation.chatMode]), + session.text('.conversation_effective_model', [effectiveModel]), + session.text('.conversation_effective_preset', [effectivePreset]), + session.text('.conversation_effective_chat_mode', [effectiveChatMode]), + session.text('.conversation_default_model', [ + resolved.constraint.defaultModel ?? '-' + ]), + session.text('.conversation_default_preset', [ + resolved.constraint.defaultPreset ?? '-' + ]), + session.text('.conversation_default_chat_mode', [ + resolved.constraint.defaultChatMode ?? '-' + ]), + session.text('.conversation_fixed_model', [ + resolved.constraint.fixedModel ?? '-' + ]), + session.text('.conversation_fixed_preset', [ + resolved.constraint.fixedPreset ?? '-' + ]), + session.text('.conversation_fixed_chat_mode', [ + resolved.constraint.fixedChatMode ?? '-' + ]), + session.text('.conversation_lock', [ + resolved.constraint.lockConversation ? 'locked' : 'unlocked' + ]), + session.text('.conversation_allow_new', [ + resolved.constraint.allowNew ? 'enabled' : 'disabled' + ]), + session.text('.conversation_allow_switch', [ + resolved.constraint.allowSwitch ? 'enabled' : 'disabled' + ]), + session.text('.conversation_allow_archive', [ + resolved.constraint.allowArchive ? 'enabled' : 'disabled' + ]), + session.text('.conversation_allow_export', [ + resolved.constraint.allowExport ? 'enabled' : 'disabled' + ]), + session.text('.conversation_manage_mode', [ + resolved.constraint.manageMode + ]), + session.text('.conversation_preset_lane', [resolved.presetLane ?? '-']), + session.text('.conversation_compression_count', [ + compression?.count ?? 0 + ]), + session.text('.conversation_updated_at', [updatedAt.toISOString()]), + '' + ].join('\n') +} + +function formatConversationLine( + session: import('koishi').Session, + conversation: ConversationRecord, + resolved: ResolvedConversationContext +) { + return formatConversationBlock(session, resolved, conversation) +} + +export function apply(ctx: Context, config: Config, chain: ChatChain) { + const pagination = new Pagination({ + formatItem: () => '', + formatString: { + top: '', + bottom: '', + pages: '' + } + }) + + chain + .middleware('conversation_new', async (session, context) => { + if (context.command !== 'conversation_new') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const presetLane = context.options.conversation_create?.preset + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { + presetLane + } + ) + + if ( + resolved.constraint.manageMode === 'admin' && + !(await checkAdmin(session)) + ) { + context.message = session.text('.messages.admin_required') + return ChainMiddlewareRunStatus.STOP + } + + if ( + resolved.constraint.lockConversation && + resolved.binding?.activeConversationId != null + ) { + context.message = session.text('.messages.action_locked', [ + session.text('.action.create') + ]) + return ChainMiddlewareRunStatus.STOP + } + + if ( + context.options.conversation_create?.model != null && + resolved.constraint.fixedModel != null && + context.options.conversation_create?.model !== + resolved.constraint.fixedModel + ) { + context.message = session.text('.messages.fixed_model', [ + resolved.constraint.fixedModel + ]) + return ChainMiddlewareRunStatus.STOP + } + + if ( + context.options.conversation_create?.chatMode != null && + resolved.constraint.fixedChatMode != null && + context.options.conversation_create?.chatMode !== + resolved.constraint.fixedChatMode + ) { + context.message = session.text('.messages.fixed_chat_mode', [ + resolved.constraint.fixedChatMode + ]) + return ChainMiddlewareRunStatus.STOP + } + + if (!resolved.constraint.allowNew) { + context.message = session.text('.messages.action_disabled', [ + session.text('.action.create') + ]) + return ChainMiddlewareRunStatus.STOP + } + + const conversation = + await ctx.chatluna.conversation.createConversation(session, { + bindingKey: resolved.bindingKey, + title: + context.options.conversation_create?.title ?? + presetLane ?? + session.text('.default_title'), + model: + context.options.conversation_create?.model ?? + resolved.effectiveModel ?? + config.defaultModel, + preset: resolved.effectivePreset ?? config.defaultPreset, + chatMode: + context.options.conversation_create?.chatMode ?? + resolved.effectiveChatMode ?? + config.defaultChatMode + }) + + context.options.conversationId = conversation.id + context.message = session.text('.messages.new_success', [ + conversation.title, + conversation.seq ?? conversation.id, + conversation.id + ]) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_switch', async (session, context) => { + if (context.command !== 'conversation_switch') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const targetConversation = + context.options.conversation_manage?.targetConversation + + if (targetConversation == null) { + context.message = session.text('.messages.target_required') + return ChainMiddlewareRunStatus.STOP + } + + try { + const conversation = + await ctx.chatluna.conversation.switchConversation( + session, + { + targetConversation, + presetLane: + context.options.conversation_manage?.presetLane + } + ) + + context.options.conversationId = conversation.id + context.message = session.text('.messages.switch_success', [ + conversation.title, + conversation.seq ?? conversation.id, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.switch_failed', [ + formatConversationError(session, error, 'switch') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_list', async (session, context) => { + if (context.command !== 'conversation_list') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const page = context.options.page ?? 1 + const limit = context.options.limit ?? 5 + const presetLane = context.options.conversation_manage?.presetLane + const includeArchived = + context.options.conversation_manage?.includeArchived === true + const resolved = + await ctx.chatluna.conversation.getCurrentConversation( + session, + { presetLane } + ) + const conversations = + await ctx.chatluna.conversation.listConversations(session, { + presetLane, + includeArchived + }) + + if (conversations.length === 0) { + context.message = session.text('.messages.list_empty') + return ChainMiddlewareRunStatus.STOP + } + + pagination.updateFormatString({ + top: session.text('.messages.list_header') + '\n', + bottom: '', + pages: '\n' + session.text('.messages.list_pages') + }) + pagination.updateFormatItem((conversation) => + formatConversationLine(session, conversation, resolved) + ) + + const key = `${resolved.bindingKey}:${session.userId}` + await pagination.push(conversations, key) + context.message = await pagination.getFormattedPage( + page, + limit, + key + ) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_current', async (session, context) => { + if (context.command !== 'conversation_current') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const resolved = + await ctx.chatluna.conversation.getCurrentConversation( + session, + { + presetLane: + context.options.conversation_manage?.presetLane + } + ) + + if (resolved.conversation == null) { + context.message = session.text('.messages.current_empty') + return ChainMiddlewareRunStatus.STOP + } + + context.message = [ + session.text('.messages.current_header'), + formatConversationLine(session, resolved.conversation, resolved) + ].join('\n') + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rename', async (session, context) => { + if (context.command !== 'conversation_rename') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const title = context.options.conversation_manage?.title + if (title == null) { + context.message = session.text('.messages.title_required') + return ChainMiddlewareRunStatus.STOP + } + + try { + const conversation = + await ctx.chatluna.conversation.renameConversation( + session, + { + conversationId: context.options.conversationId, + targetConversation: + context.options.conversation_manage + ?.targetConversation, + presetLane: + context.options.conversation_manage?.presetLane, + title + } + ) + + context.message = session.text('.messages.rename_success', [ + conversation.title, + conversation.seq ?? conversation.id, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.rename_failed', [ + formatConversationError(session, error, 'rename') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_delete', async (session, context) => { + if (context.command !== 'conversation_delete') { + return ChainMiddlewareRunStatus.SKIPPED + } + + try { + const conversation = + await ctx.chatluna.conversation.deleteConversation( + session, + { + conversationId: context.options.conversationId, + targetConversation: + context.options.conversation_manage + ?.targetConversation, + presetLane: + context.options.conversation_manage?.presetLane + } + ) + + context.message = session.text('.messages.delete_success', [ + conversation.title, + conversation.seq ?? conversation.id, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.delete_failed', [ + formatConversationError(session, error, 'delete') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_use_model', async (session, context) => { + if (context.command !== 'conversation_use_model') { + return ChainMiddlewareRunStatus.SKIPPED + } + + try { + const conversation = + await ctx.chatluna.conversation.updateConversationUsage( + session, + { + conversationId: context.options.conversationId, + presetLane: + context.options.conversation_manage?.presetLane, + model: context.options.conversation_use?.model + } + ) + + context.message = session.text('.messages.use_model_success', [ + conversation.model, + conversation.title, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.use_model_failed', [ + formatConversationError(session, error, 'update') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_use_preset', async (session, context) => { + if (context.command !== 'conversation_use_preset') { + return ChainMiddlewareRunStatus.SKIPPED + } + + try { + const conversation = + await ctx.chatluna.conversation.updateConversationUsage( + session, + { + conversationId: context.options.conversationId, + presetLane: + context.options.conversation_manage?.presetLane, + preset: context.options.conversation_use?.preset + } + ) + + context.message = session.text('.messages.use_preset_success', [ + conversation.preset, + conversation.title, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.use_preset_failed', [ + formatConversationError(session, error, 'update') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_use_mode', async (session, context) => { + if (context.command !== 'conversation_use_mode') { + return ChainMiddlewareRunStatus.SKIPPED + } + + try { + const conversation = + await ctx.chatluna.conversation.updateConversationUsage( + session, + { + conversationId: context.options.conversationId, + presetLane: + context.options.conversation_manage?.presetLane, + chatMode: context.options.conversation_use?.chatMode + } + ) + + context.message = session.text('.messages.use_mode_success', [ + conversation.chatMode, + conversation.title, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.use_mode_failed', [ + formatConversationError(session, error, 'update') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_archive', async (session, context) => { + if (context.command !== 'conversation_archive') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const targetConversation = pickConversationTarget(context) + + if (targetConversation == null) { + context.message = session.text('.messages.archive_empty') + return ChainMiddlewareRunStatus.STOP + } + + try { + const result = + await ctx.chatluna.conversation.archiveConversation( + session, + { + targetConversation, + presetLane: + context.options.conversation_manage?.presetLane + } + ) + + context.message = session.text('.messages.archive_success', [ + result.conversation.title, + result.conversation.seq ?? result.conversation.id, + result.conversation.id, + result.archive.id + ]) + } catch (error) { + context.message = session.text('.messages.archive_failed', [ + formatConversationError(session, error, 'archive') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_restore', async (session, context) => { + if (context.command !== 'conversation_restore') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const targetConversation = pickConversationTarget(context) + + if (targetConversation == null) { + context.message = session.text('.messages.restore_empty') + return ChainMiddlewareRunStatus.STOP + } + + try { + const conversation = + await ctx.chatluna.conversation.reopenConversation( + session, + { + targetConversation, + presetLane: + context.options.conversation_manage?.presetLane, + includeArchived: true + } + ) + + context.options.conversationId = conversation.id + context.message = session.text('.messages.restore_success', [ + conversation.title, + conversation.seq ?? conversation.id, + conversation.id + ]) + } catch (error) { + context.message = session.text('.messages.restore_failed', [ + formatConversationError(session, error, 'restore') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_export', async (session, context) => { + if (context.command !== 'conversation_export') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const targetConversation = pickConversationTarget(context) + + if (targetConversation == null) { + context.message = session.text('.messages.export_empty') + return ChainMiddlewareRunStatus.STOP + } + + try { + const result = + await ctx.chatluna.conversation.exportConversation( + session, + { + targetConversation, + presetLane: + context.options.conversation_manage?.presetLane, + includeArchived: true + } + ) + + context.message = session.text('.messages.export_success', [ + result.conversation.title, + result.conversation.seq ?? result.conversation.id, + result.conversation.id, + result.path, + result.size, + result.checksum + ]) + } catch (error) { + context.message = session.text('.messages.export_failed', [ + formatConversationError(session, error, 'export') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rule_model', async (session, context) => { + if (context.command !== 'conversation_rule_model') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const value = context.options.conversation_rule?.model + const record = + await ctx.chatluna.conversation.updateManagedConstraint( + session, + { + fixedModel: value === 'reset' ? null : value + } + ) + + context.message = session.text('.messages.rule_model_success', [ + formatRuleState(record.fixedModel) + ]) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rule_preset', async (session, context) => { + if (context.command !== 'conversation_rule_preset') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const value = context.options.conversation_rule?.preset + const record = + await ctx.chatluna.conversation.updateManagedConstraint( + session, + { + fixedPreset: value === 'reset' ? null : value + } + ) + + context.message = session.text('.messages.rule_preset_success', [ + formatRuleState(record.fixedPreset) + ]) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rule_mode', async (session, context) => { + if (context.command !== 'conversation_rule_mode') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const value = context.options.conversation_rule?.chatMode + const record = + await ctx.chatluna.conversation.updateManagedConstraint( + session, + { + fixedChatMode: value === 'reset' ? null : value + } + ) + + context.message = session.text('.messages.rule_mode_success', [ + formatRuleState(record.fixedChatMode) + ]) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rule_share', async (session, context) => { + if (context.command !== 'conversation_rule_share') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const share = context.options.conversation_rule?.share + const routeMode = + share === 'reset' + ? null + : share === 'shared' || share === 'personal' + ? share + : undefined + + if (routeMode === undefined) { + context.message = session.text('.messages.rule_share_failed', [ + 'share must be personal, shared, or reset.' + ]) + return ChainMiddlewareRunStatus.STOP + } + + const record = + await ctx.chatluna.conversation.updateManagedConstraint( + session, + { + routeMode + } + ) + + context.message = session.text('.messages.rule_share_success', [ + formatRuleState(record.routeMode) + ]) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rule_lock', async (session, context) => { + if (context.command !== 'conversation_rule_lock') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const current = + await ctx.chatluna.conversation.getManagedConstraint(session) + const raw = context.options.conversation_rule?.lock + const lock = + raw === 'reset' + ? null + : raw === 'true' || raw === 'on' || raw === 'lock' + ? true + : raw === 'false' || raw === 'off' || raw === 'unlock' + ? false + : raw === 'toggle' + ? !(current?.lockConversation === true) + : undefined + + if (lock === undefined) { + context.message = session.text('.messages.rule_lock_failed', [ + 'lock must be on, off, reset, or toggle.' + ]) + return ChainMiddlewareRunStatus.STOP + } + + const record = + await ctx.chatluna.conversation.updateManagedConstraint( + session, + { + lockConversation: lock + } + ) + + context.message = session.text('.messages.rule_lock_success', [ + record.lockConversation == null + ? 'reset' + : record.lockConversation + ? 'locked' + : 'unlocked' + ]) + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_rule_show', async (session, context) => { + if (context.command !== 'conversation_rule_show') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const current = + await ctx.chatluna.conversation.getManagedConstraint(session) + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { + presetLane: context.options.conversation_manage?.presetLane + } + ) + + context.message = [ + session.text('.messages.rule_show_header'), + session.text('.conversation_scope', [ + formatRouteScope(resolved.bindingKey) + ]), + session.text('.rule_share', [ + formatRuleState(current?.routeMode) + ]), + session.text('.rule_model', [ + formatRuleState(current?.fixedModel) + ]), + session.text('.rule_preset', [ + formatRuleState(current?.fixedPreset) + ]), + session.text('.rule_mode', [ + formatRuleState(current?.fixedChatMode) + ]), + session.text('.rule_lock', [ + current?.lockConversation == null + ? 'reset' + : current.lockConversation + ? 'locked' + : 'unlocked' + ]) + ].join('\n') + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + + chain + .middleware('conversation_compress', async (session, context) => { + if (context.command !== 'conversation_compress') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const key = + context.options.i18n_base ?? + 'commands.chatluna.compress.messages' + const { conversation, resolved } = await resolveManagedConversation( + ctx, + session, + context + ) + + if (conversation == null) { + context.message = session.text(`${key}.no_conversation`) + return ChainMiddlewareRunStatus.STOP + } + + if (resolved.constraint.lockConversation) { + context.message = session.text(`${key}.failed`, [ + conversation.title, + conversation.id, + session.text( + 'chatluna.conversation.messages.action_locked', + [session.text('chatluna.conversation.action.compress')] + ) + ]) + return ChainMiddlewareRunStatus.STOP + } + + try { + const result = + await ctx.chatluna.conversationRuntime.compressConversation( + conversation, + context.options.force === true + ) + const args = [ + result.inputTokens, + result.outputTokens, + result.reducedPercent.toFixed(2) + ] + + context.message = session.text( + result.compressed ? `${key}.success` : `${key}.skipped`, + args + ) + } catch (error) { + ctx.logger.error(error) + context.message = session.text(`${key}.failed`, [ + conversation.title, + conversation.id, + formatConversationError(session, error, 'compress') + ]) + } + + return ChainMiddlewareRunStatus.STOP + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') +} + +declare module '../../chains/chain' { + interface ChainMiddlewareName { + conversation_new: never + conversation_switch: never + conversation_list: never + conversation_current: never + conversation_rename: never + conversation_delete: never + conversation_use_model: never + conversation_use_preset: never + conversation_use_mode: never + conversation_archive: never + conversation_restore: never + conversation_export: never + conversation_compress: never + conversation_rule_model: never + conversation_rule_preset: never + conversation_rule_mode: never + conversation_rule_share: never + conversation_rule_lock: never + conversation_rule_show: never + } +} diff --git a/packages/core/src/middlewares/system/lifecycle.ts b/packages/core/src/middlewares/system/lifecycle.ts index fa4a68c7c..2919fa871 100644 --- a/packages/core/src/middlewares/system/lifecycle.ts +++ b/packages/core/src/middlewares/system/lifecycle.ts @@ -16,23 +16,23 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('lifecycle-handle_command', async (session, context) => 0) .after('lifecycle-prepare') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') chain - .middleware('lifecycle-request_model', async (session, context) => 0) + .middleware('lifecycle-request_conversation', async (session, context) => 0) .after('lifecycle-handle_command') .before('lifecycle-send') chain .middleware('lifecycle-send', async (session, context) => 0) - .after('lifecycle-request_model') + .after('lifecycle-request_conversation') } export const lifecycleNames = [ 'lifecycle-check', 'lifecycle-prepare', 'lifecycle-handle_command', - 'lifecycle-request_model', + 'lifecycle-request_conversation', 'lifecycle-send' ] @@ -49,7 +49,7 @@ declare module '../../chains/chain' { /** * lifecycle of the middleware execution, it mean the middleware will be request to the model */ - 'lifecycle-request_model': never + 'lifecycle-request_conversation': never /** * lifecycle of the middleware execution, it mean the middleware will be send message */ diff --git a/packages/core/src/middlewares/system/query_balance.ts b/packages/core/src/middlewares/system/query_balance.ts index 79dbaf259..2de058b85 100644 --- a/packages/core/src/middlewares/system/query_balance.ts +++ b/packages/core/src/middlewares/system/query_balance.ts @@ -21,7 +21,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/system/set_balance.ts b/packages/core/src/middlewares/system/set_balance.ts index 838ff8d7a..432b636b0 100644 --- a/packages/core/src/middlewares/system/set_balance.ts +++ b/packages/core/src/middlewares/system/set_balance.ts @@ -28,7 +28,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) .after('lifecycle-handle_command') - .before('lifecycle-request_model') + .before('lifecycle-request_conversation') } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index e18f28c5f..21eb55d7a 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -3,11 +3,69 @@ import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { createLogger } from 'koishi-plugin-chatluna/utils/logger' import fs from 'fs/promises' +import { + createLegacyTableRetention, + getLegacySchemaSentinel, + getLegacySchemaSentinelDir, + LEGACY_MIGRATION_TABLES, + LEGACY_RETENTION_META_KEY, + LEGACY_RUNTIME_TABLES, + readMetaValue, + writeMetaValue +} from '../../migration/validators' let logger: Logger export function apply(ctx: Context, config: Config, chain: ChatChain) { logger = createLogger(ctx) + chain + .middleware('purge_legacy', async (session, context) => { + if (context.command !== 'purge_legacy') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const result = await readMetaValue<{ + passed?: boolean + }>(ctx, 'validation_result') + + if (result?.passed !== true) { + context.message = + 'Legacy purge is blocked until migration validation passes.' + return ChainMiddlewareRunStatus.STOP + } + + for (const table of LEGACY_MIGRATION_TABLES) { + try { + await ctx.database.drop(table) + } catch (error) { + logger.warn(`purge legacy ${table}: ${error}`) + } + } + + const sentinel = getLegacySchemaSentinel(ctx.baseDir) + await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { + recursive: true + }) + await fs.writeFile( + sentinel, + JSON.stringify({ purgedAt: new Date().toISOString() }) + ) + + await writeMetaValue( + ctx, + 'legacy_purged_at', + new Date().toISOString() + ) + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('purged') + ) + context.message = 'Legacy ChatHub tables were purged.' + return ChainMiddlewareRunStatus.STOP + }) + .before('black_list') + chain .middleware('wipe', async (session, context) => { const { command } = context @@ -34,15 +92,21 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { // drop database tables - await ctx.database.drop('chathub_room_member') - await ctx.database.drop('chathub_conversation') - await ctx.database.drop('chathub_message') - await ctx.database.drop('chathub_room') - await ctx.database.drop('chathub_room_group_member') - await ctx.database.drop('chathub_user') - await ctx.database.drop('chathub_auth_group') - await ctx.database.drop('chathub_auth_joined_user') - await ctx.database.drop('chathub_auth_user') + await ctx.database.drop('chatluna_conversation') + await ctx.database.drop('chatluna_message') + await ctx.database.drop('chatluna_binding') + await ctx.database.drop('chatluna_constraint') + await ctx.database.drop('chatluna_archive') + await ctx.database.drop('chatluna_acl') + await ctx.database.drop('chatluna_meta') + for (const table of LEGACY_MIGRATION_TABLES) { + await ctx.database.drop(table) + } + + for (const table of LEGACY_RUNTIME_TABLES) { + await ctx.database.drop(table) + } + await ctx.database.drop('chatluna_docstore') // knowledge @@ -84,6 +148,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { declare module '../../chains/chain' { interface ChainMiddlewareName { + purge_legacy: never wipe: never } } diff --git a/packages/core/src/migration/legacy_tables.ts b/packages/core/src/migration/legacy_tables.ts new file mode 100644 index 000000000..8176f464b --- /dev/null +++ b/packages/core/src/migration/legacy_tables.ts @@ -0,0 +1,45 @@ +import path from 'path' + +export const LEGACY_SCHEMA_SENTINEL = + 'data/chatluna/temp/legacy-schema-disabled.json' + +export const LEGACY_MIGRATION_TABLES = [ + 'chathub_room_member', + 'chathub_room_group_member', + 'chathub_user', + 'chathub_room', + 'chathub_message', + 'chathub_conversation' +] as const + +export const LEGACY_RUNTIME_TABLES = [ + 'chathub_auth_group', + 'chathub_auth_joined_user', + 'chathub_auth_user' +] as const + +export const LEGACY_RETENTION_META_KEY = 'legacy_table_retention' + +export interface LegacyTableRetention { + state: 'migration-visible' | 'purged' + migrationTables: readonly string[] + runtimeTables: readonly string[] +} + +export function createLegacyTableRetention( + state: LegacyTableRetention['state'] +) { + return { + state, + migrationTables: [...LEGACY_MIGRATION_TABLES], + runtimeTables: [...LEGACY_RUNTIME_TABLES] + } satisfies LegacyTableRetention +} + +export function getLegacySchemaSentinel(baseDir: string) { + return path.resolve(baseDir, LEGACY_SCHEMA_SENTINEL) +} + +export function getLegacySchemaSentinelDir(baseDir: string) { + return path.dirname(getLegacySchemaSentinel(baseDir)) +} diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts new file mode 100644 index 000000000..c3acf7796 --- /dev/null +++ b/packages/core/src/migration/room_to_conversation.ts @@ -0,0 +1,478 @@ +import type { Context } from 'koishi' +import type { Config } from '../config' +import type { + ACLRecord, + BindingRecord, + ConversationRecord, + MessageRecord +} from '../services/conversation_types' +import type { + LegacyConversationRecord, + LegacyMessageRecord, + LegacyRoomGroupRecord, + LegacyRoomMemberRecord, + LegacyRoomRecord, + LegacyUserRecord +} from '../services/types' +import { + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention, + createLegacyBindingKey, + inferLegacyGroupRouteModes, + isComplexRoom, + readMetaValue, + validateRoomMigration, + writeMetaValue +} from './validators' + +export const BUILTIN_SCHEMA_VERSION = 1 + +interface RoomProgress { + lastRoomId: number + migrated: number +} + +interface MessageProgress { + index: number + lastId?: string + migrated: number +} + +interface BindingProgress { + index: number + migrated: number +} + +export async function runRoomToConversationMigration( + ctx: Context, + config: Config +) { + const schemaVersion = + (await readMetaValue(ctx, 'schema_version')) ?? 0 + const roomDone = + (await readMetaValue(ctx, 'room_migration_done')) ?? false + const messageDone = + (await readMetaValue(ctx, 'message_migration_done')) ?? false + + if (schemaVersion >= BUILTIN_SCHEMA_VERSION && roomDone && messageDone) { + return await ensureMigrationValidated(ctx, config) + } + + ctx.logger.info('Running built-in ChatLuna migration.') + await writeMetaValue(ctx, 'migration_started_at', new Date().toISOString()) + await writeMetaValue(ctx, 'schema_version', BUILTIN_SCHEMA_VERSION) + await writeMetaValue( + ctx, + 'legacy_binding_route_mode', + config.defaultGroupRouteMode + ) + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('migration-visible') + ) + + await migrateRooms(ctx) + await migrateMessages(ctx) + await migrateBindings(ctx) + + const result = await validateRoomMigration(ctx, config) + await writeMetaValue(ctx, 'validation_result', result) + await writeMetaValue(ctx, 'migration_timestamp', new Date().toISOString()) + await writeMetaValue(ctx, 'room_migration_done', true) + await writeMetaValue(ctx, 'message_migration_done', true) + await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) + + if (!result.passed) { + throw new Error('ChatLuna migration validation failed.') + } + + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('migration-visible') + ) + + ctx.logger.info('Built-in ChatLuna migration finished.') + return result +} + +export async function ensureMigrationValidated(ctx: Context, config: Config) { + const result = await readMetaValue< + Awaited> + >(ctx, 'validation_result') + + if (result?.passed === true) { + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('migration-visible') + ) + return result + } + + const schemaVersion = + (await readMetaValue(ctx, 'schema_version')) ?? 0 + const roomDone = + (await readMetaValue(ctx, 'room_migration_done')) ?? false + const messageDone = + (await readMetaValue(ctx, 'message_migration_done')) ?? false + + if ( + schemaVersion < BUILTIN_SCHEMA_VERSION || + roomDone !== true || + messageDone !== true + ) { + return runRoomToConversationMigration(ctx, config) + } + + const validated = await validateRoomMigration(ctx, config) + await writeMetaValue(ctx, 'validation_result', validated) + + if (!validated.passed) { + throw new Error('ChatLuna migration validation failed.') + } + + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('migration-visible') + ) + + return validated +} + +async function migrateRooms(ctx: Context) { + const done = + (await readMetaValue(ctx, 'room_migration_done')) ?? false + + if (done) { + return + } + + const rooms = ( + (await ctx.database.get('chathub_room', {})) as LegacyRoomRecord[] + ) + .filter( + (room) => + room.conversationId != null && + room.conversationId !== '' && + room.conversationId !== '0' + ) + .sort((a, b) => a.roomId - b.roomId) + const oldConversations = (await ctx.database.get( + 'chathub_conversation', + {} + )) as LegacyConversationRecord[] + const members = (await ctx.database.get( + 'chathub_room_member', + {} + )) as LegacyRoomMemberRecord[] + const groups = (await ctx.database.get( + 'chathub_room_group_member', + {} + )) as LegacyRoomGroupRecord[] + const users = (await ctx.database.get( + 'chathub_user', + {} + )) as LegacyUserRecord[] + const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) + const existing = (await ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + const existingMap = new Map(existing.map((item) => [item.id, item])) + const progress = (await readMetaValue( + ctx, + 'conversation_migration_progress' + )) ?? { + lastRoomId: 0, + migrated: existing.length + } + + let seq = existing.reduce((max, item) => Math.max(max, item.seq ?? 0), 0) + + for (const room of rooms) { + if (room.roomId <= progress.lastRoomId) { + continue + } + + const oldConversation = oldConversations.find( + (item) => item.id === room.conversationId + ) + const current = existingMap.get(room.conversationId as string) + const roomMembers = members.filter( + (item) => item.roomId === room.roomId + ) + const roomGroups = groups.filter((item) => item.roomId === room.roomId) + const bindingKey = resolveRoomBindingKey( + room, + roomMembers, + roomGroups, + routeModes + ) + const updatedAt = + oldConversation?.updatedAt != null && + oldConversation.updatedAt.getTime() > room.updatedTime.getTime() + ? oldConversation.updatedAt + : room.updatedTime + + const conversationSeq = current?.seq ?? seq + 1 + if (current?.seq == null) { + seq = conversationSeq + } + + const conversation: ConversationRecord = { + id: room.conversationId as string, + seq: conversationSeq, + bindingKey, + title: room.roomName, + model: room.model, + preset: room.preset, + chatMode: room.chatMode, + createdBy: room.roomMasterId, + createdAt: room.updatedTime, + updatedAt, + lastChatAt: oldConversation?.updatedAt ?? room.updatedTime, + status: 'active', + latestMessageId: oldConversation?.latestId ?? null, + additional_kwargs: oldConversation?.additional_kwargs ?? null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: room.roomId, + legacyMeta: JSON.stringify({ + visibility: room.visibility, + password: room.password ?? null, + autoUpdate: room.autoUpdate ?? false, + roomMasterId: room.roomMasterId, + groups: roomGroups.map((item) => item.groupId), + members: roomMembers.map((item) => ({ + userId: item.userId, + roomPermission: item.roomPermission, + mute: item.mute ?? false + })) + }) + } + + await ctx.database.upsert('chatluna_conversation', [conversation]) + existingMap.set(conversation.id, conversation) + + const acl = buildAclRecords(room, roomMembers, roomGroups) + if (acl.length > 0) { + await ctx.database.upsert('chatluna_acl', acl) + } + + progress.lastRoomId = room.roomId + progress.migrated += 1 + await writeMetaValue(ctx, 'conversation_migration_progress', progress) + } +} + +async function migrateMessages(ctx: Context) { + const done = + (await readMetaValue(ctx, 'message_migration_done')) ?? false + + if (done) { + return + } + + const oldMessages = (await ctx.database.get( + 'chathub_message', + {} + )) as LegacyMessageRecord[] + const progress = (await readMetaValue( + ctx, + 'message_migration_progress' + )) ?? { + index: 0, + migrated: 0 + } + + const batchSize = 500 + + for (let i = progress.index; i < oldMessages.length; i += batchSize) { + const batch = oldMessages.slice(i, i + batchSize) + const payload = batch.map((item) => ({ + id: item.id, + conversationId: item.conversation, + parentId: item.parent ?? null, + role: item.role, + text: typeof item.text === 'string' ? item.text : null, + content: item.content ?? null, + name: item.name ?? null, + tool_call_id: item.tool_call_id ?? null, + tool_calls: item.tool_calls, + additional_kwargs: item.additional_kwargs ?? null, + additional_kwargs_binary: item.additional_kwargs_binary ?? null, + rawId: item.rawId ?? null, + createdAt: null + })) satisfies MessageRecord[] + + if (payload.length > 0) { + await ctx.database.upsert('chatluna_message', payload) + } + + progress.index = i + batch.length + progress.lastId = batch[batch.length - 1]?.id + progress.migrated += batch.length + await writeMetaValue(ctx, 'message_migration_progress', progress) + } +} + +async function migrateBindings(ctx: Context) { + const users = ( + (await ctx.database.get('chathub_user', {})) as LegacyUserRecord[] + ).sort((a, b) => + `${a.groupId ?? '0'}:${a.userId}`.localeCompare( + `${b.groupId ?? '0'}:${b.userId}` + ) + ) + const conversations = (await ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + const rooms = (await ctx.database.get( + 'chathub_room', + {} + )) as LegacyRoomRecord[] + const groups = (await ctx.database.get( + 'chathub_room_group_member', + {} + )) as LegacyRoomGroupRecord[] + const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) + const progress = (await readMetaValue( + ctx, + 'binding_migration_progress' + )) ?? { + index: 0, + migrated: 0 + } + + for (let i = progress.index; i < users.length; i++) { + const user = users[i] + const conversation = conversations.find( + (item) => item.legacyRoomId === user.defaultRoomId + ) + + progress.index = i + 1 + + if (conversation == null) { + await writeMetaValue(ctx, 'binding_migration_progress', progress) + continue + } + + const bindingKey = createLegacyBindingKey(user, routeModes) + const current = ( + (await ctx.database.get('chatluna_binding', { + bindingKey + })) as BindingRecord[] + )[0] + + await ctx.database.upsert('chatluna_binding', [ + { + bindingKey, + activeConversationId: conversation.id, + lastConversationId: + current?.activeConversationId && + current.activeConversationId !== conversation.id + ? current.activeConversationId + : (current?.lastConversationId ?? null), + updatedAt: new Date() + } + ]) + + progress.migrated += 1 + await writeMetaValue(ctx, 'binding_migration_progress', progress) + } +} + +function resolveRoomBindingKey( + room: LegacyRoomRecord, + members: LegacyRoomMemberRecord[], + groups: LegacyRoomGroupRecord[], + routeModes: Map +) { + if (isComplexRoom(room, members, groups)) { + return `custom:legacy:room:${room.roomId}` + } + + const groupId = groups[0]?.groupId + const userId = members[0]?.userId ?? room.roomMasterId + + if (groupId == null || groupId.length === 0) { + return `personal:legacy:legacy:direct:${userId}` + } + + if ( + groups.length === 1 && + (room.visibility === 'public' || room.visibility === 'template_clone') + ) { + return `shared:legacy:legacy:${groupId}` + } + + if ((routeModes.get(groupId) ?? 'personal') === 'shared') { + return `shared:legacy:legacy:${groupId}` + } + + return `personal:legacy:legacy:${groupId}:${userId}` +} + +function buildAclRecords( + room: LegacyRoomRecord, + members: LegacyRoomMemberRecord[], + groups: LegacyRoomGroupRecord[] +) { + if (!isComplexRoom(room, members, groups)) { + return [] + } + + const conversationId = room.conversationId as string + const map = new Map() + + for (const member of members) { + addAclRecord(map, { + conversationId, + principalType: 'user', + principalId: member.userId, + permission: 'view' + }) + + if ( + member.roomPermission === 'owner' || + member.roomPermission === 'admin' + ) { + addAclRecord(map, { + conversationId, + principalType: 'user', + principalId: member.userId, + permission: 'manage' + }) + } + } + + for (const group of groups) { + addAclRecord(map, { + conversationId, + principalType: 'guild', + principalId: group.groupId, + permission: 'view' + }) + } + + addAclRecord(map, { + conversationId, + principalType: 'user', + principalId: room.roomMasterId, + permission: 'manage' + }) + + return Array.from(map.values()) +} + +function addAclRecord(map: Map, record: ACLRecord) { + map.set( + `${record.conversationId}:${record.principalType}:${record.principalId}:${record.permission}`, + record + ) +} diff --git a/packages/core/src/migration/validators.ts b/packages/core/src/migration/validators.ts new file mode 100644 index 000000000..21816546a --- /dev/null +++ b/packages/core/src/migration/validators.ts @@ -0,0 +1,421 @@ +import type { Context } from 'koishi' +import type { Config } from '../config' +export { + getLegacySchemaSentinelDir, + getLegacySchemaSentinel, + LEGACY_MIGRATION_TABLES, + LEGACY_RETENTION_META_KEY, + LEGACY_RUNTIME_TABLES, + LEGACY_SCHEMA_SENTINEL, + createLegacyTableRetention +} from './legacy_tables' +import type { + ACLRecord, + BindingRecord, + ConversationRecord, + MessageRecord, + MetaRecord +} from '../services/conversation_types' +import type { + LegacyConversationRecord, + LegacyMessageRecord, + LegacyRoomGroupRecord, + LegacyRoomMemberRecord, + LegacyRoomRecord, + LegacyUserRecord +} from '../services/types' +export interface MigrationValidationResult { + passed: boolean + checkedAt: string + conversation: { + legacy: number + migrated: number + matched: boolean + } + message: { + legacy: number + migrated: number + matched: boolean + } + latestMessageId: { + missingConversationIds: string[] + matched: boolean + } + bindingKey: { + inconsistentConversationIds: string[] + matched: boolean + } + binding: { + missingBindingKeys: string[] + missingConversationIds: string[] + matched: boolean + } + acl: { + expected: number + migrated: number + missing: string[] + matched: boolean + } +} + +export async function validateRoomMigration(ctx: Context, config: Config) { + const rooms = (await ctx.database.get( + 'chathub_room', + {} + )) as LegacyRoomRecord[] + const oldConversations = (await ctx.database.get( + 'chathub_conversation', + {} + )) as LegacyConversationRecord[] + const oldMessages = (await ctx.database.get( + 'chathub_message', + {} + )) as LegacyMessageRecord[] + const users = (await ctx.database.get( + 'chathub_user', + {} + )) as LegacyUserRecord[] + const members = (await ctx.database.get( + 'chathub_room_member', + {} + )) as LegacyRoomMemberRecord[] + const groups = (await ctx.database.get( + 'chathub_room_group_member', + {} + )) as LegacyRoomGroupRecord[] + const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) + const conversations = (await ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + const messages = (await ctx.database.get( + 'chatluna_message', + {} + )) as MessageRecord[] + const bindings = (await ctx.database.get( + 'chatluna_binding', + {} + )) as BindingRecord[] + const acl = (await ctx.database.get('chatluna_acl', {})) as ACLRecord[] + + const validRooms = rooms.filter( + (room) => + room.conversationId != null && + room.conversationId !== '' && + room.conversationId !== '0' + ) + const validConversationIds = new Set( + validRooms.map((room) => room.conversationId as string) + ) + const migratedLegacyConversations = conversations.filter( + (item) => item.legacyRoomId != null || validConversationIds.has(item.id) + ) + const migratedLegacyMessages = messages.filter( + (item) => item.createdAt == null + ) + const migratedMessageIds = new Set(messages.map((item) => item.id)) + const migratedBindingKeys = new Set(bindings.map((item) => item.bindingKey)) + const migratedAclKeys = new Set( + acl.map( + (item) => + `${item.conversationId}:${item.principalType}:${item.principalId}:${item.permission}` + ) + ) + + const missingLatestMessageIds = migratedLegacyConversations + .filter( + (item) => + item.latestMessageId != null && + !migratedMessageIds.has(item.latestMessageId) + ) + .map((item) => item.id) + const inconsistentBindingConversationIds = validRooms + .map((room) => { + const roomMembers = members.filter( + (item) => item.roomId === room.roomId + ) + const roomGroups = groups.filter( + (item) => item.roomId === room.roomId + ) + const conversation = migratedLegacyConversations.find( + (item) => item.id === room.conversationId + ) + + if (conversation == null) { + return null + } + + const bindingKey = resolveConversationBindingKey( + room, + roomMembers, + roomGroups, + routeModes + ) + + return conversation.bindingKey === bindingKey + ? null + : conversation.id + }) + .filter((id) => id != null) + + const missingBindingKeys: string[] = [] + const missingBindingConversationIds: string[] = [] + + for (const user of users) { + const conversation = conversations.find( + (item) => item.legacyRoomId === user.defaultRoomId + ) + + if (conversation == null) { + missingBindingConversationIds.push(String(user.defaultRoomId)) + continue + } + + const bindingKey = createLegacyBindingKey(user, routeModes) + if (!migratedBindingKeys.has(bindingKey)) { + missingBindingKeys.push(bindingKey) + } + } + + const expectedAclKeys: string[] = [] + + for (const room of validRooms) { + const roomMembers = members.filter( + (item) => item.roomId === room.roomId + ) + const roomGroups = groups.filter((item) => item.roomId === room.roomId) + + if (!isComplexRoom(room, roomMembers, roomGroups)) { + continue + } + + const conversationId = room.conversationId as string + + for (const member of roomMembers) { + expectedAclKeys.push(`${conversationId}:user:${member.userId}:view`) + + if ( + member.roomPermission === 'owner' || + member.roomPermission === 'admin' + ) { + expectedAclKeys.push( + `${conversationId}:user:${member.userId}:manage` + ) + } + } + + for (const group of roomGroups) { + expectedAclKeys.push( + `${conversationId}:guild:${group.groupId}:view` + ) + } + + if (room.roomMasterId.length > 0) { + expectedAclKeys.push( + `${conversationId}:user:${room.roomMasterId}:manage` + ) + } + } + + const missingAclKeys = expectedAclKeys.filter( + (key) => !migratedAclKeys.has(key) + ) + + return { + passed: + validConversationIds.size === migratedLegacyConversations.length && + oldMessages.length === migratedLegacyMessages.length && + missingLatestMessageIds.length === 0 && + inconsistentBindingConversationIds.length === 0 && + missingBindingKeys.length === 0 && + missingBindingConversationIds.length === 0 && + missingAclKeys.length === 0, + checkedAt: new Date().toISOString(), + conversation: { + legacy: validConversationIds.size, + migrated: migratedLegacyConversations.length, + matched: + validConversationIds.size === migratedLegacyConversations.length + }, + message: { + legacy: oldMessages.length, + migrated: migratedLegacyMessages.length, + matched: oldMessages.length === migratedLegacyMessages.length + }, + latestMessageId: { + missingConversationIds: missingLatestMessageIds, + matched: missingLatestMessageIds.length === 0 + }, + bindingKey: { + inconsistentConversationIds: inconsistentBindingConversationIds, + matched: inconsistentBindingConversationIds.length === 0 + }, + binding: { + missingBindingKeys, + missingConversationIds: missingBindingConversationIds, + matched: + missingBindingKeys.length === 0 && + missingBindingConversationIds.length === 0 + }, + acl: { + expected: expectedAclKeys.length, + migrated: acl.length, + missing: missingAclKeys, + matched: missingAclKeys.length === 0 + } + } satisfies MigrationValidationResult +} + +export async function readMetaValue(ctx: Context, key: string) { + const row = ( + (await ctx.database.get('chatluna_meta', { + key + })) as MetaRecord[] + )[0] + + if (row?.value == null || row.value === '') { + return undefined as T | undefined + } + + return JSON.parse(row.value) as T +} + +export async function writeMetaValue( + ctx: Context, + key: string, + value: unknown +) { + await ctx.database.upsert('chatluna_meta', [ + { + key, + value: value == null ? null : JSON.stringify(value), + updatedAt: new Date() + } + ]) +} + +export function createLegacyBindingKey( + user: LegacyUserRecord, + routeModes: 'shared' | 'personal' | Map +) { + if (user.groupId == null || user.groupId === '' || user.groupId === '0') { + return `personal:legacy:legacy:direct:${user.userId}` + } + + const routeMode = + routeModes instanceof Map + ? (routeModes.get(user.groupId) ?? 'personal') + : routeModes + + if (routeMode === 'shared') { + return `shared:legacy:legacy:${user.groupId}` + } + + return `personal:legacy:legacy:${user.groupId}:${user.userId}` +} + +export function isComplexRoom( + room: LegacyRoomRecord, + members: LegacyRoomMemberRecord[], + groups: LegacyRoomGroupRecord[] +) { + return ( + members.length > 2 || + groups.length > 1 || + (room.password != null && room.password.length > 0) + ) +} + +export function inferLegacyGroupRouteModes( + users: LegacyUserRecord[], + rooms: LegacyRoomRecord[], + groups: LegacyRoomGroupRecord[] +) { + const modes = new Map() + const publicGroups = new Set() + const roomIds = new Map() + + for (const group of groups) { + if ( + group.roomVisibility === 'public' || + group.roomVisibility === 'template_clone' + ) { + publicGroups.add(group.groupId) + modes.set(group.groupId, 'shared') + } + } + + for (const user of users) { + if ( + user.groupId == null || + user.groupId === '' || + user.groupId === '0' + ) { + continue + } + + if (publicGroups.has(user.groupId)) { + continue + } + + const room = rooms.find((item) => item.roomId === user.defaultRoomId) + if (room == null) { + continue + } + + if ( + room.visibility === 'public' || + room.visibility === 'template_clone' + ) { + modes.set(user.groupId, 'shared') + continue + } + + const previous = modes.get(user.groupId) + if (previous == null) { + roomIds.set(user.groupId, room.roomId) + modes.set(user.groupId, 'shared') + continue + } + + if ( + previous === 'shared' && + roomIds.get(user.groupId) !== room.roomId + ) { + modes.set(user.groupId, 'personal') + } + } + + return modes +} + +function resolveConversationBindingKey( + room: LegacyRoomRecord, + members: LegacyRoomMemberRecord[], + groups: LegacyRoomGroupRecord[], + routeModes: Map +) { + if (isComplexRoom(room, members, groups)) { + return `custom:legacy:room:${room.roomId}` + } + + const groupId = groups[0]?.groupId + const userId = members[0]?.userId ?? room.roomMasterId + + if (groupId == null || groupId.length === 0) { + return `personal:legacy:legacy:direct:${userId}` + } + + if ( + groups.length === 1 && + (room.visibility === 'public' || room.visibility === 'template_clone') + ) { + return `shared:legacy:legacy:${groupId}` + } + + if ((routeModes.get(groupId) ?? 'personal') === 'shared') { + return `shared:legacy:legacy:${groupId}` + } + + return `personal:legacy:legacy:${groupId}:${userId}` +} diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index 94be56139..c24e3850b 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -1,9 +1,7 @@ -import { - AIMessage, - HumanMessage, - type UsageMetadata -} from '@langchain/core/messages' +import { type UsageMetadata } from '@langchain/core/messages' +import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import fs from 'fs' +import path from 'path' import { Awaitable, Computed, @@ -14,8 +12,6 @@ import { Session } from 'koishi' import { ChatInterface } from 'koishi-plugin-chatluna/llm-core/chat/app' -import path from 'path' -import { LRUCache } from 'lru-cache' import { Cache } from '../cache' import { ChatChain } from '../chains/chain' import { ChatLunaLLMChainWrapper } from 'koishi-plugin-chatluna/llm-core/chain/base' @@ -48,16 +44,17 @@ import { ModelType, PlatformClientNames } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import { PresetService } from 'koishi-plugin-chatluna/preset' -import { ConversationRoom, Message } from '../types' +import { Message } from '../types' import { ChatLunaError, ChatLunaErrorCode } from 'koishi-plugin-chatluna/utils/error' -import { RequestIdQueue } from 'koishi-plugin-chatluna/utils/queue' import { MessageTransformer } from './message_transform' import { ChatEvents, ToolMaskArg, ToolMaskResolver } from './types' +import { ConversationService } from './conversation' +import { ConversationRuntime } from './conversation_runtime' +import { ConversationRecord } from './conversation_types' import { chatLunaFetch, ws } from 'koishi-plugin-chatluna/utils/request' import * as fetchType from 'undici/types/fetch' import { ClientOptions, WebSocket } from 'ws' @@ -75,10 +72,13 @@ import { randomUUID } from 'crypto' import type { Notifier } from '@koishijs/plugin-notifier' import { ChatLunaContextManagerService } from 'koishi-plugin-chatluna/llm-core/prompt' import { createChatPrompt } from 'koishi-plugin-chatluna/utils/chatluna' +import { + LEGACY_MIGRATION_TABLES, + getLegacySchemaSentinel +} from '../migration/legacy_tables' export class ChatLunaService extends Service { private _plugins: Record = {} - private _chatInterfaceWrapper: ChatInterfaceWrapper private readonly _chain: ChatChain private readonly _keysCache: Cache<'chatluna/keys', string> private readonly _preset: PresetService @@ -87,6 +87,8 @@ export class ChatLunaService extends Service { private readonly _renderer: DefaultRenderer private readonly _promptRenderer: ChatLunaPromptRenderService private readonly _contextManager: ChatLunaContextManagerService + private readonly _conversation: ConversationService + private readonly _conversationRuntime: ConversationRuntime private _toolMaskResolvers: Record = {} declare public config: Config @@ -108,6 +110,8 @@ export class ChatLunaService extends Service { this._renderer = new DefaultRenderer(ctx, config) this._promptRenderer = new ChatLunaPromptRenderService() this._contextManager = new ChatLunaContextManagerService(ctx) + this._conversation = new ConversationService(ctx, config) + this._conversationRuntime = new ConversationRuntime(this) this._createTempDir() this._defineDatabase() @@ -193,7 +197,7 @@ export class ChatLunaService extends Service { const platform = targetPlugin.platformName - this._chatInterfaceWrapper?.dispose(platform) + this._conversationRuntime.dispose(platform) delete this._plugins[platform] @@ -224,12 +228,9 @@ export class ChatLunaService extends Service { return this._plugins[platformName] } - /** - * @internal - */ chat( session: Session, - room: ConversationRoom, + conversation: ConversationRecord, message: Message, event: ChatEvents, stream: boolean = false, @@ -239,78 +240,45 @@ export class ChatLunaService extends Service { requestId: string = randomUUID(), toolMask?: ToolMask ) { - const chatInterfaceWrapper = - this._chatInterfaceWrapper ?? this._createChatInterfaceWrapper() - - return chatInterfaceWrapper.chat( + return this._conversationRuntime.chat( session, - room, + conversation, message, event, stream, - requestId, variables, postHandler, + requestId, toolMask ) } - async stopChat(room: ConversationRoom, requestId: string) { - const chatInterfaceWrapper = this.queryInterfaceWrapper(room, false) - - if (chatInterfaceWrapper == null) { - return undefined - } - - return chatInterfaceWrapper.stopChat(requestId) - } - - appendPendingMessage( - conversationId: string, - message: HumanMessage, - chatMode?: string - ) { - if (this._chatInterfaceWrapper == null) { - return false - } - - return this._chatInterfaceWrapper.appendPendingMessage( - conversationId, - message, - chatMode - ) - } - - queryInterfaceWrapper(room: ConversationRoom, autoCreate: boolean = true) { - return ( - this._chatInterfaceWrapper ?? - (autoCreate ? this._createChatInterfaceWrapper() : undefined) + async clearCache(conversation: ConversationRecord) { + return this._conversationRuntime.clearConversationInterface( + conversation ) } - async clearChatHistory(room: ConversationRoom) { - const chatBridger = - this._chatInterfaceWrapper ?? this._createChatInterfaceWrapper() - - return chatBridger.clearChatHistory(room) - } - - async compressContext(room: ConversationRoom, force = false) { - const chatBridger = - this._chatInterfaceWrapper ?? this._createChatInterfaceWrapper() - - return chatBridger.compressContext(room, force) - } - - getCachedInterfaceWrapper() { - return this._chatInterfaceWrapper - } - - async clearCache(room: ConversationRoom) { - const chatBridger = - this._chatInterfaceWrapper ?? this._createChatInterfaceWrapper() + async createChatInterface(conversation: ConversationRecord) { + const config = this.currentConfig + const chatInterface = new ChatInterface(this.ctx.root, { + chatMode: conversation.chatMode, + botName: config.botNames[0], + preset: this.preset.getPreset(conversation.preset), + model: conversation.model, + conversationId: conversation.id, + embeddings: + config.defaultEmbeddings && config.defaultEmbeddings.length > 0 + ? config.defaultEmbeddings + : undefined, + vectorStoreName: + config.defaultVectorStore && + config.defaultVectorStore.length > 0 + ? config.defaultVectorStore + : undefined + }) - return chatBridger.clearCache(room) + return chatInterface } async createChatModel( @@ -458,8 +426,16 @@ export class ChatLunaService extends Service { return this._contextManager } + get conversation() { + return this._conversation + } + + get conversationRuntime() { + return this._conversationRuntime + } + protected async stop(): Promise { - this._chatInterfaceWrapper?.dispose() + this._conversationRuntime.dispose() this._platformService.dispose() this._contextManager.clearAll() } @@ -475,15 +451,286 @@ export class ChatLunaService extends Service { private _defineDatabase() { const ctx = this.ctx + const legacyTablesVisible = !fs.existsSync( + getLegacySchemaSentinel(this.ctx.baseDir) + ) + + if (legacyTablesVisible && LEGACY_MIGRATION_TABLES.length > 0) { + ctx.database.extend( + 'chathub_conversation', + { + id: { + type: 'char', + length: 255 + }, + latestId: { + type: 'char', + length: 255, + nullable: true + }, + additional_kwargs: { + type: 'text', + nullable: true + }, + updatedAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + } + }, + { + autoInc: false, + primary: 'id', + unique: ['id'] + } + ) + + ctx.database.extend( + 'chathub_message', + { + id: { + type: 'char', + length: 255 + }, + text: { + type: 'text', + nullable: true + }, + content: { + type: 'binary', + nullable: true + }, + parent: { + type: 'char', + length: 255, + nullable: true + }, + role: { + type: 'char', + length: 20 + }, + conversation: { + type: 'char', + length: 255 + }, + additional_kwargs: { + type: 'text', + nullable: true + }, + additional_kwargs_binary: { + type: 'binary', + nullable: true + }, + tool_call_id: 'string', + tool_calls: { + type: 'text', + nullable: true, + dump: (value) => + value == null ? null : JSON.stringify(value), + load: (value) => { + if (value == null || value === '') { + return undefined + } + + try { + return JSON.parse(String(value)) + } catch { + return undefined + } + } + }, + name: { + type: 'char', + length: 255, + nullable: true + }, + rawId: { + type: 'char', + length: 255, + nullable: true + } + }, + { + autoInc: false, + primary: 'id', + unique: ['id'] + } + ) + + ctx.database.extend( + 'chathub_room', + { + roomId: { + type: 'integer' + }, + roomName: 'string', + conversationId: { + type: 'char', + length: 255, + nullable: true + }, + roomMasterId: { + type: 'char', + length: 255 + }, + visibility: { + type: 'char', + length: 20 + }, + preset: { + type: 'char', + length: 255 + }, + model: { + type: 'char', + length: 100 + }, + chatMode: { + type: 'char', + length: 20 + }, + password: { + type: 'char', + length: 100, + nullable: true + }, + autoUpdate: { + type: 'boolean', + initial: false + }, + updatedTime: { + type: 'timestamp', + nullable: false, + initial: new Date() + } + }, + { + autoInc: false, + primary: 'roomId', + unique: ['roomId'] + } + ) + + ctx.database.extend( + 'chathub_room_member', + { + userId: { + type: 'string', + length: 255 + }, + roomId: { + type: 'integer' + }, + roomPermission: { + type: 'char', + length: 50 + }, + mute: { + type: 'boolean', + initial: false + } + }, + { + autoInc: false, + primary: ['userId', 'roomId'] + } + ) + + ctx.database.extend( + 'chathub_room_group_member', + { + groupId: { + type: 'char', + length: 255 + }, + roomId: { + type: 'integer' + }, + roomVisibility: { + type: 'char', + length: 20 + } + }, + { + autoInc: false, + primary: ['groupId', 'roomId'] + } + ) + + ctx.database.extend( + 'chathub_user', + { + userId: { + type: 'char', + length: 255 + }, + defaultRoomId: { + type: 'integer' + }, + groupId: { + type: 'char', + length: 255, + nullable: true + } + }, + { + autoInc: false, + primary: ['userId', 'groupId'] + } + ) + } ctx.database.extend( - 'chathub_conversation', + 'chatluna_conversation', { id: { type: 'char', length: 255 }, - latestId: { + seq: { + type: 'unsigned', + nullable: true + }, + bindingKey: { + type: 'string', + length: 255 + }, + title: 'string', + model: { + type: 'char', + length: 100 + }, + preset: { + type: 'char', + length: 255 + }, + chatMode: { + type: 'char', + length: 20 + }, + createdBy: { + type: 'char', + length: 255 + }, + createdAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + }, + updatedAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + }, + lastChatAt: { + type: 'timestamp', + nullable: true + }, + status: { + type: 'char', + length: 20 + }, + latestMessageId: { type: 'char', length: 255, nullable: true @@ -492,10 +739,26 @@ export class ChatLunaService extends Service { type: 'text', nullable: true }, - updatedAt: { + compression: { + type: 'text', + nullable: true + }, + archivedAt: { type: 'timestamp', - nullable: false, - initial: new Date() + nullable: true + }, + archiveId: { + type: 'char', + length: 255, + nullable: true + }, + legacyRoomId: { + type: 'unsigned', + nullable: true + }, + legacyMeta: { + type: 'text', + nullable: true } }, { @@ -506,12 +769,25 @@ export class ChatLunaService extends Service { ) ctx.database.extend( - 'chathub_message', + 'chatluna_message', { id: { type: 'char', length: 255 }, + conversationId: { + type: 'char', + length: 255 + }, + parentId: { + type: 'char', + length: 255, + nullable: true + }, + role: { + type: 'char', + length: 20 + }, text: { type: 'text', nullable: true @@ -520,18 +796,28 @@ export class ChatLunaService extends Service { type: 'binary', nullable: true }, - parent: { + name: { type: 'char', length: 255, nullable: true }, - role: { - type: 'char', - length: 20 - }, - conversation: { - type: 'char', - length: 255 + tool_call_id: 'string', + tool_calls: { + type: 'text', + nullable: true, + dump: (value) => + value == null ? null : JSON.stringify(value), + load: (value) => { + if (value == null || value === '') { + return undefined + } + + try { + return JSON.parse(String(value)) + } catch { + return undefined + } + } }, additional_kwargs: { type: 'text', @@ -541,16 +827,13 @@ export class ChatLunaService extends Service { type: 'binary', nullable: true }, - tool_call_id: 'string', - tool_calls: 'json', - name: { + rawId: { type: 'char', length: 255, nullable: true }, - rawId: { - type: 'char', - length: 255, + createdAt: { + type: 'timestamp', nullable: true } }, @@ -558,131 +841,269 @@ export class ChatLunaService extends Service { autoInc: false, primary: 'id', unique: ['id'] - /* foreign: { - conversation: ['chathub_conversaion', 'id'] - } */ } ) ctx.database.extend( - 'chathub_room', + 'chatluna_binding', { - roomId: { - type: 'integer' + bindingKey: { + type: 'string', + length: 255 }, - roomName: 'string', - conversationId: { + activeConversationId: { type: 'char', length: 255, nullable: true }, + lastConversationId: { + type: 'char', + length: 255, + nullable: true + }, + updatedAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + } + }, + { + autoInc: false, + primary: 'bindingKey', + unique: ['bindingKey'] + } + ) - roomMasterId: { + ctx.database.extend( + 'chatluna_constraint', + { + id: 'unsigned', + name: 'string', + enabled: { + type: 'boolean', + initial: true + }, + priority: { + type: 'integer', + initial: 0 + }, + createdBy: { type: 'char', length: 255 }, - visibility: { + createdAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + }, + updatedAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + }, + platform: { type: 'char', - length: 20 + length: 255, + nullable: true }, - preset: { + selfId: { type: 'char', - length: 255 + length: 255, + nullable: true }, - model: { + guildId: { type: 'char', - length: 100 + length: 255, + nullable: true }, - chatMode: { + channelId: { type: 'char', - length: 20 + length: 255, + nullable: true + }, + direct: { + type: 'boolean', + nullable: true + }, + users: { + type: 'text', + nullable: true }, - password: { + excludeUsers: { + type: 'text', + nullable: true + }, + routeMode: { type: 'char', - length: 100 + length: 20, + nullable: true + }, + routeKey: { + type: 'string', + length: 255, + nullable: true }, - autoUpdate: { + defaultModel: { + type: 'char', + length: 100, + nullable: true + }, + defaultPreset: { + type: 'char', + length: 255, + nullable: true + }, + defaultChatMode: { + type: 'char', + length: 20, + nullable: true + }, + fixedModel: { + type: 'char', + length: 100, + nullable: true + }, + fixedPreset: { + type: 'char', + length: 255, + nullable: true + }, + fixedChatMode: { + type: 'char', + length: 20, + nullable: true + }, + lockConversation: { type: 'boolean', - initial: false + nullable: true }, - updatedTime: { - type: 'timestamp', - nullable: false, - initial: new Date() + allowNew: { + type: 'boolean', + nullable: true + }, + allowSwitch: { + type: 'boolean', + nullable: true + }, + allowArchive: { + type: 'boolean', + nullable: true + }, + allowExport: { + type: 'boolean', + nullable: true + }, + manageMode: { + type: 'char', + length: 20, + nullable: true } }, { - autoInc: false, - primary: 'roomId', - unique: ['roomId'] + autoInc: true, + primary: 'id' } ) ctx.database.extend( - 'chathub_room_member', + 'chatluna_archive', { - userId: { - type: 'string', + id: { + type: 'char', length: 255 }, - roomId: { - type: 'integer' + conversationId: { + type: 'char', + length: 255 + }, + path: 'string', + formatVersion: { + type: 'unsigned' + }, + messageCount: { + type: 'unsigned' + }, + checksum: { + type: 'char', + length: 255, + nullable: true + }, + size: { + type: 'unsigned' }, - roomPermission: { + state: { type: 'char', - length: 50 + length: 20 }, - mute: { - type: 'boolean', - initial: false + createdAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + }, + restoredAt: { + type: 'timestamp', + nullable: true } }, { autoInc: false, - primary: ['userId', 'roomId'] + primary: 'id', + unique: ['id'] } ) ctx.database.extend( - 'chathub_room_group_member', + 'chatluna_acl', { - groupId: { + conversationId: { type: 'char', length: 255 }, - roomId: { - type: 'integer' + principalType: { + type: 'char', + length: 20 + }, + principalId: { + type: 'char', + length: 255 }, - roomVisibility: { + permission: { type: 'char', length: 20 } }, { autoInc: false, - primary: ['groupId', 'roomId'] + primary: [ + 'conversationId', + 'principalType', + 'principalId', + 'permission' + ] } ) ctx.database.extend( - 'chathub_user', + 'chatluna_meta', { - userId: { - type: 'char', + key: { + type: 'string', length: 255 }, - defaultRoomId: { - type: 'integer' - }, - groupId: { - type: 'char', - length: 255, + value: { + type: 'text', nullable: true + }, + updatedAt: { + type: 'timestamp', + nullable: false, + initial: new Date() } }, { autoInc: false, - primary: ['userId', 'groupId'] + primary: 'key', + unique: ['key'] } ) @@ -708,12 +1129,6 @@ export class ChatLunaService extends Service { ) } - private _createChatInterfaceWrapper(): ChatInterfaceWrapper { - const chatBridger = new ChatInterfaceWrapper(this) - this._chatInterfaceWrapper = chatBridger - return chatBridger - } - static inject = ['database'] } @@ -958,442 +1373,6 @@ export class ChatLunaPlugin< } } -type ChatHubChatBridgerInfo = { - chatInterface: ChatInterface - room: ConversationRoom -} - -type ActiveRequest = { - requestId: string - abortController: AbortController - chatMode: string - messageQueue: MessageQueue - roundDecisionResolvers: ((canContinue: boolean) => void)[] - lastDecision?: boolean -} - -function createAbortError() { - return new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) -} - -class ChatInterfaceWrapper { - private _conversations: LRUCache = - new LRUCache({ - max: 20 - }) - - private _modelQueue = new RequestIdQueue() - private _conversationQueue = new RequestIdQueue() - private _platformService: PlatformService - - private _requestIdMap: Map = new Map() - private _activeRequests: Map = new Map() - private _platformToConversations: Map = new Map() - - constructor(private _service: ChatLunaService) { - this._platformService = _service.platform - } - - async chat( - session: Session, - room: ConversationRoom, - message: Message, - event: ChatEvents, - stream: boolean, - requestId: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - variables: Record = {}, - postHandler?: PostHandler, - toolMask?: ToolMask - ): Promise { - const { conversationId, model: fullModelName } = room - - const [platform] = parseRawModelName(fullModelName) - const client = await this._platformService.getClient(platform) - if (client.value == null) { - await this._service.awaitLoadPlatform(platform) - } - if (client.value == null) { - throw new ChatLunaError( - ChatLunaErrorCode.UNKNOWN_ERROR, - new Error(`Platform ${platform} is not available`) - ) - } - const config = client.value.configPool.getConfig(true).value - - try { - // Add to queues - await Promise.all([ - this._conversationQueue.add(conversationId, requestId), - this._modelQueue.add(platform, requestId) - ]) - - const currentQueueLength = - await this._conversationQueue.getQueueLength(conversationId) - await event['llm-queue-waiting'](currentQueueLength) - - // Wait for our turn - await Promise.all([ - this._conversationQueue.wait(conversationId, requestId, 0), - this._modelQueue.wait( - platform, - requestId, - config.concurrentMaxSize - ) - ]) - - // Track conversation - const conversationIds = - this._platformToConversations.get(platform) ?? [] - conversationIds.push(conversationId) - this._platformToConversations.set(platform, conversationIds) - - const { chatInterface } = - this._conversations.get(conversationId) ?? - (await this._createChatInterface(room)) - - const abortController = new AbortController() - const activeRequest: ActiveRequest = { - requestId, - abortController, - chatMode: room.chatMode, - messageQueue: new MessageQueue(), - roundDecisionResolvers: [] - } - - this._requestIdMap.set(requestId, abortController) - this._activeRequests.set(conversationId, activeRequest) - - const humanMessage = new HumanMessage({ - content: message.content, - name: message.name, - id: session.userId, - additional_kwargs: { - ...message.additional_kwargs, - preset: room.preset - } - }) - - const mask = - toolMask ?? - (await this._service.resolveToolMask({ - session, - room, - source: 'chatluna' - })) - - const chainValues = await chatInterface.chat({ - message: humanMessage, - events: event, - stream, - conversationId, - requestId, - session, - variables, - signal: abortController.signal, - postHandler, - messageQueue: activeRequest.messageQueue, - toolMask: mask, - onAgentEvent: async (agentEvent) => { - if (agentEvent.type === 'round-decision') { - activeRequest.lastDecision = agentEvent.canContinue - if (agentEvent.canContinue == null) { - return - } - - for (const resolve of activeRequest.roundDecisionResolvers) { - resolve(agentEvent.canContinue) - } - activeRequest.roundDecisionResolvers = [] - } - } - }) - - const aiMessage = chainValues.message as AIMessage - - const reasoningContent = aiMessage.additional_kwargs - ?.reasoning_content as string - - const reasoningTime = aiMessage.additional_kwargs - ?.reasoning_time as number - - const usageMetadata = aiMessage.usage_metadata - - const additionalReplyMessages: Message[] = [] - - if ( - reasoningContent != null && - reasoningContent.length > 0 && - this._service.currentConfig.showThoughtMessage - ) { - additionalReplyMessages.push({ - content: - reasoningTime != null - ? `Thought for ${reasoningTime / 1000} seconds: \n\n${reasoningContent}` - : `Thought: \n\n${reasoningContent}` - }) - } - - if ( - usageMetadata != null && - usageMetadata.total_tokens > 0 && - this._service.currentConfig.showThoughtMessage - ) { - additionalReplyMessages.push({ - content: formatUsageMetadataMessage(usageMetadata) - }) - } - - return { - content: aiMessage.content as string, - additionalReplyMessages - } - } finally { - // Clean up resources - await Promise.all([ - this._modelQueue.remove(platform, requestId), - this._conversationQueue.remove(conversationId, requestId) - ]) - this._requestIdMap.delete(requestId) - - const active = this._activeRequests.get(conversationId) - if (active?.requestId === requestId) { - for (const resolve of active.roundDecisionResolvers) { - resolve(false) - } - this._activeRequests.delete(conversationId) - } - } - } - - stopChat(requestId: string) { - const abortController = this._requestIdMap.get(requestId) - if (!abortController) { - return false - } - abortController.abort(createAbortError()) - this._requestIdMap.delete(requestId) - return true - } - - async appendPendingMessage( - conversationId: string, - message: HumanMessage, - chatMode?: string - ): Promise { - if (chatMode != null && chatMode !== 'plugin') { - return false - } - - const activeRequest = this._activeRequests.get(conversationId) - - if (activeRequest == null) { - return false - } - if (activeRequest.chatMode !== 'plugin') { - return false - } - - if (activeRequest.lastDecision != null) { - if (activeRequest.lastDecision) { - activeRequest.messageQueue.push(message) - } - return activeRequest.lastDecision - } - - return new Promise((resolve) => { - activeRequest.roundDecisionResolvers.push((canContinue) => { - if (canContinue) { - activeRequest.messageQueue.push(message) - } - resolve(canContinue) - }) - }) - } - - async query( - room: ConversationRoom, - create: boolean = false - ): Promise { - const { conversationId } = room - - const { chatInterface } = this._conversations.get(conversationId) ?? {} - - if (chatInterface == null && create) { - return this._createChatInterface(room).then( - (result) => result.chatInterface - ) - } - - return chatInterface - } - - async clearChatHistory(room: ConversationRoom) { - const { conversationId } = room - const requestId = randomUUID() - - try { - await this._conversationQueue.add(conversationId, requestId) - await this._conversationQueue.wait(conversationId, requestId, 0) - - const chatInterface = await this.query(room, true) - await chatInterface.clearChatHistory() - } finally { - this._conversations.delete(conversationId) - await this._conversationQueue.remove(conversationId, requestId) - } - } - - async compressContext(room: ConversationRoom, force = false) { - const { conversationId, model: fullModelName } = room - const requestId = randomUUID() - const modelRequestId = randomUUID() - - const [platform] = parseRawModelName(fullModelName) - const client = await this._platformService.getClient(platform) - - if (client.value == null) { - await this._service.awaitLoadPlatform(platform) - } - - if (client.value == null) { - throw new ChatLunaError( - ChatLunaErrorCode.UNKNOWN_ERROR, - new Error(`Platform ${platform} is not available`) - ) - } - - const config = client.value.configPool.getConfig(true).value - - try { - await Promise.all([ - this._conversationQueue.add(conversationId, requestId), - this._modelQueue.add(platform, modelRequestId) - ]) - - await Promise.all([ - this._conversationQueue.wait(conversationId, requestId, 0), - this._modelQueue.wait( - platform, - modelRequestId, - config.concurrentMaxSize - ) - ]) - - const chatInterface = await this.query(room, true) - return await chatInterface.compressContext(force) - } finally { - await Promise.all([ - this._conversationQueue.remove(conversationId, requestId), - this._modelQueue.remove(platform, modelRequestId) - ]) - } - } - - async clearCache(room: ConversationRoom) { - const { conversationId } = room - const requestId = randomUUID() - - try { - await this._conversationQueue.add(conversationId, requestId) - await this._conversationQueue.wait(conversationId, requestId, 0) - - const chatInterface = await this.query(room) - - await this._service.ctx.root.parallel( - 'chatluna/clear-chat-history', - conversationId, - chatInterface - ) - - return this._conversations.delete(conversationId) - } finally { - await this._conversationQueue.remove(conversationId, requestId) - } - } - - getCachedConversations(): [string, ChatHubChatBridgerInfo][] { - return Array.from(this._conversations.entries()) - } - - async delete(room: ConversationRoom) { - const { conversationId } = room - const requestId = randomUUID() - - try { - await this._conversationQueue.add(conversationId, requestId) - await this._conversationQueue.wait(conversationId, requestId, 1) - - const chatInterface = await this.query(room) - if (!chatInterface) return - - await chatInterface.delete(this._service.ctx, room) - await this.clearCache(room) - } finally { - await this._conversationQueue.remove(conversationId, requestId) - } - } - - dispose(platform?: string) { - // Terminate all related requests - for (const controller of this._requestIdMap.values()) { - controller.abort(createAbortError()) - } - - if (!platform) { - // Clean up all resources - this._conversations.clear() - this._requestIdMap.clear() - this._activeRequests.clear() - this._platformToConversations.clear() - return - } - - // Clean up resources for specific platform - const conversationIds = this._platformToConversations.get(platform) - if (!conversationIds?.length) return - - for (const conversationId of conversationIds) { - this._conversations.delete(conversationId) - this._activeRequests.delete(conversationId) - } - - this._platformToConversations.delete(platform) - } - - private async _createChatInterface( - room: ConversationRoom - ): Promise { - const config = this._service.currentConfig - - const chatInterface = new ChatInterface(this._service.ctx.root, { - chatMode: room.chatMode, - botName: config.botNames[0], - preset: this._service.preset.getPreset(room.preset), - model: room.model, - conversationId: room.conversationId, - embeddings: - config.defaultEmbeddings && config.defaultEmbeddings.length > 0 - ? config.defaultEmbeddings - : undefined, - vectorStoreName: - config.defaultVectorStore && - config.defaultVectorStore.length > 0 - ? config.defaultVectorStore - : undefined - }) - - const result = { - chatInterface, - room - } - - this._conversations.set(room.conversationId, result) - - return result - } -} - function formatUsageMetadataMessage(usage: UsageMetadata) { const input = [ ...(usage.input_token_details?.audio != null diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts new file mode 100644 index 000000000..87a1c325c --- /dev/null +++ b/packages/core/src/services/conversation.ts @@ -0,0 +1,1719 @@ +import { createHash, randomUUID } from 'crypto' +import fs from 'fs/promises' +import path from 'path' +import type { Session } from 'koishi' +import type { Config } from '../config' +import { + bufferToArrayBuffer, + gzipDecode, + gzipEncode +} from '../utils/compression' +import { + ACLRecord, + applyPresetLane, + ArchiveRecord, + BindingRecord, + ConversationCompressionRecord, + computeBaseBindingKey, + ConstraintRecord, + ConversationRecord, + MessageRecord, + ConstraintPermission, + ResolveConversationContextOptions, + ResolvedConstraint, + ResolvedConversationContext, + RouteMode +} from './conversation_types' + +interface ListConversationsOptions extends ResolveConversationContextOptions { + includeArchived?: boolean +} + +interface ResolveTargetConversationOptions extends ResolveConversationContextOptions { + targetConversation?: string + includeArchived?: boolean + permission?: ConstraintPermission +} + +interface SerializedMessageRecord extends Omit< + MessageRecord, + 'content' | 'additional_kwargs_binary' | 'createdAt' +> { + content?: string | null + additional_kwargs_binary?: string | null + createdAt?: string | null +} + +interface ConversationArchivePayload { + formatVersion: number + exportedAt: string + conversation: Omit< + ConversationRecord, + 'createdAt' | 'updatedAt' | 'lastChatAt' | 'archivedAt' + > & { + createdAt: string + updatedAt: string + lastChatAt?: string | null + archivedAt?: string | null + } + messages: SerializedMessageRecord[] +} + +interface ArchiveManifest { + format: 'chatluna-archive' + formatVersion: number + conversationId: string + messageCount: number + checksum?: string | null + size: number + createdAt: string +} + +export class ConversationService { + constructor( + private readonly ctx: import('koishi').Context, + private readonly config: Config + ) {} + + async getConversation(id: string) { + return ( + await this.ctx.database.get('chatluna_conversation', { + id + }) + )[0] as ConversationRecord | undefined + } + + async getBinding(bindingKey: string) { + return ( + await this.ctx.database.get('chatluna_binding', { + bindingKey + }) + )[0] as BindingRecord | undefined + } + + async getArchive(id: string) { + return ( + await this.ctx.database.get('chatluna_archive', { + id + }) + )[0] as ArchiveRecord | undefined + } + + async getArchiveByConversationId(conversationId: string) { + return ( + await this.ctx.database.get('chatluna_archive', { + conversationId + }) + )[0] as ArchiveRecord | undefined + } + + async listConstraints() { + const constraints = (await this.ctx.database.get( + 'chatluna_constraint', + {} + )) as ConstraintRecord[] + + return constraints + .filter((constraint) => constraint.enabled !== false) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + } + + async matchConstraints(session: Session) { + const constraints = await this.listConstraints() + return constraints.filter((constraint) => + this.isConstraintMatched(constraint, session) + ) + } + + isConstraintMatched(constraint: ConstraintRecord, session: Session) { + if ( + constraint.platform != null && + constraint.platform !== session.platform + ) { + return false + } + if (constraint.selfId != null && constraint.selfId !== session.selfId) { + return false + } + if ( + constraint.guildId != null && + constraint.guildId !== session.guildId + ) { + return false + } + if ( + constraint.channelId != null && + constraint.channelId !== session.channelId + ) { + return false + } + if ( + constraint.direct != null && + constraint.direct !== session.isDirect + ) { + return false + } + + const users = parseJsonArray(constraint.users) + if (users && !users.includes(session.userId)) { + return false + } + + const excludeUsers = parseJsonArray(constraint.excludeUsers) + if (excludeUsers && excludeUsers.includes(session.userId)) { + return false + } + + return true + } + + async resolveConstraint( + session: Session, + options: ResolveConversationContextOptions = {} + ): Promise { + const constraints = await this.matchConstraints(session) + const routed = constraints.find( + (constraint) => constraint.routeMode != null + ) + + const routeMode = routed?.routeMode ?? this.getDefaultRouteMode(session) + const baseKey = computeBaseBindingKey( + session, + routeMode, + routed?.routeKey + ) + const bindingKey = applyPresetLane(baseKey, options.presetLane) + + return { + routeMode, + baseKey, + bindingKey, + constraints, + defaultModel: + firstDefined(constraints, 'defaultModel') ?? + this.config.defaultModel, + defaultPreset: + options.presetLane ?? + firstDefined(constraints, 'defaultPreset') ?? + this.config.defaultPreset, + defaultChatMode: + firstDefined(constraints, 'defaultChatMode') ?? + this.config.defaultChatMode, + fixedModel: firstDefined(constraints, 'fixedModel'), + fixedPreset: firstDefined(constraints, 'fixedPreset'), + fixedChatMode: firstDefined(constraints, 'fixedChatMode'), + lockConversation: firstBoolean( + constraints, + 'lockConversation', + false + ), + allowNew: firstBoolean(constraints, 'allowNew', true), + allowSwitch: firstBoolean(constraints, 'allowSwitch', true), + allowArchive: firstBoolean(constraints, 'allowArchive', true), + allowExport: firstBoolean(constraints, 'allowExport', true), + manageMode: firstDefined(constraints, 'manageMode') ?? 'admin' + } + } + + async resolveContext( + session: Session, + options: ResolveConversationContextOptions = {} + ): Promise { + const constraint = await this.resolveConstraint(session, options) + const matched = await this.resolveBindingForKey( + session, + constraint.bindingKey + ) + const binding = matched?.binding + const conversation = options.conversationId + ? await this.getConversation(options.conversationId) + : binding?.activeConversationId + ? await this.getConversation(binding.activeConversationId) + : undefined + const allowedConversation = + conversation != null && + (await this.hasConversationPermission( + session, + conversation, + 'view', + matched?.bindingKey ?? constraint.bindingKey + )) + ? conversation + : null + + return { + bindingKey: matched?.bindingKey ?? constraint.bindingKey, + presetLane: options.presetLane, + binding: binding ?? null, + conversation: allowedConversation, + effectiveModel: + constraint.fixedModel ?? + allowedConversation?.model ?? + constraint.defaultModel ?? + this.config.defaultModel, + effectivePreset: + options.presetLane ?? + constraint.fixedPreset ?? + allowedConversation?.preset ?? + constraint.defaultPreset ?? + this.config.defaultPreset, + effectiveChatMode: + constraint.fixedChatMode ?? + allowedConversation?.chatMode ?? + constraint.defaultChatMode ?? + this.config.defaultChatMode, + constraint + } + } + + private async resolveBindingForKey(session: Session, bindingKey: string) { + const binding = await this.getBinding(bindingKey) + + if (binding != null) { + return { + bindingKey, + binding + } + } + + const suffix = bindingKey.includes(':preset:') + ? bindingKey.slice(bindingKey.indexOf(':preset:')) + : '' + + if (bindingKey.startsWith('custom:')) { + return null + } + + const keys = session.isDirect + ? [`personal:legacy:legacy:direct:${session.userId}${suffix}`] + : bindingKey.startsWith('shared:') + ? [ + `shared:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}${suffix}`, + `personal:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}:${session.userId}${suffix}` + ] + : [ + `personal:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}:${session.userId}${suffix}`, + `shared:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}${suffix}` + ] + + for (const key of keys) { + const legacyBinding = await this.getBinding(key) + if (legacyBinding != null) { + return { + bindingKey: key, + binding: legacyBinding + } + } + } + + return null + } + + async ensureActiveConversation( + session: Session, + options: ResolveConversationContextOptions = {} + ) { + const resolved = await this.resolveContext(session, options) + + if ( + resolved.constraint.lockConversation && + resolved.binding?.activeConversationId != null + ) { + return resolved as ResolvedConversationContext & { + conversation: ConversationRecord + } + } + + if (resolved.conversation != null) { + if (resolved.conversation.status === 'archived') { + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error( + 'Conversation restore is locked by constraint.' + ) + } + + if (!resolved.constraint.allowArchive) { + throw new Error( + 'Conversation restore is disabled by constraint.' + ) + } + + const conversation = await this.restoreConversation(session, { + conversationId: resolved.conversation.id + }) + + return { + ...resolved, + conversation, + effectiveModel: conversation.model, + effectivePreset: conversation.preset, + effectiveChatMode: conversation.chatMode + } + } + + return resolved as ResolvedConversationContext & { + conversation: ConversationRecord + } + } + + await this.assertManageAllowed(session, resolved.constraint) + + if (!resolved.constraint.allowNew) { + throw new Error('Conversation creation is disabled by constraint.') + } + + const conversation = await this.createConversation(session, { + bindingKey: resolved.bindingKey, + preset: resolved.effectivePreset ?? this.config.defaultPreset, + model: resolved.effectiveModel ?? this.config.defaultModel, + chatMode: resolved.effectiveChatMode ?? this.config.defaultChatMode, + title: options.presetLane ?? 'New Conversation' + }) + + return { + ...resolved, + conversation + } + } + + async createConversation( + session: Session, + options: { + bindingKey: string + title: string + model: string + preset: string + chatMode: string + } + ) { + const now = new Date() + const conversation: ConversationRecord = { + id: randomUUID(), + seq: await this.allocateConversationSeq(options.bindingKey), + bindingKey: options.bindingKey, + title: options.title, + model: options.model, + preset: options.preset, + chatMode: options.chatMode, + createdBy: session.userId, + createdAt: now, + updatedAt: now, + lastChatAt: now, + status: 'active', + latestMessageId: null, + additional_kwargs: null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: null, + legacyMeta: null + } + + await this.ctx.root.parallel('chatluna/conversation-before-create', { + conversation, + bindingKey: options.bindingKey + }) + await this.ctx.database.create('chatluna_conversation', conversation) + await this.setActiveConversation(options.bindingKey, conversation.id) + await this.ctx.root.parallel('chatluna/conversation-after-create', { + conversation, + bindingKey: options.bindingKey + }) + return conversation + } + + async setActiveConversation(bindingKey: string, conversationId: string) { + const current = await this.getBinding(bindingKey) + const payload: BindingRecord = { + bindingKey, + activeConversationId: conversationId, + lastConversationId: + current?.activeConversationId != null && + current.activeConversationId !== conversationId + ? current.activeConversationId + : (current?.lastConversationId ?? null), + updatedAt: new Date() + } + + await this.ctx.database.upsert('chatluna_binding', [payload]) + return payload + } + + async touchConversation( + conversationId: string, + patch: Partial = {} + ) { + const current = await this.getConversation(conversationId) + if (current == null) { + return undefined + } + + const updated: ConversationRecord = { + ...current, + ...patch, + id: current.id, + updatedAt: patch.updatedAt ?? new Date() + } + + await this.ctx.database.upsert('chatluna_conversation', [updated]) + return updated + } + + async listConversations( + session: Session, + options: ListConversationsOptions = {} + ) { + const resolved = await this.resolveContext(session, options) + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + { + bindingKey: resolved.bindingKey + } + )) as ConversationRecord[] + + const includeArchived = options.includeArchived === true + + return conversations + .filter( + (conversation) => + conversation.status !== 'deleted' && + conversation.status !== 'broken' && + (includeArchived || conversation.status !== 'archived') + ) + .sort((a, b) => { + const left = a.lastChatAt ?? a.updatedAt ?? a.createdAt + const right = b.lastChatAt ?? b.updatedAt ?? b.createdAt + return right.getTime() - left.getTime() + }) + } + + async switchConversation( + session: Session, + options: ResolveTargetConversationOptions + ) { + const resolved = await this.resolveContext(session, options) + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation switch is locked by constraint.') + } + + const conversation = await this.resolveTargetConversation(session, { + ...options, + permission: 'manage' + }) + + if (conversation == null) { + throw new Error('Conversation not found.') + } + + if (conversation.bindingKey !== resolved.bindingKey) { + throw new Error('Conversation does not belong to current route.') + } + + if (!resolved.constraint.allowSwitch) { + throw new Error('Conversation switch is disabled by constraint.') + } + + const previousConversation = resolved.binding?.activeConversationId + ? await this.getConversation(resolved.binding.activeConversationId) + : null + + await this.ctx.root.parallel('chatluna/conversation-before-switch', { + bindingKey: resolved.bindingKey, + conversation, + previousConversation + }) + await this.setActiveConversation(resolved.bindingKey, conversation.id) + await this.ctx.root.parallel('chatluna/conversation-after-switch', { + bindingKey: resolved.bindingKey, + conversation, + previousConversation + }) + + return conversation + } + + async getCurrentConversation( + session: Session, + options: ResolveConversationContextOptions = {} + ) { + return this.resolveContext(session, options) + } + + async reopenConversation( + session: Session, + options: ResolveTargetConversationOptions + ) { + const resolved = await this.resolveContext(session, options) + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation restore is locked by constraint.') + } + + const conversation = await this.resolveTargetConversation(session, { + ...options, + includeArchived: true, + permission: 'manage' + }) + + if (conversation == null) { + throw new Error('Conversation not found.') + } + + if (conversation.bindingKey !== resolved.bindingKey) { + throw new Error('Conversation does not belong to current route.') + } + + if (conversation.status !== 'archived') { + await this.setActiveConversation( + resolved.bindingKey, + conversation.id + ) + return conversation + } + + return this.restoreConversation(session, { + ...options, + conversationId: conversation.id + }) + } + + async listMessages(conversationId: string) { + return (await this.ctx.database.get('chatluna_message', { + conversationId + })) as MessageRecord[] + } + + async listAcl(conversationId: string) { + return (await this.ctx.database.get('chatluna_acl', { + conversationId + })) as ACLRecord[] + } + + async upsertAcl( + conversationId: string, + records: Omit[] + ) { + if (records.length < 1) { + return [] as ACLRecord[] + } + + await this.ctx.database.upsert( + 'chatluna_acl', + records.map((record) => ({ + conversationId, + ...record + })) + ) + + return this.listAcl(conversationId) + } + + async replaceAcl( + conversationId: string, + records: Omit[] + ) { + await this.ctx.database.remove('chatluna_acl', { + conversationId + }) + + return this.upsertAcl(conversationId, records) + } + + async removeAcl( + conversationId: string, + records?: Partial>[] + ) { + if (records == null || records.length < 1) { + await this.ctx.database.remove('chatluna_acl', { + conversationId + }) + return [] as ACLRecord[] + } + + const current = await this.listAcl(conversationId) + const removed = current.filter((item) => + records.some((record) => { + if ( + record.principalType != null && + record.principalType !== item.principalType + ) { + return false + } + + if ( + record.principalId != null && + record.principalId !== item.principalId + ) { + return false + } + + if ( + record.permission != null && + record.permission !== item.permission + ) { + return false + } + + return true + }) + ) + + for (const item of removed) { + await this.ctx.database.remove('chatluna_acl', item) + } + + return this.listAcl(conversationId) + } + + async exportConversation( + session: Session, + options: ResolveTargetConversationOptions & { + outputPath?: string + } = {} + ) { + const resolved = await this.resolveContext(session, options) + await this.assertManageAllowed(session, resolved.constraint) + const conversation = await this.resolveTargetConversation(session, { + ...options, + includeArchived: true, + permission: 'view' + }) + + if (conversation == null) { + throw new Error('Conversation not found.') + } + + if (!resolved.constraint.allowExport) { + throw new Error('Conversation export is disabled by constraint.') + } + + const markdown = await this.exportMarkdown(conversation) + const exportDir = await this.ensureDataDir('export') + const outputPath = + options.outputPath ?? + path.join(exportDir, `${conversation.id}-${Date.now()}.md`) + + await fs.writeFile(outputPath, markdown, 'utf8') + + const size = Buffer.byteLength(markdown) + const checksum = createHash('sha256').update(markdown).digest('hex') + + return { + conversation, + path: outputPath, + size, + checksum + } + } + + async archiveConversation( + session: Session, + options: ResolveTargetConversationOptions = {} + ) { + const resolved = await this.resolveContext(session, options) + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation archive is locked by constraint.') + } + + const conversation = await this.resolveTargetConversation(session, { + ...options, + permission: 'manage' + }) + + if (conversation == null) { + throw new Error('Conversation not found.') + } + + if (!resolved.constraint.allowArchive) { + throw new Error('Conversation archive is disabled by constraint.') + } + + return this.archiveConversationById(conversation.id) + } + + async archiveConversationById(conversationId: string) { + const conversation = await this.getConversation(conversationId) + if (conversation == null) { + throw new Error('Conversation not found.') + } + + await this.ctx.root.parallel('chatluna/conversation-before-archive', { + conversation + }) + + if ( + conversation.status === 'archived' && + conversation.archiveId != null + ) { + const archive = await this.getArchive(conversation.archiveId) + if (archive != null) { + return { + conversation, + archive, + path: archive.path + } + } + } + + const archiveDir = path.resolve( + this.ctx.baseDir, + 'data/chatluna/archive', + conversation.id + ) + await fs.mkdir(archiveDir, { recursive: true }) + + const payload = await this.buildArchivePayload(conversation) + const messageLines = payload.messages + .map((message) => JSON.stringify(message)) + .join('\n') + const messageBuffer = await gzipEncode(messageLines) + const checksum = createHash('sha256') + .update(messageBuffer) + .digest('hex') + + await fs.writeFile( + path.join(archiveDir, 'conversation.json'), + JSON.stringify(payload.conversation, null, 2), + 'utf8' + ) + await fs.writeFile( + path.join(archiveDir, 'messages.jsonl.gz'), + messageBuffer + ) + + const manifest: ArchiveManifest = { + format: 'chatluna-archive', + formatVersion: payload.formatVersion, + conversationId: conversation.id, + messageCount: payload.messages.length, + checksum, + size: messageBuffer.byteLength, + createdAt: new Date().toISOString() + } + await fs.writeFile( + path.join(archiveDir, 'manifest.json'), + JSON.stringify(manifest, null, 2), + 'utf8' + ) + + const archive: ArchiveRecord = { + id: randomUUID(), + conversationId: conversation.id, + path: archiveDir, + formatVersion: payload.formatVersion, + messageCount: payload.messages.length, + checksum, + size: messageBuffer.byteLength, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + + await this.ctx.database.upsert('chatluna_archive', [archive]) + await this.touchConversation(conversation.id, { + status: 'archived', + archivedAt: new Date(), + archiveId: archive.id + }) + await this.unbindConversation(conversation.id) + await this.ctx.database.remove('chatluna_message', { + conversationId: conversation.id + }) + await this.ctx.chatluna.conversationRuntime.clearConversationInterface( + conversation + ) + + const updatedConversation = await this.getConversation(conversation.id) + + await this.ctx.root.parallel('chatluna/conversation-after-archive', { + conversation: updatedConversation ?? conversation, + archive, + path: archiveDir + }) + + return { + conversation: updatedConversation ?? conversation, + archive, + path: archiveDir + } + } + + async restoreConversation( + session: Session, + options: ResolveConversationContextOptions & { + archiveId?: string + } = {} + ) { + const resolved = await this.resolveContext(session, options) + const targetConversation = options.conversationId + ? await this.getConversation(options.conversationId) + : resolved.conversation + const conversation = targetConversation ?? resolved.conversation + + if (conversation == null) { + throw new Error('Conversation not found.') + } + + const archive = options.archiveId + ? await this.getArchive(options.archiveId) + : conversation.archiveId + ? await this.getArchive(conversation.archiveId) + : await this.getArchiveByConversationId(conversation.id) + + if (archive == null) { + throw new Error('Archive not found.') + } + + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation restore is locked by constraint.') + } + + if (!resolved.constraint.allowArchive) { + throw new Error('Conversation restore is disabled by constraint.') + } + + await this.ctx.root.parallel('chatluna/conversation-before-restore', { + conversation, + archive + }) + + await this.ctx.database.upsert('chatluna_archive', [ + { + ...archive, + state: 'restoring' + } + ]) + + try { + const payload = await this.readArchivePayload(archive.path) + const restoredConversation = deserializeConversation( + payload.conversation + ) + const restoredMessages = payload.messages.map(deserializeMessage) + + await this.ctx.database.remove('chatluna_message', { + conversationId: conversation.id + }) + + if (restoredMessages.length > 0) { + await this.ctx.database.upsert( + 'chatluna_message', + restoredMessages + ) + } + + await this.ctx.database.upsert('chatluna_conversation', [ + { + ...conversation, + ...restoredConversation, + id: conversation.id, + status: 'active', + archivedAt: null, + archiveId: null, + updatedAt: new Date() + } + ]) + + await this.setActiveConversation( + restoredConversation.bindingKey, + conversation.id + ) + await this.ctx.database.upsert('chatluna_archive', [ + { + ...archive, + state: 'ready', + restoredAt: new Date() + } + ]) + + const updatedConversation = await this.getConversation( + conversation.id + ) + if (updatedConversation == null) { + throw new Error('Conversation restore failed.') + } + + await this.ctx.chatluna.conversationRuntime.clearConversationInterface( + updatedConversation + ) + await this.ctx.root.parallel( + 'chatluna/conversation-after-restore', + { + conversation: updatedConversation, + archive + } + ) + + return updatedConversation + } catch (error) { + await this.ctx.database.upsert('chatluna_archive', [ + { + ...archive, + state: 'broken' + } + ]) + throw error + } + } + + private async buildArchivePayload( + conversation: ConversationRecord + ): Promise { + const messages = await this.listMessages(conversation.id) + + return { + formatVersion: 1, + exportedAt: new Date().toISOString(), + conversation: serializeConversation(conversation), + messages: messages.map(serializeMessage) + } + } + + async exportMarkdown(conversation: ConversationRecord) { + const messages = await this.listMessages(conversation.id) + + return [ + `# ${conversation.title}`, + '', + `- ID: ${conversation.id}`, + `- Seq: ${conversation.seq ?? '-'}`, + `- Route: ${conversation.bindingKey}`, + `- Model: ${conversation.model}`, + `- Preset: ${conversation.preset}`, + `- Chat Mode: ${conversation.chatMode}`, + `- Status: ${conversation.status}`, + `- Updated At: ${conversation.updatedAt.toISOString()}`, + '', + ...messages.flatMap((message) => [ + `## ${message.role} ${message.name ? `(${message.name})` : ''}`.trim(), + '', + message.text ?? '', + '' + ]) + ].join('\n') + } + + async renameConversation( + session: Session, + options: ResolveTargetConversationOptions & { + title: string + } + ) { + const resolved = await this.resolveContext(session, options) + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation rename is locked by constraint.') + } + + const conversation = await this.resolveTargetConversation(session, { + ...options, + permission: 'manage' + }) + if (conversation == null) { + throw new Error('Conversation not found.') + } + + const updated = await this.touchConversation(conversation.id, { + title: options.title.trim() + }) + if (updated == null) { + throw new Error('Conversation not found.') + } + return updated + } + + async deleteConversation( + session: Session, + options: ResolveTargetConversationOptions = {} + ) { + const resolved = await this.resolveContext(session, options) + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation delete is locked by constraint.') + } + + const conversation = await this.resolveTargetConversation(session, { + ...options, + includeArchived: true, + permission: 'manage' + }) + if (conversation == null) { + throw new Error('Conversation not found.') + } + + const updated = await this.touchConversation(conversation.id, { + status: 'deleted', + archivedAt: null + }) + await this.ctx.root.parallel('chatluna/conversation-before-delete', { + conversation + }) + await this.unbindConversation(conversation.id) + await this.ctx.database.remove('chatluna_message', { + conversationId: conversation.id + }) + await this.removeAcl(conversation.id) + await this.ctx.chatluna.conversationRuntime.clearConversationInterface( + conversation + ) + await this.ctx.root.parallel('chatluna/conversation-after-delete', { + conversation: updated ?? conversation + }) + return updated ?? conversation + } + + async updateConversationUsage( + session: Session, + options: ResolveConversationContextOptions & { + model?: string + preset?: string + chatMode?: string + } + ) { + const resolved = await this.ensureActiveConversation(session, options) + await this.assertManageAllowed(session, resolved.constraint) + + if (resolved.constraint.lockConversation) { + throw new Error('Conversation update is locked by constraint.') + } + + if (options.model != null && resolved.constraint.fixedModel != null) { + throw new Error( + `Model is fixed to ${resolved.constraint.fixedModel}.` + ) + } + + if (options.preset != null && resolved.constraint.fixedPreset != null) { + throw new Error( + `Preset is fixed to ${resolved.constraint.fixedPreset}.` + ) + } + + if ( + options.chatMode != null && + resolved.constraint.fixedChatMode != null + ) { + throw new Error( + `Chat mode is fixed to ${resolved.constraint.fixedChatMode}.` + ) + } + + const updated = await this.touchConversation(resolved.conversation.id, { + model: options.model ?? resolved.conversation.model, + preset: options.preset ?? resolved.conversation.preset, + chatMode: options.chatMode ?? resolved.conversation.chatMode + }) + + if (updated == null) { + throw new Error('Conversation not found.') + } + + await this.ctx.chatluna.conversationRuntime.clearConversationInterface( + updated + ) + return updated + } + + async recordCompression( + conversationId: string, + result: { + compressed: boolean + inputTokens: number + outputTokens: number + reducedTokens: number + reducedPercent: number + originalMessageCount: number + remainingMessageCount: number + } + ) { + if (!result.compressed) { + return await this.getConversation(conversationId) + } + + const conversation = await this.getConversation(conversationId) + if (conversation == null) { + return undefined + } + + const current = parseCompressionRecord(conversation.compression) + const messages = await this.listMessages(conversationId) + const summary = messages.find( + (message) => message.name === 'infinite_context' + )?.text + + const updated = await this.touchConversation(conversationId, { + compression: JSON.stringify({ + ...current, + count: (current?.count ?? 0) + 1, + compressedAt: new Date().toISOString(), + summary: summary ?? current?.summary, + originalMessageCount: result.originalMessageCount, + remainingMessageCount: result.remainingMessageCount, + tokenUsage: result.outputTokens, + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + reducedTokens: result.reducedTokens, + reducedPercent: result.reducedPercent + } satisfies ConversationCompressionRecord) + }) + + if (updated != null) { + await this.ctx.root.parallel('chatluna/conversation-compressed', { + conversation: updated, + result + }) + } + + return updated + } + + async getManagedConstraint(session: Session) { + const route = session.isDirect + ? `direct:${session.userId}` + : `guild:${session.guildId ?? session.channelId ?? 'unknown'}` + const name = `managed:${session.platform}:${session.selfId}:${route}` + const matched = await this.ctx.database.get('chatluna_constraint', { + name + }) + return matched[0] as ConstraintRecord | undefined + } + + async updateManagedConstraint( + session: Session, + patch: Partial + ) { + const current = await this.getManagedConstraint(session) + const now = new Date() + const route = session.isDirect + ? `direct:${session.userId}` + : `guild:${session.guildId ?? session.channelId ?? 'unknown'}` + const record: ConstraintRecord = { + id: current?.id, + name: + current?.name ?? + `managed:${session.platform}:${session.selfId}:${route}`, + enabled: current?.enabled ?? true, + priority: current?.priority ?? 1000, + createdBy: current?.createdBy ?? session.userId, + createdAt: current?.createdAt ?? now, + updatedAt: now, + platform: session.platform, + selfId: session.selfId, + guildId: session.isDirect + ? null + : (session.guildId ?? session.channelId ?? null), + channelId: null, + direct: session.isDirect, + users: session.isDirect ? JSON.stringify([session.userId]) : null, + excludeUsers: null, + routeMode: current?.routeMode ?? null, + routeKey: current?.routeKey ?? null, + defaultModel: current?.defaultModel ?? null, + defaultPreset: current?.defaultPreset ?? null, + defaultChatMode: current?.defaultChatMode ?? null, + fixedModel: current?.fixedModel ?? null, + fixedPreset: current?.fixedPreset ?? null, + fixedChatMode: current?.fixedChatMode ?? null, + lockConversation: current?.lockConversation ?? null, + allowNew: current?.allowNew ?? null, + allowSwitch: current?.allowSwitch ?? null, + allowArchive: current?.allowArchive ?? null, + allowExport: current?.allowExport ?? null, + manageMode: current?.manageMode ?? 'admin', + ...patch + } + + await this.ctx.database.upsert('chatluna_constraint', [record]) + return (await this.getManagedConstraint(session)) ?? record + } + + private getDefaultRouteMode(session: Session): RouteMode { + if (session.isDirect) { + return 'personal' + } + + return this.config.defaultGroupRouteMode ?? 'shared' + } + + private async allocateConversationSeq(bindingKey: string) { + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + { + bindingKey + } + )) as ConversationRecord[] + + const maxSeq = conversations.reduce((current, conversation) => { + const seq = conversation.seq ?? 0 + return seq > current ? seq : current + }, 0) + + return maxSeq + 1 + } + + async resolveTargetConversation( + session: Session, + options: ResolveTargetConversationOptions = {} + ) { + if (options.conversationId != null) { + const resolved = await this.resolveContext(session, options) + const conversation = await this.getConversation( + options.conversationId + ) + + if (conversation == null) { + return null + } + + if ( + !(await this.hasConversationPermission( + session, + conversation, + options.permission ?? 'view', + resolved.bindingKey + )) + ) { + throw new Error( + 'Conversation does not belong to current route.' + ) + } + + return conversation + } + + const resolved = await this.resolveContext(session, options) + const target = options.targetConversation?.trim() + + if (target == null || target.length === 0) { + return resolved.conversation ?? null + } + + const conversations = await this.listConversations(session, { + presetLane: options.presetLane, + includeArchived: options.includeArchived + }) + + const byId = conversations.find( + (conversation) => conversation.id === target + ) + if (byId != null) { + return byId + } + + if (/^\d+$/.test(target)) { + const seq = Number(target) + const bySeq = conversations.find( + (conversation) => conversation.seq === seq + ) + if (bySeq != null) { + return bySeq + } + } + + const normalized = target.toLocaleLowerCase() + const exactTitle = conversations.find( + (conversation) => + conversation.title.toLocaleLowerCase() === normalized + ) + if (exactTitle != null) { + return exactTitle + } + + const partialMatches = conversations.filter((conversation) => + conversation.title.toLocaleLowerCase().includes(normalized) + ) + + if (partialMatches.length === 1) { + return partialMatches[0] + } + + if (partialMatches.length > 1) { + throw new Error('Conversation target is ambiguous.') + } + + const globalMatches = await this.findAccessibleConversations(session, { + ...options, + bindingKey: resolved.bindingKey, + query: normalized, + exactId: target, + seq: /^\d+$/.test(target) ? Number(target) : undefined + }) + + const globalById = globalMatches.find( + (conversation) => conversation.id === target + ) + if (globalById != null) { + return globalById + } + + const globalExactTitle = globalMatches.find( + (conversation) => + conversation.title.toLocaleLowerCase() === normalized + ) + if (globalExactTitle != null) { + return globalExactTitle + } + + const globalPartialMatches = globalMatches.filter((conversation) => + conversation.title.toLocaleLowerCase().includes(normalized) + ) + + if (globalPartialMatches.length === 1) { + return globalPartialMatches[0] + } + + if (globalPartialMatches.length > 1) { + throw new Error('Conversation target is ambiguous.') + } + + return null + } + + private async findAccessibleConversations( + session: Session, + options: ResolveTargetConversationOptions & { + bindingKey: string + query: string + exactId: string + seq?: number + } + ) { + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + const required = options.permission ?? 'view' + + const matches: ConversationRecord[] = [] + + for (const conversation of conversations) { + if ( + conversation.bindingKey === options.bindingKey || + conversation.status === 'deleted' || + conversation.status === 'broken' || + (!options.includeArchived && conversation.status === 'archived') + ) { + continue + } + + const title = conversation.title.toLocaleLowerCase() + const matched = + conversation.id === options.exactId || + (options.seq != null && conversation.seq === options.seq) || + title === options.query || + title.includes(options.query) + + if (!matched) { + continue + } + + if ( + !(await this.hasConversationPermission( + session, + conversation, + required, + options.bindingKey + )) + ) { + continue + } + + matches.push(conversation) + } + + return matches + } + + async resolveCommandConversation( + session: Session, + options: ResolveTargetConversationOptions = {} + ) { + return this.resolveTargetConversation(session, { + ...options, + permission: options.permission ?? 'view' + }) + } + + private async readArchivePayload(archivePath: string) { + const stat = await fs.stat(archivePath) + + if (stat.isDirectory()) { + const manifest = JSON.parse( + await fs.readFile( + path.join(archivePath, 'manifest.json'), + 'utf8' + ) + ) as ArchiveManifest + const conversation = JSON.parse( + await fs.readFile( + path.join(archivePath, 'conversation.json'), + 'utf8' + ) + ) as ConversationArchivePayload['conversation'] + const messagesRaw = await gzipDecode( + await fs.readFile(path.join(archivePath, 'messages.jsonl.gz')) + ) + const messages = messagesRaw + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as SerializedMessageRecord) + + return { + formatVersion: manifest.formatVersion, + exportedAt: manifest.createdAt, + conversation, + messages + } + } + + const compressed = await fs.readFile(archivePath) + const content = await gzipDecode(compressed) + return JSON.parse(content) as ConversationArchivePayload + } + + private async ensureDataDir(name: string) { + const target = path.resolve(this.ctx.baseDir, 'data/chatluna', name) + await fs.mkdir(target, { recursive: true }) + return target + } + + private async unbindConversation(conversationId: string) { + const bindings = (await this.ctx.database.get( + 'chatluna_binding', + {} + )) as BindingRecord[] + + for (const binding of bindings) { + if ( + binding.activeConversationId !== conversationId && + binding.lastConversationId !== conversationId + ) { + continue + } + + await this.ctx.database.upsert('chatluna_binding', [ + { + ...binding, + activeConversationId: + binding.activeConversationId === conversationId + ? null + : binding.activeConversationId, + lastConversationId: + binding.lastConversationId === conversationId + ? null + : binding.lastConversationId, + updatedAt: new Date() + } + ]) + } + } + + private async assertManageAllowed( + session: Session, + constraint: ResolvedConstraint + ) { + if (constraint.manageMode !== 'admin') { + return + } + + if (await isAdmin(session)) { + return + } + + throw new Error( + 'Conversation management requires administrator permission.' + ) + } + + private async hasConversationPermission( + session: Session, + conversation: ConversationRecord, + permission: ConstraintPermission, + bindingKey: string + ) { + if (conversation.bindingKey === bindingKey) { + return true + } + + if (await isAdmin(session)) { + return true + } + + const acl = await this.listAcl(conversation.id) + if (acl.length === 0) { + return false + } + + const principalIds = [ + ['user', session.userId], + ['guild', session.guildId ?? session.channelId] + ] as const + const required = permission === 'view' ? ['view', 'manage'] : ['manage'] + + return acl.some((item) => { + if (!required.includes(item.permission)) { + return false + } + + return principalIds.some( + ([type, id]) => + id != null && + item.principalType === type && + item.principalId === id + ) + }) + } +} + +function serializeConversation( + conversation: ConversationRecord +): ConversationArchivePayload['conversation'] { + return { + ...conversation, + createdAt: conversation.createdAt.toISOString(), + updatedAt: conversation.updatedAt.toISOString(), + lastChatAt: conversation.lastChatAt?.toISOString() ?? null, + archivedAt: conversation.archivedAt?.toISOString() ?? null + } +} + +function deserializeConversation( + conversation: ConversationArchivePayload['conversation'] +): ConversationRecord { + return { + ...conversation, + createdAt: new Date(conversation.createdAt), + updatedAt: new Date(conversation.updatedAt), + lastChatAt: conversation.lastChatAt + ? new Date(conversation.lastChatAt) + : null, + archivedAt: conversation.archivedAt + ? new Date(conversation.archivedAt) + : null + } +} + +function serializeMessage(message: MessageRecord): SerializedMessageRecord { + return { + ...message, + content: serializeBinary(message.content), + additional_kwargs_binary: serializeBinary( + message.additional_kwargs_binary + ), + createdAt: message.createdAt?.toISOString() ?? null + } +} + +function deserializeMessage(message: SerializedMessageRecord): MessageRecord { + return { + ...message, + content: deserializeBinary(message.content), + additional_kwargs_binary: deserializeBinary( + message.additional_kwargs_binary + ), + createdAt: message.createdAt ? new Date(message.createdAt) : null + } +} + +function serializeBinary(value?: ArrayBuffer | null) { + if (value == null) { + return null + } + + return Buffer.from(value).toString('base64') +} + +function deserializeBinary(value?: string | null) { + if (value == null || value.length === 0) { + return null + } + + return bufferToArrayBuffer(Buffer.from(value, 'base64')) +} + +function parseJsonArray(value?: string | null) { + if (value == null || value.length === 0) { + return null + } + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.map(String) : null + } catch { + return null + } +} + +function parseCompressionRecord(value?: string | null) { + if (value == null || value.length === 0) { + return null + } + + try { + return JSON.parse(value) as ConversationCompressionRecord + } catch { + return null + } +} + +async function isAdmin(session: Session) { + if ( + (session as Session & { user?: { authority?: number } }).user + ?.authority != null + ) { + return ( + ((session as Session & { user?: { authority?: number } }).user + ?.authority ?? 0) >= 3 + ) + } + + if ((session as Session & { authority?: number }).authority != null) { + return ( + ((session as Session & { authority?: number }).authority ?? 0) >= 3 + ) + } + + if (typeof session.getUser === 'function') { + const user = await session.getUser(session.userId, ['authority']) + return (user?.authority ?? 0) >= 3 + } + + return false +} + +function firstDefined( + constraints: ConstraintRecord[], + key: T +): ConstraintRecord[T] | undefined { + for (const constraint of constraints) { + if (constraint[key] != null) { + return constraint[key] + } + } + return undefined +} + +function firstBoolean( + constraints: ConstraintRecord[], + key: T, + fallback: boolean +) { + for (const constraint of constraints) { + const value = constraint[key] + if (typeof value === 'boolean') { + return value + } + } + return fallback +} diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts new file mode 100644 index 000000000..869e7f6bc --- /dev/null +++ b/packages/core/src/services/conversation_runtime.ts @@ -0,0 +1,492 @@ +import { + AIMessage, + BaseMessageChunk, + HumanMessage +} from '@langchain/core/messages' +import type { Session } from 'koishi' +import { LRUCache } from 'lru-cache' +import type { ChatInterface } from '../llm-core/chat/app' +import { + type AgentAction, + MessageQueue, + type ToolMask +} from '../llm-core/agent/types' +import { RequestIdQueue } from '../utils/queue' +import { randomUUID } from 'crypto' +import type { ChatLunaService } from './chat' +import { ChatLunaError, ChatLunaErrorCode } from '../utils/error' +import { parseRawModelName } from '../utils/model' +import { ConversationRecord } from './conversation_types' +import { Message } from '../types' +import type { PostHandler } from '../utils/types' + +export interface ChatEvents { + 'llm-new-token'?: (token: string) => Promise + 'llm-queue-waiting'?: (size: number) => Promise + 'llm-used-token-count'?: (token: number) => Promise + 'llm-call-tool'?: ( + tool: string, + args: any, + content: AgentAction['content'], + log: string + ) => Promise + 'llm-new-chunk'?: (chunk: BaseMessageChunk) => Promise +} + +export interface RuntimeConversationEntry { + conversation: ConversationRecord + chatInterface: ChatInterface +} + +export interface ActiveRequest { + requestId: string + conversationId: string + sessionId?: string + abortController: AbortController + chatMode: string + messageQueue: MessageQueue + roundDecisionResolvers: ((canContinue: boolean) => void)[] + lastDecision?: boolean +} + +function createAbortError() { + return new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) +} + +export class ConversationRuntime { + readonly interfaces = new LRUCache({ + max: 20 + }) + + readonly modelQueue = new RequestIdQueue() + readonly conversationQueue = new RequestIdQueue() + readonly requestsById = new Map() + readonly activeByConversation = new Map() + readonly requestBySession = new Map() + readonly platformIndex = new Map>() + + constructor(private readonly service: ChatLunaService) {} + + private get platformService() { + return this.service.platform + } + + async ensureChatInterface(conversation: ConversationRecord) { + const cached = this.interfaces.get(conversation.id) + if (cached != null) { + return cached.chatInterface + } + + const chatInterface = + await this.service.createChatInterface(conversation) + this.interfaces.set(conversation.id, { + conversation, + chatInterface + }) + return chatInterface + } + + async chat( + session: Session, + conversation: ConversationRecord, + message: Message, + event: ChatEvents, + stream: boolean = false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + variables: Record = {}, + postHandler?: PostHandler, + requestId: string = randomUUID(), + toolMask?: ToolMask + ): Promise { + return this.withConversationAndPlatformLock(conversation, async () => { + const [platform] = parseRawModelName(conversation.model) + this.registerPlatformConversation(platform, conversation.id) + + const chatInterface = await this.ensureChatInterface(conversation) + const abortController = new AbortController() + const activeRequest = this.registerRequest( + conversation.id, + requestId, + conversation.chatMode, + abortController, + session + ) + + try { + const humanMessage = new HumanMessage({ + content: message.content, + name: message.name, + id: session.userId, + additional_kwargs: { + ...message.additional_kwargs, + preset: conversation.preset + } + }) + + const mask = + toolMask ?? + (await this.service.resolveToolMask({ + session, + conversation, + bindingKey: conversation.bindingKey + })) + + const chainValues = await chatInterface.chat({ + message: humanMessage, + events: event, + stream, + conversationId: conversation.id, + requestId, + session, + variables, + signal: abortController.signal, + postHandler, + messageQueue: activeRequest.messageQueue, + toolMask: mask, + onAgentEvent: async (agentEvent) => { + if (agentEvent.type === 'round-decision') { + activeRequest.lastDecision = agentEvent.canContinue + if (agentEvent.canContinue == null) { + return + } + + for (const resolve of activeRequest.roundDecisionResolvers) { + resolve(agentEvent.canContinue) + } + activeRequest.roundDecisionResolvers = [] + } + } + }) + + const aiMessage = chainValues.message as AIMessage + const reasoningContent = aiMessage.additional_kwargs + ?.reasoning_content as string + const reasoningTime = aiMessage.additional_kwargs + ?.reasoning_time as number + const usageMetadata = aiMessage.usage_metadata + const additionalReplyMessages: Message[] = [] + + if ( + reasoningContent != null && + reasoningContent.length > 0 && + this.service.currentConfig.showThoughtMessage + ) { + additionalReplyMessages.push({ + content: + reasoningTime != null + ? `Thought for ${reasoningTime / 1000} seconds: \n\n${reasoningContent}` + : `Thought: \n\n${reasoningContent}` + }) + } + + if ( + usageMetadata != null && + usageMetadata.total_tokens > 0 && + this.service.currentConfig.showThoughtMessage + ) { + additionalReplyMessages.push({ + content: formatUsageMetadataMessage(usageMetadata) + }) + } + + return { + content: aiMessage.content as string, + additionalReplyMessages + } + } finally { + this.completeRequest(conversation.id, requestId, session) + } + }) + } + + updateConversationRecord(conversation: ConversationRecord) { + const cached = this.interfaces.get(conversation.id) + if (cached != null) { + cached.conversation = conversation + this.interfaces.set(conversation.id, cached) + } + } + + getCachedConversations(): [string, RuntimeConversationEntry][] { + return Array.from(this.interfaces.entries()) + } + + async withConversationLock( + conversationId: string, + callback: () => Promise + ): Promise { + const requestId = randomUUID() + try { + await this.conversationQueue.add(conversationId, requestId) + await this.conversationQueue.wait(conversationId, requestId, 0) + return await callback() + } finally { + await this.conversationQueue.remove(conversationId, requestId) + } + } + + async withConversationAndPlatformLock( + conversation: ConversationRecord, + callback: () => Promise + ): Promise { + const requestId = randomUUID() + const modelRequestId = randomUUID() + const [platform] = parseRawModelName(conversation.model) + const client = await this.platformService.getClient(platform) + + if (client.value == null) { + await this.service.awaitLoadPlatform(platform) + } + + if (client.value == null) { + throw new ChatLunaError( + ChatLunaErrorCode.UNKNOWN_ERROR, + new Error(`Platform ${platform} is not available`) + ) + } + + const config = client.value.configPool.getConfig(true).value + + try { + await Promise.all([ + this.conversationQueue.add(conversation.id, requestId), + this.modelQueue.add(platform, modelRequestId) + ]) + + await Promise.all([ + this.conversationQueue.wait(conversation.id, requestId, 0), + this.modelQueue.wait( + platform, + modelRequestId, + config.concurrentMaxSize + ) + ]) + + return await callback() + } finally { + await Promise.all([ + this.conversationQueue.remove(conversation.id, requestId), + this.modelQueue.remove(platform, modelRequestId) + ]) + } + } + + registerPlatformConversation(platform: string, conversationId: string) { + const values = this.platformIndex.get(platform) ?? new Set() + values.add(conversationId) + this.platformIndex.set(platform, values) + } + + unregisterPlatformConversation(platform: string, conversationId: string) { + const values = this.platformIndex.get(platform) + if (values == null) { + return + } + values.delete(conversationId) + if (values.size === 0) { + this.platformIndex.delete(platform) + } + } + + registerRequest( + conversationId: string, + requestId: string, + chatMode: string, + abortController: AbortController, + session?: Session + ) { + const activeRequest: ActiveRequest = { + requestId, + conversationId, + sessionId: session?.sid, + abortController, + chatMode, + messageQueue: new MessageQueue(), + roundDecisionResolvers: [] + } + + this.requestsById.set(requestId, abortController) + this.activeByConversation.set(conversationId, activeRequest) + if (session?.sid != null) { + this.requestBySession.set(session.sid, requestId) + } + return activeRequest + } + + completeRequest( + conversationId: string, + requestId: string, + session?: Session + ) { + this.requestsById.delete(requestId) + if (session?.sid != null) { + this.requestBySession.delete(session.sid) + } + + const active = this.activeByConversation.get(conversationId) + if (active?.requestId === requestId) { + for (const resolve of active.roundDecisionResolvers) { + resolve(false) + } + this.activeByConversation.delete(conversationId) + } + } + + stopRequest(requestId: string) { + const abortController = this.requestsById.get(requestId) + if (abortController == null) { + return false + } + abortController.abort(createAbortError()) + this.requestsById.delete(requestId) + return true + } + + getRequestIdBySession(session: Session) { + if (session.sid == null) { + return undefined + } + return this.requestBySession.get(session.sid) + } + + async appendPendingMessage( + conversationId: string, + message: HumanMessage, + chatMode?: string + ): Promise { + if (chatMode != null && chatMode !== 'plugin') { + return false + } + + const activeRequest = this.activeByConversation.get(conversationId) + + if (activeRequest == null || activeRequest.chatMode !== 'plugin') { + return false + } + + if (activeRequest.lastDecision != null) { + if (activeRequest.lastDecision) { + activeRequest.messageQueue.push(message) + } + return activeRequest.lastDecision + } + + return new Promise((resolve) => { + activeRequest.roundDecisionResolvers.push((canContinue) => { + if (canContinue) { + activeRequest.messageQueue.push(message) + } + resolve(canContinue) + }) + }) + } + + async clearConversationCache(conversationId: string) { + return this.interfaces.delete(conversationId) + } + + async clearConversationHistory(conversation: ConversationRecord) { + return this.withConversationLock(conversation.id, async () => { + const chatInterface = await this.ensureChatInterface(conversation) + await this.service.ctx.root.parallel( + 'chatluna/conversation-before-clear-history', + { + conversation, + chatInterface + } + ) + await this.service.ctx.root.parallel( + 'chatluna/clear-chat-history', + conversation.id, + chatInterface + ) + await chatInterface.clearChatHistory() + chatInterface.dispose?.() + this.interfaces.delete(conversation.id) + await this.service.ctx.root.parallel( + 'chatluna/conversation-after-clear-history', + { + conversation, + chatInterface + } + ) + }) + } + + async compressConversation( + conversation: ConversationRecord, + force = false + ) { + return this.withConversationAndPlatformLock(conversation, async () => { + const chatInterface = await this.ensureChatInterface(conversation) + return await chatInterface.compressContext(force) + }) + } + + async clearConversationInterface(conversation: ConversationRecord) { + return this.withConversationLock(conversation.id, async () => { + const cached = this.interfaces.get(conversation.id) + const existed = cached != null + await this.service.ctx.root.parallel( + 'chatluna/conversation-before-cache-clear', + { + conversation, + chatInterface: cached?.chatInterface + } + ) + cached?.chatInterface?.dispose?.() + this.interfaces.delete(conversation.id) + await this.service.ctx.root.parallel( + 'chatluna/conversation-after-cache-clear', + { + conversation + } + ) + return existed + }) + } + + dispose(platform?: string) { + for (const controller of this.requestsById.values()) { + controller.abort(createAbortError()) + } + + if (platform == null) { + for (const value of this.interfaces.values()) { + value.chatInterface.dispose?.() + } + this.interfaces.clear() + this.requestsById.clear() + this.activeByConversation.clear() + this.requestBySession.clear() + this.platformIndex.clear() + return + } + + const conversationIds = this.platformIndex.get(platform) + if (conversationIds == null) { + return + } + + for (const conversationId of conversationIds) { + this.interfaces.get(conversationId)?.chatInterface.dispose?.() + this.interfaces.delete(conversationId) + this.activeByConversation.delete(conversationId) + } + + this.platformIndex.delete(platform) + } +} + +function formatUsageMetadataMessage(usage: { + input_tokens?: number + output_tokens?: number + total_tokens?: number +}) { + return [ + 'Token usage:', + `- input: ${usage.input_tokens ?? 0}`, + `- output: ${usage.output_tokens ?? 0}`, + `- total: ${usage.total_tokens ?? 0}` + ].join('\n') +} diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts new file mode 100644 index 000000000..74c17ba22 --- /dev/null +++ b/packages/core/src/services/conversation_types.ts @@ -0,0 +1,195 @@ +import type { Session } from 'koishi' + +export type ConversationStatus = 'active' | 'archived' | 'deleted' | 'broken' +export type RouteMode = 'personal' | 'shared' | 'custom' +export type ConstraintManageMode = 'anyone' | 'admin' +export type ConstraintPrincipalType = 'user' | 'guild' +export type ConstraintPermission = 'view' | 'manage' +export type ArchiveState = 'ready' | 'restoring' | 'broken' + +export interface ConversationCompressionRecord { + count?: number + summary?: string + compressedAt?: Date | string | null + originalMessageCount?: number + remainingMessageCount?: number + tokenUsage?: number + inputTokens?: number + outputTokens?: number + reducedTokens?: number + reducedPercent?: number + [key: string]: unknown +} + +export interface ConversationRecord { + id: string + seq?: number + bindingKey: string + title: string + model: string + preset: string + chatMode: string + createdBy: string + createdAt: Date + updatedAt: Date + lastChatAt?: Date | null + status: ConversationStatus + latestMessageId?: string | null + additional_kwargs?: string | null + compression?: string | null + archivedAt?: Date | null + archiveId?: string | null + legacyRoomId?: number | null + legacyMeta?: string | null +} + +export interface MessageRecord { + id: string + conversationId: string + parentId?: string | null + role: string + text?: string | null + content?: ArrayBuffer | null + name?: string | null + tool_call_id?: string | null + tool_calls?: unknown + additional_kwargs?: string | null + additional_kwargs_binary?: ArrayBuffer | null + rawId?: string | null + createdAt?: Date | null +} + +export interface BindingRecord { + bindingKey: string + activeConversationId?: string | null + lastConversationId?: string | null + updatedAt: Date +} + +export interface ConstraintRecord { + id?: number + name: string + enabled: boolean + priority: number + createdBy: string + createdAt: Date + updatedAt: Date + platform?: string | null + selfId?: string | null + guildId?: string | null + channelId?: string | null + direct?: boolean | null + users?: string | null + excludeUsers?: string | null + routeMode?: RouteMode | null + routeKey?: string | null + defaultModel?: string | null + defaultPreset?: string | null + defaultChatMode?: string | null + fixedModel?: string | null + fixedPreset?: string | null + fixedChatMode?: string | null + lockConversation?: boolean | null + allowNew?: boolean | null + allowSwitch?: boolean | null + allowArchive?: boolean | null + allowExport?: boolean | null + manageMode?: ConstraintManageMode | null +} + +export interface ArchiveRecord { + id: string + conversationId: string + path: string + formatVersion: number + messageCount: number + checksum?: string | null + size: number + state: ArchiveState + createdAt: Date + restoredAt?: Date | null +} + +export interface ACLRecord { + conversationId: string + principalType: ConstraintPrincipalType + principalId: string + permission: ConstraintPermission +} + +export interface MetaRecord { + key: string + value?: string | null + updatedAt: Date +} + +export interface ResolvedConstraint { + routeMode: RouteMode + bindingKey: string + baseKey: string + constraints: ConstraintRecord[] + defaultModel?: string | null + defaultPreset?: string | null + defaultChatMode?: string | null + fixedModel?: string | null + fixedPreset?: string | null + fixedChatMode?: string | null + lockConversation: boolean + allowNew: boolean + allowSwitch: boolean + allowArchive: boolean + allowExport: boolean + manageMode: ConstraintManageMode +} + +export interface ResolvedConversationContext { + bindingKey: string + presetLane?: string + conversation?: ConversationRecord | null + binding?: BindingRecord | null + effectiveModel?: string | null + effectivePreset?: string | null + effectiveChatMode?: string | null + constraint: ResolvedConstraint +} + +export interface ResolveConversationContextOptions { + presetLane?: string + conversationId?: string +} + +export function computeBaseBindingKey( + session: Session, + routeMode: RouteMode, + routeKey?: string | null +): string { + const platform = session.platform ?? 'unknown' + const selfId = session.selfId ?? 'unknown' + const guildId = session.guildId ?? 'unknown' + const userId = session.userId ?? 'unknown' + + if (routeMode === 'custom') { + return `custom:${routeKey ?? 'default'}` + } + + if (routeMode === 'shared') { + return `shared:${platform}:${selfId}:${guildId}` + } + + if (session.isDirect) { + return `personal:${platform}:${selfId}:direct:${userId}` + } + + return `personal:${platform}:${selfId}:${guildId}:${userId}` +} + +export function applyPresetLane( + bindingKey: string, + presetLane?: string +): string { + if (presetLane == null || presetLane.length === 0) { + return bindingKey + } + + return `${bindingKey}:preset:${presetLane}` +} diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index b92108dc5..c6905ee69 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -1,18 +1,81 @@ import { Awaitable, Session } from 'koishi' import { - ConversationRoom, - ConversationRoomGroupInfo, - ConversationRoomMemberInfo, - ConversationRoomUserInfo -} from '../types' + ACLRecord, + ArchiveRecord, + BindingRecord, + ConstraintRecord, + ConversationRecord, + MessageRecord, + MetaRecord +} from './conversation_types' import { ChatLunaService } from './chat' -import { BaseMessageChunk } from '@langchain/core/messages' +import { + AIMessage, + BaseMessageChunk, + MessageContent, + MessageType +} from '@langchain/core/messages' import { AgentAction, SubagentContext, ToolMask } from 'koishi-plugin-chatluna/llm-core/agent' +export interface LegacyConversationRecord { + id: string + latestId?: string | null + additional_kwargs?: string | null + updatedAt?: Date | null +} + +export interface LegacyMessageRecord { + text?: MessageContent | null + content?: ArrayBuffer | null + id: string + rawId?: string | null + role: MessageType + conversation: string + name?: string | null + tool_call_id?: string | null + tool_calls?: AIMessage['tool_calls'] + additional_kwargs?: string | null + additional_kwargs_binary?: ArrayBuffer | null + parent?: string | null +} + +export interface LegacyRoomRecord { + visibility: 'public' | 'private' | 'template_clone' + roomMasterId: string + roomName: string + roomId: number + conversationId?: string | null + preset: string + model: string + chatMode: string + password?: string | null + autoUpdate?: boolean | null + updatedTime: Date +} + +export interface LegacyRoomMemberRecord { + userId: string + roomId: number + mute?: boolean | null + roomPermission: 'owner' | 'admin' | 'member' +} + +export interface LegacyRoomGroupRecord { + groupId: string + roomId: number + roomVisibility: 'public' | 'private' | 'template_clone' +} + +export interface LegacyUserRecord { + groupId?: string | null + defaultRoomId: number + userId: string +} + export interface ChatEvents { 'llm-new-token'?: (token: string) => Promise 'llm-queue-waiting'?: (size: number) => Promise @@ -38,10 +101,19 @@ declare module 'koishi' { } interface Tables { - chathub_room: ConversationRoom - chathub_room_member: ConversationRoomMemberInfo - chathub_room_group_member: ConversationRoomGroupInfo - chathub_user: ConversationRoomUserInfo + chathub_conversation: LegacyConversationRecord + chathub_message: LegacyMessageRecord + chathub_room: LegacyRoomRecord + chathub_room_member: LegacyRoomMemberRecord + chathub_room_group_member: LegacyRoomGroupRecord + chathub_user: LegacyUserRecord + chatluna_conversation: ConversationRecord + chatluna_message: MessageRecord + chatluna_binding: BindingRecord + chatluna_constraint: ConstraintRecord + chatluna_archive: ArchiveRecord + chatluna_acl: ACLRecord + chatluna_meta: MetaRecord } } @@ -57,8 +129,8 @@ export * from '@chatluna/shared-prompt-renderer' export interface ToolMaskArg { session: Session - room?: ConversationRoom - source?: 'chatluna' | 'character' + conversation?: ConversationRecord + bindingKey?: string } export type ToolMaskResolver = ( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5246faffb..f996f7671 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,42 +1,6 @@ import { MessageContent } from '@langchain/core/messages' import { h, Session } from 'koishi' -export interface ConversationRoom { - visibility: 'public' | 'private' | 'template_clone' - roomMasterId: string - roomName: string - roomId: number - conversationId?: string - preset: string - model: string - chatMode: string - password?: string - autoUpdate?: boolean - updatedTime: Date - - // allowGroups?: string[] - // allowUsers?: string[] -} - -export interface ConversationRoomMemberInfo { - userId: string - roomId: number - mute?: boolean - roomPermission: 'owner' | 'admin' | 'member' -} - -export interface ConversationRoomGroupInfo { - groupId: string - roomId: number - roomVisibility: 'public' | 'private' | 'template_clone' -} - -export interface ConversationRoomUserInfo { - groupId?: string - defaultRoomId: number - userId: string -} - /** * 渲染参数 */ diff --git a/packages/core/src/utils/archive.ts b/packages/core/src/utils/archive.ts new file mode 100644 index 000000000..7713d6b71 --- /dev/null +++ b/packages/core/src/utils/archive.ts @@ -0,0 +1,62 @@ +import fs from 'fs/promises' +import type { Context } from 'koishi' + +export async function purgeArchivedConversation( + ctx: Context, + conversation: { + id: string + archiveId?: string | null + } +) { + if (conversation.archiveId != null) { + const archive = await ctx.chatluna.conversation.getArchive( + conversation.archiveId + ) + + if (archive?.path) { + await fs.rm(archive.path, { + recursive: true, + force: true + }) + } + + await ctx.database.remove('chatluna_archive', { + id: conversation.archiveId + }) + } + + const bindings = await ctx.database.get('chatluna_binding', {}) + for (const binding of bindings) { + if ( + binding.activeConversationId !== conversation.id && + binding.lastConversationId !== conversation.id + ) { + continue + } + + await ctx.database.upsert('chatluna_binding', [ + { + ...binding, + activeConversationId: + binding.activeConversationId === conversation.id + ? null + : binding.activeConversationId, + lastConversationId: + binding.lastConversationId === conversation.id + ? null + : binding.lastConversationId, + updatedAt: new Date() + } + ]) + } + + await ctx.database.remove('chatluna_message', { + conversationId: conversation.id + }) + await ctx.database.remove('chatluna_acl', { + conversationId: conversation.id + }) + await ctx.database.remove('chatluna_conversation', { + id: conversation.id + }) +} diff --git a/packages/core/src/utils/chat_request.ts b/packages/core/src/utils/chat_request.ts new file mode 100644 index 000000000..b340c395b --- /dev/null +++ b/packages/core/src/utils/chat_request.ts @@ -0,0 +1,26 @@ +import { randomUUID } from 'crypto' +import type { Session } from 'koishi' + +const requestIdCache = new Map() + +function getRequestCacheKey(session: Session, conversationId: string) { + return session.userId + '-' + (session.guildId ?? '') + '-' + conversationId +} + +export function getRequestId(session: Session, conversationId: string) { + return requestIdCache.get(getRequestCacheKey(session, conversationId)) +} + +export function createRequestId( + session: Session, + conversationId: string, + requestId: string = randomUUID() +) { + requestIdCache.set(getRequestCacheKey(session, conversationId), requestId) + + return requestId +} + +export function deleteRequestId(session: Session, conversationId: string) { + requestIdCache.delete(getRequestCacheKey(session, conversationId)) +} diff --git a/packages/core/src/utils/compression.ts b/packages/core/src/utils/compression.ts new file mode 100644 index 000000000..32d4f34fa --- /dev/null +++ b/packages/core/src/utils/compression.ts @@ -0,0 +1,36 @@ +import { gunzip, gzip } from 'zlib' +import { promisify } from 'util' + +const gzipAsync = promisify(gzip) +const gunzipAsync = promisify(gunzip) + +type Encoding = 'buffer' | 'base64' | 'hex' +type BufferType = T extends 'buffer' + ? Buffer + : T extends 'base64' + ? string + : T extends 'hex' + ? string + : never + +export async function gzipEncode( + data: string, + encoding: T = 'buffer' as T +): Promise> { + const result = await gzipAsync(data) + return (encoding === 'buffer' + ? result + : result.toString(encoding)) as BufferType +} + +export async function gzipDecode(data: ArrayBuffer | ArrayBufferView) { + const buffer = ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : Buffer.from(data) + return (await gunzipAsync(buffer)).toString() +} + +export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer +} diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index 479bdac62..60106f15a 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -1,5 +1,3 @@ -import { logger as koishiLogger } from 'koishi-plugin-chatluna' - // eslint-disable-next-line prefer-const export let ERROR_FORMAT_TEMPLATE = '使用 ChatLuna 时出现错误,错误码为 %s。请联系开发者以解决此问题。' @@ -19,19 +17,18 @@ export class ChatLunaError extends Error { super(ERROR_FORMAT_TEMPLATE.replace('%s', errorCode.toString())) this.name = 'ChatLunaError' - const logger = koishiLogger ?? console if (!isTimeout) { - logger.error( + console.error( '='.repeat(20) + 'ChatLunaError:' + errorCode + '='.repeat(20) ) } if (originError && !isTimeout) { - logger.error(originError) + console.error(originError) if (originError.cause) { - logger.error(originError.cause) + console.error(originError.cause) } } else if (!isTimeout) { - logger.error(this) + console.error(this) } } diff --git a/packages/core/src/utils/koishi.ts b/packages/core/src/utils/koishi.ts index 59d5491cf..b6af413c8 100644 --- a/packages/core/src/utils/koishi.ts +++ b/packages/core/src/utils/koishi.ts @@ -1,4 +1,4 @@ -import { ForkScope, h } from 'koishi' +import { ForkScope, h, Session, User } from 'koishi' import { PromiseLikeDisposable } from 'koishi-plugin-chatluna/utils/types' import { Marked, Token } from 'marked' import type { MessageContent } from '@langchain/core/messages' @@ -28,6 +28,20 @@ export function forkScopeToDisposable(scope: ForkScope): PromiseLikeDisposable { } } +export async function checkAdmin(session: Session) { + const tested = await session.app.permissions.test('chatluna:admin', session) + + if (tested) { + return true + } + + const user = await session.getUser(session.userId, [ + 'authority' + ]) + + return user?.authority >= 3 +} + const tagRegExp = /<(\/?)([^!\s>/]+)([^>]*?)\s*(\/?)>/ function renderInlineToken(token: Token, platform?: string): h | undefined { diff --git a/packages/core/src/utils/lock.ts b/packages/core/src/utils/lock.ts index bcdbf81d6..08a6bce31 100644 --- a/packages/core/src/utils/lock.ts +++ b/packages/core/src/utils/lock.ts @@ -1,5 +1,5 @@ -import { Time } from 'koishi' -import { withResolver } from 'koishi-plugin-chatluna/utils/promise' +const TIME_MINUTE = 60 * 1000 +import { withResolver } from './promise' export class ObjectLock { private _lock: boolean = false @@ -10,7 +10,7 @@ export class ObjectLock { private readonly _timeout: number - constructor(timeout = Time.minute * 3) { + constructor(timeout = TIME_MINUTE * 3) { this._timeout = timeout } diff --git a/packages/core/src/utils/message_content.ts b/packages/core/src/utils/message_content.ts new file mode 100644 index 000000000..202385a4d --- /dev/null +++ b/packages/core/src/utils/message_content.ts @@ -0,0 +1,58 @@ +import { type BaseMessage } from '@langchain/core/messages' + +export interface PresetLaneParseResult { + preset?: string + content?: string + queryOnly: boolean +} + +export function getMessageContent(message: BaseMessage['content']) { + if (typeof message === 'string') { + return message + } + + if (message == null) { + return '' + } + + const buffer: string[] = [] + for (const part of message) { + if (part.type === 'text') { + buffer.push(part.text as string) + } + } + return buffer.join('') +} + +export function parsePresetLaneInput( + text: string, + aliases: string[] +): PresetLaneParseResult | null { + const source = text.trim() + if (source.length === 0) { + return null + } + + const idx = source.search(/[\s::,,]/) + const head = (idx === -1 ? source : source.slice(0, idx)).trim() + if (head.length === 0) { + return null + } + + const normalized = head.toLocaleLowerCase() + const preset = aliases.find( + (alias) => alias.toLocaleLowerCase() === normalized + ) + if (preset == null) { + return null + } + + const rest = (idx === -1 ? '' : source.slice(idx)) + .replace(/^[\s::,,]+/, '') + .trim() + return { + preset, + content: rest, + queryOnly: rest.length === 0 + } +} diff --git a/packages/core/src/utils/model.ts b/packages/core/src/utils/model.ts new file mode 100644 index 000000000..e9dbff1f4 --- /dev/null +++ b/packages/core/src/utils/model.ts @@ -0,0 +1,7 @@ +export function parseRawModelName(modelName: string): [string, string] { + if (modelName == null || modelName.trim().length < 1) { + return [undefined, undefined] + } + + return modelName.split(/(?<=^[^\/]+)\//) as [string, string] +} diff --git a/packages/core/src/utils/queue.ts b/packages/core/src/utils/queue.ts index 3b652fb35..131b6dc4a 100644 --- a/packages/core/src/utils/queue.ts +++ b/packages/core/src/utils/queue.ts @@ -1,10 +1,7 @@ -import { Time } from 'koishi' -import { - ChatLunaError, - ChatLunaErrorCode -} from 'koishi-plugin-chatluna/utils/error' -import { ObjectLock } from 'koishi-plugin-chatluna/utils/lock' -import { withResolver } from 'koishi-plugin-chatluna/utils/promise' +import { ChatLunaError, ChatLunaErrorCode } from './error' +import { ObjectLock } from './lock' +import { withResolver } from './promise' +const TIME_MINUTE = 60 * 1000 interface QueueItem { requestId: string @@ -22,9 +19,10 @@ export class RequestIdQueue { private readonly _maxQueueSize = 50 private readonly _queueTimeout: number - constructor(queueTimeout = Time.minute * 3) { + constructor(queueTimeout = TIME_MINUTE * 3) { this._queueTimeout = queueTimeout - setInterval(() => this.cleanup(), queueTimeout) + const timer = setInterval(() => this.cleanup(), queueTimeout) + timer.unref?.() } public async add(key: string, requestId: string) { diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 75b225ffd..d149d3c4e 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -2,7 +2,8 @@ import { BaseMessage } from '@langchain/core/messages' import type { HandlerResult, PostHandler } from './types' import { Context, h, Session } from 'koishi' import type {} from '@koishijs/censor' -import { Config, ConversationRoom } from 'koishi-plugin-chatluna' +import type { Config } from 'koishi-plugin-chatluna' +import type { ConversationRecord } from '../services/conversation_types' import { gunzip, gzip } from 'zlib' import { promisify } from 'util' import { chatLunaFetch } from 'koishi-plugin-chatluna/utils/request' @@ -489,8 +490,13 @@ export async function hashString( export function getSystemPromptVariables( session: Session, config: Config, - room: ConversationRoom + conversation: Pick< + ConversationRecord, + 'preset' | 'id' | 'updatedAt' | 'lastChatAt' + > ) { + const lastActiveAt = conversation.lastChatAt ?? conversation.updatedAt + return { name: config.botNames[0], date: new Date().toLocaleString(), @@ -512,16 +518,16 @@ export function getSystemPromptVariables( session.username ), built: { - preset: room.preset, + preset: conversation.preset, platform: session.platform, - conversationId: room.conversationId + conversationId: conversation.id }, noop: '', time: new Date().toLocaleTimeString(), weekday: getCurrentWeekday(), idle_duration: getTimeDiffFormat( new Date().getTime(), - room.updatedTime.getTime() + lastActiveAt.getTime() ) } } @@ -548,7 +554,10 @@ export async function formatUserPromptString( presetTemplate: PresetTemplate, session: Session, prompt: string, - room: ConversationRoom + conversation: Pick< + ConversationRecord, + 'preset' | 'id' | 'updatedAt' | 'lastChatAt' + > ) { return await session.app.chatluna.promptRenderer.renderTemplate( presetTemplate.formatUserPromptString, @@ -563,7 +572,7 @@ export async function formatUserPromptString( session.username ), prompt, - ...getSystemPromptVariables(session, config, room) + ...getSystemPromptVariables(session, config, conversation) }, { configurable: { diff --git a/packages/core/test/conversation-runtime.test.ts b/packages/core/test/conversation-runtime.test.ts new file mode 100644 index 000000000..6a3fe341d --- /dev/null +++ b/packages/core/test/conversation-runtime.test.ts @@ -0,0 +1,1606 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { HumanMessage } from '@langchain/core/messages' +import { Pagination } from '../src/utils/pagination' +import { + bufferToArrayBuffer, + gzipDecode, + gzipEncode +} from '../src/utils/compression' +import { + getMessageContent, + parsePresetLaneInput +} from '../src/utils/message_content' +import { + applyPresetLane, + type ACLRecord, + computeBaseBindingKey, + type ArchiveRecord, + type BindingRecord, + type ConstraintRecord, + type ConversationRecord, + type MessageRecord +} from '../src/services/conversation_types' +import { ConversationService } from '../src/services/conversation' +import { ConversationRuntime } from '../src/services/conversation_runtime' +import { + createLegacyBindingKey, + inferLegacyGroupRouteModes +} from '../src/migration/validators' +import { + getLegacySchemaSentinel, + getLegacySchemaSentinelDir +} from '../src/migration/legacy_tables' +import { runRoomToConversationMigration } from '../src/migration/room_to_conversation' +import { purgeArchivedConversation } from '../src/utils/archive' + +type BindingSessionShape = { + platform?: string + selfId?: string + guildId?: string + userId?: string + channelId?: string + sid?: string + isDirect?: boolean + authority?: number +} + +type TableRow = Record +type Tables = Record + +class FakeDatabase { + tables: Tables = { + chatluna_meta: [], + chatluna_conversation: [], + chatluna_binding: [], + chatluna_archive: [], + chatluna_message: [], + chatluna_constraint: [], + chatluna_acl: [], + chathub_room_member: [], + chathub_room_group_member: [], + chathub_user: [], + chathub_room: [], + chathub_message: [], + chathub_conversation: [] + } + + async get(table: string, query: Record) { + return (this.tables[table] ?? []).filter((row) => + Object.entries(query).every(([key, expected]) => { + const actual = row[key] + + if ( + expected != null && + typeof expected === 'object' && + '$in' in expected + ) { + return expected.$in.includes(actual) + } + + if (Array.isArray(expected)) { + return expected.includes(actual) + } + + return actual === expected + }) + ) + } + + async create(table: string, row: TableRow) { + ;(this.tables[table] ??= []).push({ ...row }) + } + + async upsert(table: string, rows: TableRow[]) { + const target = (this.tables[table] ??= []) + + for (const row of rows) { + const index = target.findIndex((current) => + this.samePrimary(table, current, row) + ) + if (index >= 0) { + target[index] = { ...target[index], ...row } + } else { + target.push({ ...row }) + } + } + } + + async remove(table: string, query: Record) { + const target = (this.tables[table] ??= []) + this.tables[table] = target.filter( + (row) => + !Object.entries(query).every(([key, expected]) => { + const actual = row[key] + if (Array.isArray(expected)) { + return expected.includes(actual) + } + return actual === expected + }) + ) + } + + async drop(table: string) { + this.tables[table] = [] + } + + private samePrimary(table: string, left: TableRow, right: TableRow) { + if (table === 'chatluna_binding') { + return left.bindingKey === right.bindingKey + } + + if (table === 'chatluna_archive') { + return left.id === right.id + } + + if (table === 'chatluna_message') { + return left.id === right.id + } + + if (table === 'chatluna_constraint') { + return left.id != null && left.id === right.id + } + + if (table === 'chatluna_meta') { + return left.key === right.key + } + + if (table === 'chatluna_acl') { + return ( + left.conversationId === right.conversationId && + left.principalType === right.principalType && + left.principalId === right.principalId && + left.permission === right.permission + ) + } + + return left.id === right.id + } +} + +function createSession(overrides: Partial = {}) { + return { + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + channelId: 'channel', + userId: 'user', + sid: 'discord:channel:user', + isDirect: false, + authority: 3, + ...overrides + } as BindingSessionShape as never +} + +function createConfig(overrides: Record = {}) { + return { + defaultModel: 'test-platform/test-model', + defaultPreset: 'default-preset', + defaultChatMode: 'plugin', + defaultGroupRouteMode: 'shared', + ...overrides + } as never +} + +async function createService( + options: { + tables?: Partial + baseDir?: string + clearCache?: (conversation: ConversationRecord) => Promise + config?: Record + } = {} +) { + const database = new FakeDatabase() + const events: { name: string; args: unknown[] }[] = [] + + for (const [table, rows] of Object.entries(options.tables ?? {})) { + database.tables[table] = (rows ?? []).map((row) => ({ ...row })) + } + + const clearCacheCalls: string[] = [] + const ctx = { + database, + logger: { + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + success: () => {} + }, + baseDir: + options.baseDir ?? + (await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-core-test-'))), + root: { + parallel: async (name: string, ...args: unknown[]) => { + events.push({ name, args }) + } + }, + chatluna: { + conversation: { + getArchive: async (id: string) => + database.tables.chatluna_archive.find( + (item) => item.id === id + ) as ArchiveRecord | undefined + }, + conversationRuntime: { + clearConversationInterface: async ( + conversation: ConversationRecord + ) => { + clearCacheCalls.push(conversation.id) + await options.clearCache?.(conversation) + return true + } + } + } + } as never + + const service = new ConversationService(ctx, createConfig(options.config)) + + return { + service, + database, + ctx, + clearCacheCalls, + events + } +} + +function createConversation( + overrides: Partial = {} +): ConversationRecord { + const now = new Date('2026-03-21T00:00:00.000Z') + + return { + id: 'conversation-1', + seq: 1, + bindingKey: 'shared:discord:bot:guild', + title: 'Conversation 1', + model: 'test-platform/test-model', + preset: 'default-preset', + chatMode: 'plugin', + createdBy: 'user', + createdAt: now, + updatedAt: now, + lastChatAt: now, + status: 'active', + latestMessageId: 'message-2', + additional_kwargs: null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: null, + legacyMeta: null, + ...overrides + } +} + +function createMessage(overrides: Partial = {}): MessageRecord { + return { + id: 'message-1', + conversationId: 'conversation-1', + parentId: null, + role: 'human', + text: 'hello', + content: null, + name: 'user', + tool_call_id: null, + tool_calls: null, + additional_kwargs: null, + additional_kwargs_binary: null, + rawId: null, + createdAt: new Date('2026-03-21T00:00:00.000Z'), + ...overrides + } +} + +test('conversation-first runtime removes legacy room entry points from active source tree', async () => { + const coreSrc = path.resolve(import.meta.dirname, '../src') + + await Promise.all([ + assert.rejects(fs.access(path.join(coreSrc, 'chains', 'rooms.ts'))), + assert.rejects(fs.access(path.join(coreSrc, 'commands', 'room.ts'))), + assert.rejects(fs.access(path.join(coreSrc, 'middlewares', 'room'))), + assert.rejects( + fs.access(path.join(coreSrc, 'middlewares', 'auth', 'mute_user.ts')) + ), + assert.rejects( + fs.access( + path.join(coreSrc, 'middlewares', 'model', 'request_model.ts') + ) + ), + assert.rejects(fs.access(path.join(coreSrc, 'legacy', 'types.ts'))) + ]) +}) + +test('computeBaseBindingKey builds personal direct bindings', () => { + const bindingKey = computeBaseBindingKey( + { + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + userId: 'user', + isDirect: true + } as BindingSessionShape as never, + 'personal' + ) + + assert.equal(bindingKey, 'personal:discord:bot:direct:user') +}) + +test('computeBaseBindingKey builds shared guild bindings', () => { + const bindingKey = computeBaseBindingKey( + { + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + userId: 'user', + isDirect: false + } as BindingSessionShape as never, + 'shared' + ) + + assert.equal(bindingKey, 'shared:discord:bot:guild') +}) + +test('applyPresetLane appends preset lane when provided', () => { + assert.equal( + applyPresetLane('personal:discord:bot:guild:user', 'helper'), + 'personal:discord:bot:guild:user:preset:helper' + ) + assert.equal( + applyPresetLane('personal:discord:bot:guild:user', undefined), + 'personal:discord:bot:guild:user' + ) +}) + +test('parsePresetLaneInput normalizes alias prefixes and bare queries', () => { + assert.deepEqual( + parsePresetLaneInput('Sydney: hello', ['sydney', 'helper']), + { + preset: 'sydney', + content: 'hello', + queryOnly: false + } + ) + assert.deepEqual(parsePresetLaneInput('helper,', ['sydney', 'helper']), { + preset: 'helper', + content: '', + queryOnly: true + }) + assert.equal(parsePresetLaneInput('plain message', ['sydney']), null) +}) + +test('pagination normalizes page and limit bounds', async () => { + const pagination = new Pagination({ + page: 1, + limit: 2, + formatItem: (item) => `item:${item}`, + formatString: { + top: 'top', + bottom: 'bottom', + pages: 'page [page]/[total]' + } + }) + + await pagination.push([1, 2, 3], 'numbers') + + assert.deepEqual(await pagination.getPage(0, 0, 'numbers'), [1]) + assert.equal( + await pagination.getFormattedPage(2, 2, 'numbers'), + 'top\nitem:3\nbottom\npage 2/2' + ) +}) + +test('gzip helpers round-trip archived payload content', async () => { + const json = JSON.stringify({ text: 'hello', values: [1, 2, 3] }) + const compressed = await gzipEncode(json) + const arrayBuffer = bufferToArrayBuffer(compressed) + + assert.equal(await gzipDecode(arrayBuffer), json) +}) + +test('getMessageContent flattens structured text parts', () => { + assert.equal( + getMessageContent([ + { type: 'text', text: 'hello ' }, + { type: 'image_url', image_url: 'https://example.com/x.png' }, + { type: 'text', text: 'world' } + ] as never), + 'hello world' + ) +}) + +test('ConversationService resolves routed constraints and preset lanes', async () => { + const highPriorityConstraint: ConstraintRecord = { + id: 2, + name: 'shared route', + enabled: true, + priority: 10, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + guildId: 'guild', + routeMode: 'custom', + routeKey: 'team-alpha', + defaultModel: 'constraint/model', + fixedPreset: 'fixed-preset', + allowNew: false, + allowSwitch: true, + allowArchive: false, + allowExport: true, + manageMode: 'anyone' + } + const lowerPriorityConstraint: ConstraintRecord = { + id: 1, + name: 'fallback defaults', + enabled: true, + priority: 1, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + defaultPreset: 'constraint-default-preset', + defaultChatMode: 'chat-mode-x', + fixedModel: null, + fixedChatMode: 'fixed-chat-mode' + } + + const { service } = await createService({ + tables: { + chatluna_constraint: [ + highPriorityConstraint as unknown as TableRow, + lowerPriorityConstraint as unknown as TableRow + ] + } + }) + + const resolved = await service.resolveConstraint(createSession(), { + presetLane: 'helper' + }) + + assert.equal(resolved.routeMode, 'custom') + assert.equal(resolved.baseKey, 'custom:team-alpha') + assert.equal(resolved.bindingKey, 'custom:team-alpha:preset:helper') + assert.equal(resolved.defaultModel, 'constraint/model') + assert.equal(resolved.defaultPreset, 'helper') + assert.equal(resolved.fixedPreset, 'fixed-preset') + assert.equal(resolved.fixedChatMode, 'fixed-chat-mode') + assert.equal(resolved.allowNew, false) + assert.equal(resolved.allowArchive, false) + assert.equal(resolved.manageMode, 'anyone') +}) + +test('runRoomToConversationMigration migrates legacy rooms, messages, bindings, and ACL', async () => { + const { ctx, database } = await createService({ + tables: { + chathub_room: [ + { + roomId: 1, + roomName: 'Legacy Room', + roomMasterId: 'owner', + conversationId: 'legacy-conversation', + preset: 'legacy-preset', + model: 'legacy/model', + chatMode: 'plugin', + visibility: 'private', + password: 'secret', + autoUpdate: true, + updatedTime: new Date('2026-03-21T00:00:00.000Z') + } as unknown as TableRow + ], + chathub_conversation: [ + { + id: 'legacy-conversation', + latestId: 'legacy-message-1', + additional_kwargs: '{"topic":"legacy"}', + updatedAt: new Date('2026-03-21T01:00:00.000Z') + } as unknown as TableRow + ], + chathub_message: [ + { + id: 'legacy-message-1', + conversation: 'legacy-conversation', + parent: null, + role: 'human', + text: 'hello from legacy room', + content: null, + name: 'owner', + tool_call_id: null, + tool_calls: null, + additional_kwargs: null, + additional_kwargs_binary: null, + rawId: null + } as unknown as TableRow + ], + chathub_room_member: [ + { + roomId: 1, + userId: 'owner', + roomPermission: 'owner', + mute: false + } as unknown as TableRow, + { + roomId: 1, + userId: 'guest', + roomPermission: 'member', + mute: false + } as unknown as TableRow, + { + roomId: 1, + userId: 'helper', + roomPermission: 'member', + mute: false + } as unknown as TableRow + ], + chathub_room_group_member: [ + { + roomId: 1, + groupId: 'guild', + roomVisibility: 'private' + } as unknown as TableRow, + { + roomId: 1, + groupId: 'guild-2', + roomVisibility: 'private' + } as unknown as TableRow + ], + chathub_user: [ + { + userId: 'owner', + groupId: 'guild', + defaultRoomId: 1 + } as unknown as TableRow + ] + } + }) + + await runRoomToConversationMigration(ctx, createConfig()) + + const conversation = database.tables + .chatluna_conversation[0] as ConversationRecord + const binding = database.tables.chatluna_binding[0] as BindingRecord + const message = database.tables.chatluna_message[0] as MessageRecord + const meta = database.tables.chatluna_meta.find( + (item) => item.key === 'validation_result' + ) as { value?: string | null } + + assert.equal(conversation.id, 'legacy-conversation') + assert.equal(conversation.bindingKey, 'custom:legacy:room:1') + assert.equal(conversation.latestMessageId, 'legacy-message-1') + assert.equal(conversation.legacyRoomId, 1) + assert.equal(binding.activeConversationId, 'legacy-conversation') + assert.equal(message.conversationId, 'legacy-conversation') + assert.equal(database.tables.chatluna_acl.length, 6) + assert.equal(JSON.parse(meta.value ?? '{}').passed, true) +}) + +test('inferLegacyGroupRouteModes preserves per-group legacy routing semantics', () => { + const users = [ + { userId: 'a', groupId: 'g1', defaultRoomId: 1 }, + { userId: 'b', groupId: 'g1', defaultRoomId: 2 }, + { userId: 'c', groupId: 'g2', defaultRoomId: 3 } + ] + const rooms = [ + { + roomId: 1, + visibility: 'private' + }, + { + roomId: 2, + visibility: 'private' + }, + { + roomId: 3, + visibility: 'public' + } + ] as never + const groups = [ + { roomId: 1, groupId: 'g1', roomVisibility: 'private' }, + { roomId: 2, groupId: 'g1', roomVisibility: 'private' }, + { roomId: 3, groupId: 'g2', roomVisibility: 'public' } + ] as never + + const modes = inferLegacyGroupRouteModes(users as never, rooms, groups) + + assert.equal( + createLegacyBindingKey(users[0] as never, modes), + 'personal:legacy:legacy:g1:a' + ) + assert.equal( + createLegacyBindingKey(users[1] as never, modes), + 'personal:legacy:legacy:g1:b' + ) + assert.equal( + createLegacyBindingKey(users[2] as never, modes), + 'shared:legacy:legacy:g2' + ) +}) + +test('ConversationService ensureActiveConversation creates conversation and binding on default route', async () => { + const { service, database } = await createService() + + const resolved = await service.ensureActiveConversation(createSession()) + const binding = database.tables.chatluna_binding[0] as BindingRecord + const conversation = database.tables + .chatluna_conversation[0] as ConversationRecord + + assert.equal(resolved.bindingKey, 'shared:discord:bot:guild') + assert.equal(binding.activeConversationId, resolved.conversation.id) + assert.equal(conversation.id, resolved.conversation.id) + assert.equal(conversation.seq, 1) + assert.equal(conversation.legacyRoomId, null) + assert.equal(conversation.legacyMeta, null) + assert.equal(resolved.effectiveModel, 'test-platform/test-model') + assert.equal(resolved.effectivePreset, 'default-preset') + assert.equal(resolved.effectiveChatMode, 'plugin') +}) + +test('ConversationService restores archived current conversation automatically', async () => { + const archivedConversation = createConversation({ + id: 'conversation-archived', + status: 'archived', + archiveId: 'archive-1', + archivedAt: new Date('2026-03-22T00:00:00.000Z'), + latestMessageId: null + }) + const archivedPayload = { + formatVersion: 1, + exportedAt: '2026-03-22T00:00:00.000Z', + conversation: { + ...archivedConversation, + status: 'active', + archiveId: null, + archivedAt: null, + createdAt: archivedConversation.createdAt.toISOString(), + updatedAt: archivedConversation.updatedAt.toISOString(), + lastChatAt: archivedConversation.lastChatAt?.toISOString() ?? null + }, + messages: [] + } + const archiveDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-restore-test-') + ) + const archivePath = path.join(archiveDir, 'archive.json.gz') + await fs.writeFile( + archivePath, + await gzipEncode(JSON.stringify(archivedPayload)) + ) + + const { service } = await createService({ + baseDir: archiveDir, + tables: { + chatluna_conversation: [ + archivedConversation as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-archived', + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_archive: [ + { + id: 'archive-1', + conversationId: 'conversation-archived', + path: archivePath, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + ] + } + }) + + const resolved = await service.ensureActiveConversation(createSession()) + + assert.equal(resolved.conversation.id, 'conversation-archived') + assert.equal(resolved.conversation.status, 'active') + assert.equal(resolved.conversation.archiveId, null) +}) + +test('ConversationService does not auto-restore archived conversation without manage permission', async () => { + const archivedConversation = createConversation({ + id: 'conversation-archived-locked', + status: 'archived', + archiveId: 'archive-locked', + archivedAt: new Date('2026-03-22T00:00:00.000Z'), + latestMessageId: null + }) + const archiveDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-restore-blocked-test-') + ) + const archivePath = path.join(archiveDir, 'archive.json.gz') + await fs.writeFile( + archivePath, + await gzipEncode( + JSON.stringify({ + formatVersion: 1, + exportedAt: '2026-03-22T00:00:00.000Z', + conversation: { + ...archivedConversation, + status: 'active', + archiveId: null, + archivedAt: null, + createdAt: archivedConversation.createdAt.toISOString(), + updatedAt: archivedConversation.updatedAt.toISOString(), + lastChatAt: + archivedConversation.lastChatAt?.toISOString() ?? null + }, + messages: [] + }) + ) + ) + + const { service } = await createService({ + baseDir: archiveDir, + tables: { + chatluna_conversation: [ + archivedConversation as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: archivedConversation.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_archive: [ + { + id: 'archive-locked', + conversationId: archivedConversation.id, + path: archivePath, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + ] + } + }) + + await assert.rejects( + service.ensureActiveConversation(createSession({ authority: 1 })), + /administrator permission/ + ) +}) + +test('ConversationService ensureActiveConversation respects personal default group route mode', async () => { + const { service } = await createService({ + config: { + defaultGroupRouteMode: 'personal' + } + }) + + const resolved = await service.ensureActiveConversation(createSession()) + + assert.equal(resolved.bindingKey, 'personal:discord:bot:guild:user') + assert.equal(resolved.conversation.seq, 1) +}) + +test('ConversationService switches and resolves friendly conversation targets within the same binding', async () => { + const older = createConversation({ + id: 'conversation-old', + seq: 1, + title: 'Older', + lastChatAt: new Date('2026-03-20T00:00:00.000Z') + }) + const newer = createConversation({ + id: 'conversation-new', + seq: 2, + title: 'Newer Topic', + lastChatAt: new Date('2026-03-22T00:00:00.000Z') + }) + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + older as unknown as TableRow, + newer as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-old', + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + const listed = await service.listConversations(createSession()) + assert.deepEqual( + listed.map((item) => item.id), + ['conversation-new', 'conversation-old'] + ) + + const bySeq = await service.switchConversation(createSession(), { + targetConversation: '2' + }) + const byId = await service.switchConversation(createSession(), { + targetConversation: 'conversation-old' + }) + const byTitle = await service.switchConversation(createSession(), { + targetConversation: 'newer topic' + }) + const byPartialTitle = await service.switchConversation(createSession(), { + targetConversation: 'Topic' + }) + const binding = database.tables.chatluna_binding[0] as BindingRecord + + assert.equal(bySeq.id, 'conversation-new') + assert.equal(byId.id, 'conversation-old') + assert.equal(byTitle.id, 'conversation-new') + assert.equal(byPartialTitle.id, 'conversation-new') + assert.equal(binding.activeConversationId, 'conversation-new') + assert.equal(binding.lastConversationId, 'conversation-old') +}) + +test('ConversationService rejects ambiguous friendly conversation targets', async () => { + const alpha = createConversation({ + id: 'conversation-alpha', + seq: 1, + title: 'Project Alpha' + }) + const beta = createConversation({ + id: 'conversation-beta', + seq: 2, + title: 'Project Beta' + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + alpha as unknown as TableRow, + beta as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-alpha', + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + await assert.rejects( + service.switchConversation(createSession(), { + targetConversation: 'Project' + }), + /Conversation target is ambiguous\./ + ) +}) + +test('ConversationService exports, archives, and restores conversations with legacy migration fields', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-archive-test-') + ) + const exportPath = path.join(tempDir, 'conversation-export.md') + + const conversation = createConversation({ + id: 'conversation-archive', + title: 'Archived Conversation', + latestMessageId: 'message-2', + legacyRoomId: 42, + legacyMeta: JSON.stringify({ roomName: 'legacy-room' }) + }) + const messageA = createMessage({ + id: 'message-1', + conversationId: 'conversation-archive', + text: 'hello' + }) + const messageB = createMessage({ + id: 'message-2', + conversationId: 'conversation-archive', + parentId: 'message-1', + role: 'ai', + text: 'world' + }) + + const { service, database, clearCacheCalls } = await createService({ + baseDir: tempDir, + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-archive', + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_message: [ + messageA as unknown as TableRow, + messageB as unknown as TableRow + ] + } + }) + + const exported = await service.exportConversation(createSession(), { + conversationId: 'conversation-archive', + outputPath: exportPath + }) + const exportMarkdown = await fs.readFile(exported.path, 'utf8') + + assert.match(exportMarkdown, /# Archived Conversation/) + assert.match(exportMarkdown, /hello/) + assert.match(exportMarkdown, /world/) + + const archived = await service.archiveConversation(createSession(), { + conversationId: 'conversation-archive' + }) + const archivedConversation = await service.getConversation( + 'conversation-archive' + ) + const archiveRecord = archived.archive as ArchiveRecord + const manifest = JSON.parse( + await fs.readFile(path.join(archived.path, 'manifest.json'), 'utf8') + ) + + assert.equal(archivedConversation.status, 'archived') + assert.equal(archivedConversation.archiveId, archiveRecord.id) + assert.equal(manifest.conversationId, 'conversation-archive') + assert.equal(database.tables.chatluna_message.length, 0) + assert.deepEqual(clearCacheCalls, ['conversation-archive']) + + const restored = await service.restoreConversation(createSession(), { + conversationId: 'conversation-archive' + }) + const restoredMessages = await service.listMessages('conversation-archive') + const restoredArchive = await service.getArchive(archiveRecord.id) + + assert.equal(restored.status, 'active') + assert.equal(restored.archiveId, null) + assert.equal(restored.legacyRoomId, 42) + assert.equal( + restored.legacyMeta, + JSON.stringify({ roomName: 'legacy-room' }) + ) + assert.equal(restoredMessages.length, 2) + assert.equal(restoredArchive.state, 'ready') + assert.notEqual(restoredArchive.restoredAt, null) +}) + +test('ConversationService records compression metadata and use rejects fixed fields', async () => { + const conversation = createConversation() + const message = createMessage({ + id: 'summary', + conversationId: conversation.id, + text: 'compressed summary', + name: 'infinite_context' + }) + const { service } = await createService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_message: [message as unknown as TableRow], + chatluna_constraint: [ + { + id: 1, + name: 'managed:discord:bot:guild:guild', + enabled: true, + priority: 1000, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + channelId: null, + direct: false, + users: null, + excludeUsers: null, + routeMode: null, + routeKey: null, + defaultModel: null, + defaultPreset: null, + defaultChatMode: null, + fixedModel: 'fixed-model', + fixedPreset: null, + fixedChatMode: null, + lockConversation: false, + allowNew: true, + allowSwitch: true, + allowArchive: true, + allowExport: true, + manageMode: 'anyone' + } as unknown as TableRow + ] + } + }) + + const updated = await service.recordCompression(conversation.id, { + compressed: true, + inputTokens: 120, + outputTokens: 30, + reducedTokens: 90, + reducedPercent: 75, + originalMessageCount: 8, + remainingMessageCount: 1 + }) + const compression = JSON.parse(updated.compression ?? '{}') + + assert.equal(compression.count, 1) + assert.equal(compression.summary, 'compressed summary') + assert.equal(compression.outputTokens, 30) + assert.equal(compression.originalMessageCount, 8) + assert.equal(compression.remainingMessageCount, 1) + + await assert.rejects( + service.updateConversationUsage(createSession(), { + model: 'other-model' + }), + /fixed to fixed-model/ + ) +}) + +test('ConversationService blocks raw id access outside route without ACL and allows manage ACL', async () => { + const local = createConversation({ + id: 'conversation-local', + bindingKey: 'shared:discord:bot:guild' + }) + const remote = createConversation({ + id: 'conversation-remote', + bindingKey: 'shared:discord:bot:other-guild' + }) + const acl: ACLRecord = { + conversationId: remote.id, + principalType: 'user', + principalId: 'user', + permission: 'manage' + } + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + local as unknown as TableRow, + remote as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: local.bindingKey, + activeConversationId: local.id, + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + await assert.rejects( + service.resolveCommandConversation(createSession({ authority: 1 }), { + conversationId: remote.id, + permission: 'manage' + }), + /does not belong to current route/ + ) + + database.tables.chatluna_acl.push(acl as unknown as TableRow) + + const resolved = await service.resolveCommandConversation( + createSession({ authority: 1 }), + { + conversationId: remote.id, + permission: 'manage' + } + ) + + assert.equal(resolved.id, remote.id) +}) + +test('ConversationService resolves ACL-backed cross-route targetConversation', async () => { + const local = createConversation({ + id: 'conversation-local-2', + bindingKey: 'shared:discord:bot:guild' + }) + const remote = createConversation({ + id: 'conversation-remote-2', + bindingKey: 'shared:discord:bot:other-guild', + title: 'Remote Shared Topic', + seq: 7 + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + local as unknown as TableRow, + remote as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: local.bindingKey, + activeConversationId: local.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_acl: [ + { + conversationId: remote.id, + principalType: 'user', + principalId: 'user', + permission: 'manage' + } as unknown as TableRow + ] + } + }) + + const byId = await service.resolveCommandConversation( + createSession({ authority: 1 }), + { + targetConversation: remote.id, + permission: 'manage' + } + ) + const byTitle = await service.resolveCommandConversation( + createSession({ authority: 1 }), + { + targetConversation: 'Remote Shared Topic', + permission: 'manage' + } + ) + + assert.equal(byId?.id, remote.id) + assert.equal(byTitle?.id, remote.id) +}) + +test('getLegacySchemaSentinel resolves under baseDir', () => { + assert.equal( + getLegacySchemaSentinel('C:/chatluna-base'), + path.resolve( + 'C:/chatluna-base', + 'data/chatluna/temp/legacy-schema-disabled.json' + ) + ) +}) + +test('getLegacySchemaSentinelDir matches resolved sentinel parent', () => { + assert.equal( + getLegacySchemaSentinelDir('C:/chatluna-base'), + path.dirname(getLegacySchemaSentinel('C:/chatluna-base')) + ) +}) + +test('ConversationService upserts and removes ACL records coherently', async () => { + const conversation = createConversation({ id: 'conversation-acl' }) + const { service } = await createService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow] + } + }) + + const created = await service.upsertAcl(conversation.id, [ + { + principalType: 'user', + principalId: 'alice', + permission: 'view' + }, + { + principalType: 'guild', + principalId: 'guild-x', + permission: 'manage' + } + ]) + + assert.equal(created.length, 2) + + const afterRemove = await service.removeAcl(conversation.id, [ + { + principalType: 'user', + principalId: 'alice' + } + ]) + + assert.deepEqual(afterRemove, [ + { + conversationId: conversation.id, + principalType: 'guild', + principalId: 'guild-x', + permission: 'manage' + } + ]) +}) + +test('ConversationService emits conversation lifecycle events for switch archive restore delete and compression', async () => { + const active = createConversation({ id: 'conversation-active', seq: 1 }) + const next = createConversation({ + id: 'conversation-next', + seq: 2, + title: 'Next Topic' + }) + const { service, events } = await createService({ + tables: { + chatluna_conversation: [ + active as unknown as TableRow, + next as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: active.bindingKey, + activeConversationId: active.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_message: [ + createMessage({ + id: 'summary-message', + conversationId: next.id, + name: 'infinite_context', + text: 'summary' + }) as unknown as TableRow + ] + } + }) + + await service.switchConversation(createSession(), { + targetConversation: next.id + }) + await service.recordCompression(next.id, { + compressed: true, + inputTokens: 100, + outputTokens: 20, + reducedTokens: 80, + reducedPercent: 80, + originalMessageCount: 10, + remainingMessageCount: 2 + }) + + const archived = await service.archiveConversation(createSession(), { + conversationId: next.id + }) + await service.restoreConversation(createSession(), { + conversationId: next.id, + archiveId: archived.archive.id + }) + await service.deleteConversation(createSession(), { + conversationId: next.id + }) + + assert.deepEqual( + events.map((item) => item.name), + [ + 'chatluna/conversation-before-switch', + 'chatluna/conversation-after-switch', + 'chatluna/conversation-compressed', + 'chatluna/conversation-before-archive', + 'chatluna/conversation-after-archive', + 'chatluna/conversation-before-restore', + 'chatluna/conversation-after-restore', + 'chatluna/conversation-before-delete', + 'chatluna/conversation-after-delete' + ] + ) +}) + +test('conversation cleanup listeners in downstream packages use conversation lifecycle events', async () => { + const files = [ + path.resolve( + import.meta.dirname, + '../../extension-long-memory/src/service/memory.ts' + ), + path.resolve( + import.meta.dirname, + '../../extension-agent/src/service/skills.ts' + ), + path.resolve( + import.meta.dirname, + '../../extension-agent/src/cli/service.ts' + ), + path.resolve( + import.meta.dirname, + '../../extension-tools/src/plugins/todos.ts' + ) + ] + + for (const file of files) { + const content = await fs.readFile(file, 'utf8') + assert.equal(content.includes('chatluna/clear-chat-history'), false) + assert.equal( + content.includes('chatluna/conversation-after-clear-history'), + true + ) + } +}) + +test('purgeArchivedConversation removes archive directory and clears both binding pointers', async () => { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-purge-archive-') + ) + const archiveDir = path.join(dir, 'archive-dir') + await fs.mkdir(archiveDir, { recursive: true }) + await fs.writeFile(path.join(archiveDir, 'manifest.json'), '{}', 'utf8') + + const conversation = createConversation({ + id: 'conversation-purge', + status: 'archived', + archiveId: 'archive-purge' + }) + const { ctx, database } = await createService({ + baseDir: dir, + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: conversation.id, + updatedAt: new Date() + } + ], + chatluna_archive: [ + { + id: 'archive-purge', + conversationId: conversation.id, + path: archiveDir, + formatVersion: 1, + messageCount: 1, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + ], + chatluna_message: [ + createMessage({ + conversationId: conversation.id + }) as unknown as TableRow + ], + chatluna_acl: [ + { + conversationId: conversation.id, + principalType: 'user', + principalId: 'user', + permission: 'view' + } as unknown as TableRow + ] + } + }) + + await purgeArchivedConversation(ctx, conversation) + + assert.rejects(fs.access(archiveDir)) + assert.equal(database.tables.chatluna_conversation.length, 0) + assert.equal(database.tables.chatluna_archive.length, 0) + assert.equal(database.tables.chatluna_message.length, 0) + assert.equal(database.tables.chatluna_acl.length, 0) + assert.equal(database.tables.chatluna_binding[0].activeConversationId, null) + assert.equal(database.tables.chatluna_binding[0].lastConversationId, null) +}) + +test('wipe source keeps legacy migration and runtime table cleanup wired in', async () => { + const source = await fs.readFile( + path.resolve(import.meta.dirname, '../src/middlewares/system/wipe.ts'), + 'utf8' + ) + + assert.match(source, /LEGACY_MIGRATION_TABLES/) + assert.match(source, /LEGACY_RUNTIME_TABLES/) + assert.match(source, /for \(const table of LEGACY_MIGRATION_TABLES\)/) + assert.match(source, /for \(const table of LEGACY_RUNTIME_TABLES\)/) +}) + +test('ConversationService supports sampled end-to-end lifecycle flow', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-e2e-flow-')) + const { service } = await createService({ baseDir: dir }) + const session = createSession() + + const created = await service.ensureActiveConversation(session, { + presetLane: 'helper' + }) + const listed = await service.listConversations(session, { + presetLane: 'helper' + }) + const renamed = await service.renameConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper', + title: 'Helper Session' + }) + const exported = await service.exportConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const archived = await service.archiveConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const restored = await service.restoreConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const removed = await service.deleteConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + + assert.equal(listed.length, 1) + assert.equal(renamed.title, 'Helper Session') + assert.equal(path.extname(exported.path), '.md') + assert.equal(archived.conversation.status, 'archived') + assert.equal(restored.status, 'active') + assert.equal(removed.status, 'deleted') +}) + +test('ConversationRuntime registers, resolves, and stops active requests', () => { + const runtime = new ConversationRuntime({} as never) + const abortController = new AbortController() + const session = createSession({ sid: 'sid-1' }) + + runtime.registerRequest( + 'conversation-1', + 'request-1', + 'plugin', + abortController, + session + ) + + assert.equal(runtime.getRequestIdBySession(session), 'request-1') + assert.equal(runtime.stopRequest('request-1'), true) + assert.equal(abortController.signal.aborted, true) + assert.equal(runtime.stopRequest('missing-request'), false) + + runtime.completeRequest('conversation-1', 'request-1', session) + assert.equal(runtime.getRequestIdBySession(session), undefined) +}) + +test('ConversationRuntime appendPendingMessage waits for plugin round decisions', async () => { + const runtime = new ConversationRuntime({} as never) + const activeRequest = runtime.registerRequest( + 'conversation-1', + 'request-1', + 'plugin', + new AbortController(), + createSession() + ) + + const pushed: HumanMessage[] = [] + const originalPush = activeRequest.messageQueue.push.bind( + activeRequest.messageQueue + ) + activeRequest.messageQueue.push = ((message: HumanMessage) => { + pushed.push(message) + return originalPush(message) + }) as typeof activeRequest.messageQueue.push + + const pending = runtime.appendPendingMessage( + 'conversation-1', + new HumanMessage('follow-up') + ) + + assert.equal(activeRequest.roundDecisionResolvers.length, 1) + activeRequest.roundDecisionResolvers[0](true) + assert.equal(await pending, true) + assert.equal(pushed.length, 1) + assert.equal(String(pushed[0].content), 'follow-up') + + activeRequest.lastDecision = false + assert.equal( + await runtime.appendPendingMessage( + 'conversation-1', + new HumanMessage('ignored'), + 'plugin' + ), + false + ) + assert.equal( + await runtime.appendPendingMessage( + 'conversation-1', + new HumanMessage('wrong-mode'), + 'chat' + ), + false + ) +}) + +test('ConversationRuntime clears cached interfaces and dispatches compression', async () => { + const cleared: string[] = [] + const compressed: boolean[] = [] + const runtime = new ConversationRuntime({ + createChatInterface: async () => ({ + clearChatHistory: async () => { + cleared.push('cleared') + }, + compressContext: async (force: boolean) => { + compressed.push(force) + return { + compressed: true, + inputTokens: 10, + outputTokens: 5, + reducedPercent: 50 + } + } + }), + awaitLoadPlatform: async () => {}, + platform: { + getClient: async () => ({ + value: { + configPool: { + getConfig: () => ({ + value: { + concurrentMaxSize: 1 + } + }) + } + } + }) + }, + ctx: { + root: { + parallel: async () => {} + } + } + } as never) + + const conversation = createConversation({ + id: 'conversation-runtime', + model: 'platform/model' + }) + + await runtime.ensureChatInterface(conversation) + assert.equal(runtime.getCachedConversations().length, 1) + + await runtime.clearConversationHistory(conversation) + assert.deepEqual(cleared, ['cleared']) + assert.equal(runtime.getCachedConversations().length, 0) + + const result = await runtime.compressConversation(conversation, true) + assert.equal(result.compressed, true) + assert.deepEqual(compressed, [true]) +}) + +test('ConversationRuntime dispose clears platform-scoped and global state', () => { + const runtime = new ConversationRuntime({} as never) + const session = createSession({ sid: 'sid-dispose' }) + const conversation = createConversation({ id: 'conversation-dispose' }) + + runtime.interfaces.set(conversation.id, { + conversation, + chatInterface: {} as never + }) + runtime.registerPlatformConversation('platform-a', conversation.id) + runtime.registerRequest( + conversation.id, + 'request-dispose', + 'plugin', + new AbortController(), + session + ) + + runtime.dispose('platform-a') + assert.equal(runtime.interfaces.has(conversation.id), false) + assert.equal(runtime.activeByConversation.has(conversation.id), false) + + runtime.registerRequest( + 'conversation-2', + 'request-2', + 'plugin', + new AbortController(), + createSession({ sid: 'sid-2' }) + ) + runtime.dispose() + assert.equal(runtime.requestsById.size, 0) + assert.equal(runtime.requestBySession.size, 0) + assert.equal(runtime.platformIndex.size, 0) +}) diff --git a/packages/extension-agent/src/cli/service.ts b/packages/extension-agent/src/cli/service.ts index 77f6ceed6..400f210d9 100644 --- a/packages/extension-agent/src/cli/service.ts +++ b/packages/extension-agent/src/cli/service.ts @@ -36,13 +36,29 @@ export class ChatLunaAgentCliService { private ctx: Context, private getAgent: () => ChatLunaAgentService ) { - this.ctx.on('chatluna/clear-chat-history', async (conversationId) => { + const clear = (conversationId: string) => { const prefix = `${conversationId}\n` for (const key of this._sessions.keys()) { if (key.startsWith(prefix)) { this._sessions.delete(key) } } + } + + this.ctx.on( + 'chatluna/conversation-after-clear-history', + async (payload) => { + clear(payload.conversation.id) + } + ) + this.ctx.on('chatluna/conversation-after-archive', async (payload) => { + clear(payload.conversation.id) + }) + this.ctx.on('chatluna/conversation-after-restore', async (payload) => { + clear(payload.conversation.id) + }) + this.ctx.on('chatluna/conversation-after-delete', async (payload) => { + clear(payload.conversation.id) }) } diff --git a/packages/extension-agent/src/service/permissions.ts b/packages/extension-agent/src/service/permissions.ts index e39dcd735..486e1d97d 100644 --- a/packages/extension-agent/src/service/permissions.ts +++ b/packages/extension-agent/src/service/permissions.ts @@ -35,19 +35,12 @@ export class ChatLunaAgentPermissionService { async start() { this._toolMaskDispose = this.ctx.chatluna.registerToolMaskResolver( 'agent', - async ({ - room, - session, - source - }: { - room?: { chatMode?: string } - session: Session - source?: 'chatluna' | 'character' - }) => { - const mask = this.createMainToolMask( - session, - source ?? 'chatluna' - ) + async ({ conversation, session }) => { + if (conversation && conversation.chatMode !== 'plugin') { + return + } + + const mask = this.createMainToolMask() return { ...mask, toolCallMask: await this.createToolCallMask(session, mask) diff --git a/packages/extension-agent/src/service/skills.ts b/packages/extension-agent/src/service/skills.ts index ad3b583e7..b5a674ca6 100644 --- a/packages/extension-agent/src/service/skills.ts +++ b/packages/extension-agent/src/service/skills.ts @@ -84,9 +84,22 @@ export class ChatLunaAgentSkillsService implements SkillToolService { } ) - ctx.on('chatluna/clear-chat-history', async (conversationId) => { + const clear = (conversationId: string) => { this._active.delete(conversationId) this._requested.delete(conversationId) + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-archive', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-restore', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-delete', async (payload) => { + clear(payload.conversation.id) }) } diff --git a/packages/extension-long-memory/src/plugins/add_memory.ts b/packages/extension-long-memory/src/plugins/add_memory.ts index 26f767caf..3d1d2f3b0 100644 --- a/packages/extension-long-memory/src/plugins/add_memory.ts +++ b/packages/extension-long-memory/src/plugins/add_memory.ts @@ -1,8 +1,9 @@ import { Context } from 'koishi' -import { ConversationRoom, logger } from 'koishi-plugin-chatluna' +import { logger } from 'koishi-plugin-chatluna' import { ChainMiddlewareRunStatus } from 'koishi-plugin-chatluna/chains' import { Config, MemoryRetrievalLayerType, MemoryType } from '../index' import { randomUUID } from 'crypto' +import { getMemoryScope } from '../utils/conversation' export function apply(ctx: Context, config: Config) { const chain = ctx.chatluna.chatChain @@ -11,18 +12,14 @@ export function apply(ctx: Context, config: Config) { .middleware( 'add_memory', async (session, context) => { - let { + const { command, - options: { type, content, room, view } + options: { type, content, view, conversationId, presetLane } } = context if (command !== 'add_memory') return ChainMiddlewareRunStatus.SKIPPED - if (!type) { - type = room.preset - } - let parsedLayerType = MemoryRetrievalLayerType.USER if (view != null) { @@ -40,14 +37,21 @@ export function apply(ctx: Context, config: Config) { } try { + const scope = await getMemoryScope(ctx, session, { + conversationId, + presetLane, + type + }) + + if (scope == null) { + context.message = session.text('.add_failed') + return ChainMiddlewareRunStatus.STOP + } + const layers = await ctx.chatluna_long_memory.initMemoryLayers( - { - presetId: type as string, - guildId: session.guildId || session.channelId, - userId: session.userId - }, - room.conversationId, + scope.info, + scope.conversation.id, parsedLayerType ) @@ -69,7 +73,7 @@ export function apply(ctx: Context, config: Config) { ) ) - await ctx.chatluna.clearCache(room) + await ctx.chatluna.clearCache(scope.conversation) context.message = session.text('.add_success') } catch (error) { @@ -91,6 +95,5 @@ declare module 'koishi-plugin-chatluna/chains' { interface ChainMiddlewareContextOptions { content?: string - room?: ConversationRoom } } diff --git a/packages/extension-long-memory/src/plugins/clear_memory.ts b/packages/extension-long-memory/src/plugins/clear_memory.ts index 471fface7..4c17b9306 100644 --- a/packages/extension-long-memory/src/plugins/clear_memory.ts +++ b/packages/extension-long-memory/src/plugins/clear_memory.ts @@ -3,6 +3,7 @@ import { logger } from 'koishi-plugin-chatluna' import { ChainMiddlewareRunStatus } from 'koishi-plugin-chatluna/chains' import { MemoryRetrievalLayerType } from '../types' import { Config } from '..' +import { getMemoryScope } from '../utils/conversation' export function apply(ctx: Context, config: Config) { const chain = ctx.chatluna.chatChain @@ -11,18 +12,14 @@ export function apply(ctx: Context, config: Config) { .middleware( 'clear_memory', async (session, context) => { - let { + const { command, - options: { type, room, view } + options: { type, view, conversationId, presetLane } } = context if (command !== 'clear_memory') return ChainMiddlewareRunStatus.SKIPPED - if (!type) { - type = room.preset - } - let parsedLayerType = MemoryRetrievalLayerType.USER if (view != null) { @@ -40,14 +37,21 @@ export function apply(ctx: Context, config: Config) { } try { + const scope = await getMemoryScope(ctx, session, { + conversationId, + presetLane, + type + }) + + if (scope == null) { + context.message = session.text('.clear_failed') + return ChainMiddlewareRunStatus.STOP + } + const layers = await ctx.chatluna_long_memory.initMemoryLayers( - { - presetId: type as string, - guildId: session.guildId || session.channelId, - userId: session.userId - }, - room.conversationId, + scope.info, + scope.conversation.id, parsedLayerType ) @@ -55,7 +59,7 @@ export function apply(ctx: Context, config: Config) { layers.map((layer) => layer.clearMemories()) ) - await ctx.chatluna.clearCache(room) + await ctx.chatluna.clearCache(scope.conversation) context.message = session.text('.clear_success') } catch (error) { logger?.error(error) diff --git a/packages/extension-long-memory/src/plugins/delete_memory.ts b/packages/extension-long-memory/src/plugins/delete_memory.ts index 34f9dfa54..53450ae12 100644 --- a/packages/extension-long-memory/src/plugins/delete_memory.ts +++ b/packages/extension-long-memory/src/plugins/delete_memory.ts @@ -2,6 +2,7 @@ import { Context } from 'koishi' import { logger } from 'koishi-plugin-chatluna' import { ChainMiddlewareRunStatus } from 'koishi-plugin-chatluna/chains' import { Config, MemoryRetrievalLayerType } from '../index' +import { getMemoryScope } from '../utils/conversation' export function apply(ctx: Context, config: Config) { const chain = ctx.chatluna.chatChain @@ -10,18 +11,14 @@ export function apply(ctx: Context, config: Config) { .middleware( 'delete_memory', async (session, context) => { - let { + const { command, - options: { type, room, ids, view } + options: { type, ids, view, conversationId, presetLane } } = context if (command !== 'delete_memory') return ChainMiddlewareRunStatus.SKIPPED - if (!type) { - type = room.preset - } - let parsedLayerType = MemoryRetrievalLayerType.USER if (view != null) { @@ -39,14 +36,21 @@ export function apply(ctx: Context, config: Config) { } try { + const scope = await getMemoryScope(ctx, session, { + conversationId, + presetLane, + type + }) + + if (scope == null) { + context.message = session.text('.delete_failed') + return ChainMiddlewareRunStatus.STOP + } + const layers = await ctx.chatluna_long_memory.initMemoryLayers( - { - presetId: type as string, - guildId: session.guildId || session.channelId, - userId: session.userId - }, - room.conversationId, + scope.info, + scope.conversation.id, parsedLayerType ) @@ -54,7 +58,7 @@ export function apply(ctx: Context, config: Config) { layers.map((layer) => layer.deleteMemories(ids)) ) - await ctx.chatluna.clearCache(room) + await ctx.chatluna.clearCache(scope.conversation) context.message = session.text('.delete_success') } catch (error) { logger?.error(error) diff --git a/packages/extension-long-memory/src/plugins/edit_memory.ts b/packages/extension-long-memory/src/plugins/edit_memory.ts index fba45c22a..5363ee62a 100644 --- a/packages/extension-long-memory/src/plugins/edit_memory.ts +++ b/packages/extension-long-memory/src/plugins/edit_memory.ts @@ -4,6 +4,7 @@ import { ChainMiddlewareRunStatus } from 'koishi-plugin-chatluna/chains' import { MemoryRetrievalLayerType, MemoryType } from '../types' import { Config } from '..' import { createDefaultMemory } from '../utils/memory' +import { getMemoryScope } from '../utils/conversation' export function apply(ctx: Context, config: Config) { const chain = ctx.chatluna.chatChain @@ -11,18 +12,20 @@ export function apply(ctx: Context, config: Config) { .middleware( 'edit_memory', async (session, context) => { - let { + const { command, - options: { type, room, memoryId, view } + options: { + type, + memoryId, + view, + conversationId, + presetLane + } } = context if (command !== 'edit_memory') return ChainMiddlewareRunStatus.SKIPPED - if (!type) { - type = room.preset - } - let parsedLayerType = MemoryRetrievalLayerType.USER if (view != null) { @@ -43,16 +46,27 @@ export function apply(ctx: Context, config: Config) { const content = await session.prompt() + const scope = await getMemoryScope(ctx, session, { + conversationId, + presetLane, + type + }) + + if (scope == null) { + context.message = session.text('.edit_failed') + return ChainMiddlewareRunStatus.STOP + } + const layers = await ctx.chatluna_long_memory.initMemoryLayers( { - presetId: type as string, + ...scope.info, userId: session.userId, guildId: session.guildId || session.channelId, type: parsedLayerType, memoryId }, - room.conversationId, + scope.conversation.id, parsedLayerType ) @@ -70,7 +84,7 @@ export function apply(ctx: Context, config: Config) { layers.map((layer) => layer.addMemories([memory])) ) - await ctx.chatluna.clearCache(room) + await ctx.chatluna.clearCache(scope.conversation) context.message = session.text('.edit_success') } catch (error) { logger?.error(error) diff --git a/packages/extension-long-memory/src/plugins/search_memory.ts b/packages/extension-long-memory/src/plugins/search_memory.ts index 416ca8dd5..5a3a70772 100644 --- a/packages/extension-long-memory/src/plugins/search_memory.ts +++ b/packages/extension-long-memory/src/plugins/search_memory.ts @@ -4,6 +4,7 @@ import { ChainMiddlewareRunStatus } from 'koishi-plugin-chatluna/chains' import { logger } from 'koishi-plugin-chatluna' import { EnhancedMemory, MemoryRetrievalLayerType } from '../types' import { Config } from '..' +import { getMemoryScope } from '../utils/conversation' export function apply(ctx: Context, config: Config) { const chain = ctx.chatluna.chatChain @@ -21,19 +22,23 @@ export function apply(ctx: Context, config: Config) { .middleware( 'search_memory', async (session, context) => { - let { + const { command, - options: { page, limit, query, type, room, view } + options: { + page, + limit, + query, + type, + view, + conversationId, + presetLane + } } = context if (command !== 'search_memory') { return ChainMiddlewareRunStatus.SKIPPED } - if (!type) { - type = room.preset - } - let parsedLayerType = MemoryRetrievalLayerType.USER if (view != null) { @@ -66,22 +71,29 @@ export function apply(ctx: Context, config: Config) { formatDocumentInfo(session, value) ) - query = query ?? ' ' + const content = query ?? ' ' try { + const scope = await getMemoryScope(ctx, session, { + conversationId, + presetLane, + type + }) + + if (scope == null) { + context.message = session.text('.search_failed') + return ChainMiddlewareRunStatus.STOP + } + const layers = await ctx.chatluna_long_memory.initMemoryLayers( - { - presetId: type as string, - guildId: session.guildId || session.channelId, - userId: session.userId - }, - room.conversationId, + scope.info, + scope.conversation.id, parsedLayerType ) const documents = await Promise.all( - layers.map((layer) => layer.retrieveMemory(query)) + layers.map((layer) => layer.retrieveMemory(content)) ).then((documents) => documents.flat()) await pagination.push(documents) diff --git a/packages/extension-long-memory/src/service/memory.ts b/packages/extension-long-memory/src/service/memory.ts index 444ab2b7d..ad96310d8 100644 --- a/packages/extension-long-memory/src/service/memory.ts +++ b/packages/extension-long-memory/src/service/memory.ts @@ -40,12 +40,25 @@ export class ChatLunaLongMemoryService extends Service { this.defaultLayerTypes.push(...mapped) - ctx.on( - 'chatluna/clear-chat-history', - async (conversationId, _chatInterface) => { - delete this._memoryLayerNamespaces[conversationId] - } - ) + const clear = (conversationId: string) => { + delete this._memoryLayerNamespaces[conversationId] + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-cache-clear', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-archive', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-restore', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-delete', async (payload) => { + clear(payload.conversation.id) + }) // 定期清理过期记忆 ctx.setInterval( diff --git a/packages/extension-long-memory/src/utils/conversation.ts b/packages/extension-long-memory/src/utils/conversation.ts new file mode 100644 index 000000000..2c925cb6e --- /dev/null +++ b/packages/extension-long-memory/src/utils/conversation.ts @@ -0,0 +1,40 @@ +import { Context, Session } from 'koishi' + +export async function getMemoryScope( + ctx: Context, + session: Session, + options: { + conversationId?: string + presetLane?: string + type?: string + } +): Promise<{ + conversation: Parameters[0] + preset: string + info: { + presetId: string + guildId: string + userId: string + } +} | null> { + const resolved = await ctx.chatluna.conversation.resolveContext(session, { + conversationId: options.conversationId, + presetLane: options.presetLane + }) + const conversation = resolved.conversation + + if (conversation == null) { + return null + } + + return { + conversation, + preset: options.type ?? resolved.effectivePreset ?? conversation.preset, + info: { + presetId: + options.type ?? resolved.effectivePreset ?? conversation.preset, + guildId: session.guildId || session.channelId, + userId: session.userId + } + } +} diff --git a/packages/extension-tools/src/plugins/todos.ts b/packages/extension-tools/src/plugins/todos.ts index 3ac12e1a3..f030ea14e 100644 --- a/packages/extension-tools/src/plugins/todos.ts +++ b/packages/extension-tools/src/plugins/todos.ts @@ -69,8 +69,21 @@ export async function apply( 10 ) - ctx.on('chatluna/clear-chat-history', async (conversationId) => { + const clear = (conversationId: string) => { todosStore.delete(conversationId) + } + + ctx.on('chatluna/conversation-after-clear-history', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-archive', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-restore', async (payload) => { + clear(payload.conversation.id) + }) + ctx.on('chatluna/conversation-after-delete', async (payload) => { + clear(payload.conversation.id) }) } From 5abb2b437aa411ce40c041ce8ae34175a08571b4 Mon Sep 17 00:00:00 2001 From: dingyi Date: Tue, 31 Mar 2026 08:56:58 +0800 Subject: [PATCH 02/20] feat(core): refine conversation system, chain scheduler, and command structure - Rewrite ChatChainDependencyGraph with lifecycle slot-based scheduling, replacing topological sort with a rule-based dependency engine that supports before/after lifecycle anchors and produces better error messages - Flatten command hierarchy: move chatluna.chat.* sub-commands to chatluna.* (rollback, stop, voice) and clean up preset option choices - Add autoSummarizeTitle to ChatInterface for automatic title generation after the first exchange - Refactor conversation service: simplify API, reduce complexity across conversation_runtime, middlewares, and conversation_manage - Improve room-to-conversation migration validators and legacy table handling - Add inputImageTokens/outputImageTokens tracking to adapter-gemini - Extensive i18n updates for en-US and zh-CN locales --- packages/adapter-gemini/src/requester.ts | 2 + packages/adapter-gemini/src/types.ts | 2 + packages/adapter-gemini/src/utils.ts | 5 + packages/core/src/chains/chain.ts | 586 +++++-- packages/core/src/commands/chat.ts | 141 +- packages/core/src/commands/conversation.ts | 31 +- packages/core/src/llm-core/chat/app.ts | 45 + packages/core/src/locales/en-US.yml | 124 +- packages/core/src/locales/zh-CN.yml | 126 +- .../auth/add_user_to_auth_group.ts | 2 +- .../auth/kick_user_form_auth_group.ts | 2 +- .../src/middlewares/auth/list_auth_group.ts | 26 +- .../src/middlewares/chat/rollback_chat.ts | 3 +- .../conversation/resolve_conversation.ts | 14 +- .../middlewares/system/conversation_manage.ts | 1456 +++++++---------- packages/core/src/middlewares/system/wipe.ts | 21 +- packages/core/src/migration/legacy_tables.ts | 30 +- .../src/migration/room_to_conversation.ts | 203 ++- packages/core/src/migration/types.ts | 55 + packages/core/src/migration/validators.ts | 294 ++-- packages/core/src/services/chat.ts | 40 +- packages/core/src/services/conversation.ts | 432 ++--- .../core/src/services/conversation_runtime.ts | 125 +- .../core/src/services/conversation_types.ts | 1 + packages/core/src/services/types.ts | 66 +- packages/core/src/utils/lock.ts | 2 +- packages/core/src/utils/queue.ts | 9 +- packages/shared-adapter/src/utils.ts | 8 + 28 files changed, 1980 insertions(+), 1871 deletions(-) create mode 100644 packages/core/src/migration/types.ts diff --git a/packages/adapter-gemini/src/requester.ts b/packages/adapter-gemini/src/requester.ts index 1ae1cfb67..fe6af84cb 100644 --- a/packages/adapter-gemini/src/requester.ts +++ b/packages/adapter-gemini/src/requester.ts @@ -490,6 +490,8 @@ export class GeminiRequester inputTokens: parsedChunk.usage.promptTokens, outputTokens: parsedChunk.usage.completionTokens, totalTokens: parsedChunk.usage.totalTokens, + inputImageTokens: parsedChunk.usage.inputImageTokens, + outputImageTokens: parsedChunk.usage.outputImageTokens, inputAudioTokens: parsedChunk.usage.inputAudioTokens, outputAudioTokens: parsedChunk.usage.outputAudioTokens, cacheReadTokens: parsedChunk.usage.cacheReadTokens, diff --git a/packages/adapter-gemini/src/types.ts b/packages/adapter-gemini/src/types.ts index fbe430e54..0ea20e59f 100644 --- a/packages/adapter-gemini/src/types.ts +++ b/packages/adapter-gemini/src/types.ts @@ -27,6 +27,8 @@ export type ChatUsageMetadataPart = { completionTokens: number totalTokens: number inputAudioTokens?: number + inputImageTokens?: number + outputImageTokens?: number outputAudioTokens?: number cacheReadTokens?: number reasoningTokens?: number diff --git a/packages/adapter-gemini/src/utils.ts b/packages/adapter-gemini/src/utils.ts index 85e46f36d..d912c1a64 100644 --- a/packages/adapter-gemini/src/utils.ts +++ b/packages/adapter-gemini/src/utils.ts @@ -962,6 +962,11 @@ export function getUsage(data: ChatResponse) { completionTokens: getCompletionTokens(data), totalTokens: usage.totalTokenCount, inputAudioTokens: getModalityTokens(usage.promptTokensDetails, 'AUDIO'), + inputImageTokens: getModalityTokens(usage.promptTokensDetails, 'IMAGE'), + outputImageTokens: getModalityTokens( + usage.candidatesTokensDetails, + 'IMAGE' + ), outputAudioTokens: getModalityTokens( usage.candidatesTokensDetails, 'AUDIO' diff --git a/packages/core/src/chains/chain.ts b/packages/core/src/chains/chain.ts index f5836c516..85d23fda1 100644 --- a/packages/core/src/chains/chain.ts +++ b/packages/core/src/chains/chain.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'events' import { Context, h, Logger, Session } from 'koishi' import { ChatLunaError, @@ -384,8 +383,7 @@ interface MiddlewareResult { class ChatChainDependencyGraph { private readonly _tasks = new Map() - private readonly _dependencies = new Map>() - private readonly _eventEmitter = new EventEmitter() + private readonly _rules: ChainDependencyRule[] = [] private readonly _listeners = new Map< string, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -393,31 +391,24 @@ class ChatChainDependencyGraph { >() private _cachedOrder: ChainMiddleware[][] | null = null - - constructor() { - this._eventEmitter.on('build_node', () => { - for (const [, listeners] of this._listeners) { - for (const listener of listeners) { - listener() - } - listeners.clear() - } - }) - } + private _index = 0 public addNode(middleware: ChainMiddleware): void { this._tasks.set(middleware.name, { name: middleware.name, - middleware + middleware, + index: this._index++ }) this._cachedOrder = null } removeNode(name: string): void { this._tasks.delete(name) - this._dependencies.delete(name) - for (const deps of this._dependencies.values()) { - deps.delete(name) + for (let i = this._rules.length - 1; i >= 0; i--) { + const rule = this._rules[i] + if (rule.owner === name || rule.target === name) { + this._rules.splice(i, 1) + } } this._cachedOrder = null } @@ -440,9 +431,13 @@ class ChatChainDependencyGraph { taskB = taskB.name } if (taskA && taskB) { - const dependencies = this._dependencies.get(taskA) ?? new Set() - dependencies.add(taskB) - this._dependencies.set(taskA, dependencies) + this._rules.push({ + owner: taskA, + target: taskB, + type: 'before', + source: this._captureRuleSource() + }) + this._cachedOrder = null } else { throw new Error('Invalid tasks') } @@ -459,25 +454,47 @@ class ChatChainDependencyGraph { taskB = taskB.name } if (taskA && taskB) { - const dependencies = this._dependencies.get(taskB) ?? new Set() - dependencies.add(taskA) - this._dependencies.set(taskB, dependencies) + this._rules.push({ + owner: taskA, + target: taskB, + type: 'after', + source: this._captureRuleSource() + }) + this._cachedOrder = null } else { throw new Error('Invalid tasks') } } getDependencies(task: string) { - return this._dependencies.get(task) + const deps = new Set() + + for (const rule of this._rules) { + if (rule.owner === task && rule.type === 'after') { + deps.add(rule.target) + } + + if (rule.target === task && rule.type === 'before') { + deps.add(rule.owner) + } + } + + return deps } getDependents(task: string): string[] { const dependents: string[] = [] - for (const [key, value] of this._dependencies.entries()) { - if ([...value].includes(task)) { - dependents.push(key) + + for (const rule of this._rules) { + if (rule.owner === task && rule.type === 'before') { + dependents.push(rule.target) + } + + if (rule.target === task && rule.type === 'after') { + dependents.push(rule.owner) } } + return dependents } @@ -486,161 +503,434 @@ class ChatChainDependencyGraph { return this._cachedOrder } - this._eventEmitter.emit('build_node') - const indegree = new Map() - const tempGraph = new Map>() + for (const [, listeners] of this._listeners) { + for (const listener of listeners) { + listener() + } + listeners.clear() + } - for (const taskName of this._tasks.keys()) { - indegree.set(taskName, 0) - tempGraph.set(taskName, new Set()) + const lifecycleSet = new Set(lifecycleNames) + const nodes = [...this._tasks.values()].sort( + (a, b) => a.index - b.index + ) + const nodeNames = new Set(nodes.map((node) => node.name)) + const normalNodes = nodes.filter((node) => !lifecycleSet.has(node.name)) + const ranges = new Map() + const outgoing = new Map() + const incoming = new Map() + const slots = new Map() + const slotCause = new Map() + + for (const node of normalNodes) { + ranges.set(node.name, { + min: 0, + max: lifecycleNames.length, + minRules: [], + maxRules: [] + }) + outgoing.set(node.name, []) + incoming.set(node.name, []) + slots.set(node.name, 0) } - for (const [from, deps] of this._dependencies.entries()) { - const depsSet = tempGraph.get(from) || new Set() - for (const to of deps) { - depsSet.add(to) - indegree.set(to, (indegree.get(to) || 0) + 1) + for (const rule of this._rules) { + if (!nodeNames.has(rule.owner)) { + continue } - tempGraph.set(from, depsSet) - } - const levels: ChainMiddleware[][] = [] - const visited = new Set() - let currentLevel: string[] = [] + if (!lifecycleSet.has(rule.target) && !nodeNames.has(rule.target)) { + throw new Error( + `Unknown middleware "${rule.target}" referenced by ${this._formatRule(rule)}` + ) + } - for (const [task, degree] of indegree.entries()) { - if (degree === 0) { - currentLevel.push(task) + if (lifecycleSet.has(rule.owner) && lifecycleSet.has(rule.target)) { + continue } - } - while (currentLevel.length > 0) { - const levelMiddlewares: ChainMiddleware[] = [] - const nextLevel: string[] = [] + if (lifecycleSet.has(rule.target) || lifecycleSet.has(rule.owner)) { + const name = lifecycleSet.has(rule.target) + ? rule.owner + : rule.target - for (const current of currentLevel) { - if (visited.has(current)) continue - visited.add(current) + if (lifecycleSet.has(name)) { + continue + } - const node = this._tasks.get(current) - if (node?.middleware) { - levelMiddlewares.push(node.middleware) + const range = ranges.get(name) + + if (!range) { + continue } - const successors = tempGraph.get(current) || new Set() - for (const next of successors) { - const newDegree = indegree.get(next)! - 1 - indegree.set(next, newDegree) - if (newDegree === 0) { - nextLevel.push(next) - } + const lifecycleName = lifecycleSet.has(rule.target) + ? rule.target + : rule.owner + const idx = lifecycleNames.indexOf(lifecycleName) + const isAfter = lifecycleSet.has(rule.target) + ? rule.type === 'after' + : rule.type === 'before' + + if (isAfter) { + range.min = Math.max(range.min, idx + 1) + range.minRules.push(rule) + } else { + range.max = Math.min(range.max, idx) + range.maxRules.push(rule) } - } - if (levelMiddlewares.length > 0) { - levels.push(levelMiddlewares) + slots.set(name, range.min) + continue } - currentLevel = nextLevel + + const edge: ChainDependencyEdge = + rule.type === 'before' + ? { + from: rule.owner, + to: rule.target, + rule + } + : { + from: rule.target, + to: rule.owner, + rule + } + + outgoing.get(edge.from)?.push(edge) + incoming.get(edge.to)?.push(edge) } - for (const [node, degree] of indegree.entries()) { - if (degree > 0) { - const cycles = this._findAllCycles() - const relevantCycle = cycles.find((cycle) => - cycle.includes(node) - ) - throw new Error( - `Circular dependency detected involving nodes: ${relevantCycle?.join(' -> ') || node}` + const stack: string[] = [] + const onStack = new Set() + const index = new Map() + const lowLink = new Map() + const cycles: string[][] = [] + let cursor = 0 + + const visit = (name: string) => { + index.set(name, cursor) + lowLink.set(name, cursor) + cursor += 1 + stack.push(name) + onStack.add(name) + + for (const edge of outgoing.get(name) ?? []) { + const next = edge.to + + if (!index.has(next)) { + visit(next) + lowLink.set( + name, + Math.min(lowLink.get(name)!, lowLink.get(next)!) + ) + continue + } + + if (onStack.has(next)) { + lowLink.set( + name, + Math.min(lowLink.get(name)!, index.get(next)!) + ) + } + } + + if (lowLink.get(name) !== index.get(name)) { + return + } + + const group: string[] = [] + + while (true) { + const current = stack.pop()! + onStack.delete(current) + group.push(current) + + if (current === name) { + break + } + } + + if ( + group.length > 1 || + (outgoing.get(group[0]) ?? []).some( + (edge) => edge.to === group[0] ) + ) { + cycles.push(group) } } - if (visited.size !== this._tasks.size) { + for (const node of normalNodes) { + if (!index.has(node.name)) { + visit(node.name) + } + } + + if (cycles.length > 0) { + const cycle = cycles[0] + const cycleSet = new Set(cycle) + const blocked = new Set() + const queue = [...cycle] + + while (queue.length > 0) { + const current = queue.shift()! + + for (const edge of outgoing.get(current) ?? []) { + if (cycleSet.has(edge.to) || blocked.has(edge.to)) { + continue + } + + blocked.add(edge.to) + queue.push(edge.to) + } + } + + const order = new Map(nodes.map((node) => [node.name, node.index])) + const cycleEdges = cycle + .flatMap((name) => outgoing.get(name) ?? []) + .filter((edge) => cycleSet.has(edge.to)) + .sort( + (a, b) => + (order.get(a.from) ?? 0) - (order.get(b.from) ?? 0) || + (order.get(a.to) ?? 0) - (order.get(b.to) ?? 0) + ) + const blockedList = [...blocked].sort( + (a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0) + ) + throw new Error( - 'Some nodes are unreachable in the dependency graph' + [ + 'Circular dependency detected in middleware graph.', + `Cycle nodes: ${cycle + .sort( + (a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0) + ) + .join(' -> ')}`, + 'Constraints:', + ...cycleEdges.map( + (edge) => + `- ${edge.from} -> ${edge.to} via ${this._formatRule(edge.rule)}` + ), + blockedList.length > 0 + ? `Blocked nodes: ${blockedList.join(', ')}` + : '' + ] + .filter((line) => line.length > 0) + .join('\n') ) } - this._cachedOrder = levels - return levels - } + const indegree = new Map() - private _canRunInParallel(a: ChainMiddleware, b: ChainMiddleware): boolean { - const aDeps = this._dependencies.get(a.name) || new Set() - const bDeps = this._dependencies.get(b.name) || new Set() + for (const node of normalNodes) { + indegree.set(node.name, incoming.get(node.name)?.length ?? 0) + } - return ( - !aDeps.has(b.name) && - !bDeps.has(a.name) && - !this._hasTransitiveDependency(a.name, b.name) && - !this._hasTransitiveDependency(b.name, a.name) - ) - } + const ready = normalNodes + .filter((node) => indegree.get(node.name) === 0) + .map((node) => node.name) + const topo: string[] = [] - private _hasTransitiveDependency( - from: string, - to: string, - visited = new Set() - ): boolean { - if (visited.has(from)) return false - visited.add(from) + while (ready.length > 0) { + ready.sort( + (a, b) => + (this._tasks.get(a)?.index ?? 0) - + (this._tasks.get(b)?.index ?? 0) + ) - const deps = this._dependencies.get(from) || new Set() - if (deps.has(to)) return true + const current = ready.shift()! + const currentSlot = slots.get(current) ?? 0 + const range = ranges.get(current) - for (const dep of deps) { - if (this._hasTransitiveDependency(dep, to, visited)) { - return true + if (range && currentSlot > range.max) { + const path: string[] = [] + let name = current + const visited = new Set() + + while (slotCause.has(name) && !visited.has(name)) { + visited.add(name) + const edge = slotCause.get(name)! + path.unshift( + `- ${edge.from} -> ${edge.to} via ${this._formatRule(edge.rule)}` + ) + name = edge.from + } + + throw new Error( + [ + `Cannot place middleware "${current}" in lifecycle order.`, + `Resolved position: ${this._formatSlot(currentSlot)}`, + `Latest allowed position: ${this._formatSlot(range.max)}`, + range.minRules.length > 0 ? 'Required after:' : '', + ...range.minRules.map( + (rule) => `- ${this._formatRule(rule)}` + ), + range.maxRules.length > 0 ? 'Required before:' : '', + ...range.maxRules.map( + (rule) => `- ${this._formatRule(rule)}` + ), + path.length > 0 ? 'Dependency chain:' : '', + ...path + ] + .filter((line) => line.length > 0) + .join('\n') + ) } - } - return false - } + topo.push(current) - private _findAllCycles(): string[][] { - const visited = new Set() - const recursionStack = new Set() - const cycles: string[][] = [] + for (const edge of outgoing.get(current) ?? []) { + if ((slots.get(edge.to) ?? 0) < currentSlot) { + slots.set(edge.to, currentSlot) + slotCause.set(edge.to, edge) + } + + indegree.set(edge.to, (indegree.get(edge.to) ?? 0) - 1) - const dfs = (node: string, path: string[]): void => { - if (recursionStack.has(node)) { - const cycleStart = path.indexOf(node) - if (cycleStart !== -1) { - const cycle = path.slice(cycleStart).concat([node]) - cycles.push(cycle) + if (indegree.get(edge.to) === 0) { + ready.push(edge.to) } - return } + } - if (visited.has(node)) { - return + const levels: ChainMiddleware[][] = [] + const slotGroups = new Map() + + for (const name of topo) { + const slot = slots.get(name) ?? 0 + const group = slotGroups.get(slot) ?? [] + group.push(name) + slotGroups.set(slot, group) + } + + for (let slot = 0; slot <= lifecycleNames.length; slot++) { + const group = slotGroups.get(slot) ?? [] + + if (group.length > 0) { + const groupSet = new Set(group) + const groupIndegree = new Map() + + for (const name of group) { + groupIndegree.set( + name, + (incoming.get(name) ?? []).filter((edge) => + groupSet.has(edge.from) + ).length + ) + } + + let currentLevel = group + .filter((name) => groupIndegree.get(name) === 0) + .sort( + (a, b) => + (this._tasks.get(a)?.index ?? 0) - + (this._tasks.get(b)?.index ?? 0) + ) + + while (currentLevel.length > 0) { + levels.push( + currentLevel.map( + (name) => this._tasks.get(name)!.middleware! + ) + ) + + const nextLevel: string[] = [] + + for (const name of currentLevel) { + for (const edge of outgoing.get(name) ?? []) { + if (!groupSet.has(edge.to)) { + continue + } + + groupIndegree.set( + edge.to, + (groupIndegree.get(edge.to) ?? 0) - 1 + ) + + if (groupIndegree.get(edge.to) === 0) { + nextLevel.push(edge.to) + } + } + } + + currentLevel = nextLevel.sort( + (a, b) => + (this._tasks.get(a)?.index ?? 0) - + (this._tasks.get(b)?.index ?? 0) + ) + } } - visited.add(node) - recursionStack.add(node) - path.push(node) + const lifecycle = this._tasks.get(lifecycleNames[slot])?.middleware - const deps = this._dependencies.get(node) || new Set() - for (const dep of deps) { - dfs(dep, [...path]) + if (lifecycle) { + levels.push([lifecycle]) } + } + + this._cachedOrder = levels + return levels + } + + private _captureRuleSource() { + const stack = new Error().stack?.split('\n') ?? [] + + return stack + .map((line) => line.trim()) + .find( + (line) => + line.length > 0 && + line !== 'Error' && + !line.includes('ChatChainDependencyGraph.') && + !line.includes('ChainMiddleware.') && + !line.includes('chains\\chain.') && + !line.includes('chains/chain.') + ) + } + + private _formatRule(rule: ChainDependencyRule) { + const source = rule.source ? ` at ${rule.source}` : '' + return `.${rule.type}('${rule.target}') declared by ${rule.owner}${source}` + } - recursionStack.delete(node) + private _formatSlot(slot: number) { + if (slot <= 0) { + return `before ${lifecycleNames[0]}` } - for (const node of this._tasks.keys()) { - if (!visited.has(node)) { - dfs(node, []) - } + if (slot >= lifecycleNames.length) { + return `after ${lifecycleNames[lifecycleNames.length - 1]}` } - return cycles + return `between ${lifecycleNames[slot - 1]} and ${lifecycleNames[slot]}` } } interface ChainDependencyGraphNode { middleware?: ChainMiddleware name: string + index: number +} + +interface ChainDependencyRule { + owner: string + target: string + type: 'before' | 'after' + source?: string +} + +interface ChainDependencyEdge { + from: string + to: string + rule: ChainDependencyRule +} + +interface ChainDependencyRange { + min: number + max: number + minRules: ChainDependencyRule[] + maxRules: ChainDependencyRule[] } export class ChainMiddleware { @@ -653,46 +943,12 @@ export class ChainMiddleware { before(name: T) { this.graph.before(this.name, name) - if (this.name.startsWith('lifecycle-')) { - return this - } - - const lifecycleName = lifecycleNames - - if (lifecycleName.includes(name)) { - const lastLifecycleName = - lifecycleName[lifecycleName.indexOf(name) - 1] - - if (lastLifecycleName) { - this.graph.after(this.name, lastLifecycleName) - } - - return this - } - return this } after(name: T) { this.graph.after(this.name, name) - if (this.name.startsWith('lifecycle-')) { - return this - } - - const lifecycleName = lifecycleNames - - if (lifecycleName.includes(name)) { - const nextLifecycleName = - lifecycleName[lifecycleName.indexOf(name) + 1] - - if (nextLifecycleName) { - this.graph.before(this.name, nextLifecycleName) - } - - return this - } - return this } diff --git a/packages/core/src/commands/chat.ts b/packages/core/src/commands/chat.ts index 181c39387..93fc0505d 100644 --- a/packages/core/src/commands/chat.ts +++ b/packages/core/src/commands/chat.ts @@ -1,74 +1,14 @@ -import { Command, Context, h } from 'koishi' +import { Context, h, Session } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' import { RenderType } from '../types' -function normalizeTarget(value?: string | null) { - return value == null || value.trim().length < 1 ? undefined : value.trim() -} - -function setOptionChoices(cmd: Command, name: string, values: string[]) { - if (cmd._options[name] != null) { - cmd._options[name].type = values - } -} - -async function completeConversationTarget( - ctx: Context, - session: import('koishi').Session, - target?: string, - presetLane?: string, - includeArchived = true -) { - const value = normalizeTarget(target) - if (value == null) { - return undefined - } - - const conversations = await ctx.chatluna.conversation.listConversations( - session, - { - presetLane, - includeArchived - } - ) - const expect = Array.from( - new Set( - conversations.flatMap((conversation) => [ - conversation.id, - String(conversation.seq ?? ''), - conversation.title - ]) - ) - ).filter((item) => item.length > 0) - - if (expect.length === 0) { - return value - } - - return session.suggest({ - actual: value, - expect, - suffix: session.text('commands.chatluna.chat.text.options.conversation') - }) -} - export function apply(ctx: Context, config: Config, chain: ChatChain) { - const presets = ctx.chatluna.preset - .getAllPreset(true) - .value.flatMap((entry) => entry.split(',').map((item) => item.trim())) - ctx.command('chatluna', { authority: 1 }).alias('chatluna') - ctx.command('chatluna.chat', { - authority: 1 - }) - - const chatCommand = ctx - .command('chatluna.chat ') - .alias('chatluna.chat.text') + ctx.command('chatluna.chat ') .option('conversation', '-c ') .option('preset', '-p ') .option('type', '-t ') @@ -107,9 +47,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(chatCommand, 'preset', presets) - ctx.command('chatluna.chat.rollback [message:text]') + ctx.command('chatluna.rollback [message:text]') .option('conversation', '-c ') .option('i', '-i ') .action(async ({ options, session }, message) => { @@ -137,7 +76,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) - ctx.command('chatluna.chat.stop') + ctx.command('chatluna.stop') .option('conversation', '-c ') .action(async ({ options, session }) => { await chain.receiveCommand( @@ -156,31 +95,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) - ctx.command('chatluna.chat.compress') - .option('conversation', '-c ') - .action(async ({ options, session }) => { - await chain.receiveCommand( - session, - 'conversation_compress', - { - force: true, - conversation_manage: { - targetConversation: await completeConversationTarget( - ctx, - session, - options.conversation, - undefined, - false - ) - }, - i18n_base: 'commands.chatluna.chat.compress.messages' - }, - ctx - ) - }) - - const voiceCommand = ctx - .command('chatluna.chat.voice ') + ctx.command('chatluna.voice ') .option('conversation', '-c ') .option('speaker', '-s ', { authority: 1 }) .action(async ({ options, session }, message) => { @@ -222,11 +137,55 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) - ctx.command('chatluna.restart').action(async ({ options, session }) => { + ctx.command('chatluna.restart').action(async ({ session }) => { await chain.receiveCommand(session, 'restart') }) } +function normalizeTarget(value?: string | null) { + return value == null || value.trim().length < 1 ? undefined : value.trim() +} + +async function completeConversationTarget( + ctx: Context, + session: Session, + target?: string, + presetLane?: string, + includeArchived = true +) { + const value = normalizeTarget(target) + if (value == null) { + return undefined + } + + const conversations = await ctx.chatluna.conversation.listConversations( + session, + { + presetLane, + includeArchived + } + ) + const expect = Array.from( + new Set( + conversations.flatMap((conversation) => [ + conversation.id, + String(conversation.seq ?? ''), + conversation.title + ]) + ) + ).filter((item) => item.length > 0) + + if (expect.length === 0) { + return value + } + + return session.suggest({ + actual: value, + expect, + suffix: session.text('commands.chatluna.chat.text.options.conversation') + }) +} + declare module '../chains/chain' { interface ChainMiddlewareContextOptions { message?: h[] diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index 6119e0319..93a72b4c3 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -1,4 +1,4 @@ -import { Command, Context } from 'koishi' +import { Command, Context, Session } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' @@ -21,7 +21,7 @@ function setOptionChoices(cmd: Command, name: string, values: string[]) { async function completeConversationTarget( ctx: Context, - session: import('koishi').Session, + session: Session, target?: string, presetLane?: string, includeArchived = true @@ -62,9 +62,6 @@ async function completeConversationTarget( } export function apply(ctx: Context, config: Config, chain: ChatChain) { - const presets = ctx.chatluna.preset - .getAllPreset(true) - .value.flatMap((entry) => entry.split(',').map((item) => item.trim())) const modes = ['chat', 'plugin', 'browsing'] const shares = ['personal', 'shared', 'reset'] const locks = ['on', 'off', 'toggle', 'reset'] @@ -88,7 +85,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { authority: 1 }) newCommand - .alias('chatluna.chat.new') .alias('chatluna.clear') .option('preset', '-p ') .option('model', '-m ') @@ -108,7 +104,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(newCommand, 'preset', presets) setOptionChoices(newCommand, 'model', models) setOptionChoices(newCommand, 'chatMode', modes) @@ -116,7 +111,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { authority: 1 }) switchCommand - .alias('chatluna.chat.switch') .option('preset', '-p ') .action(async ({ options, session }, conversation) => { const presetLane = normalizeTarget(options.preset) @@ -138,12 +132,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(switchCommand, 'preset', presets) ctx.command('chatluna.list', { authority: 1 }) - .alias('chatluna.chat.list') .option('page', '-p ') .option('limit', '-l ') .option('archived', '-a') @@ -170,7 +162,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { authority: 1 }) currentCommand - .alias('chatluna.chat.current') .option('preset', '-p ') .action(async ({ options, session }) => { await chain.receiveCommand( @@ -184,7 +175,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(currentCommand, 'preset', presets) const archiveCommand = ctx.command( 'chatluna.archive [conversation:string]', @@ -193,7 +183,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) archiveCommand - .alias('chatluna.chat.archive') .option('preset', '-p ') .action(async ({ options, session }, conversation) => { const presetLane = normalizeTarget(options.preset) @@ -214,7 +203,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(archiveCommand, 'preset', presets) const restoreCommand = ctx.command( 'chatluna.restore [conversation:string]', @@ -223,7 +211,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) restoreCommand - .alias('chatluna.chat.restore') .option('preset', '-p ') .action(async ({ options, session }, conversation) => { const presetLane = normalizeTarget(options.preset) @@ -244,13 +231,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(restoreCommand, 'preset', presets) const exportCommand = ctx.command('chatluna.export [conversation:string]', { authority: 1 }) exportCommand - .alias('chatluna.chat.export') .option('preset', '-p ') .action(async ({ options, session }, conversation) => { const presetLane = normalizeTarget(options.preset) @@ -271,7 +256,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(exportCommand, 'preset', presets) const compressCommand = ctx.command( 'chatluna.compress [conversation:string]', @@ -280,8 +264,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) compressCommand - .alias('chatluna.chat.compress') - .alias('chatluna.chat.compress-current') .option('preset', '-p ') .action(async ({ options, session }, conversation) => { const presetLane = normalizeTarget(options.preset) @@ -304,7 +286,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(compressCommand, 'preset', presets) const renameCommand = ctx .command('chatluna.rename ', { @@ -324,7 +305,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(renameCommand, 'preset', presets) const deleteCommand = ctx .command('chatluna.delete [conversation:string]', { @@ -350,7 +330,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setOptionChoices(deleteCommand, 'preset', presets) const useModelCommand = ctx .command('chatluna.use.model ', { @@ -373,7 +352,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) setChoices(useModelCommand, 0, models) - setOptionChoices(useModelCommand, 'preset', presets) const usePresetCommand = ctx .command('chatluna.use.preset ', { @@ -395,8 +373,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setChoices(usePresetCommand, 0, presets) - setOptionChoices(usePresetCommand, 'lane', presets) const useModeCommand = ctx .command('chatluna.use.mode ', { @@ -419,7 +395,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) setChoices(useModeCommand, 0, modes) - setOptionChoices(useModeCommand, 'preset', presets) const ruleModelCommand = ctx .command('chatluna.rule.model ', { @@ -455,8 +430,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx ) }) - setChoices(rulePresetCommand, 0, [...presets, 'reset']) - const ruleModeCommand = ctx .command('chatluna.rule.mode ', { authority: 3 diff --git a/packages/core/src/llm-core/chat/app.ts b/packages/core/src/llm-core/chat/app.ts index b630df0b8..5cb1fa8f6 100644 --- a/packages/core/src/llm-core/chat/app.ts +++ b/packages/core/src/llm-core/chat/app.ts @@ -234,6 +234,14 @@ export class ChatInterface { await this.handleChatError(arg, wrapper, error, false) } + autoSummarizeTitle( + this.ctx, + arg.conversationId, + wrapper, + arg.message, + displayResponse as AIMessage + ).catch((e) => logger.error('autoSummarizeTitle error:', e)) + return { message: displayResponse } } @@ -442,6 +450,43 @@ export class ChatInterface { } } +async function autoSummarizeTitle( + ctx: Context, + conversationId: string, + wrapper: ChatLunaLLMChainWrapper, + humanMsg: HumanMessage, + aiMsg: AIMessage +) { + const conversation = + await ctx.chatluna.conversation.getConversation(conversationId) + if (conversation == null || !conversation.autoTitle) { + return + } + + const humanContent = getMessageContent(humanMsg.content) + const aiContent = getMessageContent(aiMsg.content) + + const prompt = + `Generate a concise title for the following conversation.\n` + + `Requirements:\n` + + `- Length: 5 to 20 characters\n` + + `- Use the same language as the user's message\n` + + `- Output ONLY the title, no punctuation, no quotes, no explanation\n\n` + + `User: ${humanContent}\n` + + `Assistant: ${aiContent}` + + const result = await wrapper.model.invoke([new HumanMessage(prompt)]) + const title = getMessageContent(result.content).trim().slice(0, 20) + if (title.length < 5) { + return + } + + await ctx.chatluna.conversation.touchConversation(conversationId, { + title, + autoTitle: false + }) +} + export interface ChatInterfaceInput { chatMode: string botName?: string diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 623648031..39d3d810d 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -45,12 +45,81 @@ commands: description: Delete the current or target conversation. arguments: conversation: Target conversation by seq, ID, or title. + new: + description: Create and switch to a new conversation. + arguments: + title: Conversation title. + options: + preset: Preset lane. + model: Target model name. + chatMode: Target chat mode. + switch: + description: Switch the active conversation. + arguments: + conversation: Target conversation by seq, ID, or title. + options: + preset: Preset lane. + list: + description: List conversations in the current route. + options: + page: Page number. + limit: Items per page. + archived: Include archived conversations. + all: Include archived conversations. + preset: Preset lane. + current: + description: Show the current active conversation. + options: + preset: Preset lane. + rename: + description: Rename the current or target conversation. + arguments: + title: New conversation title. + options: + preset: Preset lane. + archive: + description: Archive a conversation. + arguments: + conversation: Target conversation by seq, ID, or title. + options: + preset: Preset lane. + restore: + description: Restore an archived conversation. + arguments: + conversation: Target conversation by seq, ID, or title. + options: + preset: Preset lane. + export: + description: Export a conversation as a markdown transcript. + arguments: + conversation: Target conversation by seq, ID, or title. + options: + preset: Preset lane. + compress: + description: Compress a conversation without referencing rooms. + arguments: + conversation: Target conversation by seq, ID, or title. + options: + preset: Preset lane. + messages: + success: '{0} -> {1}, {2}%' + skipped: 'Compression skipped. {0} -> {1}, {2}%' + no_conversation: 'No conversation is available to compress.' + failed: 'Failed to compress chat history for conversation {0} ({1}): {2}' + delete: + description: Delete the current or target conversation. + arguments: + conversation: Target conversation by seq, ID, or title. + options: + preset: Preset lane. use: description: Update the active conversation usage settings. model: description: Switch the conversation model. arguments: model: Target model name. + options: + preset: Preset lane. preset: description: Switch the conversation preset. arguments: @@ -61,6 +130,8 @@ commands: description: Switch the conversation chat mode. arguments: mode: Target chat mode. + options: + preset: Preset lane. rule: description: Manage route-level conversation rules. model: @@ -87,10 +158,13 @@ commands: description: Show the current route rule state. chat: description: ChatLuna conversation commands. + messages: + conversation_not_exist: 'Conversation does not exist.' text: description: Initiate a text conversation with the AI model. options: conversation: Target conversation by seq, ID, or title. + preset: Preset lane. type: Message rendering type. examples: - chatluna chat text -t text Hello, world! @@ -104,6 +178,7 @@ commands: description: Regenerate last conversation content. options: conversation: Target conversation by seq, ID, or title. + i: Number of rounds to roll back. arguments: message: New message content. messages: @@ -156,6 +231,10 @@ commands: messages: success: Successfully restarted ChatLuna. + admin: + purge-legacy: + description: Purge migrated legacy ChatLuna data. + auth: description: ChatLuna authentication commands. list: @@ -165,20 +244,16 @@ commands: limit: Items per page platform: Specify platform messages: - header: 'Available quota groups:' - footer: 'Use chatluna.auth.add [name/id] to join a quota group.' + header: 'Quota groups:' + footer: 'Use chatluna.auth.add [name] to join a quota group.' pages: 'Page: [page] / [total]' - name: 'Name: {0}' - platform: 'Model platform: {0}' - cost: 'Cost: {0} / 1000 tokens' - priority: 'Priority: {0}' - support_models: 'Allowed models: {0}' - limit_per_min: 'Limit: {0} messages/minute' - limit_per_day: 'Limit: {0} messages/day' + line: '{0} [{1}] priority:{2} limit:{3}/min {4}/day' general: 'General' add: description: Add user to quota group. usage: 'Usage: chatluna auth add [group] name -u @user' + arguments: + name: Quota group name. options: user: Target user messages: @@ -187,6 +262,8 @@ commands: kick: description: Remove user from quota group. usage: 'Usage: chatluna auth kick [group name] -u @user' + arguments: + name: Quota group name. options: user: Target user messages: @@ -281,8 +358,11 @@ commands: success: 'User {0} balance reset to {1}' set: description: 'Adjust user balance.' + options: + user: 'Target user' arguments: user: 'Target user' + balance: 'New balance' amount: 'New balance' examples: - 'chatluna balance set --user @username --amount 1000' @@ -599,6 +679,10 @@ chatluna: middleware_error: 'Error in {0}: {1}' not_available_model: 'No models are currently available. Please check your configuration to ensure model adapters are properly installed and configured.' chat_limit_exceeded: 'Daily chat limit reached. Please try again in {0} minutes.' + insufficient_balance: 'Insufficient balance. Current balance: {0}.' + unsupported_model: 'Quota group {0} does not support model {1}.' + limit_per_minute_exceeded: 'Quota group {0} reached the per-minute limit ({2}/{1}).' + limit_per_day_exceeded: 'Quota group {0} reached the daily limit ({2}/{1}).' conversation: default_title: 'New Conversation' active: 'active' @@ -667,34 +751,14 @@ chatluna: delete: 'delete' update: 'update' compress: 'compression' + conversation_line: '#{0} {1} [{2}] {3}' conversation_seq: 'Seq: {0}' - conversation_scope: 'Scope: {0}' - conversation_base_scope: 'Base scope: {0}' - conversation_route_mode: 'Route mode: {0}' - conversation_active: 'Active conversation: {0}' - conversation_last: 'Previous conversation: {0}' conversation_title: 'Title: {0}' conversation_id: 'ID: {0}' conversation_status: 'Status: {0}' - conversation_model: 'Model: {0}' - conversation_preset: 'Preset: {0}' - conversation_chat_mode: 'Chat mode: {0}' conversation_effective_model: 'Effective model: {0}' conversation_effective_preset: 'Effective preset: {0}' conversation_effective_chat_mode: 'Effective chat mode: {0}' - conversation_default_model: 'Route default model: {0}' - conversation_default_preset: 'Route default preset: {0}' - conversation_default_chat_mode: 'Route default chat mode: {0}' - conversation_fixed_model: 'Route fixed model: {0}' - conversation_fixed_preset: 'Route fixed preset: {0}' - conversation_fixed_chat_mode: 'Route fixed chat mode: {0}' - conversation_lock: 'Lock: {0}' - conversation_allow_new: 'Allow new: {0}' - conversation_allow_switch: 'Allow switch: {0}' - conversation_allow_archive: 'Allow archive: {0}' - conversation_allow_export: 'Allow export: {0}' - conversation_manage_mode: 'Manage mode: {0}' - conversation_preset_lane: 'Preset lane: {0}' conversation_compression_count: 'Compression count: {0}' conversation_updated_at: 'Updated at: {0}' rule_share: 'Route rule: {0}' diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 6adf81c2b..995d8b55d 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -45,12 +45,81 @@ commands: description: 删除当前会话或目标会话。 arguments: conversation: 目标会话,可使用序号、ID 或标题。 + new: + description: 创建并切换到一个新会话。 + arguments: + title: 会话标题。 + options: + preset: 预设分流。 + model: 目标模型名称。 + chatMode: 目标聊天模式。 + switch: + description: 切换当前活跃会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + options: + preset: 预设分流。 + list: + description: 列出当前路由下的会话。 + options: + page: 页码。 + limit: 每页数量。 + archived: 包含已归档会话。 + all: 包含已归档会话。 + preset: 预设分流。 + current: + description: 查看当前活跃会话。 + options: + preset: 预设分流。 + rename: + description: 重命名当前会话或目标会话。 + arguments: + title: 新的会话标题。 + options: + preset: 预设分流。 + archive: + description: 归档一个会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + options: + preset: 预设分流。 + restore: + description: 恢复一个已归档会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + options: + preset: 预设分流。 + export: + description: 将会话导出为 Markdown 记录。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + options: + preset: 预设分流。 + compress: + description: 在不引用房间的情况下压缩一个会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + options: + preset: 预设分流。 + messages: + success: '{0} -> {1}, {2}%' + skipped: '未执行压缩。{0} -> {1}, {2}%' + no_conversation: '没有可压缩的会话。' + failed: '压缩会话 {0}({1})的聊天记录失败:{2}' + delete: + description: 删除当前会话或目标会话。 + arguments: + conversation: 目标会话,可使用序号、ID 或标题。 + options: + preset: 预设分流。 use: description: 调整当前会话的使用配置。 model: description: 切换当前会话使用的模型。 arguments: model: 目标模型名称。 + options: + preset: 预设分流。 preset: description: 切换当前会话使用的预设。 arguments: @@ -61,6 +130,8 @@ commands: description: 切换当前会话使用的聊天模式。 arguments: mode: 目标聊天模式。 + options: + preset: 预设分流。 rule: description: 管理当前路由的会话规则。 model: @@ -87,10 +158,13 @@ commands: description: 查看当前路由的规则状态。 chat: description: ChatLuna 对话相关指令。 + messages: + conversation_not_exist: '会话不存在。' text: description: 与大语言模型进行文本对话。 options: conversation: 指定目标会话,可使用序号、ID 或标题。 + preset: 预设分流。 type: 设置消息的渲染类型。 examples: - chatluna chat text -t text 你好,世界! @@ -104,6 +178,7 @@ commands: description: 重新生成上一次的对话内容。 options: conversation: 指定目标会话,可使用序号、ID 或标题。 + i: 回滚轮数。 arguments: message: 新的消息内容。 messages: @@ -156,6 +231,10 @@ commands: messages: success: '已成功重启 ChatLuna。' + admin: + purge-legacy: + description: 清理已迁移的旧版 ChatLuna 数据。 + auth: description: ChatLuna 鉴权相关指令。 list: @@ -165,20 +244,16 @@ commands: limit: 每页显示的数量。 platform: 指定平台。 messages: - header: '以下是查询到目前可用的配额组列表:' - footer: '你可以使用 chatluna.auth.add [name/id] 来加入某个配额组。' - pages: '当前为第 [page] / [total] 页' - name: '名称:{0}' - platform: '适用模型平台:{0}' - cost: '计费:{0} / 1000 token' - priority: '优先级: {0}' - support_models: '限制模型:{0}' - limit_per_min: '并发限制每 {0} 条消息/分' - limit_per_day: '并发限制每 {0} 条消息/天' + header: '配额组列表:' + footer: '使用 chatluna.auth.add [name] 加入配额组。' + pages: '第 [page] / [total] 页' + line: '{0} [{1}] 优先级:{2} 限额:{3}/min {4}/day' general: '通用' add: description: 将用户加入到指定配额组。 usage: '使用方法:chatluna auth add [组名] -u @用户' + arguments: + name: 配额组名称。 options: user: 目标用户。 messages: @@ -187,6 +262,8 @@ commands: kick: description: 将用户从指定配额组中移除。 usage: '使用方法:chatluna auth kick [组名] -u @用户' + arguments: + name: 配额组名称。 options: user: 目标用户。 messages: @@ -282,8 +359,11 @@ commands: success: '已将用户 {0} 账户余额修改为 {1}' set: description: '设置指定用户的余额。可以增加或减少用户的余额。' + options: + user: '目标用户。' arguments: user: '目标用户。' + balance: '要设置的余额数量。' amount: '要设置的余额数量。' examples: - 'chatluna balance set --user @用户名 --amount 1000' @@ -599,6 +679,10 @@ chatluna: middleware_error: '执行 {0} 时出现错误: {1}' not_available_model: '当前没有可用的模型,请检查你的配置,是否正常安装了模型适配器并配置。' chat_limit_exceeded: '你的聊天次数已经用完了喵,还需要等待 {0} 分钟才能继续聊天喵 >_<' + insufficient_balance: '余额不足,当前余额为 {0}。' + unsupported_model: '配额组 {0} 不支持模型 {1}。' + limit_per_minute_exceeded: '配额组 {0} 已达到每分钟限制({2}/{1})。' + limit_per_day_exceeded: '配额组 {0} 已达到每日限制({2}/{1})。' conversation: default_title: '新会话' active: '当前活跃' @@ -667,34 +751,14 @@ chatluna: delete: '删除' update: '更新设置' compress: '压缩' + conversation_line: '#{0} {1} [{2}] {3}' conversation_seq: '序号:{0}' - conversation_scope: '作用域:{0}' - conversation_base_scope: '基础作用域:{0}' - conversation_route_mode: '路由模式:{0}' - conversation_active: '当前活跃会话:{0}' - conversation_last: '上一个会话:{0}' conversation_title: '标题:{0}' conversation_id: 'ID:{0}' conversation_status: '状态:{0}' - conversation_model: '模型:{0}' - conversation_preset: '预设:{0}' - conversation_chat_mode: '聊天模式:{0}' conversation_effective_model: '生效模型:{0}' conversation_effective_preset: '生效预设:{0}' conversation_effective_chat_mode: '生效聊天模式:{0}' - conversation_default_model: '路由默认模型:{0}' - conversation_default_preset: '路由默认预设:{0}' - conversation_default_chat_mode: '路由默认聊天模式:{0}' - conversation_fixed_model: '路由固定模型:{0}' - conversation_fixed_preset: '路由固定预设:{0}' - conversation_fixed_chat_mode: '路由固定聊天模式:{0}' - conversation_lock: '锁定状态:{0}' - conversation_allow_new: '允许新建:{0}' - conversation_allow_switch: '允许切换:{0}' - conversation_allow_archive: '允许归档:{0}' - conversation_allow_export: '允许导出:{0}' - conversation_manage_mode: '管理模式:{0}' - conversation_preset_lane: '预设分流:{0}' conversation_compression_count: '压缩次数:{0}' conversation_updated_at: '更新时间:{0}' rule_share: '路由规则:{0}' diff --git a/packages/core/src/middlewares/auth/add_user_to_auth_group.ts b/packages/core/src/middlewares/auth/add_user_to_auth_group.ts index 41cc4bd4c..540b4405c 100644 --- a/packages/core/src/middlewares/auth/add_user_to_auth_group.ts +++ b/packages/core/src/middlewares/auth/add_user_to_auth_group.ts @@ -1,7 +1,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin } from '../../utils/koishi' +import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain diff --git a/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts b/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts index f6af6e502..f37f6b41d 100644 --- a/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts +++ b/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts @@ -1,7 +1,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin } from '../../utils/koishi' +import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain diff --git a/packages/core/src/middlewares/auth/list_auth_group.ts b/packages/core/src/middlewares/auth/list_auth_group.ts index 804e0f3cd..095399446 100644 --- a/packages/core/src/middlewares/auth/list_auth_group.ts +++ b/packages/core/src/middlewares/auth/list_auth_group.ts @@ -48,25 +48,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } function formatAuthGroup(session: Session, group: ChatHubAuthGroup) { - const buffer: string[] = [] - - buffer.push(session.text('.name', [group.name])) - buffer.push( - session.text('.platform', [group.platform ?? session.text('.general')]) - ) - buffer.push(session.text('.cost', [group.costPerToken])) - buffer.push(session.text('.priority', [group.priority])) - buffer.push( - session.text('.support_models', [ - group.supportModels?.join(', ') ?? session.text('.general') - ]) - ) - buffer.push(session.text('.limit_per_min', [group.limitPerMin])) - buffer.push(session.text('.limit_per_day', [group.limitPerDay])) - - buffer.push('\n') - - return buffer.join('\n') + return session.text('.line', [ + group.name, + group.platform ?? session.text('.general'), + group.priority, + group.limitPerMin, + group.limitPerDay + ]) } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index b48b43556..2aeac82dc 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -1,9 +1,8 @@ import { Context, h } from 'koishi' -import { gzipDecode } from 'koishi-plugin-chatluna/utils/string' +import { gzipDecode, getMessageContent } from 'koishi-plugin-chatluna/utils/string' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { MessageRecord } from '../../services/conversation_types' -import { getMessageContent } from '../../utils/string' import { logger } from '../..' async function decodeMessageContent(message: MessageRecord) { diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts index a264d34d0..8b2d6e30a 100644 --- a/packages/core/src/middlewares/conversation/resolve_conversation.ts +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -1,23 +1,23 @@ import { Context } from 'koishi' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' +import { + ChainMiddlewareContext, + ChainMiddlewareRunStatus, + ChatChain +} from '../../chains/chain' import { Config } from '../../config' import type { ConversationRecord, ResolvedConversationContext } from '../../services/conversation_types' -function getPresetLane( - context: import('../../chains/chain').ChainMiddlewareContext -) { +function getPresetLane(context: ChainMiddlewareContext) { return ( context.options.conversation_manage?.presetLane ?? context.options.presetLane ) } -function getTargetConversation( - context: import('../../chains/chain').ChainMiddlewareContext -) { +function getTargetConversation(context: ChainMiddlewareContext) { return ( context.options.conversation_manage?.targetConversation ?? context.options.targetConversation diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index cb07ab117..abc14c53d 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -1,96 +1,53 @@ -import { Context } from 'koishi' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' +import { Context, Session } from 'koishi' +import { + ChainMiddlewareContext, + ChainMiddlewareRunStatus, + ChatChain +} from '../../chains/chain' import { Config } from '../../config' import { - ConversationCompressionRecord, ConversationRecord, ResolvedConversationContext } from '../../services/conversation_types' -import { Pagination } from '../../utils/pagination' -import { checkAdmin } from '../../utils/koishi' +import { Pagination } from 'koishi-plugin-chatluna/utils/pagination' +import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' function pickConversationTarget( - context: import('../../chains/chain').ChainMiddlewareContext, + context: ChainMiddlewareContext, current?: ConversationRecord | null ) { return ( context.options.conversation_manage?.targetConversation ?? context.options.conversationId ?? - current?.id ?? - undefined + current?.id ) } -async function resolveManagedConversation( - ctx: Context, - session: import('koishi').Session, - context: import('../../chains/chain').ChainMiddlewareContext -) { - const presetLane = context.options.conversation_manage?.presetLane - const resolved = await ctx.chatluna.conversation.resolveContext(session, { - presetLane, - conversationId: context.options.conversationId - }) - const targetConversation = pickConversationTarget( - context, - resolved.conversation - ) - - return { - presetLane, - resolved, - targetConversation, - conversation: - targetConversation != null - ? await ctx.chatluna.conversation.resolveTargetConversation( - session, - { - presetLane, - targetConversation, - conversationId: context.options.conversationId, - permission: 'manage', - includeArchived: - context.options.conversation_manage - ?.includeArchived === true - } - ) - : null - } -} - function formatConversationStatus( - session: import('koishi').Session, + session: Session, conversation: ConversationRecord, activeConversationId?: string | null ) { - const labels = [session.text('.status_value.' + conversation.status)] + const labels = [ + session.text( + 'chatluna.conversation.status_value.' + conversation.status + ) + ] if (conversation.id === activeConversationId) { - labels.push(session.text('.active')) + labels.push(session.text('chatluna.conversation.active')) } return labels.join(' · ') } -function parseCompression(value?: string | null) { - if (value == null || value.length === 0) { - return null - } - - try { - return JSON.parse(value) as ConversationCompressionRecord - } catch { - return null - } -} - function formatRouteScope(bindingKey: string) { - const [mode, platform, selfId, scope, userId] = bindingKey.split(':') - if (bindingKey.includes(':preset:')) { return bindingKey } + const [mode, platform, selfId, scope, userId] = bindingKey.split(':') + if (mode === 'shared') { return `${mode} ${platform}/${selfId}/${scope}` } @@ -98,40 +55,38 @@ function formatRouteScope(bindingKey: string) { return `${mode} ${platform}/${selfId}/${scope}/${userId}` } -function formatRuleState(value?: string | null, fallback = 'reset') { - return value ?? fallback -} - function formatConversationError( - session: import('koishi').Session, + session: Session, error: Error, action?: string ) { if (error.message === 'Conversation not found.') { - return session.text('.messages.target_not_found') + return session.text('chatluna.conversation.messages.target_not_found') } if (error.message === 'Conversation target is ambiguous.') { - return session.text('.messages.target_ambiguous') + return session.text('chatluna.conversation.messages.target_ambiguous') } if (error.message === 'Conversation does not belong to current route.') { - return session.text('.messages.target_outside_route') + return session.text( + 'chatluna.conversation.messages.target_outside_route' + ) } if ( error.message === 'Conversation management requires administrator permission.' ) { - return session.text('.messages.admin_required') + return session.text('chatluna.conversation.messages.admin_required') } const locked = error.message.match( /^Conversation (.+) is locked by constraint\.$/ ) if (locked) { - return session.text('.messages.action_locked', [ - session.text(`.action.${locked[1]}`) + return session.text('chatluna.conversation.messages.action_locked', [ + session.text(`chatluna.conversation.action.${locked[1]}`) ]) } @@ -139,29 +94,35 @@ function formatConversationError( /^Conversation (.+) is disabled by constraint\.$/ ) if (disabled) { - return session.text('.messages.action_disabled', [ - session.text(`.action.${disabled[1]}`) + return session.text('chatluna.conversation.messages.action_disabled', [ + session.text(`chatluna.conversation.action.${disabled[1]}`) ]) } const fixedModel = error.message.match(/^Model is fixed to (.+)\.$/) if (fixedModel) { - return session.text('.messages.fixed_model', [fixedModel[1]]) + return session.text('chatluna.conversation.messages.fixed_model', [ + fixedModel[1] + ]) } const fixedPreset = error.message.match(/^Preset is fixed to (.+)\.$/) if (fixedPreset) { - return session.text('.messages.fixed_preset', [fixedPreset[1]]) + return session.text('chatluna.conversation.messages.fixed_preset', [ + fixedPreset[1] + ]) } const fixedMode = error.message.match(/^Chat mode is fixed to (.+)\.$/) if (fixedMode) { - return session.text('.messages.fixed_chat_mode', [fixedMode[1]]) + return session.text('chatluna.conversation.messages.fixed_chat_mode', [ + fixedMode[1] + ]) } if (action != null) { - return session.text('.messages.action_failed', [ - session.text(`.action.${action}`), + return session.text('chatluna.conversation.messages.action_failed', [ + session.text(`chatluna.conversation.action.${action}`), error.message ]) } @@ -169,455 +130,346 @@ function formatConversationError( return error.message } -function formatConversationBlock( - session: import('koishi').Session, - resolved: ResolvedConversationContext, - conversation: ConversationRecord +function formatConversationLine( + session: Session, + conversation: ConversationRecord, + resolved: ResolvedConversationContext ) { - const compression = parseCompression(conversation.compression) - const updatedAt = conversation.lastChatAt ?? conversation.updatedAt + const status = formatConversationStatus( + session, + conversation, + resolved.binding?.activeConversationId + ) const effectiveModel = resolved.constraint.fixedModel ?? conversation.model ?? resolved.constraint.defaultModel ?? '-' - const effectivePreset = - resolved.presetLane ?? - resolved.constraint.fixedPreset ?? - conversation.preset ?? - resolved.constraint.defaultPreset ?? - '-' - const effectiveChatMode = - resolved.constraint.fixedChatMode ?? - conversation.chatMode ?? - resolved.constraint.defaultChatMode ?? - '-' - - return [ - session.text('.conversation_scope', [ - formatRouteScope(resolved.bindingKey) - ]), - session.text('.conversation_base_scope', [ - formatRouteScope(resolved.constraint.baseKey) - ]), - session.text('.conversation_route_mode', [ - resolved.constraint.routeMode - ]), - session.text('.conversation_active', [ - resolved.binding?.activeConversationId ?? '-' - ]), - session.text('.conversation_last', [ - resolved.binding?.lastConversationId ?? '-' - ]), - session.text('.conversation_seq', [conversation.seq ?? '-']), - session.text('.conversation_title', [conversation.title]), - session.text('.conversation_id', [conversation.id]), - session.text('.conversation_status', [ - formatConversationStatus( - session, - conversation, - resolved.binding?.activeConversationId - ) - ]), - session.text('.conversation_model', [conversation.model]), - session.text('.conversation_preset', [conversation.preset]), - session.text('.conversation_chat_mode', [conversation.chatMode]), - session.text('.conversation_effective_model', [effectiveModel]), - session.text('.conversation_effective_preset', [effectivePreset]), - session.text('.conversation_effective_chat_mode', [effectiveChatMode]), - session.text('.conversation_default_model', [ - resolved.constraint.defaultModel ?? '-' - ]), - session.text('.conversation_default_preset', [ - resolved.constraint.defaultPreset ?? '-' - ]), - session.text('.conversation_default_chat_mode', [ - resolved.constraint.defaultChatMode ?? '-' - ]), - session.text('.conversation_fixed_model', [ - resolved.constraint.fixedModel ?? '-' - ]), - session.text('.conversation_fixed_preset', [ - resolved.constraint.fixedPreset ?? '-' - ]), - session.text('.conversation_fixed_chat_mode', [ - resolved.constraint.fixedChatMode ?? '-' - ]), - session.text('.conversation_lock', [ - resolved.constraint.lockConversation ? 'locked' : 'unlocked' - ]), - session.text('.conversation_allow_new', [ - resolved.constraint.allowNew ? 'enabled' : 'disabled' - ]), - session.text('.conversation_allow_switch', [ - resolved.constraint.allowSwitch ? 'enabled' : 'disabled' - ]), - session.text('.conversation_allow_archive', [ - resolved.constraint.allowArchive ? 'enabled' : 'disabled' - ]), - session.text('.conversation_allow_export', [ - resolved.constraint.allowExport ? 'enabled' : 'disabled' - ]), - session.text('.conversation_manage_mode', [ - resolved.constraint.manageMode - ]), - session.text('.conversation_preset_lane', [resolved.presetLane ?? '-']), - session.text('.conversation_compression_count', [ - compression?.count ?? 0 - ]), - session.text('.conversation_updated_at', [updatedAt.toISOString()]), - '' - ].join('\n') + return session.text('chatluna.conversation.conversation_line', [ + conversation.seq ?? '-', + conversation.title, + status, + effectiveModel + ]) } -function formatConversationLine( - session: import('koishi').Session, - conversation: ConversationRecord, - resolved: ResolvedConversationContext -) { - return formatConversationBlock(session, resolved, conversation) +function formatLockState(lock: boolean | null | undefined) { + return lock == null ? 'reset' : lock ? 'locked' : 'unlocked' } export function apply(ctx: Context, config: Config, chain: ChatChain) { const pagination = new Pagination({ formatItem: () => '', - formatString: { - top: '', - bottom: '', - pages: '' - } + formatString: { top: '', bottom: '', pages: '' } }) - chain - .middleware('conversation_new', async (session, context) => { - if (context.command !== 'conversation_new') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const presetLane = context.options.conversation_create?.preset - const resolved = await ctx.chatluna.conversation.resolveContext( - session, - { - presetLane + function middleware( + name: Parameters[0], + fn: ( + session: Session, + context: ChainMiddlewareContext + ) => Promise + ) { + chain + .middleware(name, async (session, context) => { + if (context.command !== name) { + return ChainMiddlewareRunStatus.SKIPPED } - ) - - if ( - resolved.constraint.manageMode === 'admin' && - !(await checkAdmin(session)) - ) { - context.message = session.text('.messages.admin_required') - return ChainMiddlewareRunStatus.STOP - } + return fn(session, context) + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') + } - if ( - resolved.constraint.lockConversation && - resolved.binding?.activeConversationId != null - ) { - context.message = session.text('.messages.action_locked', [ - session.text('.action.create') - ]) - return ChainMiddlewareRunStatus.STOP - } + middleware('conversation_new', async (session, context) => { + const presetLane = context.options.conversation_create?.preset + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { presetLane } + ) + + if ( + resolved.constraint.manageMode === 'admin' && + !(await checkAdmin(session)) + ) { + context.message = session.text( + 'chatluna.conversation.messages.admin_required' + ) + return ChainMiddlewareRunStatus.STOP + } - if ( - context.options.conversation_create?.model != null && - resolved.constraint.fixedModel != null && - context.options.conversation_create?.model !== - resolved.constraint.fixedModel - ) { - context.message = session.text('.messages.fixed_model', [ - resolved.constraint.fixedModel - ]) - return ChainMiddlewareRunStatus.STOP - } + if ( + resolved.constraint.lockConversation && + resolved.binding?.activeConversationId != null + ) { + context.message = session.text( + 'chatluna.conversation.messages.action_locked', + [session.text('chatluna.conversation.action.create')] + ) + return ChainMiddlewareRunStatus.STOP + } - if ( - context.options.conversation_create?.chatMode != null && - resolved.constraint.fixedChatMode != null && - context.options.conversation_create?.chatMode !== - resolved.constraint.fixedChatMode - ) { - context.message = session.text('.messages.fixed_chat_mode', [ - resolved.constraint.fixedChatMode - ]) - return ChainMiddlewareRunStatus.STOP - } + const createModel = context.options.conversation_create?.model + if ( + createModel != null && + resolved.constraint.fixedModel != null && + createModel !== resolved.constraint.fixedModel + ) { + context.message = session.text( + 'chatluna.conversation.messages.fixed_model', + [resolved.constraint.fixedModel] + ) + return ChainMiddlewareRunStatus.STOP + } - if (!resolved.constraint.allowNew) { - context.message = session.text('.messages.action_disabled', [ - session.text('.action.create') - ]) - return ChainMiddlewareRunStatus.STOP - } + const createChatMode = context.options.conversation_create?.chatMode + if ( + createChatMode != null && + resolved.constraint.fixedChatMode != null && + createChatMode !== resolved.constraint.fixedChatMode + ) { + context.message = session.text( + 'chatluna.conversation.messages.fixed_chat_mode', + [resolved.constraint.fixedChatMode] + ) + return ChainMiddlewareRunStatus.STOP + } - const conversation = - await ctx.chatluna.conversation.createConversation(session, { - bindingKey: resolved.bindingKey, - title: - context.options.conversation_create?.title ?? - presetLane ?? - session.text('.default_title'), - model: - context.options.conversation_create?.model ?? - resolved.effectiveModel ?? - config.defaultModel, - preset: resolved.effectivePreset ?? config.defaultPreset, - chatMode: - context.options.conversation_create?.chatMode ?? - resolved.effectiveChatMode ?? - config.defaultChatMode - }) + if (!resolved.constraint.allowNew) { + context.message = session.text( + 'chatluna.conversation.messages.action_disabled', + [session.text('chatluna.conversation.action.create')] + ) + return ChainMiddlewareRunStatus.STOP + } - context.options.conversationId = conversation.id - context.message = session.text('.messages.new_success', [ + const conversation = await ctx.chatluna.conversation.createConversation( + session, + { + bindingKey: resolved.bindingKey, + title: + context.options.conversation_create?.title ?? + presetLane ?? + session.text('chatluna.conversation.default_title'), + model: + createModel ?? + resolved.effectiveModel ?? + config.defaultModel, + preset: resolved.effectivePreset ?? config.defaultPreset, + chatMode: + createChatMode ?? + resolved.effectiveChatMode ?? + config.defaultChatMode + } + ) + + context.options.conversationId = conversation.id + context.message = session.text( + 'chatluna.conversation.messages.new_success', + [ conversation.title, conversation.seq ?? conversation.id, conversation.id - ]) - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_switch', async (session, context) => { - if (context.command !== 'conversation_switch') { - return ChainMiddlewareRunStatus.SKIPPED - } + ] + ) + return ChainMiddlewareRunStatus.STOP + }) - const targetConversation = - context.options.conversation_manage?.targetConversation + middleware('conversation_switch', async (session, context) => { + const targetConversation = + context.options.conversation_manage?.targetConversation - if (targetConversation == null) { - context.message = session.text('.messages.target_required') - return ChainMiddlewareRunStatus.STOP - } + if (targetConversation == null) { + context.message = session.text( + 'chatluna.conversation.messages.target_required' + ) + return ChainMiddlewareRunStatus.STOP + } - try { - const conversation = - await ctx.chatluna.conversation.switchConversation( - session, - { - targetConversation, - presetLane: - context.options.conversation_manage?.presetLane - } - ) + try { + const conversation = + await ctx.chatluna.conversation.switchConversation(session, { + targetConversation, + presetLane: context.options.conversation_manage?.presetLane + }) - context.options.conversationId = conversation.id - context.message = session.text('.messages.switch_success', [ + context.options.conversationId = conversation.id + context.message = session.text( + 'chatluna.conversation.messages.switch_success', + [ conversation.title, conversation.seq ?? conversation.id, conversation.id - ]) - } catch (error) { - context.message = session.text('.messages.switch_failed', [ - formatConversationError(session, error, 'switch') - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_list', async (session, context) => { - if (context.command !== 'conversation_list') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const page = context.options.page ?? 1 - const limit = context.options.limit ?? 5 - const presetLane = context.options.conversation_manage?.presetLane - const includeArchived = - context.options.conversation_manage?.includeArchived === true - const resolved = - await ctx.chatluna.conversation.getCurrentConversation( - session, - { presetLane } - ) - const conversations = - await ctx.chatluna.conversation.listConversations(session, { - presetLane, - includeArchived - }) - - if (conversations.length === 0) { - context.message = session.text('.messages.list_empty') - return ChainMiddlewareRunStatus.STOP - } - - pagination.updateFormatString({ - top: session.text('.messages.list_header') + '\n', - bottom: '', - pages: '\n' + session.text('.messages.list_pages') - }) - pagination.updateFormatItem((conversation) => - formatConversationLine(session, conversation, resolved) + ] ) - - const key = `${resolved.bindingKey}:${session.userId}` - await pagination.push(conversations, key) - context.message = await pagination.getFormattedPage( - page, - limit, - key + } catch (error) { + context.message = session.text( + 'chatluna.conversation.messages.switch_failed', + [formatConversationError(session, error, 'switch')] ) - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_current', async (session, context) => { - if (context.command !== 'conversation_current') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const resolved = - await ctx.chatluna.conversation.getCurrentConversation( - session, - { - presetLane: - context.options.conversation_manage?.presetLane - } - ) + } - if (resolved.conversation == null) { - context.message = session.text('.messages.current_empty') - return ChainMiddlewareRunStatus.STOP - } + return ChainMiddlewareRunStatus.STOP + }) - context.message = [ - session.text('.messages.current_header'), - formatConversationLine(session, resolved.conversation, resolved) - ].join('\n') + middleware('conversation_list', async (session, context) => { + const page = context.options.page ?? 1 + const limit = context.options.limit ?? 5 + const presetLane = context.options.conversation_manage?.presetLane + const includeArchived = + context.options.conversation_manage?.includeArchived === true + const resolved = await ctx.chatluna.conversation.getCurrentConversation( + session, + { + presetLane + } + ) + const conversations = await ctx.chatluna.conversation.listConversations( + session, + { + presetLane, + includeArchived + } + ) + + if (conversations.length === 0) { + context.message = session.text( + 'chatluna.conversation.messages.list_empty' + ) return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + } - chain - .middleware('conversation_rename', async (session, context) => { - if (context.command !== 'conversation_rename') { - return ChainMiddlewareRunStatus.SKIPPED - } + pagination.updateFormatString({ + top: + session.text('chatluna.conversation.messages.list_header') + + '\n', + bottom: '', + pages: + '\n' + session.text('chatluna.conversation.messages.list_pages') + }) + pagination.updateFormatItem((conversation) => + formatConversationLine(session, conversation, resolved) + ) + + const key = `${resolved.bindingKey}` + await pagination.push(conversations, key) + context.message = await pagination.getFormattedPage(page, limit, key) + return ChainMiddlewareRunStatus.STOP + }) - const title = context.options.conversation_manage?.title - if (title == null) { - context.message = session.text('.messages.title_required') - return ChainMiddlewareRunStatus.STOP + middleware('conversation_current', async (session, context) => { + const resolved = await ctx.chatluna.conversation.getCurrentConversation( + session, + { + presetLane: context.options.conversation_manage?.presetLane } + ) - try { - const conversation = - await ctx.chatluna.conversation.renameConversation( - session, - { - conversationId: context.options.conversationId, - targetConversation: - context.options.conversation_manage - ?.targetConversation, - presetLane: - context.options.conversation_manage?.presetLane, - title - } - ) + if (resolved.conversation == null) { + context.message = session.text( + 'chatluna.conversation.messages.current_empty' + ) + return ChainMiddlewareRunStatus.STOP + } - context.message = session.text('.messages.rename_success', [ - conversation.title, - conversation.seq ?? conversation.id, - conversation.id - ]) - } catch (error) { - context.message = session.text('.messages.rename_failed', [ - formatConversationError(session, error, 'rename') - ]) - } + context.message = [ + session.text('chatluna.conversation.messages.current_header'), + formatConversationLine(session, resolved.conversation, resolved) + ].join('\n') + return ChainMiddlewareRunStatus.STOP + }) + middleware('conversation_rename', async (session, context) => { + const title = context.options.conversation_manage?.title + if (title == null) { + context.message = session.text( + 'chatluna.conversation.messages.title_required' + ) return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_delete', async (session, context) => { - if (context.command !== 'conversation_delete') { - return ChainMiddlewareRunStatus.SKIPPED - } + } - try { - const conversation = - await ctx.chatluna.conversation.deleteConversation( - session, - { - conversationId: context.options.conversationId, - targetConversation: - context.options.conversation_manage - ?.targetConversation, - presetLane: - context.options.conversation_manage?.presetLane - } - ) + try { + const conversation = + await ctx.chatluna.conversation.renameConversation(session, { + conversationId: context.options.conversationId, + targetConversation: + context.options.conversation_manage?.targetConversation, + presetLane: context.options.conversation_manage?.presetLane, + title + }) - context.message = session.text('.messages.delete_success', [ + context.message = session.text( + 'chatluna.conversation.messages.rename_success', + [ conversation.title, conversation.seq ?? conversation.id, conversation.id - ]) - } catch (error) { - context.message = session.text('.messages.delete_failed', [ - formatConversationError(session, error, 'delete') - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + ] + ) + } catch (error) { + context.message = session.text( + 'chatluna.conversation.messages.rename_failed', + [formatConversationError(session, error, 'rename')] + ) + } - chain - .middleware('conversation_use_model', async (session, context) => { - if (context.command !== 'conversation_use_model') { - return ChainMiddlewareRunStatus.SKIPPED - } + return ChainMiddlewareRunStatus.STOP + }) - try { - const conversation = - await ctx.chatluna.conversation.updateConversationUsage( - session, - { - conversationId: context.options.conversationId, - presetLane: - context.options.conversation_manage?.presetLane, - model: context.options.conversation_use?.model - } - ) + middleware('conversation_delete', async (session, context) => { + try { + const conversation = + await ctx.chatluna.conversation.deleteConversation(session, { + conversationId: context.options.conversationId, + targetConversation: + context.options.conversation_manage?.targetConversation, + presetLane: context.options.conversation_manage?.presetLane + }) - context.message = session.text('.messages.use_model_success', [ - conversation.model, + context.message = session.text( + 'chatluna.conversation.messages.delete_success', + [ conversation.title, + conversation.seq ?? conversation.id, conversation.id - ]) - } catch (error) { - context.message = session.text('.messages.use_model_failed', [ - formatConversationError(session, error, 'update') - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + ] + ) + } catch (error) { + context.message = session.text( + 'chatluna.conversation.messages.delete_failed', + [formatConversationError(session, error, 'delete')] + ) + } - chain - .middleware('conversation_use_preset', async (session, context) => { - if (context.command !== 'conversation_use_preset') { - return ChainMiddlewareRunStatus.SKIPPED - } + return ChainMiddlewareRunStatus.STOP + }) + for (const field of ['model', 'preset', 'mode'] as const) { + const fieldMap = { + model: { + cmd: 'conversation_use_model' as const, + optKey: 'model' as const, + recordKey: 'model' as const, + successKey: 'use_model_success', + failKey: 'use_model_failed' + }, + preset: { + cmd: 'conversation_use_preset' as const, + optKey: 'preset' as const, + recordKey: 'preset' as const, + successKey: 'use_preset_success', + failKey: 'use_preset_failed' + }, + mode: { + cmd: 'conversation_use_mode' as const, + optKey: 'chatMode' as const, + recordKey: 'chatMode' as const, + successKey: 'use_mode_success', + failKey: 'use_mode_failed' + } + }[field] + + middleware(fieldMap.cmd, async (session, context) => { try { const conversation = await ctx.chatluna.conversation.updateConversationUsage( @@ -626,446 +478,374 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { conversationId: context.options.conversationId, presetLane: context.options.conversation_manage?.presetLane, - preset: context.options.conversation_use?.preset + [fieldMap.optKey]: + context.options.conversation_use?.[ + fieldMap.optKey + ] } ) - context.message = session.text('.messages.use_preset_success', [ - conversation.preset, - conversation.title, - conversation.id - ]) + context.message = session.text( + `chatluna.conversation.messages.${fieldMap.successKey}`, + [ + conversation[fieldMap.recordKey], + conversation.title, + conversation.id + ] + ) } catch (error) { - context.message = session.text('.messages.use_preset_failed', [ - formatConversationError(session, error, 'update') - ]) + context.message = session.text( + `chatluna.conversation.messages.${fieldMap.failKey}`, + [formatConversationError(session, error, 'update')] + ) } return ChainMiddlewareRunStatus.STOP }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_use_mode', async (session, context) => { - if (context.command !== 'conversation_use_mode') { - return ChainMiddlewareRunStatus.SKIPPED - } - - try { - const conversation = - await ctx.chatluna.conversation.updateConversationUsage( - session, - { - conversationId: context.options.conversationId, - presetLane: - context.options.conversation_manage?.presetLane, - chatMode: context.options.conversation_use?.chatMode - } - ) + } - context.message = session.text('.messages.use_mode_success', [ - conversation.chatMode, - conversation.title, - conversation.id - ]) - } catch (error) { - context.message = session.text('.messages.use_mode_failed', [ - formatConversationError(session, error, 'update') - ]) - } + middleware('conversation_archive', async (session, context) => { + const targetConversation = pickConversationTarget(context) + if (targetConversation == null) { + context.message = session.text( + 'chatluna.conversation.messages.archive_empty' + ) return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_archive', async (session, context) => { - if (context.command !== 'conversation_archive') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const targetConversation = pickConversationTarget(context) - - if (targetConversation == null) { - context.message = session.text('.messages.archive_empty') - return ChainMiddlewareRunStatus.STOP - } + } - try { - const result = - await ctx.chatluna.conversation.archiveConversation( - session, - { - targetConversation, - presetLane: - context.options.conversation_manage?.presetLane - } - ) + try { + const result = await ctx.chatluna.conversation.archiveConversation( + session, + { + targetConversation, + presetLane: context.options.conversation_manage?.presetLane + } + ) - context.message = session.text('.messages.archive_success', [ + context.message = session.text( + 'chatluna.conversation.messages.archive_success', + [ result.conversation.title, result.conversation.seq ?? result.conversation.id, result.conversation.id, result.archive.id - ]) - } catch (error) { - context.message = session.text('.messages.archive_failed', [ - formatConversationError(session, error, 'archive') - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + ] + ) + } catch (error) { + context.message = session.text( + 'chatluna.conversation.messages.archive_failed', + [formatConversationError(session, error, 'archive')] + ) + } - chain - .middleware('conversation_restore', async (session, context) => { - if (context.command !== 'conversation_restore') { - return ChainMiddlewareRunStatus.SKIPPED - } + return ChainMiddlewareRunStatus.STOP + }) - const targetConversation = pickConversationTarget(context) + middleware('conversation_restore', async (session, context) => { + const targetConversation = pickConversationTarget(context) - if (targetConversation == null) { - context.message = session.text('.messages.restore_empty') - return ChainMiddlewareRunStatus.STOP - } + if (targetConversation == null) { + context.message = session.text( + 'chatluna.conversation.messages.restore_empty' + ) + return ChainMiddlewareRunStatus.STOP + } - try { - const conversation = - await ctx.chatluna.conversation.reopenConversation( - session, - { - targetConversation, - presetLane: - context.options.conversation_manage?.presetLane, - includeArchived: true - } - ) + try { + const conversation = + await ctx.chatluna.conversation.reopenConversation(session, { + targetConversation, + presetLane: context.options.conversation_manage?.presetLane, + includeArchived: true + }) - context.options.conversationId = conversation.id - context.message = session.text('.messages.restore_success', [ + context.options.conversationId = conversation.id + context.message = session.text( + 'chatluna.conversation.messages.restore_success', + [ conversation.title, conversation.seq ?? conversation.id, conversation.id - ]) - } catch (error) { - context.message = session.text('.messages.restore_failed', [ - formatConversationError(session, error, 'restore') - ]) - } + ] + ) + } catch (error) { + context.message = session.text( + 'chatluna.conversation.messages.restore_failed', + [formatConversationError(session, error, 'restore')] + ) + } + + return ChainMiddlewareRunStatus.STOP + }) + middleware('conversation_export', async (session, context) => { + const targetConversation = pickConversationTarget(context) + + if (targetConversation == null) { + context.message = session.text( + 'chatluna.conversation.messages.export_empty' + ) return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + } - chain - .middleware('conversation_export', async (session, context) => { - if (context.command !== 'conversation_export') { - return ChainMiddlewareRunStatus.SKIPPED - } + try { + const result = await ctx.chatluna.conversation.exportConversation( + session, + { + targetConversation, + presetLane: context.options.conversation_manage?.presetLane, + includeArchived: true + } + ) - const targetConversation = pickConversationTarget(context) + context.message = session.text( + 'chatluna.conversation.messages.export_success', + [ + result.conversation.title, + result.conversation.seq ?? result.conversation.id, + result.conversation.id, + result.path, + result.size + ] + ) + } catch (error) { + context.message = session.text( + 'chatluna.conversation.messages.export_failed', + [formatConversationError(session, error, 'export')] + ) + } - if (targetConversation == null) { - context.message = session.text('.messages.export_empty') - return ChainMiddlewareRunStatus.STOP - } + return ChainMiddlewareRunStatus.STOP + }) + for (const ruleField of ['model', 'preset', 'mode'] as const) { + const ruleMap = { + model: { + cmd: 'conversation_rule_model' as const, + optKey: 'model' as const, + constraintKey: 'fixedModel' as const, + msgKey: 'rule_model_success' + }, + preset: { + cmd: 'conversation_rule_preset' as const, + optKey: 'preset' as const, + constraintKey: 'fixedPreset' as const, + msgKey: 'rule_preset_success' + }, + mode: { + cmd: 'conversation_rule_mode' as const, + optKey: 'chatMode' as const, + constraintKey: 'fixedChatMode' as const, + msgKey: 'rule_mode_success' + } + }[ruleField] + + middleware(ruleMap.cmd, async (session, context) => { + const value = context.options.conversation_rule?.[ruleMap.optKey] try { - const result = - await ctx.chatluna.conversation.exportConversation( + const record = + await ctx.chatluna.conversation.updateManagedConstraint( session, { - targetConversation, - presetLane: - context.options.conversation_manage?.presetLane, - includeArchived: true + [ruleMap.constraintKey]: + value === 'reset' ? null : value } ) - context.message = session.text('.messages.export_success', [ - result.conversation.title, - result.conversation.seq ?? result.conversation.id, - result.conversation.id, - result.path, - result.size, - result.checksum - ]) + context.message = session.text( + `chatluna.conversation.messages.${ruleMap.msgKey}`, + [record[ruleMap.constraintKey] ?? 'reset'] + ) } catch (error) { - context.message = session.text('.messages.export_failed', [ - formatConversationError(session, error, 'export') - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_rule_model', async (session, context) => { - if (context.command !== 'conversation_rule_model') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const value = context.options.conversation_rule?.model - const record = - await ctx.chatluna.conversation.updateManagedConstraint( + context.message = formatConversationError( session, - { - fixedModel: value === 'reset' ? null : value - } + error, + ruleField ) - - context.message = session.text('.messages.rule_model_success', [ - formatRuleState(record.fixedModel) - ]) - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_rule_preset', async (session, context) => { - if (context.command !== 'conversation_rule_preset') { - return ChainMiddlewareRunStatus.SKIPPED } - const value = context.options.conversation_rule?.preset - const record = - await ctx.chatluna.conversation.updateManagedConstraint( - session, - { - fixedPreset: value === 'reset' ? null : value - } - ) - - context.message = session.text('.messages.rule_preset_success', [ - formatRuleState(record.fixedPreset) - ]) return ChainMiddlewareRunStatus.STOP }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_rule_mode', async (session, context) => { - if (context.command !== 'conversation_rule_mode') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const value = context.options.conversation_rule?.chatMode - const record = - await ctx.chatluna.conversation.updateManagedConstraint( - session, - { - fixedChatMode: value === 'reset' ? null : value - } - ) + } - context.message = session.text('.messages.rule_mode_success', [ - formatRuleState(record.fixedChatMode) - ]) + middleware('conversation_rule_share', async (session, context) => { + const share = context.options.conversation_rule?.share + const routeMode = + share === 'reset' + ? null + : share === 'shared' || share === 'personal' + ? share + : undefined + + if (routeMode === undefined) { + context.message = session.text( + 'chatluna.conversation.messages.rule_share_failed', + [session.text('chatluna.conversation.messages.rule_share_hint')] + ) return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_rule_share', async (session, context) => { - if (context.command !== 'conversation_rule_share') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const share = context.options.conversation_rule?.share - const routeMode = - share === 'reset' - ? null - : share === 'shared' || share === 'personal' - ? share - : undefined - - if (routeMode === undefined) { - context.message = session.text('.messages.rule_share_failed', [ - 'share must be personal, shared, or reset.' - ]) - return ChainMiddlewareRunStatus.STOP - } + } + try { const record = await ctx.chatluna.conversation.updateManagedConstraint( session, - { - routeMode - } + { routeMode } ) - context.message = session.text('.messages.rule_share_success', [ - formatRuleState(record.routeMode) - ]) - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + context.message = session.text( + 'chatluna.conversation.messages.rule_share_success', + [record.routeMode ?? 'reset'] + ) + } catch (error) { + context.message = formatConversationError(session, error, 'share') + } - chain - .middleware('conversation_rule_lock', async (session, context) => { - if (context.command !== 'conversation_rule_lock') { - return ChainMiddlewareRunStatus.SKIPPED - } + return ChainMiddlewareRunStatus.STOP + }) + middleware('conversation_rule_lock', async (session, context) => { + const raw = context.options.conversation_rule?.lock + + let lock: boolean | null | undefined + if (raw === 'reset') { + lock = null + } else if (raw === 'true' || raw === 'on' || raw === 'lock') { + lock = true + } else if (raw === 'false' || raw === 'off' || raw === 'unlock') { + lock = false + } else if (raw === 'toggle') { const current = await ctx.chatluna.conversation.getManagedConstraint(session) - const raw = context.options.conversation_rule?.lock - const lock = - raw === 'reset' - ? null - : raw === 'true' || raw === 'on' || raw === 'lock' - ? true - : raw === 'false' || raw === 'off' || raw === 'unlock' - ? false - : raw === 'toggle' - ? !(current?.lockConversation === true) - : undefined - - if (lock === undefined) { - context.message = session.text('.messages.rule_lock_failed', [ - 'lock must be on, off, reset, or toggle.' - ]) - return ChainMiddlewareRunStatus.STOP - } + lock = !(current?.lockConversation === true) + } else { + context.message = session.text( + 'chatluna.conversation.messages.rule_lock_failed', + [session.text('chatluna.conversation.messages.rule_lock_hint')] + ) + return ChainMiddlewareRunStatus.STOP + } + try { const record = await ctx.chatluna.conversation.updateManagedConstraint( session, - { - lockConversation: lock - } + { lockConversation: lock } ) - context.message = session.text('.messages.rule_lock_success', [ - record.lockConversation == null - ? 'reset' - : record.lockConversation - ? 'locked' - : 'unlocked' - ]) - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') - - chain - .middleware('conversation_rule_show', async (session, context) => { - if (context.command !== 'conversation_rule_show') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const current = - await ctx.chatluna.conversation.getManagedConstraint(session) - const resolved = await ctx.chatluna.conversation.resolveContext( - session, - { - presetLane: context.options.conversation_manage?.presetLane - } + context.message = session.text( + 'chatluna.conversation.messages.rule_lock_success', + [formatLockState(record.lockConversation)] ) + } catch (error) { + context.message = formatConversationError(session, error, 'lock') + } - context.message = [ - session.text('.messages.rule_show_header'), - session.text('.conversation_scope', [ - formatRouteScope(resolved.bindingKey) - ]), - session.text('.rule_share', [ - formatRuleState(current?.routeMode) - ]), - session.text('.rule_model', [ - formatRuleState(current?.fixedModel) - ]), - session.text('.rule_preset', [ - formatRuleState(current?.fixedPreset) - ]), - session.text('.rule_mode', [ - formatRuleState(current?.fixedChatMode) - ]), - session.text('.rule_lock', [ - current?.lockConversation == null - ? 'reset' - : current.lockConversation - ? 'locked' - : 'unlocked' - ]) - ].join('\n') - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + return ChainMiddlewareRunStatus.STOP + }) - chain - .middleware('conversation_compress', async (session, context) => { - if (context.command !== 'conversation_compress') { - return ChainMiddlewareRunStatus.SKIPPED - } + middleware('conversation_rule_show', async (session, context) => { + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { presetLane: context.options.conversation_manage?.presetLane } + ) + const current = + await ctx.chatluna.conversation.getManagedConstraint(session) + + context.message = [ + session.text('chatluna.conversation.messages.rule_show_header'), + session.text('chatluna.conversation.conversation_scope', [ + formatRouteScope(resolved.bindingKey) + ]), + session.text('chatluna.conversation.rule_share', [ + current?.routeMode ?? 'reset' + ]), + session.text('chatluna.conversation.rule_model', [ + current?.fixedModel ?? 'reset' + ]), + session.text('chatluna.conversation.rule_preset', [ + current?.fixedPreset ?? 'reset' + ]), + session.text('chatluna.conversation.rule_mode', [ + current?.fixedChatMode ?? 'reset' + ]), + session.text('chatluna.conversation.rule_lock', [ + formatLockState(current?.lockConversation) + ]) + ].join('\n') + return ChainMiddlewareRunStatus.STOP + }) - const key = - context.options.i18n_base ?? - 'commands.chatluna.compress.messages' - const { conversation, resolved } = await resolveManagedConversation( - ctx, - session, - context - ) + middleware('conversation_compress', async (session, context) => { + const key = + context.options.i18n_base ?? 'commands.chatluna.compress.messages' + const presetLane = context.options.conversation_manage?.presetLane + const resolved = await ctx.chatluna.conversation.resolveContext( + session, + { presetLane, conversationId: context.options.conversationId } + ) + const targetConversation = pickConversationTarget( + context, + resolved.conversation + ) + const conversation = + targetConversation != null + ? await ctx.chatluna.conversation.resolveTargetConversation( + session, + { + presetLane, + targetConversation, + conversationId: context.options.conversationId, + permission: 'manage', + includeArchived: + context.options.conversation_manage + ?.includeArchived === true + } + ) + : null - if (conversation == null) { - context.message = session.text(`${key}.no_conversation`) - return ChainMiddlewareRunStatus.STOP - } + if (conversation == null) { + context.message = session.text(`${key}.no_conversation`) + return ChainMiddlewareRunStatus.STOP + } - if (resolved.constraint.lockConversation) { - context.message = session.text(`${key}.failed`, [ - conversation.title, - conversation.id, - session.text( - 'chatluna.conversation.messages.action_locked', - [session.text('chatluna.conversation.action.compress')] - ) + if (resolved.constraint.lockConversation) { + context.message = session.text(`${key}.failed`, [ + conversation.title, + conversation.id, + session.text('chatluna.conversation.messages.action_locked', [ + session.text('chatluna.conversation.action.compress') ]) - return ChainMiddlewareRunStatus.STOP - } + ]) + return ChainMiddlewareRunStatus.STOP + } - try { - const result = - await ctx.chatluna.conversationRuntime.compressConversation( - conversation, - context.options.force === true - ) - const args = [ + try { + const result = + await ctx.chatluna.conversationRuntime.compressConversation( + conversation, + context.options.force === true + ) + + context.message = session.text( + result.compressed ? `${key}.success` : `${key}.skipped`, + [ result.inputTokens, result.outputTokens, result.reducedPercent.toFixed(2) ] + ) + } catch (error) { + ctx.logger.error(error) + context.message = session.text(`${key}.failed`, [ + conversation.title, + conversation.id, + formatConversationError(session, error, 'compress') + ]) + } - context.message = session.text( - result.compressed ? `${key}.success` : `${key}.skipped`, - args - ) - } catch (error) { - ctx.logger.error(error) - context.message = session.text(`${key}.failed`, [ - conversation.title, - conversation.id, - formatConversationError(session, error, 'compress') - ]) - } - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + return ChainMiddlewareRunStatus.STOP + }) } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index 21eb55d7a..d0c6d2796 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -5,11 +5,10 @@ import { createLogger } from 'koishi-plugin-chatluna/utils/logger' import fs from 'fs/promises' import { createLegacyTableRetention, - getLegacySchemaSentinel, - getLegacySchemaSentinelDir, LEGACY_MIGRATION_TABLES, LEGACY_RETENTION_META_KEY, LEGACY_RUNTIME_TABLES, + purgeLegacyTables, readMetaValue, writeMetaValue } from '../../migration/validators' @@ -34,23 +33,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - for (const table of LEGACY_MIGRATION_TABLES) { - try { - await ctx.database.drop(table) - } catch (error) { - logger.warn(`purge legacy ${table}: ${error}`) - } - } - - const sentinel = getLegacySchemaSentinel(ctx.baseDir) - await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { - recursive: true - }) - await fs.writeFile( - sentinel, - JSON.stringify({ purgedAt: new Date().toISOString() }) - ) - + await purgeLegacyTables(ctx) await writeMetaValue( ctx, 'legacy_purged_at', diff --git a/packages/core/src/migration/legacy_tables.ts b/packages/core/src/migration/legacy_tables.ts index 8176f464b..7191e7d21 100644 --- a/packages/core/src/migration/legacy_tables.ts +++ b/packages/core/src/migration/legacy_tables.ts @@ -1,4 +1,8 @@ import path from 'path' +import fs from 'fs/promises' +import type { Context } from 'koishi' +import type { LegacyTableRetention } from './types' +export type { LegacyTableRetention } from './types' export const LEGACY_SCHEMA_SENTINEL = 'data/chatluna/temp/legacy-schema-disabled.json' @@ -20,12 +24,6 @@ export const LEGACY_RUNTIME_TABLES = [ export const LEGACY_RETENTION_META_KEY = 'legacy_table_retention' -export interface LegacyTableRetention { - state: 'migration-visible' | 'purged' - migrationTables: readonly string[] - runtimeTables: readonly string[] -} - export function createLegacyTableRetention( state: LegacyTableRetention['state'] ) { @@ -43,3 +41,23 @@ export function getLegacySchemaSentinel(baseDir: string) { export function getLegacySchemaSentinelDir(baseDir: string) { return path.dirname(getLegacySchemaSentinel(baseDir)) } + +export async function purgeLegacyTables(ctx: Context) { + for (const table of [ + ...LEGACY_MIGRATION_TABLES, + ...LEGACY_RUNTIME_TABLES + ]) { + try { + await ctx.database.drop(table) + } catch (error) { + ctx.logger.warn(`purge legacy ${table}: ${error}`) + } + } + + const sentinel = getLegacySchemaSentinel(ctx.baseDir) + await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { recursive: true }) + await fs.writeFile( + sentinel, + JSON.stringify({ purgedAt: new Date().toISOString() }) + ) +} diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts index c3acf7796..a8ce23233 100644 --- a/packages/core/src/migration/room_to_conversation.ts +++ b/packages/core/src/migration/room_to_conversation.ts @@ -14,34 +14,28 @@ import type { LegacyRoomRecord, LegacyUserRecord } from '../services/types' +import type { BindingProgress, MessageProgress, RoomProgress } from './types' import { LEGACY_RETENTION_META_KEY, - createLegacyTableRetention, + aclKey, createLegacyBindingKey, + createLegacyTableRetention, + filterValidRooms, inferLegacyGroupRouteModes, isComplexRoom, + purgeLegacyTables, readMetaValue, + resolveRoomBindingKey, validateRoomMigration, writeMetaValue } from './validators' export const BUILTIN_SCHEMA_VERSION = 1 -interface RoomProgress { - lastRoomId: number - migrated: number -} - -interface MessageProgress { - index: number - lastId?: string - migrated: number -} - -interface BindingProgress { - index: number - migrated: number -} +// (#11) module-level tuning constant +const MESSAGE_BATCH_SIZE = 500 +// (#8) write progress at most every N bindings to reduce I/O +const BINDING_PROGRESS_BATCH = 50 export async function runRoomToConversationMigration( ctx: Context, @@ -59,6 +53,7 @@ export async function runRoomToConversationMigration( } ctx.logger.info('Running built-in ChatLuna migration.') + // (#15) only one start timestamp needed await writeMetaValue(ctx, 'migration_started_at', new Date().toISOString()) await writeMetaValue(ctx, 'schema_version', BUILTIN_SCHEMA_VERSION) await writeMetaValue( @@ -78,25 +73,29 @@ export async function runRoomToConversationMigration( const result = await validateRoomMigration(ctx, config) await writeMetaValue(ctx, 'validation_result', result) - await writeMetaValue(ctx, 'migration_timestamp', new Date().toISOString()) + await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) await writeMetaValue(ctx, 'room_migration_done', true) await writeMetaValue(ctx, 'message_migration_done', true) - await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) if (!result.passed) { throw new Error('ChatLuna migration validation failed.') } + await purgeLegacyTables(ctx) await writeMetaValue( ctx, LEGACY_RETENTION_META_KEY, - createLegacyTableRetention('migration-visible') + createLegacyTableRetention('purged') ) ctx.logger.info('Built-in ChatLuna migration finished.') return result } +// (#13) guard against re-entrant call: ensureMigrationValidated only calls +// runRoomToConversationMigration when flags indicate migration is incomplete, +// while runRoomToConversationMigration only calls ensureMigrationValidated when +// flags indicate it IS complete — so the two paths are mutually exclusive. export async function ensureMigrationValidated(ctx: Context, config: Config) { const result = await readMetaValue< Awaited> @@ -106,7 +105,7 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { await writeMetaValue( ctx, LEGACY_RETENTION_META_KEY, - createLegacyTableRetention('migration-visible') + createLegacyTableRetention('purged') ) return result } @@ -123,6 +122,9 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { roomDone !== true || messageDone !== true ) { + // Migration is genuinely incomplete; restart it. + // runRoomToConversationMigration will NOT call back into ensureMigrationValidated + // because it only does so when all done-flags are true, which they are not here. return runRoomToConversationMigration(ctx, config) } @@ -133,33 +135,22 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { throw new Error('ChatLuna migration validation failed.') } + await purgeLegacyTables(ctx) await writeMetaValue( ctx, LEGACY_RETENTION_META_KEY, - createLegacyTableRetention('migration-visible') + createLegacyTableRetention('purged') ) return validated } +// (#6) removed redundant inner `done` check — the caller already guards on room_migration_done async function migrateRooms(ctx: Context) { - const done = - (await readMetaValue(ctx, 'room_migration_done')) ?? false - - if (done) { - return - } - - const rooms = ( + const rooms = filterValidRooms( (await ctx.database.get('chathub_room', {})) as LegacyRoomRecord[] - ) - .filter( - (room) => - room.conversationId != null && - room.conversationId !== '' && - room.conversationId !== '0' - ) - .sort((a, b) => a.roomId - b.roomId) + ).sort((a, b) => a.roomId - b.roomId) + const oldConversations = (await ctx.database.get( 'chathub_conversation', {} @@ -181,13 +172,20 @@ async function migrateRooms(ctx: Context) { 'chatluna_conversation', {} )) as ConversationRecord[] - const existingMap = new Map(existing.map((item) => [item.id, item])) + + // (#9) key by legacyRoomId to avoid conversationId collisions across multiple rooms + const existingByRoomId = new Map( + existing + .filter((item) => item.legacyRoomId != null) + .map((item) => [item.legacyRoomId!, item]) + ) + // (#10) only count records that were actually migrated from legacy rooms const progress = (await readMetaValue( ctx, 'conversation_migration_progress' )) ?? { lastRoomId: 0, - migrated: existing.length + migrated: existing.filter((item) => item.legacyRoomId != null).length } let seq = existing.reduce((max, item) => Math.max(max, item.seq ?? 0), 0) @@ -200,7 +198,7 @@ async function migrateRooms(ctx: Context) { const oldConversation = oldConversations.find( (item) => item.id === room.conversationId ) - const current = existingMap.get(room.conversationId as string) + const current = existingByRoomId.get(room.roomId) const roomMembers = members.filter( (item) => item.roomId === room.roomId ) @@ -256,7 +254,7 @@ async function migrateRooms(ctx: Context) { } await ctx.database.upsert('chatluna_conversation', [conversation]) - existingMap.set(conversation.id, conversation) + existingByRoomId.set(room.roomId, conversation) const acl = buildAclRecords(room, roomMembers, roomGroups) if (acl.length > 0) { @@ -269,14 +267,8 @@ async function migrateRooms(ctx: Context) { } } +// (#7) removed redundant inner `done` check async function migrateMessages(ctx: Context) { - const done = - (await readMetaValue(ctx, 'message_migration_done')) ?? false - - if (done) { - return - } - const oldMessages = (await ctx.database.get( 'chathub_message', {} @@ -289,10 +281,13 @@ async function migrateMessages(ctx: Context) { migrated: 0 } - const batchSize = 500 - - for (let i = progress.index; i < oldMessages.length; i += batchSize) { - const batch = oldMessages.slice(i, i + batchSize) + for ( + let i = progress.index; + i < oldMessages.length; + i += MESSAGE_BATCH_SIZE + ) { + const batch = oldMessages.slice(i, i + MESSAGE_BATCH_SIZE) + // (#12) removed redundant payload.length > 0 check — batch is always non-empty here const payload = batch.map((item) => ({ id: item.id, conversationId: item.conversation, @@ -309,9 +304,7 @@ async function migrateMessages(ctx: Context) { createdAt: null })) satisfies MessageRecord[] - if (payload.length > 0) { - await ctx.database.upsert('chatluna_message', payload) - } + await ctx.database.upsert('chatluna_message', payload) progress.index = i + batch.length progress.lastId = batch[batch.length - 1]?.id @@ -349,6 +342,13 @@ async function migrateBindings(ctx: Context) { migrated: 0 } + // (#17) load all existing bindings up front to avoid N+1 DB queries + const existingBindings = new Map( + ( + (await ctx.database.get('chatluna_binding', {})) as BindingRecord[] + ).map((item) => [item.bindingKey, item]) + ) + for (let i = progress.index; i < users.length; i++) { const user = users[i] const conversation = conversations.find( @@ -358,64 +358,45 @@ async function migrateBindings(ctx: Context) { progress.index = i + 1 if (conversation == null) { - await writeMetaValue(ctx, 'binding_migration_progress', progress) + // (#8) batch progress writes; flush at interval or at end + if (i % BINDING_PROGRESS_BATCH === 0 || i === users.length - 1) { + await writeMetaValue( + ctx, + 'binding_migration_progress', + progress + ) + } continue } const bindingKey = createLegacyBindingKey(user, routeModes) - const current = ( - (await ctx.database.get('chatluna_binding', { - bindingKey - })) as BindingRecord[] - )[0] + const current = existingBindings.get(bindingKey) + + // (#16) decomposed nested ternary into explicit variable + const prevActive = current?.activeConversationId + const lastConversationId = + prevActive != null && prevActive !== conversation.id + ? prevActive + : (current?.lastConversationId ?? null) await ctx.database.upsert('chatluna_binding', [ { bindingKey, activeConversationId: conversation.id, - lastConversationId: - current?.activeConversationId && - current.activeConversationId !== conversation.id - ? current.activeConversationId - : (current?.lastConversationId ?? null), + lastConversationId, updatedAt: new Date() } ]) progress.migrated += 1 - await writeMetaValue(ctx, 'binding_migration_progress', progress) - } -} - -function resolveRoomBindingKey( - room: LegacyRoomRecord, - members: LegacyRoomMemberRecord[], - groups: LegacyRoomGroupRecord[], - routeModes: Map -) { - if (isComplexRoom(room, members, groups)) { - return `custom:legacy:room:${room.roomId}` - } - - const groupId = groups[0]?.groupId - const userId = members[0]?.userId ?? room.roomMasterId - - if (groupId == null || groupId.length === 0) { - return `personal:legacy:legacy:direct:${userId}` - } - - if ( - groups.length === 1 && - (room.visibility === 'public' || room.visibility === 'template_clone') - ) { - return `shared:legacy:legacy:${groupId}` - } - - if ((routeModes.get(groupId) ?? 'personal') === 'shared') { - return `shared:legacy:legacy:${groupId}` + // (#8) batch progress writes + if ( + progress.migrated % BINDING_PROGRESS_BATCH === 0 || + i === users.length - 1 + ) { + await writeMetaValue(ctx, 'binding_migration_progress', progress) + } } - - return `personal:legacy:legacy:${groupId}:${userId}` } function buildAclRecords( @@ -428,10 +409,21 @@ function buildAclRecords( } const conversationId = room.conversationId as string + // (#4) inlined addAclRecord — was a 4-line single-use wrapper const map = new Map() + const add = (record: ACLRecord) => + map.set( + aclKey( + record.conversationId, + record.principalType, + record.principalId, + record.permission + ), + record + ) for (const member of members) { - addAclRecord(map, { + add({ conversationId, principalType: 'user', principalId: member.userId, @@ -442,7 +434,7 @@ function buildAclRecords( member.roomPermission === 'owner' || member.roomPermission === 'admin' ) { - addAclRecord(map, { + add({ conversationId, principalType: 'user', principalId: member.userId, @@ -452,7 +444,7 @@ function buildAclRecords( } for (const group of groups) { - addAclRecord(map, { + add({ conversationId, principalType: 'guild', principalId: group.groupId, @@ -460,7 +452,7 @@ function buildAclRecords( }) } - addAclRecord(map, { + add({ conversationId, principalType: 'user', principalId: room.roomMasterId, @@ -469,10 +461,3 @@ function buildAclRecords( return Array.from(map.values()) } - -function addAclRecord(map: Map, record: ACLRecord) { - map.set( - `${record.conversationId}:${record.principalType}:${record.principalId}:${record.permission}`, - record - ) -} diff --git a/packages/core/src/migration/types.ts b/packages/core/src/migration/types.ts new file mode 100644 index 000000000..82bdb8cfa --- /dev/null +++ b/packages/core/src/migration/types.ts @@ -0,0 +1,55 @@ +export interface LegacyTableRetention { + state: 'migration-visible' | 'purged' + migrationTables: readonly string[] + runtimeTables: readonly string[] +} + +export interface RoomProgress { + lastRoomId: number + migrated: number +} + +export interface MessageProgress { + index: number + lastId?: string + migrated: number +} + +export interface BindingProgress { + index: number + migrated: number +} + +export interface MigrationValidationResult { + passed: boolean + checkedAt: string + conversation: { + legacy: number + migrated: number + matched: boolean + } + message: { + legacy: number + migrated: number + matched: boolean + } + latestMessageId: { + missingConversationIds: string[] + matched: boolean + } + bindingKey: { + inconsistentConversationIds: string[] + matched: boolean + } + binding: { + missingBindingKeys: string[] + missingConversationIds: string[] + matched: boolean + } + acl: { + expected: number + migrated: number + missing: string[] + matched: boolean + } +} diff --git a/packages/core/src/migration/validators.ts b/packages/core/src/migration/validators.ts index 21816546a..da8c2a0ac 100644 --- a/packages/core/src/migration/validators.ts +++ b/packages/core/src/migration/validators.ts @@ -1,14 +1,5 @@ import type { Context } from 'koishi' import type { Config } from '../config' -export { - getLegacySchemaSentinelDir, - getLegacySchemaSentinel, - LEGACY_MIGRATION_TABLES, - LEGACY_RETENTION_META_KEY, - LEGACY_RUNTIME_TABLES, - LEGACY_SCHEMA_SENTINEL, - createLegacyTableRetention -} from './legacy_tables' import type { ACLRecord, BindingRecord, @@ -24,86 +15,60 @@ import type { LegacyRoomRecord, LegacyUserRecord } from '../services/types' -export interface MigrationValidationResult { - passed: boolean - checkedAt: string - conversation: { - legacy: number - migrated: number - matched: boolean - } - message: { - legacy: number - migrated: number - matched: boolean - } - latestMessageId: { - missingConversationIds: string[] - matched: boolean - } - bindingKey: { - inconsistentConversationIds: string[] - matched: boolean - } - binding: { - missingBindingKeys: string[] - missingConversationIds: string[] - matched: boolean - } - acl: { - expected: number - migrated: number - missing: string[] - matched: boolean - } -} - +import type { MigrationValidationResult } from './types' +export type { MigrationValidationResult } from './types' +export { + getLegacySchemaSentinelDir, + getLegacySchemaSentinel, + LEGACY_MIGRATION_TABLES, + LEGACY_RETENTION_META_KEY, + LEGACY_RUNTIME_TABLES, + LEGACY_SCHEMA_SENTINEL, + createLegacyTableRetention, + purgeLegacyTables +} from './legacy_tables' export async function validateRoomMigration(ctx: Context, config: Config) { - const rooms = (await ctx.database.get( - 'chathub_room', - {} - )) as LegacyRoomRecord[] - const oldConversations = (await ctx.database.get( - 'chathub_conversation', - {} - )) as LegacyConversationRecord[] - const oldMessages = (await ctx.database.get( - 'chathub_message', - {} - )) as LegacyMessageRecord[] - const users = (await ctx.database.get( - 'chathub_user', - {} - )) as LegacyUserRecord[] - const members = (await ctx.database.get( - 'chathub_room_member', - {} - )) as LegacyRoomMemberRecord[] - const groups = (await ctx.database.get( - 'chathub_room_group_member', - {} - )) as LegacyRoomGroupRecord[] + // All queries are independent — run in parallel (#20) + const [ + rooms, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _, + oldMessages, + users, + members, + groups, + conversations, + messages, + bindings, + acl + ] = (await Promise.all([ + ctx.database.get('chathub_room', {}), + ctx.database.get('chathub_conversation', {}), + ctx.database.get('chathub_message', {}), + ctx.database.get('chathub_user', {}), + ctx.database.get('chathub_room_member', {}), + ctx.database.get('chathub_room_group_member', {}), + ctx.database.get('chatluna_conversation', {}), + ctx.database.get('chatluna_message', {}), + ctx.database.get('chatluna_binding', {}), + ctx.database.get('chatluna_acl', {}) + ])) as [ + LegacyRoomRecord[], + LegacyConversationRecord[], + LegacyMessageRecord[], + LegacyUserRecord[], + LegacyRoomMemberRecord[], + LegacyRoomGroupRecord[], + ConversationRecord[], + MessageRecord[], + BindingRecord[], + ACLRecord[] + ] + const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) - const conversations = (await ctx.database.get( - 'chatluna_conversation', - {} - )) as ConversationRecord[] - const messages = (await ctx.database.get( - 'chatluna_message', - {} - )) as MessageRecord[] - const bindings = (await ctx.database.get( - 'chatluna_binding', - {} - )) as BindingRecord[] - const acl = (await ctx.database.get('chatluna_acl', {})) as ACLRecord[] - - const validRooms = rooms.filter( - (room) => - room.conversationId != null && - room.conversationId !== '' && - room.conversationId !== '0' - ) + + // Shared filter logic (#21) + const validRooms = filterValidRooms(rooms) const validConversationIds = new Set( validRooms.map((room) => room.conversationId as string) ) @@ -116,9 +81,13 @@ export async function validateRoomMigration(ctx: Context, config: Config) { const migratedMessageIds = new Set(messages.map((item) => item.id)) const migratedBindingKeys = new Set(bindings.map((item) => item.bindingKey)) const migratedAclKeys = new Set( - acl.map( - (item) => - `${item.conversationId}:${item.principalType}:${item.principalId}:${item.permission}` + acl.map((item) => + aclKey( + item.conversationId, + item.principalType, + item.principalId, + item.permission + ) ) ) @@ -145,7 +114,7 @@ export async function validateRoomMigration(ctx: Context, config: Config) { return null } - const bindingKey = resolveConversationBindingKey( + const bindingKey = resolveRoomBindingKey( room, roomMembers, roomGroups, @@ -156,7 +125,7 @@ export async function validateRoomMigration(ctx: Context, config: Config) { ? null : conversation.id }) - .filter((id) => id != null) + .filter((id) => id != null) as string[] // (#22) const missingBindingKeys: string[] = [] const missingBindingConversationIds: string[] = [] @@ -167,6 +136,9 @@ export async function validateRoomMigration(ctx: Context, config: Config) { ) if (conversation == null) { + // (#23) push the conversation id (derived from defaultRoomId match), not the raw roomId + // When no conversation was migrated for this user's defaultRoomId, record it as missing. + // We record String(user.defaultRoomId) as a sentinel since no conversationId exists yet. missingBindingConversationIds.push(String(user.defaultRoomId)) continue } @@ -192,27 +164,29 @@ export async function validateRoomMigration(ctx: Context, config: Config) { const conversationId = room.conversationId as string for (const member of roomMembers) { - expectedAclKeys.push(`${conversationId}:user:${member.userId}:view`) + expectedAclKeys.push( + aclKey(conversationId, 'user', member.userId, 'view') + ) if ( member.roomPermission === 'owner' || member.roomPermission === 'admin' ) { expectedAclKeys.push( - `${conversationId}:user:${member.userId}:manage` + aclKey(conversationId, 'user', member.userId, 'manage') ) } } for (const group of roomGroups) { expectedAclKeys.push( - `${conversationId}:guild:${group.groupId}:view` + aclKey(conversationId, 'guild', group.groupId, 'view') ) } if (room.roomMasterId.length > 0) { expectedAclKeys.push( - `${conversationId}:user:${room.roomMasterId}:manage` + aclKey(conversationId, 'user', room.roomMasterId, 'manage') ) } } @@ -221,15 +195,8 @@ export async function validateRoomMigration(ctx: Context, config: Config) { (key) => !migratedAclKeys.has(key) ) - return { - passed: - validConversationIds.size === migratedLegacyConversations.length && - oldMessages.length === migratedLegacyMessages.length && - missingLatestMessageIds.length === 0 && - inconsistentBindingConversationIds.length === 0 && - missingBindingKeys.length === 0 && - missingBindingConversationIds.length === 0 && - missingAclKeys.length === 0, + // (#28) derive `passed` from the sub-result fields rather than repeating conditions + const result = { checkedAt: new Date().toISOString(), conversation: { legacy: validConversationIds.size, @@ -263,6 +230,17 @@ export async function validateRoomMigration(ctx: Context, config: Config) { missing: missingAclKeys, matched: missingAclKeys.length === 0 } + } + + return { + ...result, + passed: + result.conversation.matched && + result.message.matched && + result.latestMessageId.matched && + result.bindingKey.matched && + result.binding.matched && + result.acl.matched } satisfies MigrationValidationResult } @@ -273,7 +251,8 @@ export async function readMetaValue(ctx: Context, key: string) { })) as MetaRecord[] )[0] - if (row?.value == null || row.value === '') { + // (#27) remove spurious value === '' check; writeMetaValue stores SQL NULL for null, never '' + if (row?.value == null) { return undefined as T | undefined } @@ -296,18 +275,13 @@ export async function writeMetaValue( export function createLegacyBindingKey( user: LegacyUserRecord, - routeModes: 'shared' | 'personal' | Map + routeModes: Map // (#26) removed dead union branch; callers always pass Map ) { if (user.groupId == null || user.groupId === '' || user.groupId === '0') { return `personal:legacy:legacy:direct:${user.userId}` } - const routeMode = - routeModes instanceof Map - ? (routeModes.get(user.groupId) ?? 'personal') - : routeModes - - if (routeMode === 'shared') { + if ((routeModes.get(user.groupId) ?? 'personal') === 'shared') { return `shared:legacy:legacy:${user.groupId}` } @@ -326,6 +300,59 @@ export function isComplexRoom( ) } +// (#21) shared filter extracted from both validateRoomMigration and migrateRooms +export function filterValidRooms(rooms: LegacyRoomRecord[]) { + return rooms.filter( + (room) => + room.conversationId != null && + room.conversationId !== '' && + room.conversationId !== '0' + ) +} + +// (#18) shared ACL key format used in both migration and validation +export function aclKey( + conversationId: string, + principalType: string, + principalId: string, + permission: string +) { + return `${conversationId}:${principalType}:${principalId}:${permission}` +} + +// (#5, #29) unified binding key resolver — replaces the duplicated resolveRoomBindingKey +// in room_to_conversation.ts and resolveConversationBindingKey in validators.ts +export function resolveRoomBindingKey( + room: LegacyRoomRecord, + members: LegacyRoomMemberRecord[], + groups: LegacyRoomGroupRecord[], + routeModes: Map +) { + if (isComplexRoom(room, members, groups)) { + return `custom:legacy:room:${room.roomId}` + } + + const groupId = groups[0]?.groupId + const userId = members[0]?.userId ?? room.roomMasterId + + if (groupId == null || groupId.length === 0) { + return `personal:legacy:legacy:direct:${userId}` + } + + if ( + groups.length === 1 && + (room.visibility === 'public' || room.visibility === 'template_clone') + ) { + return `shared:legacy:legacy:${groupId}` + } + + if ((routeModes.get(groupId) ?? 'personal') === 'shared') { + return `shared:legacy:legacy:${groupId}` + } + + return `personal:legacy:legacy:${groupId}:${userId}` +} + export function inferLegacyGroupRouteModes( users: LegacyUserRecord[], rooms: LegacyRoomRecord[], @@ -333,7 +360,11 @@ export function inferLegacyGroupRouteModes( ) { const modes = new Map() const publicGroups = new Set() - const roomIds = new Map() + // (#24) firstRoomId replaces the separate roomIds Map — stores the roomId seen + // the first time a group is set to 'shared' via the optimistic heuristic (#25). + // Heuristic: if all private-room users in a group share the same roomId, it's + // treated as shared; if a second user has a different roomId, we switch to personal. + const firstRoomId = new Map() for (const group of groups) { if ( @@ -373,14 +404,14 @@ export function inferLegacyGroupRouteModes( const previous = modes.get(user.groupId) if (previous == null) { - roomIds.set(user.groupId, room.roomId) + firstRoomId.set(user.groupId, room.roomId) modes.set(user.groupId, 'shared') continue } if ( previous === 'shared' && - roomIds.get(user.groupId) !== room.roomId + firstRoomId.get(user.groupId) !== room.roomId ) { modes.set(user.groupId, 'personal') } @@ -388,34 +419,3 @@ export function inferLegacyGroupRouteModes( return modes } - -function resolveConversationBindingKey( - room: LegacyRoomRecord, - members: LegacyRoomMemberRecord[], - groups: LegacyRoomGroupRecord[], - routeModes: Map -) { - if (isComplexRoom(room, members, groups)) { - return `custom:legacy:room:${room.roomId}` - } - - const groupId = groups[0]?.groupId - const userId = members[0]?.userId ?? room.roomMasterId - - if (groupId == null || groupId.length === 0) { - return `personal:legacy:legacy:direct:${userId}` - } - - if ( - groups.length === 1 && - (room.visibility === 'public' || room.visibility === 'template_clone') - ) { - return `shared:legacy:legacy:${groupId}` - } - - if ((routeModes.get(groupId) ?? 'personal') === 'shared') { - return `shared:legacy:legacy:${groupId}` - } - - return `personal:legacy:legacy:${groupId}:${userId}` -} diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index c24e3850b..5c9c1b575 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -1,4 +1,3 @@ -import { type UsageMetadata } from '@langchain/core/messages' import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import fs from 'fs' import path from 'path' @@ -73,8 +72,8 @@ import type { Notifier } from '@koishijs/plugin-notifier' import { ChatLunaContextManagerService } from 'koishi-plugin-chatluna/llm-core/prompt' import { createChatPrompt } from 'koishi-plugin-chatluna/utils/chatluna' import { - LEGACY_MIGRATION_TABLES, - getLegacySchemaSentinel + getLegacySchemaSentinel, + LEGACY_MIGRATION_TABLES } from '../migration/legacy_tables' export class ChatLunaService extends Service { @@ -759,6 +758,10 @@ export class ChatLunaService extends Service { legacyMeta: { type: 'text', nullable: true + }, + autoTitle: { + type: 'boolean', + nullable: true } }, { @@ -1373,37 +1376,6 @@ export class ChatLunaPlugin< } } -function formatUsageMetadataMessage(usage: UsageMetadata) { - const input = [ - ...(usage.input_token_details?.audio != null - ? [`audio=${usage.input_token_details.audio}`] - : []), - ...(usage.input_token_details?.cache_read != null - ? [`cache_read=${usage.input_token_details.cache_read}`] - : []), - ...(usage.input_token_details?.cache_creation != null - ? [`cache_creation=${usage.input_token_details.cache_creation}`] - : []) - ] - const output = [ - ...(usage.output_token_details?.audio != null - ? [`audio=${usage.output_token_details.audio}`] - : []), - ...(usage.output_token_details?.reasoning != null - ? [`reasoning=${usage.output_token_details.reasoning}`] - : []) - ] - - return [ - 'Token usage:', - `- input: ${usage.input_tokens}`, - `- output: ${usage.output_tokens}`, - `- total: ${usage.total_tokens}`, - ...(input.length > 0 ? [`- input details: ${input.join(', ')}`] : []), - ...(output.length > 0 ? [`- output details: ${output.join(', ')}`] : []) - ].join('\n') -} - // eslint-disable-next-line @typescript-eslint/no-namespace export namespace ChatLunaPlugin { export interface Config { diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 87a1c325c..b9727eb01 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' -import type { Session } from 'koishi' +import type { Context, Session, User } from 'koishi' import type { Config } from '../config' import { bufferToArrayBuffer, @@ -13,116 +13,64 @@ import { applyPresetLane, ArchiveRecord, BindingRecord, - ConversationCompressionRecord, computeBaseBindingKey, + ConstraintPermission, ConstraintRecord, + ConversationCompressionRecord, ConversationRecord, MessageRecord, - ConstraintPermission, ResolveConversationContextOptions, ResolvedConstraint, ResolvedConversationContext, RouteMode } from './conversation_types' - -interface ListConversationsOptions extends ResolveConversationContextOptions { - includeArchived?: boolean -} - -interface ResolveTargetConversationOptions extends ResolveConversationContextOptions { - targetConversation?: string - includeArchived?: boolean - permission?: ConstraintPermission -} - -interface SerializedMessageRecord extends Omit< - MessageRecord, - 'content' | 'additional_kwargs_binary' | 'createdAt' -> { - content?: string | null - additional_kwargs_binary?: string | null - createdAt?: string | null -} - -interface ConversationArchivePayload { - formatVersion: number - exportedAt: string - conversation: Omit< - ConversationRecord, - 'createdAt' | 'updatedAt' | 'lastChatAt' | 'archivedAt' - > & { - createdAt: string - updatedAt: string - lastChatAt?: string | null - archivedAt?: string | null - } - messages: SerializedMessageRecord[] -} - -interface ArchiveManifest { - format: 'chatluna-archive' - formatVersion: number - conversationId: string - messageCount: number - checksum?: string | null - size: number - createdAt: string -} +import { + ArchiveManifest, + ConversationArchivePayload, + ListConversationsOptions, + ResolveTargetConversationOptions, + SerializedMessageRecord +} from './types' export class ConversationService { constructor( - private readonly ctx: import('koishi').Context, + private readonly ctx: Context, private readonly config: Config ) {} async getConversation(id: string) { return ( - await this.ctx.database.get('chatluna_conversation', { - id - }) + await this.ctx.database.get('chatluna_conversation', { id }) )[0] as ConversationRecord | undefined } async getBinding(bindingKey: string) { return ( - await this.ctx.database.get('chatluna_binding', { - bindingKey - }) + await this.ctx.database.get('chatluna_binding', { bindingKey }) )[0] as BindingRecord | undefined } async getArchive(id: string) { - return ( - await this.ctx.database.get('chatluna_archive', { - id - }) - )[0] as ArchiveRecord | undefined + return (await this.ctx.database.get('chatluna_archive', { id }))[0] as + | ArchiveRecord + | undefined } async getArchiveByConversationId(conversationId: string) { return ( - await this.ctx.database.get('chatluna_archive', { - conversationId - }) + await this.ctx.database.get('chatluna_archive', { conversationId }) )[0] as ArchiveRecord | undefined } async listConstraints() { - const constraints = (await this.ctx.database.get( - 'chatluna_constraint', - {} - )) as ConstraintRecord[] - - return constraints - .filter((constraint) => constraint.enabled !== false) - .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) - } - - async matchConstraints(session: Session) { - const constraints = await this.listConstraints() - return constraints.filter((constraint) => - this.isConstraintMatched(constraint, session) + return ( + (await this.ctx.database.get( + 'chatluna_constraint', + {} + )) as ConstraintRecord[] ) + .filter((c) => c.enabled !== false) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) } isConstraintMatched(constraint: ConstraintRecord, session: Session) { @@ -155,12 +103,12 @@ export class ConversationService { } const users = parseJsonArray(constraint.users) - if (users && !users.includes(session.userId)) { + if (users != null && !users.includes(session.userId)) { return false } const excludeUsers = parseJsonArray(constraint.excludeUsers) - if (excludeUsers && excludeUsers.includes(session.userId)) { + if (excludeUsers != null && excludeUsers.includes(session.userId)) { return false } @@ -171,11 +119,10 @@ export class ConversationService { session: Session, options: ResolveConversationContextOptions = {} ): Promise { - const constraints = await this.matchConstraints(session) - const routed = constraints.find( - (constraint) => constraint.routeMode != null + const constraints = (await this.listConstraints()).filter((c) => + this.isConstraintMatched(c, session) ) - + const routed = constraints.find((c) => c.routeMode != null) const routeMode = routed?.routeMode ?? this.getDefaultRouteMode(session) const baseKey = computeBaseBindingKey( session, @@ -225,6 +172,7 @@ export class ConversationService { constraint.bindingKey ) const binding = matched?.binding + const bindingKey = matched?.bindingKey ?? constraint.bindingKey const conversation = options.conversationId ? await this.getConversation(options.conversationId) : binding?.activeConversationId @@ -236,13 +184,13 @@ export class ConversationService { session, conversation, 'view', - matched?.bindingKey ?? constraint.bindingKey + bindingKey )) ? conversation : null return { - bindingKey: matched?.bindingKey ?? constraint.bindingKey, + bindingKey, presetLane: options.presetLane, binding: binding ?? null, conversation: allowedConversation, @@ -276,24 +224,24 @@ export class ConversationService { } } - const suffix = bindingKey.includes(':preset:') - ? bindingKey.slice(bindingKey.indexOf(':preset:')) - : '' + const idx = bindingKey.indexOf(':preset:') + const suffix = idx >= 0 ? bindingKey.slice(idx) : '' if (bindingKey.startsWith('custom:')) { return null } + const guildOrChannel = session.guildId ?? session.channelId ?? 'unknown' const keys = session.isDirect ? [`personal:legacy:legacy:direct:${session.userId}${suffix}`] : bindingKey.startsWith('shared:') ? [ - `shared:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}${suffix}`, - `personal:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}:${session.userId}${suffix}` + `shared:legacy:legacy:${guildOrChannel}${suffix}`, + `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}` ] : [ - `personal:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}:${session.userId}${suffix}`, - `shared:legacy:legacy:${session.guildId ?? session.channelId ?? 'unknown'}${suffix}` + `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}`, + `shared:legacy:legacy:${guildOrChannel}${suffix}` ] for (const key of keys) { @@ -328,12 +276,6 @@ export class ConversationService { if (resolved.conversation.status === 'archived') { await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error( - 'Conversation restore is locked by constraint.' - ) - } - if (!resolved.constraint.allowArchive) { throw new Error( 'Conversation restore is disabled by constraint.' @@ -366,9 +308,9 @@ export class ConversationService { const conversation = await this.createConversation(session, { bindingKey: resolved.bindingKey, - preset: resolved.effectivePreset ?? this.config.defaultPreset, - model: resolved.effectiveModel ?? this.config.defaultModel, - chatMode: resolved.effectiveChatMode ?? this.config.defaultChatMode, + preset: resolved.effectivePreset, + model: resolved.effectiveModel, + chatMode: resolved.effectiveChatMode, title: options.presetLane ?? 'New Conversation' }) @@ -408,7 +350,8 @@ export class ConversationService { archivedAt: null, archiveId: null, legacyRoomId: null, - legacyMeta: null + legacyMeta: null, + autoTitle: true } await this.ctx.root.parallel('chatluna/conversation-before-create', { @@ -426,13 +369,13 @@ export class ConversationService { async setActiveConversation(bindingKey: string, conversationId: string) { const current = await this.getBinding(bindingKey) + const prev = current?.activeConversationId const payload: BindingRecord = { bindingKey, activeConversationId: conversationId, lastConversationId: - current?.activeConversationId != null && - current.activeConversationId !== conversationId - ? current.activeConversationId + prev != null && prev !== conversationId + ? prev : (current?.lastConversationId ?? null), updatedAt: new Date() } @@ -453,7 +396,6 @@ export class ConversationService { const updated: ConversationRecord = { ...current, ...patch, - id: current.id, updatedAt: patch.updatedAt ?? new Date() } @@ -473,18 +415,17 @@ export class ConversationService { } )) as ConversationRecord[] - const includeArchived = options.includeArchived === true - return conversations .filter( (conversation) => conversation.status !== 'deleted' && conversation.status !== 'broken' && - (includeArchived || conversation.status !== 'archived') + (options.includeArchived || + conversation.status !== 'archived') ) .sort((a, b) => { - const left = a.lastChatAt ?? a.updatedAt ?? a.createdAt - const right = b.lastChatAt ?? b.updatedAt ?? b.createdAt + const left = a.lastChatAt ?? a.updatedAt + const right = b.lastChatAt ?? b.updatedAt return right.getTime() - left.getTime() }) } @@ -598,7 +539,7 @@ export class ConversationService { conversationId: string, records: Omit[] ) { - if (records.length < 1) { + if (records.length === 0) { return [] as ACLRecord[] } @@ -628,7 +569,7 @@ export class ConversationService { conversationId: string, records?: Partial>[] ) { - if (records == null || records.length < 1) { + if (records == null || records.length === 0) { await this.ctx.database.remove('chatluna_acl', { conversationId }) @@ -637,30 +578,15 @@ export class ConversationService { const current = await this.listAcl(conversationId) const removed = current.filter((item) => - records.some((record) => { - if ( - record.principalType != null && - record.principalType !== item.principalType - ) { - return false - } - - if ( - record.principalId != null && - record.principalId !== item.principalId - ) { - return false - } - - if ( - record.permission != null && - record.permission !== item.permission - ) { - return false - } - - return true - }) + records.some( + (record) => + (record.principalType == null || + record.principalType === item.principalType) && + (record.principalId == null || + record.principalId === item.principalId) && + (record.permission == null || + record.permission === item.permission) + ) ) for (const item of removed) { @@ -693,21 +619,19 @@ export class ConversationService { } const markdown = await this.exportMarkdown(conversation) - const exportDir = await this.ensureDataDir('export') const outputPath = options.outputPath ?? - path.join(exportDir, `${conversation.id}-${Date.now()}.md`) + path.join( + await this.ensureDataDir('export'), + `${conversation.id}-${Date.now()}.md` + ) await fs.writeFile(outputPath, markdown, 'utf8') - const size = Buffer.byteLength(markdown) - const checksum = createHash('sha256').update(markdown).digest('hex') - return { conversation, path: outputPath, - size, - checksum + size: Buffer.byteLength(markdown) } } @@ -762,14 +686,17 @@ export class ConversationService { } } - const archiveDir = path.resolve( - this.ctx.baseDir, - 'data/chatluna/archive', - conversation.id + const archiveDir = await this.ensureDataDir( + path.join('archive', conversation.id) ) - await fs.mkdir(archiveDir, { recursive: true }) - const payload = await this.buildArchivePayload(conversation) + const messages = await this.listMessages(conversation.id) + const payload: ConversationArchivePayload = { + formatVersion: 1, + exportedAt: new Date().toISOString(), + conversation: serializeConversation(conversation), + messages: messages.map(serializeMessage) + } const messageLines = payload.messages .map((message) => JSON.stringify(message)) .join('\n') @@ -788,6 +715,7 @@ export class ConversationService { messageBuffer ) + const now = new Date() const manifest: ArchiveManifest = { format: 'chatluna-archive', formatVersion: payload.formatVersion, @@ -795,7 +723,7 @@ export class ConversationService { messageCount: payload.messages.length, checksum, size: messageBuffer.byteLength, - createdAt: new Date().toISOString() + createdAt: now.toISOString() } await fs.writeFile( path.join(archiveDir, 'manifest.json'), @@ -805,21 +733,21 @@ export class ConversationService { const archive: ArchiveRecord = { id: randomUUID(), - conversationId: conversation.id, + conversationId: manifest.conversationId, path: archiveDir, - formatVersion: payload.formatVersion, - messageCount: payload.messages.length, - checksum, - size: messageBuffer.byteLength, + formatVersion: manifest.formatVersion, + messageCount: manifest.messageCount, + checksum: manifest.checksum, + size: manifest.size, state: 'ready', - createdAt: new Date(), + createdAt: now, restoredAt: null } await this.ctx.database.upsert('chatluna_archive', [archive]) await this.touchConversation(conversation.id, { status: 'archived', - archivedAt: new Date(), + archivedAt: now, archiveId: archive.id }) await this.unbindConversation(conversation.id) @@ -852,10 +780,10 @@ export class ConversationService { } = {} ) { const resolved = await this.resolveContext(session, options) - const targetConversation = options.conversationId - ? await this.getConversation(options.conversationId) + const conversation = options.conversationId + ? ((await this.getConversation(options.conversationId)) ?? + resolved.conversation) : resolved.conversation - const conversation = targetConversation ?? resolved.conversation if (conversation == null) { throw new Error('Conversation not found.') @@ -965,19 +893,6 @@ export class ConversationService { } } - private async buildArchivePayload( - conversation: ConversationRecord - ): Promise { - const messages = await this.listMessages(conversation.id) - - return { - formatVersion: 1, - exportedAt: new Date().toISOString(), - conversation: serializeConversation(conversation), - messages: messages.map(serializeMessage) - } - } - async exportMarkdown(conversation: ConversationRecord) { const messages = await this.listMessages(conversation.id) @@ -1026,10 +941,7 @@ export class ConversationService { const updated = await this.touchConversation(conversation.id, { title: options.title.trim() }) - if (updated == null) { - throw new Error('Conversation not found.') - } - return updated + return updated! } async deleteConversation( @@ -1088,31 +1000,22 @@ export class ConversationService { throw new Error('Conversation update is locked by constraint.') } - if (options.model != null && resolved.constraint.fixedModel != null) { - throw new Error( - `Model is fixed to ${resolved.constraint.fixedModel}.` - ) - } - - if (options.preset != null && resolved.constraint.fixedPreset != null) { - throw new Error( - `Preset is fixed to ${resolved.constraint.fixedPreset}.` - ) - } - - if ( - options.chatMode != null && - resolved.constraint.fixedChatMode != null - ) { - throw new Error( - `Chat mode is fixed to ${resolved.constraint.fixedChatMode}.` - ) + for (const [key, fixedKey] of [ + ['model', 'fixedModel'], + ['preset', 'fixedPreset'], + ['chatMode', 'fixedChatMode'] + ] as const) { + if (options[key] != null && resolved.constraint[fixedKey] != null) { + throw new Error( + `${key} is fixed to ${resolved.constraint[fixedKey]}.` + ) + } } const updated = await this.touchConversation(resolved.conversation.id, { - model: options.model ?? resolved.conversation.model, - preset: options.preset ?? resolved.conversation.preset, - chatMode: options.chatMode ?? resolved.conversation.chatMode + model: options.model, + preset: options.preset, + chatMode: options.chatMode }) if (updated == null) { @@ -1137,11 +1040,11 @@ export class ConversationService { remainingMessageCount: number } ) { + const conversation = await this.getConversation(conversationId) if (!result.compressed) { - return await this.getConversation(conversationId) + return conversation } - const conversation = await this.getConversation(conversationId) if (conversation == null) { return undefined } @@ -1200,14 +1103,11 @@ export class ConversationService { : `guild:${session.guildId ?? session.channelId ?? 'unknown'}` const record: ConstraintRecord = { id: current?.id, - name: - current?.name ?? - `managed:${session.platform}:${session.selfId}:${route}`, - enabled: current?.enabled ?? true, - priority: current?.priority ?? 1000, - createdBy: current?.createdBy ?? session.userId, - createdAt: current?.createdAt ?? now, - updatedAt: now, + name: `managed:${session.platform}:${session.selfId}:${route}`, + enabled: true, + priority: 1000, + createdBy: session.userId, + createdAt: now, platform: session.platform, selfId: session.selfId, guildId: session.isDirect @@ -1217,21 +1117,23 @@ export class ConversationService { direct: session.isDirect, users: session.isDirect ? JSON.stringify([session.userId]) : null, excludeUsers: null, - routeMode: current?.routeMode ?? null, - routeKey: current?.routeKey ?? null, - defaultModel: current?.defaultModel ?? null, - defaultPreset: current?.defaultPreset ?? null, - defaultChatMode: current?.defaultChatMode ?? null, - fixedModel: current?.fixedModel ?? null, - fixedPreset: current?.fixedPreset ?? null, - fixedChatMode: current?.fixedChatMode ?? null, - lockConversation: current?.lockConversation ?? null, - allowNew: current?.allowNew ?? null, - allowSwitch: current?.allowSwitch ?? null, - allowArchive: current?.allowArchive ?? null, - allowExport: current?.allowExport ?? null, - manageMode: current?.manageMode ?? 'admin', - ...patch + routeMode: null, + routeKey: null, + defaultModel: null, + defaultPreset: null, + defaultChatMode: null, + fixedModel: null, + fixedPreset: null, + fixedChatMode: null, + lockConversation: null, + allowNew: null, + allowSwitch: null, + allowArchive: null, + allowExport: null, + manageMode: 'admin', + ...current, + ...patch, + updatedAt: now } await this.ctx.database.upsert('chatluna_constraint', [record]) @@ -1247,27 +1149,21 @@ export class ConversationService { } private async allocateConversationSeq(bindingKey: string) { - const conversations = (await this.ctx.database.get( + const [latest] = (await this.ctx.database.get( 'chatluna_conversation', - { - bindingKey - } + { bindingKey }, + { sort: { seq: 'desc' }, limit: 1 } )) as ConversationRecord[] - - const maxSeq = conversations.reduce((current, conversation) => { - const seq = conversation.seq ?? 0 - return seq > current ? seq : current - }, 0) - - return maxSeq + 1 + return (latest?.seq ?? 0) + 1 } async resolveTargetConversation( session: Session, options: ResolveTargetConversationOptions = {} ) { + const resolved = await this.resolveContext(session, options) + if (options.conversationId != null) { - const resolved = await this.resolveContext(session, options) const conversation = await this.getConversation( options.conversationId ) @@ -1292,7 +1188,6 @@ export class ConversationService { return conversation } - const resolved = await this.resolveContext(session, options) const target = options.targetConversation?.trim() if (target == null || target.length === 0) { @@ -1304,18 +1199,14 @@ export class ConversationService { includeArchived: options.includeArchived }) - const byId = conversations.find( - (conversation) => conversation.id === target - ) + const byId = conversations.find((c) => c.id === target) if (byId != null) { return byId } if (/^\d+$/.test(target)) { const seq = Number(target) - const bySeq = conversations.find( - (conversation) => conversation.seq === seq - ) + const bySeq = conversations.find((c) => c.seq === seq) if (bySeq != null) { return bySeq } @@ -1323,15 +1214,14 @@ export class ConversationService { const normalized = target.toLocaleLowerCase() const exactTitle = conversations.find( - (conversation) => - conversation.title.toLocaleLowerCase() === normalized + (c) => c.title.toLocaleLowerCase() === normalized ) if (exactTitle != null) { return exactTitle } - const partialMatches = conversations.filter((conversation) => - conversation.title.toLocaleLowerCase().includes(normalized) + const partialMatches = conversations.filter((c) => + c.title.toLocaleLowerCase().includes(normalized) ) if (partialMatches.length === 1) { @@ -1350,23 +1240,20 @@ export class ConversationService { seq: /^\d+$/.test(target) ? Number(target) : undefined }) - const globalById = globalMatches.find( - (conversation) => conversation.id === target - ) + const globalById = globalMatches.find((c) => c.id === target) if (globalById != null) { return globalById } const globalExactTitle = globalMatches.find( - (conversation) => - conversation.title.toLocaleLowerCase() === normalized + (c) => c.title.toLocaleLowerCase() === normalized ) if (globalExactTitle != null) { return globalExactTitle } - const globalPartialMatches = globalMatches.filter((conversation) => - conversation.title.toLocaleLowerCase().includes(normalized) + const globalPartialMatches = globalMatches.filter((c) => + c.title.toLocaleLowerCase().includes(normalized) ) if (globalPartialMatches.length === 1) { @@ -1439,10 +1326,7 @@ export class ConversationService { session: Session, options: ResolveTargetConversationOptions = {} ) { - return this.resolveTargetConversation(session, { - ...options, - permission: options.permission ?? 'view' - }) + return this.resolveTargetConversation(session, options) } private async readArchivePayload(archivePath: string) { @@ -1477,6 +1361,7 @@ export class ConversationService { } } + // Legacy format: single gzip file containing the full payload JSON const compressed = await fs.readFile(archivePath) const content = await gzipDecode(compressed) return JSON.parse(content) as ConversationArchivePayload @@ -1489,6 +1374,7 @@ export class ConversationService { } private async unbindConversation(conversationId: string) { + // Full table scan needed as minari doesn't support OR conditions in queries const bindings = (await this.ctx.database.get( 'chatluna_binding', {} @@ -1583,8 +1469,12 @@ function serializeConversation( ...conversation, createdAt: conversation.createdAt.toISOString(), updatedAt: conversation.updatedAt.toISOString(), - lastChatAt: conversation.lastChatAt?.toISOString() ?? null, - archivedAt: conversation.archivedAt?.toISOString() ?? null + lastChatAt: conversation.lastChatAt + ? conversation.lastChatAt.toISOString() + : null, + archivedAt: conversation.archivedAt + ? conversation.archivedAt.toISOString() + : null } } @@ -1668,25 +1558,9 @@ function parseCompressionRecord(value?: string | null) { } async function isAdmin(session: Session) { - if ( - (session as Session & { user?: { authority?: number } }).user - ?.authority != null - ) { - return ( - ((session as Session & { user?: { authority?: number } }).user - ?.authority ?? 0) >= 3 - ) - } - - if ((session as Session & { authority?: number }).authority != null) { - return ( - ((session as Session & { authority?: number }).authority ?? 0) >= 3 - ) - } - - if (typeof session.getUser === 'function') { - const user = await session.getUser(session.userId, ['authority']) - return (user?.authority ?? 0) >= 3 + const s = session as Session + if (s.user?.authority != null) { + return s.user.authority >= 3 } return false diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts index 869e7f6bc..35b1a9fed 100644 --- a/packages/core/src/services/conversation_runtime.ts +++ b/packages/core/src/services/conversation_runtime.ts @@ -1,57 +1,20 @@ -import { - AIMessage, - BaseMessageChunk, - HumanMessage -} from '@langchain/core/messages' +import { AIMessage, HumanMessage } from '@langchain/core/messages' import type { Session } from 'koishi' import { LRUCache } from 'lru-cache' -import type { ChatInterface } from '../llm-core/chat/app' -import { - type AgentAction, - MessageQueue, - type ToolMask -} from '../llm-core/agent/types' -import { RequestIdQueue } from '../utils/queue' +import { MessageQueue, type ToolMask } from '../llm-core/agent/types' +import { RequestIdQueue } from 'koishi-plugin-chatluna/utils/queue' import { randomUUID } from 'crypto' import type { ChatLunaService } from './chat' -import { ChatLunaError, ChatLunaErrorCode } from '../utils/error' +import { + ChatLunaError, + ChatLunaErrorCode +} from 'koishi-plugin-chatluna/utils/error' import { parseRawModelName } from '../utils/model' import { ConversationRecord } from './conversation_types' import { Message } from '../types' import type { PostHandler } from '../utils/types' - -export interface ChatEvents { - 'llm-new-token'?: (token: string) => Promise - 'llm-queue-waiting'?: (size: number) => Promise - 'llm-used-token-count'?: (token: number) => Promise - 'llm-call-tool'?: ( - tool: string, - args: any, - content: AgentAction['content'], - log: string - ) => Promise - 'llm-new-chunk'?: (chunk: BaseMessageChunk) => Promise -} - -export interface RuntimeConversationEntry { - conversation: ConversationRecord - chatInterface: ChatInterface -} - -export interface ActiveRequest { - requestId: string - conversationId: string - sessionId?: string - abortController: AbortController - chatMode: string - messageQueue: MessageQueue - roundDecisionResolvers: ((canContinue: boolean) => void)[] - lastDecision?: boolean -} - -function createAbortError() { - return new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) -} +import { ActiveRequest, ChatEvents, RuntimeConversationEntry } from './types' +import { type UsageMetadata } from '@langchain/core/messages' export class ConversationRuntime { readonly interfaces = new LRUCache({ @@ -203,7 +166,6 @@ export class ConversationRuntime { const cached = this.interfaces.get(conversation.id) if (cached != null) { cached.conversation = conversation - this.interfaces.set(conversation.id, cached) } } @@ -337,7 +299,9 @@ export class ConversationRuntime { if (abortController == null) { return false } - abortController.abort(createAbortError()) + abortController.abort( + new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) + ) this.requestsById.delete(requestId) return true } @@ -401,7 +365,6 @@ export class ConversationRuntime { chatInterface ) await chatInterface.clearChatHistory() - chatInterface.dispose?.() this.interfaces.delete(conversation.id) await this.service.ctx.root.parallel( 'chatluna/conversation-after-clear-history', @@ -448,7 +411,9 @@ export class ConversationRuntime { dispose(platform?: string) { for (const controller of this.requestsById.values()) { - controller.abort(createAbortError()) + controller.abort( + new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) + ) } if (platform == null) { @@ -478,15 +443,59 @@ export class ConversationRuntime { } } -function formatUsageMetadataMessage(usage: { - input_tokens?: number - output_tokens?: number - total_tokens?: number -}) { +function formatUsageMetadataMessage(usage: UsageMetadata) { + const input = [ + ...(usage.input_token_details?.audio != null && + usage.input_token_details?.audio > 0 + ? [`audio=${usage.input_token_details.audio}`] + : []), + ...(usage.input_token_details?.image != null && + usage.input_token_details?.image > 0 + ? [`image=${usage.input_token_details.image}`] + : []), + ...(usage.input_token_details?.video != null && + usage.input_token_details?.video > 0 + ? [`video=${usage.input_token_details.video}`] + : []), + ...(usage.input_token_details?.document > 0 + ? [`document=${usage.input_token_details.document}`] + : []), + ...(usage.input_token_details?.cache_read != null + ? [`cache_read=${usage.input_token_details.cache_read}`] + : []), + ...(usage.input_token_details?.cache_creation != null + ? [`cache_creation=${usage.input_token_details.cache_creation}`] + : []) + ] + const output = [ + ...(usage.input_token_details?.audio != null && + usage.input_token_details?.audio > 0 + ? [`audio=${usage.input_token_details.audio}`] + : []), + ...(usage.input_token_details?.image != null && + usage.input_token_details?.image > 0 + ? [`image=${usage.input_token_details.image}`] + : []), + ...(usage.input_token_details?.video != null && + usage.input_token_details?.video > 0 + ? [`video=${usage.input_token_details.video}`] + : []), + ...(usage.input_token_details?.document > 0 + ? [`document=${usage.input_token_details.document}`] + : []), + ...(usage.output_token_details?.reasoning != null + ? [`reasoning=${usage.output_token_details.reasoning}`] + : []) + ] + return [ 'Token usage:', - `- input: ${usage.input_tokens ?? 0}`, - `- output: ${usage.output_tokens ?? 0}`, - `- total: ${usage.total_tokens ?? 0}` + `- input: ${usage.input_tokens}`, + `- output: ${usage.output_tokens}`, + `- total: ${usage.total_tokens}`, + ...(input.length > 0 ? [`- input details: ${input.join(', ')}`] : []), + ...(output.length > 0 ? [`- output details: ${output.join(', ')}`] : []) ].join('\n') } + +export { ChatEvents, RuntimeConversationEntry, ActiveRequest } from './types' diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts index 74c17ba22..d5629df27 100644 --- a/packages/core/src/services/conversation_types.ts +++ b/packages/core/src/services/conversation_types.ts @@ -41,6 +41,7 @@ export interface ConversationRecord { archiveId?: string | null legacyRoomId?: number | null legacyMeta?: string | null + autoTitle?: boolean | null } export interface MessageRecord { diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index c6905ee69..02973f58d 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -3,10 +3,12 @@ import { ACLRecord, ArchiveRecord, BindingRecord, + ConstraintPermission, ConstraintRecord, ConversationRecord, MessageRecord, - MetaRecord + MetaRecord, + ResolveConversationContextOptions } from './conversation_types' import { ChatLunaService } from './chat' import { @@ -20,6 +22,8 @@ import { SubagentContext, ToolMask } from 'koishi-plugin-chatluna/llm-core/agent' +import type { ChatInterface } from '../llm-core/chat/app' +import { MessageQueue } from '../llm-core/agent/types' export interface LegacyConversationRecord { id: string @@ -91,6 +95,66 @@ export interface ChatEvents { 'llm-new-chunk'?: (chunk: BaseMessageChunk) => Promise } +export interface RuntimeConversationEntry { + conversation: ConversationRecord + chatInterface: ChatInterface +} + +export interface ActiveRequest { + requestId: string + conversationId: string + sessionId?: string + abortController: AbortController + chatMode: string + messageQueue: MessageQueue + roundDecisionResolvers: ((canContinue: boolean) => void)[] + lastDecision?: boolean +} + +export interface ListConversationsOptions extends ResolveConversationContextOptions { + includeArchived?: boolean +} + +export interface ResolveTargetConversationOptions extends ResolveConversationContextOptions { + targetConversation?: string + includeArchived?: boolean + permission?: ConstraintPermission +} + +export interface SerializedMessageRecord extends Omit< + MessageRecord, + 'content' | 'additional_kwargs_binary' | 'createdAt' +> { + content?: string | null + additional_kwargs_binary?: string | null + createdAt?: string | null +} + +export interface ConversationArchivePayload { + formatVersion: number + exportedAt: string + conversation: Omit< + ConversationRecord, + 'createdAt' | 'updatedAt' | 'lastChatAt' | 'archivedAt' + > & { + createdAt: string + updatedAt: string + lastChatAt?: string | null + archivedAt?: string | null + } + messages: SerializedMessageRecord[] +} + +export interface ArchiveManifest { + format: 'chatluna-archive' + formatVersion: number + conversationId: string + messageCount: number + checksum?: string | null + size: number + createdAt: string +} + declare module 'koishi' { export interface Context { chatluna: ChatLunaService diff --git a/packages/core/src/utils/lock.ts b/packages/core/src/utils/lock.ts index 08a6bce31..9f94f5cb0 100644 --- a/packages/core/src/utils/lock.ts +++ b/packages/core/src/utils/lock.ts @@ -1,5 +1,5 @@ const TIME_MINUTE = 60 * 1000 -import { withResolver } from './promise' +import { withResolver } from 'koishi-plugin-chatluna/utils/promise' export class ObjectLock { private _lock: boolean = false diff --git a/packages/core/src/utils/queue.ts b/packages/core/src/utils/queue.ts index 131b6dc4a..fe1e97377 100644 --- a/packages/core/src/utils/queue.ts +++ b/packages/core/src/utils/queue.ts @@ -1,6 +1,9 @@ -import { ChatLunaError, ChatLunaErrorCode } from './error' -import { ObjectLock } from './lock' -import { withResolver } from './promise' +import { + ChatLunaError, + ChatLunaErrorCode +} from 'koishi-plugin-chatluna/utils/error' +import { ObjectLock } from 'koishi-plugin-chatluna/utils/lock' +import { withResolver } from 'koishi-plugin-chatluna/utils/promise' const TIME_MINUTE = 60 * 1000 interface QueueItem { diff --git a/packages/shared-adapter/src/utils.ts b/packages/shared-adapter/src/utils.ts index d7addc477..975a48b29 100644 --- a/packages/shared-adapter/src/utils.ts +++ b/packages/shared-adapter/src/utils.ts @@ -36,7 +36,9 @@ export function createUsageMetadata(data: { outputTokens: number totalTokens: number inputAudioTokens?: number + inputImageTokens?: number outputAudioTokens?: number + outputImageTokens?: number cacheReadTokens?: number cacheCreationTokens?: number reasoningTokens?: number @@ -45,6 +47,9 @@ export function createUsageMetadata(data: { ...(data.inputAudioTokens != null ? { audio: data.inputAudioTokens } : {}), + ...(data.inputImageTokens != null + ? { image: data.inputImageTokens } + : {}), ...(data.cacheReadTokens != null ? { cache_read: data.cacheReadTokens } : {}), @@ -56,6 +61,9 @@ export function createUsageMetadata(data: { ...(data.outputAudioTokens != null ? { audio: data.outputAudioTokens } : {}), + ...(data.outputImageTokens != null + ? { image: data.outputImageTokens } + : {}), ...(data.reasoningTokens != null ? { reasoning: data.reasoningTokens } : {}) From cf4dd0573397ff77e5c7a145658974e8251bd637 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 02:10:51 +0800 Subject: [PATCH 03/20] feat(core): harden conversation lifecycle and routing Keep conversation state, target resolution, and provider-specific context in sync across the new conversation flow while cleaning up the refactor fallout. --- packages/adapter-dify/src/requester.ts | 51 ++- packages/core/src/chains/chain.ts | 12 +- packages/core/src/command.ts | 12 +- packages/core/src/commands/chat.ts | 52 +-- packages/core/src/commands/conversation.ts | 411 ++++++++--------- packages/core/src/commands/utils.ts | 43 ++ packages/core/src/index.ts | 113 ++--- packages/core/src/llm-core/agent/agent.ts | 9 +- packages/core/src/llm-core/agent/executor.ts | 1 + packages/core/src/llm-core/agent/sub-agent.ts | 14 +- packages/core/src/llm-core/chat/app.ts | 34 +- packages/core/src/llm-core/chat/default.ts | 16 +- .../memory/message/database_history.ts | 7 +- .../core/src/llm-core/platform/service.ts | 4 +- packages/core/src/llm-core/platform/types.ts | 7 +- .../src/llm-core/prompt/context_manager.ts | 12 +- .../core/src/llm-core/utils/count_tokens.ts | 28 +- .../middlewares/chat/chat_time_limit_check.ts | 2 +- .../middlewares/chat/chat_time_limit_save.ts | 12 +- .../src/middlewares/chat/read_chat_message.ts | 37 +- .../src/middlewares/chat/rollback_chat.ts | 25 +- .../conversation/request_conversation.ts | 99 ++--- .../conversation/resolve_conversation.ts | 9 +- .../src/middlewares/model/resolve_model.ts | 26 +- .../model/set_default_embeddings.ts | 2 + .../src/middlewares/preset/delete_preset.ts | 42 +- .../middlewares/system/conversation_manage.ts | 46 +- .../core/src/middlewares/system/lifecycle.ts | 5 +- packages/core/src/middlewares/system/wipe.ts | 59 ++- packages/core/src/migration/legacy_tables.ts | 7 + .../src/migration/room_to_conversation.ts | 55 ++- packages/core/src/migration/validators.ts | 227 ++++++---- packages/core/src/services/chat.ts | 3 +- packages/core/src/services/conversation.ts | 412 +++++++++++++----- .../core/src/services/conversation_runtime.ts | 75 ++-- .../core/src/services/conversation_types.ts | 34 +- packages/core/src/utils/archive.ts | 24 +- packages/core/src/utils/chat_request.ts | 6 +- packages/core/src/utils/compression.ts | 18 +- packages/core/src/utils/error.ts | 10 +- packages/core/src/utils/koishi.ts | 37 +- packages/core/src/utils/lock.ts | 3 +- packages/core/src/utils/model.ts | 17 +- .../core/test/conversation-runtime.test.ts | 22 +- packages/extension-agent/client/index.ts | 4 +- packages/extension-agent/src/cli/dispatch.ts | 4 +- packages/extension-agent/src/cli/render.ts | 20 +- .../src/computer/backends/local/store.ts | 4 +- .../src/computer/tools/bash.ts | 6 +- .../extension-agent/src/config/defaults.ts | 19 +- .../extension-agent/src/service/computer.ts | 3 +- packages/extension-agent/src/service/index.ts | 3 +- .../src/service/permissions.ts | 2 +- .../extension-agent/src/service/skills.ts | 6 - .../extension-agent/src/service/sub_agent.ts | 1 - packages/extension-agent/src/skills/scan.ts | 24 +- .../extension-agent/src/sub-agent/builtin.ts | 101 +++-- .../extension-agent/src/sub-agent/render.ts | 2 - packages/extension-agent/src/sub-agent/run.ts | 2 +- .../extension-agent/src/sub-agent/session.ts | 6 +- .../extension-agent/src/sub-agent/tool.ts | 8 +- .../src/plugins/edit_memory.ts | 4 +- 62 files changed, 1468 insertions(+), 891 deletions(-) create mode 100644 packages/core/src/commands/utils.ts diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index e1d0f453e..ae1d89264 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -79,6 +79,7 @@ export class DifyRequester extends ModelRequester { conversationId, config ) + const difyUser = this.resolveDifyUser(params) let iter: ReturnType @@ -88,7 +89,8 @@ export class DifyRequester extends ModelRequester { difyConversationId, params.input[params.input.length - 1].content as string, conversationId, - config + config, + difyUser ) } else { iter = this._workflowStream(params, conversationId, config) @@ -126,11 +128,11 @@ export class DifyRequester extends ModelRequester { difyConversationId: string, input: string, conversationId: string, - config: { apiKey: string; workflowName: string; workflowType: string } + config: { apiKey: string; workflowName: string; workflowType: string }, + difyUser: string ): AsyncGenerator { const lastMessage = params.input?.[params.input.length - 1] const query = getMessageContent(lastMessage?.content ?? input ?? '') - const difyUser = this.resolveDifyUser(params) const { files, chatlunaMultimodal } = await this.prepareFiles( params, lastMessage, @@ -225,7 +227,8 @@ export class DifyRequester extends ModelRequester { await this.updateDifyConversationId( conversationId, config.workflowName, - updatedDifyConversationId + updatedDifyConversationId, + difyUser ) break } @@ -655,21 +658,35 @@ export class DifyRequester extends ModelRequester { conversationId: string, config: { apiKey: string; workflowName: string; workflowType: string } ) { - return this.ctx.chatluna.cache.get( + const cached = await this.ctx.chatluna.cache.get( 'chatluna/keys', 'dify/' + conversationId + '/' + config.workflowName ) + + if (cached == null) { + return undefined + } + + try { + return (JSON.parse(cached) as { id?: string }).id + } catch { + return cached + } } private async updateDifyConversationId( conversationId: string, workflowName: string, - difyConversationId: string + difyConversationId: string, + user: string ) { return this.ctx.chatluna.cache.set( 'chatluna/keys', 'dify/' + conversationId + '/' + workflowName, - difyConversationId + JSON.stringify({ + id: difyConversationId, + user + }) ) } @@ -724,17 +741,30 @@ export class DifyRequester extends ModelRequester { } const conversationId = id const config = this._config.value.additionalModel.get(model) + const cacheKey = 'dify/' + conversationId + '/' + config.workflowName + const cached = await this.ctx.chatluna.cache.get( + 'chatluna/keys', + cacheKey + ) const difyConversationId = await this.getDifyConversationId( conversationId, config ) + let difyUser = 'chatluna' + + if (cached != null) { + try { + difyUser = + (JSON.parse(cached) as { user?: string }).user ?? 'chatluna' + } catch {} + } if (difyConversationId) { await this._plugin .fetch(this.concatUrl('/conversations/' + difyConversationId), { headers: this._buildHeaders(config.apiKey), method: 'DELETE', - body: JSON.stringify({ user: 'chatluna' }) + body: JSON.stringify({ user: difyUser }) }) .then(async (res) => { if (res.ok) { @@ -746,10 +776,7 @@ export class DifyRequester extends ModelRequester { } }) - await this.ctx.chatluna.cache.delete( - 'chatluna/keys', - 'dify/' + conversationId + '/' + config.workflowName - ) + await this.ctx.chatluna.cache.delete('chatluna/keys', cacheKey) } } diff --git a/packages/core/src/chains/chain.ts b/packages/core/src/chains/chain.ts index 85d23fda1..c218ea06a 100644 --- a/packages/core/src/chains/chain.ts +++ b/packages/core/src/chains/chain.ts @@ -830,9 +830,15 @@ class ChatChainDependencyGraph { while (currentLevel.length > 0) { levels.push( - currentLevel.map( - (name) => this._tasks.get(name)!.middleware! - ) + currentLevel.map((name) => { + const task = this._tasks.get(name) + if (task?.middleware == null) { + throw new Error( + `Missing middleware for task ${name}` + ) + } + return task.middleware + }) ) const nextLevel: string[] = [] diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 56f0d09d8..c0b8debe7 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -21,17 +21,7 @@ export async function command(ctx: Context, config: Config) { const middlewares: Command[] = // middleware start - [ - auth, - chat, - conversation, - mcp, - memory, - model, - preset, - providers, - tool - ] // middleware end + [auth, chat, conversation, mcp, memory, model, preset, providers, tool] // middleware end for (const middleware of middlewares) { await middleware(ctx, config, ctx.chatluna.chatChain) diff --git a/packages/core/src/commands/chat.ts b/packages/core/src/commands/chat.ts index 93fc0505d..c6f4aa176 100644 --- a/packages/core/src/commands/chat.ts +++ b/packages/core/src/commands/chat.ts @@ -1,7 +1,8 @@ -import { Context, h, Session } from 'koishi' +import { Context, h } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' import { RenderType } from '../types' +import { completeConversationTarget } from './utils' export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx.command('chatluna', { @@ -14,7 +15,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { .option('type', '-t ') .action(async ({ options, session }, message) => { const renderType = options.type ?? config.outputMode - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() if ( !ctx.chatluna.renderer.rendererTypeList.some( @@ -142,50 +146,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) } -function normalizeTarget(value?: string | null) { - return value == null || value.trim().length < 1 ? undefined : value.trim() -} - -async function completeConversationTarget( - ctx: Context, - session: Session, - target?: string, - presetLane?: string, - includeArchived = true -) { - const value = normalizeTarget(target) - if (value == null) { - return undefined - } - - const conversations = await ctx.chatluna.conversation.listConversations( - session, - { - presetLane, - includeArchived - } - ) - const expect = Array.from( - new Set( - conversations.flatMap((conversation) => [ - conversation.id, - String(conversation.seq ?? ''), - conversation.title - ]) - ) - ).filter((item) => item.length > 0) - - if (expect.length === 0) { - return value - } - - return session.suggest({ - actual: value, - expect, - suffix: session.text('commands.chatluna.chat.text.options.conversation') - }) -} - declare module '../chains/chain' { interface ChainMiddlewareContextOptions { message?: h[] diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index 93a72b4c3..040475b54 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -1,74 +1,9 @@ -import { Command, Context, Session } from 'koishi' +import { Context } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' - -function normalizeTarget(value?: string | null) { - return value == null || value.trim().length < 1 ? undefined : value.trim() -} - -function setChoices(cmd: Command, index: number, values: string[]) { - if (cmd._arguments[index] != null) { - cmd._arguments[index].type = values - } -} - -function setOptionChoices(cmd: Command, name: string, values: string[]) { - if (cmd._options[name] != null) { - cmd._options[name].type = values - } -} - -async function completeConversationTarget( - ctx: Context, - session: Session, - target?: string, - presetLane?: string, - includeArchived = true -) { - const value = normalizeTarget(target) - if (value == null) { - return undefined - } - - const conversations = await ctx.chatluna.conversation.listConversations( - session, - { - presetLane, - includeArchived - } - ) - const expect = Array.from( - new Set( - conversations.flatMap((conversation) => [ - conversation.id, - String(conversation.seq ?? ''), - conversation.title - ]) - ) - ).filter((item) => item.length > 0) - - if (expect.length === 0) { - return value - } - - return session.suggest({ - actual: value, - expect, - suffix: session.text( - 'commands.chatluna.conversation.options.conversation' - ) - }) -} - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const modes = ['chat', 'plugin', 'browsing'] - const shares = ['personal', 'shared', 'reset'] - const locks = ['on', 'off', 'toggle', 'reset'] - const models = ctx.chatluna.platform - .listAllModels(ModelType.llm) - .value.map((item) => item.name) +import { completeConversationTarget } from './utils' +export function apply(ctx: Context, _config: Config, chain: ChatChain) { ctx.command('chatluna.conversation', { authority: 1 }).alias('chatluna.session') @@ -95,17 +30,30 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'conversation_new', { conversation_create: { - title: normalizeTarget(title), - preset: normalizeTarget(options.preset), - model: normalizeTarget(options.model), - chatMode: normalizeTarget(options.chatMode) + title: + title == null || title.trim().length < 1 + ? undefined + : title.trim(), + preset: + options.preset == null || + options.preset.trim().length < 1 + ? undefined + : options.preset.trim(), + model: + options.model == null || + options.model.trim().length < 1 + ? undefined + : options.model.trim(), + chatMode: + options.chatMode == null || + options.chatMode.trim().length < 1 + ? undefined + : options.chatMode.trim() } }, ctx ) }) - setOptionChoices(newCommand, 'model', models) - setOptionChoices(newCommand, 'chatMode', modes) const switchCommand = ctx.command('chatluna.switch ', { authority: 1 @@ -113,7 +61,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { switchCommand .option('preset', '-p ') .action(async ({ options, session }, conversation) => { - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() await chain.receiveCommand( session, 'conversation_switch', @@ -124,7 +75,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, conversation, presetLane, - false + false, + 'commands.chatluna.conversation.options.conversation' ), presetLane } @@ -151,7 +103,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { conversation_manage: { includeArchived: options.archived === true || options.all === true, - presetLane: normalizeTarget(options.preset) + presetLane: + options.preset == null || + options.preset.trim().length < 1 + ? undefined + : options.preset.trim() } }, ctx @@ -169,7 +125,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'conversation_current', { conversation_manage: { - presetLane: normalizeTarget(options.preset) + presetLane: + options.preset == null || + options.preset.trim().length < 1 + ? undefined + : options.preset.trim() } }, ctx @@ -185,7 +145,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { archiveCommand .option('preset', '-p ') .action(async ({ options, session }, conversation) => { - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() await chain.receiveCommand( session, 'conversation_archive', @@ -195,7 +158,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx, session, conversation, - presetLane + presetLane, + true, + 'commands.chatluna.conversation.options.conversation' ), presetLane } @@ -213,7 +178,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { restoreCommand .option('preset', '-p ') .action(async ({ options, session }, conversation) => { - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() await chain.receiveCommand( session, 'conversation_restore', @@ -223,7 +191,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx, session, conversation, - presetLane + presetLane, + true, + 'commands.chatluna.conversation.options.conversation' ), presetLane } @@ -238,7 +208,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { exportCommand .option('preset', '-p ') .action(async ({ options, session }, conversation) => { - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() await chain.receiveCommand( session, 'conversation_export', @@ -248,7 +221,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx, session, conversation, - presetLane + presetLane, + true, + 'commands.chatluna.conversation.options.conversation' ), presetLane } @@ -266,7 +241,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { compressCommand .option('preset', '-p ') .action(async ({ options, session }, conversation) => { - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() await chain.receiveCommand( session, 'conversation_compress', @@ -277,7 +255,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx, session, conversation, - presetLane + presetLane, + true, + 'commands.chatluna.conversation.options.conversation' ), presetLane }, @@ -287,10 +267,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) - const renameCommand = ctx - .command('chatluna.rename ', { - authority: 1 - }) + ctx.command('chatluna.rename ', { + authority: 1 + }) .option('preset', '-p ') .action(async ({ options, session }, title) => { await chain.receiveCommand( @@ -298,21 +277,30 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'conversation_rename', { conversation_manage: { - title: normalizeTarget(title), - presetLane: normalizeTarget(options.preset) + title: + title == null || title.trim().length < 1 + ? undefined + : title.trim(), + presetLane: + options.preset == null || + options.preset.trim().length < 1 + ? undefined + : options.preset.trim() } }, ctx ) }) - const deleteCommand = ctx - .command('chatluna.delete [conversation:string]', { - authority: 1 - }) + ctx.command('chatluna.delete [conversation:string]', { + authority: 1 + }) .option('preset', '-p ') .action(async ({ options, session }, conversation) => { - const presetLane = normalizeTarget(options.preset) + const presetLane = + options.preset == null || options.preset.trim().length < 1 + ? undefined + : options.preset.trim() await chain.receiveCommand( session, 'conversation_delete', @@ -322,7 +310,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx, session, conversation, - presetLane + presetLane, + true, + 'commands.chatluna.conversation.options.conversation' ), presetLane } @@ -331,10 +321,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) }) - const useModelCommand = ctx - .command('chatluna.use.model ', { - authority: 1 - }) + ctx.command('chatluna.use.model ', { + authority: 1 + }) .option('preset', '-p ') .action(async ({ options, session }, model) => { await chain.receiveCommand( @@ -342,21 +331,26 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'conversation_use_model', { conversation_manage: { - presetLane: normalizeTarget(options.preset) + presetLane: + options.preset == null || + options.preset.trim().length < 1 + ? undefined + : options.preset.trim() }, conversation_use: { - model: normalizeTarget(model) + model: + model == null || model.trim().length < 1 + ? undefined + : model.trim() } }, ctx ) }) - setChoices(useModelCommand, 0, models) - const usePresetCommand = ctx - .command('chatluna.use.preset ', { - authority: 1 - }) + ctx.command('chatluna.use.preset ', { + authority: 1 + }) .option('lane', '-p ') .action(async ({ options, session }, preset) => { await chain.receiveCommand( @@ -364,20 +358,26 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'conversation_use_preset', { conversation_manage: { - presetLane: normalizeTarget(options.lane) + presetLane: + options.lane == null || + options.lane.trim().length < 1 + ? undefined + : options.lane.trim() }, conversation_use: { - preset: normalizeTarget(preset) + preset: + preset == null || preset.trim().length < 1 + ? undefined + : preset.trim() } }, ctx ) }) - const useModeCommand = ctx - .command('chatluna.use.mode ', { - authority: 1 - }) + ctx.command('chatluna.use.mode ', { + authority: 1 + }) .option('preset', '-p ') .action(async ({ options, session }, mode) => { await chain.receiveCommand( @@ -385,104 +385,111 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 'conversation_use_mode', { conversation_manage: { - presetLane: normalizeTarget(options.preset) + presetLane: + options.preset == null || + options.preset.trim().length < 1 + ? undefined + : options.preset.trim() }, conversation_use: { - chatMode: normalizeTarget(mode) + chatMode: + mode == null || mode.trim().length < 1 + ? undefined + : mode.trim() } }, ctx ) }) - setChoices(useModeCommand, 0, modes) - const ruleModelCommand = ctx - .command('chatluna.rule.model ', { - authority: 3 - }) - .action(async ({ session }, model) => { - await chain.receiveCommand( - session, - 'conversation_rule_model', - { - conversation_rule: { - model: normalizeTarget(model) - } - }, - ctx - ) - }) - setChoices(ruleModelCommand, 0, [...models, 'reset']) + ctx.command('chatluna.rule.model ', { + authority: 3 + }).action(async ({ session }, model) => { + await chain.receiveCommand( + session, + 'conversation_rule_model', + { + conversation_rule: { + model: + model == null || model.trim().length < 1 + ? undefined + : model.trim() + } + }, + ctx + ) + }) - const rulePresetCommand = ctx - .command('chatluna.rule.preset ', { - authority: 3 - }) - .action(async ({ session }, preset) => { - await chain.receiveCommand( - session, - 'conversation_rule_preset', - { - conversation_rule: { - preset: normalizeTarget(preset) - } - }, - ctx - ) - }) - const ruleModeCommand = ctx - .command('chatluna.rule.mode ', { - authority: 3 - }) - .action(async ({ session }, mode) => { - await chain.receiveCommand( - session, - 'conversation_rule_mode', - { - conversation_rule: { - chatMode: normalizeTarget(mode) - } - }, - ctx - ) - }) - setChoices(ruleModeCommand, 0, [...modes, 'reset']) + ctx.command('chatluna.rule.preset ', { + authority: 3 + }).action(async ({ session }, preset) => { + await chain.receiveCommand( + session, + 'conversation_rule_preset', + { + conversation_rule: { + preset: + preset == null || preset.trim().length < 1 + ? undefined + : preset.trim() + } + }, + ctx + ) + }) + ctx.command('chatluna.rule.mode ', { + authority: 3 + }).action(async ({ session }, mode) => { + await chain.receiveCommand( + session, + 'conversation_rule_mode', + { + conversation_rule: { + chatMode: + mode == null || mode.trim().length < 1 + ? undefined + : mode.trim() + } + }, + ctx + ) + }) - const ruleShareCommand = ctx - .command('chatluna.rule.share ', { - authority: 3 - }) - .action(async ({ session }, mode) => { - await chain.receiveCommand( - session, - 'conversation_rule_share', - { - conversation_rule: { - share: normalizeTarget(mode) - } - }, - ctx - ) - }) - setChoices(ruleShareCommand, 0, shares) + ctx.command('chatluna.rule.share ', { + authority: 3 + }).action(async ({ session }, mode) => { + await chain.receiveCommand( + session, + 'conversation_rule_share', + { + conversation_rule: { + share: + mode == null || mode.trim().length < 1 + ? undefined + : mode.trim() + } + }, + ctx + ) + }) - const ruleLockCommand = ctx - .command('chatluna.rule.lock [state:string]', { - authority: 3 - }) - .action(async ({ session }, state) => { - await chain.receiveCommand( - session, - 'conversation_rule_lock', - { - conversation_rule: { - lock: normalizeTarget(state) ?? 'toggle' - } - }, - ctx - ) - }) - setChoices(ruleLockCommand, 0, locks) + ctx.command('chatluna.rule.lock [state:string]', { + authority: 3 + }).action(async ({ session }, state) => { + await chain.receiveCommand( + session, + 'conversation_rule_lock', + { + conversation_rule: { + lock: + state == null || state.trim().length < 1 + ? 'toggle' + : state.trim() + } + }, + ctx + ) + }) ctx.command('chatluna.rule.show', { authority: 3 diff --git a/packages/core/src/commands/utils.ts b/packages/core/src/commands/utils.ts new file mode 100644 index 000000000..43940954c --- /dev/null +++ b/packages/core/src/commands/utils.ts @@ -0,0 +1,43 @@ +import { Context, Session } from 'koishi' + +export async function completeConversationTarget( + ctx: Context, + session: Session, + target: string | undefined, + presetLane?: string, + includeArchived = true, + suffix = 'commands.chatluna.chat.text.options.conversation' +) { + const value = + target == null || target.trim().length < 1 ? undefined : target.trim() + if (value == null) { + return undefined + } + + const conversations = await ctx.chatluna.conversation.listConversations( + session, + { + presetLane, + includeArchived + } + ) + const expect = Array.from( + new Set( + conversations.flatMap((conversation) => [ + conversation.id, + String(conversation.seq ?? ''), + conversation.title + ]) + ) + ).filter((item) => item.length > 0) + + if (expect.length === 0) { + return value + } + + return session.suggest({ + actual: value, + expect, + suffix: session.text(suffix) + }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9a39c7f7a..1d5bf3e82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { Context, Logger, Time, User } from 'koishi' -import fs from 'fs/promises' import { ChatLunaService } from 'koishi-plugin-chatluna/services/chat' import { forkScopeToDisposable } from 'koishi-plugin-chatluna/utils/koishi' import { @@ -199,37 +198,43 @@ async function setupAutoArchive(ctx: Context, config: Config) { if (!ctx.scope.isActive) { return } - const conversations = await ctx.database.get('chatluna_conversation', { - updatedAt: { - $lt: new Date(Date.now() - config.autoArchiveTimeout * 1000) - }, - status: 'active' - }) - if (conversations.length === 0) { - return - } + try { + const conversations = await ctx.database.get( + 'chatluna_conversation', + { + updatedAt: { + $lt: new Date( + Date.now() - config.autoArchiveTimeout * 1000 + ) + }, + status: 'active' + } + ) - logger.info('Auto archive task running') + if (conversations.length === 0) { + return + } + + logger.info('Auto archive task running') - const success: string[] = [] + let success = 0 - for (const conversation of conversations) { - try { - await ctx.chatluna.conversation.archiveConversationById( - conversation.id - ) - success.push(conversation.title) - } catch (e) { - logger.error(e) + for (const conversation of conversations) { + try { + await ctx.chatluna.conversation.archiveConversationById( + conversation.id + ) + success += 1 + } catch (e) { + logger.error(e) + } } - } - logger.success( - `Successfully archived %d conversations: %s`, - success.length, - success.join(',') - ) + logger.success(`Successfully archived %d conversations`, success) + } catch (e) { + logger.error(e) + } } await execute() @@ -249,37 +254,43 @@ async function setupAutoPurgeArchive(ctx: Context, config: Config) { return } - const conversations = await ctx.database.get('chatluna_conversation', { - archivedAt: { - $lt: new Date( - Date.now() - config.autoPurgeArchiveTimeout * 1000 - ) - }, - status: 'archived' - }) + try { + const conversations = await ctx.database.get( + 'chatluna_conversation', + { + archivedAt: { + $lt: new Date( + Date.now() - config.autoPurgeArchiveTimeout * 1000 + ) + }, + status: 'archived' + } + ) - if (conversations.length === 0) { - return - } + if (conversations.length === 0) { + return + } - logger.info('Auto purge archive task running') + logger.info('Auto purge archive task running') - const success: string[] = [] + let success = 0 - for (const conversation of conversations) { - try { - await purgeArchivedConversation(ctx, conversation) - success.push(conversation.title) - } catch (e) { - logger.error(e) + for (const conversation of conversations) { + try { + await purgeArchivedConversation(ctx, conversation) + success += 1 + } catch (e) { + logger.error(e) + } } - } - logger.success( - `Successfully purged %d archived conversations: %s`, - success.length, - success.join(',') - ) + logger.success( + `Successfully purged %d archived conversations`, + success + ) + } catch (e) { + logger.error(e) + } } await execute() diff --git a/packages/core/src/llm-core/agent/agent.ts b/packages/core/src/llm-core/agent/agent.ts index 4fec1b919..2e423f4d6 100644 --- a/packages/core/src/llm-core/agent/agent.ts +++ b/packages/core/src/llm-core/agent/agent.ts @@ -1,6 +1,5 @@ import { CallbackManager } from '@langchain/core/callbacks/manager' import { - AIMessage, BaseMessage, BaseMessageChunk, HumanMessage, @@ -357,13 +356,13 @@ function createAsyncQueue() { } function createWaiter() { - let resolve: () => void - const promise = new Promise((next) => { - resolve = next + let finish!: () => void + const promise = new Promise((resolve) => { + finish = resolve }) return { promise, - resolve + resolve: finish } } diff --git a/packages/core/src/llm-core/agent/executor.ts b/packages/core/src/llm-core/agent/executor.ts index 17c0aad71..c0d38bcc4 100644 --- a/packages/core/src/llm-core/agent/executor.ts +++ b/packages/core/src/llm-core/agent/executor.ts @@ -22,6 +22,7 @@ export class AgentRunner extends Runnable { // eslint-disable-next-line @typescript-eslint/naming-convention lc_serializable = false + // eslint-disable-next-line @typescript-eslint/naming-convention lc_namespace = ['chatluna', 'agent'] agent: Runnable diff --git a/packages/core/src/llm-core/agent/sub-agent.ts b/packages/core/src/llm-core/agent/sub-agent.ts index c8ecb7ab0..c72374b95 100644 --- a/packages/core/src/llm-core/agent/sub-agent.ts +++ b/packages/core/src/llm-core/agent/sub-agent.ts @@ -409,7 +409,9 @@ export function buildTaskToolDescription() { 'Delegate a focused task to a specialized agent when parallel work, deeper investigation, or a narrower prompt will help.', 'Use the exact agent name from the injected catalog.', 'If delegated work may take a while, set background=true so it can continue beyond the normal tool timeout.', - 'Use action=list or action=status to inspect background tasks, action=message to send more guidance while they run, and action=run with the same id to continue the same session later.' + 'Use action=list or action=status to inspect background tasks, ' + + 'action=message to send more guidance while they run, and ' + + 'action=run with the same id to continue the same session later.' ].join('\n') } @@ -464,7 +466,9 @@ class AgentTaskTool extends StructuredTool { .enum(['run', 'status', 'list', 'message']) .optional() .describe( - 'run starts or resumes an agent task, status inspects one task, list shows recent tasks in this conversation, message sends live guidance to a running background task.' + 'run starts or resumes an agent task, status inspects one task, ' + + 'list shows recent tasks in this conversation, message sends ' + + 'live guidance to a running background task.' ), agent: z .string() @@ -882,7 +886,8 @@ function formatTaskResult( `agent: ${task.agentName}`, `run_id: ${run.runId}`, `state: ${run.state}`, - `resume_hint: use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} to continue this session. Add "background":true when the work may take a while.`, + `resume_hint: use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} ` + + 'to continue this session. Add "background":true when the work may take a while.', '', output.trim() || '(empty)' ].join('\n') @@ -975,7 +980,8 @@ function formatTaskDetail( if (run?.state !== 'running') { lines.push( - `resume_hint: use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} to continue this session. Add "background":true when the work may take a while.` + `resume_hint: use ${toolName} with {"action":"run","id":"${task.id}","prompt":"next instruction"} ` + + 'to continue this session. Add "background":true when the work may take a while.' ) } diff --git a/packages/core/src/llm-core/chat/app.ts b/packages/core/src/llm-core/chat/app.ts index 5cb1fa8f6..ad0a630d4 100644 --- a/packages/core/src/llm-core/chat/app.ts +++ b/packages/core/src/llm-core/chat/app.ts @@ -47,6 +47,7 @@ export class ChatInterface { input: ChatInterfaceInput ) { this._input = input + ctx.on('dispose', () => this.dispose()) } dispose() { @@ -457,9 +458,9 @@ async function autoSummarizeTitle( humanMsg: HumanMessage, aiMsg: AIMessage ) { - const conversation = - await ctx.chatluna.conversation.getConversation(conversationId) - if (conversation == null || !conversation.autoTitle) { + const claimed = + await ctx.chatluna.conversation.claimAutoTitle(conversationId) + if (!claimed) { return } @@ -475,16 +476,25 @@ async function autoSummarizeTitle( `User: ${humanContent}\n` + `Assistant: ${aiContent}` - const result = await wrapper.model.invoke([new HumanMessage(prompt)]) - const title = getMessageContent(result.content).trim().slice(0, 20) - if (title.length < 5) { - return - } + try { + const result = await wrapper.model.invoke([new HumanMessage(prompt)]) + const title = getMessageContent(result.content).trim().slice(0, 20) + if (title.length < 5) { + await ctx.chatluna.conversation.touchConversation(conversationId, { + autoTitle: true + }) + return + } - await ctx.chatluna.conversation.touchConversation(conversationId, { - title, - autoTitle: false - }) + await ctx.chatluna.conversation.touchConversation(conversationId, { + title + }) + } catch (error) { + await ctx.chatluna.conversation.touchConversation(conversationId, { + autoTitle: true + }) + throw error + } } export interface ChatInterfaceInput { diff --git a/packages/core/src/llm-core/chat/default.ts b/packages/core/src/llm-core/chat/default.ts index 90fe93957..dbb216196 100644 --- a/packages/core/src/llm-core/chat/default.ts +++ b/packages/core/src/llm-core/chat/default.ts @@ -18,14 +18,14 @@ export async function defaultFactory(ctx: Context, service: PlatformService) { embeddingsSchema(ctx) chatChainSchema(ctx) - ctx.on('chatluna/model-removed', (_service, platform) => { - ctx.chatluna.conversationRuntime + ctx.on('chatluna/model-removed', async (_service, platform) => { + const tasks = ctx.chatluna.conversationRuntime .getCachedConversations() .filter( ([_, entry]) => parseRawModelName(entry.conversation.model)[0] === platform ) - .forEach(async ([id, entry]) => { + .map(async ([id, entry]) => { const result = await ctx.chatluna.conversationRuntime.clearConversationInterface( entry.conversation @@ -35,17 +35,19 @@ export async function defaultFactory(ctx: Context, service: PlatformService) { logger?.debug(`Cleared cache for conversation ${id}`) } }) + + await Promise.allSettled(tasks) }) - ctx.on('chatluna/tool-updated', () => { - ctx.chatluna.conversationRuntime + ctx.on('chatluna/tool-updated', async () => { + const tasks = ctx.chatluna.conversationRuntime .getCachedConversations() .filter( ([_, entry]) => entry?.chatInterface?.chatMode === 'plugin' || entry?.chatInterface?.chatMode === 'browsing' ) - .forEach(async ([id, entry]) => { + .map(async ([id, entry]) => { const result = await ctx.chatluna.conversationRuntime.clearConversationInterface( entry.conversation @@ -55,6 +57,8 @@ export async function defaultFactory(ctx: Context, service: PlatformService) { logger?.debug(`Cleared cache for conversation ${id}`) } }) + + await Promise.allSettled(tasks) }) service.registerChatChain( diff --git a/packages/core/src/llm-core/memory/message/database_history.ts b/packages/core/src/llm-core/memory/message/database_history.ts index 1f2dcd6c7..9467b7a64 100644 --- a/packages/core/src/llm-core/memory/message/database_history.ts +++ b/packages/core/src/llm-core/memory/message/database_history.ts @@ -5,7 +5,6 @@ import { FunctionMessage, HumanMessage, MessageContent, - MessageType, SystemMessage, ToolMessage } from '@langchain/core/messages' @@ -381,7 +380,8 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { content, id: item.rawId ?? undefined, name: item.name ?? undefined, - tool_calls: (item.tool_calls as AIMessage['tool_calls']) ?? undefined, + tool_calls: + (item.tool_calls as AIMessage['tool_calls']) ?? undefined, tool_call_id: item.tool_call_id ?? undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any additional_kwargs: args as any @@ -435,7 +435,8 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { archivedAt: null, archiveId: null, legacyRoomId: null, - legacyMeta: null + legacyMeta: null, + autoTitle: true }) } diff --git a/packages/core/src/llm-core/platform/service.ts b/packages/core/src/llm-core/platform/service.ts index c8a9e57de..80589f339 100644 --- a/packages/core/src/llm-core/platform/service.ts +++ b/packages/core/src/llm-core/platform/service.ts @@ -53,8 +53,8 @@ export class PlatformService { }) constructor(private ctx: Context) { - const clear = async () => { - this._tmpVectorStores.clear() + const clear = async (payload: { conversation: { id: string } }) => { + this._tmpVectorStores.delete(payload.conversation.id) } this.ctx.on('chatluna/conversation-after-clear-history', clear) diff --git a/packages/core/src/llm-core/platform/types.ts b/packages/core/src/llm-core/platform/types.ts index 451a4ccaf..f4239d455 100644 --- a/packages/core/src/llm-core/platform/types.ts +++ b/packages/core/src/llm-core/platform/types.ts @@ -80,7 +80,12 @@ export interface ChatLunaToolDefaultAvailability { } export interface ChatLunaToolMeta { - source?: 'core' | 'extension' | 'mcp' | 'action' | (string & {}) + source?: + | 'core' + | 'extension' + | 'mcp' + | 'action' + | (string & Record) group?: string tags?: string[] isMcp?: boolean diff --git a/packages/core/src/llm-core/prompt/context_manager.ts b/packages/core/src/llm-core/prompt/context_manager.ts index cd5bb0a02..2ae5e966d 100644 --- a/packages/core/src/llm-core/prompt/context_manager.ts +++ b/packages/core/src/llm-core/prompt/context_manager.ts @@ -294,21 +294,21 @@ export class ChatLunaContextManagerService { } constructor(ctx: Context) { - const clear = (conversationId: string) => { - this.clearConversation(conversationId) + const clearQueue = (conversationId: string) => { + this._conversationQueue.delete(conversationId) } ctx.on('chatluna/conversation-after-clear-history', async (payload) => - clear(payload.conversation.id) + this.clearConversation(payload.conversation.id) ) ctx.on('chatluna/conversation-after-archive', async (payload) => - clear(payload.conversation.id) + clearQueue(payload.conversation.id) ) ctx.on('chatluna/conversation-after-restore', async (payload) => - clear(payload.conversation.id) + clearQueue(payload.conversation.id) ) ctx.on('chatluna/conversation-after-delete', async (payload) => - clear(payload.conversation.id) + this.clearConversation(payload.conversation.id) ) } diff --git a/packages/core/src/llm-core/utils/count_tokens.ts b/packages/core/src/llm-core/utils/count_tokens.ts index 2f37371c3..3e9d3a489 100644 --- a/packages/core/src/llm-core/utils/count_tokens.ts +++ b/packages/core/src/llm-core/utils/count_tokens.ts @@ -1,11 +1,6 @@ import { MessageType } from '@langchain/core/messages' import { type TiktokenModel } from 'js-tiktoken/lite' import { encodingForModel } from './tiktoken' -import { - ChatLunaError, - ChatLunaErrorCode -} from 'koishi-plugin-chatluna/utils/error' -import { logger } from 'koishi-plugin-chatluna' // https://www.npmjs.com/package/js-tiktoken @@ -192,14 +187,23 @@ export const getModelContextSize = (modelName: string): number => { } } -export function parseRawModelName(modelName: string): [string, string] { +export function parseRawModelName( + modelName: string +): [string | undefined, string | undefined] { if (modelName == null || modelName.trim().length < 1) { - try { - throw new ChatLunaError(ChatLunaErrorCode.MODEL_NOT_FOUND) - } catch (error) { - logger.error(error) - } return [undefined, undefined] } - return modelName.split(/(?<=^[^\/]+)\//) as [string, string] + + const value = modelName.trim() + const index = value.indexOf('/') + + if (index === -1) { + return [undefined, value] + } + + if (index === 0 || index === value.length - 1) { + return [undefined, undefined] + } + + return [value.slice(0, index), value.slice(index + 1)] } diff --git a/packages/core/src/middlewares/chat/chat_time_limit_check.ts b/packages/core/src/middlewares/chat/chat_time_limit_check.ts index 89493d2a1..13929d860 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_check.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_check.ts @@ -111,7 +111,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const target = await resolveConversationTarget(session, context) if (target == null) { - return + return ChainMiddlewareRunStatus.CONTINUE } const { model, conversationId } = target diff --git a/packages/core/src/middlewares/chat/chat_time_limit_save.ts b/packages/core/src/middlewares/chat/chat_time_limit_save.ts index d8dfd791b..90710bef6 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_save.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_save.ts @@ -34,8 +34,16 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const { chatLimit, chatLimitCache } = context.options const conversationId = context.options.conversationId - if (conversationId == null || chatLimit == null || chatLimitCache == null) { - return ChainMiddlewareRunStatus.CONTINUE + if (conversationId == null) { + throw new Error('chat_time_limit_save missing conversationId') + } + + if (chatLimit == null) { + throw new Error('chat_time_limit_save missing chatLimit') + } + + if (chatLimitCache == null) { + throw new Error('chat_time_limit_save missing chatLimitCache') } /* console.log( diff --git a/packages/core/src/middlewares/chat/read_chat_message.ts b/packages/core/src/middlewares/chat/read_chat_message.ts index 5aae8fcd8..ed0474d73 100644 --- a/packages/core/src/middlewares/chat/read_chat_message.ts +++ b/packages/core/src/middlewares/chat/read_chat_message.ts @@ -77,7 +77,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (preset != null) { context.options.presetLane = preset.triggerKeyword[0] - if (parsed.queryOnly) { + if ( + parsed.queryOnly && + (message as h[]).every( + (element) => element.type === 'text' + ) + ) { context.command = 'conversation_current' context.options.conversation_manage = { ...context.options.conversation_manage, @@ -87,7 +92,31 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE } - message = [h.text(parsed.content)] + let skip = text.length - (parsed.content?.length ?? 0) + const next: h[] = [] + + for (const element of message as h[]) { + if (element.type !== 'text') { + next.push(element) + continue + } + + const content = String(element.attrs.content ?? '') + if (skip >= content.length) { + skip -= content.length + continue + } + + next.push( + h('text', { + ...element.attrs, + content: content.slice(skip) + }) + ) + skip = 0 + } + + message = next } } } @@ -250,7 +279,9 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) { if (!isInstalledImageService) { logger.warn( - `Model "${model}" does not support image input. Please use a model that supports vision capabilities, or install chatluna-multimodal-service plugin to enable image description.` + `Model "${model}" does not support image input. ` + + 'Please use a model that supports vision capabilities, ' + + 'or install chatluna-multimodal-service plugin to enable image description.' ) } return false diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 2aeac82dc..001026e94 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -1,9 +1,10 @@ -import { Context, h } from 'koishi' -import { gzipDecode, getMessageContent } from 'koishi-plugin-chatluna/utils/string' +import { Context } from 'koishi' +import { gzipDecode } from 'koishi-plugin-chatluna/utils/string' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { MessageRecord } from '../../services/conversation_types' import { logger } from '../..' +import { transformMessageContentToElements } from '../../utils/koishi' async function decodeMessageContent(message: MessageRecord) { try { @@ -76,8 +77,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { let parentId = conversation.latestMessageId const messages: MessageRecord[] = [] + let humanMessage: MessageRecord | undefined + let humanCount = 0 - while (messages.length < rollbackRound * 2 && parentId != null) { + while (parentId != null) { const message = await ctx.database.get('chatluna_message', { conversationId: conversation.id, id: parentId @@ -91,15 +94,23 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { parentId = currentMessage.parentId messages.unshift(currentMessage) + + if (currentMessage.role === 'human') { + humanMessage = currentMessage + humanCount += 1 + + if (humanCount >= rollbackRound) { + break + } + } } - if (messages.length < rollbackRound * 2) { + if (humanCount < rollbackRound || humanMessage == null) { context.message = session.text('.no_chat_history') return ChainMiddlewareRunStatus.STOP } - const previousLatestId = parentId ?? null - const humanMessage = messages[messages.length - 2] + const previousLatestId = humanMessage.parentId ?? null await ctx.database.upsert('chatluna_conversation', [ { @@ -119,7 +130,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.options.inputMessage = await ctx.chatluna.messageTransformer.transform( session, - [h.text(getMessageContent(humanContent))], + transformMessageContentToElements(humanContent), reResolved.effectiveModel ?? conversation.model, undefined, { diff --git a/packages/core/src/middlewares/conversation/request_conversation.ts b/packages/core/src/middlewares/conversation/request_conversation.ts index 563cd3477..1e38dc3e9 100644 --- a/packages/core/src/middlewares/conversation/request_conversation.ts +++ b/packages/core/src/middlewares/conversation/request_conversation.ts @@ -349,76 +349,67 @@ function sortContentByType(content: MessageContentComplex[]) { ) } -function setupRegularMessageStream( +async function setupRegularMessageStream( context: ChainMiddlewareContext, config: Config, textStream: ReadableStream ) { - return new Promise(async (resolve) => { - const reader = textStream.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - await sendMessage(context, value, config) - } - } catch (error) { - logger.error('Error in message stream:', error) - } finally { - reader.releaseLock() - resolve() + const reader = textStream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + await sendMessage(context, value, config) } - }) + } catch (error) { + logger.error('Error in message stream:', error) + } finally { + reader.releaseLock() + } } -function setupEditMessageStream( +async function setupEditMessageStream( context: ChainMiddlewareContext, session: Session, config: Config, bufferText: StreamingBufferText ) { const cachedStream = bufferText.getCached() - return new Promise(async (resolve) => { - const { ctx } = context - let messageId: string | null = null - const messageQueue = new MessageEditQueue() - - const reader = cachedStream.getReader() - try { - while (true) { - const { done, value } = await reader.read() - - if (done) break - - let processedElements = value - if (config.censor) { - processedElements = await ctx.censor - .transform(value, session) - .then((result) => result) - } + const { ctx } = context + let messageId: string | null = null + const messageQueue = new MessageEditQueue() + + const reader = cachedStream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + + if (done) break + + let processedElements = value + if (config.censor) { + processedElements = await ctx.censor + .transform(value, session) + .then((result) => result) + } - if (messageId == null) { - messageId = await sendInitialMessage( - session, - processedElements - ) - } else { - await messageQueue.enqueue( - messageId, - session, - processedElements - ) - } + if (messageId == null) { + messageId = await sendInitialMessage(session, processedElements) + } else { + await messageQueue.enqueue( + messageId, + session, + processedElements + ) } - messageQueue.finish() - } catch (error) { - logger.error('Error in edit message stream:', error) - } finally { - reader.releaseLock() - resolve() } - }) + messageQueue.finish() + } catch (error) { + logger.error('Error in edit message stream:', error) + } finally { + reader.releaseLock() + } } async function renderMessageWithCensor( diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts index 8b2d6e30a..64aea4141 100644 --- a/packages/core/src/middlewares/conversation/resolve_conversation.ts +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -56,13 +56,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.options.resolvedConversation = conversation } - const resolved = await ctx.chatluna.conversation.resolveContext( - session, - { + const resolved = + context.options.resolvedConversationContext ?? + (await ctx.chatluna.conversation.resolveContext(session, { conversationId: context.options.conversationId, presetLane - } - ) + })) context.options.resolvedConversation = context.options.resolvedConversation ?? resolved.conversation diff --git a/packages/core/src/middlewares/model/resolve_model.ts b/packages/core/src/middlewares/model/resolve_model.ts index 6501c7e21..ed0bc414a 100644 --- a/packages/core/src/middlewares/model/resolve_model.ts +++ b/packages/core/src/middlewares/model/resolve_model.ts @@ -1,7 +1,7 @@ import { Context } from 'koishi' import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { ChatChain, ChainMiddlewareRunStatus } from '../../chains/chain' +import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { logger } from '../..' import { Config } from '../../config' @@ -39,18 +39,30 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const [platformName, rawModelName] = parseRawModelName(modelName) + const presetExists = + ctx.chatluna.preset.getPreset(conversation.preset, false) + .value != null + + if ( + modelName === 'empty' || + platformName == null || + rawModelName == null + ) { + await context.send( + session.text( + 'chatluna.conversation.messages.unavailable', + [modelName] + ) + ) + return ChainMiddlewareRunStatus.STOP + } + const platformModels = ctx.chatluna.platform.listPlatformModels( platformName, ModelType.llm ).value - const presetExists = - ctx.chatluna.preset.getPreset(conversation.preset, false) - .value != null if ( - modelName !== 'empty' && - platformName != null && - rawModelName != null && platformModels.length > 0 && platformModels.some((it) => it.name === rawModelName) && presetExists diff --git a/packages/core/src/middlewares/model/set_default_embeddings.ts b/packages/core/src/middlewares/model/set_default_embeddings.ts index 601754ac0..438422d93 100644 --- a/packages/core/src/middlewares/model/set_default_embeddings.ts +++ b/packages/core/src/middlewares/model/set_default_embeddings.ts @@ -49,8 +49,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) context.message = buffer.join('\n') + return ChainMiddlewareRunStatus.STOP } else if (targetEmbeddings.length === 0) { context.message = session.text('.model_not_found') + return ChainMiddlewareRunStatus.STOP } const fullName = targetEmbeddings[0] diff --git a/packages/core/src/middlewares/preset/delete_preset.ts b/packages/core/src/middlewares/preset/delete_preset.ts index 13cafba01..5cf574194 100644 --- a/packages/core/src/middlewares/preset/delete_preset.ts +++ b/packages/core/src/middlewares/preset/delete_preset.ts @@ -1,15 +1,9 @@ -import { Context, Logger } from 'koishi' +import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { createLogger } from 'koishi-plugin-chatluna/utils/logger' import fs from 'fs/promises' -import { PresetTemplate } from '../../llm-core/prompt' - -let logger: Logger - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - logger = createLogger(ctx) +export function apply(ctx: Context, _config: Config, chain: ChatChain) { chain .middleware('delete_preset', async (session, context) => { const { command } = context @@ -20,20 +14,16 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const presetName = context.options.deletePreset const preset = ctx.chatluna.preset - let presetTemplate: PresetTemplate - - try { - presetTemplate = preset.getPreset(presetName).value + const presetTemplate = preset.getPreset(presetName).value + if (presetTemplate == null) { + await context.send(session.text('.not_found')) + return ChainMiddlewareRunStatus.STOP + } - const allPreset = preset.getAllPreset().value + const allPreset = preset.getAllPreset().value - if (allPreset.length === 1) { - await context.send(session.text('.only_one_preset')) - return ChainMiddlewareRunStatus.STOP - } - } catch (e) { - logger.error(e) - await context.send(session.text('.not_found')) + if (allPreset.length === 1) { + await context.send(session.text('.only_one_preset')) return ChainMiddlewareRunStatus.STOP } @@ -53,18 +43,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { await fs.rm(presetTemplate.path) - let defaultPreset: PresetTemplate - try { - defaultPreset = preset.getDefaultPreset().value - if (!defaultPreset || !defaultPreset.triggerKeyword?.length) { - throw new Error('Default preset is invalid') - } - } catch (e) { - logger.error('Failed to get default preset:', e) - await context.send(session.text('.failed_to_get_default')) - return ChainMiddlewareRunStatus.STOP - } - context.message = session.text('.success', [presetName]) return ChainMiddlewareRunStatus.STOP diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index abc14c53d..bdd762513 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -48,10 +48,22 @@ function formatRouteScope(bindingKey: string) { const [mode, platform, selfId, scope, userId] = bindingKey.split(':') + if (mode !== 'shared' && mode !== 'personal') { + return bindingKey + } + + if (platform == null || selfId == null || scope == null) { + return bindingKey + } + if (mode === 'shared') { return `${mode} ${platform}/${selfId}/${scope}` } + if (userId == null) { + return bindingKey + } + return `${mode} ${platform}/${selfId}/${scope}/${userId}` } @@ -158,11 +170,6 @@ function formatLockState(lock: boolean | null | undefined) { } export function apply(ctx: Context, config: Config, chain: ChatChain) { - const pagination = new Pagination({ - formatItem: () => '', - formatString: { top: '', bottom: '', pages: '' } - }) - function middleware( name: Parameters[0], fn: ( @@ -339,17 +346,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - pagination.updateFormatString({ - top: - session.text('chatluna.conversation.messages.list_header') + - '\n', - bottom: '', - pages: - '\n' + session.text('chatluna.conversation.messages.list_pages') + const pagination = new Pagination({ + formatItem: (conversation) => + formatConversationLine(session, conversation, resolved), + formatString: { + top: + session.text('chatluna.conversation.messages.list_header') + + '\n', + bottom: '', + pages: + '\n' + + session.text('chatluna.conversation.messages.list_pages') + } }) - pagination.updateFormatItem((conversation) => - formatConversationLine(session, conversation, resolved) - ) const key = `${resolved.bindingKey}` await pagination.push(conversations, key) @@ -809,7 +818,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - if (resolved.constraint.lockConversation) { + const target = + await ctx.chatluna.conversation.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + + if (target?.lockConversation ?? resolved.constraint.lockConversation) { context.message = session.text(`${key}.failed`, [ conversation.title, conversation.id, diff --git a/packages/core/src/middlewares/system/lifecycle.ts b/packages/core/src/middlewares/system/lifecycle.ts index 2919fa871..3a96045a2 100644 --- a/packages/core/src/middlewares/system/lifecycle.ts +++ b/packages/core/src/middlewares/system/lifecycle.ts @@ -19,7 +19,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { .before('lifecycle-request_conversation') chain - .middleware('lifecycle-request_conversation', async (session, context) => 0) + .middleware( + 'lifecycle-request_conversation', + async (session, context) => 0 + ) .after('lifecycle-handle_command') .before('lifecycle-send') diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index d0c6d2796..9205d2844 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -1,4 +1,4 @@ -import { Context, Logger } from 'koishi' +import { Context, Logger, Session } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { createLogger } from 'koishi-plugin-chatluna/utils/logger' @@ -15,7 +15,7 @@ import { let logger: Logger -export function apply(ctx: Context, config: Config, chain: ChatChain) { +export function apply(ctx: Context, _config: Config, chain: ChatChain) { logger = createLogger(ctx) chain .middleware('purge_legacy', async (session, context) => { @@ -33,6 +33,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } + const status = await confirmWipe(session, '.confirm_wipe') + if (status !== 'ok') { + context.message = + status === 'timeout' + ? session.text('.timeout') + : session.text('.incorrect_input') + return ChainMiddlewareRunStatus.STOP + } + await purgeLegacyTables(ctx) await writeMetaValue( ctx, @@ -55,21 +64,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (command !== 'wipe') return ChainMiddlewareRunStatus.SKIPPED - const expression = generateExpression() - - await context.send( - session.text('.confirm_wipe', [expression.expression]) + const status = await confirmWipe( + session, + '.confirm_wipe', + context.send ) - - const result = await session.prompt(1000 * 30) - - if (!result) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } - - if (result !== expression.result.toString()) { - context.message = session.text('.incorrect_input') + if (status !== 'ok') { + context.message = session.text( + status === 'timeout' ? '.timeout' : '.incorrect_input' + ) return ChainMiddlewareRunStatus.STOP } @@ -165,3 +168,27 @@ export function generateExpression() { result } } + +async function confirmWipe( + session: Session, + key: string, + send?: (message: string) => Promise +) { + const expression = generateExpression() + + if (send) { + await send(session.text(key, [expression.expression])) + } else { + await session.send(session.text(key, [expression.expression])) + } + + const result = await session.prompt(1000 * 30) + + if (!result) { + return 'timeout' as const + } + + return result === expression.result.toString() + ? ('ok' as const) + : ('incorrect' as const) +} diff --git a/packages/core/src/migration/legacy_tables.ts b/packages/core/src/migration/legacy_tables.ts index 7191e7d21..f6ff8e298 100644 --- a/packages/core/src/migration/legacy_tables.ts +++ b/packages/core/src/migration/legacy_tables.ts @@ -43,6 +43,8 @@ export function getLegacySchemaSentinelDir(baseDir: string) { } export async function purgeLegacyTables(ctx: Context) { + const failed: string[] = [] + for (const table of [ ...LEGACY_MIGRATION_TABLES, ...LEGACY_RUNTIME_TABLES @@ -51,9 +53,14 @@ export async function purgeLegacyTables(ctx: Context) { await ctx.database.drop(table) } catch (error) { ctx.logger.warn(`purge legacy ${table}: ${error}`) + failed.push(table) } } + if (failed.length > 0) { + throw new Error(`Failed to purge legacy tables: ${failed.join(', ')}`) + } + const sentinel = getLegacySchemaSentinel(ctx.baseDir) await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { recursive: true }) await fs.writeFile( diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts index a8ce23233..e59610e4b 100644 --- a/packages/core/src/migration/room_to_conversation.ts +++ b/packages/core/src/migration/room_to_conversation.ts @@ -16,13 +16,13 @@ import type { } from '../services/types' import type { BindingProgress, MessageProgress, RoomProgress } from './types' import { - LEGACY_RETENTION_META_KEY, aclKey, createLegacyBindingKey, createLegacyTableRetention, filterValidRooms, inferLegacyGroupRouteModes, isComplexRoom, + LEGACY_RETENTION_META_KEY, purgeLegacyTables, readMetaValue, resolveRoomBindingKey, @@ -221,6 +221,7 @@ async function migrateRooms(ctx: Context) { } const conversation: ConversationRecord = { + // filterValidRooms() guarantees conversationId is present here. id: room.conversationId as string, seq: conversationSeq, bindingKey, @@ -269,10 +270,6 @@ async function migrateRooms(ctx: Context) { // (#7) removed redundant inner `done` check async function migrateMessages(ctx: Context) { - const oldMessages = (await ctx.database.get( - 'chathub_message', - {} - )) as LegacyMessageRecord[] const progress = (await readMetaValue( ctx, 'message_migration_progress' @@ -281,13 +278,23 @@ async function migrateMessages(ctx: Context) { migrated: 0 } - for ( - let i = progress.index; - i < oldMessages.length; - i += MESSAGE_BATCH_SIZE - ) { - const batch = oldMessages.slice(i, i + MESSAGE_BATCH_SIZE) - // (#12) removed redundant payload.length > 0 check — batch is always non-empty here + while (true) { + const batch = (await ctx.database.get( + 'chathub_message', + {}, + { + offset: progress.index, + limit: MESSAGE_BATCH_SIZE, + sort: { + id: 'asc' + } + } + )) as LegacyMessageRecord[] + + if (batch.length === 0) { + break + } + const payload = batch.map((item) => ({ id: item.id, conversationId: item.conversation, @@ -306,7 +313,7 @@ async function migrateMessages(ctx: Context) { await ctx.database.upsert('chatluna_message', payload) - progress.index = i + batch.length + progress.index += batch.length progress.lastId = batch[batch.length - 1]?.id progress.migrated += batch.length await writeMetaValue(ctx, 'message_migration_progress', progress) @@ -334,6 +341,11 @@ async function migrateBindings(ctx: Context) { {} )) as LegacyRoomGroupRecord[] const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) + const conversationsByRoomId = new Map( + conversations + .filter((item) => item.legacyRoomId != null) + .map((item) => [item.legacyRoomId!, item]) + ) const progress = (await readMetaValue( ctx, 'binding_migration_progress' @@ -342,18 +354,9 @@ async function migrateBindings(ctx: Context) { migrated: 0 } - // (#17) load all existing bindings up front to avoid N+1 DB queries - const existingBindings = new Map( - ( - (await ctx.database.get('chatluna_binding', {})) as BindingRecord[] - ).map((item) => [item.bindingKey, item]) - ) - for (let i = progress.index; i < users.length; i++) { const user = users[i] - const conversation = conversations.find( - (item) => item.legacyRoomId === user.defaultRoomId - ) + const conversation = conversationsByRoomId.get(user.defaultRoomId) progress.index = i + 1 @@ -370,7 +373,11 @@ async function migrateBindings(ctx: Context) { } const bindingKey = createLegacyBindingKey(user, routeModes) - const current = existingBindings.get(bindingKey) + const current = ( + (await ctx.database.get('chatluna_binding', { + bindingKey + })) as BindingRecord[] + )[0] // (#16) decomposed nested ternary into explicit variable const prevActive = current?.activeConversationId diff --git a/packages/core/src/migration/validators.ts b/packages/core/src/migration/validators.ts index da8c2a0ac..dd9b61380 100644 --- a/packages/core/src/migration/validators.ts +++ b/packages/core/src/migration/validators.ts @@ -8,7 +8,6 @@ import type { MetaRecord } from '../services/conversation_types' import type { - LegacyConversationRecord, LegacyMessageRecord, LegacyRoomGroupRecord, LegacyRoomMemberRecord, @@ -27,77 +26,129 @@ export { createLegacyTableRetention, purgeLegacyTables } from './legacy_tables' -export async function validateRoomMigration(ctx: Context, config: Config) { - // All queries are independent — run in parallel (#20) - const [ - rooms, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _, - oldMessages, - users, - members, - groups, - conversations, - messages, - bindings, - acl - ] = (await Promise.all([ - ctx.database.get('chathub_room', {}), - ctx.database.get('chathub_conversation', {}), - ctx.database.get('chathub_message', {}), - ctx.database.get('chathub_user', {}), - ctx.database.get('chathub_room_member', {}), - ctx.database.get('chathub_room_group_member', {}), - ctx.database.get('chatluna_conversation', {}), - ctx.database.get('chatluna_message', {}), - ctx.database.get('chatluna_binding', {}), - ctx.database.get('chatluna_acl', {}) - ])) as [ - LegacyRoomRecord[], - LegacyConversationRecord[], - LegacyMessageRecord[], - LegacyUserRecord[], - LegacyRoomMemberRecord[], - LegacyRoomGroupRecord[], - ConversationRecord[], - MessageRecord[], - BindingRecord[], - ACLRecord[] - ] + +const VALIDATION_BATCH_SIZE = 500 + +export async function validateRoomMigration(ctx: Context, _config: Config) { + const rooms = (await ctx.database.get( + 'chathub_room', + {} + )) as LegacyRoomRecord[] + const users = (await ctx.database.get( + 'chathub_user', + {} + )) as LegacyUserRecord[] + const members = (await ctx.database.get( + 'chathub_room_member', + {} + )) as LegacyRoomMemberRecord[] + const groups = (await ctx.database.get( + 'chathub_room_group_member', + {} + )) as LegacyRoomGroupRecord[] const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) - // Shared filter logic (#21) const validRooms = filterValidRooms(rooms) const validConversationIds = new Set( validRooms.map((room) => room.conversationId as string) ) - const migratedLegacyConversations = conversations.filter( - (item) => item.legacyRoomId != null || validConversationIds.has(item.id) - ) - const migratedLegacyMessages = messages.filter( - (item) => item.createdAt == null + + let legacyMessageCount = 0 + await readTableBatches( + ctx, + 'chathub_message', + (rows) => { + legacyMessageCount += rows.length + } ) - const migratedMessageIds = new Set(messages.map((item) => item.id)) - const migratedBindingKeys = new Set(bindings.map((item) => item.bindingKey)) - const migratedAclKeys = new Set( - acl.map((item) => - aclKey( - item.conversationId, - item.principalType, - item.principalId, - item.permission + + let migratedLegacyMessageCount = 0 + await readTableBatches(ctx, 'chatluna_message', (rows) => { + for (const row of rows) { + if (row.createdAt == null) { + migratedLegacyMessageCount += 1 + } + } + }) + + const conversationsById = new Map() + const conversationsByRoomId = new Map() + const missingLatestMessageIds: string[] = [] + let migratedLegacyConversationCount = 0 + + await readTableBatches( + ctx, + 'chatluna_conversation', + async (rows) => { + const checked = rows.filter( + (row) => + row.legacyRoomId != null || validConversationIds.has(row.id) ) - ) + + if (checked.length === 0) { + return + } + + migratedLegacyConversationCount += checked.length + + for (const row of checked) { + conversationsById.set(row.id, row) + if (row.legacyRoomId != null) { + conversationsByRoomId.set(row.legacyRoomId, row) + } + } + + const latestMessageIds = checked + .map((row) => row.latestMessageId) + .filter((id) => id != null) + + if (latestMessageIds.length === 0) { + return + } + + const existing = new Set( + ( + (await ctx.database.get('chatluna_message', { + id: { $in: latestMessageIds } + })) as MessageRecord[] + ).map((row) => row.id) + ) + + for (const row of checked) { + if ( + row.latestMessageId != null && + !existing.has(row.latestMessageId) + ) { + missingLatestMessageIds.push(row.id) + } + } + } ) - const missingLatestMessageIds = migratedLegacyConversations - .filter( - (item) => - item.latestMessageId != null && - !migratedMessageIds.has(item.latestMessageId) - ) - .map((item) => item.id) + const migratedBindingKeys = new Set() + await readTableBatches(ctx, 'chatluna_binding', (rows) => { + for (const row of rows) { + migratedBindingKeys.add(row.bindingKey) + } + }) + + const migratedAclKeys = new Set() + let migratedAclCount = 0 + await readTableBatches(ctx, 'chatluna_acl', (rows) => { + migratedAclCount += rows.length + for (const row of rows) { + migratedAclKeys.add( + aclKey( + row.conversationId, + row.principalType, + row.principalId, + row.permission + ) + ) + } + }) + const inconsistentBindingConversationIds = validRooms .map((room) => { const roomMembers = members.filter( @@ -106,8 +157,8 @@ export async function validateRoomMigration(ctx: Context, config: Config) { const roomGroups = groups.filter( (item) => item.roomId === room.roomId ) - const conversation = migratedLegacyConversations.find( - (item) => item.id === room.conversationId + const conversation = conversationsById.get( + room.conversationId as string ) if (conversation == null) { @@ -131,14 +182,9 @@ export async function validateRoomMigration(ctx: Context, config: Config) { const missingBindingConversationIds: string[] = [] for (const user of users) { - const conversation = conversations.find( - (item) => item.legacyRoomId === user.defaultRoomId - ) + const conversation = conversationsByRoomId.get(user.defaultRoomId) if (conversation == null) { - // (#23) push the conversation id (derived from defaultRoomId match), not the raw roomId - // When no conversation was migrated for this user's defaultRoomId, record it as missing. - // We record String(user.defaultRoomId) as a sentinel since no conversationId exists yet. missingBindingConversationIds.push(String(user.defaultRoomId)) continue } @@ -200,14 +246,14 @@ export async function validateRoomMigration(ctx: Context, config: Config) { checkedAt: new Date().toISOString(), conversation: { legacy: validConversationIds.size, - migrated: migratedLegacyConversations.length, + migrated: migratedLegacyConversationCount, matched: - validConversationIds.size === migratedLegacyConversations.length + validConversationIds.size === migratedLegacyConversationCount }, message: { - legacy: oldMessages.length, - migrated: migratedLegacyMessages.length, - matched: oldMessages.length === migratedLegacyMessages.length + legacy: legacyMessageCount, + migrated: migratedLegacyMessageCount, + matched: legacyMessageCount === migratedLegacyMessageCount }, latestMessageId: { missingConversationIds: missingLatestMessageIds, @@ -226,7 +272,7 @@ export async function validateRoomMigration(ctx: Context, config: Config) { }, acl: { expected: expectedAclKeys.length, - migrated: acl.length, + migrated: migratedAclCount, missing: missingAclKeys, matched: missingAclKeys.length === 0 } @@ -244,6 +290,37 @@ export async function validateRoomMigration(ctx: Context, config: Config) { } satisfies MigrationValidationResult } +async function readTableBatches( + ctx: Context, + table: + | 'chathub_message' + | 'chatluna_message' + | 'chatluna_conversation' + | 'chatluna_binding' + | 'chatluna_acl', + callback: (rows: T[]) => Promise | void +) { + let offset = 0 + + while (true) { + const rows = (await ctx.database.get( + table, + {}, + { + offset, + limit: VALIDATION_BATCH_SIZE + } + )) as T[] + + if (rows.length === 0) { + return + } + + await callback(rows) + offset += rows.length + } +} + export async function readMetaValue(ctx: Context, key: string) { const row = ( (await ctx.database.get('chatluna_meta', { diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index 5c9c1b575..19c618b95 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -1,4 +1,3 @@ -import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import fs from 'fs' import path from 'path' import { @@ -10,6 +9,7 @@ import { Service, Session } from 'koishi' +import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import { ChatInterface } from 'koishi-plugin-chatluna/llm-core/chat/app' import { Cache } from '../cache' import { ChatChain } from '../chains/chain' @@ -18,7 +18,6 @@ import { type ChatLunaAgent, createAgent, type CreateChatLunaAgentOptions, - MessageQueue, resolveAgentEmbeddings, resolveAgentModel, resolveAgentPreset, diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index b9727eb01..8beb5db38 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' -import type { Context, Session, User } from 'koishi' +import type { Context, Session } from 'koishi' import type { Config } from '../config' import { bufferToArrayBuffer, @@ -31,8 +31,13 @@ import { ResolveTargetConversationOptions, SerializedMessageRecord } from './types' +import { checkAdmin } from '../utils/koishi' +import { ObjectLock } from '../utils/lock' export class ConversationService { + private readonly _bindingLocks = new Map() + private readonly _titleLocks = new Map() + constructor( private readonly ctx: Context, private readonly config: Config @@ -330,41 +335,57 @@ export class ConversationService { chatMode: string } ) { - const now = new Date() - const conversation: ConversationRecord = { - id: randomUUID(), - seq: await this.allocateConversationSeq(options.bindingKey), - bindingKey: options.bindingKey, - title: options.title, - model: options.model, - preset: options.preset, - chatMode: options.chatMode, - createdBy: session.userId, - createdAt: now, - updatedAt: now, - lastChatAt: now, - status: 'active', - latestMessageId: null, - additional_kwargs: null, - compression: null, - archivedAt: null, - archiveId: null, - legacyRoomId: null, - legacyMeta: null, - autoTitle: true - } - - await this.ctx.root.parallel('chatluna/conversation-before-create', { - conversation, - bindingKey: options.bindingKey - }) - await this.ctx.database.create('chatluna_conversation', conversation) - await this.setActiveConversation(options.bindingKey, conversation.id) - await this.ctx.root.parallel('chatluna/conversation-after-create', { - conversation, - bindingKey: options.bindingKey - }) - return conversation + return getLock(this._bindingLocks, options.bindingKey).runLocked( + async () => { + const now = new Date() + const conversation: ConversationRecord = { + id: randomUUID(), + seq: await this.allocateConversationSeq(options.bindingKey), + bindingKey: options.bindingKey, + title: options.title, + model: options.model, + preset: options.preset, + chatMode: options.chatMode, + createdBy: session.userId, + createdAt: now, + updatedAt: now, + lastChatAt: now, + status: 'active', + latestMessageId: null, + additional_kwargs: null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: null, + legacyMeta: null, + autoTitle: true + } + + await this.ctx.root.parallel( + 'chatluna/conversation-before-create', + { + conversation, + bindingKey: options.bindingKey + } + ) + await this.ctx.database.create( + 'chatluna_conversation', + conversation + ) + await this.setActiveConversation( + options.bindingKey, + conversation.id + ) + await this.ctx.root.parallel( + 'chatluna/conversation-after-create', + { + conversation, + bindingKey: options.bindingKey + } + ) + return conversation + } + ) } async setActiveConversation(bindingKey: string, conversationId: string) { @@ -393,16 +414,36 @@ export class ConversationService { return undefined } - const updated: ConversationRecord = { + const updated = { ...current, - ...patch, updatedAt: patch.updatedAt ?? new Date() + } as ConversationRecord + + for (const key in patch) { + const value = patch[key as keyof ConversationRecord] + if (value !== undefined) { + updated[key as keyof ConversationRecord] = value as never + } } await this.ctx.database.upsert('chatluna_conversation', [updated]) return updated } + async claimAutoTitle(conversationId: string) { + return getLock(this._titleLocks, conversationId).runLocked(async () => { + const conversation = await this.getConversation(conversationId) + if (conversation == null || !conversation.autoTitle) { + return false + } + + await this.touchConversation(conversationId, { + autoTitle: false + }) + return true + }) + } + async listConversations( session: Session, options: ListConversationsOptions = {} @@ -437,10 +478,6 @@ export class ConversationService { const resolved = await this.resolveContext(session, options) await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error('Conversation switch is locked by constraint.') - } - const conversation = await this.resolveTargetConversation(session, { ...options, permission: 'manage' @@ -450,11 +487,15 @@ export class ConversationService { throw new Error('Conversation not found.') } - if (conversation.bindingKey !== resolved.bindingKey) { - throw new Error('Conversation does not belong to current route.') + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + + if (target?.lockConversation ?? resolved.constraint.lockConversation) { + throw new Error('Conversation switch is locked by constraint.') } - if (!resolved.constraint.allowSwitch) { + if (!(target?.allowSwitch ?? resolved.constraint.allowSwitch)) { throw new Error('Conversation switch is disabled by constraint.') } @@ -491,10 +532,6 @@ export class ConversationService { const resolved = await this.resolveContext(session, options) await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error('Conversation restore is locked by constraint.') - } - const conversation = await this.resolveTargetConversation(session, { ...options, includeArchived: true, @@ -505,8 +542,12 @@ export class ConversationService { throw new Error('Conversation not found.') } - if (conversation.bindingKey !== resolved.bindingKey) { - throw new Error('Conversation does not belong to current route.') + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + + if (target?.lockConversation ?? resolved.constraint.lockConversation) { + throw new Error('Conversation restore is locked by constraint.') } if (conversation.status !== 'archived') { @@ -614,7 +655,11 @@ export class ConversationService { throw new Error('Conversation not found.') } - if (!resolved.constraint.allowExport) { + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + + if (!(target?.allowExport ?? resolved.constraint.allowExport)) { throw new Error('Conversation export is disabled by constraint.') } @@ -642,10 +687,6 @@ export class ConversationService { const resolved = await this.resolveContext(session, options) await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error('Conversation archive is locked by constraint.') - } - const conversation = await this.resolveTargetConversation(session, { ...options, permission: 'manage' @@ -655,7 +696,15 @@ export class ConversationService { throw new Error('Conversation not found.') } - if (!resolved.constraint.allowArchive) { + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + + if (target?.lockConversation ?? resolved.constraint.lockConversation) { + throw new Error('Conversation archive is locked by constraint.') + } + + if (!(target?.allowArchive ?? resolved.constraint.allowArchive)) { throw new Error('Conversation archive is disabled by constraint.') } @@ -801,11 +850,15 @@ export class ConversationService { await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + + if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation restore is locked by constraint.') } - if (!resolved.constraint.allowArchive) { + if (!(target?.allowArchive ?? resolved.constraint.allowArchive)) { throw new Error('Conversation restore is disabled by constraint.') } @@ -926,10 +979,6 @@ export class ConversationService { const resolved = await this.resolveContext(session, options) await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error('Conversation rename is locked by constraint.') - } - const conversation = await this.resolveTargetConversation(session, { ...options, permission: 'manage' @@ -938,6 +987,13 @@ export class ConversationService { throw new Error('Conversation not found.') } + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + if (target?.lockConversation ?? resolved.constraint.lockConversation) { + throw new Error('Conversation rename is locked by constraint.') + } + const updated = await this.touchConversation(conversation.id, { title: options.title.trim() }) @@ -951,10 +1007,6 @@ export class ConversationService { const resolved = await this.resolveContext(session, options) await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error('Conversation delete is locked by constraint.') - } - const conversation = await this.resolveTargetConversation(session, { ...options, includeArchived: true, @@ -964,6 +1016,13 @@ export class ConversationService { throw new Error('Conversation not found.') } + const target = await this.getManagedConstraintByBindingKey( + conversation.bindingKey + ) + if (target?.lockConversation ?? resolved.constraint.lockConversation) { + throw new Error('Conversation delete is locked by constraint.') + } + const updated = await this.touchConversation(conversation.id, { status: 'deleted', archivedAt: null @@ -996,10 +1055,6 @@ export class ConversationService { const resolved = await this.ensureActiveConversation(session, options) await this.assertManageAllowed(session, resolved.constraint) - if (resolved.constraint.lockConversation) { - throw new Error('Conversation update is locked by constraint.') - } - for (const [key, fixedKey] of [ ['model', 'fixedModel'], ['preset', 'fixedPreset'], @@ -1012,6 +1067,13 @@ export class ConversationService { } } + const target = await this.getManagedConstraintByBindingKey( + resolved.conversation.bindingKey + ) + if (target?.lockConversation ?? resolved.constraint.lockConversation) { + throw new Error('Conversation update is locked by constraint.') + } + const updated = await this.touchConversation(resolved.conversation.id, { model: options.model, preset: options.preset, @@ -1050,10 +1112,21 @@ export class ConversationService { } const current = parseCompressionRecord(conversation.compression) - const messages = await this.listMessages(conversationId) - const summary = messages.find( - (message) => message.name === 'infinite_context' - )?.text + const summary = ( + (await this.ctx.database.get( + 'chatluna_message', + { + conversationId, + name: 'infinite_context' + }, + { + limit: 1, + sort: { + createdAt: 'desc' + } + } + )) as MessageRecord[] + )[0]?.text const updated = await this.touchConversation(conversationId, { compression: JSON.stringify({ @@ -1092,6 +1165,37 @@ export class ConversationService { return matched[0] as ConstraintRecord | undefined } + async getManagedConstraintByBindingKey(bindingKey: string) { + const target = bindingKey.includes(':preset:') + ? bindingKey.slice(0, bindingKey.indexOf(':preset:')) + : bindingKey + const parts = target.split(':') + + let name: string | undefined + + if (parts[0] === 'shared' && parts.length >= 4) { + name = `managed:${parts[1]}:${parts[2]}:guild:${parts[3]}` + } else if ( + parts[0] === 'personal' && + parts.length >= 5 && + parts[3] === 'direct' + ) { + name = `managed:${parts[1]}:${parts[2]}:direct:${parts[4]}` + } else if (parts[0] === 'personal' && parts.length >= 5) { + name = `managed:${parts[1]}:${parts[2]}:guild:${parts[3]}` + } + + if (name == null) { + return undefined + } + + return ( + await this.ctx.database.get('chatluna_constraint', { + name + }) + )[0] as ConstraintRecord | undefined + } + async updateManagedConstraint( session: Session, patch: Partial @@ -1276,47 +1380,109 @@ export class ConversationService { seq?: number } ) { - const conversations = (await this.ctx.database.get( - 'chatluna_conversation', - {} - )) as ConversationRecord[] const required = options.permission ?? 'view' - - const matches: ConversationRecord[] = [] - - for (const conversation of conversations) { + const matched = (conversation: ConversationRecord) => { if ( conversation.bindingKey === options.bindingKey || conversation.status === 'deleted' || conversation.status === 'broken' || (!options.includeArchived && conversation.status === 'archived') ) { - continue + return false } const title = conversation.title.toLocaleLowerCase() - const matched = + return ( conversation.id === options.exactId || (options.seq != null && conversation.seq === options.seq) || title === options.query || title.includes(options.query) + ) + } - if (!matched) { - continue + if (await checkAdmin(session)) { + if (options.exactId.length > 0) { + const exact = await this.getConversation(options.exactId) + if (exact != null && matched(exact)) { + return [exact] + } } - if ( - !(await this.hasConversationPermission( - session, - conversation, - required, - options.bindingKey - )) - ) { - continue + if (options.seq != null) { + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + { + seq: options.seq + } + )) as ConversationRecord[] + return conversations.filter(matched) } - matches.push(conversation) + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + return conversations.filter(matched) + } + + const acl = [ + ...((await this.ctx.database.get('chatluna_acl', { + principalType: 'user', + principalId: session.userId, + permission: 'manage' + })) as ACLRecord[]) + ] + + if (required === 'view') { + acl.push( + ...((await this.ctx.database.get('chatluna_acl', { + principalType: 'user', + principalId: session.userId, + permission: 'view' + })) as ACLRecord[]) + ) + } + + const guildId = session.guildId ?? session.channelId + + if (guildId != null) { + acl.push( + ...((await this.ctx.database.get('chatluna_acl', { + principalType: 'guild', + principalId: guildId, + permission: 'manage' + })) as ACLRecord[]) + ) + + if (required === 'view') { + acl.push( + ...((await this.ctx.database.get('chatluna_acl', { + principalType: 'guild', + principalId: guildId, + permission: 'view' + })) as ACLRecord[]) + ) + } + } + + const conversationIds = Array.from( + new Set(acl.map((item) => item.conversationId)) + ) + + const matches: ConversationRecord[] = [] + + for (let i = 0; i < conversationIds.length; i += 200) { + const ids = conversationIds.slice(i, i + 200) + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + { + id: { + $in: ids + } + } + )) as ConversationRecord[] + + matches.push(...conversations.filter(matched)) } return matches @@ -1374,23 +1540,27 @@ export class ConversationService { } private async unbindConversation(conversationId: string) { - // Full table scan needed as minari doesn't support OR conditions in queries - const bindings = (await this.ctx.database.get( - 'chatluna_binding', - {} - )) as BindingRecord[] + const [active, last] = await Promise.all([ + this.ctx.database.get('chatluna_binding', { + activeConversationId: conversationId + }), + this.ctx.database.get('chatluna_binding', { + lastConversationId: conversationId + }) + ]) + const bindings = Array.from( + new Map( + [ + ...(active as BindingRecord[]), + ...(last as BindingRecord[]) + ].map((item) => [item.bindingKey, item]) + ).values() + ) for (const binding of bindings) { - if ( - binding.activeConversationId !== conversationId && - binding.lastConversationId !== conversationId - ) { - continue - } - await this.ctx.database.upsert('chatluna_binding', [ { - ...binding, + bindingKey: binding.bindingKey, activeConversationId: binding.activeConversationId === conversationId ? null @@ -1413,7 +1583,7 @@ export class ConversationService { return } - if (await isAdmin(session)) { + if (await checkAdmin(session)) { return } @@ -1432,7 +1602,7 @@ export class ConversationService { return true } - if (await isAdmin(session)) { + if (await checkAdmin(session)) { return true } @@ -1557,15 +1727,6 @@ function parseCompressionRecord(value?: string | null) { } } -async function isAdmin(session: Session) { - const s = session as Session - if (s.user?.authority != null) { - return s.user.authority >= 3 - } - - return false -} - function firstDefined( constraints: ConstraintRecord[], key: T @@ -1591,3 +1752,12 @@ function firstBoolean( } return fallback } + +function getLock(locks: Map, key: string) { + let lock = locks.get(key) + if (lock == null) { + lock = new ObjectLock() + locks.set(key, lock) + } + return lock +} diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts index 35b1a9fed..d483cc40c 100644 --- a/packages/core/src/services/conversation_runtime.ts +++ b/packages/core/src/services/conversation_runtime.ts @@ -18,7 +18,14 @@ import { type UsageMetadata } from '@langchain/core/messages' export class ConversationRuntime { readonly interfaces = new LRUCache({ - max: 20 + max: 20, + dispose: (value, key) => { + const [platform] = parseRawModelName(value.conversation.model) + if (platform != null) { + this.unregisterPlatformConversation(platform, key) + } + value.chatInterface.dispose?.() + } }) readonly modelQueue = new RequestIdQueue() @@ -37,6 +44,7 @@ export class ConversationRuntime { async ensureChatInterface(conversation: ConversationRecord) { const cached = this.interfaces.get(conversation.id) if (cached != null) { + cached.conversation = conversation return cached.chatInterface } @@ -63,6 +71,14 @@ export class ConversationRuntime { ): Promise { return this.withConversationAndPlatformLock(conversation, async () => { const [platform] = parseRawModelName(conversation.model) + if (platform == null) { + throw new ChatLunaError( + ChatLunaErrorCode.UNKNOWN_ERROR, + new Error( + `Invalid conversation model: ${conversation.model}` + ) + ) + } this.registerPlatformConversation(platform, conversation.id) const chatInterface = await this.ensureChatInterface(conversation) @@ -194,6 +210,12 @@ export class ConversationRuntime { const requestId = randomUUID() const modelRequestId = randomUUID() const [platform] = parseRawModelName(conversation.model) + if (platform == null) { + throw new ChatLunaError( + ChatLunaErrorCode.UNKNOWN_ERROR, + new Error(`Invalid conversation model: ${conversation.model}`) + ) + } const client = await this.platformService.getClient(platform) if (client.value == null) { @@ -397,7 +419,6 @@ export class ConversationRuntime { chatInterface: cached?.chatInterface } ) - cached?.chatInterface?.dispose?.() this.interfaces.delete(conversation.id) await this.service.ctx.root.parallel( 'chatluna/conversation-after-cache-clear', @@ -410,15 +431,9 @@ export class ConversationRuntime { } dispose(platform?: string) { - for (const controller of this.requestsById.values()) { - controller.abort( - new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) - ) - } - if (platform == null) { - for (const value of this.interfaces.values()) { - value.chatInterface.dispose?.() + for (const requestId of Array.from(this.requestsById.keys())) { + this.stopRequest(requestId) } this.interfaces.clear() this.requestsById.clear() @@ -433,10 +448,12 @@ export class ConversationRuntime { return } - for (const conversationId of conversationIds) { - this.interfaces.get(conversationId)?.chatInterface.dispose?.() + for (const conversationId of Array.from(conversationIds)) { + const active = this.activeByConversation.get(conversationId) + if (active != null) { + this.stopRequest(active.requestId) + } this.interfaces.delete(conversationId) - this.activeByConversation.delete(conversationId) } this.platformIndex.delete(platform) @@ -453,13 +470,6 @@ function formatUsageMetadataMessage(usage: UsageMetadata) { usage.input_token_details?.image > 0 ? [`image=${usage.input_token_details.image}`] : []), - ...(usage.input_token_details?.video != null && - usage.input_token_details?.video > 0 - ? [`video=${usage.input_token_details.video}`] - : []), - ...(usage.input_token_details?.document > 0 - ? [`document=${usage.input_token_details.document}`] - : []), ...(usage.input_token_details?.cache_read != null ? [`cache_read=${usage.input_token_details.cache_read}`] : []), @@ -468,20 +478,13 @@ function formatUsageMetadataMessage(usage: UsageMetadata) { : []) ] const output = [ - ...(usage.input_token_details?.audio != null && - usage.input_token_details?.audio > 0 - ? [`audio=${usage.input_token_details.audio}`] - : []), - ...(usage.input_token_details?.image != null && - usage.input_token_details?.image > 0 - ? [`image=${usage.input_token_details.image}`] - : []), - ...(usage.input_token_details?.video != null && - usage.input_token_details?.video > 0 - ? [`video=${usage.input_token_details.video}`] + ...(usage.output_token_details?.audio != null && + usage.output_token_details?.audio > 0 + ? [`audio=${usage.output_token_details.audio}`] : []), - ...(usage.input_token_details?.document > 0 - ? [`document=${usage.input_token_details.document}`] + ...(usage.output_token_details?.image != null && + usage.output_token_details?.image > 0 + ? [`image=${usage.output_token_details.image}`] : []), ...(usage.output_token_details?.reasoning != null ? [`reasoning=${usage.output_token_details.reasoning}`] @@ -498,4 +501,8 @@ function formatUsageMetadataMessage(usage: UsageMetadata) { ].join('\n') } -export { ChatEvents, RuntimeConversationEntry, ActiveRequest } from './types' +export type { + ChatEvents, + RuntimeConversationEntry, + ActiveRequest +} from './types' diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts index d5629df27..eb5de8ccc 100644 --- a/packages/core/src/services/conversation_types.ts +++ b/packages/core/src/services/conversation_types.ts @@ -164,23 +164,47 @@ export function computeBaseBindingKey( routeMode: RouteMode, routeKey?: string | null ): string { - const platform = session.platform ?? 'unknown' - const selfId = session.selfId ?? 'unknown' - const guildId = session.guildId ?? 'unknown' - const userId = session.userId ?? 'unknown' + const platform = session.platform + if (platform == null) { + throw new Error('Session platform is missing.') + } + + const selfId = session.selfId + if (selfId == null) { + throw new Error('Session selfId is missing.') + } if (routeMode === 'custom') { - return `custom:${routeKey ?? 'default'}` + if (routeKey == null || routeKey.length === 0) { + throw new Error('Custom route key is missing.') + } + + return `custom:${routeKey}` } if (routeMode === 'shared') { + const guildId = session.guildId ?? session.channelId + if (guildId == null) { + throw new Error('Shared conversation route requires guildId.') + } + return `shared:${platform}:${selfId}:${guildId}` } + const userId = session.userId + if (userId == null) { + throw new Error('Personal conversation route requires userId.') + } + if (session.isDirect) { return `personal:${platform}:${selfId}:direct:${userId}` } + const guildId = session.guildId ?? session.channelId + if (guildId == null) { + throw new Error('Personal conversation route requires guildId.') + } + return `personal:${platform}:${selfId}:${guildId}:${userId}` } diff --git a/packages/core/src/utils/archive.ts b/packages/core/src/utils/archive.ts index 7713d6b71..bbef7905f 100644 --- a/packages/core/src/utils/archive.ts +++ b/packages/core/src/utils/archive.ts @@ -25,18 +25,24 @@ export async function purgeArchivedConversation( }) } - const bindings = await ctx.database.get('chatluna_binding', {}) - for (const binding of bindings) { - if ( - binding.activeConversationId !== conversation.id && - binding.lastConversationId !== conversation.id - ) { - continue - } + const [active, last] = await Promise.all([ + ctx.database.get('chatluna_binding', { + activeConversationId: conversation.id + }), + ctx.database.get('chatluna_binding', { + lastConversationId: conversation.id + }) + ]) + const bindings = Array.from( + new Map( + [...active, ...last].map((binding) => [binding.bindingKey, binding]) + ).values() + ) + for (const binding of bindings) { await ctx.database.upsert('chatluna_binding', [ { - ...binding, + bindingKey: binding.bindingKey, activeConversationId: binding.activeConversationId === conversation.id ? null diff --git a/packages/core/src/utils/chat_request.ts b/packages/core/src/utils/chat_request.ts index b340c395b..102ee6ef0 100644 --- a/packages/core/src/utils/chat_request.ts +++ b/packages/core/src/utils/chat_request.ts @@ -4,7 +4,11 @@ import type { Session } from 'koishi' const requestIdCache = new Map() function getRequestCacheKey(session: Session, conversationId: string) { - return session.userId + '-' + (session.guildId ?? '') + '-' + conversationId + return JSON.stringify([ + session.userId, + session.guildId ?? '', + conversationId + ]) } export function getRequestId(session: Session, conversationId: string) { diff --git a/packages/core/src/utils/compression.ts b/packages/core/src/utils/compression.ts index 32d4f34fa..c61e7bbcb 100644 --- a/packages/core/src/utils/compression.ts +++ b/packages/core/src/utils/compression.ts @@ -18,13 +18,21 @@ export async function gzipEncode( encoding: T = 'buffer' as T ): Promise> { const result = await gzipAsync(data) - return (encoding === 'buffer' - ? result - : result.toString(encoding)) as BufferType + return ( + encoding === 'buffer' ? result : result.toString(encoding) + ) as BufferType } -export async function gzipDecode(data: ArrayBuffer | ArrayBufferView) { - const buffer = ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : Buffer.from(data) +export async function gzipDecode( + data: ArrayBuffer | ArrayBufferView | string, + encoding: 'base64' | 'hex' = 'base64' +) { + const buffer = + typeof data === 'string' + ? Buffer.from(data, encoding) + : ArrayBuffer.isView(data) + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) + : Buffer.from(data) return (await gunzipAsync(buffer)).toString() } diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index 60106f15a..deb519516 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -1,3 +1,5 @@ +import { logger } from 'koishi-plugin-chatluna' + // eslint-disable-next-line prefer-const export let ERROR_FORMAT_TEMPLATE = '使用 ChatLuna 时出现错误,错误码为 %s。请联系开发者以解决此问题。' @@ -18,17 +20,17 @@ export class ChatLunaError extends Error { this.name = 'ChatLunaError' if (!isTimeout) { - console.error( + logger?.error( '='.repeat(20) + 'ChatLunaError:' + errorCode + '='.repeat(20) ) } if (originError && !isTimeout) { - console.error(originError) + logger?.error(originError) if (originError.cause) { - console.error(originError.cause) + logger?.error(originError.cause) } } else if (!isTimeout) { - console.error(this) + logger?.error(this) } } diff --git a/packages/core/src/utils/koishi.ts b/packages/core/src/utils/koishi.ts index b6af413c8..214edfb96 100644 --- a/packages/core/src/utils/koishi.ts +++ b/packages/core/src/utils/koishi.ts @@ -3,6 +3,11 @@ import { PromiseLikeDisposable } from 'koishi-plugin-chatluna/utils/types' import { Marked, Token } from 'marked' import type { MessageContent } from '@langchain/core/messages' import { isMessageContentImageUrl } from 'koishi-plugin-chatluna/utils/langchain' +import { + isMessageContentAudio, + isMessageContentFileUrl, + isMessageContentVideo +} from './langchain' const marked = new Marked({ tokenizer: { @@ -29,11 +34,16 @@ export function forkScopeToDisposable(scope: ForkScope): PromiseLikeDisposable { } export async function checkAdmin(session: Session) { - const tested = await session.app.permissions.test('chatluna:admin', session) + try { + const tested = await session.app.permissions.test( + 'chatluna:admin', + session + ) - if (tested) { - return true - } + if (tested) { + return true + } + } catch {} const user = await session.getUser(session.userId, [ 'authority' @@ -175,7 +185,24 @@ export function transformMessageContentToElements(content: MessageContent) { : h.image(imageUrl.url) } - // TODO: support other message types (audio) + if (isMessageContentFileUrl(message)) { + return typeof message.file_url === 'string' + ? h.file(message.file_url) + : h.file(message.file_url.url) + } + + if (isMessageContentAudio(message)) { + return typeof message.audio_url === 'string' + ? h.audio(message.audio_url) + : h.audio(message.audio_url.url) + } + + if (isMessageContentVideo(message)) { + return typeof message.video_url === 'string' + ? h.video(message.video_url) + : h.video(message.video_url.url) + } + return h.text(message.text) }) } diff --git a/packages/core/src/utils/lock.ts b/packages/core/src/utils/lock.ts index 9f94f5cb0..813bd37f8 100644 --- a/packages/core/src/utils/lock.ts +++ b/packages/core/src/utils/lock.ts @@ -1,6 +1,7 @@ -const TIME_MINUTE = 60 * 1000 import { withResolver } from 'koishi-plugin-chatluna/utils/promise' +const TIME_MINUTE = 60 * 1000 + export class ObjectLock { private _lock: boolean = false private _queue: { diff --git a/packages/core/src/utils/model.ts b/packages/core/src/utils/model.ts index e9dbff1f4..9c8f60519 100644 --- a/packages/core/src/utils/model.ts +++ b/packages/core/src/utils/model.ts @@ -1,7 +1,20 @@ -export function parseRawModelName(modelName: string): [string, string] { +export function parseRawModelName( + modelName: string +): [string | undefined, string | undefined] { if (modelName == null || modelName.trim().length < 1) { return [undefined, undefined] } - return modelName.split(/(?<=^[^\/]+)\//) as [string, string] + const value = modelName.trim() + const index = value.indexOf('/') + + if (index === -1) { + return [undefined, value] + } + + if (index === 0 || index === value.length - 1) { + return [undefined, undefined] + } + + return [value.slice(0, index), value.slice(index + 1)] } diff --git a/packages/core/test/conversation-runtime.test.ts b/packages/core/test/conversation-runtime.test.ts index 6a3fe341d..85f0b0772 100644 --- a/packages/core/test/conversation-runtime.test.ts +++ b/packages/core/test/conversation-runtime.test.ts @@ -15,11 +15,11 @@ import { parsePresetLaneInput } from '../src/utils/message_content' import { - applyPresetLane, type ACLRecord, - computeBaseBindingKey, + applyPresetLane, type ArchiveRecord, type BindingRecord, + computeBaseBindingKey, type ConstraintRecord, type ConversationRecord, type MessageRecord @@ -48,7 +48,7 @@ type BindingSessionShape = { authority?: number } -type TableRow = Record +type TableRow = Record type Tables = Record class FakeDatabase { @@ -68,7 +68,7 @@ class FakeDatabase { chathub_conversation: [] } - async get(table: string, query: Record) { + async get(table: string, query: Record) { return (this.tables[table] ?? []).filter((row) => Object.entries(query).every(([key, expected]) => { const actual = row[key] @@ -78,7 +78,9 @@ class FakeDatabase { typeof expected === 'object' && '$in' in expected ) { - return expected.$in.includes(actual) + return Array.isArray(expected.$in) + ? expected.$in.includes(actual) + : false } if (Array.isArray(expected)) { @@ -109,7 +111,7 @@ class FakeDatabase { } } - async remove(table: string, query: Record) { + async remove(table: string, query: Record) { const target = (this.tables[table] ??= []) this.tables[table] = target.filter( (row) => @@ -175,7 +177,7 @@ function createSession(overrides: Partial = {}) { } as BindingSessionShape as never } -function createConfig(overrides: Record = {}) { +function createConfig(overrides: Record = {}) { return { defaultModel: 'test-platform/test-model', defaultPreset: 'default-preset', @@ -190,7 +192,7 @@ async function createService( tables?: Partial baseDir?: string clearCache?: (conversation: ConversationRecord) => Promise - config?: Record + config?: Record } = {} ) { const database = new FakeDatabase() @@ -397,9 +399,11 @@ test('pagination normalizes page and limit bounds', async () => { test('gzip helpers round-trip archived payload content', async () => { const json = JSON.stringify({ text: 'hello', values: [1, 2, 3] }) const compressed = await gzipEncode(json) + const base64 = await gzipEncode(json, 'base64') const arrayBuffer = bufferToArrayBuffer(compressed) assert.equal(await gzipDecode(arrayBuffer), json) + assert.equal(await gzipDecode(base64), json) }) test('getMessageContent flattens structured text parts', () => { @@ -1381,7 +1385,7 @@ test('purgeArchivedConversation removes archive directory and clears both bindin await purgeArchivedConversation(ctx, conversation) - assert.rejects(fs.access(archiveDir)) + await assert.rejects(fs.access(archiveDir)) assert.equal(database.tables.chatluna_conversation.length, 0) assert.equal(database.tables.chatluna_archive.length, 0) assert.equal(database.tables.chatluna_message.length, 0) diff --git a/packages/extension-agent/client/index.ts b/packages/extension-agent/client/index.ts index 7f2ce5ba4..395fbb9d0 100644 --- a/packages/extension-agent/client/index.ts +++ b/packages/extension-agent/client/index.ts @@ -1,6 +1,6 @@ import { Context } from '@koishijs/client' import type {} from 'koishi-plugin-chatluna-agent' -import Dashboard from './dashboard.vue' +import dashboard from './dashboard.vue' export default (ctx: Context) => { ctx.page({ @@ -8,6 +8,6 @@ export default (ctx: Context) => { path: '/chatluna-agent', fields: ['chatluna_agent_webui'], authority: 3, - component: Dashboard + component: dashboard }) } diff --git a/packages/extension-agent/src/cli/dispatch.ts b/packages/extension-agent/src/cli/dispatch.ts index e513bc657..244c0a025 100644 --- a/packages/extension-agent/src/cli/dispatch.ts +++ b/packages/extension-agent/src/cli/dispatch.ts @@ -2127,7 +2127,9 @@ function helpLines(args: string[] = []) { usage: ['agentcli sync [skills|subagents|all]'], notes: [ 'Sync reads files from the current remote computer session.', - 'Skill sync fans out to ChatLuna and compatibility directories such as .agents/skills, .openclaw/skills, .codex/skills, .claude/skills, and OpenCode skill roots.', + 'Skill sync fans out to ChatLuna and compatibility directories ' + + 'such as .agents/skills, .openclaw/skills, .codex/skills, ' + + '.claude/skills, and OpenCode skill roots.', 'Files are staged as a preview first; use `agentcli apply last` to write them locally.', 'If the preview shows overwrites, confirm with the user before applying it.' ], diff --git a/packages/extension-agent/src/cli/render.ts b/packages/extension-agent/src/cli/render.ts index ea169c57d..ef61256d1 100644 --- a/packages/extension-agent/src/cli/render.ts +++ b/packages/extension-agent/src/cli/render.ts @@ -74,15 +74,25 @@ export function renderAgentCliPrompt( ...indentCli([ 'Use the `agentcli` tool and pass full commands that start with `agentcli`.', 'For create, update, or config work on skills, sub-agents, tools, or MCP, run the activation sweep first.', - 'Activation sweep: `agentcli show skills`, `agentcli show subagents`, `agentcli show tools`, `agentcli show mcp servers`, `agentcli show mcp tools`.', + 'Activation sweep: `agentcli show skills`, `agentcli show subagents`, ' + + '`agentcli show tools`, `agentcli show mcp servers`, ' + + '`agentcli show mcp tools`.', 'Use local ChatLuna paths as the source of truth. Do not replace them with your own machine paths.', backend === 'local' - ? 'The current default computer backend is local, so write skills and sub-agents directly to the local ChatLuna paths.' - : 'The current default computer backend is not local, so write files in the sandbox paths first, create missing directories there when needed, and finish with `agentcli sync`.', + ? 'The current default computer backend is local, so write skills ' + + 'and sub-agents directly to the local ChatLuna paths.' + : 'The current default computer backend is not local, so write ' + + 'files in the sandbox paths first, create missing directories ' + + 'there when needed, and finish with `agentcli sync`.', 'Then inspect the exact target with `agentcli show ...`.', - 'Stage changes with `agentcli preview ...`; repeated preview commands append until apply or cancel, many named preview commands accept multiple targets in one call, and tool authority uses Koishi levels 0-5.', + 'Stage changes with `agentcli preview ...`; repeated preview commands ' + + 'append until apply or cancel, many named preview commands accept ' + + 'multiple targets in one call, and tool authority uses Koishi ' + + 'levels 0-5.', 'Load `skill-creator` or `sub-agent-creator` before authoring those files because `agentcli` cannot create them directly.', - 'Command chains support `&`, `&&`, `|`, `|&`, `||`, and `;` inside the `command` string. Pipe operators only separate agentcli calls; they do not stream stdin.', + 'Command chains support `&`, `&&`, `|`, `|&`, `||`, and `;` inside ' + + 'the `command` string. Pipe operators only separate agentcli ' + + 'calls; they do not stream stdin.', 'Use `agentcli sync` to bring sandbox skills and sub-agents back to local paths.', 'If a sync preview shows overwrites, wait for the user to confirm before `agentcli apply last`.', 'Commit with `agentcli apply last` or discard with `agentcli cancel pending`.', diff --git a/packages/extension-agent/src/computer/backends/local/store.ts b/packages/extension-agent/src/computer/backends/local/store.ts index 2f7e2e813..9dfb2104e 100644 --- a/packages/extension-agent/src/computer/backends/local/store.ts +++ b/packages/extension-agent/src/computer/backends/local/store.ts @@ -463,7 +463,9 @@ function replaceSubstring( ) if (secondIdx !== -1) { throw new Error( - 'Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match, or set replaceAll to change every instance.' + 'Found multiple matches for oldString. Provide more surrounding ' + + 'lines in oldString to identify the correct match, or set ' + + 'replaceAll to change every instance.' ) } } diff --git a/packages/extension-agent/src/computer/tools/bash.ts b/packages/extension-agent/src/computer/tools/bash.ts index e9c320464..8ad8434fc 100644 --- a/packages/extension-agent/src/computer/tools/bash.ts +++ b/packages/extension-agent/src/computer/tools/bash.ts @@ -53,7 +53,11 @@ When to use: .boolean() .optional() .describe( - 'Run the command as a managed background job only when strictly necessary for a long-lived process such as a server. Do not use this in normal cases. Commands ending with & are also treated as background jobs automatically.' + 'Run the command as a managed background job only when ' + + 'strictly necessary for a long-lived process such as a ' + + 'server. Do not use this in normal cases. Commands ' + + 'ending with & are also treated as background jobs ' + + 'automatically.' ), action: z .enum(['run', 'status', 'list', 'kill']) diff --git a/packages/extension-agent/src/config/defaults.ts b/packages/extension-agent/src/config/defaults.ts index 9c982622b..6aac2c359 100644 --- a/packages/extension-agent/src/config/defaults.ts +++ b/packages/extension-agent/src/config/defaults.ts @@ -3,11 +3,11 @@ import { AgentConfig, ComputerConfig, + createToolMetaOverride, PermissionRule, SkillConfig, SubAgentConfig, SubAgentItemConfig, - createToolMetaOverride, ToolConfig, ToolItemConfig } from '../types' @@ -276,7 +276,11 @@ export function createDefaultSubAgentConfig(): SubAgentConfig { enabled: false, name: 'plan', description: - 'Read-only architect agent for designing implementation plans. Use when you need to analyze code, assess impact, identify constraints, and produce step-by-step plans before making changes. Returns structured plans with file paths, key changes, and risk assessment.', + 'Read-only architect agent for designing implementation ' + + 'plans. Use when you need to analyze code, assess impact, ' + + 'identify constraints, and produce step-by-step plans ' + + 'before making changes. Returns structured plans with file ' + + 'paths, key changes, and risk assessment.', source: 'builtin', format: 'chatluna' }), @@ -284,7 +288,11 @@ export function createDefaultSubAgentConfig(): SubAgentConfig { enabled: false, name: 'general', description: - 'Full-capability development agent for multi-step implementation tasks. Use when you need to read code, make changes across files, run builds or tests, and report results. Has access to file_read, file_write, file_edit, grep, glob, and bash.', + 'Full-capability development agent for multi-step ' + + 'implementation tasks. Use when you need to read code, ' + + 'make changes across files, run builds or tests, and ' + + 'report results. Has access to file_read, file_write, ' + + 'file_edit, grep, glob, and bash.', source: 'builtin', format: 'chatluna' }), @@ -292,7 +300,10 @@ export function createDefaultSubAgentConfig(): SubAgentConfig { enabled: false, name: 'explore', description: - 'Fast read-only search agent for codebase exploration. Use when you need to quickly find files, search for symbols, trace imports, or gather context. Returns precise file paths, line numbers, and code snippets.', + 'Fast read-only search agent for codebase exploration. ' + + 'Use when you need to quickly find files, search for ' + + 'symbols, trace imports, or gather context. Returns ' + + 'precise file paths, line numbers, and code snippets.', source: 'builtin', format: 'chatluna' }) diff --git a/packages/extension-agent/src/service/computer.ts b/packages/extension-agent/src/service/computer.ts index 5f54a911a..40f871d23 100644 --- a/packages/extension-agent/src/service/computer.ts +++ b/packages/extension-agent/src/service/computer.ts @@ -833,8 +833,9 @@ export class ChatLunaAgentComputerService { throw new Error(`Refusing to remove unsafe path: ${path}`) } + const quoted = quoteShellPath(target) const result = await session.execute( - `if [ -d ${quoteShellPath(target)} ]; then rm -rf ${quoteShellPath(target)}; elif [ -e ${quoteShellPath(target)} ]; then rm -f ${quoteShellPath(target)}; fi`, + `if [ -d ${quoted} ]; then rm -rf ${quoted}; elif [ -e ${quoted} ]; then rm -f ${quoted}; fi`, { timeout: 15000 } diff --git a/packages/extension-agent/src/service/index.ts b/packages/extension-agent/src/service/index.ts index 356ddd1da..cba4f77a2 100644 --- a/packages/extension-agent/src/service/index.ts +++ b/packages/extension-agent/src/service/index.ts @@ -386,7 +386,8 @@ export class ChatLunaAgentService extends Service { promptContent: input.promptContent, chatluna: input.chatluna ?? info.chatlunaEnabled, character: input.character ?? info.characterEnabled, - characterGroup: input.characterGroup ?? info.characterGroupEnabled, + characterGroup: + input.characterGroup ?? info.characterGroupEnabled, characterPrivate: input.characterPrivate ?? info.characterPrivateEnabled, characterGroupMode: diff --git a/packages/extension-agent/src/service/permissions.ts b/packages/extension-agent/src/service/permissions.ts index 486e1d97d..51e3f7699 100644 --- a/packages/extension-agent/src/service/permissions.ts +++ b/packages/extension-agent/src/service/permissions.ts @@ -10,11 +10,11 @@ import { import { AgentConfig, ComputerBackendType, + createToolDefaultAvailability, PermissionRule, SkillInfo, SubAgentInfo, SubAgentPermissionConfig, - createToolDefaultAvailability, ToolAvailabilityInfo, ToolInfo, ToolStatus diff --git a/packages/extension-agent/src/service/skills.ts b/packages/extension-agent/src/service/skills.ts index b5a674ca6..8af5283ea 100644 --- a/packages/extension-agent/src/service/skills.ts +++ b/packages/extension-agent/src/service/skills.ts @@ -92,12 +92,6 @@ export class ChatLunaAgentSkillsService implements SkillToolService { ctx.on('chatluna/conversation-after-clear-history', async (payload) => { clear(payload.conversation.id) }) - ctx.on('chatluna/conversation-after-archive', async (payload) => { - clear(payload.conversation.id) - }) - ctx.on('chatluna/conversation-after-restore', async (payload) => { - clear(payload.conversation.id) - }) ctx.on('chatluna/conversation-after-delete', async (payload) => { clear(payload.conversation.id) }) diff --git a/packages/extension-agent/src/service/sub_agent.ts b/packages/extension-agent/src/service/sub_agent.ts index f87058592..0d3b3c71b 100644 --- a/packages/extension-agent/src/service/sub_agent.ts +++ b/packages/extension-agent/src/service/sub_agent.ts @@ -2,7 +2,6 @@ import { Context } from 'koishi' import { - type AgentTaskResolveContext, type AgentTaskToolRuntime, createTaskTool, renderAvailableAgents, diff --git a/packages/extension-agent/src/skills/scan.ts b/packages/extension-agent/src/skills/scan.ts index d29494ea5..850a4904e 100644 --- a/packages/extension-agent/src/skills/scan.ts +++ b/packages/extension-agent/src/skills/scan.ts @@ -203,7 +203,13 @@ export async function listRemoteSkillResources( dir: string ) { const result = await session.execute( - `root=${quoteShellPath(dir)} && [ -d "$root" ] && find "$root" -type f ! -name SKILL.md -print | awk -v root="$root" 'index($0, root "/") == 1 { print substr($0, length(root) + 2) }' || true`, + [ + `root=${quoteShellPath(dir)}`, + '&& [ -d "$root" ]', + '&& find "$root" -type f ! -name SKILL.md -print', + `| awk -v root="$root" 'index($0, root "/") == 1 { print substr($0, length(root) + 2) }'`, + '|| true' + ].join(' '), { timeout: 10000 } @@ -667,7 +673,21 @@ async function checkAvailability( if (diagnostics.length && install?.length) { diagnostics.push( - `Install options: ${install.map((item) => item.label || [item.kind, item.formula, item.package, item.url, item.id].filter(Boolean).join(': ')).join('; ')}` + `Install options: ${install + .map( + (item) => + item.label || + [ + item.kind, + item.formula, + item.package, + item.url, + item.id + ] + .filter(Boolean) + .join(': ') + ) + .join('; ')}` ) } diff --git a/packages/extension-agent/src/sub-agent/builtin.ts b/packages/extension-agent/src/sub-agent/builtin.ts index deedee437..b8c50c5f0 100644 --- a/packages/extension-agent/src/sub-agent/builtin.ts +++ b/packages/extension-agent/src/sub-agent/builtin.ts @@ -50,42 +50,65 @@ function createBuiltin( // Builtin agent system prompts // --------------------------------------------------------------------------- -const PLAN_PROMPT = `You are the Plan sub-agent. Your job is to analyze code and come up with implementation plans. You can only read, not write. - -Tools you have: -file_read — read file contents or list a directory. Use offset/limit for large files. -grep — search file contents with regex. Use include patterns to narrow things down (e.g. "*.ts"). -glob — find files by pattern (e.g. "src/**/*.ts"). - -You don't have write access. Don't try to use file_write, file_edit, or bash. - -Start by understanding what's being asked, then go read the relevant code, search for related logic, and trace through call chains and constraints. Once you have a clear picture, put together a concrete plan covering which files need to change, what the key code changes look like, any compatibility concerns, and how to test it. - -Be specific — include file paths, function names, and the order things should happen in. If something is unclear or needs a human decision, say so.` - -const GENERAL_PROMPT = `You are the General sub-agent. You handle implementation tasks from start to finish. - -Tools you have: -file_read — read file contents or list a directory. Use offset/limit for large files. -file_write — create or overwrite files. Parent directories are created automatically. -file_edit — make targeted replacements in existing files. Use this over file_write for small changes. -grep — search file contents with regex. -glob — find files by pattern. -bash — run shell commands for building, testing, scripts, git, etc. - -Read the relevant code first to understand context before changing anything. For small edits use file_edit; only use file_write for new files or full rewrites. After making changes, run the build or tests to make sure things still work, then summarize what you changed and why. - -Always read a file before editing it — don't guess at contents. Keep changes focused and don't refactor unrelated code. If the task touches many files, work through them one at a time. Use safe shell commands; avoid rm -rf or force operations unless you're told to.` - -const EXPLORE_PROMPT = `You are the Explore sub-agent. You quickly gather information from the codebase. You can only read, not write. - -Tools you have: -file_read — read file contents or list a directory. Use offset/limit for large files. -grep — search file contents with regex. Use include patterns to narrow things down. -glob — find files by pattern. - -You don't have write access. Don't try to use file_write, file_edit, or bash. - -Start with broad searches using glob and grep to find relevant files, then read them selectively. Follow imports, call sites, and type definitions to piece together how things connect. - -Give back exact file paths, line numbers, and code snippets. Stick to facts and don't speculate. If there are multiple possible interpretations, lay them all out with the evidence for each.` +const PLAN_PROMPT = [ + 'You are the Plan sub-agent. Your job is to analyze code and come up with implementation plans. You can only read, not write.', + '', + 'Tools you have:', + 'file_read — read file contents or list a directory. Use offset/limit for large files.', + 'grep — search file contents with regex. Use include patterns to narrow things down (e.g. "*.ts").', + 'glob — find files by pattern (e.g. "src/**/*.ts").', + '', + "You don't have write access. Don't try to use file_write, file_edit, or bash.", + '', + "Start by understanding what's being asked, then go read the relevant code, " + + 'search for related logic, and trace through call chains and constraints. ' + + 'Once you have a clear picture, put together a concrete plan covering ' + + 'which files need to change, what the key code changes look like, any ' + + 'compatibility concerns, and how to test it.', + '', + 'Be specific — include file paths, function names, and the order things ' + + 'should happen in. If something is unclear or needs a human decision, ' + + 'say so.' +].join('\n') + +const GENERAL_PROMPT = [ + 'You are the General sub-agent. You handle implementation tasks from start to finish.', + '', + 'Tools you have:', + 'file_read — read file contents or list a directory. Use offset/limit for large files.', + 'file_write — create or overwrite files. Parent directories are created automatically.', + 'file_edit — make targeted replacements in existing files. Use this over file_write for small changes.', + 'grep — search file contents with regex.', + 'glob — find files by pattern.', + 'bash — run shell commands for building, testing, scripts, git, etc.', + '', + 'Read the relevant code first to understand context before changing ' + + 'anything. For small edits use file_edit; only use file_write for new ' + + 'files or full rewrites. After making changes, run the build or tests ' + + 'to make sure things still work, then summarize what you changed and ' + + 'why.', + '', + "Always read a file before editing it — don't guess at contents. Keep " + + "changes focused and don't refactor unrelated code. If the task " + + 'touches many files, work through them one at a time. Use safe shell ' + + "commands; avoid rm -rf or force operations unless you're told to." +].join('\n') + +const EXPLORE_PROMPT = [ + 'You are the Explore sub-agent. You quickly gather information from the codebase. You can only read, not write.', + '', + 'Tools you have:', + 'file_read — read file contents or list a directory. Use offset/limit for large files.', + 'grep — search file contents with regex. Use include patterns to narrow things down.', + 'glob — find files by pattern.', + '', + "You don't have write access. Don't try to use file_write, file_edit, or bash.", + '', + 'Start with broad searches using glob and grep to find relevant files, then ' + + 'read them selectively. Follow imports, call sites, and type ' + + 'definitions to piece together how things connect.', + '', + 'Give back exact file paths, line numbers, and code snippets. Stick to ' + + "facts and don't speculate. If there are multiple possible " + + 'interpretations, lay them all out with the evidence for each.' +].join('\n') diff --git a/packages/extension-agent/src/sub-agent/render.ts b/packages/extension-agent/src/sub-agent/render.ts index a92ad031a..2d53410a0 100644 --- a/packages/extension-agent/src/sub-agent/render.ts +++ b/packages/extension-agent/src/sub-agent/render.ts @@ -1,12 +1,10 @@ /** @module sub-agent/render */ -import { SystemMessage } from '@langchain/core/messages' import { renderAvailableAgents, SubagentContext } from 'koishi-plugin-chatluna/llm-core/agent' import { SubAgentInfo } from '../types' -import { escapeXml } from '../utils/xml' export function renderAvailableSubAgents( agents: SubAgentInfo[], diff --git a/packages/extension-agent/src/sub-agent/run.ts b/packages/extension-agent/src/sub-agent/run.ts index c222d3c81..c13a98d19 100644 --- a/packages/extension-agent/src/sub-agent/run.ts +++ b/packages/extension-agent/src/sub-agent/run.ts @@ -2,10 +2,10 @@ import { HumanMessage } from '@langchain/core/messages' import { - createAgentTool, type AgentGenerateOptions, type AgentToolOptions, type ChatLunaAgent, + createAgentTool, type ToolMask } from 'koishi-plugin-chatluna/llm-core/agent' import { diff --git a/packages/extension-agent/src/sub-agent/session.ts b/packages/extension-agent/src/sub-agent/session.ts index 7ff1d6ed3..b0f242092 100644 --- a/packages/extension-agent/src/sub-agent/session.ts +++ b/packages/extension-agent/src/sub-agent/session.ts @@ -94,7 +94,8 @@ export function formatTaskResult( `agent: ${task.agentName}`, `run_id: ${run.runId}`, `state: ${run.state}`, - `resume_hint: use task with {"action":"run","id":"${task.id}","prompt":"next instruction"} to continue this session. Add "background":true when the work may take a while.`, + `resume_hint: use task with {"action":"run","id":"${task.id}","prompt":"next instruction"} ` + + 'to continue this session. Add "background":true when the work may take a while.', '', output.trim() || '(empty)' ].join('\n') @@ -184,7 +185,8 @@ export function formatTaskDetail( if (run?.state !== 'running') { lines.push( - `resume_hint: use task with {"action":"run","id":"${task.id}","prompt":"next instruction"} to continue this session. Add "background":true when the work may take a while.` + `resume_hint: use task with {"action":"run","id":"${task.id}","prompt":"next instruction"} ` + + 'to continue this session. Add "background":true when the work may take a while.' ) } diff --git a/packages/extension-agent/src/sub-agent/tool.ts b/packages/extension-agent/src/sub-agent/tool.ts index 3b14f7d0d..a626667a7 100644 --- a/packages/extension-agent/src/sub-agent/tool.ts +++ b/packages/extension-agent/src/sub-agent/tool.ts @@ -14,13 +14,17 @@ export class TaskTool extends StructuredTool { .enum(['run', 'status', 'list', 'message']) .optional() .describe( - 'run starts or resumes a sub-agent task, status inspects one task, list shows recent tasks in this conversation, message sends live guidance to a running background task.' + 'run starts or resumes a sub-agent task, status inspects ' + + 'one task, list shows recent tasks in this conversation, ' + + 'message sends live guidance to a running background task.' ), agent: z .string() .optional() .describe( - 'The exact sub-agent name from the injected sub-agent catalog. Required when starting a new task. Optional when resuming an existing task by id.' + 'The exact sub-agent name from the injected sub-agent catalog. ' + + 'Required when starting a new task. Optional when ' + + 'resuming an existing task by id.' ), id: z .string() diff --git a/packages/extension-long-memory/src/plugins/edit_memory.ts b/packages/extension-long-memory/src/plugins/edit_memory.ts index 5363ee62a..89dbdd116 100644 --- a/packages/extension-long-memory/src/plugins/edit_memory.ts +++ b/packages/extension-long-memory/src/plugins/edit_memory.ts @@ -44,8 +44,6 @@ export function apply(ctx: Context, config: Config) { try { await session.send(session.text('.edit_memory_start')) - const content = await session.prompt() - const scope = await getMemoryScope(ctx, session, { conversationId, presetLane, @@ -57,6 +55,8 @@ export function apply(ctx: Context, config: Config) { return ChainMiddlewareRunStatus.STOP } + const content = await session.prompt() + const layers = await ctx.chatluna_long_memory.initMemoryLayers( { From ba4727473f14e179d18b138029b6afaf4ed502b6 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 02:20:51 +0800 Subject: [PATCH 04/20] fix(core): move command helper out of auto imports Keep the conversation target helper out of the generated commands entry so process-dynamic-import no longer treats it as a command module that must export apply. --- packages/core/src/commands/chat.ts | 2 +- packages/core/src/commands/conversation.ts | 2 +- packages/core/src/{commands/utils.ts => utils/conversation.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/core/src/{commands/utils.ts => utils/conversation.ts} (100%) diff --git a/packages/core/src/commands/chat.ts b/packages/core/src/commands/chat.ts index c6f4aa176..75b5303d4 100644 --- a/packages/core/src/commands/chat.ts +++ b/packages/core/src/commands/chat.ts @@ -2,7 +2,7 @@ import { Context, h } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' import { RenderType } from '../types' -import { completeConversationTarget } from './utils' +import { completeConversationTarget } from '../utils/conversation' export function apply(ctx: Context, config: Config, chain: ChatChain) { ctx.command('chatluna', { diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index 040475b54..cce2e76e0 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -1,7 +1,7 @@ import { Context } from 'koishi' import { Config } from '../config' import { ChatChain } from '../chains/chain' -import { completeConversationTarget } from './utils' +import { completeConversationTarget } from '../utils/conversation' export function apply(ctx: Context, _config: Config, chain: ChatChain) { ctx.command('chatluna.conversation', { diff --git a/packages/core/src/commands/utils.ts b/packages/core/src/utils/conversation.ts similarity index 100% rename from packages/core/src/commands/utils.ts rename to packages/core/src/utils/conversation.ts From a6621df0506602f2b25d1626a1dade8f62a44acd Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 04:17:26 +0800 Subject: [PATCH 05/20] test(core): migrate conversation suite to mocha specs Align the core conversation tests with Koishi's Mocha-based setup so they run cleanly in the current CJS test environment. Split the suite by concern and include the runtime/source fixes needed for the new harness. --- packages/core/package.json | 14 +- .../src/llm-core/agent/react/output_parser.ts | 2 +- .../core/src/services/conversation_runtime.ts | 67 +- .../core/tests/conversation-archive.spec.ts | 217 ++++++ packages/core/tests/conversation-e2e.spec.ts | 58 ++ .../core/tests/conversation-migration.spec.ts | 173 +++++ .../core/tests/conversation-runtime.spec.ts | 243 ++++++ .../core/tests/conversation-service.spec.ts | 720 ++++++++++++++++++ .../core/tests/conversation-source.spec.ts | 58 ++ .../core/tests/conversation-utils.spec.ts | 157 ++++ packages/core/tests/helpers.ts | 422 ++++++++++ packages/core/tests/tsconfig.json | 9 + 12 files changed, 2120 insertions(+), 20 deletions(-) create mode 100644 packages/core/tests/conversation-archive.spec.ts create mode 100644 packages/core/tests/conversation-e2e.spec.ts create mode 100644 packages/core/tests/conversation-migration.spec.ts create mode 100644 packages/core/tests/conversation-runtime.spec.ts create mode 100644 packages/core/tests/conversation-service.spec.ts create mode 100644 packages/core/tests/conversation-source.spec.ts create mode 100644 packages/core/tests/conversation-utils.spec.ts create mode 100644 packages/core/tests/helpers.ts create mode 100644 packages/core/tests/tsconfig.json diff --git a/packages/core/package.json b/packages/core/package.json index 7250fcc68..89f715f33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -226,7 +226,7 @@ }, "scripts": { "build": "atsc -b", - "test": "node --import tsx --test test/**/*.test.ts" + "test": "yarn exec cross-env TS_NODE_PROJECT=tests/tsconfig.json mocha -r tsconfig-paths/register -r esbuild-register -r yml-register --exit \"tests/**/*.spec.ts\"" }, "engines": { "node": ">=18.0.0" @@ -266,13 +266,23 @@ "@koishijs/cache": "^2.1.0", "@koishijs/censor": "^1.1.0", "@koishijs/plugin-adapter-qq": "^4.10.1", + "@koishijs/plugin-database-memory": "^3.7.0", "@koishijs/plugin-notifier": "^1.2.1", + "@types/chai": "^4.3.20", "@types/he": "^1.2.3", "@types/js-yaml": "^4.0.9", + "@types/mocha": "^10.0.10", "@types/qrcode": "^1.5.5", "@types/useragent": "^2", "atsc": "^2.1.0", - "koishi-plugin-adapter-onebot": "^6.8.0" + "chai": "^4.4.1", + "cross-env": "^7.0.3", + "esbuild": "^0.25.10", + "esbuild-register": "npm:@shigma/esbuild-register@^1.1.1", + "koishi-plugin-adapter-onebot": "^6.8.0", + "mocha": "^9.2.2", + "tsconfig-paths": "^4.2.0", + "yml-register": "^1.2.5" }, "peerDependencies": { "koishi": "^4.18.9", diff --git a/packages/core/src/llm-core/agent/react/output_parser.ts b/packages/core/src/llm-core/agent/react/output_parser.ts index c1cd6f42c..7b2033ab5 100644 --- a/packages/core/src/llm-core/agent/react/output_parser.ts +++ b/packages/core/src/llm-core/agent/react/output_parser.ts @@ -4,7 +4,7 @@ import { BaseOutputParser, OutputParserException } from '@langchain/core/output_parsers' -import { FORMAT_INSTRUCTIONS } from './prompt.js' +import { FORMAT_INSTRUCTIONS } from './prompt' /** * Parses ReAct-style LLM calls that support multiple tool inputs using XML tags. diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts index d483cc40c..8c48414ca 100644 --- a/packages/core/src/services/conversation_runtime.ts +++ b/packages/core/src/services/conversation_runtime.ts @@ -170,6 +170,7 @@ export class ConversationRuntime { return { content: aiMessage.content as string, + additional_kwargs: aiMessage.additional_kwargs, additionalReplyMessages } } finally { @@ -203,6 +204,21 @@ export class ConversationRuntime { } } + async withConversationSync( + conversation: ConversationRecord, + callback: () => Promise + ): Promise { + const requestId = randomUUID() + try { + await this.conversationQueue.add(conversation.id, requestId) + this.stopConversationRequest(conversation.id) + await this.conversationQueue.wait(conversation.id, requestId, 0) + return await callback() + } finally { + await this.conversationQueue.remove(conversation.id, requestId) + } + } + async withConversationAndPlatformLock( conversation: ConversationRecord, callback: () => Promise @@ -328,6 +344,15 @@ export class ConversationRuntime { return true } + stopConversationRequest(conversationId: string) { + const activeRequest = this.activeByConversation.get(conversationId) + if (activeRequest == null) { + return false + } + + return this.stopRequest(activeRequest.requestId) + } + getRequestIdBySession(session: Session) { if (session.sid == null) { return undefined @@ -408,25 +433,29 @@ export class ConversationRuntime { }) } + async clearConversationInterfaceLocked(conversation: ConversationRecord) { + const cached = this.interfaces.get(conversation.id) + const existed = cached != null + await this.service.ctx.root.parallel( + 'chatluna/conversation-before-cache-clear', + { + conversation, + chatInterface: cached?.chatInterface + } + ) + this.interfaces.delete(conversation.id) + await this.service.ctx.root.parallel( + 'chatluna/conversation-after-cache-clear', + { + conversation + } + ) + return existed + } + async clearConversationInterface(conversation: ConversationRecord) { return this.withConversationLock(conversation.id, async () => { - const cached = this.interfaces.get(conversation.id) - const existed = cached != null - await this.service.ctx.root.parallel( - 'chatluna/conversation-before-cache-clear', - { - conversation, - chatInterface: cached?.chatInterface - } - ) - this.interfaces.delete(conversation.id) - await this.service.ctx.root.parallel( - 'chatluna/conversation-after-cache-clear', - { - conversation - } - ) - return existed + return this.clearConversationInterfaceLocked(conversation) }) } @@ -452,6 +481,10 @@ export class ConversationRuntime { const active = this.activeByConversation.get(conversationId) if (active != null) { this.stopRequest(active.requestId) + this.activeByConversation.delete(conversationId) + if (active.sessionId != null) { + this.requestBySession.delete(active.sessionId) + } } this.interfaces.delete(conversationId) } diff --git a/packages/core/tests/conversation-archive.spec.ts b/packages/core/tests/conversation-archive.spec.ts new file mode 100644 index 000000000..9ca25efa2 --- /dev/null +++ b/packages/core/tests/conversation-archive.spec.ts @@ -0,0 +1,217 @@ +/// + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { assert } from 'chai' +import { purgeArchivedConversation } from '../src/utils/archive' +import type { + ArchiveRecord, + BindingRecord, + ConversationRecord +} from '../src/services/conversation_types' +import { + createConversation, + createMessage, + createService, + createSession, + expectRejected, + type TableRow +} from './helpers' + +it('ConversationService exports, archives, and restores conversations with legacy migration fields', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-archive-test-') + ) + const exportPath = path.join(tempDir, 'conversation-export.md') + + const conversation = createConversation({ + id: 'conversation-archive', + title: 'Archived Conversation', + latestMessageId: 'message-2', + legacyRoomId: 42, + legacyMeta: JSON.stringify({ roomName: 'legacy-room' }) + }) + const messageA = createMessage({ + id: 'message-1', + conversationId: 'conversation-archive', + text: 'hello' + }) + const messageB = createMessage({ + id: 'message-2', + conversationId: 'conversation-archive', + parentId: 'message-1', + role: 'ai', + text: 'world' + }) + + const { service, database, clearCacheCalls } = await createService({ + baseDir: tempDir, + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-archive', + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_message: [ + messageA as unknown as TableRow, + messageB as unknown as TableRow + ] + } + }) + + const exported = await service.exportConversation(createSession(), { + conversationId: 'conversation-archive', + outputPath: exportPath + }) + const exportMarkdown = await fs.readFile(exported.path, 'utf8') + + assert.match(exportMarkdown, /# Archived Conversation/) + assert.match(exportMarkdown, /hello/) + assert.match(exportMarkdown, /world/) + + const archived = await service.archiveConversation(createSession(), { + conversationId: 'conversation-archive' + }) + const archivedConversation = await service.getConversation( + 'conversation-archive' + ) + const archiveRecord = archived.archive as ArchiveRecord + const manifest = JSON.parse( + await fs.readFile(path.join(archived.path, 'manifest.json'), 'utf8') + ) + + assert.equal(archivedConversation.status, 'archived') + assert.equal(archivedConversation.archiveId, archiveRecord.id) + assert.equal(manifest.conversationId, 'conversation-archive') + assert.equal(database.tables.chatluna_message.length, 0) + assert.deepEqual(clearCacheCalls, ['conversation-archive']) + + const restored = await service.restoreConversation(createSession(), { + conversationId: 'conversation-archive' + }) + const restoredMessages = await service.listMessages('conversation-archive') + const restoredArchive = await service.getArchive(archiveRecord.id) + + assert.equal(restored.status, 'active') + assert.equal(restored.archiveId, null) + assert.equal(restored.legacyRoomId, 42) + assert.equal( + restored.legacyMeta, + JSON.stringify({ roomName: 'legacy-room' }) + ) + assert.equal(restoredMessages.length, 2) + assert.equal(restoredArchive.state, 'ready') + assert.notEqual(restoredArchive.restoredAt, null) +}) + +it('ConversationService rejects restoring archives from another conversation', async () => { + const conversation = createConversation({ + id: 'conversation-restore-owner' + }) + const foreignArchive: ArchiveRecord = { + id: 'archive-foreign', + conversationId: 'conversation-foreign', + path: path.join(os.tmpdir(), 'archive-foreign'), + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 0, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + + const { service } = await createService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ], + chatluna_archive: [foreignArchive as unknown as TableRow] + } + }) + + await expectRejected( + service.restoreConversation(createSession(), { + conversationId: conversation.id, + archiveId: foreignArchive.id + }), + /Archive does not belong to conversation\./ + ) +}) + +it('purgeArchivedConversation removes archive directory and clears both binding pointers', async () => { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-purge-archive-') + ) + const archiveDir = path.join(dir, 'archive-dir') + await fs.mkdir(archiveDir, { recursive: true }) + await fs.writeFile(path.join(archiveDir, 'manifest.json'), '{}', 'utf8') + + const conversation = createConversation({ + id: 'conversation-purge', + status: 'archived', + archiveId: 'archive-purge' + }) + const { ctx, database } = await createService({ + baseDir: dir, + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: conversation.id, + updatedAt: new Date() + } + ], + chatluna_archive: [ + { + id: 'archive-purge', + conversationId: conversation.id, + path: archiveDir, + formatVersion: 1, + messageCount: 1, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + ], + chatluna_message: [ + createMessage({ + conversationId: conversation.id + }) as unknown as TableRow + ], + chatluna_acl: [ + { + conversationId: conversation.id, + principalType: 'user', + principalId: 'user', + permission: 'view' + } as unknown as TableRow + ] + } + }) + + await purgeArchivedConversation(ctx, conversation) + + await expectRejected(fs.access(archiveDir)) + assert.equal(database.tables.chatluna_conversation.length, 0) + assert.equal(database.tables.chatluna_archive.length, 0) + assert.equal(database.tables.chatluna_message.length, 0) + assert.equal(database.tables.chatluna_acl.length, 0) + assert.equal(database.tables.chatluna_binding[0].activeConversationId, null) + assert.equal(database.tables.chatluna_binding[0].lastConversationId, null) +}) diff --git a/packages/core/tests/conversation-e2e.spec.ts b/packages/core/tests/conversation-e2e.spec.ts new file mode 100644 index 000000000..8851d2740 --- /dev/null +++ b/packages/core/tests/conversation-e2e.spec.ts @@ -0,0 +1,58 @@ +/// + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { assert } from 'chai' +import { createMemoryService, createSession } from './helpers' + +it('ConversationService supports sampled end-to-end lifecycle flow', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-e2e-flow-')) + const { app, database, service } = await createMemoryService({ + baseDir: dir + }) + + try { + const session = createSession() + const created = await service.ensureActiveConversation(session, { + presetLane: 'helper' + }) + const listed = await service.listConversations(session, { + presetLane: 'helper' + }) + const renamed = await service.renameConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper', + title: 'Helper Session' + }) + const exported = await service.exportConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const archived = await service.archiveConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const restored = await service.restoreConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const removed = await service.deleteConversation(session, { + conversationId: created.conversation.id, + presetLane: 'helper' + }) + const stored = await database.get('chatluna_conversation', { + id: created.conversation.id + }) + + assert.equal(listed.length, 1) + assert.equal(renamed.title, 'Helper Session') + assert.equal(path.extname(exported.path), '.md') + assert.equal(archived.conversation.status, 'archived') + assert.equal(restored.status, 'active') + assert.equal(removed.status, 'deleted') + assert.equal(stored[0].status, 'deleted') + } finally { + await app.stop() + } +}) diff --git a/packages/core/tests/conversation-migration.spec.ts b/packages/core/tests/conversation-migration.spec.ts new file mode 100644 index 000000000..eb77b14fe --- /dev/null +++ b/packages/core/tests/conversation-migration.spec.ts @@ -0,0 +1,173 @@ +/// + +import { assert } from 'chai' +import path from 'node:path' +import { + createLegacyBindingKey, + inferLegacyGroupRouteModes +} from '../src/migration/validators' +import { + getLegacySchemaSentinel, + getLegacySchemaSentinelDir +} from '../src/migration/legacy_tables' +import { runRoomToConversationMigration } from '../src/migration/room_to_conversation' +import { createConfig, createService, type TableRow } from './helpers' +import type { + BindingRecord, + ConversationRecord, + MessageRecord +} from '../src/services/conversation_types' + +it('runRoomToConversationMigration migrates legacy rooms, messages, bindings, and ACL', async () => { + const { ctx, database } = await createService({ + tables: { + chathub_room: [ + { + roomId: 1, + roomName: 'Legacy Room', + roomMasterId: 'owner', + conversationId: 'legacy-conversation', + preset: 'legacy-preset', + model: 'legacy/model', + chatMode: 'plugin', + visibility: 'private', + password: 'secret', + autoUpdate: true, + updatedTime: new Date('2026-03-21T00:00:00.000Z') + } as unknown as TableRow + ], + chathub_conversation: [ + { + id: 'legacy-conversation', + latestId: 'legacy-message-1', + additional_kwargs: '{"topic":"legacy"}', + updatedAt: new Date('2026-03-21T01:00:00.000Z') + } as unknown as TableRow + ], + chathub_message: [ + { + id: 'legacy-message-1', + conversation: 'legacy-conversation', + parent: null, + role: 'human', + text: 'hello from legacy room', + content: null, + name: 'owner', + tool_call_id: null, + tool_calls: null, + additional_kwargs: null, + additional_kwargs_binary: null, + rawId: null + } as unknown as TableRow + ], + chathub_room_member: [ + { + roomId: 1, + userId: 'owner', + roomPermission: 'owner', + mute: false + } as unknown as TableRow, + { + roomId: 1, + userId: 'guest', + roomPermission: 'member', + mute: false + } as unknown as TableRow, + { + roomId: 1, + userId: 'helper', + roomPermission: 'member', + mute: false + } as unknown as TableRow + ], + chathub_room_group_member: [ + { + roomId: 1, + groupId: 'guild', + roomVisibility: 'private' + } as unknown as TableRow, + { + roomId: 1, + groupId: 'guild-2', + roomVisibility: 'private' + } as unknown as TableRow + ], + chathub_user: [ + { + userId: 'owner', + groupId: 'guild', + defaultRoomId: 1 + } as unknown as TableRow + ] + } + }) + + await runRoomToConversationMigration(ctx, createConfig()) + + const conversation = database.tables + .chatluna_conversation[0] as ConversationRecord + const binding = database.tables.chatluna_binding[0] as BindingRecord + const message = database.tables.chatluna_message[0] as MessageRecord + const meta = database.tables.chatluna_meta.find( + (item) => item.key === 'validation_result' + ) as { value?: string | null } + + assert.equal(conversation.id, 'legacy-conversation') + assert.equal(conversation.bindingKey, 'custom:legacy:room:1') + assert.equal(conversation.latestMessageId, 'legacy-message-1') + assert.equal(conversation.legacyRoomId, 1) + assert.equal(binding.activeConversationId, 'legacy-conversation') + assert.equal(message.conversationId, 'legacy-conversation') + assert.equal(database.tables.chatluna_acl.length, 6) + assert.equal(JSON.parse(meta.value ?? '{}').passed, true) +}) + +it('inferLegacyGroupRouteModes preserves per-group legacy routing semantics', () => { + const users = [ + { userId: 'a', groupId: 'g1', defaultRoomId: 1 }, + { userId: 'b', groupId: 'g1', defaultRoomId: 2 }, + { userId: 'c', groupId: 'g2', defaultRoomId: 3 } + ] + const rooms = [ + { roomId: 1, visibility: 'private' }, + { roomId: 2, visibility: 'private' }, + { roomId: 3, visibility: 'public' } + ] as never + const groups = [ + { roomId: 1, groupId: 'g1', roomVisibility: 'private' }, + { roomId: 2, groupId: 'g1', roomVisibility: 'private' }, + { roomId: 3, groupId: 'g2', roomVisibility: 'public' } + ] as never + + const modes = inferLegacyGroupRouteModes(users as never, rooms, groups) + + assert.equal( + createLegacyBindingKey(users[0] as never, modes), + 'personal:legacy:legacy:g1:a' + ) + assert.equal( + createLegacyBindingKey(users[1] as never, modes), + 'personal:legacy:legacy:g1:b' + ) + assert.equal( + createLegacyBindingKey(users[2] as never, modes), + 'shared:legacy:legacy:g2' + ) +}) + +it('getLegacySchemaSentinel resolves under baseDir', () => { + assert.equal( + getLegacySchemaSentinel('C:/chatluna-base'), + path.resolve( + 'C:/chatluna-base', + 'data/chatluna/temp/legacy-schema-disabled.json' + ) + ) +}) + +it('getLegacySchemaSentinelDir matches resolved sentinel parent', () => { + assert.equal( + getLegacySchemaSentinelDir('C:/chatluna-base'), + path.dirname(getLegacySchemaSentinel('C:/chatluna-base')) + ) +}) diff --git a/packages/core/tests/conversation-runtime.spec.ts b/packages/core/tests/conversation-runtime.spec.ts new file mode 100644 index 000000000..05c52d2ae --- /dev/null +++ b/packages/core/tests/conversation-runtime.spec.ts @@ -0,0 +1,243 @@ +/// + +import { HumanMessage } from '@langchain/core/messages' +import { assert } from 'chai' +import { ConversationRuntime } from '../src/services/conversation_runtime' +import { createConversation, createSession } from './helpers' + +it('ConversationRuntime registers, resolves, and stops active requests', () => { + const runtime = new ConversationRuntime({} as never) + const abortController = new AbortController() + const session = createSession({ sid: 'sid-1' }) + + runtime.registerRequest( + 'conversation-1', + 'request-1', + 'plugin', + abortController, + session + ) + + assert.equal(runtime.getRequestIdBySession(session), 'request-1') + assert.equal(runtime.stopRequest('request-1'), true) + assert.equal(abortController.signal.aborted, true) + assert.equal(runtime.stopRequest('missing-request'), false) + + runtime.completeRequest('conversation-1', 'request-1', session) + assert.equal(runtime.getRequestIdBySession(session), undefined) +}) + +it('ConversationRuntime chat preserves additional kwargs metadata', async () => { + const runtime = new ConversationRuntime({ + createChatInterface: async () => ({ + chat: async () => ({ + message: new HumanMessage('placeholder') + }) + }), + resolveToolMask: async () => undefined, + awaitLoadPlatform: async () => {}, + currentConfig: { + showThoughtMessage: true + }, + platform: { + getClient: async () => ({ + value: { + configPool: { + getConfig: () => ({ + value: { + concurrentMaxSize: 1 + } + }) + } + } + }) + }, + ctx: { + root: { + parallel: async () => {} + } + } + } as never) + + const conversation = createConversation({ + id: 'conversation-runtime-chat', + model: 'platform/model' + }) + const chatInterface = { + chat: async () => ({ + message: { + content: 'assistant reply', + additional_kwargs: { + provider: 'mock', + reasoning_content: 'thinking', + reasoning_time: 1000 + }, + usage_metadata: { + total_tokens: 12, + input_tokens: 5, + output_tokens: 7 + } + } + }) + } + runtime.interfaces.set(conversation.id, { + conversation, + chatInterface: chatInterface as never + }) + + const result = await runtime.chat( + createSession(), + conversation, + { + content: 'hello' + }, + {} as never + ) + + assert.deepEqual(result.additional_kwargs, { + provider: 'mock', + reasoning_content: 'thinking', + reasoning_time: 1000 + }) + assert.equal(result.additionalReplyMessages?.length, 2) +}) + +it('ConversationRuntime appendPendingMessage waits for plugin round decisions', async () => { + const runtime = new ConversationRuntime({} as never) + const activeRequest = runtime.registerRequest( + 'conversation-1', + 'request-1', + 'plugin', + new AbortController(), + createSession() + ) + + const pushed: HumanMessage[] = [] + const originalPush = activeRequest.messageQueue.push.bind( + activeRequest.messageQueue + ) + activeRequest.messageQueue.push = ((message: HumanMessage) => { + pushed.push(message) + return originalPush(message) + }) as typeof activeRequest.messageQueue.push + + const pending = runtime.appendPendingMessage( + 'conversation-1', + new HumanMessage('follow-up') + ) + + assert.equal(activeRequest.roundDecisionResolvers.length, 1) + activeRequest.roundDecisionResolvers[0](true) + assert.equal(await pending, true) + assert.equal(pushed.length, 1) + assert.equal(String(pushed[0].content), 'follow-up') + + activeRequest.lastDecision = false + assert.equal( + await runtime.appendPendingMessage( + 'conversation-1', + new HumanMessage('ignored'), + 'plugin' + ), + false + ) + assert.equal( + await runtime.appendPendingMessage( + 'conversation-1', + new HumanMessage('wrong-mode'), + 'chat' + ), + false + ) +}) + +it('ConversationRuntime clears cached interfaces and dispatches compression', async () => { + const cleared: string[] = [] + const compressed: boolean[] = [] + const runtime = new ConversationRuntime({ + createChatInterface: async () => ({ + clearChatHistory: async () => { + cleared.push('cleared') + }, + compressContext: async (force: boolean) => { + compressed.push(force) + return { + compressed: true, + inputTokens: 10, + outputTokens: 5, + reducedPercent: 50 + } + } + }), + awaitLoadPlatform: async () => {}, + platform: { + getClient: async () => ({ + value: { + configPool: { + getConfig: () => ({ + value: { + concurrentMaxSize: 1 + } + }) + } + } + }) + }, + ctx: { + root: { + parallel: async () => {} + } + } + } as never) + + const conversation = createConversation({ + id: 'conversation-runtime', + model: 'platform/model' + }) + + await runtime.ensureChatInterface(conversation) + assert.equal(runtime.getCachedConversations().length, 1) + + await runtime.clearConversationHistory(conversation) + assert.deepEqual(cleared, ['cleared']) + assert.equal(runtime.getCachedConversations().length, 0) + + const result = await runtime.compressConversation(conversation, true) + assert.equal(result.compressed, true) + assert.deepEqual(compressed, [true]) +}) + +it('ConversationRuntime dispose clears platform-scoped and global state', () => { + const runtime = new ConversationRuntime({} as never) + const session = createSession({ sid: 'sid-dispose' }) + const conversation = createConversation({ id: 'conversation-dispose' }) + + runtime.interfaces.set(conversation.id, { + conversation, + chatInterface: {} as never + }) + runtime.registerPlatformConversation('platform-a', conversation.id) + runtime.registerRequest( + conversation.id, + 'request-dispose', + 'plugin', + new AbortController(), + session + ) + + runtime.dispose('platform-a') + assert.equal(runtime.interfaces.has(conversation.id), false) + assert.equal(runtime.activeByConversation.has(conversation.id), false) + + runtime.registerRequest( + 'conversation-2', + 'request-2', + 'plugin', + new AbortController(), + createSession({ sid: 'sid-2' }) + ) + runtime.dispose() + assert.equal(runtime.requestsById.size, 0) + assert.equal(runtime.requestBySession.size, 0) + assert.equal(runtime.platformIndex.size, 0) +}) diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts new file mode 100644 index 000000000..df80da613 --- /dev/null +++ b/packages/core/tests/conversation-service.spec.ts @@ -0,0 +1,720 @@ +/// + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { assert } from 'chai' +import type { + ACLRecord, + ArchiveRecord, + BindingRecord, + ConstraintRecord +} from '../src/services/conversation_types' +import { gzipEncode } from '../src/utils/compression' +import { + createConversation, + createMessage, + createService, + createSession, + expectRejected, + type TableRow +} from './helpers' + +it('ConversationService resolves routed constraints and preset lanes', async () => { + const highPriorityConstraint: ConstraintRecord = { + id: 2, + name: 'shared route', + enabled: true, + priority: 10, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + guildId: 'guild', + routeMode: 'custom', + routeKey: 'team-alpha', + defaultModel: 'constraint/model', + fixedPreset: 'fixed-preset', + allowNew: false, + allowSwitch: true, + allowArchive: false, + allowExport: true, + manageMode: 'anyone' + } + const lowerPriorityConstraint: ConstraintRecord = { + id: 1, + name: 'fallback defaults', + enabled: true, + priority: 1, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + defaultPreset: 'constraint-default-preset', + defaultChatMode: 'chat-mode-x', + fixedModel: null, + fixedChatMode: 'fixed-chat-mode' + } + + const { service } = await createService({ + tables: { + chatluna_constraint: [ + highPriorityConstraint as unknown as TableRow, + lowerPriorityConstraint as unknown as TableRow + ] + } + }) + + const resolved = await service.resolveConstraint(createSession(), { + presetLane: 'helper' + }) + + assert.equal(resolved.routeMode, 'custom') + assert.equal(resolved.baseKey, 'custom:team-alpha') + assert.equal(resolved.bindingKey, 'custom:team-alpha:preset:helper') + assert.equal(resolved.defaultModel, 'constraint/model') + assert.equal(resolved.defaultPreset, 'helper') + assert.equal(resolved.fixedPreset, 'fixed-preset') + assert.equal(resolved.fixedChatMode, 'fixed-chat-mode') + assert.equal(resolved.allowNew, false) + assert.equal(resolved.allowArchive, false) + assert.equal(resolved.manageMode, 'anyone') +}) + +it('ConversationService gives fixed preset precedence over preset lane', async () => { + const constraint: ConstraintRecord = { + id: 1, + name: 'fixed-preset', + enabled: true, + priority: 10, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + fixedPreset: 'fixed-preset' + } + + const { service } = await createService({ + tables: { + chatluna_constraint: [constraint as unknown as TableRow] + } + }) + + const resolved = await service.resolveContext(createSession(), { + presetLane: 'helper' + }) + + assert.equal(resolved.effectivePreset, 'fixed-preset') +}) + +it('ConversationService ensureActiveConversation creates conversation and binding on default route', async () => { + const { service, database } = await createService() + + const resolved = await service.ensureActiveConversation(createSession()) + const binding = database.tables.chatluna_binding[0] as BindingRecord + const conversation = database.tables.chatluna_conversation[0] + + assert.equal(resolved.bindingKey, 'shared:discord:bot:guild') + assert.equal(binding.activeConversationId, resolved.conversation.id) + assert.equal(conversation.id, resolved.conversation.id) + assert.equal(conversation.seq, 1) + assert.equal(conversation.legacyRoomId, null) + assert.equal(conversation.legacyMeta, null) + assert.equal(resolved.effectiveModel, 'test-platform/test-model') + assert.equal(resolved.effectivePreset, 'default-preset') + assert.equal(resolved.effectiveChatMode, 'plugin') +}) + +it('ConversationService restores archived current conversation automatically', async () => { + const archivedConversation = createConversation({ + id: 'conversation-archived', + status: 'archived', + archiveId: 'archive-1', + archivedAt: new Date('2026-03-22T00:00:00.000Z'), + latestMessageId: null + }) + const archivedPayload = { + formatVersion: 1, + exportedAt: '2026-03-22T00:00:00.000Z', + conversation: { + ...archivedConversation, + status: 'active', + archiveId: null, + archivedAt: null, + createdAt: archivedConversation.createdAt.toISOString(), + updatedAt: archivedConversation.updatedAt.toISOString(), + lastChatAt: archivedConversation.lastChatAt?.toISOString() ?? null + }, + messages: [] + } + const archiveDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-restore-test-') + ) + const archivePath = path.join(archiveDir, 'archive.json.gz') + await fs.writeFile( + archivePath, + await gzipEncode(JSON.stringify(archivedPayload)) + ) + + const { service } = await createService({ + baseDir: archiveDir, + tables: { + chatluna_conversation: [ + archivedConversation as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-archived', + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_archive: [ + { + id: 'archive-1', + conversationId: 'conversation-archived', + path: archivePath, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + ] + } + }) + + const resolved = await service.ensureActiveConversation(createSession()) + + assert.equal(resolved.conversation.id, 'conversation-archived') + assert.equal(resolved.conversation.status, 'active') + assert.equal(resolved.conversation.archiveId, null) +}) + +it('ConversationService does not auto-restore archived conversation without manage permission', async () => { + const archivedConversation = createConversation({ + id: 'conversation-archived-locked', + status: 'archived', + archiveId: 'archive-locked', + archivedAt: new Date('2026-03-22T00:00:00.000Z'), + latestMessageId: null + }) + const archiveDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-restore-blocked-test-') + ) + const archivePath = path.join(archiveDir, 'archive.json.gz') + await fs.writeFile( + archivePath, + await gzipEncode( + JSON.stringify({ + formatVersion: 1, + exportedAt: '2026-03-22T00:00:00.000Z', + conversation: { + ...archivedConversation, + status: 'active', + archiveId: null, + archivedAt: null, + createdAt: archivedConversation.createdAt.toISOString(), + updatedAt: archivedConversation.updatedAt.toISOString(), + lastChatAt: + archivedConversation.lastChatAt?.toISOString() ?? null + }, + messages: [] + }) + ) + ) + + const { service } = await createService({ + baseDir: archiveDir, + tables: { + chatluna_conversation: [ + archivedConversation as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: archivedConversation.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_archive: [ + { + id: 'archive-locked', + conversationId: archivedConversation.id, + path: archivePath, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } + ] + } + }) + + await expectRejected( + service.ensureActiveConversation(createSession({ authority: 1 })), + /administrator permission/ + ) +}) + +it('ConversationService ensureActiveConversation respects personal default group route mode', async () => { + const { service } = await createService({ + config: { + defaultGroupRouteMode: 'personal' + } + }) + + const resolved = await service.ensureActiveConversation(createSession()) + + assert.equal(resolved.bindingKey, 'personal:discord:bot:guild:user') + assert.equal(resolved.conversation.seq, 1) +}) + +it('ConversationService switches and resolves friendly conversation targets within the same binding', async () => { + const older = createConversation({ + id: 'conversation-old', + seq: 1, + title: 'Older', + lastChatAt: new Date('2026-03-20T00:00:00.000Z') + }) + const newer = createConversation({ + id: 'conversation-new', + seq: 2, + title: 'Newer Topic', + lastChatAt: new Date('2026-03-22T00:00:00.000Z') + }) + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + older as unknown as TableRow, + newer as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-old', + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + const listed = await service.listConversations(createSession()) + assert.deepEqual( + listed.map((item) => item.id), + ['conversation-new', 'conversation-old'] + ) + + const bySeq = await service.switchConversation(createSession(), { + targetConversation: '2' + }) + const byId = await service.switchConversation(createSession(), { + targetConversation: 'conversation-old' + }) + const byTitle = await service.switchConversation(createSession(), { + targetConversation: 'newer topic' + }) + const byPartialTitle = await service.switchConversation(createSession(), { + targetConversation: 'Topic' + }) + const binding = database.tables.chatluna_binding[0] as BindingRecord + + assert.equal(bySeq.id, 'conversation-new') + assert.equal(byId.id, 'conversation-old') + assert.equal(byTitle.id, 'conversation-new') + assert.equal(byPartialTitle.id, 'conversation-new') + assert.equal(binding.activeConversationId, 'conversation-new') + assert.equal(binding.lastConversationId, 'conversation-old') +}) + +it('ConversationService rejects ambiguous friendly conversation targets', async () => { + const alpha = createConversation({ + id: 'conversation-alpha', + seq: 1, + title: 'Project Alpha' + }) + const beta = createConversation({ + id: 'conversation-beta', + seq: 2, + title: 'Project Beta' + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + alpha as unknown as TableRow, + beta as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: 'shared:discord:bot:guild', + activeConversationId: 'conversation-alpha', + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + await expectRejected( + service.switchConversation(createSession(), { + targetConversation: 'Project' + }), + /Conversation target is ambiguous\./ + ) +}) + +it('ConversationService records compression metadata and use rejects fixed fields', async () => { + const conversation = createConversation() + const message = createMessage({ + id: 'summary', + conversationId: conversation.id, + text: 'compressed summary', + name: 'infinite_context' + }) + const { service } = await createService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_message: [message as unknown as TableRow], + chatluna_constraint: [ + { + id: 1, + name: 'managed:discord:bot:guild:guild', + enabled: true, + priority: 1000, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + channelId: null, + direct: false, + users: null, + excludeUsers: null, + routeMode: null, + routeKey: null, + defaultModel: null, + defaultPreset: null, + defaultChatMode: null, + fixedModel: 'fixed-model', + fixedPreset: null, + fixedChatMode: null, + lockConversation: false, + allowNew: true, + allowSwitch: true, + allowArchive: true, + allowExport: true, + manageMode: 'anyone' + } as unknown as TableRow + ] + } + }) + + const updated = await service.recordCompression(conversation.id, { + compressed: true, + inputTokens: 120, + outputTokens: 30, + reducedTokens: 90, + reducedPercent: 75, + originalMessageCount: 8, + remainingMessageCount: 1 + }) + const compression = JSON.parse(updated.compression ?? '{}') + + assert.equal(compression.count, 1) + assert.equal(compression.summary, 'compressed summary') + assert.equal(compression.outputTokens, 30) + assert.equal(compression.originalMessageCount, 8) + assert.equal(compression.remainingMessageCount, 1) + + await expectRejected( + service.updateConversationUsage(createSession(), { + model: 'other-model' + }), + /fixed to fixed-model/ + ) +}) + +it('ConversationService blocks raw id access outside route without ACL and allows manage ACL', async () => { + const local = createConversation({ + id: 'conversation-local', + bindingKey: 'shared:discord:bot:guild' + }) + const remote = createConversation({ + id: 'conversation-remote', + bindingKey: 'shared:discord:bot:other-guild' + }) + const acl: ACLRecord = { + conversationId: remote.id, + principalType: 'user', + principalId: 'user', + permission: 'manage' + } + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + local as unknown as TableRow, + remote as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: local.bindingKey, + activeConversationId: local.id, + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + await expectRejected( + service.resolveCommandConversation(createSession({ authority: 1 }), { + conversationId: remote.id, + permission: 'manage' + }), + /does not belong to current route/ + ) + + database.tables.chatluna_acl.push(acl as unknown as TableRow) + + const resolved = await service.resolveCommandConversation( + createSession({ authority: 1 }), + { + conversationId: remote.id, + permission: 'manage' + } + ) + + assert.equal(resolved.id, remote.id) +}) + +it('ConversationService resolves ACL-backed cross-route targetConversation', async () => { + const local = createConversation({ + id: 'conversation-local-2', + bindingKey: 'shared:discord:bot:guild' + }) + const remote = createConversation({ + id: 'conversation-remote-2', + bindingKey: 'shared:discord:bot:other-guild', + title: 'Remote Shared Topic', + seq: 7 + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + local as unknown as TableRow, + remote as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: local.bindingKey, + activeConversationId: local.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_acl: [ + { + conversationId: remote.id, + principalType: 'user', + principalId: 'user', + permission: 'manage' + } as unknown as TableRow + ] + } + }) + + const byId = await service.resolveCommandConversation( + createSession({ authority: 1 }), + { + targetConversation: remote.id, + permission: 'manage' + } + ) + const byTitle = await service.resolveCommandConversation( + createSession({ authority: 1 }), + { + targetConversation: 'Remote Shared Topic', + permission: 'manage' + } + ) + + assert.equal(byId?.id, remote.id) + assert.equal(byTitle?.id, remote.id) +}) + +it('ConversationService keeps local bindings untouched for cross-route switch and reopen', async () => { + const local = createConversation({ + id: 'conversation-local-switch', + bindingKey: 'shared:discord:bot:guild' + }) + const remote = createConversation({ + id: 'conversation-remote-switch', + bindingKey: 'shared:discord:bot:other-guild', + title: 'Remote Topic' + }) + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + local as unknown as TableRow, + remote as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: local.bindingKey, + activeConversationId: local.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ] + } + }) + + await service.switchConversation(createSession(), { + targetConversation: remote.id + }) + await service.reopenConversation(createSession(), { + conversationId: remote.id + }) + + const localBinding = database.tables.chatluna_binding.find( + (item) => item.bindingKey === local.bindingKey + ) as BindingRecord | undefined + const remoteBinding = database.tables.chatluna_binding.find( + (item) => item.bindingKey === remote.bindingKey + ) as BindingRecord | undefined + + assert.equal(localBinding?.activeConversationId, local.id) + assert.equal(remoteBinding?.activeConversationId, remote.id) +}) + +it('ConversationService upserts and removes ACL records coherently', async () => { + const conversation = createConversation({ id: 'conversation-acl' }) + const { service } = await createService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow] + } + }) + + const created = await service.upsertAcl(conversation.id, [ + { + principalType: 'user', + principalId: 'alice', + permission: 'view' + }, + { + principalType: 'guild', + principalId: 'guild-x', + permission: 'manage' + } + ]) + + assert.equal(created.length, 2) + + const afterRemove = await service.removeAcl(conversation.id, [ + { + principalType: 'user', + principalId: 'alice' + } + ]) + + assert.deepEqual(afterRemove, [ + { + conversationId: conversation.id, + principalType: 'guild', + principalId: 'guild-x', + permission: 'manage' + } + ]) +}) + +it('ConversationService emits conversation lifecycle events for switch archive restore delete and compression', async () => { + const active = createConversation({ id: 'conversation-active', seq: 1 }) + const next = createConversation({ + id: 'conversation-next', + seq: 2, + title: 'Next Topic' + }) + const { service, events, syncCalls } = await createService({ + tables: { + chatluna_conversation: [ + active as unknown as TableRow, + next as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: active.bindingKey, + activeConversationId: active.id, + lastConversationId: null, + updatedAt: new Date() + } + ], + chatluna_message: [ + createMessage({ + id: 'summary-message', + conversationId: next.id, + name: 'infinite_context', + text: 'summary' + }) as unknown as TableRow + ] + } + }) + + await service.switchConversation(createSession(), { + targetConversation: next.id + }) + await service.recordCompression(next.id, { + compressed: true, + inputTokens: 100, + outputTokens: 20, + reducedTokens: 80, + reducedPercent: 80, + originalMessageCount: 10, + remainingMessageCount: 2 + }) + + const archived = await service.archiveConversation(createSession(), { + conversationId: next.id + }) + await service.restoreConversation(createSession(), { + conversationId: next.id, + archiveId: archived.archive.id + }) + await service.deleteConversation(createSession(), { + conversationId: next.id + }) + + assert.deepEqual( + events.map((item) => item.name), + [ + 'chatluna/conversation-before-switch', + 'chatluna/conversation-after-switch', + 'chatluna/conversation-compressed', + 'chatluna/conversation-before-archive', + 'chatluna/conversation-after-archive', + 'chatluna/conversation-before-restore', + 'chatluna/conversation-after-restore', + 'chatluna/conversation-before-delete', + 'chatluna/conversation-after-delete' + ] + ) + assert.deepEqual(syncCalls, [next.id, next.id, next.id]) +}) diff --git a/packages/core/tests/conversation-source.spec.ts b/packages/core/tests/conversation-source.spec.ts new file mode 100644 index 000000000..d94ff7f6b --- /dev/null +++ b/packages/core/tests/conversation-source.spec.ts @@ -0,0 +1,58 @@ +/// + +import fs from 'node:fs/promises' +import path from 'node:path' +import { assert } from 'chai' +import { expectRejected } from './helpers' + +it('conversation-first runtime removes legacy room entry points from active source tree', async () => { + const coreSrc = path.resolve(__dirname, '../src') + + await Promise.all([ + expectRejected(fs.access(path.join(coreSrc, 'chains', 'rooms.ts'))), + expectRejected(fs.access(path.join(coreSrc, 'commands', 'room.ts'))), + expectRejected(fs.access(path.join(coreSrc, 'middlewares', 'room'))), + expectRejected( + fs.access(path.join(coreSrc, 'middlewares', 'auth', 'mute_user.ts')) + ), + expectRejected( + fs.access( + path.join(coreSrc, 'middlewares', 'model', 'request_model.ts') + ) + ), + expectRejected(fs.access(path.join(coreSrc, 'legacy', 'types.ts'))) + ]) +}) + +it('conversation cleanup listeners in downstream packages use conversation lifecycle events', async () => { + const files = [ + path.resolve( + __dirname, + '../../extension-long-memory/src/service/memory.ts' + ), + path.resolve(__dirname, '../../extension-agent/src/service/skills.ts'), + path.resolve(__dirname, '../../extension-agent/src/cli/service.ts'), + path.resolve(__dirname, '../../extension-tools/src/plugins/todos.ts') + ] + + for (const file of files) { + const content = await fs.readFile(file, 'utf8') + assert.equal(content.includes('chatluna/clear-chat-history'), false) + assert.equal( + content.includes('chatluna/conversation-after-clear-history'), + true + ) + } +}) + +it('wipe source keeps legacy migration and runtime table cleanup wired in', async () => { + const source = await fs.readFile( + path.resolve(__dirname, '../src/middlewares/system/wipe.ts'), + 'utf8' + ) + + assert.match(source, /LEGACY_MIGRATION_TABLES/) + assert.match(source, /LEGACY_RUNTIME_TABLES/) + assert.match(source, /for \(const table of LEGACY_MIGRATION_TABLES\)/) + assert.match(source, /for \(const table of LEGACY_RUNTIME_TABLES\)/) +}) diff --git a/packages/core/tests/conversation-utils.spec.ts b/packages/core/tests/conversation-utils.spec.ts new file mode 100644 index 000000000..cd66cae4f --- /dev/null +++ b/packages/core/tests/conversation-utils.spec.ts @@ -0,0 +1,157 @@ +/// + +import { assert } from 'chai' +import { Pagination } from '../src/utils/pagination' +import { + bufferToArrayBuffer, + gzipDecode, + gzipEncode +} from '../src/utils/compression' +import { + getMessageContent, + parsePresetLaneInput +} from '../src/utils/message_content' +import { + applyPresetLane, + computeBaseBindingKey +} from '../src/services/conversation_types' +import { + createMessage, + type BindingSessionShape, + FakeDatabase +} from './helpers' + +it('computeBaseBindingKey builds personal direct bindings', () => { + const bindingKey = computeBaseBindingKey( + { + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + userId: 'user', + isDirect: true + } as BindingSessionShape as never, + 'personal' + ) + + assert.equal(bindingKey, 'personal:discord:bot:direct:user') +}) + +it('computeBaseBindingKey builds shared guild bindings', () => { + const bindingKey = computeBaseBindingKey( + { + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + userId: 'user', + isDirect: false + } as BindingSessionShape as never, + 'shared' + ) + + assert.equal(bindingKey, 'shared:discord:bot:guild') +}) + +it('applyPresetLane appends preset lane when provided', () => { + assert.equal( + applyPresetLane('personal:discord:bot:guild:user', 'helper'), + 'personal:discord:bot:guild:user:preset:helper' + ) + assert.equal( + applyPresetLane('personal:discord:bot:guild:user', undefined), + 'personal:discord:bot:guild:user' + ) +}) + +it('parsePresetLaneInput normalizes alias prefixes and bare queries', () => { + assert.deepEqual( + parsePresetLaneInput('Sydney: hello', ['sydney', 'helper']), + { + preset: 'sydney', + content: 'hello', + queryOnly: false + } + ) + assert.deepEqual(parsePresetLaneInput('helper,', ['sydney', 'helper']), { + preset: 'helper', + content: '', + queryOnly: true + }) + assert.equal(parsePresetLaneInput('plain message', ['sydney']), null) +}) + +it('pagination normalizes page and limit bounds', async () => { + const pagination = new Pagination({ + page: 1, + limit: 2, + formatItem: (item) => `item:${item}`, + formatString: { + top: 'top', + bottom: 'bottom', + pages: 'page [page]/[total]' + } + }) + + await pagination.push([1, 2, 3], 'numbers') + + assert.deepEqual(await pagination.getPage(0, 0, 'numbers'), [1]) + assert.equal( + await pagination.getFormattedPage(2, 2, 'numbers'), + 'top\nitem:3\nbottom\npage 2/2' + ) +}) + +it('FakeDatabase.get applies sort and pagination modifiers', async () => { + const database = new FakeDatabase() + database.tables.chatluna_message = [ + createMessage({ + id: 'message-a', + createdAt: new Date('2026-03-21T00:00:03.000Z') + }), + createMessage({ + id: 'message-b', + createdAt: new Date('2026-03-21T00:00:01.000Z') + }), + createMessage({ + id: 'message-c', + createdAt: new Date('2026-03-21T00:00:02.000Z') + }) + ] + + const rows = await database.get( + 'chatluna_message', + {}, + { + sort: { + createdAt: 'asc' + }, + offset: 1, + limit: 1 + } + ) + + assert.deepEqual( + rows.map((row) => row.id), + ['message-c'] + ) +}) + +it('gzip helpers round-trip archived payload content', async () => { + const json = JSON.stringify({ text: 'hello', values: [1, 2, 3] }) + const compressed = await gzipEncode(json) + const base64 = await gzipEncode(json, 'base64') + const arrayBuffer = bufferToArrayBuffer(compressed) + + assert.equal(await gzipDecode(arrayBuffer), json) + assert.equal(await gzipDecode(base64), json) +}) + +it('getMessageContent flattens structured text parts', () => { + assert.equal( + getMessageContent([ + { type: 'text', text: 'hello ' }, + { type: 'image_url', image_url: 'https://example.com/x.png' }, + { type: 'text', text: 'world' } + ] as never), + 'hello world' + ) +}) diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts new file mode 100644 index 000000000..3ff32504f --- /dev/null +++ b/packages/core/tests/helpers.ts @@ -0,0 +1,422 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { assert } from 'chai' +import memory from '@koishijs/plugin-database-memory' +import { Context } from 'koishi' +import type {} from '../src/services/types' +import { + type ArchiveRecord, + type ConversationRecord, + type MessageRecord +} from '../src/services/conversation_types' +import { ChatLunaService } from '../src/services/chat' +import { ConversationService } from '../src/services/conversation' + +export async function expectRejected( + promise: Promise, + pattern?: RegExp +) { + try { + await promise + } catch (err) { + if (pattern != null) { + assert.match(String(err), pattern) + } + return + } + + assert.fail('Expected promise to reject.') +} + +export type BindingSessionShape = { + platform?: string + selfId?: string + guildId?: string + userId?: string + channelId?: string + sid?: string + isDirect?: boolean + authority?: number +} + +export type TableRow = Record +export type Tables = Record + +type QueryOptions = { + offset?: number + limit?: number + sort?: Record | [string, 'asc' | 'desc'][] +} + +export class FakeDatabase { + tables: Tables = { + chatluna_meta: [], + chatluna_conversation: [], + chatluna_binding: [], + chatluna_archive: [], + chatluna_message: [], + chatluna_constraint: [], + chatluna_acl: [], + chathub_room_member: [], + chathub_room_group_member: [], + chathub_user: [], + chathub_room: [], + chathub_message: [], + chathub_conversation: [] + } + + async get( + table: string, + query: Record, + modifier: QueryOptions | string[] = {} + ) { + const filters = { ...query } + const options = Array.isArray(modifier) ? {} : { ...modifier } + + if ('$offset' in filters) { + options.offset = Number(filters.$offset) + delete filters.$offset + } + + if ('$limit' in filters) { + options.limit = Number(filters.$limit) + delete filters.$limit + } + + if ('$sort' in filters) { + options.sort = filters.$sort as QueryOptions['sort'] + delete filters.$sort + } + + let rows = (this.tables[table] ?? []).filter((row) => + Object.entries(filters).every(([key, expected]) => { + const actual = row[key] + + if ( + expected != null && + typeof expected === 'object' && + '$in' in expected + ) { + return Array.isArray(expected.$in) + ? expected.$in.includes(actual) + : false + } + + if (Array.isArray(expected)) { + return expected.includes(actual) + } + + return actual === expected + }) + ) + + const sortEntries = Array.isArray(options.sort) + ? options.sort + : Object.entries(options.sort ?? {}) + const [sortKey, sortDir] = sortEntries[0] ?? [] + + if (sortKey != null && sortDir != null) { + rows = [...rows].sort((left, right) => { + const a = left[sortKey] + const b = right[sortKey] + + if (a === b) { + return 0 + } + + if (a == null) { + return sortDir === 'asc' ? -1 : 1 + } + + if (b == null) { + return sortDir === 'asc' ? 1 : -1 + } + + return (a < b ? -1 : 1) * (sortDir === 'asc' ? 1 : -1) + }) + } + + const offset = Number.isFinite(options.offset) + ? Math.max(0, Number(options.offset)) + : 0 + const limit = Number.isFinite(options.limit) + ? Math.max(0, Number(options.limit)) + : undefined + rows = + limit == null + ? rows.slice(offset) + : rows.slice(offset, offset + limit) + + if (Array.isArray(modifier)) { + return rows.map((row) => + Object.fromEntries(modifier.map((field) => [field, row[field]])) + ) + } + + return rows + } + + async create(table: string, row: TableRow) { + ;(this.tables[table] ??= []).push({ ...row }) + } + + async upsert(table: string, rows: TableRow[]) { + const target = (this.tables[table] ??= []) + + for (const row of rows) { + const idx = target.findIndex((current) => + this.samePrimary(table, current, row) + ) + if (idx >= 0) { + target[idx] = { ...target[idx], ...row } + } else { + target.push({ ...row }) + } + } + } + + async remove(table: string, query: Record) { + const target = (this.tables[table] ??= []) + this.tables[table] = target.filter( + (row) => + !Object.entries(query).every(([key, expected]) => { + const actual = row[key] + if (Array.isArray(expected)) { + return expected.includes(actual) + } + return actual === expected + }) + ) + } + + async drop(table: string) { + this.tables[table] = [] + } + + private samePrimary(table: string, left: TableRow, right: TableRow) { + if (table === 'chatluna_binding') { + return left.bindingKey === right.bindingKey + } + + if (table === 'chatluna_archive') { + return left.id === right.id + } + + if (table === 'chatluna_message') { + return left.id === right.id + } + + if (table === 'chatluna_constraint') { + return ( + (left.id != null && left.id === right.id) || + left.name === right.name + ) + } + + if (table === 'chatluna_meta') { + return left.key === right.key + } + + if (table === 'chatluna_acl') { + return ( + left.conversationId === right.conversationId && + left.principalType === right.principalType && + left.principalId === right.principalId && + left.permission === right.permission + ) + } + + return left.id === right.id + } +} + +export function createSession(overrides: Partial = {}) { + const authority = overrides.authority ?? 3 + + return { + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + channelId: 'channel', + userId: 'user', + sid: 'discord:channel:user', + isDirect: false, + authority, + app: { + permissions: { + test: async () => authority >= 3 + }, + logger: { + debug: () => {} + } + }, + getUser: async () => ({ + authority + }), + ...overrides + } as BindingSessionShape as never +} + +export function createConfig(overrides: Record = {}) { + return { + defaultModel: 'test-platform/test-model', + defaultPreset: 'default-preset', + defaultChatMode: 'plugin', + defaultGroupRouteMode: 'shared', + ...overrides + } as never +} + +export async function createService( + options: { + tables?: Partial + baseDir?: string + clearCache?: (conversation: ConversationRecord) => Promise + config?: Record + } = {} +) { + const database = new FakeDatabase() + const events: { name: string; args: unknown[] }[] = [] + const syncCalls: string[] = [] + + for (const [table, rows] of Object.entries(options.tables ?? {})) { + database.tables[table] = (rows ?? []).map((row) => ({ ...row })) + } + + const clearCacheCalls: string[] = [] + const clearConversation = async (conversation: ConversationRecord) => { + clearCacheCalls.push(conversation.id) + await options.clearCache?.(conversation) + return true + } + const ctx = { + database, + logger: { + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + success: () => {} + }, + baseDir: + options.baseDir ?? + (await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-core-test-'))), + root: { + parallel: async (name: string, ...args: unknown[]) => { + events.push({ name, args }) + } + }, + chatluna: { + conversation: { + getArchive: async (id: string) => + database.tables.chatluna_archive.find( + (item) => item.id === id + ) as ArchiveRecord | undefined + }, + conversationRuntime: { + withConversationSync: async ( + conversation: ConversationRecord, + callback: () => Promise + ) => { + syncCalls.push(conversation.id) + return callback() + }, + clearConversationInterfaceLocked: clearConversation, + clearConversationInterface: async ( + conversation: ConversationRecord + ) => clearConversation(conversation) + } + } + } as never + + const service = new ConversationService(ctx, createConfig(options.config)) + + return { + service, + database, + ctx, + clearCacheCalls, + syncCalls, + events + } +} + +export async function createMemoryService( + options: { + tables?: Partial + config?: Record + baseDir?: string + } = {} +) { + const app = new Context() + app.baseDir = + options.baseDir ?? + (await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-core-test-'))) + app.plugin(memory) + app.plugin(ChatLunaService, createConfig(options.config)) + await app.start() + + for (const [table, rows] of Object.entries(options.tables ?? {})) { + for (const row of rows ?? []) { + await app.database.create(table as never, row as never) + } + } + + return { + app, + ctx: app, + database: app.database, + service: app.chatluna.conversation + } +} + +export function createConversation( + overrides: Partial = {} +): ConversationRecord { + const now = new Date('2026-03-21T00:00:00.000Z') + + return { + id: 'conversation-1', + seq: 1, + bindingKey: 'shared:discord:bot:guild', + title: 'Conversation 1', + model: 'test-platform/test-model', + preset: 'default-preset', + chatMode: 'plugin', + createdBy: 'user', + createdAt: now, + updatedAt: now, + lastChatAt: now, + status: 'active', + latestMessageId: 'message-2', + additional_kwargs: null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: null, + legacyMeta: null, + ...overrides + } +} + +export function createMessage( + overrides: Partial = {} +): MessageRecord { + return { + id: 'message-1', + conversationId: 'conversation-1', + parentId: null, + role: 'human', + text: 'hello', + content: null, + name: 'user', + tool_call_id: null, + tool_calls: null, + additional_kwargs: null, + additional_kwargs_binary: null, + rawId: null, + createdAt: new Date('2026-03-21T00:00:00.000Z'), + ...overrides + } +} diff --git a/packages/core/tests/tsconfig.json b/packages/core/tests/tsconfig.json new file mode 100644 index 000000000..356ff1c23 --- /dev/null +++ b/packages/core/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "types": ["node", "mocha", "chai"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} From 9a59e73e38422d7f8deb8eebcd7e1042ec465f39 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 04:19:46 +0800 Subject: [PATCH 06/20] fix(core): harden conversation lifecycle and legacy cleanup Keep conversation state, preset routing, and archive flows consistent as the room-to-conversation migration finishes. Tighten cleanup and validation paths so legacy data and admin-only actions behave predictably. --- package.json | 15 +- packages/adapter-dify/src/requester.ts | 2 +- .../memory/message/database_history.ts | 15 +- packages/core/src/locales/en-US.yml | 2 +- packages/core/src/locales/zh-CN.yml | 2 +- .../src/middlewares/chat/read_chat_message.ts | 4 +- .../src/middlewares/chat/rollback_chat.ts | 105 +- .../core/src/middlewares/chat/stop_chat.ts | 84 +- .../src/middlewares/preset/delete_preset.ts | 45 +- packages/core/src/middlewares/system/wipe.ts | 34 +- packages/core/src/migration/legacy_tables.ts | 27 +- .../src/migration/room_to_conversation.ts | 63 +- packages/core/src/migration/validators.ts | 15 +- packages/core/src/services/chat.ts | 51 +- packages/core/src/services/conversation.ts | 442 +++-- packages/core/src/utils/koishi.ts | 10 +- .../core/test/conversation-runtime.test.ts | 1610 ----------------- yakumo.yml | 16 +- 18 files changed, 612 insertions(+), 1930 deletions(-) delete mode 100644 packages/core/test/conversation-runtime.test.ts diff --git a/package.json b/package.json index f7240a67a..12012f34e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "yarn process-dynamic-import --lint && yarn fast-build shared-prompt-renderer && yarn fast-build core && yarn fast-build", "bump": "yakumo version", "dep": "yakumo upgrade", + "test": "cross-env TS_NODE_PROJECT=packages/core/tests/tsconfig.json yakumo mocha -r tsconfig-paths/register -r esbuild-register -r yml-register --exit", "pub": "yakumo publish --tag latest", "pub:next": "yakumo publish --tag next", "lint": "yarn eslint packages --cache --ext=ts", @@ -23,16 +24,20 @@ "@koishijs/cache": "^2.1.0", "@koishijs/censor": "^1.1.0", "@koishijs/client": "^5.30.9", + "@koishijs/plugin-database-memory": "^3.7.0", "@koishijs/plugin-hmr": "^1.2.9", "@koishijs/scripts": "^4.6.1", + "@types/chai": "^4.3.20", "@types/he": "^1.2.3", "@types/js-yaml": "^4.0.9", "@types/marked": "^6.0.0", + "@types/mocha": "^10.0.10", "@types/node": "^22.18.7", "@types/qrcode": "^1.5.5", "@typescript-eslint/eslint-plugin": "^7.18.1-alpha.3", "@typescript-eslint/parser": "^8.45.1-alpha.0", "atsc": "^2.1.0", + "chai": "^4.4.1", "cross-env": "^7.0.3", "esbuild": "^0.25.10", "esbuild-register": "npm:@shigma/esbuild-register@^1.1.1", @@ -57,18 +62,20 @@ "marked": "^15.0.12", "marked-highlight": "^2.2.2", "marked-katex-extension": "^5.1.5", + "mocha": "^9.2.2", "prettier": "^3.6.2", "qrcode": "^1.5.4", "socks-proxy-agent": "^8.0.5", + "tsconfig-paths": "^4.2.0", "tsx": "^4.20.6", "typescript": "^5.9.2", "undici": "^6.21.3", "user-agents": "^2.0.0-alpha.679", "ws": "^8.18.3", - "yakumo": "^1.0.0", - "yakumo-esbuild": "^1.0.0", - "yakumo-mocha": "^1.0.0", - "yakumo-tsc": "^1.0.0", + "yakumo": "^1.0.0-beta.20", + "yakumo-esbuild": "^1.0.0-beta.7", + "yakumo-mocha": "^1.0.0-beta.2", + "yakumo-tsc": "^1.0.0-beta.5", "yml-register": "^1.2.5", "zod": "3.25.76", "zod-to-json-schema": "^3.24.6" diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index ae1d89264..243036147 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -300,7 +300,7 @@ export class DifyRequester extends ModelRequester { throw new ChatLunaError( ChatLunaErrorCode.API_REQUEST_FAILED, new Error( - 'error when calling qwen completion, Result: ' + chunk + 'error when calling dify completion, Result: ' + chunk ) ) } diff --git a/packages/core/src/llm-core/memory/message/database_history.ts b/packages/core/src/llm-core/memory/message/database_history.ts index 9467b7a64..51a60af0c 100644 --- a/packages/core/src/llm-core/memory/message/database_history.ts +++ b/packages/core/src/llm-core/memory/message/database_history.ts @@ -316,12 +316,20 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { let currentMessageId = this._latestId let isBad = false + const seen = new Set() if (currentMessageId == null && queried.length > 0) { isBad = true } while (currentMessageId != null && !isBad) { + if (seen.has(currentMessageId)) { + isBad = true + break + } + + seen.add(currentMessageId) + const currentMessage = queried.find( (item) => item.id === currentMessageId ) @@ -422,12 +430,13 @@ export class KoishiChatMessageHistory extends BaseChatMessageHistory { id: this.conversationId, bindingKey: this.conversationId, title: 'Conversation', - model: '', - preset: '', - chatMode: '', + model: this._ctx.chatluna.config.defaultModel, + preset: this._ctx.chatluna.config.defaultPreset, + chatMode: this._ctx.chatluna.config.defaultChatMode, createdBy: 'system', createdAt: new Date(), updatedAt: new Date(), + lastChatAt: new Date(), status: 'active', latestMessageId: null, additional_kwargs: null, diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 39d3d810d..96ad994a5 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -617,7 +617,7 @@ commands: messages: only_one_preset: 'Cannot delete the only existing preset.' not_found: 'Preset not found. Check the name and try again.' - confirm_delete: 'Delete preset {0}? Y to confirm, any other key to cancel. Note: Associated sessions will be deleted.' + confirm_delete: 'Delete preset {0}? Y to confirm, any other key to cancel. Note: Associated sessions will switch to another available preset.' timeout: 'Operation timed out. Cancelled deleting preset: {0}.' cancelled: 'Cancelled deleting preset: {0}' success: 'Preset {0} deleted. Restarting to apply changes.' diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 995d8b55d..2740089da 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -617,7 +617,7 @@ commands: messages: only_one_preset: '现在只有一个预设了,删除后将无法使用预设功能,所以不允许删除。' not_found: '找不到该预设!请检查你是否输入了正确的预设?' - confirm_delete: '是否要删除 {0} 预设?输入大写 Y 来确认删除,输入其他字符来取消删除。提示:删除后使用了该预设的会话将会自动删除无法使用。' + confirm_delete: '是否要删除 {0} 预设?输入大写 Y 来确认删除,输入其他字符来取消删除。提示:删除后使用该预设的会话会自动切换到其它可用预设。' timeout: '删除预设超时,已取消删除预设: {0}。' cancelled: '已取消删除预设: {0}' success: '已删除预设: {0},即将自动重启完成更改。' diff --git a/packages/core/src/middlewares/chat/read_chat_message.ts b/packages/core/src/middlewares/chat/read_chat_message.ts index ed0474d73..11a7ffe47 100644 --- a/packages/core/src/middlewares/chat/read_chat_message.ts +++ b/packages/core/src/middlewares/chat/read_chat_message.ts @@ -75,7 +75,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ).value if (preset != null) { - context.options.presetLane = preset.triggerKeyword[0] + context.options.presetLane = parsed.preset if ( parsed.queryOnly && @@ -86,7 +86,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.command = 'conversation_current' context.options.conversation_manage = { ...context.options.conversation_manage, - presetLane: preset.triggerKeyword[0] + presetLane: parsed.preset } context.message = null return ChainMiddlewareRunStatus.CONTINUE diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 001026e94..d35324413 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -4,7 +4,12 @@ import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { MessageRecord } from '../../services/conversation_types' import { logger } from '../..' -import { transformMessageContentToElements } from '../../utils/koishi' +import { + checkAdmin, + transformMessageContentToElements +} from '../../utils/koishi' + +const MAX_ROLLBACK_HOPS = 1000 async function decodeMessageContent(message: MessageRecord) { try { @@ -26,49 +31,72 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (command !== 'rollback') return ChainMiddlewareRunStatus.SKIPPED const rollbackRound = context.options.rollback_round ?? 1 - const resolved = - context.options.resolvedConversation !== undefined - ? { - conversation: context.options.resolvedConversation - } - : context.options.conversationId != null || - context.options.targetConversation != null - ? { - conversation: - await ctx.chatluna.conversation.resolveCommandConversation( - session, - { - conversationId: - context.options.conversationId, - targetConversation: - context.options.targetConversation, - presetLane: context.options.presetLane, - permission: 'manage' - } - ) - } - : await ctx.chatluna.conversation.getCurrentConversation( - session + let conversation = + context.options.resolvedConversation != null + ? await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: + context.options.resolvedConversation.id, + presetLane: context.options.presetLane, + permission: 'manage' + } + ) + : await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: context.options.conversationId, + targetConversation: + context.options.targetConversation, + presetLane: context.options.presetLane, + permission: 'manage' + } + ) + + if (conversation == null) { + conversation = ( + await ctx.chatluna.conversation.getCurrentConversation( + session + ) + ).conversation + + if (conversation != null) { + conversation = + await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: conversation.id, + presetLane: context.options.presetLane, + permission: 'manage' + } ) - const conversation = resolved.conversation + } + } if (conversation == null) { context.message = session.text('.conversation_not_exist') return ChainMiddlewareRunStatus.STOP } + const resolvedContext = + await ctx.chatluna.conversation.resolveContext(session, { + conversationId: conversation.id, + presetLane: context.options.presetLane + }) + if ( - ( - await ctx.chatluna.conversation.resolveContext(session, { - conversationId: conversation.id, - presetLane: context.options.presetLane - }) - ).constraint.lockConversation + resolvedContext.constraint.manageMode === 'admin' && + !(await checkAdmin(session)) ) { context.message = session.text('.conversation_not_exist') return ChainMiddlewareRunStatus.STOP } + if (resolvedContext.constraint.lockConversation) { + context.message = session.text('.conversation_not_exist') + return ChainMiddlewareRunStatus.STOP + } + context.options.conversationId = conversation.id await ctx.chatluna.conversationRuntime.clearConversationInterface( @@ -79,8 +107,23 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const messages: MessageRecord[] = [] let humanMessage: MessageRecord | undefined let humanCount = 0 + const seen = new Set() while (parentId != null) { + if (seen.has(parentId)) { + logger.warn(`rollback cycle detected: ${parentId}`) + break + } + + if (seen.size >= MAX_ROLLBACK_HOPS) { + logger.warn( + `rollback hop limit reached: ${conversation.id}` + ) + break + } + + seen.add(parentId) + const message = await ctx.database.get('chatluna_message', { conversationId: conversation.id, id: parentId diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index 0e03c08b0..b49da1867 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -2,6 +2,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { getRequestId } from '../../utils/chat_request' +import { checkAdmin } from '../../utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain @@ -10,49 +11,72 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if (command !== 'stop_chat') return ChainMiddlewareRunStatus.SKIPPED - const resolved = - context.options.resolvedConversation !== undefined - ? { - conversation: context.options.resolvedConversation - } - : context.options.conversationId != null || - context.options.targetConversation != null - ? { - conversation: - await ctx.chatluna.conversation.resolveCommandConversation( - session, - { - conversationId: - context.options.conversationId, - targetConversation: - context.options.targetConversation, - presetLane: context.options.presetLane, - permission: 'manage' - } - ) - } - : await ctx.chatluna.conversation.getCurrentConversation( - session + let conversation = + context.options.resolvedConversation != null + ? await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: + context.options.resolvedConversation.id, + presetLane: context.options.presetLane, + permission: 'manage' + } + ) + : await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: context.options.conversationId, + targetConversation: + context.options.targetConversation, + presetLane: context.options.presetLane, + permission: 'manage' + } + ) + + if (conversation == null) { + conversation = ( + await ctx.chatluna.conversation.getCurrentConversation( + session + ) + ).conversation + + if (conversation != null) { + conversation = + await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + conversationId: conversation.id, + presetLane: context.options.presetLane, + permission: 'manage' + } ) - const conversation = resolved.conversation + } + } if (conversation == null) { context.message = session.text('.no_active_chat') return ChainMiddlewareRunStatus.STOP } + const resolvedContext = + await ctx.chatluna.conversation.resolveContext(session, { + conversationId: conversation.id, + presetLane: context.options.presetLane + }) + if ( - ( - await ctx.chatluna.conversation.resolveContext(session, { - conversationId: conversation.id, - presetLane: context.options.presetLane - }) - ).constraint.lockConversation + resolvedContext.constraint.manageMode === 'admin' && + !(await checkAdmin(session)) ) { context.message = session.text('.stop_failed') return ChainMiddlewareRunStatus.STOP } + if (resolvedContext.constraint.lockConversation) { + context.message = session.text('.stop_failed') + return ChainMiddlewareRunStatus.STOP + } + context.options.conversationId = conversation.id const requestId = getRequestId(session, conversation.id) diff --git a/packages/core/src/middlewares/preset/delete_preset.ts b/packages/core/src/middlewares/preset/delete_preset.ts index 5cf574194..36ed3682a 100644 --- a/packages/core/src/middlewares/preset/delete_preset.ts +++ b/packages/core/src/middlewares/preset/delete_preset.ts @@ -2,6 +2,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import fs from 'fs/promises' +import { ConversationRecord } from '../../services/conversation_types' export function apply(ctx: Context, _config: Config, chain: ChatChain) { chain @@ -20,13 +21,30 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - const allPreset = preset.getAllPreset().value + const allPreset = preset.getAllPreset(false).value if (allPreset.length === 1) { await context.send(session.text('.only_one_preset')) return ChainMiddlewareRunStatus.STOP } + const usesDefaultPreset = presetTemplate.triggerKeyword.includes( + _config.defaultPreset + ) + const nextPreset = + !usesDefaultPreset && + preset.getPreset(_config.defaultPreset, false).value != null + ? _config.defaultPreset + : allPreset.find( + (name) => + !presetTemplate.triggerKeyword.includes(name) + ) + + if (nextPreset == null) { + await context.send(session.text('.only_one_preset')) + return ChainMiddlewareRunStatus.STOP + } + await context.send(session.text('.confirm_delete', [presetName])) const result = await session.prompt(1000 * 30) @@ -41,6 +59,31 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } + const conversations = (await ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + const updatedAt = new Date() + const patched = conversations + .filter((conversation) => + presetTemplate.triggerKeyword.includes(conversation.preset) + ) + .map((conversation) => ({ + ...conversation, + preset: nextPreset, + updatedAt + })) + + if (patched.length > 0) { + await ctx.database.upsert('chatluna_conversation', patched) + } + + if (usesDefaultPreset) { + _config.defaultPreset = nextPreset + ctx.chatluna.config.defaultPreset = nextPreset + ctx.chatluna.currentConfig.defaultPreset = nextPreset + } + await fs.rm(presetTemplate.path) context.message = session.text('.success', [presetName]) diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index 9205d2844..8247d7a48 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -5,6 +5,7 @@ import { createLogger } from 'koishi-plugin-chatluna/utils/logger' import fs from 'fs/promises' import { createLegacyTableRetention, + dropTableIfExists, LEGACY_MIGRATION_TABLES, LEGACY_RETENTION_META_KEY, LEGACY_RUNTIME_TABLES, @@ -78,30 +79,31 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { // drop database tables - await ctx.database.drop('chatluna_conversation') - await ctx.database.drop('chatluna_message') - await ctx.database.drop('chatluna_binding') - await ctx.database.drop('chatluna_constraint') - await ctx.database.drop('chatluna_archive') - await ctx.database.drop('chatluna_acl') - await ctx.database.drop('chatluna_meta') + for (const table of [ + 'chatluna_conversation', + 'chatluna_message', + 'chatluna_binding', + 'chatluna_constraint', + 'chatluna_archive', + 'chatluna_acl', + 'chatluna_meta' + ]) { + await dropTableIfExists(ctx, table) + } + for (const table of LEGACY_MIGRATION_TABLES) { - await ctx.database.drop(table) + await dropTableIfExists(ctx, table) } for (const table of LEGACY_RUNTIME_TABLES) { - await ctx.database.drop(table) + await dropTableIfExists(ctx, table) } - await ctx.database.drop('chatluna_docstore') + await dropTableIfExists(ctx, 'chatluna_docstore') // knowledge - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await ctx.database.drop('chathub_knowledge' as any) - } catch (e) { - logger.warn(`wipe: ${e}`) - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await dropTableIfExists(ctx, 'chathub_knowledge' as any) // drop caches diff --git a/packages/core/src/migration/legacy_tables.ts b/packages/core/src/migration/legacy_tables.ts index f6ff8e298..d52cfec29 100644 --- a/packages/core/src/migration/legacy_tables.ts +++ b/packages/core/src/migration/legacy_tables.ts @@ -42,6 +42,31 @@ export function getLegacySchemaSentinelDir(baseDir: string) { return path.dirname(getLegacySchemaSentinel(baseDir)) } +function isMissingTableError(error: unknown) { + const message = String(error).toLowerCase() + return ( + message.includes('no such table') || + message.includes('unknown table') || + message.includes('not found') || + message.includes("doesn't exist") || + message.includes('does not exist') + ) +} + +export async function dropTableIfExists(ctx: Context, table: string) { + try { + await ctx.database.drop(table as never) + return true + } catch (error) { + if (!isMissingTableError(error)) { + throw error + } + + ctx.logger.warn(`drop ${table}: ${error}`) + return false + } +} + export async function purgeLegacyTables(ctx: Context) { const failed: string[] = [] @@ -50,7 +75,7 @@ export async function purgeLegacyTables(ctx: Context) { ...LEGACY_RUNTIME_TABLES ]) { try { - await ctx.database.drop(table) + await dropTableIfExists(ctx, table) } catch (error) { ctx.logger.warn(`purge legacy ${table}: ${error}`) failed.push(table) diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts index e59610e4b..051f5959d 100644 --- a/packages/core/src/migration/room_to_conversation.ts +++ b/packages/core/src/migration/room_to_conversation.ts @@ -41,6 +41,9 @@ export async function runRoomToConversationMigration( ctx: Context, config: Config ) { + const result = await readMetaValue< + Awaited> + >(ctx, 'validation_result') const schemaVersion = (await readMetaValue(ctx, 'schema_version')) ?? 0 const roomDone = @@ -48,7 +51,10 @@ export async function runRoomToConversationMigration( const messageDone = (await readMetaValue(ctx, 'message_migration_done')) ?? false - if (schemaVersion >= BUILTIN_SCHEMA_VERSION && roomDone && messageDone) { + if ( + schemaVersion >= BUILTIN_SCHEMA_VERSION && + (result?.passed === true || (roomDone && messageDone)) + ) { return await ensureMigrationValidated(ctx, config) } @@ -71,25 +77,18 @@ export async function runRoomToConversationMigration( await migrateMessages(ctx) await migrateBindings(ctx) - const result = await validateRoomMigration(ctx, config) - await writeMetaValue(ctx, 'validation_result', result) - await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) - await writeMetaValue(ctx, 'room_migration_done', true) - await writeMetaValue(ctx, 'message_migration_done', true) + const validated = await validateRoomMigration(ctx, config) + await writeMetaValue(ctx, 'validation_result', validated) - if (!result.passed) { + if (!validated.passed) { throw new Error('ChatLuna migration validation failed.') } await purgeLegacyTables(ctx) - await writeMetaValue( - ctx, - LEGACY_RETENTION_META_KEY, - createLegacyTableRetention('purged') - ) + await writeMigrationFinished(ctx) ctx.logger.info('Built-in ChatLuna migration finished.') - return result + return validated } // (#13) guard against re-entrant call: ensureMigrationValidated only calls @@ -100,13 +99,16 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { const result = await readMetaValue< Awaited> >(ctx, 'validation_result') + const retention = await readMetaValue<{ + state?: string + }>(ctx, LEGACY_RETENTION_META_KEY) if (result?.passed === true) { - await writeMetaValue( - ctx, - LEGACY_RETENTION_META_KEY, - createLegacyTableRetention('purged') - ) + if (retention?.state !== 'purged') { + await purgeLegacyTables(ctx) + } + + await writeMigrationFinished(ctx) return result } @@ -136,13 +138,20 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { } await purgeLegacyTables(ctx) + await writeMigrationFinished(ctx) + + return validated +} + +async function writeMigrationFinished(ctx: Context) { + await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) + await writeMetaValue(ctx, 'room_migration_done', true) + await writeMetaValue(ctx, 'message_migration_done', true) await writeMetaValue( ctx, LEGACY_RETENTION_META_KEY, createLegacyTableRetention('purged') ) - - return validated } // (#6) removed redundant inner `done` check — the caller already guards on room_migration_done @@ -459,12 +468,14 @@ function buildAclRecords( }) } - add({ - conversationId, - principalType: 'user', - principalId: room.roomMasterId, - permission: 'manage' - }) + if (room.roomMasterId.length > 0) { + add({ + conversationId, + principalType: 'user', + principalId: room.roomMasterId, + permission: 'manage' + }) + } return Array.from(map.values()) } diff --git a/packages/core/src/migration/validators.ts b/packages/core/src/migration/validators.ts index dd9b61380..a1277b3a3 100644 --- a/packages/core/src/migration/validators.ts +++ b/packages/core/src/migration/validators.ts @@ -17,6 +17,7 @@ import type { import type { MigrationValidationResult } from './types' export type { MigrationValidationResult } from './types' export { + dropTableIfExists, getLegacySchemaSentinelDir, getLegacySchemaSentinel, LEGACY_MIGRATION_TABLES, @@ -59,14 +60,19 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { ctx, 'chathub_message', (rows) => { - legacyMessageCount += rows.length + legacyMessageCount += rows.filter((row) => + validConversationIds.has(row.conversation) + ).length } ) let migratedLegacyMessageCount = 0 await readTableBatches(ctx, 'chatluna_message', (rows) => { for (const row of rows) { - if (row.createdAt == null) { + if ( + row.createdAt == null && + validConversationIds.has(row.conversationId) + ) { migratedLegacyMessageCount += 1 } } @@ -182,10 +188,13 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { const missingBindingConversationIds: string[] = [] for (const user of users) { + if (user.defaultRoomId == null || user.defaultRoomId === 0) { + continue + } + const conversation = conversationsByRoomId.get(user.defaultRoomId) if (conversation == null) { - missingBindingConversationIds.push(String(user.defaultRoomId)) continue } diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index 19c618b95..583560c40 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -52,7 +52,7 @@ import { MessageTransformer } from './message_transform' import { ChatEvents, ToolMaskArg, ToolMaskResolver } from './types' import { ConversationService } from './conversation' import { ConversationRuntime } from './conversation_runtime' -import { ConversationRecord } from './conversation_types' +import { ConstraintRecord, ConversationRecord } from './conversation_types' import { chatLunaFetch, ws } from 'koishi-plugin-chatluna/utils/request' import * as fetchType from 'undici/types/fetch' import { ClientOptions, WebSocket } from 'ws' @@ -113,6 +113,9 @@ export class ChatLunaService extends Service { this._createTempDir() this._defineDatabase() + this.ctx.on('ready', async () => { + await this._dedupeConstraintNames() + }) } async installPlugin(plugin: ChatLunaPlugin) { @@ -447,6 +450,49 @@ export class ChatLunaService extends Service { } } + private async _dedupeConstraintNames() { + const rows = (await this.ctx.database.get( + 'chatluna_constraint', + {} + )) as ConstraintRecord[] + + if (rows.length < 2) { + return + } + + const names = new Set() + const ids = [...rows] + .sort((left, right) => { + const leftTime = left.updatedAt?.getTime() ?? 0 + const rightTime = right.updatedAt?.getTime() ?? 0 + if (leftTime !== rightTime) { + return rightTime - leftTime + } + + return (right.id ?? 0) - (left.id ?? 0) + }) + .filter((row) => { + if (!names.has(row.name)) { + names.add(row.name) + return false + } + + return row.id != null + }) + .map((row) => row.id!) + + if (ids.length === 0) { + return + } + + this.ctx.logger.warn( + `Removing ${ids.length} duplicate chatluna_constraint rows.` + ) + await this.ctx.database.remove('chatluna_constraint', { + id: ids + }) + } + private _defineDatabase() { const ctx = this.ctx const legacyTablesVisible = !fs.existsSync( @@ -1003,7 +1049,8 @@ export class ChatLunaService extends Service { }, { autoInc: true, - primary: 'id' + primary: 'id', + unique: ['name'] } ) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 8beb5db38..a42c51d97 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -205,9 +205,9 @@ export class ConversationService { constraint.defaultModel ?? this.config.defaultModel, effectivePreset: - options.presetLane ?? constraint.fixedPreset ?? allowedConversation?.preset ?? + options.presetLane ?? constraint.defaultPreset ?? this.config.defaultPreset, effectiveChatMode: @@ -502,17 +502,27 @@ export class ConversationService { const previousConversation = resolved.binding?.activeConversationId ? await this.getConversation(resolved.binding.activeConversationId) : null + const targetBinding = + conversation.bindingKey === resolved.bindingKey + ? resolved.binding + : await this.getBinding(conversation.bindingKey) + const targetPreviousConversation = targetBinding?.activeConversationId + ? await this.getConversation(targetBinding.activeConversationId) + : null await this.ctx.root.parallel('chatluna/conversation-before-switch', { bindingKey: resolved.bindingKey, conversation, previousConversation }) - await this.setActiveConversation(resolved.bindingKey, conversation.id) + await this.setActiveConversation( + conversation.bindingKey, + conversation.id + ) await this.ctx.root.parallel('chatluna/conversation-after-switch', { - bindingKey: resolved.bindingKey, + bindingKey: conversation.bindingKey, conversation, - previousConversation + previousConversation: targetPreviousConversation }) return conversation @@ -552,7 +562,7 @@ export class ConversationService { if (conversation.status !== 'archived') { await this.setActiveConversation( - resolved.bindingKey, + conversation.bindingKey, conversation.id ) return conversation @@ -717,109 +727,125 @@ export class ConversationService { throw new Error('Conversation not found.') } - await this.ctx.root.parallel('chatluna/conversation-before-archive', { - conversation - }) - - if ( - conversation.status === 'archived' && - conversation.archiveId != null - ) { - const archive = await this.getArchive(conversation.archiveId) - if (archive != null) { - return { - conversation, - archive, - path: archive.path + return this.ctx.chatluna.conversationRuntime.withConversationSync( + conversation, + async () => { + const current = await this.getConversation(conversationId) + if (current == null) { + throw new Error('Conversation not found.') } - } - } - const archiveDir = await this.ensureDataDir( - path.join('archive', conversation.id) - ) + if ( + current.status === 'archived' && + current.archiveId != null + ) { + const archive = await this.getArchive(current.archiveId) + if (archive != null) { + return { + conversation: current, + archive, + path: archive.path + } + } + } - const messages = await this.listMessages(conversation.id) - const payload: ConversationArchivePayload = { - formatVersion: 1, - exportedAt: new Date().toISOString(), - conversation: serializeConversation(conversation), - messages: messages.map(serializeMessage) - } - const messageLines = payload.messages - .map((message) => JSON.stringify(message)) - .join('\n') - const messageBuffer = await gzipEncode(messageLines) - const checksum = createHash('sha256') - .update(messageBuffer) - .digest('hex') - - await fs.writeFile( - path.join(archiveDir, 'conversation.json'), - JSON.stringify(payload.conversation, null, 2), - 'utf8' - ) - await fs.writeFile( - path.join(archiveDir, 'messages.jsonl.gz'), - messageBuffer - ) + await this.ctx.root.parallel( + 'chatluna/conversation-before-archive', + { + conversation: current + } + ) - const now = new Date() - const manifest: ArchiveManifest = { - format: 'chatluna-archive', - formatVersion: payload.formatVersion, - conversationId: conversation.id, - messageCount: payload.messages.length, - checksum, - size: messageBuffer.byteLength, - createdAt: now.toISOString() - } - await fs.writeFile( - path.join(archiveDir, 'manifest.json'), - JSON.stringify(manifest, null, 2), - 'utf8' - ) + const archiveDir = await this.ensureDataDir( + path.join('archive', current.id) + ) + const messages = await this.listMessages(current.id) + const payload: ConversationArchivePayload = { + formatVersion: 1, + exportedAt: new Date().toISOString(), + conversation: serializeConversation(current), + messages: messages.map(serializeMessage) + } + const messageLines = payload.messages + .map((message) => JSON.stringify(message)) + .join('\n') + const messageBuffer = await gzipEncode(messageLines) + const checksum = createHash('sha256') + .update(messageBuffer) + .digest('hex') + + await fs.writeFile( + path.join(archiveDir, 'conversation.json'), + JSON.stringify(payload.conversation, null, 2), + 'utf8' + ) + await fs.writeFile( + path.join(archiveDir, 'messages.jsonl.gz'), + messageBuffer + ) - const archive: ArchiveRecord = { - id: randomUUID(), - conversationId: manifest.conversationId, - path: archiveDir, - formatVersion: manifest.formatVersion, - messageCount: manifest.messageCount, - checksum: manifest.checksum, - size: manifest.size, - state: 'ready', - createdAt: now, - restoredAt: null - } + const now = new Date() + const manifest: ArchiveManifest = { + format: 'chatluna-archive', + formatVersion: payload.formatVersion, + conversationId: current.id, + messageCount: payload.messages.length, + checksum, + size: messageBuffer.byteLength, + createdAt: now.toISOString() + } + await fs.writeFile( + path.join(archiveDir, 'manifest.json'), + JSON.stringify(manifest, null, 2), + 'utf8' + ) - await this.ctx.database.upsert('chatluna_archive', [archive]) - await this.touchConversation(conversation.id, { - status: 'archived', - archivedAt: now, - archiveId: archive.id - }) - await this.unbindConversation(conversation.id) - await this.ctx.database.remove('chatluna_message', { - conversationId: conversation.id - }) - await this.ctx.chatluna.conversationRuntime.clearConversationInterface( - conversation - ) + const archive: ArchiveRecord = { + id: randomUUID(), + conversationId: manifest.conversationId, + path: archiveDir, + formatVersion: manifest.formatVersion, + messageCount: manifest.messageCount, + checksum: manifest.checksum, + size: manifest.size, + state: 'ready', + createdAt: now, + restoredAt: null + } - const updatedConversation = await this.getConversation(conversation.id) + await this.ctx.database.upsert('chatluna_archive', [archive]) + await this.touchConversation(current.id, { + status: 'archived', + archivedAt: now, + archiveId: archive.id + }) + await this.unbindConversation(current.id) + await this.ctx.database.remove('chatluna_message', { + conversationId: current.id + }) - await this.ctx.root.parallel('chatluna/conversation-after-archive', { - conversation: updatedConversation ?? conversation, - archive, - path: archiveDir - }) + const updatedConversation = await this.getConversation( + current.id + ) + await this.ctx.chatluna.conversationRuntime.clearConversationInterfaceLocked( + updatedConversation ?? current + ) + await this.ctx.root.parallel( + 'chatluna/conversation-after-archive', + { + conversation: updatedConversation ?? current, + archive, + path: archiveDir + } + ) - return { - conversation: updatedConversation ?? conversation, - archive, - path: archiveDir - } + return { + conversation: updatedConversation ?? current, + archive, + path: archiveDir + } + } + ) } async restoreConversation( @@ -848,6 +874,10 @@ export class ConversationService { throw new Error('Archive not found.') } + if (archive.conversationId !== conversation.id) { + throw new Error('Archive does not belong to conversation.') + } + await this.assertManageAllowed(session, resolved.constraint) const target = await this.getManagedConstraintByBindingKey( @@ -862,88 +892,105 @@ export class ConversationService { throw new Error('Conversation restore is disabled by constraint.') } - await this.ctx.root.parallel('chatluna/conversation-before-restore', { + return this.ctx.chatluna.conversationRuntime.withConversationSync( conversation, - archive - }) - - await this.ctx.database.upsert('chatluna_archive', [ - { - ...archive, - state: 'restoring' - } - ]) + async () => { + const current = await this.getConversation(conversation.id) + if (current == null) { + throw new Error('Conversation not found.') + } - try { - const payload = await this.readArchivePayload(archive.path) - const restoredConversation = deserializeConversation( - payload.conversation - ) - const restoredMessages = payload.messages.map(deserializeMessage) + await this.ctx.root.parallel( + 'chatluna/conversation-before-restore', + { + conversation: current, + archive + } + ) - await this.ctx.database.remove('chatluna_message', { - conversationId: conversation.id - }) + await this.ctx.database.upsert('chatluna_archive', [ + { + ...archive, + state: 'restoring' + } + ]) - if (restoredMessages.length > 0) { - await this.ctx.database.upsert( - 'chatluna_message', - restoredMessages - ) - } + try { + const payload = await this.readArchivePayload(archive.path) + const restoredConversation = deserializeConversation( + payload.conversation + ) + const restoredMessages = payload.messages.map( + (message) => ({ + ...deserializeMessage(message), + conversationId: current.id + }) + ) - await this.ctx.database.upsert('chatluna_conversation', [ - { - ...conversation, - ...restoredConversation, - id: conversation.id, - status: 'active', - archivedAt: null, - archiveId: null, - updatedAt: new Date() - } - ]) + await this.ctx.database.remove('chatluna_message', { + conversationId: current.id + }) - await this.setActiveConversation( - restoredConversation.bindingKey, - conversation.id - ) - await this.ctx.database.upsert('chatluna_archive', [ - { - ...archive, - state: 'ready', - restoredAt: new Date() - } - ]) + if (restoredMessages.length > 0) { + await this.ctx.database.upsert( + 'chatluna_message', + restoredMessages + ) + } - const updatedConversation = await this.getConversation( - conversation.id - ) - if (updatedConversation == null) { - throw new Error('Conversation restore failed.') - } + await this.ctx.database.upsert('chatluna_conversation', [ + { + ...current, + ...restoredConversation, + id: current.id, + status: 'active', + archivedAt: null, + archiveId: null, + updatedAt: new Date() + } + ]) + await this.ctx.database.upsert('chatluna_archive', [ + { + ...archive, + state: 'ready', + restoredAt: new Date() + } + ]) + + const updatedConversation = await this.getConversation( + current.id + ) + if (updatedConversation == null) { + throw new Error('Conversation restore failed.') + } - await this.ctx.chatluna.conversationRuntime.clearConversationInterface( - updatedConversation - ) - await this.ctx.root.parallel( - 'chatluna/conversation-after-restore', - { - conversation: updatedConversation, - archive - } - ) + await this.setActiveConversation( + updatedConversation.bindingKey, + updatedConversation.id + ) + await this.ctx.chatluna.conversationRuntime.clearConversationInterfaceLocked( + updatedConversation + ) + await this.ctx.root.parallel( + 'chatluna/conversation-after-restore', + { + conversation: updatedConversation, + archive + } + ) - return updatedConversation - } catch (error) { - await this.ctx.database.upsert('chatluna_archive', [ - { - ...archive, - state: 'broken' + return updatedConversation + } catch (error) { + await this.ctx.database.upsert('chatluna_archive', [ + { + ...archive, + state: 'broken' + } + ]) + throw error } - ]) - throw error - } + } + ) } async exportMarkdown(conversation: ConversationRecord) { @@ -1023,25 +1070,42 @@ export class ConversationService { throw new Error('Conversation delete is locked by constraint.') } - const updated = await this.touchConversation(conversation.id, { - status: 'deleted', - archivedAt: null - }) - await this.ctx.root.parallel('chatluna/conversation-before-delete', { - conversation - }) - await this.unbindConversation(conversation.id) - await this.ctx.database.remove('chatluna_message', { - conversationId: conversation.id - }) - await this.removeAcl(conversation.id) - await this.ctx.chatluna.conversationRuntime.clearConversationInterface( - conversation + return this.ctx.chatluna.conversationRuntime.withConversationSync( + conversation, + async () => { + const current = await this.getConversation(conversation.id) + if (current == null) { + throw new Error('Conversation not found.') + } + + await this.ctx.root.parallel( + 'chatluna/conversation-before-delete', + { + conversation: current + } + ) + + const updated = await this.touchConversation(current.id, { + status: 'deleted', + archivedAt: null + }) + await this.unbindConversation(current.id) + await this.ctx.database.remove('chatluna_message', { + conversationId: current.id + }) + await this.removeAcl(current.id) + await this.ctx.chatluna.conversationRuntime.clearConversationInterfaceLocked( + updated ?? current + ) + await this.ctx.root.parallel( + 'chatluna/conversation-after-delete', + { + conversation: updated ?? current + } + ) + return updated ?? current + } ) - await this.ctx.root.parallel('chatluna/conversation-after-delete', { - conversation: updated ?? conversation - }) - return updated ?? conversation } async updateConversationUsage( diff --git a/packages/core/src/utils/koishi.ts b/packages/core/src/utils/koishi.ts index 214edfb96..1fc7e06ad 100644 --- a/packages/core/src/utils/koishi.ts +++ b/packages/core/src/utils/koishi.ts @@ -43,13 +43,19 @@ export async function checkAdmin(session: Session) { if (tested) { return true } - } catch {} + } catch (error) { + session.app.logger.debug(`checkAdmin permission test failed: ${error}`) + } const user = await session.getUser(session.userId, [ 'authority' ]) - return user?.authority >= 3 + if (user == null) { + return false + } + + return user.authority >= 3 } const tagRegExp = /<(\/?)([^!\s>/]+)([^>]*?)\s*(\/?)>/ diff --git a/packages/core/test/conversation-runtime.test.ts b/packages/core/test/conversation-runtime.test.ts deleted file mode 100644 index 85f0b0772..000000000 --- a/packages/core/test/conversation-runtime.test.ts +++ /dev/null @@ -1,1610 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import fs from 'node:fs/promises' -import os from 'node:os' -import path from 'node:path' -import { HumanMessage } from '@langchain/core/messages' -import { Pagination } from '../src/utils/pagination' -import { - bufferToArrayBuffer, - gzipDecode, - gzipEncode -} from '../src/utils/compression' -import { - getMessageContent, - parsePresetLaneInput -} from '../src/utils/message_content' -import { - type ACLRecord, - applyPresetLane, - type ArchiveRecord, - type BindingRecord, - computeBaseBindingKey, - type ConstraintRecord, - type ConversationRecord, - type MessageRecord -} from '../src/services/conversation_types' -import { ConversationService } from '../src/services/conversation' -import { ConversationRuntime } from '../src/services/conversation_runtime' -import { - createLegacyBindingKey, - inferLegacyGroupRouteModes -} from '../src/migration/validators' -import { - getLegacySchemaSentinel, - getLegacySchemaSentinelDir -} from '../src/migration/legacy_tables' -import { runRoomToConversationMigration } from '../src/migration/room_to_conversation' -import { purgeArchivedConversation } from '../src/utils/archive' - -type BindingSessionShape = { - platform?: string - selfId?: string - guildId?: string - userId?: string - channelId?: string - sid?: string - isDirect?: boolean - authority?: number -} - -type TableRow = Record -type Tables = Record - -class FakeDatabase { - tables: Tables = { - chatluna_meta: [], - chatluna_conversation: [], - chatluna_binding: [], - chatluna_archive: [], - chatluna_message: [], - chatluna_constraint: [], - chatluna_acl: [], - chathub_room_member: [], - chathub_room_group_member: [], - chathub_user: [], - chathub_room: [], - chathub_message: [], - chathub_conversation: [] - } - - async get(table: string, query: Record) { - return (this.tables[table] ?? []).filter((row) => - Object.entries(query).every(([key, expected]) => { - const actual = row[key] - - if ( - expected != null && - typeof expected === 'object' && - '$in' in expected - ) { - return Array.isArray(expected.$in) - ? expected.$in.includes(actual) - : false - } - - if (Array.isArray(expected)) { - return expected.includes(actual) - } - - return actual === expected - }) - ) - } - - async create(table: string, row: TableRow) { - ;(this.tables[table] ??= []).push({ ...row }) - } - - async upsert(table: string, rows: TableRow[]) { - const target = (this.tables[table] ??= []) - - for (const row of rows) { - const index = target.findIndex((current) => - this.samePrimary(table, current, row) - ) - if (index >= 0) { - target[index] = { ...target[index], ...row } - } else { - target.push({ ...row }) - } - } - } - - async remove(table: string, query: Record) { - const target = (this.tables[table] ??= []) - this.tables[table] = target.filter( - (row) => - !Object.entries(query).every(([key, expected]) => { - const actual = row[key] - if (Array.isArray(expected)) { - return expected.includes(actual) - } - return actual === expected - }) - ) - } - - async drop(table: string) { - this.tables[table] = [] - } - - private samePrimary(table: string, left: TableRow, right: TableRow) { - if (table === 'chatluna_binding') { - return left.bindingKey === right.bindingKey - } - - if (table === 'chatluna_archive') { - return left.id === right.id - } - - if (table === 'chatluna_message') { - return left.id === right.id - } - - if (table === 'chatluna_constraint') { - return left.id != null && left.id === right.id - } - - if (table === 'chatluna_meta') { - return left.key === right.key - } - - if (table === 'chatluna_acl') { - return ( - left.conversationId === right.conversationId && - left.principalType === right.principalType && - left.principalId === right.principalId && - left.permission === right.permission - ) - } - - return left.id === right.id - } -} - -function createSession(overrides: Partial = {}) { - return { - platform: 'discord', - selfId: 'bot', - guildId: 'guild', - channelId: 'channel', - userId: 'user', - sid: 'discord:channel:user', - isDirect: false, - authority: 3, - ...overrides - } as BindingSessionShape as never -} - -function createConfig(overrides: Record = {}) { - return { - defaultModel: 'test-platform/test-model', - defaultPreset: 'default-preset', - defaultChatMode: 'plugin', - defaultGroupRouteMode: 'shared', - ...overrides - } as never -} - -async function createService( - options: { - tables?: Partial - baseDir?: string - clearCache?: (conversation: ConversationRecord) => Promise - config?: Record - } = {} -) { - const database = new FakeDatabase() - const events: { name: string; args: unknown[] }[] = [] - - for (const [table, rows] of Object.entries(options.tables ?? {})) { - database.tables[table] = (rows ?? []).map((row) => ({ ...row })) - } - - const clearCacheCalls: string[] = [] - const ctx = { - database, - logger: { - info: () => {}, - error: () => {}, - warn: () => {}, - debug: () => {}, - success: () => {} - }, - baseDir: - options.baseDir ?? - (await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-core-test-'))), - root: { - parallel: async (name: string, ...args: unknown[]) => { - events.push({ name, args }) - } - }, - chatluna: { - conversation: { - getArchive: async (id: string) => - database.tables.chatluna_archive.find( - (item) => item.id === id - ) as ArchiveRecord | undefined - }, - conversationRuntime: { - clearConversationInterface: async ( - conversation: ConversationRecord - ) => { - clearCacheCalls.push(conversation.id) - await options.clearCache?.(conversation) - return true - } - } - } - } as never - - const service = new ConversationService(ctx, createConfig(options.config)) - - return { - service, - database, - ctx, - clearCacheCalls, - events - } -} - -function createConversation( - overrides: Partial = {} -): ConversationRecord { - const now = new Date('2026-03-21T00:00:00.000Z') - - return { - id: 'conversation-1', - seq: 1, - bindingKey: 'shared:discord:bot:guild', - title: 'Conversation 1', - model: 'test-platform/test-model', - preset: 'default-preset', - chatMode: 'plugin', - createdBy: 'user', - createdAt: now, - updatedAt: now, - lastChatAt: now, - status: 'active', - latestMessageId: 'message-2', - additional_kwargs: null, - compression: null, - archivedAt: null, - archiveId: null, - legacyRoomId: null, - legacyMeta: null, - ...overrides - } -} - -function createMessage(overrides: Partial = {}): MessageRecord { - return { - id: 'message-1', - conversationId: 'conversation-1', - parentId: null, - role: 'human', - text: 'hello', - content: null, - name: 'user', - tool_call_id: null, - tool_calls: null, - additional_kwargs: null, - additional_kwargs_binary: null, - rawId: null, - createdAt: new Date('2026-03-21T00:00:00.000Z'), - ...overrides - } -} - -test('conversation-first runtime removes legacy room entry points from active source tree', async () => { - const coreSrc = path.resolve(import.meta.dirname, '../src') - - await Promise.all([ - assert.rejects(fs.access(path.join(coreSrc, 'chains', 'rooms.ts'))), - assert.rejects(fs.access(path.join(coreSrc, 'commands', 'room.ts'))), - assert.rejects(fs.access(path.join(coreSrc, 'middlewares', 'room'))), - assert.rejects( - fs.access(path.join(coreSrc, 'middlewares', 'auth', 'mute_user.ts')) - ), - assert.rejects( - fs.access( - path.join(coreSrc, 'middlewares', 'model', 'request_model.ts') - ) - ), - assert.rejects(fs.access(path.join(coreSrc, 'legacy', 'types.ts'))) - ]) -}) - -test('computeBaseBindingKey builds personal direct bindings', () => { - const bindingKey = computeBaseBindingKey( - { - platform: 'discord', - selfId: 'bot', - guildId: 'guild', - userId: 'user', - isDirect: true - } as BindingSessionShape as never, - 'personal' - ) - - assert.equal(bindingKey, 'personal:discord:bot:direct:user') -}) - -test('computeBaseBindingKey builds shared guild bindings', () => { - const bindingKey = computeBaseBindingKey( - { - platform: 'discord', - selfId: 'bot', - guildId: 'guild', - userId: 'user', - isDirect: false - } as BindingSessionShape as never, - 'shared' - ) - - assert.equal(bindingKey, 'shared:discord:bot:guild') -}) - -test('applyPresetLane appends preset lane when provided', () => { - assert.equal( - applyPresetLane('personal:discord:bot:guild:user', 'helper'), - 'personal:discord:bot:guild:user:preset:helper' - ) - assert.equal( - applyPresetLane('personal:discord:bot:guild:user', undefined), - 'personal:discord:bot:guild:user' - ) -}) - -test('parsePresetLaneInput normalizes alias prefixes and bare queries', () => { - assert.deepEqual( - parsePresetLaneInput('Sydney: hello', ['sydney', 'helper']), - { - preset: 'sydney', - content: 'hello', - queryOnly: false - } - ) - assert.deepEqual(parsePresetLaneInput('helper,', ['sydney', 'helper']), { - preset: 'helper', - content: '', - queryOnly: true - }) - assert.equal(parsePresetLaneInput('plain message', ['sydney']), null) -}) - -test('pagination normalizes page and limit bounds', async () => { - const pagination = new Pagination({ - page: 1, - limit: 2, - formatItem: (item) => `item:${item}`, - formatString: { - top: 'top', - bottom: 'bottom', - pages: 'page [page]/[total]' - } - }) - - await pagination.push([1, 2, 3], 'numbers') - - assert.deepEqual(await pagination.getPage(0, 0, 'numbers'), [1]) - assert.equal( - await pagination.getFormattedPage(2, 2, 'numbers'), - 'top\nitem:3\nbottom\npage 2/2' - ) -}) - -test('gzip helpers round-trip archived payload content', async () => { - const json = JSON.stringify({ text: 'hello', values: [1, 2, 3] }) - const compressed = await gzipEncode(json) - const base64 = await gzipEncode(json, 'base64') - const arrayBuffer = bufferToArrayBuffer(compressed) - - assert.equal(await gzipDecode(arrayBuffer), json) - assert.equal(await gzipDecode(base64), json) -}) - -test('getMessageContent flattens structured text parts', () => { - assert.equal( - getMessageContent([ - { type: 'text', text: 'hello ' }, - { type: 'image_url', image_url: 'https://example.com/x.png' }, - { type: 'text', text: 'world' } - ] as never), - 'hello world' - ) -}) - -test('ConversationService resolves routed constraints and preset lanes', async () => { - const highPriorityConstraint: ConstraintRecord = { - id: 2, - name: 'shared route', - enabled: true, - priority: 10, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - guildId: 'guild', - routeMode: 'custom', - routeKey: 'team-alpha', - defaultModel: 'constraint/model', - fixedPreset: 'fixed-preset', - allowNew: false, - allowSwitch: true, - allowArchive: false, - allowExport: true, - manageMode: 'anyone' - } - const lowerPriorityConstraint: ConstraintRecord = { - id: 1, - name: 'fallback defaults', - enabled: true, - priority: 1, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - defaultPreset: 'constraint-default-preset', - defaultChatMode: 'chat-mode-x', - fixedModel: null, - fixedChatMode: 'fixed-chat-mode' - } - - const { service } = await createService({ - tables: { - chatluna_constraint: [ - highPriorityConstraint as unknown as TableRow, - lowerPriorityConstraint as unknown as TableRow - ] - } - }) - - const resolved = await service.resolveConstraint(createSession(), { - presetLane: 'helper' - }) - - assert.equal(resolved.routeMode, 'custom') - assert.equal(resolved.baseKey, 'custom:team-alpha') - assert.equal(resolved.bindingKey, 'custom:team-alpha:preset:helper') - assert.equal(resolved.defaultModel, 'constraint/model') - assert.equal(resolved.defaultPreset, 'helper') - assert.equal(resolved.fixedPreset, 'fixed-preset') - assert.equal(resolved.fixedChatMode, 'fixed-chat-mode') - assert.equal(resolved.allowNew, false) - assert.equal(resolved.allowArchive, false) - assert.equal(resolved.manageMode, 'anyone') -}) - -test('runRoomToConversationMigration migrates legacy rooms, messages, bindings, and ACL', async () => { - const { ctx, database } = await createService({ - tables: { - chathub_room: [ - { - roomId: 1, - roomName: 'Legacy Room', - roomMasterId: 'owner', - conversationId: 'legacy-conversation', - preset: 'legacy-preset', - model: 'legacy/model', - chatMode: 'plugin', - visibility: 'private', - password: 'secret', - autoUpdate: true, - updatedTime: new Date('2026-03-21T00:00:00.000Z') - } as unknown as TableRow - ], - chathub_conversation: [ - { - id: 'legacy-conversation', - latestId: 'legacy-message-1', - additional_kwargs: '{"topic":"legacy"}', - updatedAt: new Date('2026-03-21T01:00:00.000Z') - } as unknown as TableRow - ], - chathub_message: [ - { - id: 'legacy-message-1', - conversation: 'legacy-conversation', - parent: null, - role: 'human', - text: 'hello from legacy room', - content: null, - name: 'owner', - tool_call_id: null, - tool_calls: null, - additional_kwargs: null, - additional_kwargs_binary: null, - rawId: null - } as unknown as TableRow - ], - chathub_room_member: [ - { - roomId: 1, - userId: 'owner', - roomPermission: 'owner', - mute: false - } as unknown as TableRow, - { - roomId: 1, - userId: 'guest', - roomPermission: 'member', - mute: false - } as unknown as TableRow, - { - roomId: 1, - userId: 'helper', - roomPermission: 'member', - mute: false - } as unknown as TableRow - ], - chathub_room_group_member: [ - { - roomId: 1, - groupId: 'guild', - roomVisibility: 'private' - } as unknown as TableRow, - { - roomId: 1, - groupId: 'guild-2', - roomVisibility: 'private' - } as unknown as TableRow - ], - chathub_user: [ - { - userId: 'owner', - groupId: 'guild', - defaultRoomId: 1 - } as unknown as TableRow - ] - } - }) - - await runRoomToConversationMigration(ctx, createConfig()) - - const conversation = database.tables - .chatluna_conversation[0] as ConversationRecord - const binding = database.tables.chatluna_binding[0] as BindingRecord - const message = database.tables.chatluna_message[0] as MessageRecord - const meta = database.tables.chatluna_meta.find( - (item) => item.key === 'validation_result' - ) as { value?: string | null } - - assert.equal(conversation.id, 'legacy-conversation') - assert.equal(conversation.bindingKey, 'custom:legacy:room:1') - assert.equal(conversation.latestMessageId, 'legacy-message-1') - assert.equal(conversation.legacyRoomId, 1) - assert.equal(binding.activeConversationId, 'legacy-conversation') - assert.equal(message.conversationId, 'legacy-conversation') - assert.equal(database.tables.chatluna_acl.length, 6) - assert.equal(JSON.parse(meta.value ?? '{}').passed, true) -}) - -test('inferLegacyGroupRouteModes preserves per-group legacy routing semantics', () => { - const users = [ - { userId: 'a', groupId: 'g1', defaultRoomId: 1 }, - { userId: 'b', groupId: 'g1', defaultRoomId: 2 }, - { userId: 'c', groupId: 'g2', defaultRoomId: 3 } - ] - const rooms = [ - { - roomId: 1, - visibility: 'private' - }, - { - roomId: 2, - visibility: 'private' - }, - { - roomId: 3, - visibility: 'public' - } - ] as never - const groups = [ - { roomId: 1, groupId: 'g1', roomVisibility: 'private' }, - { roomId: 2, groupId: 'g1', roomVisibility: 'private' }, - { roomId: 3, groupId: 'g2', roomVisibility: 'public' } - ] as never - - const modes = inferLegacyGroupRouteModes(users as never, rooms, groups) - - assert.equal( - createLegacyBindingKey(users[0] as never, modes), - 'personal:legacy:legacy:g1:a' - ) - assert.equal( - createLegacyBindingKey(users[1] as never, modes), - 'personal:legacy:legacy:g1:b' - ) - assert.equal( - createLegacyBindingKey(users[2] as never, modes), - 'shared:legacy:legacy:g2' - ) -}) - -test('ConversationService ensureActiveConversation creates conversation and binding on default route', async () => { - const { service, database } = await createService() - - const resolved = await service.ensureActiveConversation(createSession()) - const binding = database.tables.chatluna_binding[0] as BindingRecord - const conversation = database.tables - .chatluna_conversation[0] as ConversationRecord - - assert.equal(resolved.bindingKey, 'shared:discord:bot:guild') - assert.equal(binding.activeConversationId, resolved.conversation.id) - assert.equal(conversation.id, resolved.conversation.id) - assert.equal(conversation.seq, 1) - assert.equal(conversation.legacyRoomId, null) - assert.equal(conversation.legacyMeta, null) - assert.equal(resolved.effectiveModel, 'test-platform/test-model') - assert.equal(resolved.effectivePreset, 'default-preset') - assert.equal(resolved.effectiveChatMode, 'plugin') -}) - -test('ConversationService restores archived current conversation automatically', async () => { - const archivedConversation = createConversation({ - id: 'conversation-archived', - status: 'archived', - archiveId: 'archive-1', - archivedAt: new Date('2026-03-22T00:00:00.000Z'), - latestMessageId: null - }) - const archivedPayload = { - formatVersion: 1, - exportedAt: '2026-03-22T00:00:00.000Z', - conversation: { - ...archivedConversation, - status: 'active', - archiveId: null, - archivedAt: null, - createdAt: archivedConversation.createdAt.toISOString(), - updatedAt: archivedConversation.updatedAt.toISOString(), - lastChatAt: archivedConversation.lastChatAt?.toISOString() ?? null - }, - messages: [] - } - const archiveDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'chatluna-restore-test-') - ) - const archivePath = path.join(archiveDir, 'archive.json.gz') - await fs.writeFile( - archivePath, - await gzipEncode(JSON.stringify(archivedPayload)) - ) - - const { service } = await createService({ - baseDir: archiveDir, - tables: { - chatluna_conversation: [ - archivedConversation as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: 'shared:discord:bot:guild', - activeConversationId: 'conversation-archived', - lastConversationId: null, - updatedAt: new Date() - } - ], - chatluna_archive: [ - { - id: 'archive-1', - conversationId: 'conversation-archived', - path: archivePath, - formatVersion: 1, - messageCount: 0, - checksum: null, - size: 1, - state: 'ready', - createdAt: new Date(), - restoredAt: null - } - ] - } - }) - - const resolved = await service.ensureActiveConversation(createSession()) - - assert.equal(resolved.conversation.id, 'conversation-archived') - assert.equal(resolved.conversation.status, 'active') - assert.equal(resolved.conversation.archiveId, null) -}) - -test('ConversationService does not auto-restore archived conversation without manage permission', async () => { - const archivedConversation = createConversation({ - id: 'conversation-archived-locked', - status: 'archived', - archiveId: 'archive-locked', - archivedAt: new Date('2026-03-22T00:00:00.000Z'), - latestMessageId: null - }) - const archiveDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'chatluna-restore-blocked-test-') - ) - const archivePath = path.join(archiveDir, 'archive.json.gz') - await fs.writeFile( - archivePath, - await gzipEncode( - JSON.stringify({ - formatVersion: 1, - exportedAt: '2026-03-22T00:00:00.000Z', - conversation: { - ...archivedConversation, - status: 'active', - archiveId: null, - archivedAt: null, - createdAt: archivedConversation.createdAt.toISOString(), - updatedAt: archivedConversation.updatedAt.toISOString(), - lastChatAt: - archivedConversation.lastChatAt?.toISOString() ?? null - }, - messages: [] - }) - ) - ) - - const { service } = await createService({ - baseDir: archiveDir, - tables: { - chatluna_conversation: [ - archivedConversation as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: 'shared:discord:bot:guild', - activeConversationId: archivedConversation.id, - lastConversationId: null, - updatedAt: new Date() - } - ], - chatluna_archive: [ - { - id: 'archive-locked', - conversationId: archivedConversation.id, - path: archivePath, - formatVersion: 1, - messageCount: 0, - checksum: null, - size: 1, - state: 'ready', - createdAt: new Date(), - restoredAt: null - } - ] - } - }) - - await assert.rejects( - service.ensureActiveConversation(createSession({ authority: 1 })), - /administrator permission/ - ) -}) - -test('ConversationService ensureActiveConversation respects personal default group route mode', async () => { - const { service } = await createService({ - config: { - defaultGroupRouteMode: 'personal' - } - }) - - const resolved = await service.ensureActiveConversation(createSession()) - - assert.equal(resolved.bindingKey, 'personal:discord:bot:guild:user') - assert.equal(resolved.conversation.seq, 1) -}) - -test('ConversationService switches and resolves friendly conversation targets within the same binding', async () => { - const older = createConversation({ - id: 'conversation-old', - seq: 1, - title: 'Older', - lastChatAt: new Date('2026-03-20T00:00:00.000Z') - }) - const newer = createConversation({ - id: 'conversation-new', - seq: 2, - title: 'Newer Topic', - lastChatAt: new Date('2026-03-22T00:00:00.000Z') - }) - - const { service, database } = await createService({ - tables: { - chatluna_conversation: [ - older as unknown as TableRow, - newer as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: 'shared:discord:bot:guild', - activeConversationId: 'conversation-old', - lastConversationId: null, - updatedAt: new Date() - } - ] - } - }) - - const listed = await service.listConversations(createSession()) - assert.deepEqual( - listed.map((item) => item.id), - ['conversation-new', 'conversation-old'] - ) - - const bySeq = await service.switchConversation(createSession(), { - targetConversation: '2' - }) - const byId = await service.switchConversation(createSession(), { - targetConversation: 'conversation-old' - }) - const byTitle = await service.switchConversation(createSession(), { - targetConversation: 'newer topic' - }) - const byPartialTitle = await service.switchConversation(createSession(), { - targetConversation: 'Topic' - }) - const binding = database.tables.chatluna_binding[0] as BindingRecord - - assert.equal(bySeq.id, 'conversation-new') - assert.equal(byId.id, 'conversation-old') - assert.equal(byTitle.id, 'conversation-new') - assert.equal(byPartialTitle.id, 'conversation-new') - assert.equal(binding.activeConversationId, 'conversation-new') - assert.equal(binding.lastConversationId, 'conversation-old') -}) - -test('ConversationService rejects ambiguous friendly conversation targets', async () => { - const alpha = createConversation({ - id: 'conversation-alpha', - seq: 1, - title: 'Project Alpha' - }) - const beta = createConversation({ - id: 'conversation-beta', - seq: 2, - title: 'Project Beta' - }) - - const { service } = await createService({ - tables: { - chatluna_conversation: [ - alpha as unknown as TableRow, - beta as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: 'shared:discord:bot:guild', - activeConversationId: 'conversation-alpha', - lastConversationId: null, - updatedAt: new Date() - } - ] - } - }) - - await assert.rejects( - service.switchConversation(createSession(), { - targetConversation: 'Project' - }), - /Conversation target is ambiguous\./ - ) -}) - -test('ConversationService exports, archives, and restores conversations with legacy migration fields', async () => { - const tempDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'chatluna-archive-test-') - ) - const exportPath = path.join(tempDir, 'conversation-export.md') - - const conversation = createConversation({ - id: 'conversation-archive', - title: 'Archived Conversation', - latestMessageId: 'message-2', - legacyRoomId: 42, - legacyMeta: JSON.stringify({ roomName: 'legacy-room' }) - }) - const messageA = createMessage({ - id: 'message-1', - conversationId: 'conversation-archive', - text: 'hello' - }) - const messageB = createMessage({ - id: 'message-2', - conversationId: 'conversation-archive', - parentId: 'message-1', - role: 'ai', - text: 'world' - }) - - const { service, database, clearCacheCalls } = await createService({ - baseDir: tempDir, - tables: { - chatluna_conversation: [conversation as unknown as TableRow], - chatluna_binding: [ - { - bindingKey: 'shared:discord:bot:guild', - activeConversationId: 'conversation-archive', - lastConversationId: null, - updatedAt: new Date() - } - ], - chatluna_message: [ - messageA as unknown as TableRow, - messageB as unknown as TableRow - ] - } - }) - - const exported = await service.exportConversation(createSession(), { - conversationId: 'conversation-archive', - outputPath: exportPath - }) - const exportMarkdown = await fs.readFile(exported.path, 'utf8') - - assert.match(exportMarkdown, /# Archived Conversation/) - assert.match(exportMarkdown, /hello/) - assert.match(exportMarkdown, /world/) - - const archived = await service.archiveConversation(createSession(), { - conversationId: 'conversation-archive' - }) - const archivedConversation = await service.getConversation( - 'conversation-archive' - ) - const archiveRecord = archived.archive as ArchiveRecord - const manifest = JSON.parse( - await fs.readFile(path.join(archived.path, 'manifest.json'), 'utf8') - ) - - assert.equal(archivedConversation.status, 'archived') - assert.equal(archivedConversation.archiveId, archiveRecord.id) - assert.equal(manifest.conversationId, 'conversation-archive') - assert.equal(database.tables.chatluna_message.length, 0) - assert.deepEqual(clearCacheCalls, ['conversation-archive']) - - const restored = await service.restoreConversation(createSession(), { - conversationId: 'conversation-archive' - }) - const restoredMessages = await service.listMessages('conversation-archive') - const restoredArchive = await service.getArchive(archiveRecord.id) - - assert.equal(restored.status, 'active') - assert.equal(restored.archiveId, null) - assert.equal(restored.legacyRoomId, 42) - assert.equal( - restored.legacyMeta, - JSON.stringify({ roomName: 'legacy-room' }) - ) - assert.equal(restoredMessages.length, 2) - assert.equal(restoredArchive.state, 'ready') - assert.notEqual(restoredArchive.restoredAt, null) -}) - -test('ConversationService records compression metadata and use rejects fixed fields', async () => { - const conversation = createConversation() - const message = createMessage({ - id: 'summary', - conversationId: conversation.id, - text: 'compressed summary', - name: 'infinite_context' - }) - const { service } = await createService({ - tables: { - chatluna_conversation: [conversation as unknown as TableRow], - chatluna_binding: [ - { - bindingKey: conversation.bindingKey, - activeConversationId: conversation.id, - lastConversationId: null, - updatedAt: new Date() - } - ], - chatluna_message: [message as unknown as TableRow], - chatluna_constraint: [ - { - id: 1, - name: 'managed:discord:bot:guild:guild', - enabled: true, - priority: 1000, - createdBy: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - platform: 'discord', - selfId: 'bot', - guildId: 'guild', - channelId: null, - direct: false, - users: null, - excludeUsers: null, - routeMode: null, - routeKey: null, - defaultModel: null, - defaultPreset: null, - defaultChatMode: null, - fixedModel: 'fixed-model', - fixedPreset: null, - fixedChatMode: null, - lockConversation: false, - allowNew: true, - allowSwitch: true, - allowArchive: true, - allowExport: true, - manageMode: 'anyone' - } as unknown as TableRow - ] - } - }) - - const updated = await service.recordCompression(conversation.id, { - compressed: true, - inputTokens: 120, - outputTokens: 30, - reducedTokens: 90, - reducedPercent: 75, - originalMessageCount: 8, - remainingMessageCount: 1 - }) - const compression = JSON.parse(updated.compression ?? '{}') - - assert.equal(compression.count, 1) - assert.equal(compression.summary, 'compressed summary') - assert.equal(compression.outputTokens, 30) - assert.equal(compression.originalMessageCount, 8) - assert.equal(compression.remainingMessageCount, 1) - - await assert.rejects( - service.updateConversationUsage(createSession(), { - model: 'other-model' - }), - /fixed to fixed-model/ - ) -}) - -test('ConversationService blocks raw id access outside route without ACL and allows manage ACL', async () => { - const local = createConversation({ - id: 'conversation-local', - bindingKey: 'shared:discord:bot:guild' - }) - const remote = createConversation({ - id: 'conversation-remote', - bindingKey: 'shared:discord:bot:other-guild' - }) - const acl: ACLRecord = { - conversationId: remote.id, - principalType: 'user', - principalId: 'user', - permission: 'manage' - } - - const { service, database } = await createService({ - tables: { - chatluna_conversation: [ - local as unknown as TableRow, - remote as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: local.bindingKey, - activeConversationId: local.id, - lastConversationId: null, - updatedAt: new Date() - } - ] - } - }) - - await assert.rejects( - service.resolveCommandConversation(createSession({ authority: 1 }), { - conversationId: remote.id, - permission: 'manage' - }), - /does not belong to current route/ - ) - - database.tables.chatluna_acl.push(acl as unknown as TableRow) - - const resolved = await service.resolveCommandConversation( - createSession({ authority: 1 }), - { - conversationId: remote.id, - permission: 'manage' - } - ) - - assert.equal(resolved.id, remote.id) -}) - -test('ConversationService resolves ACL-backed cross-route targetConversation', async () => { - const local = createConversation({ - id: 'conversation-local-2', - bindingKey: 'shared:discord:bot:guild' - }) - const remote = createConversation({ - id: 'conversation-remote-2', - bindingKey: 'shared:discord:bot:other-guild', - title: 'Remote Shared Topic', - seq: 7 - }) - - const { service } = await createService({ - tables: { - chatluna_conversation: [ - local as unknown as TableRow, - remote as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: local.bindingKey, - activeConversationId: local.id, - lastConversationId: null, - updatedAt: new Date() - } - ], - chatluna_acl: [ - { - conversationId: remote.id, - principalType: 'user', - principalId: 'user', - permission: 'manage' - } as unknown as TableRow - ] - } - }) - - const byId = await service.resolveCommandConversation( - createSession({ authority: 1 }), - { - targetConversation: remote.id, - permission: 'manage' - } - ) - const byTitle = await service.resolveCommandConversation( - createSession({ authority: 1 }), - { - targetConversation: 'Remote Shared Topic', - permission: 'manage' - } - ) - - assert.equal(byId?.id, remote.id) - assert.equal(byTitle?.id, remote.id) -}) - -test('getLegacySchemaSentinel resolves under baseDir', () => { - assert.equal( - getLegacySchemaSentinel('C:/chatluna-base'), - path.resolve( - 'C:/chatluna-base', - 'data/chatluna/temp/legacy-schema-disabled.json' - ) - ) -}) - -test('getLegacySchemaSentinelDir matches resolved sentinel parent', () => { - assert.equal( - getLegacySchemaSentinelDir('C:/chatluna-base'), - path.dirname(getLegacySchemaSentinel('C:/chatluna-base')) - ) -}) - -test('ConversationService upserts and removes ACL records coherently', async () => { - const conversation = createConversation({ id: 'conversation-acl' }) - const { service } = await createService({ - tables: { - chatluna_conversation: [conversation as unknown as TableRow] - } - }) - - const created = await service.upsertAcl(conversation.id, [ - { - principalType: 'user', - principalId: 'alice', - permission: 'view' - }, - { - principalType: 'guild', - principalId: 'guild-x', - permission: 'manage' - } - ]) - - assert.equal(created.length, 2) - - const afterRemove = await service.removeAcl(conversation.id, [ - { - principalType: 'user', - principalId: 'alice' - } - ]) - - assert.deepEqual(afterRemove, [ - { - conversationId: conversation.id, - principalType: 'guild', - principalId: 'guild-x', - permission: 'manage' - } - ]) -}) - -test('ConversationService emits conversation lifecycle events for switch archive restore delete and compression', async () => { - const active = createConversation({ id: 'conversation-active', seq: 1 }) - const next = createConversation({ - id: 'conversation-next', - seq: 2, - title: 'Next Topic' - }) - const { service, events } = await createService({ - tables: { - chatluna_conversation: [ - active as unknown as TableRow, - next as unknown as TableRow - ], - chatluna_binding: [ - { - bindingKey: active.bindingKey, - activeConversationId: active.id, - lastConversationId: null, - updatedAt: new Date() - } - ], - chatluna_message: [ - createMessage({ - id: 'summary-message', - conversationId: next.id, - name: 'infinite_context', - text: 'summary' - }) as unknown as TableRow - ] - } - }) - - await service.switchConversation(createSession(), { - targetConversation: next.id - }) - await service.recordCompression(next.id, { - compressed: true, - inputTokens: 100, - outputTokens: 20, - reducedTokens: 80, - reducedPercent: 80, - originalMessageCount: 10, - remainingMessageCount: 2 - }) - - const archived = await service.archiveConversation(createSession(), { - conversationId: next.id - }) - await service.restoreConversation(createSession(), { - conversationId: next.id, - archiveId: archived.archive.id - }) - await service.deleteConversation(createSession(), { - conversationId: next.id - }) - - assert.deepEqual( - events.map((item) => item.name), - [ - 'chatluna/conversation-before-switch', - 'chatluna/conversation-after-switch', - 'chatluna/conversation-compressed', - 'chatluna/conversation-before-archive', - 'chatluna/conversation-after-archive', - 'chatluna/conversation-before-restore', - 'chatluna/conversation-after-restore', - 'chatluna/conversation-before-delete', - 'chatluna/conversation-after-delete' - ] - ) -}) - -test('conversation cleanup listeners in downstream packages use conversation lifecycle events', async () => { - const files = [ - path.resolve( - import.meta.dirname, - '../../extension-long-memory/src/service/memory.ts' - ), - path.resolve( - import.meta.dirname, - '../../extension-agent/src/service/skills.ts' - ), - path.resolve( - import.meta.dirname, - '../../extension-agent/src/cli/service.ts' - ), - path.resolve( - import.meta.dirname, - '../../extension-tools/src/plugins/todos.ts' - ) - ] - - for (const file of files) { - const content = await fs.readFile(file, 'utf8') - assert.equal(content.includes('chatluna/clear-chat-history'), false) - assert.equal( - content.includes('chatluna/conversation-after-clear-history'), - true - ) - } -}) - -test('purgeArchivedConversation removes archive directory and clears both binding pointers', async () => { - const dir = await fs.mkdtemp( - path.join(os.tmpdir(), 'chatluna-purge-archive-') - ) - const archiveDir = path.join(dir, 'archive-dir') - await fs.mkdir(archiveDir, { recursive: true }) - await fs.writeFile(path.join(archiveDir, 'manifest.json'), '{}', 'utf8') - - const conversation = createConversation({ - id: 'conversation-purge', - status: 'archived', - archiveId: 'archive-purge' - }) - const { ctx, database } = await createService({ - baseDir: dir, - tables: { - chatluna_conversation: [conversation as unknown as TableRow], - chatluna_binding: [ - { - bindingKey: conversation.bindingKey, - activeConversationId: conversation.id, - lastConversationId: conversation.id, - updatedAt: new Date() - } - ], - chatluna_archive: [ - { - id: 'archive-purge', - conversationId: conversation.id, - path: archiveDir, - formatVersion: 1, - messageCount: 1, - checksum: null, - size: 1, - state: 'ready', - createdAt: new Date(), - restoredAt: null - } - ], - chatluna_message: [ - createMessage({ - conversationId: conversation.id - }) as unknown as TableRow - ], - chatluna_acl: [ - { - conversationId: conversation.id, - principalType: 'user', - principalId: 'user', - permission: 'view' - } as unknown as TableRow - ] - } - }) - - await purgeArchivedConversation(ctx, conversation) - - await assert.rejects(fs.access(archiveDir)) - assert.equal(database.tables.chatluna_conversation.length, 0) - assert.equal(database.tables.chatluna_archive.length, 0) - assert.equal(database.tables.chatluna_message.length, 0) - assert.equal(database.tables.chatluna_acl.length, 0) - assert.equal(database.tables.chatluna_binding[0].activeConversationId, null) - assert.equal(database.tables.chatluna_binding[0].lastConversationId, null) -}) - -test('wipe source keeps legacy migration and runtime table cleanup wired in', async () => { - const source = await fs.readFile( - path.resolve(import.meta.dirname, '../src/middlewares/system/wipe.ts'), - 'utf8' - ) - - assert.match(source, /LEGACY_MIGRATION_TABLES/) - assert.match(source, /LEGACY_RUNTIME_TABLES/) - assert.match(source, /for \(const table of LEGACY_MIGRATION_TABLES\)/) - assert.match(source, /for \(const table of LEGACY_RUNTIME_TABLES\)/) -}) - -test('ConversationService supports sampled end-to-end lifecycle flow', async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chatluna-e2e-flow-')) - const { service } = await createService({ baseDir: dir }) - const session = createSession() - - const created = await service.ensureActiveConversation(session, { - presetLane: 'helper' - }) - const listed = await service.listConversations(session, { - presetLane: 'helper' - }) - const renamed = await service.renameConversation(session, { - conversationId: created.conversation.id, - presetLane: 'helper', - title: 'Helper Session' - }) - const exported = await service.exportConversation(session, { - conversationId: created.conversation.id, - presetLane: 'helper' - }) - const archived = await service.archiveConversation(session, { - conversationId: created.conversation.id, - presetLane: 'helper' - }) - const restored = await service.restoreConversation(session, { - conversationId: created.conversation.id, - presetLane: 'helper' - }) - const removed = await service.deleteConversation(session, { - conversationId: created.conversation.id, - presetLane: 'helper' - }) - - assert.equal(listed.length, 1) - assert.equal(renamed.title, 'Helper Session') - assert.equal(path.extname(exported.path), '.md') - assert.equal(archived.conversation.status, 'archived') - assert.equal(restored.status, 'active') - assert.equal(removed.status, 'deleted') -}) - -test('ConversationRuntime registers, resolves, and stops active requests', () => { - const runtime = new ConversationRuntime({} as never) - const abortController = new AbortController() - const session = createSession({ sid: 'sid-1' }) - - runtime.registerRequest( - 'conversation-1', - 'request-1', - 'plugin', - abortController, - session - ) - - assert.equal(runtime.getRequestIdBySession(session), 'request-1') - assert.equal(runtime.stopRequest('request-1'), true) - assert.equal(abortController.signal.aborted, true) - assert.equal(runtime.stopRequest('missing-request'), false) - - runtime.completeRequest('conversation-1', 'request-1', session) - assert.equal(runtime.getRequestIdBySession(session), undefined) -}) - -test('ConversationRuntime appendPendingMessage waits for plugin round decisions', async () => { - const runtime = new ConversationRuntime({} as never) - const activeRequest = runtime.registerRequest( - 'conversation-1', - 'request-1', - 'plugin', - new AbortController(), - createSession() - ) - - const pushed: HumanMessage[] = [] - const originalPush = activeRequest.messageQueue.push.bind( - activeRequest.messageQueue - ) - activeRequest.messageQueue.push = ((message: HumanMessage) => { - pushed.push(message) - return originalPush(message) - }) as typeof activeRequest.messageQueue.push - - const pending = runtime.appendPendingMessage( - 'conversation-1', - new HumanMessage('follow-up') - ) - - assert.equal(activeRequest.roundDecisionResolvers.length, 1) - activeRequest.roundDecisionResolvers[0](true) - assert.equal(await pending, true) - assert.equal(pushed.length, 1) - assert.equal(String(pushed[0].content), 'follow-up') - - activeRequest.lastDecision = false - assert.equal( - await runtime.appendPendingMessage( - 'conversation-1', - new HumanMessage('ignored'), - 'plugin' - ), - false - ) - assert.equal( - await runtime.appendPendingMessage( - 'conversation-1', - new HumanMessage('wrong-mode'), - 'chat' - ), - false - ) -}) - -test('ConversationRuntime clears cached interfaces and dispatches compression', async () => { - const cleared: string[] = [] - const compressed: boolean[] = [] - const runtime = new ConversationRuntime({ - createChatInterface: async () => ({ - clearChatHistory: async () => { - cleared.push('cleared') - }, - compressContext: async (force: boolean) => { - compressed.push(force) - return { - compressed: true, - inputTokens: 10, - outputTokens: 5, - reducedPercent: 50 - } - } - }), - awaitLoadPlatform: async () => {}, - platform: { - getClient: async () => ({ - value: { - configPool: { - getConfig: () => ({ - value: { - concurrentMaxSize: 1 - } - }) - } - } - }) - }, - ctx: { - root: { - parallel: async () => {} - } - } - } as never) - - const conversation = createConversation({ - id: 'conversation-runtime', - model: 'platform/model' - }) - - await runtime.ensureChatInterface(conversation) - assert.equal(runtime.getCachedConversations().length, 1) - - await runtime.clearConversationHistory(conversation) - assert.deepEqual(cleared, ['cleared']) - assert.equal(runtime.getCachedConversations().length, 0) - - const result = await runtime.compressConversation(conversation, true) - assert.equal(result.compressed, true) - assert.deepEqual(compressed, [true]) -}) - -test('ConversationRuntime dispose clears platform-scoped and global state', () => { - const runtime = new ConversationRuntime({} as never) - const session = createSession({ sid: 'sid-dispose' }) - const conversation = createConversation({ id: 'conversation-dispose' }) - - runtime.interfaces.set(conversation.id, { - conversation, - chatInterface: {} as never - }) - runtime.registerPlatformConversation('platform-a', conversation.id) - runtime.registerRequest( - conversation.id, - 'request-dispose', - 'plugin', - new AbortController(), - session - ) - - runtime.dispose('platform-a') - assert.equal(runtime.interfaces.has(conversation.id), false) - assert.equal(runtime.activeByConversation.has(conversation.id), false) - - runtime.registerRequest( - 'conversation-2', - 'request-2', - 'plugin', - new AbortController(), - createSession({ sid: 'sid-2' }) - ) - runtime.dispose() - assert.equal(runtime.requestsById.size, 0) - assert.equal(runtime.requestBySession.size, 0) - assert.equal(runtime.platformIndex.size, 0) -}) diff --git a/yakumo.yml b/yakumo.yml index 4f5b93840..e83c46d61 100644 --- a/yakumo.yml +++ b/yakumo.yml @@ -1,13 +1,13 @@ - id: bc0pa1 name: yakumo config: - pipeline: - build: - - esbuild - - tsc - - client - clean: - - tsc --clean + pipeline: + build: + - esbuild + - tsc + - client + clean: + - tsc --clean - id: wxc6xn name: yakumo-esbuild - id: a502x9 @@ -22,3 +22,5 @@ name: yakumo/upgrade - id: ac2tqc name: yakumo/run +- id: aavbcc + name: yakumo-mocha From a093687d29246368fcb7f89a802d32103fff263e Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:39:42 +0000 Subject: [PATCH 07/20] fix: apply CodeRabbit auto-fixes Fixed 4 file(s) based on 7 unresolved review comments. Co-authored-by: CodeRabbit --- packages/adapter-dify/src/requester.ts | 8 ++- packages/core/src/middleware.ts | 6 +- .../src/middlewares/preset/delete_preset.ts | 8 ++- packages/core/src/services/conversation.ts | 66 ++++++++++++++++++- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index 243036147..966c3d9db 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -741,6 +741,12 @@ export class DifyRequester extends ModelRequester { } const conversationId = id const config = this._config.value.additionalModel.get(model) + if (config == null || config.workflowName == null) { + this.ctx.logger.warn( + `Dify clear: config not found for model ${model}` + ) + return + } const cacheKey = 'dify/' + conversationId + '/' + config.workflowName const cached = await this.ctx.chatluna.cache.get( 'chatluna/keys', @@ -783,4 +789,4 @@ export class DifyRequester extends ModelRequester { get logger() { return logger } -} +} \ No newline at end of file diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index 566850fcf..b0481912a 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -37,8 +37,8 @@ import { apply as clone_preset } from './middlewares/preset/clone_preset' import { apply as delete_preset } from './middlewares/preset/delete_preset' import { apply as list_all_preset } from './middlewares/preset/list_all_preset' import { apply as set_preset } from './middlewares/preset/set_preset' -import { apply as conversation_manage } from './middlewares/system/conversation_manage' import { apply as clear_balance } from './middlewares/system/clear_balance' +import { apply as conversation_manage } from './middlewares/system/conversation_manage' import { apply as lifecycle } from './middlewares/system/lifecycle' import { apply as query_balance } from './middlewares/system/query_balance' import { apply as restart } from './middlewares/system/restart' @@ -89,8 +89,8 @@ export async function middleware(ctx: Context, config: Config) { delete_preset, list_all_preset, set_preset, - conversation_manage, clear_balance, + conversation_manage, lifecycle, query_balance, restart, @@ -101,4 +101,4 @@ export async function middleware(ctx: Context, config: Config) { for (const middleware of middlewares) { await middleware(ctx, config, ctx.chatluna.chatChain) } -} +} \ No newline at end of file diff --git a/packages/core/src/middlewares/preset/delete_preset.ts b/packages/core/src/middlewares/preset/delete_preset.ts index 36ed3682a..5f66fad16 100644 --- a/packages/core/src/middlewares/preset/delete_preset.ts +++ b/packages/core/src/middlewares/preset/delete_preset.ts @@ -84,7 +84,11 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { ctx.chatluna.currentConfig.defaultPreset = nextPreset } - await fs.rm(presetTemplate.path) + try { + await fs.rm(presetTemplate.path) + } catch (e) { + ctx.logger.error(e) + } context.message = session.text('.success', [presetName]) @@ -102,4 +106,4 @@ declare module '../../chains/chain' { interface ChainMiddlewareContextOptions { deletePreset?: string } -} +} \ No newline at end of file diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index a42c51d97..fcf4261ea 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -491,6 +491,10 @@ export class ConversationService { conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } + if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation switch is locked by constraint.') } @@ -556,6 +560,10 @@ export class ConversationService { conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } + if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation restore is locked by constraint.') } @@ -669,6 +677,10 @@ export class ConversationService { conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } + if (!(target?.allowExport ?? resolved.constraint.allowExport)) { throw new Error('Conversation export is disabled by constraint.') } @@ -710,6 +722,10 @@ export class ConversationService { conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } + if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation archive is locked by constraint.') } @@ -884,6 +900,10 @@ export class ConversationService { conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } + if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation restore is locked by constraint.') } @@ -994,7 +1014,27 @@ export class ConversationService { } async exportMarkdown(conversation: ConversationRecord) { - const messages = await this.listMessages(conversation.id) + let messages: MessageRecord[] + + if (conversation.status === 'archived' && conversation.archiveId) { + const archive = await this.getArchive(conversation.archiveId) + if (archive != null) { + try { + const payload = await this.readArchivePayload(archive.path) + messages = payload.messages.map((msg) => ({ + ...msg, + createdAt: new Date(msg.createdAt), + conversationId: conversation.id + })) as unknown as MessageRecord[] + } catch { + messages = await this.listMessages(conversation.id) + } + } else { + messages = await this.listMessages(conversation.id) + } + } else { + messages = await this.listMessages(conversation.id) + } return [ `# ${conversation.title}`, @@ -1037,6 +1077,9 @@ export class ConversationService { const target = await this.getManagedConstraintByBindingKey( conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation rename is locked by constraint.') } @@ -1066,6 +1109,9 @@ export class ConversationService { const target = await this.getManagedConstraintByBindingKey( conversation.bindingKey ) + if (target != null) { + await this.assertManageAllowed(session, target) + } if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation delete is locked by constraint.') } @@ -1340,6 +1386,20 @@ export class ConversationService { return null } + if ( + conversation.status === 'deleted' || + conversation.status === 'broken' + ) { + return null + } + + if ( + conversation.status === 'archived' && + !options.includeArchived + ) { + return null + } + if ( !(await this.hasConversationPermission( session, @@ -1641,7 +1701,7 @@ export class ConversationService { private async assertManageAllowed( session: Session, - constraint: ResolvedConstraint + constraint: ResolvedConstraint | ConstraintRecord ) { if (constraint.manageMode !== 'admin') { return @@ -1824,4 +1884,4 @@ function getLock(locks: Map, key: string) { locks.set(key, lock) } return lock -} +} \ No newline at end of file From c6ede64a331c63306394b6aeb45e7d4f542c9f2e Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:00:55 +0000 Subject: [PATCH 08/20] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit From 6f1dd0bd11d0ee6903da8d9aee9458bb95864c95 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 07:24:42 +0800 Subject: [PATCH 09/20] fix(core): enforce target binding chat constraints Resolve stop and rollback checks against the selected conversation binding so cross-route admin and lock rules are applied consistently. --- .../src/middlewares/chat/rollback_chat.ts | 3 +- .../core/src/middlewares/chat/stop_chat.ts | 3 +- packages/core/src/services/conversation.ts | 51 +++++++++-- .../core/src/services/conversation_types.ts | 1 + .../core/tests/conversation-service.spec.ts | 87 +++++++++++++++++++ 5 files changed, 134 insertions(+), 11 deletions(-) diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index d35324413..309c09fe9 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -81,7 +81,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const resolvedContext = await ctx.chatluna.conversation.resolveContext(session, { conversationId: conversation.id, - presetLane: context.options.presetLane + presetLane: context.options.presetLane, + bindingKey: conversation.bindingKey }) if ( diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index b49da1867..1c2ce40d4 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -61,7 +61,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const resolvedContext = await ctx.chatluna.conversation.resolveContext(session, { conversationId: conversation.id, - presetLane: context.options.presetLane + presetLane: context.options.presetLane, + bindingKey: conversation.bindingKey }) if ( diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index fcf4261ea..0e47a635d 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -124,17 +124,44 @@ export class ConversationService { session: Session, options: ResolveConversationContextOptions = {} ): Promise { - const constraints = (await this.listConstraints()).filter((c) => + let constraints = (await this.listConstraints()).filter((c) => this.isConstraintMatched(c, session) ) const routed = constraints.find((c) => c.routeMode != null) - const routeMode = routed?.routeMode ?? this.getDefaultRouteMode(session) - const baseKey = computeBaseBindingKey( + let routeMode = routed?.routeMode ?? this.getDefaultRouteMode(session) + let baseKey = computeBaseBindingKey( session, routeMode, routed?.routeKey ) - const bindingKey = applyPresetLane(baseKey, options.presetLane) + let bindingKey = + options.bindingKey == null + ? applyPresetLane(baseKey, options.presetLane) + : options.bindingKey.includes(':preset:') + ? options.bindingKey + : applyPresetLane(options.bindingKey, options.presetLane) + + if (options.bindingKey != null) { + constraints = constraints.filter( + (c) => !c.name.startsWith('managed:') + ) + + const managed = + await this.getManagedConstraintByBindingKey(bindingKey) + + if (managed != null) { + constraints.unshift(managed) + } + + baseKey = bindingKey.includes(':preset:') + ? bindingKey.slice(0, bindingKey.indexOf(':preset:')) + : bindingKey + routeMode = baseKey.startsWith('shared:') + ? 'shared' + : baseKey.startsWith('personal:') + ? 'personal' + : 'custom' + } return { routeMode, @@ -172,10 +199,16 @@ export class ConversationService { options: ResolveConversationContextOptions = {} ): Promise { const constraint = await this.resolveConstraint(session, options) - const matched = await this.resolveBindingForKey( - session, - constraint.bindingKey - ) + const matched = + options.bindingKey == null + ? await this.resolveBindingForKey( + session, + constraint.bindingKey + ) + : { + bindingKey: constraint.bindingKey, + binding: await this.getBinding(constraint.bindingKey) + } const binding = matched?.binding const bindingKey = matched?.bindingKey ?? constraint.bindingKey const conversation = options.conversationId @@ -1884,4 +1917,4 @@ function getLock(locks: Map, key: string) { locks.set(key, lock) } return lock -} \ No newline at end of file +} diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts index eb5de8ccc..f9d88d0ed 100644 --- a/packages/core/src/services/conversation_types.ts +++ b/packages/core/src/services/conversation_types.ts @@ -157,6 +157,7 @@ export interface ResolvedConversationContext { export interface ResolveConversationContextOptions { presetLane?: string conversationId?: string + bindingKey?: string } export function computeBaseBindingKey( diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts index df80da613..c78f3d8bb 100644 --- a/packages/core/tests/conversation-service.spec.ts +++ b/packages/core/tests/conversation-service.spec.ts @@ -104,6 +104,93 @@ it('ConversationService gives fixed preset precedence over preset lane', async ( assert.equal(resolved.effectivePreset, 'fixed-preset') }) +it('ConversationService resolveContext uses explicit binding key constraints', async () => { + const remote = createConversation({ + id: 'conversation-remote-binding', + bindingKey: 'shared:discord:bot:other-guild' + }) + const { service } = await createService({ + tables: { + chatluna_conversation: [remote as unknown as TableRow], + chatluna_constraint: [ + { + id: 1, + name: 'managed:discord:bot:guild:guild', + enabled: true, + priority: 1000, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + platform: 'discord', + selfId: 'bot', + guildId: 'guild', + channelId: null, + direct: false, + users: null, + excludeUsers: null, + routeMode: null, + routeKey: null, + defaultModel: null, + defaultPreset: null, + defaultChatMode: null, + fixedModel: null, + fixedPreset: null, + fixedChatMode: null, + lockConversation: false, + allowNew: true, + allowSwitch: true, + allowArchive: true, + allowExport: true, + manageMode: 'admin' + } as unknown as TableRow, + { + id: 2, + name: 'managed:discord:bot:guild:other-guild', + enabled: true, + priority: 1000, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + platform: 'discord', + selfId: 'bot', + guildId: 'other-guild', + channelId: null, + direct: false, + users: null, + excludeUsers: null, + routeMode: null, + routeKey: null, + defaultModel: null, + defaultPreset: null, + defaultChatMode: null, + fixedModel: null, + fixedPreset: null, + fixedChatMode: null, + lockConversation: true, + allowNew: true, + allowSwitch: true, + allowArchive: true, + allowExport: true, + manageMode: 'anyone' + } as unknown as TableRow + ] + } + }) + + const resolved = await service.resolveContext( + createSession({ authority: 1 }), + { + conversationId: remote.id, + bindingKey: remote.bindingKey + } + ) + + assert.equal(resolved.bindingKey, remote.bindingKey) + assert.equal(resolved.conversation?.id, remote.id) + assert.equal(resolved.constraint.manageMode, 'anyone') + assert.equal(resolved.constraint.lockConversation, true) +}) + it('ConversationService ensureActiveConversation creates conversation and binding on default route', async () => { const { service, database } = await createService() From 7ad69bd1057cc86731bb993f8045de75086ac428 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 07:26:31 +0800 Subject: [PATCH 10/20] Update packages/core/src/services/conversation.ts Co-authored-by: codefactor-io[bot] <47775046+codefactor-io[bot]@users.noreply.github.com> --- packages/core/src/services/conversation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 0e47a635d..29e7d1671 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -134,7 +134,7 @@ export class ConversationService { routeMode, routed?.routeKey ) - let bindingKey = + const bindingKey = options.bindingKey == null ? applyPresetLane(baseKey, options.presetLane) : options.bindingKey.includes(':preset:') From 461c88c9eaea5d73a7606878e84b1e144293c8bc Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 07:30:51 +0800 Subject: [PATCH 11/20] chore: normalize source file endings Add trailing newlines to touched source files so the working tree stays clean and consistent across builds. --- packages/adapter-dify/src/requester.ts | 2 +- packages/core/src/middleware.ts | 2 +- packages/core/src/middlewares/preset/delete_preset.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index 966c3d9db..433969335 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -789,4 +789,4 @@ export class DifyRequester extends ModelRequester { get logger() { return logger } -} \ No newline at end of file +} diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index b0481912a..5602e6457 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -101,4 +101,4 @@ export async function middleware(ctx: Context, config: Config) { for (const middleware of middlewares) { await middleware(ctx, config, ctx.chatluna.chatChain) } -} \ No newline at end of file +} diff --git a/packages/core/src/middlewares/preset/delete_preset.ts b/packages/core/src/middlewares/preset/delete_preset.ts index 5f66fad16..bf32f9796 100644 --- a/packages/core/src/middlewares/preset/delete_preset.ts +++ b/packages/core/src/middlewares/preset/delete_preset.ts @@ -106,4 +106,4 @@ declare module '../../chains/chain' { interface ChainMiddlewareContextOptions { deletePreset?: string } -} \ No newline at end of file +} From 10b0f62d23ea195c8cba5a796340111bf2a5c3f8 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 08:03:07 +0800 Subject: [PATCH 12/20] fix(adapter-dify): preserve shared Dify conversations Use per-user variables only in personal route mode so shared chats keep the same Dify user, and rely on typed additional_kwargs access when reading conversation IDs. --- packages/adapter-dify/src/requester.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index 433969335..03d8557cb 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -106,12 +106,9 @@ export class DifyRequester extends ModelRequester { ): string | undefined { for (const message of messages) { const conversationId = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message as any)?.additional_kwargs?.chatluna_conversation_id || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message as any)?.additional_kwargs?.conversationId || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message as any)?.additional_kwargs?.conversation_id + message?.additional_kwargs?.chatluna_conversation_id || + message?.additional_kwargs?.conversationId || + message?.additional_kwargs?.conversation_id if ( typeof conversationId === 'string' && @@ -412,11 +409,15 @@ export class DifyRequester extends ModelRequester { } private resolveDifyUser(params: ModelRequestParams): string { - return ( - (params.variables?.['user_id'] as string) || - (params.variables?.['user'] as string) || - 'chatluna' - ) + if (this.ctx.chatluna.config.defaultGroupRouteMode === 'personal') { + return ( + (params.variables?.['user_id'] as string) || + (params.variables?.['user'] as string) || + 'chatluna' + ) + } else { + return 'chatluna' + } } private async prepareFiles( From 493721701cf109b738ec24137bf3abb0c10f62f8 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 09:24:13 +0800 Subject: [PATCH 13/20] refactor(core): remove auth quota system Drop legacy auth and balance flows so the room-to-conversation migration only keeps the legacy ChatHub tables that still need to survive until purge. --- packages/core/src/authorization/service.ts | 525 ------------------ packages/core/src/authorization/types.ts | 46 -- packages/core/src/command.ts | 3 +- packages/core/src/commands/auth.ts | 115 ---- packages/core/src/config.ts | 18 - packages/core/src/index.ts | 7 +- packages/core/src/locales/en-US.schema.yml | 10 - packages/core/src/locales/en-US.yml | 148 ----- packages/core/src/locales/zh-CN.schema.yml | 10 - packages/core/src/locales/zh-CN.yml | 149 ----- packages/core/src/middleware.ts | 18 - .../auth/add_user_to_auth_group.ts | 42 -- .../core/src/middlewares/auth/black_list.ts | 30 - .../src/middlewares/auth/create_auth_group.ts | 400 ------------- .../auth/kick_user_form_auth_group.ts | 42 -- .../src/middlewares/auth/list_auth_group.ts | 68 --- .../src/middlewares/auth/set_auth_group.ts | 452 --------------- .../middlewares/chat/chat_time_limit_check.ts | 86 +-- .../middlewares/chat/chat_time_limit_save.ts | 21 +- .../src/middlewares/chat/rollback_chat.ts | 2 +- .../core/src/middlewares/chat/stop_chat.ts | 2 +- .../conversation/request_conversation.ts | 38 +- .../src/middlewares/system/clear_balance.ts | 34 -- .../src/middlewares/system/query_balance.ts | 31 -- .../core/src/middlewares/system/restart.ts | 18 +- .../src/middlewares/system/set_balance.ts | 43 -- packages/core/src/middlewares/system/wipe.ts | 184 +++--- packages/core/src/migration/legacy_tables.ts | 223 +++++++- .../src/migration/room_to_conversation.ts | 7 + packages/core/src/services/chat.ts | 232 -------- packages/core/src/services/conversation.ts | 4 +- packages/core/src/utils/error.ts | 4 - packages/core/src/utils/koishi.ts | 4 +- .../core/tests/conversation-source.spec.ts | 3 - packages/core/tests/helpers.ts | 4 + 35 files changed, 336 insertions(+), 2687 deletions(-) delete mode 100644 packages/core/src/authorization/service.ts delete mode 100644 packages/core/src/authorization/types.ts delete mode 100644 packages/core/src/commands/auth.ts delete mode 100644 packages/core/src/middlewares/auth/add_user_to_auth_group.ts delete mode 100644 packages/core/src/middlewares/auth/black_list.ts delete mode 100644 packages/core/src/middlewares/auth/create_auth_group.ts delete mode 100644 packages/core/src/middlewares/auth/kick_user_form_auth_group.ts delete mode 100644 packages/core/src/middlewares/auth/list_auth_group.ts delete mode 100644 packages/core/src/middlewares/auth/set_auth_group.ts delete mode 100644 packages/core/src/middlewares/system/clear_balance.ts delete mode 100644 packages/core/src/middlewares/system/query_balance.ts delete mode 100644 packages/core/src/middlewares/system/set_balance.ts diff --git a/packages/core/src/authorization/service.ts b/packages/core/src/authorization/service.ts deleted file mode 100644 index b66c76a85..000000000 --- a/packages/core/src/authorization/service.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { Decimal } from 'decimal.js' -import { Context, Service, Session } from 'koishi' -import { - ChatLunaError, - ChatLunaErrorCode -} from 'koishi-plugin-chatluna/utils/error' -import { ChatHubAuthGroup, ChatHubAuthUser } from './types' - -export class ChatLunaAuthService extends Service { - constructor( - public readonly ctx: Context, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public config: any - ) { - super(ctx, 'chatluna_auth') - - ctx.on('ready', async () => { - await this._defineDatabase() - }) - } - - async getUser( - session: Session, - userId: string = session.userId - ): Promise { - const list = await this.ctx.database.get('chathub_auth_user', { - userId - }) - - if (list.length === 0) { - return this._createUser(session, userId) - } else if (list.length > 1) { - throw new ChatLunaError(ChatLunaErrorCode.USER_NOT_FOUND) - } - - return list[0] - } - - private async _createUser( - session: Session, - userId: string = session.userId - ): Promise { - const user = await this.ctx.database.getUser(session.platform, userId) - - if (user == null) { - throw new ChatLunaError( - ChatLunaErrorCode.USER_NOT_FOUND, - new Error(` - user not found in platform ${session.platform} and id ${userId}`) - ) - } - - const resolveAuthType = (authType: number) => - authType > 2 ? 'admin' : authType > 1 ? 'user' : 'guest' - - const copyOfSession = session?.bot?.session(session.event) ?? session - copyOfSession.userId = userId - - let [rawAuthType, balance, authGroup] = (await copyOfSession.resolve( - this.config.authUserDefaultGroup - )) ?? [0, 0, 'guest'] - - const authType = resolveAuthType( - user.authority > rawAuthType ? user.authority : rawAuthType - ) - - if (authType === 'admin') { - authGroup = authType - } - - const authUser: ChatHubAuthUser = { - userId, - balance: - balance === 0 - ? authType === 'admin' - ? 10000 - : authType === 'user' - ? 10 - : 1 - : balance, - authType - } - - await this.ctx.database.upsert('chathub_auth_user', [authUser]) - - await this.addUserToGroup(authUser, authGroup) - - return authUser - } - - async createAuthGroup(session: Session, group: ChatHubAuthGroup) { - const user = await this.getUser(session) - - await this.ctx.database.upsert('chathub_auth_group', [group]) - - await this.addUserToGroup(user, group.name) - } - - async resolveAuthGroup( - session: Session, - platform: string, - userId: string = session.userId - ): Promise { - // search platform - - const groups = ( - await this.ctx.database.get('chathub_auth_group', { - platform: { - $or: [undefined, platform] - } - }) - ).sort((a, b) => { - // prefer the same platform - if (a.platform === platform) { - return -1 - } - if (b.platform === platform) { - return 1 - } - return b.priority - a.priority - }) - - // Here there will be no such thing as a user joining too many groups, so a query will work. - const groupIds = groups.map((g) => g.id) - - const joinedGroups = ( - await this.ctx.database.get('chathub_auth_joined_user', { - groupId: { - // max 50?? - $in: groupIds - }, - userId - }) - ).sort( - (a, b) => groupIds.indexOf(a.groupId) - groupIds.indexOf(b.groupId) - ) - - if (joinedGroups.length === 0) { - throw new ChatLunaError(ChatLunaErrorCode.AUTH_GROUP_NOT_JOINED) - } - - const result = groups.find((g) => g.id === joinedGroups[0].groupId) - - if (result == null) { - throw new ChatLunaError( - ChatLunaErrorCode.AUTH_GROUP_NOT_FOUND, - new Error(`Group not found for user ${session.username} and platform - ${platform}`) - ) - } - - return result - } - - async getAuthGroups(platform?: string) { - const groups = await this.ctx.database.get('chathub_auth_group', { - platform - }) - - return groups - } - - async getAuthGroup(name: string, throwError: boolean = true) { - const result = ( - await this.ctx.database.get('chathub_auth_group', { name }) - )?.[0] - - if (result == null && throwError) { - throw new ChatLunaError(ChatLunaErrorCode.AUTH_GROUP_NOT_FOUND) - } - - return result - } - - async calculateBalance( - session: Session, - platform: string, - usedTokenNumber: number, - userId: string = session.userId - ): Promise { - // TODO: use default balance checker - // await this.getUser(session) - - const currentAuthGroup = await this.resolveAuthGroup( - session, - platform, - userId - ) - - // 1k token per - const usedBalance = new Decimal(0.001) - .mul(currentAuthGroup.costPerToken) - .mul(usedTokenNumber) - - return await this.modifyBalance(session, -usedBalance.toNumber()) - } - - async getBalance( - session: Session, - userId: string = session.userId - ): Promise { - return (await this.getUser(session, userId)).balance - } - - async modifyBalance( - session: Session, - amount: number, - userId: string = session.userId - ): Promise { - const user = await this.getUser(session, userId) - - user.balance = new Decimal(user.balance).add(amount).toNumber() - - await this.ctx.database.upsert('chathub_auth_user', [user]) - - return user.balance - } - - async setBalance( - session: Session, - amount: number, - userId: string = session.userId - ): Promise { - const user = await this.getUser(session, userId) - - user.balance = amount - - await this.ctx.database.upsert('chathub_auth_user', [user]) - - return user.balance - } - - private async _getAuthGroup(authGroupId: number) { - const authGroup = ( - await this.ctx.database.get('chathub_auth_group', { - id: authGroupId - }) - )?.[0] - - if (authGroup == null) { - throw new ChatLunaError( - ChatLunaErrorCode.AUTH_GROUP_NOT_FOUND, - new Error(`Auth group not found for id ${authGroupId}`) - ) - } - - return authGroup - } - - async resetAuthGroup(authGroupId: number) { - const authGroup = await this._getAuthGroup(authGroupId) - const currentTime = new Date() - - authGroup.lastCallTime = authGroup.lastCallTime ?? currentTime.getTime() - - const authGroupDate = new Date(authGroup.lastCallTime) - - const currentDayOfStart = new Date().setHours(0, 0, 0, 0) - - // If the last call time is not today, then all zeroed out - - if (authGroupDate.getTime() < currentDayOfStart) { - authGroup.currentLimitPerDay = 0 - authGroup.currentLimitPerMin = 0 - authGroup.lastCallTime = currentTime.getTime() - - await this.ctx.database.upsert('chathub_auth_group', [authGroup]) - - return authGroup - } - - // Check to see if it's been more than a minute since the last call - - if (currentTime.getTime() - authGroup.lastCallTime >= 60000) { - // clear - - authGroup.currentLimitPerMin = 0 - authGroup.lastCallTime = currentTime.getTime() - - await this.ctx.database.upsert('chathub_auth_group', [authGroup]) - - return authGroup - } - - return authGroup - } - - async increaseAuthGroupCount(authGroupId: number) { - const authGroup = await this._getAuthGroup(authGroupId) - const currentTime = new Date() - - authGroup.lastCallTime = authGroup.lastCallTime ?? currentTime.getTime() - - const authGroupDate = new Date(authGroup.lastCallTime) - - const currentDayOfStart = new Date().setHours(0, 0, 0, 0) - - // If the last call time is not today, then all zeroed out - - if (authGroupDate.getTime() < currentDayOfStart) { - authGroup.currentLimitPerDay = 1 - authGroup.currentLimitPerMin = 1 - authGroup.lastCallTime = currentTime.getTime() - - await this.ctx.database.upsert('chathub_auth_group', [authGroup]) - - return - } - - // Check to see if it's been more than a minute since the last call - - if (currentTime.getTime() - authGroup.lastCallTime >= 60000) { - // clear - - authGroup.currentLimitPerDay += 1 - authGroup.currentLimitPerMin = 1 - authGroup.lastCallTime = currentTime.getTime() - - await this.ctx.database.upsert('chathub_auth_group', [authGroup]) - } - - authGroup.currentLimitPerDay += 1 - authGroup.currentLimitPerMin += 1 - - await this.ctx.database.upsert('chathub_auth_group', [authGroup]) - } - - async addUserToGroup(user: ChatHubAuthUser, groupName: string) { - const group = await this.getAuthGroup(groupName) - - const isJoined = - ( - await this.ctx.database.get('chathub_auth_joined_user', { - groupName, - userId: user.userId - }) - ).length === 1 - - if (isJoined) { - throw new ChatLunaError(ChatLunaErrorCode.AUTH_GROUP_ALREADY_JOINED) - } - - await this.ctx.database.upsert('chathub_auth_joined_user', [ - { - userId: user.userId, - groupId: group.id, - groupName: group.name - } - ]) - } - - async removeUserFormGroup(user: ChatHubAuthUser, groupName: string) { - const group = await this.getAuthGroup(groupName) - - await this.ctx.database.remove('chathub_auth_joined_user', { - userId: user.userId, - groupName: group.name - }) - } - - async setAuthGroup(groupName: string, group: Partial) { - await this.ctx.database.upsert('chathub_auth_group', [ - Object.assign({}, group, { - name: groupName - }) - ]) - } - - private async _initAuthGroup() { - // init guest group - - const groups = await this.ctx.database.get('chathub_auth_group', { - name: { - $in: ['guest', 'user', 'admin'] - } - }) - - let currentGroup: Omit - - if (!groups.some((g) => g.name === 'guest')) { - currentGroup = { - name: 'guest', - priority: 0, - - limitPerMin: 10, - limitPerDay: 2000, - - // 1000 token / 0.3 - costPerToken: 0.3 - } - - await this.ctx.database.upsert('chathub_auth_group', [currentGroup]) - } - - if (!groups.some((g) => g.name === 'user')) { - currentGroup = { - name: 'user', - priority: 1, - limitPerMin: 1000, - limitPerDay: 200000, - - // 1000 token / 0.01 - costPerToken: 0.01 - } - - await this.ctx.database.upsert('chathub_auth_group', [currentGroup]) - } - - if (!groups.some((g) => g.name === 'admin')) { - currentGroup = { - name: 'admin', - priority: 2, - limitPerMin: 10000, - limitPerDay: 20000000, - - // 1000 token / 0.001 - costPerToken: 0.001 - } - - await this.ctx.database.upsert('chathub_auth_group', [currentGroup]) - } - } - - private async _defineDatabase() { - const ctx = this.ctx - - ctx.database.extend( - 'chathub_auth_user', - { - userId: { - type: 'string' - }, - balance: { - type: 'decimal', - precision: 20, - scale: 10 - }, - authType: { - type: 'char', - length: 50 - } - }, - { - autoInc: false, - primary: 'userId', - unique: ['userId'] - } - ) - - ctx.database.extend( - 'chathub_auth_joined_user', - { - userId: 'string', - groupId: 'integer', - groupName: 'string', - id: 'integer' - }, - { - autoInc: true, - primary: 'id', - unique: ['id'] - } - ) - - ctx.database.extend( - 'chathub_auth_group', - { - limitPerDay: { - type: 'integer', - nullable: false - }, - limitPerMin: { - type: 'integer', - nullable: false - }, - lastCallTime: { - type: 'integer', - length: 7, - nullable: true - }, - currentLimitPerDay: { - type: 'integer', - nullable: true - }, - currentLimitPerMin: { - type: 'integer', - nullable: true - }, - supportModels: { - type: 'json', - nullable: true - }, - priority: { - type: 'integer', - nullable: false, - initial: 0 - }, - platform: { - type: 'char', - length: 255, - nullable: true - }, - costPerToken: { - type: 'decimal', - precision: 8, - scale: 4 - }, - name: { - type: 'char', - length: 255 - }, - id: { - type: 'integer' - } - }, - { - autoInc: true, - primary: 'id', - unique: ['id', 'name'] - } - ) - - await this._initAuthGroup() - } -} diff --git a/packages/core/src/authorization/types.ts b/packages/core/src/authorization/types.ts deleted file mode 100644 index 990a45e4b..000000000 --- a/packages/core/src/authorization/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ChatLunaAuthService } from './service' - -export interface ChatHubAuthUser { - userId: string - balance: number - authType: AuthType -} - -export interface ChatHubAuthJoinedUser { - userId: string - groupId: number - groupName: string - id: number -} - -export type AuthType = 'guest' | 'user' | 'admin' - -export interface ChatHubAuthGroup { - name: string - platform?: string - priority: number - id: number - limitPerMin: number - limitPerDay: number - - costPerToken: number - - currentLimitPerMin?: number - currentLimitPerDay?: number - - lastCallTime?: number - - supportModels: string[] -} - -declare module 'koishi' { - interface Context { - chatluna_auth: ChatLunaAuthService - } - - interface Tables { - chathub_auth_group: ChatHubAuthGroup - chathub_auth_user: ChatHubAuthUser - chathub_auth_joined_user: ChatHubAuthJoinedUser - } -} diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index c0b8debe7..98504a13b 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -2,7 +2,6 @@ import { Context } from 'koishi' import { ChatChain } from 'koishi-plugin-chatluna/chains' import { Config } from './config' // import start -import { apply as auth } from './commands/auth' import { apply as chat } from './commands/chat' import { apply as conversation } from './commands/conversation' import { apply as mcp } from './commands/mcp' @@ -21,7 +20,7 @@ export async function command(ctx: Context, config: Config) { const middlewares: Command[] = // middleware start - [auth, chat, conversation, mcp, memory, model, preset, providers, tool] // middleware end + [chat, conversation, mcp, memory, model, preset, providers, tool] // middleware end for (const middleware of middlewares) { await middleware(ctx, config, ctx.chatluna.chatChain) diff --git a/packages/core/src/commands/auth.ts b/packages/core/src/commands/auth.ts deleted file mode 100644 index 798d7b124..000000000 --- a/packages/core/src/commands/auth.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Context } from 'koishi' -import { ChatChain } from '../chains/chain' -import { Config } from '../config' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - if (config.authSystem !== true) return - - ctx.command('chatluna.auth', { authority: 1 }) - - ctx.command('chatluna.auth.list') - .option('page', '-p ') - .option('limit', '-l ') - .option('platform', '-t ') - .action(async ({ options, session }) => { - await chain.receiveCommand(session, 'list_auth_group', { - authPlatform: options.platform, - page: options.page ?? 1, - limit: options.limit ?? 3 - }) - }) - - ctx.command('chatluna.auth.add ') - .option('user', '-u ') - .action(async ({ session, options }, name) => { - const userId = options.user?.split(':')?.[1] ?? session.userId - await chain.receiveCommand(session, 'add_user_to_auth_group', { - auth_group_resolve: { name }, - authUser: userId - }) - }) - - ctx.command('chatluna.auth.kick ') - .option('user', '-u ') - .action(async ({ session, options }, name) => { - const userId = options.user?.split(':')?.[1] ?? session.userId - await chain.receiveCommand(session, 'kick_user_form_auth_group', { - auth_group_resolve: { name }, - authUser: userId - }) - }) - - ctx.command('chatluna.auth.create') - .option('name', '-n ') - .option('preMin', '-pm ') - .option('preDay', '-pd ') - .option('platform', '-pf ') - .option('supportModels', '-s [...model]') - .option('priority', '-p ') - .option('cost', '-c ') - .action(async ({ session, options }) => { - await chain.receiveCommand(session, 'create_auth_group', { - auth_group_resolve: { - name: options.name ?? undefined, - requestPreDay: options.preDay ?? undefined, - requestPreMin: options.preMin ?? undefined, - platform: options.platform ?? undefined, - supportModels: options.supportModels ?? undefined, - priority: options.priority ?? undefined - } - }) - }) - - ctx.command('chatluna.auth.set') - .option('name', '-n ') - .option('preMin', '-pm ') - .option('preDay', '-pd ') - .option('platform', '-pf ') - .option('supportModels', '-s [...model]') - .option('priority', '-p ') - .option('cost', '-c ') - .action(async ({ session, options }) => { - await chain.receiveCommand(session, 'set_auth_group', { - auth_group_resolve: { - name: options.name ?? undefined, - requestPreDay: options.preDay ?? undefined, - requestPreMin: options.preMin ?? undefined, - platform: options.platform ?? undefined, - supportModels: options.supportModels ?? undefined, - priority: options.priority ?? undefined - } - }) - }) - - ctx.command('chatluna.balance') - - ctx.command('chatluna.balance.clear ', { authority: 3 }).action( - async ({ session }, user) => { - const userId = user?.split(':')?.[1] ?? user - - await chain.receiveCommand(session, 'clear_balance', { - authUser: userId - }) - } - ) - - ctx.command('chatluna.balance.set ', { authority: 3 }) - .option('user', '-u ') - .action(async ({ options, session }, balance) => { - const userId = options.user?.split(':')?.[1] ?? session.userId - await chain.receiveCommand(session, 'set_balance', { - authUser: userId, - balance - }) - }) - - ctx.command('chatluna.balance.query [user:user]').action( - async ({ session }, user) => { - const userId = user?.split(':')?.[1] ?? session.userId - - await chain.receiveCommand(session, 'query_balance', { - authUser: userId - }) - } - ) -} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5185815bd..f896d1d7b 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -48,9 +48,6 @@ export interface Config { defaultModel: string defaultPreset: string - authUserDefaultGroup: Computed> - authSystem: boolean - voiceSpeakId: number enableSimilarityCheck: boolean @@ -176,7 +173,6 @@ export const Config: Schema = Schema.intersect([ }), Schema.object({ - authSystem: Schema.boolean().experimental().hidden().default(false), isProxy: Schema.boolean().default(false), voiceSpeakId: Schema.number().default(0), isLog: Schema.boolean().default(false) @@ -188,20 +184,6 @@ export const Config: Schema = Schema.intersect([ proxyAddress: Schema.string().default('') }), Schema.object({}) - ]), - - Schema.union([ - Schema.object({ - authSystem: Schema.const(true).required(), - authUserDefaultGroup: Schema.tuple([ - Schema.number().default(0), - Schema.number().default(1.0), - Schema.string().default('guest') - ]) - .computed() - .default([0, 1.0, 'guest']) - }), - Schema.object({}) ]) ]).i18n({ 'zh-CN': require('./locales/zh-CN.schema'), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d5bf3e82..dc2b30ed6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,6 @@ import { } from 'koishi-plugin-chatluna/utils/logger' import * as request from 'koishi-plugin-chatluna/utils/request' import { PromiseLikeDisposable } from 'koishi-plugin-chatluna/utils/types' -import { ChatLunaAuthService } from './authorization/service' import { command } from './command' import { Config } from './config' import { defaultFactory } from './llm-core/chat/default' @@ -87,7 +86,6 @@ function setupEntryPoint( inject: { ...inject2, chatluna: { required: true }, - chatluna_auth: { required: false }, chatluna_storage: { required: false }, database: { required: false }, notifier: { required: false } @@ -158,10 +156,7 @@ function setupServices( config: Config, disposables: PromiseLikeDisposable[] ) { - disposables.push( - forkScopeToDisposable(ctx.plugin(ChatLunaService, config)), - forkScopeToDisposable(ctx.plugin(ChatLunaAuthService, config)) - ) + disposables.push(forkScopeToDisposable(ctx.plugin(ChatLunaService, config))) } function setupPermissions(ctx: Context, disposables: PromiseLikeDisposable[]) { diff --git a/packages/core/src/locales/en-US.schema.yml b/packages/core/src/locales/en-US.schema.yml index a475a7a4b..db5f8c587 100644 --- a/packages/core/src/locales/en-US.schema.yml +++ b/packages/core/src/locales/en-US.schema.yml @@ -39,7 +39,6 @@ $inner: - $desc: Blacklist Management blackList: Configure blacklist. Use cautiously to avoid unintended blocking. The number 1 indicates enabled blacklist. Set to zero value to unenable blacklist. - blockText: Set fixed reply for blacklisted users. - $desc: History Management infiniteContext: Enable automatic Infinite Context compression when history nears the model limit. Preserves critical topics and instructions while discarding noise. @@ -64,10 +63,6 @@ $inner: defaultPreset: Set default chat preset. - $desc: Miscellaneous - authSystem: - $desc: Enable quota group and user permission system (experimental). Overrides adapter call limits. - $inner: - - true errorTemplate: Set error prompt message template (subject to change). voiceSpeakId: Set default speaker ID for vits service. isLog: Enable debug mode. @@ -79,8 +74,3 @@ $inner: - - $desc: Proxy Configuration $inner: proxyAddress: Set proxy address for network requests. Falls back to Koishi global proxy settings if empty. - - - - $desc: Quota Group Configuration - $inner: - authUserDefaultGroup: - $desc: 'Format: [permission level, initial balance, authorization group name]. Levels: 0 (guest), 1 (user), 2 (admin). Leave unconfigured if uncertain.' diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 96ad994a5..4f2719c5b 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -235,149 +235,6 @@ commands: purge-legacy: description: Purge migrated legacy ChatLuna data. - auth: - description: ChatLuna authentication commands. - list: - description: Display authorization groups. - options: - page: Page number - limit: Items per page - platform: Specify platform - messages: - header: 'Quota groups:' - footer: 'Use chatluna.auth.add [name] to join a quota group.' - pages: 'Page: [page] / [total]' - line: '{0} [{1}] priority:{2} limit:{3}/min {4}/day' - general: 'General' - add: - description: Add user to quota group. - usage: 'Usage: chatluna auth add [group] name -u @user' - arguments: - name: Quota group name. - options: - user: Target user - messages: - permission_denied: 'Insufficient permissions for this operation.' - success: 'User {0} added to quota group {1}.' - kick: - description: Remove user from quota group. - usage: 'Usage: chatluna auth kick [group name] -u @user' - arguments: - name: Quota group name. - options: - user: Target user - messages: - permission_denied: 'Insufficient permissions for this operation.' - success: 'User {0} removed from quota group {1}.' - create: - description: Create new authorization group. - options: - name: Group name - preMin: Per-minute limit - preDay: Daily limit - platform: Platform - supportModels: 'Supported models' - priority: Priority - cost: Token cost - messages: - enter_name: 'Enter quota group name (e.g., OpenAI Quota Group):' - name_exists: 'Group name already exists. Please try again.' - enter_limit_per_min: 'Enter per-minute limit (>0):' - enter_limit_per_day: 'Enter daily limit (>per-minute limit):' - enter_platform: 'Enter model platform ID (e.g., openai) or N to skip:' - enter_priority: 'Enter priority (higher number = higher priority):' - enter_cost: 'Enter token cost (per 1000 tokens):' - enter_models: 'Enter allowed models (comma-separated) or N to skip:' - invalid_input: 'Invalid input for {0}. Please try again.' - confirm_create: 'Create quota group? Y: Create, N: Interactive mode, Any other: Cancel.' - timeout: 'Response timeout. Creation cancelled.' - cancelled: 'Quota group creation cancelled.' - success: 'Quota group "{0}" created successfully.' - change_or_keep: '{0} {1}: {2}. Change? New value or N to keep.' - invalid_models: 'Invalid models detected. Please try again.' - action: - input: 'Input' - set: 'Set' - select: 'Select' - field: - name: 'Group name' - limit_per_min: 'Per-minute limit' - limit_per_day: 'Daily limit' - platform: 'Platform ID' - priority: 'Priority' - cost: 'Cost' - models: 'Allowed models' - set: - description: 'Modify existing authorization group.' - options: - name: 'Group name' - preMin: 'Per-minute limit' - preDay: 'Daily limit' - platform: 'Platform' - supportModels: 'Supported models' - priority: 'Priority' - cost: 'Token cost' - messages: - confirm_set: 'Modify quota group? Y: Direct modify, N: Interactive mode, Any other: Cancel.' - timeout: 'Response timeout. Modification cancelled.' - cancelled: 'Quota group modification cancelled.' - enter_name: 'Enter new group name (e.g., OpenAI Quota Group) or Q to exit:' - name_exists: 'Group name already exists. Please try again.' - enter_limit_per_min: 'Enter new per-minute limit (>0) or Q to exit:' - enter_limit_per_day: 'Enter new daily limit (>per-minute limit) or Q to exit:' - enter_platform: 'Enter new platform ID (e.g., openai), N to skip, or Q to exit:' - enter_priority: 'Enter new priority (higher = more priority) or Q to exit:' - enter_cost: 'Enter new token cost (per 1000 tokens) or Q to exit:' - enter_models: 'Enter new allowed models (comma-separated), N to skip, or Q to exit:' - invalid_input: 'Invalid input for {0}. Please try again.' - change_or_keep: '{0} {1}: {2}. Change? New value, N to keep, or Q to exit.' - invalid_models: 'Invalid models detected. Please try again.' - success: 'Quota group "{0}" modified successfully.' - action: - input: 'Input' - set: 'Set' - select: 'Select' - field: - name: 'Group name' - limit_per_min: 'Per-minute limit' - limit_per_day: 'Daily limit' - platform: 'Platform ID' - priority: 'Priority' - cost: 'Cost' - models: 'Allowed models' - - balance: - description: ChatLuna balance management. - clear: - description: Reset user balance. - arguments: - user: Target user - examples: - - 'chatluna balance clear --user @username' - messages: - success: 'User {0} balance reset to {1}' - set: - description: 'Adjust user balance.' - options: - user: 'Target user' - arguments: - user: 'Target user' - balance: 'New balance' - amount: 'New balance' - examples: - - 'chatluna balance set --user @username --amount 1000' - messages: - success: 'User {0} balance updated to {1}' - query: - description: Check user balance. - arguments: - user: 'Target user (default: current user)' - examples: - - 'chatluna balance query' - - 'chatluna balance query --user @username' - messages: - success: 'User {0} current balance: {1}' - model: description: ChatLuna model management. list: @@ -674,15 +531,10 @@ chatluna: quote_said: 'said' aborted: 'Current conversation generation stopped successfully.' thinking_message: 'Processing... {0} messages in queue. Please wait.' - block_message: '' error_message: 'ChatLuna error occurred. Error code: %s. Contact developer for assistance.' middleware_error: 'Error in {0}: {1}' not_available_model: 'No models are currently available. Please check your configuration to ensure model adapters are properly installed and configured.' chat_limit_exceeded: 'Daily chat limit reached. Please try again in {0} minutes.' - insufficient_balance: 'Insufficient balance. Current balance: {0}.' - unsupported_model: 'Quota group {0} does not support model {1}.' - limit_per_minute_exceeded: 'Quota group {0} reached the per-minute limit ({2}/{1}).' - limit_per_day_exceeded: 'Quota group {0} reached the daily limit ({2}/{1}).' conversation: default_title: 'New Conversation' active: 'active' diff --git a/packages/core/src/locales/zh-CN.schema.yml b/packages/core/src/locales/zh-CN.schema.yml index c223ab0c7..b4399592c 100644 --- a/packages/core/src/locales/zh-CN.schema.yml +++ b/packages/core/src/locales/zh-CN.schema.yml @@ -40,7 +40,6 @@ $inner: - $desc: 黑名单选项 blackList: $desc: 设置黑名单列表。请谨慎使用,只对需要拉黑的用户或群启用。1 表示启用黑名单,0 表示不启用黑名单。默认值为 0。 - blockText: 设置对被拉黑用户的固定回复内容。 - $desc: 历史记录选项 infiniteContext: 启用「无限上下文」,当对话接近模型的上下文上限时自动压缩旧消息,保留关键话题和指令并丢弃无关内容。 @@ -65,10 +64,6 @@ $inner: defaultPreset: 设置默认使用的聊天预设。 - $desc: 杂项 - authSystem: - $desc: 是否启用配额组和用户权限系统(实验性功能)。启用后,各适配器设置的调用限额将失效。 - $inner: - - true errorTemplate: 设置错误提示消息的模板(此设置在未来版本中可能会有变更)。 voiceSpeakId: 设置使用 vits 服务时的默认发音人 ID。 isLog: 是否启用调试模式。 @@ -80,8 +75,3 @@ $inner: - - $desc: 代理选项 $inner: proxyAddress: 网络请求的代理地址。填写后,ChatLuna 相关插件的网络服务将使用此代理地址。如不填写,将尝试使用 Koishi 全局配置中的代理设置。 - - - - $desc: 配额组选项 - $inner: - authUserDefaultGroup: - $desc: 格式为 [权限等级, 初始余额, 授权组名称]。权限等级:0 为 guest,1 为 user,2 为 admin。如不了解,请勿配置。 diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 2740089da..7ab23fdad 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -235,150 +235,6 @@ commands: purge-legacy: description: 清理已迁移的旧版 ChatLuna 数据。 - auth: - description: ChatLuna 鉴权相关指令。 - list: - description: 列出授权组。 - options: - page: 页码。 - limit: 每页显示的数量。 - platform: 指定平台。 - messages: - header: '配额组列表:' - footer: '使用 chatluna.auth.add [name] 加入配额组。' - pages: '第 [page] / [total] 页' - line: '{0} [{1}] 优先级:{2} 限额:{3}/min {4}/day' - general: '通用' - add: - description: 将用户加入到指定配额组。 - usage: '使用方法:chatluna auth add [组名] -u @用户' - arguments: - name: 配额组名称。 - options: - user: 目标用户。 - messages: - permission_denied: '你的权限不足以执行此操作。' - success: '已将用户 {0} 添加到配额组 {1}。' - kick: - description: 将用户从指定配额组中移除。 - usage: '使用方法:chatluna auth kick [组名] -u @用户' - arguments: - name: 配额组名称。 - options: - user: 目标用户。 - messages: - permission_denied: '你的权限不足以执行此操作。' - success: '已将用户 {0} 踢出配额组 {1}' - create: - description: 创建一个新的授权组。 - options: - name: 授权组名称。 - preMin: 每分钟请求限额。 - preDay: 每日请求限额。 - platform: 指定平台。 - supportModels: 支持的模型列表。 - priority: 优先级。 - cost: token 费用。 - messages: - enter_name: '请输入你需要使用的配额组名,如:OpenAI配额组' - name_exists: '你输入的配额组名已存在,请重新输入。' - enter_limit_per_min: '请输入配额组每分钟的限额条数,要求为数字并且大于 0。' - enter_limit_per_day: '请输入配额组每天的限额条数,要求为数字并且大于每分钟的限额次数。' - enter_platform: '请输入对该配额组的模型平台标识符,如: openai。表示优先在使用该平台模型时使用该配额组,如不输入回复 N' - enter_priority: '请输入配额组的优先级(数字,越大越优先)(这很重要,会决定配额组的使用顺序)。' - enter_cost: '请输入配额组的 token 费用(数字,按一千 token 计费,实际扣除用户余额)。' - enter_models: '请输入该配额组可使用的模型列表(白名单机制),用英文逗号分割,如(openai/gpt-3.5-turbo, openai/gpt-4)。如果不输入回复 N(则不设置型列表)。' - invalid_input: '你输入的{0}有误,请重新输入。' - confirm_create: '你目前已提供基础参数,是否直接创建配额组?如需直接创建配额组请回复 Y,如需进入交互式创建请回复 N,其他回复将视为取消。' - timeout: '你超时未回复,已取消创建配额组。' - cancelled: '你已取消创建配额组。' - success: '配额组创建成功,配额组名为:{0}。' - change_or_keep: '你已经{0}{1}:{2},是否需要更换?如需更换请回复更换后的{1},否则回复 N。' - invalid_models: '模型组里有不支持的模型,请重新输入。' - action: - input: '输入' - set: '设置' - select: '选择' - field: - name: '配额组名' - limit_per_min: '每分钟限额条数' - limit_per_day: '每天限额条数' - platform: '平台标识符' - priority: '优先级' - cost: '费用' - models: '模型列表' - - set: - description: '修改现有授权组的参数。' - options: - name: '授权组名称。' - preMin: '每分钟请求限额。' - preDay: '每日请求限额。' - platform: '指定平台。' - supportModels: '支持的模型列表。' - priority: '优先级。' - cost: 'token 费用。' - messages: - confirm_set: '你目前已提供基础参数,是否直接修改配额组?如需直接修改配额组请回复 Y,如需进入交互式创建请回复 N,其他回复将视为取消。' - timeout: '你超时未回复,已取消修改配额组。' - cancelled: '你已取消修改配额组。' - enter_name: '请输入你需要使用的配额组名,如:OpenAI配额组。回复 Q 退出修改。' - name_exists: '你输入的配额组名已存在,请重新输入。' - enter_limit_per_min: '请输入配额组每分钟的限额条数,要求为数字并且大于 0。回复 Q 退出修改。' - enter_limit_per_day: '请输入配额组每天的限额条数,要求为数字并且大于每分钟的限额次数。回复 Q 退出修改。' - enter_platform: '请输入对该配额组的模型平台标识符,如: openai。表示会优先在使用该平台模型时使用该配额组,如不输入回复 N。回复 Q 退出修改。' - enter_priority: '请输入配额组的优先级(数字,越大越优先)(这很重要,会决定配额组的使用顺序)。回复 Q 退出修改。' - enter_cost: '请输入配额组的 token 费用(数字,按一千 token 计费,实际扣除用户余额)。回复 Q 退出修改。' - enter_models: '请输入该配额组可使用的模型列表(白名单机制),用英文逗号分割,如(openai/gpt-3.5-turbo, openai/gpt-4)。如果不输入回复 N(则不设置模型列表)。回复 Q 退出修改。' - invalid_input: '你输入的{0}有误,请重新输入。' - change_or_keep: '你已经{0}{1}:{2},是否需要更换?如需更换请回复更换后的{1},否则回复 N。回复 Q 退出修改。' - invalid_models: '模型组里有不支持的模型,请重新输入。' - success: '配额组修改成功,新配额组名为:{0}。' - action: - input: '输入' - set: '设置' - select: '选择' - field: - name: '配额组名' - limit_per_min: '每分钟限额条数' - limit_per_day: '每天限额条数' - platform: '平台标识符' - priority: '优先级' - cost: '费用' - models: '模型列表' - - balance: - description: ChatLuna 余额相关指令。 - clear: - description: 清除指定用户的余额。将用户的余额重置为0。 - arguments: - user: 目标用户。 - examples: - - 'chatluna balance clear --user @用户名' - messages: - success: '已将用户 {0} 账户余额修改为 {1}' - set: - description: '设置指定用户的余额。可以增加或减少用户的余额。' - options: - user: '目标用户。' - arguments: - user: '目标用户。' - balance: '要设置的余额数量。' - amount: '要设置的余额数量。' - examples: - - 'chatluna balance set --user @用户名 --amount 1000' - messages: - success: '已将用户 {0} 账户余额修改为 {1}' - query: - description: 查询用户的当前余额。如果不指定用户,则查询自己的余额。 - arguments: - user: 目标用户。如果不指定,则查询当前用户。 - examples: - - 'chatluna balance query' - - 'chatluna balance query --user @用户名' - messages: - success: '用户 {0} 当前的账户余额为 {1}' - model: description: ChatLuna 模型相关指令。 list: @@ -674,15 +530,10 @@ chatluna: quote_said: '说' aborted: '已成功停止当前对话的生成。' thinking_message: '我还在思考中,前面还有 {0} 条消息等着我回复呢,稍等一下~' - block_message: '' error_message: '使用 ChatLuna 时出现错误,错误码为 %s。请联系开发者以解决此问题。' middleware_error: '执行 {0} 时出现错误: {1}' not_available_model: '当前没有可用的模型,请检查你的配置,是否正常安装了模型适配器并配置。' chat_limit_exceeded: '你的聊天次数已经用完了喵,还需要等待 {0} 分钟才能继续聊天喵 >_<' - insufficient_balance: '余额不足,当前余额为 {0}。' - unsupported_model: '配额组 {0} 不支持模型 {1}。' - limit_per_minute_exceeded: '配额组 {0} 已达到每分钟限制({2}/{1})。' - limit_per_day_exceeded: '配额组 {0} 已达到每日限制({2}/{1})。' conversation: default_title: '新会话' active: '当前活跃' diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index 5602e6457..993860f46 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -3,12 +3,6 @@ import { ChatChain } from './chains/chain' import { Config } from './config' // import start -import { apply as add_user_to_auth_group } from './middlewares/auth/add_user_to_auth_group' -import { apply as black_list } from './middlewares/auth/black_list' -import { apply as create_auth_group } from './middlewares/auth/create_auth_group' -import { apply as kick_user_form_auth_group } from './middlewares/auth/kick_user_form_auth_group' -import { apply as list_auth_group } from './middlewares/auth/list_auth_group' -import { apply as set_auth_group } from './middlewares/auth/set_auth_group' import { apply as allow_reply } from './middlewares/chat/allow_reply' import { apply as censor } from './middlewares/chat/censor' import { apply as chat_time_limit_check } from './middlewares/chat/chat_time_limit_check' @@ -37,12 +31,9 @@ import { apply as clone_preset } from './middlewares/preset/clone_preset' import { apply as delete_preset } from './middlewares/preset/delete_preset' import { apply as list_all_preset } from './middlewares/preset/list_all_preset' import { apply as set_preset } from './middlewares/preset/set_preset' -import { apply as clear_balance } from './middlewares/system/clear_balance' import { apply as conversation_manage } from './middlewares/system/conversation_manage' import { apply as lifecycle } from './middlewares/system/lifecycle' -import { apply as query_balance } from './middlewares/system/query_balance' import { apply as restart } from './middlewares/system/restart' -import { apply as set_balance } from './middlewares/system/set_balance' import { apply as wipe } from './middlewares/system/wipe' // import end export async function middleware(ctx: Context, config: Config) { @@ -55,12 +46,6 @@ export async function middleware(ctx: Context, config: Config) { const middlewares: Middleware[] = // middleware start [ - add_user_to_auth_group, - black_list, - create_auth_group, - kick_user_form_auth_group, - list_auth_group, - set_auth_group, allow_reply, censor, chat_time_limit_check, @@ -89,12 +74,9 @@ export async function middleware(ctx: Context, config: Config) { delete_preset, list_all_preset, set_preset, - clear_balance, conversation_manage, lifecycle, - query_balance, restart, - set_balance, wipe ] // middleware end diff --git a/packages/core/src/middlewares/auth/add_user_to_auth_group.ts b/packages/core/src/middlewares/auth/add_user_to_auth_group.ts deleted file mode 100644 index 540b4405c..000000000 --- a/packages/core/src/middlewares/auth/add_user_to_auth_group.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('add_user_to_auth_group', async (session, context) => { - const { command } = context - - if (command !== 'add_user_to_auth_group') - return ChainMiddlewareRunStatus.SKIPPED - - const { - authUser: userId, - auth_group_resolve: { name } - } = context.options - - if (!(await checkAdmin(session))) { - context.message = session.text('.permission_denied') - return ChainMiddlewareRunStatus.STOP - } - - const service = ctx.chatluna_auth - - const user = await service.getUser(session, userId) - - await service.addUserToGroup(user, name) - - context.message = session.text('.success', [userId, name]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - add_user_to_auth_group: never - } -} diff --git a/packages/core/src/middlewares/auth/black_list.ts b/packages/core/src/middlewares/auth/black_list.ts deleted file mode 100644 index 88d3dac99..000000000 --- a/packages/core/src/middlewares/auth/black_list.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Context, Logger } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { createLogger } from 'koishi-plugin-chatluna/utils/logger' - -let logger: Logger - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - logger = createLogger(ctx) - chain - .middleware('black_list', async (session, context) => { - const resolved = await session.resolve(config.blackList) - - if (resolved === 1) { - logger.debug( - `[黑名单] ${session.username}(${session.userId}): ${session.content}` - ) - context.message = session.text('chatluna.block_message') - return ChainMiddlewareRunStatus.STOP - } - return ChainMiddlewareRunStatus.CONTINUE - }) - .after('allow_reply') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - black_list: never - } -} diff --git a/packages/core/src/middlewares/auth/create_auth_group.ts b/packages/core/src/middlewares/auth/create_auth_group.ts deleted file mode 100644 index 5aefe635f..000000000 --- a/packages/core/src/middlewares/auth/create_auth_group.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { Context, Session } from 'koishi' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { ChatLunaAuthService } from '../../authorization/service' -import { ChatHubAuthGroup } from '../../authorization/types' -import { - ChainMiddlewareContext, - ChainMiddlewareContextOptions, - ChainMiddlewareRunStatus, - ChatChain -} from '../../chains/chain' -import { Config } from '../../config' -import { PlatformService } from '../../llm-core/platform/service' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const service = ctx.chatluna.platform - const authService = ctx.chatluna_auth - - chain - .middleware('create_auth_group', async (session, context) => { - const { - command, - options: { auth_group_resolve: authGroupResolve } - } = context - - if (command !== 'create_auth_group') - return ChainMiddlewareRunStatus.SKIPPED - - if (!authGroupResolve) return ChainMiddlewareRunStatus.SKIPPED - - let { - name, - supportModels, - requestPreDay, - requestPreMin, - platform, - priority, - costPerToken: constPerToken - } = authGroupResolve - - if ( - Object.values(authGroupResolve).filter((value) => value != null) - .length > 0 && - name != null && - requestPreDay != null && - requestPreMin != null - ) { - await context.send(session.text('.confirm_create')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } - - if (result === 'Y') { - authGroupResolve.priority = priority == null ? 0 : priority - - if ( - (await checkAuthGroupName(authService, name)) === false - ) { - context.message = session.text('.name_exists') - return ChainMiddlewareRunStatus.STOP - } - - if ( - supportModels != null && - !checkModelList(service, supportModels) - ) { - context.message = session.text('.invalid_models') - return ChainMiddlewareRunStatus.STOP - } - - await createAuthGroup( - ctx, - context, - session, - context.options - ) - - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - context.message = session.text('.cancelled') - return ChainMiddlewareRunStatus.STOP - } - } - - // 交互式创建 - - // 1. 输入配额组名 - - while (true) { - if (name == null) { - await context.send(session.text('.enter_name')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.name'), - name - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if ( - (await checkAuthGroupName(authService, result)) === false - ) { - context.message = session.text('.name_exists') - continue - } else if (result === 'N' && name != null) { - break - } else if (result !== 'N') { - name = result.trim() - authGroupResolve.name = name - break - } - } - - // 2. 输入每分钟限额 - - while (true) { - if (requestPreMin == null) { - await context.send(session.text('.enter_limit_per_min')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.set'), - session.text('.field.limit_per_min'), - requestPreMin.toString() - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && requestPreMin != null) { - break - } else if (isNaN(Number(result)) || Number(result) <= 0) { - await context.send( - session.text('.invalid_input', [ - session.text('.field.limit_per_min') - ]) - ) - continue - } - - requestPreMin = Number(result) - authGroupResolve.requestPreMin = requestPreMin - break - } - - // 3. 输入每天限额 - - while (true) { - if (requestPreDay == null) { - await context.send(session.text('.enter_limit_per_day')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.set'), - session.text('.field.limit_per_day'), - requestPreDay.toString() - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && requestPreDay != null) { - break - } else if ( - isNaN(Number(result)) || - Number(result) < requestPreMin - ) { - await context.send( - session.text('.invalid_input', [ - session.text('.field.limit_per_day') - ]) - ) - continue - } - - requestPreDay = Number(result) - authGroupResolve.requestPreDay = requestPreDay - break - } - - // 4. 输入平台标识符 - - if (platform == null) { - await context.send(session.text('.enter_platform')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.select'), - session.text('.field.platform'), - platform - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - platform = result - authGroupResolve.platform = platform - } - - // 5. 输入优先级 - - while (true) { - if (priority == null) { - await context.send(session.text('.enter_priority')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.priority'), - priority.toString() - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && priority != null) { - break - } else if (isNaN(Number(result))) { - await context.send( - session.text('.invalid_input', [ - session.text('.field.priority') - ]) - ) - continue - } - - priority = Number(result) - authGroupResolve.priority = priority - break - } - - // 6. 输入费用 - - while (true) { - if (constPerToken == null) { - await context.send(session.text('.enter_cost')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.cost'), - constPerToken.toString() - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && constPerToken != null) { - break - } else if (isNaN(Number(result))) { - await context.send( - session.text('.invalid_input', [ - session.text('.field.cost') - ]) - ) - continue - } - - constPerToken = Number(result) - authGroupResolve.costPerToken = constPerToken - break - } - - // 7. 输入支持模型列表 - - while (true) { - if (supportModels == null) { - await context.send(session.text('.enter_models')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.models'), - supportModels.join(',') - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - const parsedResult = result - ?.split(',') - ?.map((item) => item.trim()) - - if (result == null) { - context.message = session.text('.timeout') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - break - } else if (checkModelList(service, parsedResult)) { - await context.send(session.text('.invalid_models')) - continue - } else { - supportModels = parsedResult - authGroupResolve.supportModels = parsedResult - break - } - } - - // 8. 创建配额组 - await createAuthGroup(ctx, context, session, context.options) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -async function checkAuthGroupName(service: ChatLunaAuthService, name: string) { - const authGroup = await service.getAuthGroup(name, false) - return authGroup == null -} - -function checkModelList(service: PlatformService, models: string[]) { - const availableModels = service - .listAllModels(ModelType.llm) - .value.map((model) => model.toModelName()) - - return models.some((model) => !availableModels.includes(model)) -} - -async function createAuthGroup( - ctx: Context, - context: ChainMiddlewareContext, - session: Session, - options: ChainMiddlewareContextOptions -) { - const resolve = options.auth_group_resolve - - const group: ChatHubAuthGroup = { - name: resolve.name, - priority: resolve.priority ?? 0, - - limitPerMin: resolve.requestPreMin, - limitPerDay: resolve.requestPreDay, - - costPerToken: resolve.costPerToken, - id: null, - supportModels: resolve.supportModels ?? null - } - - delete group.id - - if (resolve.supportModels == null) { - delete resolve.supportModels - } - - await ctx.chatluna_auth.createAuthGroup(session, group) - - context.message = session.text('.success', [group.name]) -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - create_auth_group: never - } - - interface ChainMiddlewareContextOptions { - auth_group_resolve?: { - name?: string - requestPreMin?: number - requestPreDay?: number - costPerToken?: number - supportModels?: string[] - platform?: string - priority?: number - } - } -} diff --git a/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts b/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts deleted file mode 100644 index f37f6b41d..000000000 --- a/packages/core/src/middlewares/auth/kick_user_form_auth_group.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('kick_user_form_auth_group', async (session, context) => { - const { command } = context - - if (command !== 'kick_user_form_auth_group') - return ChainMiddlewareRunStatus.SKIPPED - - const { - authUser: userId, - auth_group_resolve: { name } - } = context.options - - if (!(await checkAdmin(session))) { - context.message = session.text('.permission_denied') - return ChainMiddlewareRunStatus.STOP - } - - const service = ctx.chatluna_auth - - const user = await service.getUser(session, userId) - - await service.removeUserFormGroup(user, name) - - context.message = session.text('.success', [userId, name]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - kick_user_form_auth_group: never - } -} diff --git a/packages/core/src/middlewares/auth/list_auth_group.ts b/packages/core/src/middlewares/auth/list_auth_group.ts deleted file mode 100644 index 095399446..000000000 --- a/packages/core/src/middlewares/auth/list_auth_group.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Context, Session } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { Pagination } from 'koishi-plugin-chatluna/utils/pagination' -import { ChatHubAuthGroup } from '../../authorization/types' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const pagination = new Pagination({ - formatItem: (value) => '', - formatString: { - top: '', - bottom: '', - pages: '' - } - }) - - chain - .middleware('list_auth_group', async (session, context) => { - const { - command, - options: { page, limit, authPlatform } - } = context - - if (command !== 'list_auth_group') - return ChainMiddlewareRunStatus.SKIPPED - - pagination.updateFormatString({ - top: session.text('.header') + '\n', - bottom: '\n' + session.text('.footer'), - pages: '\n' + session.text('.pages') - }) - - pagination.updateFormatItem((value) => - formatAuthGroup(session, value) - ) - - const authGroups = - await ctx.chatluna_auth.getAuthGroups(authPlatform) - - await pagination.push(authGroups) - - context.message = await pagination.getFormattedPage(page, limit) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -function formatAuthGroup(session: Session, group: ChatHubAuthGroup) { - return session.text('.line', [ - group.name, - group.platform ?? session.text('.general'), - group.priority, - group.limitPerMin, - group.limitPerDay - ]) -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - list_auth_group: never - } - - interface ChainMiddlewareContextOptions { - authPlatform?: string - } -} diff --git a/packages/core/src/middlewares/auth/set_auth_group.ts b/packages/core/src/middlewares/auth/set_auth_group.ts deleted file mode 100644 index d1db51c60..000000000 --- a/packages/core/src/middlewares/auth/set_auth_group.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { Context, Session } from 'koishi' -// import { createLogger } from 'koishi-plugin-chatluna/utils/logger' -import { ModelType } from 'koishi-plugin-chatluna/llm-core/platform/types' -import { ChatLunaAuthService } from '../../authorization/service' -import { ChatHubAuthGroup } from '../../authorization/types' -import { - ChainMiddlewareContext, - ChainMiddlewareContextOptions, - ChainMiddlewareRunStatus, - ChatChain -} from '../../chains/chain' -import { Config } from '../../config' -import { PlatformService } from '../../llm-core/platform/service' - -// const logger = createLogger() - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - const service = ctx.chatluna.platform - const authService = ctx.chatluna_auth - - chain - .middleware('set_auth_group', async (session, context) => { - const { - command, - // eslint-disable-next-line @typescript-eslint/naming-convention - options: { auth_group_resolve } - } = context - - if (command !== 'set_auth_group') - return ChainMiddlewareRunStatus.SKIPPED - - if (!auth_group_resolve) return ChainMiddlewareRunStatus.SKIPPED - - let { - name, - supportModels, - requestPreDay, - requestPreMin, - platform, - priority, - costPerToken: constPerToken - } = auth_group_resolve - - let currentAuthGroupName = 'guest' - - while (true) { - // 修改模型 - - await context.send( - session.text('.change_or_keep', [ - session.text('.action.select'), - session.text('.field.name'), - currentAuthGroupName - ]) - ) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - break - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if ( - (await ctx.chatluna_auth.getAuthGroup( - currentAuthGroupName, - false - )) == null - ) { - await context.send(session.text('.invalid_name')) - continue - } else { - currentAuthGroupName = result.trim() - break - } - } - - if ( - Object.values(auth_group_resolve).filter( - (value) => value != null - ).length > 0 && - name != null && - requestPreDay != null && - requestPreMin != null - ) { - await context.send(session.text('.confirm_set')) - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } - - if (result === 'Y') { - auth_group_resolve.priority = - priority == null ? 0 : priority - - if ( - (await checkAuthGroupName(authService, name)) === false - ) { - context.message = session.text('.name_exists') - return ChainMiddlewareRunStatus.STOP - } - - if ( - supportModels != null && - !checkModelList(service, supportModels) - ) { - context.message = session.text('.invalid_models') - return ChainMiddlewareRunStatus.STOP - } - - await setAuthGroup( - ctx, - session, - context, - currentAuthGroupName, - context.options - ) - - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } - } - - // 交互式创建 - - // 1. 输入配额组名 - - while (true) { - if (name == null) { - await context.send(session.text('.enter_name')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.name'), - name - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if ( - (await checkAuthGroupName(authService, result)) === false - ) { - await context.send(session.text('.name_exists')) - continue - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && name != null) { - break - } else if (result !== 'N') { - name = result.trim() - - auth_group_resolve.name = name - break - } - } - - // 2. 选择模型 - - while (true) { - if (requestPreMin == null) { - await context.send(session.text('.enter_requestPreMin')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.requestPreMin'), - requestPreMin - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && requestPreMin != null) { - break - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if (isNaN(Number(result)) && Number(result) !== 0) { - await context.send(session.text('.invalid_requestPreMin')) - continue - } - - requestPreMin = Number(result) - auth_group_resolve.requestPreMin = requestPreMin - - break - } - - // 3. 选择预设 - - while (true) { - if (requestPreDay == null) { - await context.send(session.text('.enter_requestPreDay')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.requestPreDay'), - requestPreDay - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && requestPreDay != null) { - break - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if ( - isNaN(Number(result)) || - Number(result) < requestPreMin - ) { - await context.send(session.text('.invalid_requestPreDay')) - continue - } - - requestPreDay = Number(result) - auth_group_resolve.requestPreDay = requestPreDay - - break - } - - // 4. 平台 - - if (platform == null) { - await context.send(session.text('.enter_platform')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.platform'), - platform - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result !== 'N') { - platform = result - auth_group_resolve.platform = platform - } - - // 5. 优先级 - - while (true) { - if (priority == null) { - await context.send(session.text('.enter_priority')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.priority'), - priority - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && priority != null) { - break - } else if (isNaN(Number(result))) { - await context.send(session.text('.invalid_priority')) - continue - } - - priority = Number(result) - auth_group_resolve.priority = priority - - break - } - - // 6. 费用 - - while (true) { - if (constPerToken == null) { - await context.send(session.text('.enter_costPerToken')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.costPerToken'), - constPerToken - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N' && constPerToken != null) { - break - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if (isNaN(Number(result))) { - await context.send(session.text('.invalid_costPerToken')) - continue - } - - constPerToken = Number(result) - auth_group_resolve.costPerToken = constPerToken - - break - } - - while (true) { - // 7. 支持模型 - if (supportModels == null) { - await context.send(session.text('.enter_models')) - } else { - await context.send( - session.text('.change_or_keep', [ - session.text('.action.input'), - session.text('.field.models'), - supportModels.join(', ') - ]) - ) - } - - const result = await session.prompt(1000 * 30) - - const parsedResult = result - ?.split(',') - ?.map((item) => item.trim()) - - if (result == null) { - context.message = session.text('.timeout_cancel') - return ChainMiddlewareRunStatus.STOP - } else if (result === 'N') { - break - } else if (result === 'Q') { - context.message = session.text('.cancel_set') - return ChainMiddlewareRunStatus.STOP - } else if (checkModelList(service, parsedResult)) { - await context.send(session.text('.invalid_models')) - continue - } else { - supportModels = parsedResult - auth_group_resolve.supportModels = parsedResult - break - } - } - - // 8. 创建配额组 - await setAuthGroup( - ctx, - session, - context, - currentAuthGroupName, - context.options - ) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -async function checkAuthGroupName(service: ChatLunaAuthService, name: string) { - const authGroup = await service.getAuthGroup(name) - return authGroup == null -} - -function checkModelList(service: PlatformService, models: string[]) { - const availableModels = service - .listAllModels(ModelType.llm) - .value.map((model) => model.toModelName()) - - return models.some((model) => !availableModels.includes(model)) -} - -async function setAuthGroup( - ctx: Context, - session: Session, - context: ChainMiddlewareContext, - oldAuthGroupName: string, - options: ChainMiddlewareContextOptions -) { - const resolve = options.auth_group_resolve - - const group: ChatHubAuthGroup = { - name: resolve.name, - priority: resolve.priority ?? 0, - - limitPerMin: resolve.requestPreMin, - limitPerDay: resolve.requestPreDay, - - // 1000 token / 0.3 - costPerToken: resolve.costPerToken, - id: null, - supportModels: resolve.supportModels ?? null - } - - delete group.id - - if (resolve.supportModels == null) { - delete resolve.supportModels - } - - for (const key in group) { - if (group[key] == null) { - delete group[key] - } - } - - await ctx.chatluna_auth.setAuthGroup(oldAuthGroupName, group) - - context.message = session.text('.success', [group.name]) -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - set_auth_group: never - } -} diff --git a/packages/core/src/middlewares/chat/chat_time_limit_check.ts b/packages/core/src/middlewares/chat/chat_time_limit_check.ts index 13929d860..ad2593584 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_check.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_check.ts @@ -1,6 +1,5 @@ import { Context, Session } from 'koishi' import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' -import { ChatHubAuthGroup } from '../../authorization/types' import { Cache } from '../../cache' import { ChainMiddlewareContext, @@ -15,91 +14,9 @@ import { logger } from 'koishi-plugin-chatluna' export function apply(ctx: Context, config: Config, chain: ChatChain) { const chatLimitCache = new Cache(ctx, config, 'chatluna/chat_limit') - const authService = ctx.chatluna_auth - chain .middleware('chat_time_limit_check', async (session, context) => { - if (config.authSystem !== true) { - return await oldChatLimitCheck(session, context) - } - - const target = await resolveConversationTarget(session, context) - - if (target == null) { - return ChainMiddlewareRunStatus.CONTINUE - } - - const { model } = target - - // check account balance - const authUser = await authService.getUser(session) - - if ( - authUser /* && context.command == null */ && - authUser.balance <= 0 - ) { - context.message = session.text( - 'chatluna.insufficient_balance', - [authUser.balance] - ) - return ChainMiddlewareRunStatus.STOP - } - - let authGroup = await authService.resolveAuthGroup( - session, - parseRawModelName(model)[0] - ) - - if ( - authGroup.supportModels != null && - authGroup.supportModels.find((m) => m === model) == null - ) { - context.message = session.text('chatluna.unsupported_model', [ - authGroup.name, - model - ]) - return ChainMiddlewareRunStatus.STOP - } - - authGroup = await authService.resetAuthGroup(authGroup.id) - - context.options.authGroup = authGroup - - // check pre min - - if ( - (authGroup.currentLimitPerMin ?? 0) + 1 > - authGroup.limitPerMin - ) { - context.message = session.text( - 'chatluna.limit_per_minute_exceeded', - [ - authGroup.name, - authGroup.limitPerMin, - authGroup.currentLimitPerMin - ] - ) - - return ChainMiddlewareRunStatus.STOP - } - - if ( - (authGroup.currentLimitPerDay ?? 0) + 1 > - authGroup.limitPerDay - ) { - context.message = session.text( - 'chatluna.limit_per_day_exceeded', - [ - authGroup.name, - authGroup.limitPerDay, - authGroup.currentLimitPerDay - ] - ) - - return ChainMiddlewareRunStatus.STOP - } - - return ChainMiddlewareRunStatus.CONTINUE + return await oldChatLimitCheck(session, context) }) .after('resolve_model') .before('lifecycle-request_conversation') @@ -240,7 +157,6 @@ declare module '../../chains/chain' { interface ChainMiddlewareContextOptions { chatLimitCache?: Cache<'chatluna/chat_limit', ChatLimit> chatLimit?: ChatLimit - authGroup?: ChatHubAuthGroup } } diff --git a/packages/core/src/middlewares/chat/chat_time_limit_save.ts b/packages/core/src/middlewares/chat/chat_time_limit_save.ts index 90710bef6..b69a9497b 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_save.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_save.ts @@ -9,24 +9,12 @@ import { import { createHash } from 'crypto' export function apply(ctx: Context, config: Config, chain: ChatChain) { - const authService = ctx.chatluna_auth - chain .middleware('chat_time_limit_save', async (session, context) => { - if (config.authSystem !== true) { - return await oldChatLimitSave(session, context) - } - - await authService.increaseAuthGroupCount( - context.options.authGroup.id - ) - - return ChainMiddlewareRunStatus.CONTINUE + return await oldChatLimitSave(session, context) }) .after('render_message') - // .before("lifecycle-request_conversation") - async function oldChatLimitSave( session: Session, context: ChainMiddlewareContext @@ -46,13 +34,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { throw new Error('chat_time_limit_save missing chatLimitCache') } - /* console.log( - await ctx.chatluna_auth._selectCurrentAuthGroup( - session, - parseRawModelName(model)[0] - ) - ) */ - let key = conversationId + '-' + session.userId key = createHash('md5').update(key).digest('hex') diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 309c09fe9..c8a1881db 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -7,7 +7,7 @@ import { logger } from '../..' import { checkAdmin, transformMessageContentToElements -} from '../../utils/koishi' +} from 'koishi-plugin-chatluna/utils/koishi' const MAX_ROLLBACK_HOPS = 1000 diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index 1c2ce40d4..39b8f79cc 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -2,7 +2,7 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' import { getRequestId } from '../../utils/chat_request' -import { checkAdmin } from '../../utils/koishi' +import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain diff --git a/packages/core/src/middlewares/conversation/request_conversation.ts b/packages/core/src/middlewares/conversation/request_conversation.ts index 1e38dc3e9..7d582d4e7 100644 --- a/packages/core/src/middlewares/conversation/request_conversation.ts +++ b/packages/core/src/middlewares/conversation/request_conversation.ts @@ -1,6 +1,5 @@ import { Context, Element, Fragment, Logger, Session } from 'koishi' import { PresetTemplate } from 'koishi-plugin-chatluna/llm-core/prompt' -import { parseRawModelName } from 'koishi-plugin-chatluna/llm-core/utils/count_tokens' import { ChatLunaError, ChatLunaErrorCode @@ -131,10 +130,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const chatCallbacks = createChatCallbacks( context, - session, config, - bufferText, - conversation + bufferText ) try { @@ -186,21 +183,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { function createChatCallbacks( context: ChainMiddlewareContext, - session: Session, config: Config, - bufferText: StreamingBufferText, - conversation: Pick + bufferText: StreamingBufferText ) { return { 'llm-new-chunk': createChunkHandler(context, bufferText), 'llm-queue-waiting': createQueueWaitingHandler(context), - 'llm-call-tool': createToolCallHandler(context, config), - 'llm-used-token-count': createTokenCountHandler( - context, - session, - config, - conversation - ) + 'llm-call-tool': createToolCallHandler(context, config) } } @@ -277,27 +266,6 @@ function createToolCallHandler( } } -function createTokenCountHandler( - context: ChainMiddlewareContext, - session: Session, - config: Config, - conversation: Pick -) { - return async (tokens: number) => { - if (config.authSystem !== true) { - return - } - - const balance = await context.ctx.chatluna_auth.calculateBalance( - session, - parseRawModelName(conversation.model)[0], - tokens - ) - - logger.debug(`Current balance: ${balance}`) - } -} - async function processUserPrompt( config: Config, presetTemplate: PresetTemplate, diff --git a/packages/core/src/middlewares/system/clear_balance.ts b/packages/core/src/middlewares/system/clear_balance.ts deleted file mode 100644 index 2766208ef..000000000 --- a/packages/core/src/middlewares/system/clear_balance.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Context } from 'koishi' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { Config } from '../../config' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('clear_balance', async (session, context) => { - const { command } = context - - if (command !== 'clear_balance') - return ChainMiddlewareRunStatus.SKIPPED - - const { authUser: userId } = context.options - - const service = ctx.chatluna_auth - - const modifiedBalance = await service.setBalance(session, 0, userId) - - context.message = session.text('.success', [ - userId, - modifiedBalance - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - clear_balance: never - } -} diff --git a/packages/core/src/middlewares/system/query_balance.ts b/packages/core/src/middlewares/system/query_balance.ts deleted file mode 100644 index 2de058b85..000000000 --- a/packages/core/src/middlewares/system/query_balance.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Context } from 'koishi' -import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('query_balance', async (session, context) => { - const { command } = context - - if (command !== 'query_balance') - return ChainMiddlewareRunStatus.SKIPPED - - const { authUser: userId } = context.options - - const service = ctx.chatluna_auth - - const user = await service.getUser(session, userId) - - context.message = session.text('.success', [userId, user.balance]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - query_balance: never - } -} diff --git a/packages/core/src/middlewares/system/restart.ts b/packages/core/src/middlewares/system/restart.ts index 251e5ca8c..184ad79ee 100644 --- a/packages/core/src/middlewares/system/restart.ts +++ b/packages/core/src/middlewares/system/restart.ts @@ -3,20 +3,18 @@ import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('restart', async (session, context) => { - const { command } = context + chain.middleware('restart', async (session, context) => { + const { command } = context - if (command !== 'restart') return ChainMiddlewareRunStatus.SKIPPED + if (command !== 'restart') return ChainMiddlewareRunStatus.SKIPPED - const appContext = ctx.scope.parent - appContext.scope.update(appContext.config, true) + const appContext = ctx.scope.parent + appContext.scope.update(appContext.config, true) - context.message = session.text('.success') + context.message = session.text('.success') - return ChainMiddlewareRunStatus.STOP - }) - .before('black_list') + return ChainMiddlewareRunStatus.STOP + }) } declare module '../../chains/chain' { diff --git a/packages/core/src/middlewares/system/set_balance.ts b/packages/core/src/middlewares/system/set_balance.ts deleted file mode 100644 index 432b636b0..000000000 --- a/packages/core/src/middlewares/system/set_balance.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Context } from 'koishi' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { Config } from '../../config' - -export function apply(ctx: Context, config: Config, chain: ChatChain) { - chain - .middleware('set_balance', async (session, context) => { - const { command } = context - - if (command !== 'set_balance') - return ChainMiddlewareRunStatus.SKIPPED - - const { authUser: userId, balance } = context.options - - const service = ctx.chatluna_auth - - const modifiedBalance = await service.setBalance( - session, - balance, - userId - ) - - context.message = session.text('.success', [ - userId, - modifiedBalance - ]) - - return ChainMiddlewareRunStatus.STOP - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') -} - -declare module '../../chains/chain' { - interface ChainMiddlewareName { - set_balance: never - } - - interface ChainMiddlewareContextOptions { - authUser?: string - balance?: number - } -} diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index 8247d7a48..661697b65 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -18,120 +18,108 @@ let logger: Logger export function apply(ctx: Context, _config: Config, chain: ChatChain) { logger = createLogger(ctx) - chain - .middleware('purge_legacy', async (session, context) => { - if (context.command !== 'purge_legacy') { - return ChainMiddlewareRunStatus.SKIPPED - } - - const result = await readMetaValue<{ - passed?: boolean - }>(ctx, 'validation_result') - - if (result?.passed !== true) { - context.message = - 'Legacy purge is blocked until migration validation passes.' - return ChainMiddlewareRunStatus.STOP - } - - const status = await confirmWipe(session, '.confirm_wipe') - if (status !== 'ok') { - context.message = - status === 'timeout' - ? session.text('.timeout') - : session.text('.incorrect_input') - return ChainMiddlewareRunStatus.STOP - } - - await purgeLegacyTables(ctx) - await writeMetaValue( - ctx, - 'legacy_purged_at', - new Date().toISOString() - ) - await writeMetaValue( - ctx, - LEGACY_RETENTION_META_KEY, - createLegacyTableRetention('purged') - ) - context.message = 'Legacy ChatHub tables were purged.' + chain.middleware('purge_legacy', async (session, context) => { + if (context.command !== 'purge_legacy') { + return ChainMiddlewareRunStatus.SKIPPED + } + + const result = await readMetaValue<{ + passed?: boolean + }>(ctx, 'validation_result') + + if (result?.passed !== true) { + context.message = + 'Legacy purge is blocked until migration validation passes.' return ChainMiddlewareRunStatus.STOP - }) - .before('black_list') - - chain - .middleware('wipe', async (session, context) => { - const { command } = context - - if (command !== 'wipe') return ChainMiddlewareRunStatus.SKIPPED - - const status = await confirmWipe( - session, - '.confirm_wipe', - context.send + } + + const status = await confirmWipe(session, '.confirm_wipe') + if (status !== 'ok') { + context.message = + status === 'timeout' + ? session.text('.timeout') + : session.text('.incorrect_input') + return ChainMiddlewareRunStatus.STOP + } + + await purgeLegacyTables(ctx) + await writeMetaValue(ctx, 'legacy_purged_at', new Date().toISOString()) + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('purged') + ) + context.message = 'Legacy ChatHub tables were purged.' + return ChainMiddlewareRunStatus.STOP + }) + + chain.middleware('wipe', async (session, context) => { + const { command } = context + + if (command !== 'wipe') return ChainMiddlewareRunStatus.SKIPPED + + const status = await confirmWipe(session, '.confirm_wipe', context.send) + if (status !== 'ok') { + context.message = session.text( + status === 'timeout' ? '.timeout' : '.incorrect_input' ) - if (status !== 'ok') { - context.message = session.text( - status === 'timeout' ? '.timeout' : '.incorrect_input' - ) - return ChainMiddlewareRunStatus.STOP - } + return ChainMiddlewareRunStatus.STOP + } - // drop database tables + // drop database tables - for (const table of [ - 'chatluna_conversation', - 'chatluna_message', - 'chatluna_binding', - 'chatluna_constraint', - 'chatluna_archive', - 'chatluna_acl', - 'chatluna_meta' - ]) { - await dropTableIfExists(ctx, table) - } + for (const table of [ + 'chatluna_conversation', + 'chatluna_message', + 'chatluna_binding', + 'chatluna_constraint', + 'chatluna_archive', + 'chatluna_acl', + 'chatluna_meta' + ]) { + await dropTableIfExists(ctx, table) + } - for (const table of LEGACY_MIGRATION_TABLES) { - await dropTableIfExists(ctx, table) - } + for (const table of LEGACY_MIGRATION_TABLES) { + await dropTableIfExists(ctx, table) + } - for (const table of LEGACY_RUNTIME_TABLES) { - await dropTableIfExists(ctx, table) - } + for (const table of LEGACY_RUNTIME_TABLES) { + await dropTableIfExists(ctx, table) + } - await dropTableIfExists(ctx, 'chatluna_docstore') - // knowledge + await dropTableIfExists(ctx, 'chatluna_docstore') + // knowledge - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await dropTableIfExists(ctx, 'chathub_knowledge' as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await dropTableIfExists(ctx, 'chathub_knowledge' as any) - // drop caches + // drop caches - await ctx.chatluna.cache.clear('chatluna/chat_limit') - await ctx.chatluna.cache.clear('chatluna/keys') + await ctx.chatluna.cache.clear('chatluna/chat_limit') + await ctx.chatluna.cache.clear('chatluna/keys') - // delete local database and temps + // delete local database and temps - try { - await fs.rm('data/chathub/vector_store', { recursive: true }) - } catch (e) { - logger.warn(`wipe: ${e}`) - } + try { + await fs.rm('data/chathub/vector_store', { recursive: true }) + } catch (e) { + logger.warn(`wipe: ${e}`) + } - try { - await fs.rm('data/chatluna/temp', { recursive: true }) - } catch (e) { - logger.warn(`wipe: ${e}`) - } + try { + await fs.rm('data/chatluna/temp', { recursive: true }) + } catch (e) { + logger.warn(`wipe: ${e}`) + } - context.message = session.text('.success') + context.message = session.text('.success') - const appContext = ctx.scope.parent - appContext.scope.update(appContext.config, true) + const appContext = ctx.scope.parent + appContext.scope.update(appContext.config, true) - return ChainMiddlewareRunStatus.STOP - }) - .before('black_list') + return ChainMiddlewareRunStatus.STOP + }) } declare module '../../chains/chain' { diff --git a/packages/core/src/migration/legacy_tables.ts b/packages/core/src/migration/legacy_tables.ts index d52cfec29..5b780c100 100644 --- a/packages/core/src/migration/legacy_tables.ts +++ b/packages/core/src/migration/legacy_tables.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs' import path from 'path' import fs from 'fs/promises' import type { Context } from 'koishi' @@ -16,11 +17,7 @@ export const LEGACY_MIGRATION_TABLES = [ 'chathub_conversation' ] as const -export const LEGACY_RUNTIME_TABLES = [ - 'chathub_auth_group', - 'chathub_auth_joined_user', - 'chathub_auth_user' -] as const +export const LEGACY_RUNTIME_TABLES = [] as const export const LEGACY_RETENTION_META_KEY = 'legacy_table_retention' @@ -42,6 +39,222 @@ export function getLegacySchemaSentinelDir(baseDir: string) { return path.dirname(getLegacySchemaSentinel(baseDir)) } +export function defineLegacyMigrationTables(ctx: Context) { + if (existsSync(getLegacySchemaSentinel(ctx.baseDir))) { + return + } + + ctx.database.extend( + 'chathub_conversation', + { + id: { + type: 'char', + length: 255 + }, + latestId: { + type: 'char', + length: 255, + nullable: true + }, + additional_kwargs: { + type: 'text', + nullable: true + }, + updatedAt: { + type: 'timestamp', + nullable: false, + initial: new Date() + } + }, + { + autoInc: false, + primary: 'id', + unique: ['id'] + } + ) + + ctx.database.extend( + 'chathub_message', + { + id: { + type: 'char', + length: 255 + }, + text: { + type: 'text', + nullable: true + }, + content: { + type: 'binary', + nullable: true + }, + parent: { + type: 'char', + length: 255, + nullable: true + }, + role: { + type: 'char', + length: 20 + }, + conversation: { + type: 'char', + length: 255 + }, + additional_kwargs: { + type: 'text', + nullable: true + }, + additional_kwargs_binary: { + type: 'binary', + nullable: true + }, + tool_call_id: 'string', + tool_calls: { + type: 'json', + nullable: true + }, + name: { + type: 'char', + length: 255, + nullable: true + }, + rawId: { + type: 'char', + length: 255, + nullable: true + } + }, + { + autoInc: false, + primary: 'id', + unique: ['id'] + } + ) + + ctx.database.extend( + 'chathub_room', + { + roomId: { + type: 'integer' + }, + roomName: 'string', + conversationId: { + type: 'char', + length: 255, + nullable: true + }, + roomMasterId: { + type: 'char', + length: 255 + }, + visibility: { + type: 'char', + length: 20 + }, + preset: { + type: 'char', + length: 255 + }, + model: { + type: 'char', + length: 100 + }, + chatMode: { + type: 'char', + length: 20 + }, + password: { + type: 'char', + length: 100, + nullable: true + }, + autoUpdate: { + type: 'boolean', + initial: false + }, + updatedTime: { + type: 'timestamp', + nullable: false, + initial: new Date() + } + }, + { + autoInc: false, + primary: 'roomId', + unique: ['roomId'] + } + ) + + ctx.database.extend( + 'chathub_room_member', + { + userId: { + type: 'string', + length: 255 + }, + roomId: { + type: 'integer' + }, + roomPermission: { + type: 'char', + length: 50 + }, + mute: { + type: 'boolean', + initial: false + } + }, + { + autoInc: false, + primary: ['userId', 'roomId'] + } + ) + + ctx.database.extend( + 'chathub_room_group_member', + { + groupId: { + type: 'char', + length: 255 + }, + roomId: { + type: 'integer' + }, + roomVisibility: { + type: 'char', + length: 20 + } + }, + { + autoInc: false, + primary: ['groupId', 'roomId'] + } + ) + + ctx.database.extend( + 'chathub_user', + { + userId: { + type: 'char', + length: 255 + }, + defaultRoomId: { + type: 'integer' + }, + groupId: { + type: 'char', + length: 255, + nullable: true + } + }, + { + autoInc: false, + primary: ['userId', 'groupId'] + } + ) +} + function isMissingTableError(error: unknown) { const message = String(error).toLowerCase() return ( diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts index 051f5959d..0a7361bda 100644 --- a/packages/core/src/migration/room_to_conversation.ts +++ b/packages/core/src/migration/room_to_conversation.ts @@ -14,6 +14,7 @@ import type { LegacyRoomRecord, LegacyUserRecord } from '../services/types' +import { defineLegacyMigrationTables } from './legacy_tables' import type { BindingProgress, MessageProgress, RoomProgress } from './types' import { aclKey, @@ -41,6 +42,8 @@ export async function runRoomToConversationMigration( ctx: Context, config: Config ) { + defineLegacyMigrationTables(ctx) + const result = await readMetaValue< Awaited> >(ctx, 'validation_result') @@ -130,6 +133,10 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { return runRoomToConversationMigration(ctx, config) } + if (retention?.state !== 'purged') { + defineLegacyMigrationTables(ctx) + } + const validated = await validateRoomMigration(ctx, config) await writeMetaValue(ctx, 'validation_result', validated) diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index 583560c40..ae855d944 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -70,10 +70,6 @@ import { randomUUID } from 'crypto' import type { Notifier } from '@koishijs/plugin-notifier' import { ChatLunaContextManagerService } from 'koishi-plugin-chatluna/llm-core/prompt' import { createChatPrompt } from 'koishi-plugin-chatluna/utils/chatluna' -import { - getLegacySchemaSentinel, - LEGACY_MIGRATION_TABLES -} from '../migration/legacy_tables' export class ChatLunaService extends Service { private _plugins: Record = {} @@ -495,234 +491,6 @@ export class ChatLunaService extends Service { private _defineDatabase() { const ctx = this.ctx - const legacyTablesVisible = !fs.existsSync( - getLegacySchemaSentinel(this.ctx.baseDir) - ) - - if (legacyTablesVisible && LEGACY_MIGRATION_TABLES.length > 0) { - ctx.database.extend( - 'chathub_conversation', - { - id: { - type: 'char', - length: 255 - }, - latestId: { - type: 'char', - length: 255, - nullable: true - }, - additional_kwargs: { - type: 'text', - nullable: true - }, - updatedAt: { - type: 'timestamp', - nullable: false, - initial: new Date() - } - }, - { - autoInc: false, - primary: 'id', - unique: ['id'] - } - ) - - ctx.database.extend( - 'chathub_message', - { - id: { - type: 'char', - length: 255 - }, - text: { - type: 'text', - nullable: true - }, - content: { - type: 'binary', - nullable: true - }, - parent: { - type: 'char', - length: 255, - nullable: true - }, - role: { - type: 'char', - length: 20 - }, - conversation: { - type: 'char', - length: 255 - }, - additional_kwargs: { - type: 'text', - nullable: true - }, - additional_kwargs_binary: { - type: 'binary', - nullable: true - }, - tool_call_id: 'string', - tool_calls: { - type: 'text', - nullable: true, - dump: (value) => - value == null ? null : JSON.stringify(value), - load: (value) => { - if (value == null || value === '') { - return undefined - } - - try { - return JSON.parse(String(value)) - } catch { - return undefined - } - } - }, - name: { - type: 'char', - length: 255, - nullable: true - }, - rawId: { - type: 'char', - length: 255, - nullable: true - } - }, - { - autoInc: false, - primary: 'id', - unique: ['id'] - } - ) - - ctx.database.extend( - 'chathub_room', - { - roomId: { - type: 'integer' - }, - roomName: 'string', - conversationId: { - type: 'char', - length: 255, - nullable: true - }, - roomMasterId: { - type: 'char', - length: 255 - }, - visibility: { - type: 'char', - length: 20 - }, - preset: { - type: 'char', - length: 255 - }, - model: { - type: 'char', - length: 100 - }, - chatMode: { - type: 'char', - length: 20 - }, - password: { - type: 'char', - length: 100, - nullable: true - }, - autoUpdate: { - type: 'boolean', - initial: false - }, - updatedTime: { - type: 'timestamp', - nullable: false, - initial: new Date() - } - }, - { - autoInc: false, - primary: 'roomId', - unique: ['roomId'] - } - ) - - ctx.database.extend( - 'chathub_room_member', - { - userId: { - type: 'string', - length: 255 - }, - roomId: { - type: 'integer' - }, - roomPermission: { - type: 'char', - length: 50 - }, - mute: { - type: 'boolean', - initial: false - } - }, - { - autoInc: false, - primary: ['userId', 'roomId'] - } - ) - - ctx.database.extend( - 'chathub_room_group_member', - { - groupId: { - type: 'char', - length: 255 - }, - roomId: { - type: 'integer' - }, - roomVisibility: { - type: 'char', - length: 20 - } - }, - { - autoInc: false, - primary: ['groupId', 'roomId'] - } - ) - - ctx.database.extend( - 'chathub_user', - { - userId: { - type: 'char', - length: 255 - }, - defaultRoomId: { - type: 'integer' - }, - groupId: { - type: 'char', - length: 255, - nullable: true - } - }, - { - autoInc: false, - primary: ['userId', 'groupId'] - } - ) - } ctx.database.extend( 'chatluna_conversation', diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 29e7d1671..9215d0bcc 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -8,6 +8,8 @@ import { gzipDecode, gzipEncode } from '../utils/compression' +import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' +import { ObjectLock } from 'koishi-plugin-chatluna/utils/lock' import { ACLRecord, applyPresetLane, @@ -31,8 +33,6 @@ import { ResolveTargetConversationOptions, SerializedMessageRecord } from './types' -import { checkAdmin } from '../utils/koishi' -import { ObjectLock } from '../utils/lock' export class ConversationService { private readonly _bindingLocks = new Map() diff --git a/packages/core/src/utils/error.ts b/packages/core/src/utils/error.ts index deb519516..9151c3a7f 100644 --- a/packages/core/src/utils/error.ts +++ b/packages/core/src/utils/error.ts @@ -80,9 +80,5 @@ export enum ChatLunaErrorCode { KNOWLEDGE_UNSUPPORTED_FILE_TYPE = 503, KNOWLEDGE_EXIST_FILE = 504, KNOWLEDGE_VECTOR_NOT_FOUND = 505, - USER_NOT_FOUND = 600, - AUTH_GROUP_NOT_FOUND = 601, - AUTH_GROUP_NOT_JOINED = 602, - AUTH_GROUP_ALREADY_JOINED = 603, UNKNOWN_ERROR = 999 } diff --git a/packages/core/src/utils/koishi.ts b/packages/core/src/utils/koishi.ts index 1fc7e06ad..2b2f83180 100644 --- a/packages/core/src/utils/koishi.ts +++ b/packages/core/src/utils/koishi.ts @@ -2,12 +2,12 @@ import { ForkScope, h, Session, User } from 'koishi' import { PromiseLikeDisposable } from 'koishi-plugin-chatluna/utils/types' import { Marked, Token } from 'marked' import type { MessageContent } from '@langchain/core/messages' -import { isMessageContentImageUrl } from 'koishi-plugin-chatluna/utils/langchain' import { isMessageContentAudio, isMessageContentFileUrl, + isMessageContentImageUrl, isMessageContentVideo -} from './langchain' +} from 'koishi-plugin-chatluna/utils/langchain' const marked = new Marked({ tokenizer: { diff --git a/packages/core/tests/conversation-source.spec.ts b/packages/core/tests/conversation-source.spec.ts index d94ff7f6b..24fe5eaa7 100644 --- a/packages/core/tests/conversation-source.spec.ts +++ b/packages/core/tests/conversation-source.spec.ts @@ -12,9 +12,6 @@ it('conversation-first runtime removes legacy room entry points from active sour expectRejected(fs.access(path.join(coreSrc, 'chains', 'rooms.ts'))), expectRejected(fs.access(path.join(coreSrc, 'commands', 'room.ts'))), expectRejected(fs.access(path.join(coreSrc, 'middlewares', 'room'))), - expectRejected( - fs.access(path.join(coreSrc, 'middlewares', 'auth', 'mute_user.ts')) - ), expectRejected( fs.access( path.join(coreSrc, 'middlewares', 'model', 'request_model.ts') diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 3ff32504f..9f0c570ba 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -66,6 +66,10 @@ export class FakeDatabase { chathub_conversation: [] } + extend(table: string) { + this.tables[table] ??= [] + } + async get( table: string, query: Record, From b799832d65f214280910b88aad3b543b5ff629e5 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 12:35:46 +0800 Subject: [PATCH 14/20] feat(core): rework conversation route rules Track route-level preset lanes and defaults so switching, listing, migration, export, and chat limits stay consistent across conversations. Also keep Dify cache entries when remote conversation deletion fails. --- packages/adapter-dify/src/requester.ts | 26 +- packages/core/src/commands/conversation.ts | 166 +++--- packages/core/src/config.ts | 2 - packages/core/src/index.ts | 104 +++- packages/core/src/llm-core/chat/app.ts | 24 +- packages/core/src/locales/en-US.yml | 32 +- packages/core/src/locales/zh-CN.yml | 32 +- .../core/src/middlewares/chat/allow_reply.ts | 4 - .../middlewares/chat/chat_time_limit_check.ts | 81 ++- .../middlewares/chat/chat_time_limit_save.ts | 18 +- .../src/middlewares/chat/rollback_chat.ts | 5 +- .../conversation/request_conversation.ts | 7 +- .../conversation/resolve_conversation.ts | 7 +- .../src/middlewares/model/resolve_model.ts | 61 +- .../middlewares/system/conversation_manage.ts | 550 +++++++++++------- packages/core/src/middlewares/system/wipe.ts | 7 + .../src/migration/room_to_conversation.ts | 180 +++++- packages/core/src/migration/validators.ts | 183 ++++-- packages/core/src/services/chat.ts | 28 +- packages/core/src/services/conversation.ts | 529 ++++++++++++++--- .../core/src/services/conversation_types.ts | 18 +- packages/core/src/services/types.ts | 2 + packages/core/src/utils/conversation.ts | 4 +- packages/core/src/utils/koishi.ts | 4 +- packages/core/src/utils/message_content.ts | 4 +- 25 files changed, 1449 insertions(+), 629 deletions(-) diff --git a/packages/adapter-dify/src/requester.ts b/packages/adapter-dify/src/requester.ts index 03d8557cb..45f99cac3 100644 --- a/packages/adapter-dify/src/requester.ts +++ b/packages/adapter-dify/src/requester.ts @@ -767,23 +767,23 @@ export class DifyRequester extends ModelRequester { } if (difyConversationId) { - await this._plugin - .fetch(this.concatUrl('/conversations/' + difyConversationId), { + const res = await this._plugin.fetch( + this.concatUrl('/conversations/' + difyConversationId), + { headers: this._buildHeaders(config.apiKey), method: 'DELETE', body: JSON.stringify({ user: difyUser }) - }) - .then(async (res) => { - if (res.ok) { - this.ctx.logger.info('Dify clear: success') - } else { - this.ctx.logger.warn( - 'Dify clear: failed: ' + (await res.text()) - ) - } - }) + } + ) - await this.ctx.chatluna.cache.delete('chatluna/keys', cacheKey) + if (res.ok) { + this.ctx.logger.info('Dify clear: success') + await this.ctx.chatluna.cache.delete('chatluna/keys', cacheKey) + } else { + this.ctx.logger.warn( + 'Dify clear: failed: ' + (await res.text()) + ) + } } } diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index cce2e76e0..39141a0d1 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -58,32 +58,26 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { const switchCommand = ctx.command('chatluna.switch ', { authority: 1 }) - switchCommand - .option('preset', '-p ') - .action(async ({ options, session }, conversation) => { - const presetLane = - options.preset == null || options.preset.trim().length < 1 - ? undefined - : options.preset.trim() - await chain.receiveCommand( - session, - 'conversation_switch', - { - conversation_manage: { - targetConversation: await completeConversationTarget( - ctx, - session, - conversation, - presetLane, - false, - 'commands.chatluna.conversation.options.conversation' - ), - presetLane - } - }, - ctx - ) - }) + switchCommand.action(async ({ session }, conversation) => { + await chain.receiveCommand( + session, + 'conversation_switch', + { + conversation_manage: { + targetConversation: await completeConversationTarget( + ctx, + session, + conversation, + undefined, + false, + 'commands.chatluna.conversation.options.conversation', + true + ) + } + }, + ctx + ) + }) ctx.command('chatluna.list', { authority: 1 @@ -92,7 +86,6 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { .option('limit', '-l ') .option('archived', '-a') .option('all', '--all') - .option('preset', '-P ') .action(async ({ options, session }) => { await chain.receiveCommand( session, @@ -102,12 +95,7 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { limit: options.limit ?? 5, conversation_manage: { includeArchived: - options.archived === true || options.all === true, - presetLane: - options.preset == null || - options.preset.trim().length < 1 - ? undefined - : options.preset.trim() + options.archived === true || options.all === true } }, ctx @@ -402,60 +390,77 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { ) }) - ctx.command('chatluna.rule.model ', { + ctx.command('chatluna.rule.model [model:string]', { authority: 3 - }).action(async ({ session }, model) => { - await chain.receiveCommand( - session, - 'conversation_rule_model', - { - conversation_rule: { - model: - model == null || model.trim().length < 1 - ? undefined - : model.trim() - } - }, - ctx - ) }) + .option('force', '-f') + .option('clear', '-c') + .action(async ({ options, session }, model) => { + await chain.receiveCommand( + session, + 'conversation_rule_model', + { + conversation_rule: { + model: + model == null || model.trim().length < 1 + ? undefined + : model.trim(), + force: options.force === true, + clear: options.clear === true + } + }, + ctx + ) + }) - ctx.command('chatluna.rule.preset ', { + ctx.command('chatluna.rule.preset [preset:string]', { authority: 3 - }).action(async ({ session }, preset) => { - await chain.receiveCommand( - session, - 'conversation_rule_preset', - { - conversation_rule: { - preset: - preset == null || preset.trim().length < 1 - ? undefined - : preset.trim() - } - }, - ctx - ) }) - ctx.command('chatluna.rule.mode ', { + .option('force', '-f') + .option('newOnly', '-n') + .option('clear', '-c') + .action(async ({ options, session }, preset) => { + await chain.receiveCommand( + session, + 'conversation_rule_preset', + { + conversation_rule: { + preset: + preset == null || preset.trim().length < 1 + ? undefined + : preset.trim(), + force: options.force === true, + newOnly: options.newOnly === true, + clear: options.clear === true + } + }, + ctx + ) + }) + ctx.command('chatluna.rule.mode [mode:string]', { authority: 3 - }).action(async ({ session }, mode) => { - await chain.receiveCommand( - session, - 'conversation_rule_mode', - { - conversation_rule: { - chatMode: - mode == null || mode.trim().length < 1 - ? undefined - : mode.trim() - } - }, - ctx - ) }) + .option('force', '-f') + .option('clear', '-c') + .action(async ({ options, session }, mode) => { + await chain.receiveCommand( + session, + 'conversation_rule_mode', + { + conversation_rule: { + chatMode: + mode == null || mode.trim().length < 1 + ? undefined + : mode.trim(), + force: options.force === true, + clear: options.clear === true + } + }, + ctx + ) + }) - ctx.command('chatluna.rule.share ', { + ctx.command('chatluna.rule.share [mode:string]', { authority: 3 }).action(async ({ session }, mode) => { await chain.receiveCommand( @@ -523,6 +528,9 @@ declare module '../chains/chain' { chatMode?: string share?: string lock?: string + force?: boolean + newOnly?: boolean + clear?: boolean } i18n_base?: string } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f896d1d7b..75dd5ec1d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -7,7 +7,6 @@ export interface Config { allowPrivate: boolean isForwardMsg: boolean forwardMsgMinLength: number - allowConversationTriggerPrefix: boolean msgCooldown: number randomReplyFrequency: Computed> includeQuoteReply: boolean @@ -65,7 +64,6 @@ export const Config: Schema = Schema.intersect([ allowAtReply: Schema.boolean().default(true), allowQuoteReply: Schema.boolean().default(false), privateChatWithoutCommand: Schema.boolean().default(true), - allowConversationTriggerPrefix: Schema.boolean().default(false), includeQuoteReply: Schema.boolean().default(true), randomReplyFrequency: Schema.percent() .min(0) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc2b30ed6..b1f5f0510 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ import { apply as loreBook } from './llm-core/memory/lore_book' import { apply as authorsNote } from './llm-core/memory/authors_note' import { ensureMigrationValidated } from './migration/room_to_conversation' import { middleware } from './middleware' +import type { ConstraintRecord } from './services/conversation_types' import { purgeArchivedConversation } from './utils/archive' export * from './config' @@ -56,6 +57,7 @@ export function apply(ctx: Context, config: Config) { ctx.on('ready', async () => { setupProxy(ctx, config) + await dedupeConstraintNames(ctx) setupServices(ctx, config, disposables) setupPermissions(ctx, disposables) setupEntryPoint(ctx, config, disposables) @@ -195,13 +197,14 @@ async function setupAutoArchive(ctx: Context, config: Config) { } try { + const cutoff = new Date( + Date.now() - config.autoArchiveTimeout * 1000 + ) const conversations = await ctx.database.get( 'chatluna_conversation', { updatedAt: { - $lt: new Date( - Date.now() - config.autoArchiveTimeout * 1000 - ) + $lt: cutoff }, status: 'active' } @@ -217,10 +220,15 @@ async function setupAutoArchive(ctx: Context, config: Config) { for (const conversation of conversations) { try { - await ctx.chatluna.conversation.archiveConversationById( - conversation.id - ) - success += 1 + const archived = + await ctx.chatluna.conversation.archiveConversationById( + conversation.id, + cutoff + ) + + if (archived != null) { + success += 1 + } } catch (e) { logger.error(e) } @@ -250,13 +258,14 @@ async function setupAutoPurgeArchive(ctx: Context, config: Config) { } try { + const cutoff = new Date( + Date.now() - config.autoPurgeArchiveTimeout * 1000 + ) const conversations = await ctx.database.get( 'chatluna_conversation', { archivedAt: { - $lt: new Date( - Date.now() - config.autoPurgeArchiveTimeout * 1000 - ) + $lt: cutoff }, status: 'archived' } @@ -272,8 +281,33 @@ async function setupAutoPurgeArchive(ctx: Context, config: Config) { for (const conversation of conversations) { try { - await purgeArchivedConversation(ctx, conversation) - success += 1 + const purged = + await ctx.chatluna.conversationRuntime.withConversationSync( + conversation, + async () => { + const current = + await ctx.chatluna.conversation.getConversation( + conversation.id + ) + + if ( + current == null || + current.status !== 'archived' || + current.archivedAt == null || + current.archivedAt.getTime() >= + cutoff.getTime() + ) { + return false + } + + await purgeArchivedConversation(ctx, current) + return true + } + ) + + if (purged) { + success += 1 + } } catch (e) { logger.error(e) } @@ -294,3 +328,49 @@ async function setupAutoPurgeArchive(ctx: Context, config: Config) { await execute() }, Time.minute * 10) } + +async function dedupeConstraintNames(ctx: Context) { + try { + const rows = (await ctx.database.get( + 'chatluna_constraint', + {} + )) as ConstraintRecord[] + + if (rows.length < 2) { + return + } + + const names = new Set() + const ids = [...rows] + .sort((left, right) => { + const leftTime = left.updatedAt?.getTime() ?? 0 + const rightTime = right.updatedAt?.getTime() ?? 0 + + if (leftTime !== rightTime) { + return rightTime - leftTime + } + + return (right.id ?? 0) - (left.id ?? 0) + }) + .filter((row) => { + if (!names.has(row.name)) { + names.add(row.name) + return false + } + + return row.id != null + }) + .map((row) => row.id!) + + if (ids.length === 0) { + return + } + + logger.warn( + `Removing ${ids.length} duplicate chatluna_constraint rows.` + ) + await ctx.database.remove('chatluna_constraint', { + id: ids + }) + } catch {} +} diff --git a/packages/core/src/llm-core/chat/app.ts b/packages/core/src/llm-core/chat/app.ts index ad0a630d4..3746c6ae3 100644 --- a/packages/core/src/llm-core/chat/app.ts +++ b/packages/core/src/llm-core/chat/app.ts @@ -235,13 +235,15 @@ export class ChatInterface { await this.handleChatError(arg, wrapper, error, false) } - autoSummarizeTitle( - this.ctx, - arg.conversationId, - wrapper, - arg.message, - displayResponse as AIMessage - ).catch((e) => logger.error('autoSummarizeTitle error:', e)) + if (this._input.autoTitle !== false) { + autoSummarizeTitle( + this.ctx, + arg.conversationId, + wrapper, + arg.message, + displayResponse as AIMessage + ).catch((e) => logger.error('autoSummarizeTitle error:', e)) + } return { message: displayResponse } } @@ -479,17 +481,12 @@ async function autoSummarizeTitle( try { const result = await wrapper.model.invoke([new HumanMessage(prompt)]) const title = getMessageContent(result.content).trim().slice(0, 20) - if (title.length < 5) { - await ctx.chatluna.conversation.touchConversation(conversationId, { - autoTitle: true - }) - return - } await ctx.chatluna.conversation.touchConversation(conversationId, { title }) } catch (error) { + logger.error(error) await ctx.chatluna.conversation.touchConversation(conversationId, { autoTitle: true }) @@ -499,6 +496,7 @@ async function autoSummarizeTitle( export interface ChatInterfaceInput { chatMode: string + autoTitle?: boolean botName?: string preset?: ComputedRef model: string diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 4f2719c5b..890b2ab26 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -537,7 +537,7 @@ chatluna: chat_limit_exceeded: 'Daily chat limit reached. Please try again in {0} minutes.' conversation: default_title: 'New Conversation' - active: 'active' + active: 'current' status_value: active: 'active' archived: 'archived' @@ -545,7 +545,7 @@ chatluna: broken: 'broken' messages: unavailable: 'The model {0} for this conversation is unavailable. Please contact an administrator to configure the API.' - new_success: 'Created conversation {0} (#{1}, ID: {2}).' + new_success: 'Created conversation: {0}' new_forbidden: 'Creating new conversations is disabled in the current route.' admin_required: 'Conversation management requires administrator permission in the current route.' target_required: 'Please provide a conversation target by seq, ID, or title.' @@ -559,32 +559,36 @@ chatluna: fixed_model: 'The current route fixes the model to {0}.' fixed_preset: 'The current route fixes the preset to {0}.' fixed_chat_mode: 'The current route fixes the chat mode to {0}.' - switch_success: 'Switched to conversation {0} (#{1}, ID: {2}).' + switch_success: 'Switched to: {0}' switch_failed: 'Failed to switch conversation: {0}' list_header: 'Conversations in the current route:' list_pages: 'Page [page] / [total]' list_empty: 'No conversations were found in the current route.' current_header: 'Current conversation:' current_empty: 'No active conversation is bound to the current route.' - rename_success: 'Renamed conversation {0} (#{1}, ID: {2}).' + rename_success: 'Renamed to: {0}' rename_failed: 'Failed to rename conversation: {0}' - delete_success: 'Deleted conversation {0} (#{1}, ID: {2}).' + delete_success: 'Deleted conversation: {0}' delete_failed: 'Failed to delete conversation: {0}' - use_model_success: 'Using model {0} for {1} ({2}).' + use_model_success: '{1} now uses model {0}.' use_model_failed: 'Failed to switch model: {0}' - use_preset_success: 'Using preset {0} for {1} ({2}).' + use_preset_success: '{1} now uses preset {0}.' use_preset_failed: 'Failed to switch preset: {0}' - use_mode_success: 'Using chat mode {0} for {1} ({2}).' + use_mode_success: '{1} now uses chat mode {0}.' use_mode_failed: 'Failed to switch chat mode: {0}' archive_empty: 'No conversation is available to archive.' - archive_success: 'Archived conversation {0} (#{1}, ID: {2}). Archive ID: {3}' + archive_success: 'Archived conversation: {0}' archive_failed: 'Failed to archive conversation: {0}' restore_empty: 'No conversation is available to restore.' - restore_success: 'Restored conversation {0} (#{1}, ID: {2}).' + restore_success: 'Restored conversation: {0}' restore_failed: 'Failed to restore conversation: {0}' export_empty: 'No conversation is available to export.' - export_success: 'Exported markdown transcript for conversation {0} (#{1}, ID: {2}) to {3}. Size: {4} bytes. SHA256: {5}' + export_success: 'Exported conversation: {0}, file: {3}' export_failed: 'Failed to export conversation: {0}' + rule_model_status: 'Model rule: default {0}, fixed {1}.' + rule_preset_status: 'Current preset lane: {0}. New conversation preset: {1}.' + rule_mode_status: 'Chat mode rule: default {0}, fixed {1}.' + rule_share_status: 'Route mode: {0}.' rule_model_success: 'Rule fixed model set to {0}.' rule_preset_success: 'Rule fixed preset set to {0}.' rule_mode_success: 'Rule fixed chat mode set to {0}.' @@ -603,7 +607,10 @@ chatluna: delete: 'delete' update: 'update' compress: 'compression' - conversation_line: '#{0} {1} [{2}] {3}' + conversation_line: '#{0} {1} [{2}]' + conversation_line_with_status: '#{0} {1} [{2}] [{3}]' + conversation_line_with_lane: '#{0} {1} [{2}] <{3}>' + conversation_line_with_lane_status: '#{0} {1} [{2}] <{3}> [{4}]' conversation_seq: 'Seq: {0}' conversation_title: 'Title: {0}' conversation_id: 'ID: {0}' @@ -618,4 +625,5 @@ chatluna: rule_preset: 'Fixed preset: {0}' rule_mode: 'Fixed chat mode: {0}' rule_lock: 'Lock rule: {0}' + main_lane: 'main' cooldown_wait_message: 'Message rate limit reached. Please wait {0}s before sending another message.' diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 7ab23fdad..bc1527219 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -536,7 +536,7 @@ chatluna: chat_limit_exceeded: '你的聊天次数已经用完了喵,还需要等待 {0} 分钟才能继续聊天喵 >_<' conversation: default_title: '新会话' - active: '当前活跃' + active: '当前' status_value: active: '活跃' archived: '已归档' @@ -544,7 +544,7 @@ chatluna: broken: '已损坏' messages: unavailable: '当前会话对应的模型 {0} 不可用,请联系管理员配置 API。' - new_success: '已创建会话 {0}(#{1},ID:{2})。' + new_success: '已创建会话:{0}' new_forbidden: '当前路由已禁止创建新会话。' admin_required: '当前路由要求管理员权限才能管理会话。' target_required: '请提供目标会话,可使用序号、ID 或标题。' @@ -558,32 +558,36 @@ chatluna: fixed_model: '当前路由已将模型固定为 {0}。' fixed_preset: '当前路由已将预设固定为 {0}。' fixed_chat_mode: '当前路由已将聊天模式固定为 {0}。' - switch_success: '已切换到会话 {0}(#{1},ID:{2})。' + switch_success: '已切换到:{0}' switch_failed: '切换会话失败:{0}' list_header: '当前路由下的会话列表:' list_pages: '第 [page] / [total] 页' list_empty: '当前路由下没有找到任何会话。' current_header: '当前会话:' current_empty: '当前路由未绑定活跃会话。' - rename_success: '已重命名会话 {0}(#{1},ID:{2})。' + rename_success: '已重命名为:{0}' rename_failed: '重命名会话失败:{0}' - delete_success: '已删除会话 {0}(#{1},ID:{2})。' + delete_success: '已删除会话:{0}' delete_failed: '删除会话失败:{0}' - use_model_success: '会话 {1}({2})已切换为模型 {0}。' + use_model_success: '{1} 已切换到模型 {0}。' use_model_failed: '切换模型失败:{0}' - use_preset_success: '会话 {1}({2})已切换为预设 {0}。' + use_preset_success: '{1} 已切换到预设 {0}。' use_preset_failed: '切换预设失败:{0}' - use_mode_success: '会话 {1}({2})已切换为聊天模式 {0}。' + use_mode_success: '{1} 已切换到聊天模式 {0}。' use_mode_failed: '切换聊天模式失败:{0}' archive_empty: '没有可归档的会话。' - archive_success: '已归档会话 {0}(#{1},ID:{2})。归档 ID:{3}' + archive_success: '已归档会话:{0}' archive_failed: '归档会话失败:{0}' restore_empty: '没有可恢复的会话。' - restore_success: '已恢复会话 {0}(#{1},ID:{2})。' + restore_success: '已恢复会话:{0}' restore_failed: '恢复会话失败:{0}' export_empty: '没有可导出的会话。' - export_success: '已将会话 {0}(#{1},ID:{2})导出为 Markdown 记录到 {3}。大小:{4} 字节。SHA256:{5}' + export_success: '已导出会话:{0},文件:{3}' export_failed: '导出会话失败:{0}' + rule_model_status: '模型规则:默认 {0},强制 {1}。' + rule_preset_status: '当前预设线:{0};新建默认预设:{1}。' + rule_mode_status: '聊天模式规则:默认 {0},强制 {1}。' + rule_share_status: '路由模式:{0}。' rule_model_success: '规则固定模型已设置为 {0}。' rule_preset_success: '规则固定预设已设置为 {0}。' rule_mode_success: '规则固定聊天模式已设置为 {0}。' @@ -602,7 +606,10 @@ chatluna: delete: '删除' update: '更新设置' compress: '压缩' - conversation_line: '#{0} {1} [{2}] {3}' + conversation_line: '#{0} {1} [{2}]' + conversation_line_with_status: '#{0} {1} [{2}] [{3}]' + conversation_line_with_lane: '#{0} {1} [{2}] <{3}>' + conversation_line_with_lane_status: '#{0} {1} [{2}] <{3}> [{4}]' conversation_seq: '序号:{0}' conversation_title: '标题:{0}' conversation_id: 'ID:{0}' @@ -617,5 +624,6 @@ chatluna: rule_preset: '固定预设:{0}' rule_mode: '固定聊天模式:{0}' rule_lock: '锁定规则:{0}' + main_lane: '主线' cooldown_wait_message: '不要发这么快喵,等 {0}s 后我们再聊天喵。' diff --git a/packages/core/src/middlewares/chat/allow_reply.ts b/packages/core/src/middlewares/chat/allow_reply.ts index a93403e74..a3baa9946 100644 --- a/packages/core/src/middlewares/chat/allow_reply.ts +++ b/packages/core/src/middlewares/chat/allow_reply.ts @@ -95,10 +95,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE } - if (config.allowConversationTriggerPrefix) { - return ChainMiddlewareRunStatus.CONTINUE - } - return ChainMiddlewareRunStatus.STOP // 辅助函数:检查回复权限 diff --git a/packages/core/src/middlewares/chat/chat_time_limit_check.ts b/packages/core/src/middlewares/chat/chat_time_limit_check.ts index ad2593584..f1bc82d55 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_check.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_check.ts @@ -18,14 +18,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { .middleware('chat_time_limit_check', async (session, context) => { return await oldChatLimitCheck(session, context) }) - .after('resolve_model') + .after('resolve_conversation') + .after('rollback_chat') .before('lifecycle-request_conversation') async function oldChatLimitCheck( session: Session, context: ChainMiddlewareContext ) { - const target = await resolveConversationTarget(session, context) + const target = resolveConversationTarget(context) if (target == null) { return ChainMiddlewareRunStatus.CONTINUE @@ -78,42 +79,31 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { let chatLimitOnDataBase = await chatLimitCache.get(key) - if (chatLimitOnDataBase) { - // 如果大于1小时的间隔,就重置 - if (Date.now() - chatLimitOnDataBase.time > 1000 * 60 * 60) { - chatLimitOnDataBase = { - time: Date.now(), - count: 0 - } - } else { - // 用满了 - if (chatLimitOnDataBase.count >= chatLimitComputed) { - const time = Math.ceil( - (1000 * 60 * 60 - - (Date.now() - chatLimitOnDataBase.time)) / - 1000 / - 60 - ) - - context.message = session.text( - 'chatluna.chat_limit_exceeded', - [time] - ) - - return ChainMiddlewareRunStatus.STOP - } else { - chatLimitOnDataBase.count++ - } + if (chatLimitOnDataBase == null) { + chatLimitOnDataBase = { + time: Date.now(), + count: 0 } - } else { + } else if (Date.now() - chatLimitOnDataBase.time > 1000 * 60 * 60) { chatLimitOnDataBase = { time: Date.now(), count: 0 } } - // 先保存一次 - await chatLimitCache.set(key, chatLimitOnDataBase) + if (chatLimitOnDataBase.count >= chatLimitComputed) { + const time = Math.ceil( + (1000 * 60 * 60 - (Date.now() - chatLimitOnDataBase.time)) / + 1000 / + 60 + ) + + context.message = session.text('chatluna.chat_limit_exceeded', [ + time + ]) + + return ChainMiddlewareRunStatus.STOP + } context.options.chatLimit = chatLimitOnDataBase context.options.chatLimitCache = chatLimitCache @@ -121,30 +111,27 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.CONTINUE } - async function resolveConversationTarget( - session: Session, - context: ChainMiddlewareContext - ) { - const conversationId = context.options.conversationId - - if (conversationId == null) { + function resolveConversationTarget(context: ChainMiddlewareContext) { + if (context.options.inputMessage == null) { return null } - const resolved = await ctx.chatluna.conversation.resolveContext( - session, - { - conversationId - } - ) + const resolved = context.options.resolvedConversationContext + const conversation = + context.options.resolvedConversation ?? + resolved?.conversation ?? + null - if (resolved.conversation == null) { + if (conversation == null) { return null } + context.options.conversationId = conversation.id + context.options.resolvedConversation = conversation + return { - model: resolved.effectiveModel ?? resolved.conversation.model, - conversationId: resolved.conversation.id + model: resolved?.effectiveModel ?? conversation.model, + conversationId: conversation.id } } } diff --git a/packages/core/src/middlewares/chat/chat_time_limit_save.ts b/packages/core/src/middlewares/chat/chat_time_limit_save.ts index b69a9497b..4fefc2f07 100644 --- a/packages/core/src/middlewares/chat/chat_time_limit_save.ts +++ b/packages/core/src/middlewares/chat/chat_time_limit_save.ts @@ -13,7 +13,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { .middleware('chat_time_limit_save', async (session, context) => { return await oldChatLimitSave(session, context) }) - .after('render_message') + .after('request_conversation') async function oldChatLimitSave( session: Session, @@ -22,16 +22,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const { chatLimit, chatLimitCache } = context.options const conversationId = context.options.conversationId - if (conversationId == null) { - throw new Error('chat_time_limit_save missing conversationId') - } - - if (chatLimit == null) { - throw new Error('chat_time_limit_save missing chatLimit') - } - - if (chatLimitCache == null) { - throw new Error('chat_time_limit_save missing chatLimitCache') + if ( + conversationId == null || + chatLimit == null || + chatLimitCache == null + ) { + return ChainMiddlewareRunStatus.SKIPPED } let key = conversationId + '-' + session.userId diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index c8a1881db..4ac7b4e3e 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -166,9 +166,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if ((context.options.message?.length ?? 0) < 1) { const reResolved = - await ctx.chatluna.conversation.resolveContext(session, { - conversationId: conversation.id - }) + context.options.resolvedConversationContext ?? + resolvedContext const humanContent = await decodeMessageContent(humanMessage) context.options.inputMessage = diff --git a/packages/core/src/middlewares/conversation/request_conversation.ts b/packages/core/src/middlewares/conversation/request_conversation.ts index 7d582d4e7..ab0b2e6ff 100644 --- a/packages/core/src/middlewares/conversation/request_conversation.ts +++ b/packages/core/src/middlewares/conversation/request_conversation.ts @@ -41,12 +41,17 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('request_conversation', async (session, context) => { const { inputMessage } = context.options + const useRoutePresetLane = + context.options.presetLane == null && + context.options.conversationId == null && + (context.command == null || context.command.length === 0) const resolved = await ctx.chatluna.conversation.ensureActiveConversation( session, { conversationId: context.options.conversationId, - presetLane: context.options.presetLane + presetLane: context.options.presetLane, + useRoutePresetLane } ) const conversation = resolved.conversation diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts index 64aea4141..71c875728 100644 --- a/packages/core/src/middlewares/conversation/resolve_conversation.ts +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -29,6 +29,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { .middleware('resolve_conversation', async (session, context) => { const presetLane = getPresetLane(context) const targetConversation = getTargetConversation(context) + const useRoutePresetLane = + presetLane == null && + targetConversation == null && + (context.command == null || context.command.length === 0) context.options.presetLane = presetLane @@ -60,7 +64,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.options.resolvedConversationContext ?? (await ctx.chatluna.conversation.resolveContext(session, { conversationId: context.options.conversationId, - presetLane + presetLane, + useRoutePresetLane })) context.options.resolvedConversation = diff --git a/packages/core/src/middlewares/model/resolve_model.ts b/packages/core/src/middlewares/model/resolve_model.ts index ed0bc414a..c774159b6 100644 --- a/packages/core/src/middlewares/model/resolve_model.ts +++ b/packages/core/src/middlewares/model/resolve_model.ts @@ -9,44 +9,47 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('resolve_model', async (session, context) => { const conversationId = context.options.conversationId + const resolved = context.options.resolvedConversationContext if ((context.command?.length ?? 0) > 1) { return ChainMiddlewareRunStatus.CONTINUE } - if (conversationId == null) { + if (conversationId == null && resolved == null) { return ChainMiddlewareRunStatus.CONTINUE } try { - const conversation = + let conversation = context.options.resolvedConversation ?? - (await ctx.chatluna.conversation.getConversation( - conversationId - )) + resolved?.conversation - if (conversation == null) { - return ChainMiddlewareRunStatus.STOP + if (conversation == null && conversationId != null) { + conversation = + await ctx.chatluna.conversation.getConversation( + conversationId + ) } const modelName = - conversation.model == null || - conversation.model.trim().length < 1 || - conversation.model === '无' || - conversation.model === 'empty' - ? 'empty' - : conversation.model - - const [platformName, rawModelName] = - parseRawModelName(modelName) + resolved?.effectiveModel ?? + conversation?.model ?? + config.defaultModel ?? + 'empty' + const presetName = + resolved?.effectivePreset ?? + conversation?.preset ?? + config.defaultPreset const presetExists = - ctx.chatluna.preset.getPreset(conversation.preset, false) - .value != null + presetName != null && + ctx.chatluna.preset.getPreset(presetName, false).value != + null if ( - modelName === 'empty' || - platformName == null || - rawModelName == null + !presetExists || + modelName.trim().length < 1 || + modelName === '无' || + modelName === 'empty' ) { await context.send( session.text( @@ -57,6 +60,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } + const [platformName, rawModelName] = + parseRawModelName(modelName) + + if (platformName == null || rawModelName == null) { + await context.send( + session.text( + 'chatluna.conversation.messages.unavailable', + [modelName] + ) + ) + return ChainMiddlewareRunStatus.STOP + } + const platformModels = ctx.chatluna.platform.listPlatformModels( platformName, ModelType.llm @@ -64,8 +80,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if ( platformModels.length > 0 && - platformModels.some((it) => it.name === rawModelName) && - presetExists + platformModels.some((it) => it.name === rawModelName) ) { return ChainMiddlewareRunStatus.CONTINUE } diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index bdd762513..37dda3786 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -1,4 +1,4 @@ -import { Context, Session } from 'koishi' +import { Context, h, Session } from 'koishi' import { ChainMiddlewareContext, ChainMiddlewareRunStatus, @@ -7,167 +7,14 @@ import { import { Config } from '../../config' import { ConversationRecord, + getBaseBindingKey, + getPresetLane, ResolvedConversationContext } from '../../services/conversation_types' import { Pagination } from 'koishi-plugin-chatluna/utils/pagination' import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' - -function pickConversationTarget( - context: ChainMiddlewareContext, - current?: ConversationRecord | null -) { - return ( - context.options.conversation_manage?.targetConversation ?? - context.options.conversationId ?? - current?.id - ) -} - -function formatConversationStatus( - session: Session, - conversation: ConversationRecord, - activeConversationId?: string | null -) { - const labels = [ - session.text( - 'chatluna.conversation.status_value.' + conversation.status - ) - ] - - if (conversation.id === activeConversationId) { - labels.push(session.text('chatluna.conversation.active')) - } - - return labels.join(' · ') -} - -function formatRouteScope(bindingKey: string) { - if (bindingKey.includes(':preset:')) { - return bindingKey - } - - const [mode, platform, selfId, scope, userId] = bindingKey.split(':') - - if (mode !== 'shared' && mode !== 'personal') { - return bindingKey - } - - if (platform == null || selfId == null || scope == null) { - return bindingKey - } - - if (mode === 'shared') { - return `${mode} ${platform}/${selfId}/${scope}` - } - - if (userId == null) { - return bindingKey - } - - return `${mode} ${platform}/${selfId}/${scope}/${userId}` -} - -function formatConversationError( - session: Session, - error: Error, - action?: string -) { - if (error.message === 'Conversation not found.') { - return session.text('chatluna.conversation.messages.target_not_found') - } - - if (error.message === 'Conversation target is ambiguous.') { - return session.text('chatluna.conversation.messages.target_ambiguous') - } - - if (error.message === 'Conversation does not belong to current route.') { - return session.text( - 'chatluna.conversation.messages.target_outside_route' - ) - } - - if ( - error.message === - 'Conversation management requires administrator permission.' - ) { - return session.text('chatluna.conversation.messages.admin_required') - } - - const locked = error.message.match( - /^Conversation (.+) is locked by constraint\.$/ - ) - if (locked) { - return session.text('chatluna.conversation.messages.action_locked', [ - session.text(`chatluna.conversation.action.${locked[1]}`) - ]) - } - - const disabled = error.message.match( - /^Conversation (.+) is disabled by constraint\.$/ - ) - if (disabled) { - return session.text('chatluna.conversation.messages.action_disabled', [ - session.text(`chatluna.conversation.action.${disabled[1]}`) - ]) - } - - const fixedModel = error.message.match(/^Model is fixed to (.+)\.$/) - if (fixedModel) { - return session.text('chatluna.conversation.messages.fixed_model', [ - fixedModel[1] - ]) - } - - const fixedPreset = error.message.match(/^Preset is fixed to (.+)\.$/) - if (fixedPreset) { - return session.text('chatluna.conversation.messages.fixed_preset', [ - fixedPreset[1] - ]) - } - - const fixedMode = error.message.match(/^Chat mode is fixed to (.+)\.$/) - if (fixedMode) { - return session.text('chatluna.conversation.messages.fixed_chat_mode', [ - fixedMode[1] - ]) - } - - if (action != null) { - return session.text('chatluna.conversation.messages.action_failed', [ - session.text(`chatluna.conversation.action.${action}`), - error.message - ]) - } - - return error.message -} - -function formatConversationLine( - session: Session, - conversation: ConversationRecord, - resolved: ResolvedConversationContext -) { - const status = formatConversationStatus( - session, - conversation, - resolved.binding?.activeConversationId - ) - const effectiveModel = - resolved.constraint.fixedModel ?? - conversation.model ?? - resolved.constraint.defaultModel ?? - '-' - return session.text('chatluna.conversation.conversation_line', [ - conversation.seq ?? '-', - conversation.title, - status, - effectiveModel - ]) -} - -function formatLockState(lock: boolean | null | undefined) { - return lock == null ? 'reset' : lock ? 'locked' : 'unlocked' -} +import { logger } from '../..' +import fs from 'fs/promises' export function apply(ctx: Context, config: Config, chain: ChatChain) { function middleware( @@ -297,7 +144,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const conversation = await ctx.chatluna.conversation.switchConversation(session, { targetConversation, - presetLane: context.options.conversation_manage?.presetLane + allPresetLanes: true }) context.options.conversationId = conversation.id @@ -322,19 +169,18 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_list', async (session, context) => { const page = context.options.page ?? 1 const limit = context.options.limit ?? 5 - const presetLane = context.options.conversation_manage?.presetLane const includeArchived = context.options.conversation_manage?.includeArchived === true const resolved = await ctx.chatluna.conversation.getCurrentConversation( session, { - presetLane + useRoutePresetLane: true } ) const conversations = await ctx.chatluna.conversation.listConversations( session, { - presetLane, + allPresetLanes: true, includeArchived } ) @@ -348,7 +194,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const pagination = new Pagination({ formatItem: (conversation) => - formatConversationLine(session, conversation, resolved), + formatConversationLine(session, conversation, resolved, true), formatString: { top: session.text('chatluna.conversation.messages.list_header') + @@ -360,17 +206,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } }) - const key = `${resolved.bindingKey}` + const key = `${getBaseBindingKey(resolved.bindingKey)}:all` await pagination.push(conversations, key) context.message = await pagination.getFormattedPage(page, limit, key) return ChainMiddlewareRunStatus.STOP }) middleware('conversation_current', async (session, context) => { + const presetLane = context.options.conversation_manage?.presetLane const resolved = await ctx.chatluna.conversation.getCurrentConversation( session, { - presetLane: context.options.conversation_manage?.presetLane + presetLane, + useRoutePresetLane: presetLane == null } ) @@ -516,13 +364,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_archive', async (session, context) => { const targetConversation = pickConversationTarget(context) - if (targetConversation == null) { - context.message = session.text( - 'chatluna.conversation.messages.archive_empty' - ) - return ChainMiddlewareRunStatus.STOP - } - try { const result = await ctx.chatluna.conversation.archiveConversation( session, @@ -554,13 +395,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_restore', async (session, context) => { const targetConversation = pickConversationTarget(context) - if (targetConversation == null) { - context.message = session.text( - 'chatluna.conversation.messages.restore_empty' - ) - return ChainMiddlewareRunStatus.STOP - } - try { const conversation = await ctx.chatluna.conversation.reopenConversation(session, { @@ -591,13 +425,6 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_export', async (session, context) => { const targetConversation = pickConversationTarget(context) - if (targetConversation == null) { - context.message = session.text( - 'chatluna.conversation.messages.export_empty' - ) - return ChainMiddlewareRunStatus.STOP - } - try { const result = await ctx.chatluna.conversation.exportConversation( session, @@ -608,6 +435,17 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) + try { + const buffer = await fs.readFile(result.path) + await session.send( + h.file(buffer, 'application/markdown', { + title: result.conversation.title + '.md' + }) + ) + } catch (error) { + logger.error(error) + } + context.message = session.text( 'chatluna.conversation.messages.export_success', [ @@ -628,43 +466,59 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) - for (const ruleField of ['model', 'preset', 'mode'] as const) { + for (const ruleField of ['model', 'mode'] as const) { const ruleMap = { model: { cmd: 'conversation_rule_model' as const, optKey: 'model' as const, + defaultKey: 'defaultModel' as const, constraintKey: 'fixedModel' as const, - msgKey: 'rule_model_success' - }, - preset: { - cmd: 'conversation_rule_preset' as const, - optKey: 'preset' as const, - constraintKey: 'fixedPreset' as const, - msgKey: 'rule_preset_success' + msgKey: 'rule_model_status' }, mode: { cmd: 'conversation_rule_mode' as const, optKey: 'chatMode' as const, + defaultKey: 'defaultChatMode' as const, constraintKey: 'fixedChatMode' as const, - msgKey: 'rule_mode_success' + msgKey: 'rule_mode_status' } }[ruleField] middleware(ruleMap.cmd, async (session, context) => { const value = context.options.conversation_rule?.[ruleMap.optKey] + const clear = + context.options.conversation_rule?.clear === true || + value === 'reset' + const force = context.options.conversation_rule?.force === true + try { const record = - await ctx.chatluna.conversation.updateManagedConstraint( - session, - { - [ruleMap.constraintKey]: - value === 'reset' ? null : value - } - ) + value == null && !clear + ? await ctx.chatluna.conversation.getManagedConstraint( + session + ) + : await ctx.chatluna.conversation.updateManagedConstraint( + session, + clear + ? { + [ruleMap.defaultKey]: null, + [ruleMap.constraintKey]: null + } + : force + ? { + [ruleMap.constraintKey]: value + } + : { + [ruleMap.defaultKey]: value + } + ) context.message = session.text( `chatluna.conversation.messages.${ruleMap.msgKey}`, - [record[ruleMap.constraintKey] ?? 'reset'] + [ + record?.[ruleMap.defaultKey] ?? 'reset', + record?.[ruleMap.constraintKey] ?? 'reset' + ] ) } catch (error) { context.message = formatConversationError( @@ -678,8 +532,65 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) } + middleware('conversation_rule_preset', async (session, context) => { + const value = context.options.conversation_rule?.preset + const clear = + context.options.conversation_rule?.clear === true || + value === 'reset' + const newOnly = context.options.conversation_rule?.newOnly === true + + try { + const record = + value == null && !clear + ? await ctx.chatluna.conversation.getManagedConstraint( + session + ) + : await ctx.chatluna.conversation.updateManagedConstraint( + session, + clear + ? { + activePresetLane: null, + defaultPreset: null, + fixedPreset: null + } + : newOnly + ? { + defaultPreset: value, + fixedPreset: null + } + : { + activePresetLane: value, + fixedPreset: null + } + ) + + context.message = session.text( + 'chatluna.conversation.messages.rule_preset_status', + [ + formatPresetLane(session, record?.activePresetLane), + record?.defaultPreset ?? 'reset' + ] + ) + } catch (error) { + context.message = formatConversationError(session, error, 'preset') + } + + return ChainMiddlewareRunStatus.STOP + }) + middleware('conversation_rule_share', async (session, context) => { const share = context.options.conversation_rule?.share + + if (share == null) { + const resolved = + await ctx.chatluna.conversation.resolveContext(session) + context.message = session.text( + 'chatluna.conversation.messages.rule_share_status', + [resolved.constraint.routeMode] + ) + return ChainMiddlewareRunStatus.STOP + } + const routeMode = share === 'reset' ? null @@ -696,15 +607,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } try { - const record = - await ctx.chatluna.conversation.updateManagedConstraint( - session, - { routeMode } - ) + await ctx.chatluna.conversation.updateManagedConstraint(session, { + routeMode + }) + const resolved = + await ctx.chatluna.conversation.resolveContext(session) context.message = session.text( - 'chatluna.conversation.messages.rule_share_success', - [record.routeMode ?? 'reset'] + 'chatluna.conversation.messages.rule_share_status', + [resolved.constraint.routeMode] ) } catch (error) { context.message = formatConversationError(session, error, 'share') @@ -766,16 +677,23 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session.text('chatluna.conversation.conversation_scope', [ formatRouteScope(resolved.bindingKey) ]), - session.text('chatluna.conversation.rule_share', [ - current?.routeMode ?? 'reset' + session.text('chatluna.conversation.messages.rule_share_status', [ + resolved.constraint.routeMode ]), - session.text('chatluna.conversation.rule_model', [ + session.text('chatluna.conversation.messages.rule_model_status', [ + current?.defaultModel ?? 'reset', current?.fixedModel ?? 'reset' ]), - session.text('chatluna.conversation.rule_preset', [ - current?.fixedPreset ?? 'reset' + session.text('chatluna.conversation.messages.rule_preset_status', [ + formatPresetLane( + session, + current?.activePresetLane ?? + resolved.constraint.activePresetLane + ), + current?.defaultPreset ?? 'reset' ]), - session.text('chatluna.conversation.rule_mode', [ + session.text('chatluna.conversation.messages.rule_mode_status', [ + current?.defaultChatMode ?? 'reset', current?.fixedChatMode ?? 'reset' ]), session.text('chatluna.conversation.rule_lock', [ @@ -862,6 +780,206 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) } +function pickConversationTarget( + context: ChainMiddlewareContext, + current?: ConversationRecord | null +) { + return ( + context.options.conversation_manage?.targetConversation ?? + context.options.conversationId ?? + current?.id + ) +} + +function formatConversationStatus( + session: Session, + conversation: ConversationRecord, + activeConversationId?: string | null +) { + if (conversation.id === activeConversationId) { + return session.text('chatluna.conversation.active') + } + + if (conversation.status === 'active') { + return null + } + + return session.text( + 'chatluna.conversation.status_value.' + conversation.status + ) +} + +function formatRouteScope(bindingKey: string) { + if (bindingKey.includes(':preset:')) { + return bindingKey + } + + const [mode, platform, selfId, scope, userId] = bindingKey.split(':') + + if (mode !== 'shared' && mode !== 'personal') { + return bindingKey + } + + if (platform == null || selfId == null || scope == null) { + return bindingKey + } + + if (mode === 'shared') { + return `${mode} ${platform}/${selfId}/${scope}` + } + + if (userId == null) { + return bindingKey + } + + return `${mode} ${platform}/${selfId}/${scope}/${userId}` +} + +function formatConversationError( + session: Session, + error: Error, + action?: string +) { + if (error.message === 'Conversation not found.') { + return session.text('chatluna.conversation.messages.target_not_found') + } + + if (error.message === 'Conversation target is ambiguous.') { + return session.text('chatluna.conversation.messages.target_ambiguous') + } + + if (error.message === 'Conversation does not belong to current route.') { + return session.text( + 'chatluna.conversation.messages.target_outside_route' + ) + } + + if ( + error.message === + 'Conversation management requires administrator permission.' + ) { + return session.text('chatluna.conversation.messages.admin_required') + } + + const locked = error.message.match( + /^Conversation (.+) is locked by constraint\.$/ + ) + if (locked) { + return session.text('chatluna.conversation.messages.action_locked', [ + session.text(`chatluna.conversation.action.${locked[1]}`) + ]) + } + + const disabled = error.message.match( + /^Conversation (.+) is disabled by constraint\.$/ + ) + if (disabled) { + return session.text('chatluna.conversation.messages.action_disabled', [ + session.text(`chatluna.conversation.action.${disabled[1]}`) + ]) + } + + const fixedModel = error.message.match(/^Model is fixed to (.+)\.$/) + if (fixedModel) { + return session.text('chatluna.conversation.messages.fixed_model', [ + fixedModel[1] + ]) + } + + const fixedPreset = error.message.match(/^Preset is fixed to (.+)\.$/) + if (fixedPreset) { + return session.text('chatluna.conversation.messages.fixed_preset', [ + fixedPreset[1] + ]) + } + + const fixedMode = error.message.match(/^Chat mode is fixed to (.+)\.$/) + if (fixedMode) { + return session.text('chatluna.conversation.messages.fixed_chat_mode', [ + fixedMode[1] + ]) + } + + if (action != null) { + return session.text('chatluna.conversation.messages.action_failed', [ + session.text(`chatluna.conversation.action.${action}`), + error.message + ]) + } + + return error.message +} + +function formatConversationLine( + session: Session, + conversation: ConversationRecord, + resolved: ResolvedConversationContext, + showLane = false +) { + const status = formatConversationStatus( + session, + conversation, + resolved.binding?.activeConversationId + ) + const effectiveModel = + resolved.constraint.fixedModel ?? + conversation.model ?? + resolved.constraint.defaultModel ?? + '-' + const lane = formatPresetLane( + session, + getPresetLane(conversation.bindingKey) + ) + + if (!showLane && status == null) { + return session.text('chatluna.conversation.conversation_line', [ + conversation.seq ?? '-', + conversation.title, + effectiveModel + ]) + } + + if (!showLane) { + return session.text( + 'chatluna.conversation.conversation_line_with_status', + [ + conversation.seq ?? '-', + conversation.title, + effectiveModel, + status + ] + ) + } + + if (status == null) { + return session.text( + 'chatluna.conversation.conversation_line_with_lane', + [conversation.seq ?? '-', conversation.title, effectiveModel, lane] + ) + } + + return session.text( + 'chatluna.conversation.conversation_line_with_lane_status', + [ + conversation.seq ?? '-', + conversation.title, + effectiveModel, + lane, + status + ] + ) +} + +function formatLockState(lock: boolean | null | undefined) { + return lock == null ? 'reset' : lock ? 'locked' : 'unlocked' +} + +function formatPresetLane(session: Session, presetLane?: string | null) { + return presetLane == null + ? session.text('chatluna.conversation.main_lane') + : presetLane +} + declare module '../../chains/chain' { interface ChainMiddlewareName { conversation_new: never diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index 661697b65..bcf4be38d 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -6,6 +6,8 @@ import fs from 'fs/promises' import { createLegacyTableRetention, dropTableIfExists, + getLegacySchemaSentinel, + getLegacySchemaSentinelDir, LEGACY_MIGRATION_TABLES, LEGACY_RETENTION_META_KEY, LEGACY_RUNTIME_TABLES, @@ -113,6 +115,11 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { logger.warn(`wipe: ${e}`) } + const sentinelDir = getLegacySchemaSentinelDir(ctx.baseDir) + const sentinelPath = getLegacySchemaSentinel(ctx.baseDir) + await fs.mkdir(sentinelDir, { recursive: true }) + await fs.writeFile(sentinelPath, '{}', 'utf8') + context.message = session.text('.success') const appContext = ctx.scope.parent diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts index 0a7361bda..41203aa35 100644 --- a/packages/core/src/migration/room_to_conversation.ts +++ b/packages/core/src/migration/room_to_conversation.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from 'crypto' import type { Context } from 'koishi' import type { Config } from '../config' import type { @@ -195,6 +196,7 @@ async function migrateRooms(ctx: Context) { .filter((item) => item.legacyRoomId != null) .map((item) => [item.legacyRoomId!, item]) ) + const usedConversationIds = new Set(existing.map((item) => item.id)) // (#10) only count records that were actually migrated from legacy rooms const progress = (await readMetaValue( ctx, @@ -204,6 +206,10 @@ async function migrateRooms(ctx: Context) { migrated: existing.filter((item) => item.legacyRoomId != null).length } + ctx.logger.info( + `Migrating conversations: ${progress.migrated}/${rooms.length}` + ) + let seq = existing.reduce((max, item) => Math.max(max, item.seq ?? 0), 0) for (const room of rooms) { @@ -219,6 +225,7 @@ async function migrateRooms(ctx: Context) { (item) => item.roomId === room.roomId ) const roomGroups = groups.filter((item) => item.roomId === room.roomId) + const legacyConversationId = room.conversationId as string const bindingKey = resolveRoomBindingKey( room, roomMembers, @@ -236,9 +243,15 @@ async function migrateRooms(ctx: Context) { seq = conversationSeq } + const conversationId = + current?.id ?? + (usedConversationIds.has(legacyConversationId) + ? randomUUID() + : legacyConversationId) + usedConversationIds.add(conversationId) + const conversation: ConversationRecord = { - // filterValidRooms() guarantees conversationId is present here. - id: room.conversationId as string, + id: conversationId, seq: conversationSeq, bindingKey, title: room.roomName, @@ -250,7 +263,11 @@ async function migrateRooms(ctx: Context) { updatedAt, lastChatAt: oldConversation?.updatedAt ?? room.updatedTime, status: 'active', - latestMessageId: oldConversation?.latestId ?? null, + latestMessageId: mapMessageId( + legacyConversationId, + conversationId, + oldConversation?.latestId ?? null + ), additional_kwargs: oldConversation?.additional_kwargs ?? null, compression: null, archivedAt: null, @@ -273,7 +290,12 @@ async function migrateRooms(ctx: Context) { await ctx.database.upsert('chatluna_conversation', [conversation]) existingByRoomId.set(room.roomId, conversation) - const acl = buildAclRecords(room, roomMembers, roomGroups) + const acl = buildAclRecords( + conversation.id, + room, + roomMembers, + roomGroups + ) if (acl.length > 0) { await ctx.database.upsert('chatluna_acl', acl) } @@ -281,11 +303,32 @@ async function migrateRooms(ctx: Context) { progress.lastRoomId = room.roomId progress.migrated += 1 await writeMetaValue(ctx, 'conversation_migration_progress', progress) + + if ( + progress.migrated % BINDING_PROGRESS_BATCH === 0 || + room.roomId === rooms[rooms.length - 1]?.roomId + ) { + ctx.logger.info( + `Migrating conversations: ${progress.migrated}/${rooms.length}` + ) + } } + + ctx.logger.info( + `Conversation migration done: ${progress.migrated}/${rooms.length}` + ) } // (#7) removed redundant inner `done` check async function migrateMessages(ctx: Context) { + const rooms = filterValidRooms( + (await ctx.database.get('chathub_room', {})) as LegacyRoomRecord[] + ) + const conversations = (await ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + const targets = createConversationTargets(rooms, conversations) const progress = (await readMetaValue( ctx, 'message_migration_progress' @@ -294,6 +337,10 @@ async function migrateMessages(ctx: Context) { migrated: 0 } + ctx.logger.info( + `Migrating messages: scanned ${progress.index}, written ${progress.migrated}` + ) + while (true) { const batch = (await ctx.database.get( 'chathub_message', @@ -311,29 +358,50 @@ async function migrateMessages(ctx: Context) { break } - const payload = batch.map((item) => ({ - id: item.id, - conversationId: item.conversation, - parentId: item.parent ?? null, - role: item.role, - text: typeof item.text === 'string' ? item.text : null, - content: item.content ?? null, - name: item.name ?? null, - tool_call_id: item.tool_call_id ?? null, - tool_calls: item.tool_calls, - additional_kwargs: item.additional_kwargs ?? null, - additional_kwargs_binary: item.additional_kwargs_binary ?? null, - rawId: item.rawId ?? null, - createdAt: null - })) satisfies MessageRecord[] - - await ctx.database.upsert('chatluna_message', payload) + const payload: MessageRecord[] = batch.flatMap((item) => { + const conversationIds = targets.get(item.conversation) + + if (conversationIds == null) { + return [] + } + + return conversationIds.map((conversationId) => ({ + id: mapMessageId(item.conversation, conversationId, item.id)!, + conversationId, + parentId: mapMessageId( + item.conversation, + conversationId, + item.parent ?? null + ), + role: item.role, + text: typeof item.text === 'string' ? item.text : null, + content: item.content ?? null, + name: item.name ?? null, + tool_call_id: item.tool_call_id ?? null, + tool_calls: item.tool_calls, + additional_kwargs: item.additional_kwargs ?? null, + additional_kwargs_binary: item.additional_kwargs_binary ?? null, + rawId: item.rawId ?? null, + createdAt: null + })) + }) + + if (payload.length > 0) { + await ctx.database.upsert('chatluna_message', payload) + } progress.index += batch.length progress.lastId = batch[batch.length - 1]?.id - progress.migrated += batch.length + progress.migrated += payload.length await writeMetaValue(ctx, 'message_migration_progress', progress) + ctx.logger.info( + `Migrating messages: scanned ${progress.index}, written ${progress.migrated}` + ) } + + ctx.logger.info( + `Message migration done: scanned ${progress.index}, written ${progress.migrated}` + ) } async function migrateBindings(ctx: Context) { @@ -370,6 +438,10 @@ async function migrateBindings(ctx: Context) { migrated: 0 } + ctx.logger.info( + `Migrating bindings: processed ${progress.index}/${users.length}, migrated ${progress.migrated}` + ) + for (let i = progress.index; i < users.length; i++) { const user = users[i] const conversation = conversationsByRoomId.get(user.defaultRoomId) @@ -384,6 +456,9 @@ async function migrateBindings(ctx: Context) { 'binding_migration_progress', progress ) + ctx.logger.info( + `Migrating bindings: processed ${progress.index}/${users.length}, migrated ${progress.migrated}` + ) } continue } @@ -418,11 +493,71 @@ async function migrateBindings(ctx: Context) { i === users.length - 1 ) { await writeMetaValue(ctx, 'binding_migration_progress', progress) + ctx.logger.info( + `Migrating bindings: processed ${progress.index}/${users.length}, migrated ${progress.migrated}` + ) + } + } + + ctx.logger.info( + `Binding migration done: processed ${progress.index}/${users.length}, migrated ${progress.migrated}` + ) +} + +function createConversationTargets( + rooms: LegacyRoomRecord[], + conversations: ConversationRecord[] +) { + const conversationsByRoomId = new Map( + conversations + .filter((item) => item.legacyRoomId != null) + .map((item) => [item.legacyRoomId!, item]) + ) + const targets = new Map() + + for (const room of rooms) { + const conversation = conversationsByRoomId.get(room.roomId) + + if (conversation == null) { + continue + } + + const legacyConversationId = room.conversationId as string + const ids = targets.get(legacyConversationId) + + if (ids == null) { + targets.set(legacyConversationId, [conversation.id]) + continue + } + + if (!ids.includes(conversation.id)) { + ids.push(conversation.id) } } + + return targets +} + +function mapMessageId( + legacyConversationId: string, + conversationId: string, + messageId?: string | null +) { + if (messageId == null) { + return null + } + + if (conversationId === legacyConversationId) { + return messageId + } + + return createHash('sha256') + .update(conversationId + ':' + messageId) + .digest('hex') } function buildAclRecords( + conversationId: string, room: LegacyRoomRecord, members: LegacyRoomMemberRecord[], groups: LegacyRoomGroupRecord[] @@ -431,7 +566,6 @@ function buildAclRecords( return [] } - const conversationId = room.conversationId as string // (#4) inlined addAclRecord — was a 4-line single-use wrapper const map = new Map() const add = (record: ACLRecord) => diff --git a/packages/core/src/migration/validators.ts b/packages/core/src/migration/validators.ts index a1277b3a3..5636d1d80 100644 --- a/packages/core/src/migration/validators.ts +++ b/packages/core/src/migration/validators.ts @@ -31,6 +31,8 @@ export { const VALIDATION_BATCH_SIZE = 500 export async function validateRoomMigration(ctx: Context, _config: Config) { + ctx.logger.info('Validating built-in ChatLuna migration.') + const rooms = (await ctx.database.get( 'chathub_room', {} @@ -51,34 +53,8 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { const routeModes = inferLegacyGroupRouteModes(users, rooms, groups) const validRooms = filterValidRooms(rooms) - const validConversationIds = new Set( - validRooms.map((room) => room.conversationId as string) - ) - - let legacyMessageCount = 0 - await readTableBatches( - ctx, - 'chathub_message', - (rows) => { - legacyMessageCount += rows.filter((row) => - validConversationIds.has(row.conversation) - ).length - } - ) + const validRoomIds = new Set(validRooms.map((room) => room.roomId)) - let migratedLegacyMessageCount = 0 - await readTableBatches(ctx, 'chatluna_message', (rows) => { - for (const row of rows) { - if ( - row.createdAt == null && - validConversationIds.has(row.conversationId) - ) { - migratedLegacyMessageCount += 1 - } - } - }) - - const conversationsById = new Map() const conversationsByRoomId = new Map() const missingLatestMessageIds: string[] = [] let migratedLegacyConversationCount = 0 @@ -89,7 +65,8 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { async (rows) => { const checked = rows.filter( (row) => - row.legacyRoomId != null || validConversationIds.has(row.id) + row.legacyRoomId != null && + validRoomIds.has(row.legacyRoomId) ) if (checked.length === 0) { @@ -99,7 +76,6 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { migratedLegacyConversationCount += checked.length for (const row of checked) { - conversationsById.set(row.id, row) if (row.legacyRoomId != null) { conversationsByRoomId.set(row.legacyRoomId, row) } @@ -129,31 +105,80 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { missingLatestMessageIds.push(row.id) } } - } + }, + 'conversations' + ) + + const conversationTargets = createConversationTargets( + validRooms, + conversationsByRoomId + ) + const migratedConversationIds = new Set( + Array.from(conversationsByRoomId.values()).map((item) => item.id) + ) + + let legacyMessageCount = 0 + await readTableBatches( + ctx, + 'chathub_message', + (rows) => { + for (const row of rows) { + legacyMessageCount += + conversationTargets.get(row.conversation)?.length ?? 0 + } + }, + 'legacy messages' + ) + + let migratedLegacyMessageCount = 0 + await readTableBatches( + ctx, + 'chatluna_message', + (rows) => { + for (const row of rows) { + if ( + row.createdAt == null && + migratedConversationIds.has(row.conversationId) + ) { + migratedLegacyMessageCount += 1 + } + } + }, + 'migrated messages' ) const migratedBindingKeys = new Set() - await readTableBatches(ctx, 'chatluna_binding', (rows) => { - for (const row of rows) { - migratedBindingKeys.add(row.bindingKey) - } - }) + await readTableBatches( + ctx, + 'chatluna_binding', + (rows) => { + for (const row of rows) { + migratedBindingKeys.add(row.bindingKey) + } + }, + 'bindings' + ) const migratedAclKeys = new Set() let migratedAclCount = 0 - await readTableBatches(ctx, 'chatluna_acl', (rows) => { - migratedAclCount += rows.length - for (const row of rows) { - migratedAclKeys.add( - aclKey( - row.conversationId, - row.principalType, - row.principalId, - row.permission + await readTableBatches( + ctx, + 'chatluna_acl', + (rows) => { + migratedAclCount += rows.length + for (const row of rows) { + migratedAclKeys.add( + aclKey( + row.conversationId, + row.principalType, + row.principalId, + row.permission + ) ) - ) - } - }) + } + }, + 'acl' + ) const inconsistentBindingConversationIds = validRooms .map((room) => { @@ -163,9 +188,7 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { const roomGroups = groups.filter( (item) => item.roomId === room.roomId ) - const conversation = conversationsById.get( - room.conversationId as string - ) + const conversation = conversationsByRoomId.get(room.roomId) if (conversation == null) { return null @@ -211,12 +234,17 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { (item) => item.roomId === room.roomId ) const roomGroups = groups.filter((item) => item.roomId === room.roomId) + const conversation = conversationsByRoomId.get(room.roomId) + + if (conversation == null) { + continue + } if (!isComplexRoom(room, roomMembers, roomGroups)) { continue } - const conversationId = room.conversationId as string + const conversationId = conversation.id for (const member of roomMembers) { expectedAclKeys.push( @@ -254,10 +282,9 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { const result = { checkedAt: new Date().toISOString(), conversation: { - legacy: validConversationIds.size, + legacy: validRooms.length, migrated: migratedLegacyConversationCount, - matched: - validConversationIds.size === migratedLegacyConversationCount + matched: validRooms.length === migratedLegacyConversationCount }, message: { legacy: legacyMessageCount, @@ -287,7 +314,7 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { } } - return { + const validation = { ...result, passed: result.conversation.matched && @@ -297,6 +324,17 @@ export async function validateRoomMigration(ctx: Context, _config: Config) { result.binding.matched && result.acl.matched } satisfies MigrationValidationResult + + ctx.logger.info( + [ + `Migration validation ${validation.passed ? 'passed' : 'failed'}`, + `conversations ${validation.conversation.migrated}/${validation.conversation.legacy}`, + `messages ${validation.message.migrated}/${validation.message.legacy}`, + `acl ${validation.acl.migrated}/${validation.acl.expected}` + ].join(', ') + ) + + return validation } async function readTableBatches( @@ -307,10 +345,13 @@ async function readTableBatches( | 'chatluna_conversation' | 'chatluna_binding' | 'chatluna_acl', - callback: (rows: T[]) => Promise | void + callback: (rows: T[]) => Promise | void, + label: string = table ) { let offset = 0 + ctx.logger.info(`Validating ${label}: 0`) + while (true) { const rows = (await ctx.database.get( table, @@ -327,7 +368,37 @@ async function readTableBatches( await callback(rows) offset += rows.length + ctx.logger.info(`Validating ${label}: ${offset}`) + } +} + +function createConversationTargets( + rooms: LegacyRoomRecord[], + conversationsByRoomId: Map +) { + const targets = new Map() + + for (const room of rooms) { + const conversation = conversationsByRoomId.get(room.roomId) + + if (conversation == null) { + continue + } + + const legacyConversationId = room.conversationId as string + const ids = targets.get(legacyConversationId) + + if (ids == null) { + targets.set(legacyConversationId, [conversation.id]) + continue + } + + if (!ids.includes(conversation.id)) { + ids.push(conversation.id) + } } + + return targets } export async function readMetaValue(ctx: Context, key: string) { diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index ae855d944..265313b6b 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -260,6 +260,7 @@ export class ChatLunaService extends Service { const config = this.currentConfig const chatInterface = new ChatInterface(this.ctx.root, { chatMode: conversation.chatMode, + autoTitle: conversation.autoTitle ?? true, botName: config.botNames[0], preset: this.preset.getPreset(conversation.preset), model: conversation.model, @@ -617,23 +618,13 @@ export class ChatLunaService extends Service { length: 255, nullable: true }, - tool_call_id: 'string', + tool_call_id: { + type: 'string', + nullable: true + }, tool_calls: { - type: 'text', - nullable: true, - dump: (value) => - value == null ? null : JSON.stringify(value), - load: (value) => { - if (value == null || value === '') { - return undefined - } - - try { - return JSON.parse(String(value)) - } catch { - return undefined - } - } + type: 'json', + nullable: true }, additional_kwargs: { type: 'text', @@ -759,6 +750,11 @@ export class ChatLunaService extends Service { length: 255, nullable: true }, + activePresetLane: { + type: 'char', + length: 255, + nullable: true + }, defaultModel: { type: 'char', length: 100, diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 9215d0bcc..630d74754 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' +import type { MessageContent } from '@langchain/core/messages' import type { Context, Session } from 'koishi' import type { Config } from '../config' import { @@ -8,6 +9,15 @@ import { gzipDecode, gzipEncode } from '../utils/compression' +import { + isMessageContentAudio, + isMessageContentFileUrl, + isMessageContentImageUrl, + isMessageContentText, + isMessageContentVideo + // Don't change this line!!!!!!!!!! +} from 'koishi-plugin-chatluna/utils/langchain' +import { getMessageContent } from '../utils/message_content' import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' import { ObjectLock } from 'koishi-plugin-chatluna/utils/lock' import { @@ -20,6 +30,8 @@ import { ConstraintRecord, ConversationCompressionRecord, ConversationRecord, + getBaseBindingKey, + getPresetLane, MessageRecord, ResolveConversationContextOptions, ResolvedConstraint, @@ -134,7 +146,7 @@ export class ConversationService { routeMode, routed?.routeKey ) - const bindingKey = + let bindingKey = options.bindingKey == null ? applyPresetLane(baseKey, options.presetLane) : options.bindingKey.includes(':preset:') @@ -153,9 +165,7 @@ export class ConversationService { constraints.unshift(managed) } - baseKey = bindingKey.includes(':preset:') - ? bindingKey.slice(0, bindingKey.indexOf(':preset:')) - : bindingKey + baseKey = getBaseBindingKey(bindingKey) routeMode = baseKey.startsWith('shared:') ? 'shared' : baseKey.startsWith('personal:') @@ -163,16 +173,31 @@ export class ConversationService { : 'custom' } + const activePresetLane = firstDefined(constraints, 'activePresetLane') + const presetLane = + options.presetLane ?? + (options.useRoutePresetLane ? activePresetLane : undefined) + + if ( + options.bindingKey == null || + !options.bindingKey.includes(':preset:') + ) { + bindingKey = applyPresetLane(baseKey, presetLane) + } + + const lane = getPresetLane(bindingKey) + return { routeMode, baseKey, bindingKey, constraints, + activePresetLane, defaultModel: firstDefined(constraints, 'defaultModel') ?? this.config.defaultModel, defaultPreset: - options.presetLane ?? + lane ?? firstDefined(constraints, 'defaultPreset') ?? this.config.defaultPreset, defaultChatMode: @@ -229,7 +254,7 @@ export class ConversationService { return { bindingKey, - presetLane: options.presetLane, + presetLane: getPresetLane(bindingKey), binding: binding ?? null, conversation: allowedConversation, effectiveModel: @@ -240,7 +265,7 @@ export class ConversationService { effectivePreset: constraint.fixedPreset ?? allowedConversation?.preset ?? - options.presetLane ?? + getPresetLane(bindingKey) ?? constraint.defaultPreset ?? this.config.defaultPreset, effectiveChatMode: @@ -299,14 +324,38 @@ export class ConversationService { session: Session, options: ResolveConversationContextOptions = {} ) { - const resolved = await this.resolveContext(session, options) + let resolved = await this.resolveContext(session, options) if ( resolved.constraint.lockConversation && resolved.binding?.activeConversationId != null ) { - return resolved as ResolvedConversationContext & { - conversation: ConversationRecord + const conversation = await this.getConversation( + resolved.binding.activeConversationId + ) + + if ( + conversation != null && + conversation.status !== 'deleted' && + conversation.status !== 'broken' && + (await this.hasConversationPermission( + session, + conversation, + 'view', + resolved.bindingKey + )) + ) { + resolved = { + ...resolved, + conversation, + effectiveModel: + resolved.constraint.fixedModel ?? conversation.model, + effectivePreset: + resolved.constraint.fixedPreset ?? conversation.preset, + effectiveChatMode: + resolved.constraint.fixedChatMode ?? + conversation.chatMode + } } } @@ -349,7 +398,7 @@ export class ConversationService { preset: resolved.effectivePreset, model: resolved.effectiveModel, chatMode: resolved.effectiveChatMode, - title: options.presetLane ?? 'New Conversation' + title: resolved.presetLane ?? 'New Conversation' }) return { @@ -482,12 +531,26 @@ export class ConversationService { options: ListConversationsOptions = {} ) { const resolved = await this.resolveContext(session, options) - const conversations = (await this.ctx.database.get( - 'chatluna_conversation', - { - bindingKey: resolved.bindingKey - } - )) as ConversationRecord[] + const bindingKey = options.allPresetLanes + ? getBaseBindingKey(resolved.bindingKey) + : resolved.bindingKey + const conversations = options.allPresetLanes + ? ( + (await this.ctx.database.get( + 'chatluna_conversation', + {} + )) as ConversationRecord[] + ).filter((conversation) => { + return ( + conversation.bindingKey === bindingKey || + conversation.bindingKey.startsWith( + bindingKey + ':preset:' + ) + ) + }) + : ((await this.ctx.database.get('chatluna_conversation', { + bindingKey + })) as ConversationRecord[]) return conversations .filter( @@ -509,6 +572,9 @@ export class ConversationService { options: ResolveTargetConversationOptions ) { const resolved = await this.resolveContext(session, options) + const current = options.allPresetLanes + ? await this.resolveContext(session, { useRoutePresetLane: true }) + : resolved await this.assertManageAllowed(session, resolved.constraint) const conversation = await this.resolveTargetConversation(session, { @@ -536,30 +602,33 @@ export class ConversationService { throw new Error('Conversation switch is disabled by constraint.') } - const previousConversation = resolved.binding?.activeConversationId - ? await this.getConversation(resolved.binding.activeConversationId) - : null - const targetBinding = - conversation.bindingKey === resolved.bindingKey - ? resolved.binding - : await this.getBinding(conversation.bindingKey) - const targetPreviousConversation = targetBinding?.activeConversationId - ? await this.getConversation(targetBinding.activeConversationId) + const previousConversation = current.binding?.activeConversationId + ? await this.getConversation(current.binding.activeConversationId) : null + const sameRouteBase = + options.allPresetLanes && + getBaseBindingKey(conversation.bindingKey) === + getBaseBindingKey(resolved.bindingKey) + const bindingKey = sameRouteBase + ? conversation.bindingKey + : resolved.bindingKey + + if (sameRouteBase) { + await this.updateManagedConstraint(session, { + activePresetLane: getPresetLane(conversation.bindingKey) ?? null + }) + } await this.ctx.root.parallel('chatluna/conversation-before-switch', { - bindingKey: resolved.bindingKey, + bindingKey, conversation, previousConversation }) - await this.setActiveConversation( - conversation.bindingKey, - conversation.id - ) + await this.setActiveConversation(bindingKey, conversation.id) await this.ctx.root.parallel('chatluna/conversation-after-switch', { - bindingKey: conversation.bindingKey, + bindingKey, conversation, - previousConversation: targetPreviousConversation + previousConversation }) return conversation @@ -602,8 +671,14 @@ export class ConversationService { } if (conversation.status !== 'archived') { + if (!(target?.allowSwitch ?? resolved.constraint.allowSwitch)) { + throw new Error( + 'Conversation switch is disabled by constraint.' + ) + } + await this.setActiveConversation( - conversation.bindingKey, + resolved.bindingKey, conversation.id ) return conversation @@ -616,9 +691,55 @@ export class ConversationService { } async listMessages(conversationId: string) { - return (await this.ctx.database.get('chatluna_message', { - conversationId - })) as MessageRecord[] + const [conversation, messages] = await Promise.all([ + this.getConversation(conversationId), + this.ctx.database.get('chatluna_message', { + conversationId + }) + ]) + const records = messages as MessageRecord[] + + if (records.length < 2) { + return records + } + + if (conversation?.latestMessageId == null) { + return records.sort((a, b) => { + const left = a.createdAt?.getTime() ?? 0 + const right = b.createdAt?.getTime() ?? 0 + return left - right + }) + } + + const map = new Map(records.map((message) => [message.id, message])) + const ordered: MessageRecord[] = [] + const seen = new Set() + let currentId: string | null | undefined = conversation.latestMessageId + + while (currentId != null) { + if (seen.has(currentId)) { + break + } + + const message = map.get(currentId) + if (message == null) { + break + } + + ordered.unshift(message) + seen.add(currentId) + currentId = message.parentId + } + + if (ordered.length === records.length) { + return ordered + } + + return records.sort((a, b) => { + const left = a.createdAt?.getTime() ?? 0 + const right = b.createdAt?.getTime() ?? 0 + return left - right + }) } async listAcl(conversationId: string) { @@ -770,7 +891,10 @@ export class ConversationService { return this.archiveConversationById(conversation.id) } - async archiveConversationById(conversationId: string) { + async archiveConversationById( + conversationId: string, + inactiveBefore?: Date + ) { const conversation = await this.getConversation(conversationId) if (conversation == null) { throw new Error('Conversation not found.') @@ -784,6 +908,14 @@ export class ConversationService { throw new Error('Conversation not found.') } + if ( + inactiveBefore != null && + (current.status !== 'active' || + current.updatedAt.getTime() >= inactiveBefore.getTime()) + ) { + return null + } + if ( current.status === 'archived' && current.archiveId != null @@ -1052,16 +1184,11 @@ export class ConversationService { if (conversation.status === 'archived' && conversation.archiveId) { const archive = await this.getArchive(conversation.archiveId) if (archive != null) { - try { - const payload = await this.readArchivePayload(archive.path) - messages = payload.messages.map((msg) => ({ - ...msg, - createdAt: new Date(msg.createdAt), - conversationId: conversation.id - })) as unknown as MessageRecord[] - } catch { - messages = await this.listMessages(conversation.id) - } + const payload = await this.readArchivePayload(archive.path) + messages = payload.messages.map((message) => ({ + ...deserializeMessage(message), + conversationId: conversation.id + })) } else { messages = await this.listMessages(conversation.id) } @@ -1081,12 +1208,16 @@ export class ConversationService { `- Status: ${conversation.status}`, `- Updated At: ${conversation.updatedAt.toISOString()}`, '', - ...messages.flatMap((message) => [ - `## ${message.role} ${message.name ? `(${message.name})` : ''}`.trim(), - '', - message.text ?? '', - '' - ]) + ...( + await Promise.all( + messages.map(async (message) => [ + `## ${message.role} ${message.name ? `(${message.name})` : ''}`.trim(), + '', + await formatMessage(message), + '' + ]) + ) + ).flat() ].join('\n') } @@ -1195,29 +1326,47 @@ export class ConversationService { chatMode?: string } ) { - const resolved = await this.ensureActiveConversation(session, options) + const resolved = await this.resolveContext(session, options) await this.assertManageAllowed(session, resolved.constraint) - for (const [key, fixedKey] of [ - ['model', 'fixedModel'], - ['preset', 'fixedPreset'], - ['chatMode', 'fixedChatMode'] - ] as const) { - if (options[key] != null && resolved.constraint[fixedKey] != null) { - throw new Error( - `${key} is fixed to ${resolved.constraint[fixedKey]}.` - ) - } + const conversation = + options.conversationId == null + ? (await this.ensureActiveConversation(session, options)) + .conversation + : await this.resolveTargetConversation(session, { + ...options, + permission: 'manage' + }) + + if (conversation == null) { + throw new Error('Conversation not found.') } const target = await this.getManagedConstraintByBindingKey( - resolved.conversation.bindingKey + conversation.bindingKey ) + + if (target != null) { + await this.assertManageAllowed(session, target) + } + + for (const [key, fixedKey, label] of [ + ['model', 'fixedModel', 'Model'], + ['preset', 'fixedPreset', 'Preset'], + ['chatMode', 'fixedChatMode', 'Chat mode'] + ] as const) { + const fixed = target?.[fixedKey] ?? resolved.constraint[fixedKey] + + if (options[key] != null && fixed != null) { + throw new Error(`${label} is fixed to ${fixed}.`) + } + } + if (target?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation update is locked by constraint.') } - const updated = await this.touchConversation(resolved.conversation.id, { + const updated = await this.touchConversation(conversation.id, { model: options.model, preset: options.preset, chatMode: options.chatMode @@ -1255,7 +1404,7 @@ export class ConversationService { } const current = parseCompressionRecord(conversation.compression) - const summary = ( + const summaryMessage = ( (await this.ctx.database.get( 'chatluna_message', { @@ -1269,7 +1418,9 @@ export class ConversationService { } } )) as MessageRecord[] - )[0]?.text + )[0] + const summary = + summaryMessage == null ? undefined : await readText(summaryMessage) const updated = await this.touchConversation(conversationId, { compression: JSON.stringify({ @@ -1366,6 +1517,7 @@ export class ConversationService { excludeUsers: null, routeMode: null, routeKey: null, + activePresetLane: null, defaultModel: null, defaultPreset: null, defaultChatMode: null, @@ -1457,6 +1609,7 @@ export class ConversationService { const conversations = await this.listConversations(session, { presetLane: options.presetLane, + allPresetLanes: options.allPresetLanes, includeArchived: options.includeArchived }) @@ -1467,18 +1620,26 @@ export class ConversationService { if (/^\d+$/.test(target)) { const seq = Number(target) - const bySeq = conversations.find((c) => c.seq === seq) - if (bySeq != null) { - return bySeq + const bySeq = conversations.filter((c) => c.seq === seq) + if (bySeq.length === 1) { + return bySeq[0] + } + + if (bySeq.length > 1) { + throw new Error('Conversation target is ambiguous.') } } const normalized = target.toLocaleLowerCase() - const exactTitle = conversations.find( + const exactTitle = conversations.filter( (c) => c.title.toLocaleLowerCase() === normalized ) - if (exactTitle != null) { - return exactTitle + if (exactTitle.length === 1) { + return exactTitle[0] + } + + if (exactTitle.length > 1) { + throw new Error('Conversation target is ambiguous.') } const partialMatches = conversations.filter((c) => @@ -1506,6 +1667,19 @@ export class ConversationService { return globalById } + if (/^\d+$/.test(target)) { + const seq = Number(target) + const globalBySeq = globalMatches.filter((c) => c.seq === seq) + + if (globalBySeq.length === 1) { + return globalBySeq[0] + } + + if (globalBySeq.length > 1) { + throw new Error('Conversation target is ambiguous.') + } + } + const globalExactTitle = globalMatches.find( (c) => c.title.toLocaleLowerCase() === normalized ) @@ -1668,9 +1842,25 @@ export class ConversationService { 'utf8' ) ) as ConversationArchivePayload['conversation'] - const messagesRaw = await gzipDecode( - await fs.readFile(path.join(archivePath, 'messages.jsonl.gz')) + const messageBuffer = await fs.readFile( + path.join(archivePath, 'messages.jsonl.gz') ) + + if (manifest.size !== messageBuffer.byteLength) { + throw new Error('Archive payload size mismatch.') + } + + if (manifest.checksum != null && manifest.checksum.length > 0) { + const checksum = createHash('sha256') + .update(messageBuffer) + .digest('hex') + + if (checksum !== manifest.checksum) { + throw new Error('Archive payload checksum mismatch.') + } + } + + const messagesRaw = await gzipDecode(messageBuffer) const messages = messagesRaw .split('\n') .filter((line) => line.length > 0) @@ -1884,6 +2074,191 @@ function parseCompressionRecord(value?: string | null) { } } +async function parseContent(message: MessageRecord) { + if (message.content == null) { + return null + } + + try { + return JSON.parse(await gzipDecode(message.content)) as MessageContent + } catch { + return null + } +} + +async function readText(message: MessageRecord) { + const content = await parseContent(message) + + if (content == null) { + return message.text ?? '' + } + + if (typeof content === 'string') { + return content + } + + return getMessageContent(content) +} + +function formatUrl(url: string) { + return url.length > 120 ? url.slice(0, 117) + '...' : url +} + +async function formatMessage(message: MessageRecord) { + const content = await parseContent(message) + + const text = + content == null + ? (message.text ?? '').trim() + : typeof content === 'string' + ? content.trim() + : getMessageContent(content).trim() + const media = + content != null && Array.isArray(content) + ? content + .map((part) => { + if (isMessageContentText(part)) { + return null + } + + if (isMessageContentImageUrl(part)) { + const url = + typeof part.image_url === 'string' + ? part.image_url + : part.image_url.url + return `[image] ${formatUrl(url)}` + } + + if (isMessageContentFileUrl(part)) { + const url = + typeof part.file_url === 'string' + ? part.file_url + : part.file_url.url + return `[file] ${formatUrl(url)}` + } + + if (isMessageContentAudio(part)) { + const url = + typeof part.audio_url === 'string' + ? part.audio_url + : part.audio_url.url + return `[audio] ${formatUrl(url)}` + } + + if (isMessageContentVideo(part)) { + const url = + typeof part.video_url === 'string' + ? part.video_url + : part.video_url.url + return `[video] ${formatUrl(url)}` + } + + return `[${part.type}]` + }) + .filter((line) => line != null) + : [] + const parts: string[] = [] + + if (message.role === 'tool') { + if (message.tool_call_id != null && message.tool_call_id.length > 0) { + parts.push(`Call ID: \`${message.tool_call_id}\``) + } + + const body = + text.length > 0 + ? text + : media.length > 0 + ? media.join('\n') + : (message.text ?? '') + + if (body.length > 0) { + let block = body + let lang = 'text' + + try { + const parsed = JSON.parse(body) + block = JSON.stringify(parsed, null, 2) + lang = 'json' + } catch {} + + if (parts.length > 0) { + parts.push('') + } + + parts.push('```' + lang) + parts.push(block) + parts.push('```') + } + } else { + if (text.length > 0) { + parts.push(text) + } + + if (media.length > 0) { + if (parts.length > 0) { + parts.push('') + } + + parts.push('Attachments:') + parts.push(...media.map((line) => `- ${line}`)) + } + } + + if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { + if (parts.length > 0) { + parts.push('') + } + + parts.push('Tool calls:') + + for (let i = 0; i < message.tool_calls.length; i++) { + const tool = message.tool_calls[i] as Record + const fn = tool.function as Record | undefined + const name = + typeof tool.name === 'string' + ? tool.name + : typeof fn?.name === 'string' + ? fn.name + : 'unknown' + const id = typeof tool.id === 'string' ? tool.id : '' + const raw = tool.args ?? fn?.arguments ?? {} + + let block: string + let lang = 'json' + + if (typeof raw === 'string') { + block = raw + try { + block = JSON.stringify(JSON.parse(raw), null, 2) + } catch { + lang = 'text' + } + } else { + block = JSON.stringify(raw, null, 2) + } + + if (i > 0) { + parts.push('') + } + + parts.push(`- \`${name}\`${id.length > 0 ? ` (\`${id}\`)` : ''}`) + parts.push('```' + lang) + parts.push(block.length > 0 ? block : '{}') + parts.push('```') + } + } + + if (parts.length > 0) { + return parts.join('\n') + } + + if (content != null && typeof content === 'string') { + return content + } + + return message.text ?? '' +} + function firstDefined( constraints: ConstraintRecord[], key: T diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts index f9d88d0ed..27644ad2c 100644 --- a/packages/core/src/services/conversation_types.ts +++ b/packages/core/src/services/conversation_types.ts @@ -1,3 +1,4 @@ +import { AIMessage } from '@langchain/core/messages' import type { Session } from 'koishi' export type ConversationStatus = 'active' | 'archived' | 'deleted' | 'broken' @@ -53,7 +54,7 @@ export interface MessageRecord { content?: ArrayBuffer | null name?: string | null tool_call_id?: string | null - tool_calls?: unknown + tool_calls?: AIMessage['tool_calls'] additional_kwargs?: string | null additional_kwargs_binary?: ArrayBuffer | null rawId?: string | null @@ -84,6 +85,7 @@ export interface ConstraintRecord { excludeUsers?: string | null routeMode?: RouteMode | null routeKey?: string | null + activePresetLane?: string | null defaultModel?: string | null defaultPreset?: string | null defaultChatMode?: string | null @@ -129,6 +131,7 @@ export interface ResolvedConstraint { bindingKey: string baseKey: string constraints: ConstraintRecord[] + activePresetLane?: string | null defaultModel?: string | null defaultPreset?: string | null defaultChatMode?: string | null @@ -158,6 +161,17 @@ export interface ResolveConversationContextOptions { presetLane?: string conversationId?: string bindingKey?: string + useRoutePresetLane?: boolean +} + +export function getBaseBindingKey(bindingKey: string) { + const idx = bindingKey.indexOf(':preset:') + return idx < 0 ? bindingKey : bindingKey.slice(0, idx) +} + +export function getPresetLane(bindingKey: string) { + const idx = bindingKey.indexOf(':preset:') + return idx < 0 ? undefined : bindingKey.slice(idx + 8) } export function computeBaseBindingKey( @@ -217,5 +231,5 @@ export function applyPresetLane( return bindingKey } - return `${bindingKey}:preset:${presetLane}` + return `${getBaseBindingKey(bindingKey)}:preset:${presetLane}` } diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index 02973f58d..07df33288 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -113,12 +113,14 @@ export interface ActiveRequest { export interface ListConversationsOptions extends ResolveConversationContextOptions { includeArchived?: boolean + allPresetLanes?: boolean } export interface ResolveTargetConversationOptions extends ResolveConversationContextOptions { targetConversation?: string includeArchived?: boolean permission?: ConstraintPermission + allPresetLanes?: boolean } export interface SerializedMessageRecord extends Omit< diff --git a/packages/core/src/utils/conversation.ts b/packages/core/src/utils/conversation.ts index 43940954c..d6e9ba60d 100644 --- a/packages/core/src/utils/conversation.ts +++ b/packages/core/src/utils/conversation.ts @@ -6,7 +6,8 @@ export async function completeConversationTarget( target: string | undefined, presetLane?: string, includeArchived = true, - suffix = 'commands.chatluna.chat.text.options.conversation' + suffix = 'commands.chatluna.chat.text.options.conversation', + allPresetLanes = false ) { const value = target == null || target.trim().length < 1 ? undefined : target.trim() @@ -18,6 +19,7 @@ export async function completeConversationTarget( session, { presetLane, + allPresetLanes, includeArchived } ) diff --git a/packages/core/src/utils/koishi.ts b/packages/core/src/utils/koishi.ts index 2b2f83180..38502c69b 100644 --- a/packages/core/src/utils/koishi.ts +++ b/packages/core/src/utils/koishi.ts @@ -47,9 +47,7 @@ export async function checkAdmin(session: Session) { session.app.logger.debug(`checkAdmin permission test failed: ${error}`) } - const user = await session.getUser(session.userId, [ - 'authority' - ]) + const user = (session as Session).user if (user == null) { return false diff --git a/packages/core/src/utils/message_content.ts b/packages/core/src/utils/message_content.ts index 202385a4d..c74a0bbe1 100644 --- a/packages/core/src/utils/message_content.ts +++ b/packages/core/src/utils/message_content.ts @@ -39,9 +39,9 @@ export function parsePresetLaneInput( return null } - const normalized = head.toLocaleLowerCase() + const lowerHead = head.toLocaleLowerCase() const preset = aliases.find( - (alias) => alias.toLocaleLowerCase() === normalized + (alias) => alias.toLocaleLowerCase() === lowerHead ) if (preset == null) { return null From 5d84cd8398c31afa593141a545734de565096447 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:56:05 +0000 Subject: [PATCH 15/20] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- packages/extension-agent/src/service/sub_agent.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/extension-agent/src/service/sub_agent.ts b/packages/extension-agent/src/service/sub_agent.ts index 0d3b3c71b..476224e65 100644 --- a/packages/extension-agent/src/service/sub_agent.ts +++ b/packages/extension-agent/src/service/sub_agent.ts @@ -170,8 +170,8 @@ export class ChatLunaAgentSubAgentService { private _createTaskRuntime() { return createTaskTool({ - list: ({ session, source }) => - this.listRunnableAgents(session, source).map((item) => ({ + list: ({ session, bindingKey }) => + this.listRunnableAgents(session, bindingKey).map((item) => ({ id: item.id, name: item.name, description: item.description @@ -180,7 +180,7 @@ export class ChatLunaAgentSubAgentService { const info = this.findRunnableAgent( name, ctx.session, - ctx.source + ctx.bindingKey ) if (!info) { return undefined @@ -357,4 +357,4 @@ function buildToolMask(allow: string[]): ToolMask { allow, deny: [] } -} +} \ No newline at end of file From 84eae570c2abc8c38fde2771e5588897febcf16d Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 1 Apr 2026 13:46:49 +0800 Subject: [PATCH 16/20] fix(extension-agent): add source and toolMask to runtime config --- packages/core/src/services/types.ts | 2 ++ .../extension-agent/src/service/sub_agent.ts | 25 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index 07df33288..c77447b1e 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -188,6 +188,8 @@ declare module '@chatluna/shared-prompt-renderer' { session?: Session conversationId?: string subagentContext?: SubagentContext + toolMask?: ToolMask + source?: string } } diff --git a/packages/extension-agent/src/service/sub_agent.ts b/packages/extension-agent/src/service/sub_agent.ts index 476224e65..11dc702ed 100644 --- a/packages/extension-agent/src/service/sub_agent.ts +++ b/packages/extension-agent/src/service/sub_agent.ts @@ -170,8 +170,8 @@ export class ChatLunaAgentSubAgentService { private _createTaskRuntime() { return createTaskTool({ - list: ({ session, bindingKey }) => - this.listRunnableAgents(session, bindingKey).map((item) => ({ + list: ({ session, source }) => + this.listRunnableAgents(session, source).map((item) => ({ id: item.id, name: item.name, description: item.description @@ -180,8 +180,9 @@ export class ChatLunaAgentSubAgentService { const info = this.findRunnableAgent( name, ctx.session, - ctx.bindingKey + ctx.source ) + if (!info) { return undefined } @@ -268,15 +269,13 @@ export class ChatLunaAgentSubAgentService { if (runtime.configurable?.subagentContext) return next() const session = runtime.configurable?.session - const source = - ( - runtime.configurable as { - source?: 'chatluna' | 'character' - } - )?.source ?? 'chatluna' - - const mask = (runtime.configurable as { toolMask?: ToolMask }) - ?.toolMask + const source = (runtime.configurable?.source ?? + 'chatluna') as Parameters< + ChatLunaAgentPermissionService['canUseSubAgent'] + >[2] + + const mask = runtime.configurable?.toolMask + if ( mask != null && !this.ctx.chatluna.platform @@ -357,4 +356,4 @@ function buildToolMask(allow: string[]): ToolMask { allow, deny: [] } -} \ No newline at end of file +} From ccaf54c86ccd071cb6beb7ba3163a8ffb25d1182 Mon Sep 17 00:00:00 2001 From: dingyi Date: Thu, 2 Apr 2026 07:27:08 +0800 Subject: [PATCH 17/20] fix(core): resolve conversations across preset lanes Allow conversation management and target completion to work across canonical and legacy preset routes while showing preset names in list output. --- packages/core/src/commands/chat.ts | 24 ++- packages/core/src/commands/conversation.ts | 15 +- packages/core/src/locales/en-US.yml | 8 +- packages/core/src/locales/zh-CN.yml | 8 +- .../src/middlewares/chat/read_chat_message.ts | 3 +- .../src/middlewares/chat/rollback_chat.ts | 3 + .../core/src/middlewares/chat/stop_chat.ts | 3 + .../conversation/resolve_conversation.ts | 4 +- .../middlewares/system/conversation_manage.ts | 68 +++++---- packages/core/src/services/conversation.ts | 143 +++++++++++++----- .../core/src/services/conversation_types.ts | 5 + packages/core/src/utils/conversation.ts | 18 ++- .../core/tests/conversation-service.spec.ts | 92 +++++++++++ .../core/tests/conversation-target.spec.ts | 50 ++++++ 14 files changed, 352 insertions(+), 92 deletions(-) create mode 100644 packages/core/tests/conversation-target.spec.ts diff --git a/packages/core/src/commands/chat.ts b/packages/core/src/commands/chat.ts index 75b5303d4..f773c4aac 100644 --- a/packages/core/src/commands/chat.ts +++ b/packages/core/src/commands/chat.ts @@ -19,6 +19,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { options.preset == null || options.preset.trim().length < 1 ? undefined : options.preset.trim() + const allPresetLanes = presetLane == null if ( !ctx.chatluna.renderer.rendererTypeList.some( @@ -39,8 +40,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, options.conversation, presetLane, - false + false, + 'commands.chatluna.chat.text.options.conversation', + allPresetLanes ), + allPresetLanes, presetLane, renderOptions: { session, @@ -67,8 +71,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, options.conversation, undefined, - false + false, + 'commands.chatluna.chat.text.options.conversation', + true ), + allPresetLanes: true, renderOptions: { session, split: config.splitMessage, @@ -92,8 +99,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, options.conversation, undefined, - false - ) + false, + 'commands.chatluna.chat.text.options.conversation', + true + ), + allPresetLanes: true }, ctx ) @@ -114,8 +124,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, options.conversation, undefined, - false + false, + 'commands.chatluna.chat.text.options.conversation', + true ), + allPresetLanes: true, renderOptions: { split: config.splitMessage, type: 'voice', @@ -152,5 +165,6 @@ declare module '../chains/chain' { conversationId?: string targetConversation?: string presetLane?: string + allPresetLanes?: boolean } } diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index 39141a0d1..1fe9c6b8b 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -148,7 +148,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { conversation, presetLane, true, - 'commands.chatluna.conversation.options.conversation' + 'commands.chatluna.conversation.options.conversation', + presetLane == null ), presetLane } @@ -181,7 +182,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { conversation, presetLane, true, - 'commands.chatluna.conversation.options.conversation' + 'commands.chatluna.conversation.options.conversation', + presetLane == null ), presetLane } @@ -211,7 +213,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { conversation, presetLane, true, - 'commands.chatluna.conversation.options.conversation' + 'commands.chatluna.conversation.options.conversation', + presetLane == null ), presetLane } @@ -245,7 +248,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { conversation, presetLane, true, - 'commands.chatluna.conversation.options.conversation' + 'commands.chatluna.conversation.options.conversation', + presetLane == null ), presetLane }, @@ -300,7 +304,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { conversation, presetLane, true, - 'commands.chatluna.conversation.options.conversation' + 'commands.chatluna.conversation.options.conversation', + presetLane == null ), presetLane } diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 890b2ab26..00fa23bc0 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -607,10 +607,10 @@ chatluna: delete: 'delete' update: 'update' compress: 'compression' - conversation_line: '#{0} {1} [{2}]' - conversation_line_with_status: '#{0} {1} [{2}] [{3}]' - conversation_line_with_lane: '#{0} {1} [{2}] <{3}>' - conversation_line_with_lane_status: '#{0} {1} [{2}] <{3}> [{4}]' + conversation_line: '#{0} {1} [{2}] [{3}]' + conversation_line_with_status: '#{0} {1} [{2}] [{3}] [{4}]' + conversation_line_with_lane: '#{0} {1} [{2}] [{3}] ({4})' + conversation_line_with_lane_status: '#{0} {1} [{2}] [{3}] ({4}) [{5}]' conversation_seq: 'Seq: {0}' conversation_title: 'Title: {0}' conversation_id: 'ID: {0}' diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index bc1527219..ef175ad82 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -606,10 +606,10 @@ chatluna: delete: '删除' update: '更新设置' compress: '压缩' - conversation_line: '#{0} {1} [{2}]' - conversation_line_with_status: '#{0} {1} [{2}] [{3}]' - conversation_line_with_lane: '#{0} {1} [{2}] <{3}>' - conversation_line_with_lane_status: '#{0} {1} [{2}] <{3}> [{4}]' + conversation_line: '#{0} {1} [{2}] [{3}]' + conversation_line_with_status: '#{0} {1} [{2}] [{3}] [{4}]' + conversation_line_with_lane: '#{0} {1} [{2}] [{3}] ({4})' + conversation_line_with_lane_status: '#{0} {1} [{2}] [{3}] ({4}) [{5}]' conversation_seq: '序号:{0}' conversation_title: '标题:{0}' conversation_id: 'ID:{0}' diff --git a/packages/core/src/middlewares/chat/read_chat_message.ts b/packages/core/src/middlewares/chat/read_chat_message.ts index 11a7ffe47..309a3e1cd 100644 --- a/packages/core/src/middlewares/chat/read_chat_message.ts +++ b/packages/core/src/middlewares/chat/read_chat_message.ts @@ -133,7 +133,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { { targetConversation: context.options.targetConversation, - presetLane: context.options.presetLane + presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes } ) diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 4ac7b4e3e..3ba85f81f 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -39,6 +39,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { conversationId: context.options.resolvedConversation.id, presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes, permission: 'manage' } ) @@ -49,6 +50,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { targetConversation: context.options.targetConversation, presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes, permission: 'manage' } ) @@ -67,6 +69,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { { conversationId: conversation.id, presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes, permission: 'manage' } ) diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index 39b8f79cc..cbd8cf1a2 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -19,6 +19,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { conversationId: context.options.resolvedConversation.id, presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes, permission: 'manage' } ) @@ -29,6 +30,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { targetConversation: context.options.targetConversation, presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes, permission: 'manage' } ) @@ -47,6 +49,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { { conversationId: conversation.id, presetLane: context.options.presetLane, + allPresetLanes: context.options.allPresetLanes, permission: 'manage' } ) diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts index 71c875728..4857f5f1d 100644 --- a/packages/core/src/middlewares/conversation/resolve_conversation.ts +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -45,7 +45,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, { targetConversation, - presetLane + presetLane, + allPresetLanes: context.options.allPresetLanes } ) @@ -84,6 +85,7 @@ declare module '../../chains/chain' { } interface ChainMiddlewareContextOptions { + allPresetLanes?: boolean resolvedConversation?: ConversationRecord | null resolvedConversationContext?: ResolvedConversationContext } diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index 37dda3786..8487895b9 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -6,6 +6,7 @@ import { } from '../../chains/chain' import { Config } from '../../config' import { + ConversationListEntry, ConversationRecord, getBaseBindingKey, getPresetLane, @@ -177,13 +178,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { useRoutePresetLane: true } ) - const conversations = await ctx.chatluna.conversation.listConversations( - session, - { + const conversations = + await ctx.chatluna.conversation.listConversationEntries(session, { allPresetLanes: true, includeArchived - } - ) + }) if (conversations.length === 0) { context.message = session.text( @@ -192,9 +191,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - const pagination = new Pagination({ - formatItem: (conversation) => - formatConversationLine(session, conversation, resolved, true), + const pagination = new Pagination({ + formatItem: (item) => + formatConversationLine( + session, + item.conversation, + resolved, + false, + item.displaySeq + ), formatString: { top: session.text('chatluna.conversation.messages.list_header') + @@ -275,12 +280,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_delete', async (session, context) => { try { + const presetLane = context.options.conversation_manage?.presetLane const conversation = await ctx.chatluna.conversation.deleteConversation(session, { conversationId: context.options.conversationId, targetConversation: context.options.conversation_manage?.targetConversation, - presetLane: context.options.conversation_manage?.presetLane + presetLane, + allPresetLanes: presetLane == null }) context.message = session.text( @@ -365,11 +372,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const targetConversation = pickConversationTarget(context) try { + const presetLane = context.options.conversation_manage?.presetLane const result = await ctx.chatluna.conversation.archiveConversation( session, { targetConversation, - presetLane: context.options.conversation_manage?.presetLane + presetLane, + allPresetLanes: presetLane == null } ) @@ -396,10 +405,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const targetConversation = pickConversationTarget(context) try { + const presetLane = context.options.conversation_manage?.presetLane const conversation = await ctx.chatluna.conversation.reopenConversation(session, { targetConversation, - presetLane: context.options.conversation_manage?.presetLane, + presetLane, + allPresetLanes: presetLane == null, includeArchived: true }) @@ -426,11 +437,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const targetConversation = pickConversationTarget(context) try { + const presetLane = context.options.conversation_manage?.presetLane const result = await ctx.chatluna.conversation.exportConversation( session, { targetConversation, - presetLane: context.options.conversation_manage?.presetLane, + presetLane, + allPresetLanes: presetLane == null, includeArchived: true } ) @@ -721,6 +734,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { session, { presetLane, + allPresetLanes: presetLane == null, targetConversation, conversationId: context.options.conversationId, permission: 'manage', @@ -914,7 +928,8 @@ function formatConversationLine( session: Session, conversation: ConversationRecord, resolved: ResolvedConversationContext, - showLane = false + showLane = false, + seq: number | string = conversation.seq ?? '-' ) { const status = formatConversationStatus( session, @@ -926,6 +941,11 @@ function formatConversationLine( conversation.model ?? resolved.constraint.defaultModel ?? '-' + const effectivePreset = + resolved.constraint.fixedPreset ?? + conversation.preset ?? + resolved.constraint.defaultPreset ?? + '-' const lane = formatPresetLane( session, getPresetLane(conversation.bindingKey) @@ -933,40 +953,30 @@ function formatConversationLine( if (!showLane && status == null) { return session.text('chatluna.conversation.conversation_line', [ - conversation.seq ?? '-', + seq, conversation.title, - effectiveModel + effectiveModel, + effectivePreset ]) } if (!showLane) { return session.text( 'chatluna.conversation.conversation_line_with_status', - [ - conversation.seq ?? '-', - conversation.title, - effectiveModel, - status - ] + [seq, conversation.title, effectiveModel, effectivePreset, status] ) } if (status == null) { return session.text( 'chatluna.conversation.conversation_line_with_lane', - [conversation.seq ?? '-', conversation.title, effectiveModel, lane] + [seq, conversation.title, effectiveModel, effectivePreset, lane] ) } return session.text( 'chatluna.conversation.conversation_line_with_lane_status', - [ - conversation.seq ?? '-', - conversation.title, - effectiveModel, - lane, - status - ] + [seq, conversation.title, effectiveModel, effectivePreset, lane, status] ) } diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 630d74754..775b1e5ec 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -28,6 +28,7 @@ import { computeBaseBindingKey, ConstraintPermission, ConstraintRecord, + ConversationListEntry, ConversationCompressionRecord, ConversationRecord, getBaseBindingKey, @@ -287,27 +288,7 @@ export class ConversationService { } } - const idx = bindingKey.indexOf(':preset:') - const suffix = idx >= 0 ? bindingKey.slice(idx) : '' - - if (bindingKey.startsWith('custom:')) { - return null - } - - const guildOrChannel = session.guildId ?? session.channelId ?? 'unknown' - const keys = session.isDirect - ? [`personal:legacy:legacy:direct:${session.userId}${suffix}`] - : bindingKey.startsWith('shared:') - ? [ - `shared:legacy:legacy:${guildOrChannel}${suffix}`, - `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}` - ] - : [ - `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}`, - `shared:legacy:legacy:${guildOrChannel}${suffix}` - ] - - for (const key of keys) { + for (const key of getFallbackBindingKeys(session, bindingKey)) { const legacyBinding = await this.getBinding(key) if (legacyBinding != null) { return { @@ -531,9 +512,11 @@ export class ConversationService { options: ListConversationsOptions = {} ) { const resolved = await this.resolveContext(session, options) - const bindingKey = options.allPresetLanes - ? getBaseBindingKey(resolved.bindingKey) - : resolved.bindingKey + const keys = getLookupKeys( + session, + resolved.constraint.bindingKey, + options.allPresetLanes + ) const conversations = options.allPresetLanes ? ( (await this.ctx.database.get( @@ -541,15 +524,15 @@ export class ConversationService { {} )) as ConversationRecord[] ).filter((conversation) => { - return ( - conversation.bindingKey === bindingKey || - conversation.bindingKey.startsWith( - bindingKey + ':preset:' + return keys.some((key) => { + return ( + conversation.bindingKey === key || + conversation.bindingKey.startsWith(key + ':preset:') ) - ) + }) }) : ((await this.ctx.database.get('chatluna_conversation', { - bindingKey + bindingKey: keys.length === 1 ? keys[0] : { $in: keys } })) as ConversationRecord[]) return conversations @@ -567,6 +550,17 @@ export class ConversationService { }) } + async listConversationEntries( + session: Session, + options: ListConversationsOptions = {} + ): Promise { + const conversations = await this.listConversations(session, options) + return conversations.map((conversation, idx) => ({ + conversation, + displaySeq: idx + 1 + })) + } + async switchConversation( session: Session, options: ResolveTargetConversationOptions @@ -609,9 +603,13 @@ export class ConversationService { options.allPresetLanes && getBaseBindingKey(conversation.bindingKey) === getBaseBindingKey(resolved.bindingKey) - const bindingKey = sameRouteBase - ? conversation.bindingKey - : resolved.bindingKey + const bindingKey = pickBindingKey( + session, + resolved, + conversation, + options.allPresetLanes, + sameRouteBase + ) if (sameRouteBase) { await this.updateManagedConstraint(session, { @@ -678,7 +676,7 @@ export class ConversationService { } await this.setActiveConversation( - resolved.bindingKey, + pickBindingKey(session, resolved, conversation), conversation.id ) return conversation @@ -1602,16 +1600,19 @@ export class ConversationService { } const target = options.targetConversation?.trim() + const useDisplaySeq = + options.allPresetLanes === true && options.presetLane == null if (target == null || target.length === 0) { return resolved.conversation ?? null } - const conversations = await this.listConversations(session, { + const entries = await this.listConversationEntries(session, { presetLane: options.presetLane, allPresetLanes: options.allPresetLanes, includeArchived: options.includeArchived }) + const conversations = entries.map((item) => item.conversation) const byId = conversations.find((c) => c.id === target) if (byId != null) { @@ -1620,7 +1621,11 @@ export class ConversationService { if (/^\d+$/.test(target)) { const seq = Number(target) - const bySeq = conversations.filter((c) => c.seq === seq) + const bySeq = useDisplaySeq + ? entries + .filter((item) => item.displaySeq === seq) + .map((item) => item.conversation) + : conversations.filter((c) => c.seq === seq) if (bySeq.length === 1) { return bySeq[0] } @@ -1659,7 +1664,10 @@ export class ConversationService { bindingKey: resolved.bindingKey, query: normalized, exactId: target, - seq: /^\d+$/.test(target) ? Number(target) : undefined + seq: + /^\d+$/.test(target) && !useDisplaySeq + ? Number(target) + : undefined }) const globalById = globalMatches.find((c) => c.id === target) @@ -1667,7 +1675,7 @@ export class ConversationService { return globalById } - if (/^\d+$/.test(target)) { + if (/^\d+$/.test(target) && !useDisplaySeq) { const seq = Number(target) const globalBySeq = globalMatches.filter((c) => c.seq === seq) @@ -2104,6 +2112,65 @@ function formatUrl(url: string) { return url.length > 120 ? url.slice(0, 117) + '...' : url } +function getFallbackBindingKeys(session: Session, bindingKey: string) { + const idx = bindingKey.indexOf(':preset:') + const suffix = idx >= 0 ? bindingKey.slice(idx) : '' + + if (bindingKey.startsWith('custom:')) { + return [] + } + + const guildOrChannel = session.guildId ?? session.channelId ?? 'unknown' + return session.isDirect + ? [`personal:legacy:legacy:direct:${session.userId}${suffix}`] + : bindingKey.startsWith('shared:') + ? [ + `shared:legacy:legacy:${guildOrChannel}${suffix}`, + `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}` + ] + : [ + `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}`, + `shared:legacy:legacy:${guildOrChannel}${suffix}` + ] +} + +function getLookupKeys( + session: Session, + bindingKey: string, + allPresetLanes = false +) { + const keys = new Set() + + keys.add(allPresetLanes ? getBaseBindingKey(bindingKey) : bindingKey) + + for (const key of getFallbackBindingKeys(session, bindingKey)) { + keys.add(allPresetLanes ? getBaseBindingKey(key) : key) + } + + return Array.from(keys) +} + +function matchesBindingKey(bindingKey: string, keys: string[]) { + return keys.some((key) => { + return bindingKey === key || bindingKey.startsWith(key + ':preset:') + }) +} + +function pickBindingKey( + session: Session, + resolved: ResolvedConversationContext, + conversation: ConversationRecord, + allPresetLanes = false, + sameRouteBase = false +) { + const keys = getLookupKeys(session, resolved.constraint.bindingKey, true) + if (!matchesBindingKey(conversation.bindingKey, keys)) { + return conversation.bindingKey + } + + return sameRouteBase ? conversation.bindingKey : resolved.bindingKey +} + async function formatMessage(message: MessageRecord) { const content = await parseContent(message) diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts index 27644ad2c..bfb4024ca 100644 --- a/packages/core/src/services/conversation_types.ts +++ b/packages/core/src/services/conversation_types.ts @@ -45,6 +45,11 @@ export interface ConversationRecord { autoTitle?: boolean | null } +export interface ConversationListEntry { + conversation: ConversationRecord + displaySeq: number +} + export interface MessageRecord { id: string conversationId: string diff --git a/packages/core/src/utils/conversation.ts b/packages/core/src/utils/conversation.ts index d6e9ba60d..996ac18ec 100644 --- a/packages/core/src/utils/conversation.ts +++ b/packages/core/src/utils/conversation.ts @@ -15,7 +15,7 @@ export async function completeConversationTarget( return undefined } - const conversations = await ctx.chatluna.conversation.listConversations( + const entries = await ctx.chatluna.conversation.listConversationEntries( session, { presetLane, @@ -25,10 +25,14 @@ export async function completeConversationTarget( ) const expect = Array.from( new Set( - conversations.flatMap((conversation) => [ - conversation.id, - String(conversation.seq ?? ''), - conversation.title + entries.flatMap((item) => [ + item.conversation.id, + String( + allPresetLanes && presetLane == null + ? item.displaySeq + : (item.conversation.seq ?? '') + ), + item.conversation.title ]) ) ).filter((item) => item.length > 0) @@ -37,6 +41,10 @@ export async function completeConversationTarget( return value } + if (expect.includes(value)) { + return value + } + return session.suggest({ actual: value, expect, diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts index c78f3d8bb..51e1e1bce 100644 --- a/packages/core/tests/conversation-service.spec.ts +++ b/packages/core/tests/conversation-service.spec.ts @@ -420,6 +420,98 @@ it('ConversationService switches and resolves friendly conversation targets with assert.equal(binding.lastConversationId, 'conversation-old') }) +it('ConversationService lists and switches preset lanes across canonical and legacy routes', async () => { + const session = createSession({ + platform: 'onebot', + selfId: '1016049163', + guildId: '391122026', + channelId: '391122026' + }) + const canonicalBase = 'shared:onebot:1016049163:391122026' + const legacyBase = 'shared:legacy:legacy:391122026' + const legacy = createConversation({ + id: 'conversation-legacy', + bindingKey: legacyBase, + title: 'Legacy', + seq: 1, + lastChatAt: new Date('2026-03-21T00:00:00.000Z') + }) + const aqua = createConversation({ + id: 'conversation-aqua', + bindingKey: `${canonicalBase}:preset:Aqua`, + title: 'Aqua', + seq: 1, + lastChatAt: new Date('2026-03-22T00:00:00.000Z') + }) + const chatgpt = createConversation({ + id: 'conversation-chatgpt', + bindingKey: `${canonicalBase}:preset:chatgpt`, + title: 'ChatGPT', + seq: 1, + lastChatAt: new Date('2026-03-23T00:00:00.000Z') + }) + const sydney = createConversation({ + id: 'conversation-sydney', + bindingKey: `${canonicalBase}:preset:sydney`, + title: 'Sydney', + seq: 1, + lastChatAt: new Date('2026-03-24T00:00:00.000Z') + }) + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + legacy as unknown as TableRow, + aqua as unknown as TableRow, + chatgpt as unknown as TableRow, + sydney as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: legacyBase, + activeConversationId: legacy.id, + lastConversationId: null, + updatedAt: new Date() + } + ] + } + }) + + const listed = await service.listConversations(session, { + allPresetLanes: true + }) + const entries = await service.listConversationEntries(session, { + allPresetLanes: true + }) + const switched = await service.switchConversation(session, { + targetConversation: '2', + allPresetLanes: true + }) + const binding = database.tables.chatluna_binding[0] as BindingRecord + + assert.deepEqual( + listed.map((item) => item.id), + [ + 'conversation-sydney', + 'conversation-chatgpt', + 'conversation-aqua', + 'conversation-legacy' + ] + ) + assert.deepEqual( + entries.map((item) => [item.displaySeq, item.conversation.id]), + [ + [1, 'conversation-sydney'], + [2, 'conversation-chatgpt'], + [3, 'conversation-aqua'], + [4, 'conversation-legacy'] + ] + ) + assert.equal(switched.id, 'conversation-chatgpt') + assert.equal(binding.activeConversationId, 'conversation-chatgpt') + assert.equal(binding.lastConversationId, 'conversation-legacy') +}) + it('ConversationService rejects ambiguous friendly conversation targets', async () => { const alpha = createConversation({ id: 'conversation-alpha', diff --git a/packages/core/tests/conversation-target.spec.ts b/packages/core/tests/conversation-target.spec.ts new file mode 100644 index 000000000..af68f771b --- /dev/null +++ b/packages/core/tests/conversation-target.spec.ts @@ -0,0 +1,50 @@ +/// + +import { assert } from 'chai' +import { completeConversationTarget } from '../src/utils/conversation' + +it('completeConversationTarget accepts exact display seq without suggest', async () => { + let called = false + + const result = await completeConversationTarget( + { + chatluna: { + conversation: { + listConversationEntries: async () => [ + { + displaySeq: 1, + conversation: { + id: 'conversation-1', + title: 'First', + seq: 1 + } + }, + { + displaySeq: 2, + conversation: { + id: 'conversation-2', + title: 'Second', + seq: 1 + } + } + ] + } + } + } as never, + { + text: () => '', + suggest: async () => { + called = true + return 'suggested' + } + } as never, + '2', + undefined, + false, + 'commands.chatluna.chat.text.options.conversation', + true + ) + + assert.equal(result, '2') + assert.equal(called, false) +}) From e50eddb4387afaf8c7c875356072c57b67ee0bc7 Mon Sep 17 00:00:00 2001 From: dingyi Date: Thu, 2 Apr 2026 08:24:06 +0800 Subject: [PATCH 18/20] fix(core): tighten preset lane conversation resolution Allow exact ID and accessible cross-route targets without rebinding the current lane. Reject ambiguous exact-title matches so switches and exports resolve the intended conversation. --- .../src/middlewares/chat/rollback_chat.ts | 5 +- .../conversation/resolve_conversation.ts | 38 +- packages/core/src/services/conversation.ts | 641 ++++++------------ packages/core/src/utils/archive.ts | 169 ++++- packages/core/src/utils/conversation.ts | 70 +- .../core/tests/conversation-service.spec.ts | 139 +++- .../core/tests/conversation-target.spec.ts | 42 ++ 7 files changed, 630 insertions(+), 474 deletions(-) diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 3ba85f81f..e5bc15ee6 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -168,16 +168,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ]) if ((context.options.message?.length ?? 0) < 1) { - const reResolved = - context.options.resolvedConversationContext ?? - resolvedContext const humanContent = await decodeMessageContent(humanMessage) context.options.inputMessage = await ctx.chatluna.messageTransformer.transform( session, transformMessageContentToElements(humanContent), - reResolved.effectiveModel ?? conversation.model, + resolvedContext.effectiveModel ?? conversation.model, undefined, { quote: false, diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts index 4857f5f1d..fe5a9deb5 100644 --- a/packages/core/src/middlewares/conversation/resolve_conversation.ts +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -61,16 +61,36 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.options.resolvedConversation = conversation } - const resolved = - context.options.resolvedConversationContext ?? - (await ctx.chatluna.conversation.resolveContext(session, { - conversationId: context.options.conversationId, - presetLane, - useRoutePresetLane - })) + const current = context.options.resolvedConversation + let resolved = + current != null && + context.options.resolvedConversationContext?.conversation + ?.id === current.id && + context.options.resolvedConversationContext.bindingKey === + current.bindingKey + ? context.options.resolvedConversationContext + : await ctx.chatluna.conversation.resolveContext(session, { + conversationId: context.options.conversationId, + bindingKey: current?.bindingKey, + presetLane: current == null ? presetLane : undefined, + useRoutePresetLane: + current == null ? useRoutePresetLane : false + }) - context.options.resolvedConversation = - context.options.resolvedConversation ?? resolved.conversation + if ( + resolved.conversation != null && + resolved.conversation.bindingKey !== resolved.bindingKey + ) { + resolved = await ctx.chatluna.conversation.resolveContext( + session, + { + conversationId: resolved.conversation.id, + bindingKey: resolved.conversation.bindingKey + } + ) + } + + context.options.resolvedConversation = resolved.conversation context.options.resolvedConversationContext = resolved return ChainMiddlewareRunStatus.CONTINUE diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 775b1e5ec..d6d704985 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -5,10 +5,19 @@ import type { MessageContent } from '@langchain/core/messages' import type { Context, Session } from 'koishi' import type { Config } from '../config' import { - bufferToArrayBuffer, - gzipDecode, - gzipEncode -} from '../utils/compression' + deserializeConversation, + deserializeMessage, + readArchivePayload, + serializeConversation, + serializeMessage, + unbindConversation +} from '../utils/archive' +import { gzipDecode, gzipEncode } from '../utils/compression' +import { + getFallbackBindingKeys, + getLookupKeys, + pickBindingKey +} from '../utils/conversation' import { isMessageContentAudio, isMessageContentFileUrl, @@ -43,8 +52,7 @@ import { ArchiveManifest, ConversationArchivePayload, ListConversationsOptions, - ResolveTargetConversationOptions, - SerializedMessageRecord + ResolveTargetConversationOptions } from './types' export class ConversationService { @@ -91,54 +99,12 @@ export class ConversationService { .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) } - isConstraintMatched(constraint: ConstraintRecord, session: Session) { - if ( - constraint.platform != null && - constraint.platform !== session.platform - ) { - return false - } - if (constraint.selfId != null && constraint.selfId !== session.selfId) { - return false - } - if ( - constraint.guildId != null && - constraint.guildId !== session.guildId - ) { - return false - } - if ( - constraint.channelId != null && - constraint.channelId !== session.channelId - ) { - return false - } - if ( - constraint.direct != null && - constraint.direct !== session.isDirect - ) { - return false - } - - const users = parseJsonArray(constraint.users) - if (users != null && !users.includes(session.userId)) { - return false - } - - const excludeUsers = parseJsonArray(constraint.excludeUsers) - if (excludeUsers != null && excludeUsers.includes(session.userId)) { - return false - } - - return true - } - async resolveConstraint( session: Session, options: ResolveConversationContextOptions = {} ): Promise { let constraints = (await this.listConstraints()).filter((c) => - this.isConstraintMatched(c, session) + isConstraintMatched(c, session) ) const routed = constraints.find((c) => c.routeMode != null) let routeMode = routed?.routeMode ?? this.getDefaultRouteMode(session) @@ -244,7 +210,8 @@ export class ConversationService { : undefined const allowedConversation = conversation != null && - (await this.hasConversationPermission( + (await hasConversationPermission( + this.ctx, session, conversation, 'view', @@ -319,7 +286,8 @@ export class ConversationService { conversation != null && conversation.status !== 'deleted' && conversation.status !== 'broken' && - (await this.hasConversationPermission( + (await hasConversationPermission( + this.ctx, session, conversation, 'view', @@ -342,7 +310,7 @@ export class ConversationService { if (resolved.conversation != null) { if (resolved.conversation.status === 'archived') { - await this.assertManageAllowed(session, resolved.constraint) + await assertManageAllowed(session, resolved.constraint) if (!resolved.constraint.allowArchive) { throw new Error( @@ -368,7 +336,7 @@ export class ConversationService { } } - await this.assertManageAllowed(session, resolved.constraint) + await assertManageAllowed(session, resolved.constraint) if (!resolved.constraint.allowNew) { throw new Error('Conversation creation is disabled by constraint.') @@ -561,38 +529,61 @@ export class ConversationService { })) } - async switchConversation( + private async getTarget( session: Session, - options: ResolveTargetConversationOptions + options: ResolveTargetConversationOptions, + permission: ConstraintPermission, + includeArchived = false ) { const resolved = await this.resolveContext(session, options) - const current = options.allPresetLanes - ? await this.resolveContext(session, { useRoutePresetLane: true }) - : resolved - await this.assertManageAllowed(session, resolved.constraint) - - const conversation = await this.resolveTargetConversation(session, { - ...options, - permission: 'manage' - }) + await assertManageAllowed(session, resolved.constraint) + const conversation = await this.resolveTargetConversation( + session, + { + ...options, + includeArchived, + permission + }, + resolved + ) if (conversation == null) { throw new Error('Conversation not found.') } - const target = await this.getManagedConstraintByBindingKey( + const managed = await this.getManagedConstraintByBindingKey( conversation.bindingKey ) - if (target != null) { - await this.assertManageAllowed(session, target) + if (managed != null) { + await assertManageAllowed(session, managed) } - if (target?.lockConversation ?? resolved.constraint.lockConversation) { + return { + resolved, + conversation, + managed + } + } + + async switchConversation( + session: Session, + options: ResolveTargetConversationOptions + ) { + const { resolved, conversation, managed } = await this.getTarget( + session, + options, + 'manage' + ) + const current = options.allPresetLanes + ? await this.resolveContext(session, { useRoutePresetLane: true }) + : resolved + + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation switch is locked by constraint.') } - if (!(target?.allowSwitch ?? resolved.constraint.allowSwitch)) { + if (!(managed?.allowSwitch ?? resolved.constraint.allowSwitch)) { throw new Error('Conversation switch is disabled by constraint.') } @@ -603,13 +594,7 @@ export class ConversationService { options.allPresetLanes && getBaseBindingKey(conversation.bindingKey) === getBaseBindingKey(resolved.bindingKey) - const bindingKey = pickBindingKey( - session, - resolved, - conversation, - options.allPresetLanes, - sameRouteBase - ) + const bindingKey = pickBindingKey(resolved, conversation) if (sameRouteBase) { await this.updateManagedConstraint(session, { @@ -643,40 +628,26 @@ export class ConversationService { session: Session, options: ResolveTargetConversationOptions ) { - const resolved = await this.resolveContext(session, options) - await this.assertManageAllowed(session, resolved.constraint) - - const conversation = await this.resolveTargetConversation(session, { - ...options, - includeArchived: true, - permission: 'manage' - }) - - if (conversation == null) { - throw new Error('Conversation not found.') - } - - const target = await this.getManagedConstraintByBindingKey( - conversation.bindingKey + const { resolved, conversation, managed } = await this.getTarget( + session, + options, + 'manage', + true ) - if (target != null) { - await this.assertManageAllowed(session, target) - } - - if (target?.lockConversation ?? resolved.constraint.lockConversation) { + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation restore is locked by constraint.') } if (conversation.status !== 'archived') { - if (!(target?.allowSwitch ?? resolved.constraint.allowSwitch)) { + if (!(managed?.allowSwitch ?? resolved.constraint.allowSwitch)) { throw new Error( 'Conversation switch is disabled by constraint.' ) } await this.setActiveConversation( - pickBindingKey(session, resolved, conversation), + pickBindingKey(resolved, conversation), conversation.id ) return conversation @@ -813,27 +784,14 @@ export class ConversationService { outputPath?: string } = {} ) { - const resolved = await this.resolveContext(session, options) - await this.assertManageAllowed(session, resolved.constraint) - const conversation = await this.resolveTargetConversation(session, { - ...options, - includeArchived: true, - permission: 'view' - }) - - if (conversation == null) { - throw new Error('Conversation not found.') - } - - const target = await this.getManagedConstraintByBindingKey( - conversation.bindingKey + const { resolved, conversation, managed } = await this.getTarget( + session, + options, + 'view', + true ) - if (target != null) { - await this.assertManageAllowed(session, target) - } - - if (!(target?.allowExport ?? resolved.constraint.allowExport)) { + if (!(managed?.allowExport ?? resolved.constraint.allowExport)) { throw new Error('Conversation export is disabled by constraint.') } @@ -858,31 +816,17 @@ export class ConversationService { session: Session, options: ResolveTargetConversationOptions = {} ) { - const resolved = await this.resolveContext(session, options) - await this.assertManageAllowed(session, resolved.constraint) - - const conversation = await this.resolveTargetConversation(session, { - ...options, - permission: 'manage' - }) - - if (conversation == null) { - throw new Error('Conversation not found.') - } - - const target = await this.getManagedConstraintByBindingKey( - conversation.bindingKey + const { conversation, managed, resolved } = await this.getTarget( + session, + options, + 'manage' ) - if (target != null) { - await this.assertManageAllowed(session, target) - } - - if (target?.lockConversation ?? resolved.constraint.lockConversation) { + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation archive is locked by constraint.') } - if (!(target?.allowArchive ?? resolved.constraint.allowArchive)) { + if (!(managed?.allowArchive ?? resolved.constraint.allowArchive)) { throw new Error('Conversation archive is disabled by constraint.') } @@ -998,7 +942,7 @@ export class ConversationService { archivedAt: now, archiveId: archive.id }) - await this.unbindConversation(current.id) + await unbindConversation(this.ctx, current.id) await this.ctx.database.remove('chatluna_message', { conversationId: current.id }) @@ -1057,14 +1001,14 @@ export class ConversationService { throw new Error('Archive does not belong to conversation.') } - await this.assertManageAllowed(session, resolved.constraint) + await assertManageAllowed(session, resolved.constraint) const target = await this.getManagedConstraintByBindingKey( conversation.bindingKey ) if (target != null) { - await this.assertManageAllowed(session, target) + await assertManageAllowed(session, target) } if (target?.lockConversation ?? resolved.constraint.lockConversation) { @@ -1099,7 +1043,7 @@ export class ConversationService { ]) try { - const payload = await this.readArchivePayload(archive.path) + const payload = await readArchivePayload(archive.path) const restoredConversation = deserializeConversation( payload.conversation ) @@ -1182,7 +1126,7 @@ export class ConversationService { if (conversation.status === 'archived' && conversation.archiveId) { const archive = await this.getArchive(conversation.archiveId) if (archive != null) { - const payload = await this.readArchivePayload(archive.path) + const payload = await readArchivePayload(archive.path) messages = payload.messages.map((message) => ({ ...deserializeMessage(message), conversationId: conversation.id @@ -1225,24 +1169,12 @@ export class ConversationService { title: string } ) { - const resolved = await this.resolveContext(session, options) - await this.assertManageAllowed(session, resolved.constraint) - - const conversation = await this.resolveTargetConversation(session, { - ...options, - permission: 'manage' - }) - if (conversation == null) { - throw new Error('Conversation not found.') - } - - const target = await this.getManagedConstraintByBindingKey( - conversation.bindingKey + const { resolved, conversation, managed } = await this.getTarget( + session, + options, + 'manage' ) - if (target != null) { - await this.assertManageAllowed(session, target) - } - if (target?.lockConversation ?? resolved.constraint.lockConversation) { + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation rename is locked by constraint.') } @@ -1256,25 +1188,13 @@ export class ConversationService { session: Session, options: ResolveTargetConversationOptions = {} ) { - const resolved = await this.resolveContext(session, options) - await this.assertManageAllowed(session, resolved.constraint) - - const conversation = await this.resolveTargetConversation(session, { - ...options, - includeArchived: true, - permission: 'manage' - }) - if (conversation == null) { - throw new Error('Conversation not found.') - } - - const target = await this.getManagedConstraintByBindingKey( - conversation.bindingKey + const { resolved, conversation, managed } = await this.getTarget( + session, + options, + 'manage', + true ) - if (target != null) { - await this.assertManageAllowed(session, target) - } - if (target?.lockConversation ?? resolved.constraint.lockConversation) { + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation delete is locked by constraint.') } @@ -1297,7 +1217,7 @@ export class ConversationService { status: 'deleted', archivedAt: null }) - await this.unbindConversation(current.id) + await unbindConversation(this.ctx, current.id) await this.ctx.database.remove('chatluna_message', { conversationId: current.id }) @@ -1325,16 +1245,20 @@ export class ConversationService { } ) { const resolved = await this.resolveContext(session, options) - await this.assertManageAllowed(session, resolved.constraint) + await assertManageAllowed(session, resolved.constraint) const conversation = options.conversationId == null ? (await this.ensureActiveConversation(session, options)) .conversation - : await this.resolveTargetConversation(session, { - ...options, - permission: 'manage' - }) + : await this.resolveTargetConversation( + session, + { + ...options, + permission: 'manage' + }, + resolved + ) if (conversation == null) { throw new Error('Conversation not found.') @@ -1345,7 +1269,7 @@ export class ConversationService { ) if (target != null) { - await this.assertManageAllowed(session, target) + await assertManageAllowed(session, target) } for (const [key, fixedKey, label] of [ @@ -1556,9 +1480,10 @@ export class ConversationService { async resolveTargetConversation( session: Session, - options: ResolveTargetConversationOptions = {} + options: ResolveTargetConversationOptions = {}, + resolved?: ResolvedConversationContext ) { - const resolved = await this.resolveContext(session, options) + resolved = resolved ?? (await this.resolveContext(session, options)) if (options.conversationId != null) { const conversation = await this.getConversation( @@ -1584,7 +1509,13 @@ export class ConversationService { } if ( - !(await this.hasConversationPermission( + !( + options.allPresetLanes === true && + getBaseBindingKey(conversation.bindingKey) === + getBaseBindingKey(resolved.bindingKey) + ) && + !(await hasConversationPermission( + this.ctx, session, conversation, options.permission ?? 'view', @@ -1688,11 +1619,15 @@ export class ConversationService { } } - const globalExactTitle = globalMatches.find( + const globalExactTitle = globalMatches.filter( (c) => c.title.toLocaleLowerCase() === normalized ) - if (globalExactTitle != null) { - return globalExactTitle + if (globalExactTitle.length === 1) { + return globalExactTitle[0] + } + + if (globalExactTitle.length > 1) { + throw new Error('Conversation target is ambiguous.') } const globalPartialMatches = globalMatches.filter((c) => @@ -1834,227 +1769,106 @@ export class ConversationService { return this.resolveTargetConversation(session, options) } - private async readArchivePayload(archivePath: string) { - const stat = await fs.stat(archivePath) - - if (stat.isDirectory()) { - const manifest = JSON.parse( - await fs.readFile( - path.join(archivePath, 'manifest.json'), - 'utf8' - ) - ) as ArchiveManifest - const conversation = JSON.parse( - await fs.readFile( - path.join(archivePath, 'conversation.json'), - 'utf8' - ) - ) as ConversationArchivePayload['conversation'] - const messageBuffer = await fs.readFile( - path.join(archivePath, 'messages.jsonl.gz') - ) - - if (manifest.size !== messageBuffer.byteLength) { - throw new Error('Archive payload size mismatch.') - } - - if (manifest.checksum != null && manifest.checksum.length > 0) { - const checksum = createHash('sha256') - .update(messageBuffer) - .digest('hex') - - if (checksum !== manifest.checksum) { - throw new Error('Archive payload checksum mismatch.') - } - } - - const messagesRaw = await gzipDecode(messageBuffer) - const messages = messagesRaw - .split('\n') - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as SerializedMessageRecord) - - return { - formatVersion: manifest.formatVersion, - exportedAt: manifest.createdAt, - conversation, - messages - } - } - - // Legacy format: single gzip file containing the full payload JSON - const compressed = await fs.readFile(archivePath) - const content = await gzipDecode(compressed) - return JSON.parse(content) as ConversationArchivePayload - } - private async ensureDataDir(name: string) { const target = path.resolve(this.ctx.baseDir, 'data/chatluna', name) await fs.mkdir(target, { recursive: true }) return target } +} - private async unbindConversation(conversationId: string) { - const [active, last] = await Promise.all([ - this.ctx.database.get('chatluna_binding', { - activeConversationId: conversationId - }), - this.ctx.database.get('chatluna_binding', { - lastConversationId: conversationId - }) - ]) - const bindings = Array.from( - new Map( - [ - ...(active as BindingRecord[]), - ...(last as BindingRecord[]) - ].map((item) => [item.bindingKey, item]) - ).values() - ) - - for (const binding of bindings) { - await this.ctx.database.upsert('chatluna_binding', [ - { - bindingKey: binding.bindingKey, - activeConversationId: - binding.activeConversationId === conversationId - ? null - : binding.activeConversationId, - lastConversationId: - binding.lastConversationId === conversationId - ? null - : binding.lastConversationId, - updatedAt: new Date() - } - ]) - } - } - - private async assertManageAllowed( - session: Session, - constraint: ResolvedConstraint | ConstraintRecord +function isConstraintMatched(constraint: ConstraintRecord, session: Session) { + if ( + constraint.platform != null && + constraint.platform !== session.platform ) { - if (constraint.manageMode !== 'admin') { - return - } - - if (await checkAdmin(session)) { - return - } - - throw new Error( - 'Conversation management requires administrator permission.' - ) + return false } - - private async hasConversationPermission( - session: Session, - conversation: ConversationRecord, - permission: ConstraintPermission, - bindingKey: string + if (constraint.selfId != null && constraint.selfId !== session.selfId) { + return false + } + if (constraint.guildId != null && constraint.guildId !== session.guildId) { + return false + } + if ( + constraint.channelId != null && + constraint.channelId !== session.channelId ) { - if (conversation.bindingKey === bindingKey) { - return true - } - - if (await checkAdmin(session)) { - return true - } - - const acl = await this.listAcl(conversation.id) - if (acl.length === 0) { - return false - } - - const principalIds = [ - ['user', session.userId], - ['guild', session.guildId ?? session.channelId] - ] as const - const required = permission === 'view' ? ['view', 'manage'] : ['manage'] + return false + } + if (constraint.direct != null && constraint.direct !== session.isDirect) { + return false + } - return acl.some((item) => { - if (!required.includes(item.permission)) { - return false - } + const users = parseJsonArray(constraint.users) + if (users != null && !users.includes(session.userId)) { + return false + } - return principalIds.some( - ([type, id]) => - id != null && - item.principalType === type && - item.principalId === id - ) - }) + const excludeUsers = parseJsonArray(constraint.excludeUsers) + if (excludeUsers != null && excludeUsers.includes(session.userId)) { + return false } + + return true } -function serializeConversation( - conversation: ConversationRecord -): ConversationArchivePayload['conversation'] { - return { - ...conversation, - createdAt: conversation.createdAt.toISOString(), - updatedAt: conversation.updatedAt.toISOString(), - lastChatAt: conversation.lastChatAt - ? conversation.lastChatAt.toISOString() - : null, - archivedAt: conversation.archivedAt - ? conversation.archivedAt.toISOString() - : null +async function assertManageAllowed( + session: Session, + constraint: ResolvedConstraint | ConstraintRecord +) { + if (constraint.manageMode !== 'admin') { + return } -} -function deserializeConversation( - conversation: ConversationArchivePayload['conversation'] -): ConversationRecord { - return { - ...conversation, - createdAt: new Date(conversation.createdAt), - updatedAt: new Date(conversation.updatedAt), - lastChatAt: conversation.lastChatAt - ? new Date(conversation.lastChatAt) - : null, - archivedAt: conversation.archivedAt - ? new Date(conversation.archivedAt) - : null + if (await checkAdmin(session)) { + return } + + throw new Error( + 'Conversation management requires administrator permission.' + ) } -function serializeMessage(message: MessageRecord): SerializedMessageRecord { - return { - ...message, - content: serializeBinary(message.content), - additional_kwargs_binary: serializeBinary( - message.additional_kwargs_binary - ), - createdAt: message.createdAt?.toISOString() ?? null +async function hasConversationPermission( + ctx: Context, + session: Session, + conversation: ConversationRecord, + permission: ConstraintPermission, + bindingKey: string +) { + if (conversation.bindingKey === bindingKey) { + return true } -} -function deserializeMessage(message: SerializedMessageRecord): MessageRecord { - return { - ...message, - content: deserializeBinary(message.content), - additional_kwargs_binary: deserializeBinary( - message.additional_kwargs_binary - ), - createdAt: message.createdAt ? new Date(message.createdAt) : null + if (await checkAdmin(session)) { + return true } -} -function serializeBinary(value?: ArrayBuffer | null) { - if (value == null) { - return null + const acl = (await ctx.database.get('chatluna_acl', { + conversationId: conversation.id + })) as ACLRecord[] + if (acl.length === 0) { + return false } - return Buffer.from(value).toString('base64') -} + const principalIds = [ + ['user', session.userId], + ['guild', session.guildId ?? session.channelId] + ] as const + const required = permission === 'view' ? ['view', 'manage'] : ['manage'] -function deserializeBinary(value?: string | null) { - if (value == null || value.length === 0) { - return null - } + return acl.some((item) => { + if (!required.includes(item.permission)) { + return false + } - return bufferToArrayBuffer(Buffer.from(value, 'base64')) + return principalIds.some( + ([type, id]) => + id != null && + item.principalType === type && + item.principalId === id + ) + }) } function parseJsonArray(value?: string | null) { @@ -2112,65 +1926,6 @@ function formatUrl(url: string) { return url.length > 120 ? url.slice(0, 117) + '...' : url } -function getFallbackBindingKeys(session: Session, bindingKey: string) { - const idx = bindingKey.indexOf(':preset:') - const suffix = idx >= 0 ? bindingKey.slice(idx) : '' - - if (bindingKey.startsWith('custom:')) { - return [] - } - - const guildOrChannel = session.guildId ?? session.channelId ?? 'unknown' - return session.isDirect - ? [`personal:legacy:legacy:direct:${session.userId}${suffix}`] - : bindingKey.startsWith('shared:') - ? [ - `shared:legacy:legacy:${guildOrChannel}${suffix}`, - `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}` - ] - : [ - `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}`, - `shared:legacy:legacy:${guildOrChannel}${suffix}` - ] -} - -function getLookupKeys( - session: Session, - bindingKey: string, - allPresetLanes = false -) { - const keys = new Set() - - keys.add(allPresetLanes ? getBaseBindingKey(bindingKey) : bindingKey) - - for (const key of getFallbackBindingKeys(session, bindingKey)) { - keys.add(allPresetLanes ? getBaseBindingKey(key) : key) - } - - return Array.from(keys) -} - -function matchesBindingKey(bindingKey: string, keys: string[]) { - return keys.some((key) => { - return bindingKey === key || bindingKey.startsWith(key + ':preset:') - }) -} - -function pickBindingKey( - session: Session, - resolved: ResolvedConversationContext, - conversation: ConversationRecord, - allPresetLanes = false, - sameRouteBase = false -) { - const keys = getLookupKeys(session, resolved.constraint.bindingKey, true) - if (!matchesBindingKey(conversation.bindingKey, keys)) { - return conversation.bindingKey - } - - return sameRouteBase ? conversation.bindingKey : resolved.bindingKey -} - async function formatMessage(message: MessageRecord) { const content = await parseContent(message) diff --git a/packages/core/src/utils/archive.ts b/packages/core/src/utils/archive.ts index bbef7905f..3639364e5 100644 --- a/packages/core/src/utils/archive.ts +++ b/packages/core/src/utils/archive.ts @@ -1,5 +1,18 @@ +import { createHash } from 'crypto' import fs from 'fs/promises' +import path from 'path' import type { Context } from 'koishi' +import type { + BindingRecord, + ConversationRecord, + MessageRecord +} from '../services/conversation_types' +import type { + ArchiveManifest, + ConversationArchivePayload, + SerializedMessageRecord +} from '../services/types' +import { bufferToArrayBuffer, gzipDecode } from './compression' export async function purgeArchivedConversation( ctx: Context, @@ -25,17 +38,32 @@ export async function purgeArchivedConversation( }) } + await unbindConversation(ctx, conversation.id) + await ctx.database.remove('chatluna_message', { + conversationId: conversation.id + }) + await ctx.database.remove('chatluna_acl', { + conversationId: conversation.id + }) + await ctx.database.remove('chatluna_conversation', { + id: conversation.id + }) +} + +export async function unbindConversation(ctx: Context, conversationId: string) { const [active, last] = await Promise.all([ ctx.database.get('chatluna_binding', { - activeConversationId: conversation.id + activeConversationId: conversationId }), ctx.database.get('chatluna_binding', { - lastConversationId: conversation.id + lastConversationId: conversationId }) ]) const bindings = Array.from( new Map( - [...active, ...last].map((binding) => [binding.bindingKey, binding]) + [...(active as BindingRecord[]), ...(last as BindingRecord[])].map( + (item) => [item.bindingKey, item] + ) ).values() ) @@ -44,25 +72,138 @@ export async function purgeArchivedConversation( { bindingKey: binding.bindingKey, activeConversationId: - binding.activeConversationId === conversation.id + binding.activeConversationId === conversationId ? null : binding.activeConversationId, lastConversationId: - binding.lastConversationId === conversation.id + binding.lastConversationId === conversationId ? null : binding.lastConversationId, updatedAt: new Date() } ]) } +} - await ctx.database.remove('chatluna_message', { - conversationId: conversation.id - }) - await ctx.database.remove('chatluna_acl', { - conversationId: conversation.id - }) - await ctx.database.remove('chatluna_conversation', { - id: conversation.id - }) +export async function readArchivePayload(archivePath: string) { + const stat = await fs.stat(archivePath) + + if (stat.isDirectory()) { + const manifest = JSON.parse( + await fs.readFile(path.join(archivePath, 'manifest.json'), 'utf8') + ) as ArchiveManifest + const conversation = JSON.parse( + await fs.readFile( + path.join(archivePath, 'conversation.json'), + 'utf8' + ) + ) as ConversationArchivePayload['conversation'] + const messageBuffer = await fs.readFile( + path.join(archivePath, 'messages.jsonl.gz') + ) + + if (manifest.size !== messageBuffer.byteLength) { + throw new Error('Archive payload size mismatch.') + } + + if (manifest.checksum != null && manifest.checksum.length > 0) { + const checksum = createHash('sha256') + .update(messageBuffer) + .digest('hex') + + if (checksum !== manifest.checksum) { + throw new Error('Archive payload checksum mismatch.') + } + } + + const messages = (await gzipDecode(messageBuffer)) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as SerializedMessageRecord) + + return { + formatVersion: manifest.formatVersion, + exportedAt: manifest.createdAt, + conversation, + messages + } + } + + return JSON.parse( + await gzipDecode(await fs.readFile(archivePath)) + ) as ConversationArchivePayload +} + +export function serializeConversation( + conversation: ConversationRecord +): ConversationArchivePayload['conversation'] { + return { + ...conversation, + createdAt: conversation.createdAt.toISOString(), + updatedAt: conversation.updatedAt.toISOString(), + lastChatAt: conversation.lastChatAt + ? conversation.lastChatAt.toISOString() + : null, + archivedAt: conversation.archivedAt + ? conversation.archivedAt.toISOString() + : null + } +} + +export function deserializeConversation( + conversation: ConversationArchivePayload['conversation'] +): ConversationRecord { + return { + ...conversation, + createdAt: new Date(conversation.createdAt), + updatedAt: new Date(conversation.updatedAt), + lastChatAt: conversation.lastChatAt + ? new Date(conversation.lastChatAt) + : null, + archivedAt: conversation.archivedAt + ? new Date(conversation.archivedAt) + : null + } +} + +export function serializeMessage( + message: MessageRecord +): SerializedMessageRecord { + return { + ...message, + content: serializeBinary(message.content), + additional_kwargs_binary: serializeBinary( + message.additional_kwargs_binary + ), + createdAt: message.createdAt?.toISOString() ?? null + } +} + +export function deserializeMessage( + message: SerializedMessageRecord +): MessageRecord { + return { + ...message, + content: deserializeBinary(message.content), + additional_kwargs_binary: deserializeBinary( + message.additional_kwargs_binary + ), + createdAt: message.createdAt ? new Date(message.createdAt) : null + } +} + +function serializeBinary(value?: ArrayBuffer | null) { + if (value == null) { + return null + } + + return Buffer.from(value).toString('base64') +} + +function deserializeBinary(value?: string | null) { + if (value == null || value.length === 0) { + return null + } + + return bufferToArrayBuffer(Buffer.from(value, 'base64')) } diff --git a/packages/core/src/utils/conversation.ts b/packages/core/src/utils/conversation.ts index 996ac18ec..493f75df9 100644 --- a/packages/core/src/utils/conversation.ts +++ b/packages/core/src/utils/conversation.ts @@ -1,4 +1,9 @@ -import { Context, Session } from 'koishi' +import type { Context, Session } from 'koishi' +import { + getBaseBindingKey, + type ConversationRecord, + type ResolvedConversationContext +} from '../services/conversation_types' export async function completeConversationTarget( ctx: Context, @@ -45,9 +50,72 @@ export async function completeConversationTarget( return value } + try { + if ( + (await ctx.chatluna.conversation.resolveCommandConversation( + session, + { + targetConversation: value, + presetLane, + includeArchived, + allPresetLanes + } + )) != null + ) { + return value + } + } catch {} + return session.suggest({ actual: value, expect, suffix: session.text(suffix) }) } + +export function getFallbackBindingKeys(session: Session, bindingKey: string) { + const idx = bindingKey.indexOf(':preset:') + const suffix = idx >= 0 ? bindingKey.slice(idx) : '' + + if (bindingKey.startsWith('custom:')) { + return [] + } + + const guildOrChannel = session.guildId ?? session.channelId ?? 'unknown' + return session.isDirect + ? [`personal:legacy:legacy:direct:${session.userId}${suffix}`] + : bindingKey.startsWith('shared:') + ? [ + `shared:legacy:legacy:${guildOrChannel}${suffix}`, + `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}` + ] + : [ + `personal:legacy:legacy:${guildOrChannel}:${session.userId}${suffix}`, + `shared:legacy:legacy:${guildOrChannel}${suffix}` + ] +} + +export function getLookupKeys( + session: Session, + bindingKey: string, + allPresetLanes = false +) { + const keys = new Set() + + keys.add(allPresetLanes ? getBaseBindingKey(bindingKey) : bindingKey) + + for (const key of getFallbackBindingKeys(session, bindingKey)) { + keys.add(allPresetLanes ? getBaseBindingKey(key) : key) + } + + return Array.from(keys) +} + +export function pickBindingKey( + resolved: ResolvedConversationContext, + conversation: ConversationRecord +) { + return conversation.bindingKey === resolved.bindingKey + ? resolved.bindingKey + : conversation.bindingKey +} diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts index 51e1e1bce..f30fd3f7d 100644 --- a/packages/core/tests/conversation-service.spec.ts +++ b/packages/core/tests/conversation-service.spec.ts @@ -487,7 +487,12 @@ it('ConversationService lists and switches preset lanes across canonical and leg targetConversation: '2', allPresetLanes: true }) - const binding = database.tables.chatluna_binding[0] as BindingRecord + const legacyBinding = database.tables.chatluna_binding.find( + (item) => item.bindingKey === legacyBase + ) as BindingRecord | undefined + const laneBinding = database.tables.chatluna_binding.find( + (item) => item.bindingKey === chatgpt.bindingKey + ) as BindingRecord | undefined assert.deepEqual( listed.map((item) => item.id), @@ -508,8 +513,79 @@ it('ConversationService lists and switches preset lanes across canonical and leg ] ) assert.equal(switched.id, 'conversation-chatgpt') - assert.equal(binding.activeConversationId, 'conversation-chatgpt') - assert.equal(binding.lastConversationId, 'conversation-legacy') + assert.equal(legacyBinding?.activeConversationId, 'conversation-legacy') + assert.equal(laneBinding?.activeConversationId, 'conversation-chatgpt') +}) + +it('ConversationService allows exact id across preset lanes when allPresetLanes is enabled', async () => { + const laneA = createConversation({ + id: 'conversation-lane-a', + bindingKey: 'shared:discord:bot:guild:preset:A' + }) + const laneB = createConversation({ + id: 'conversation-lane-b', + bindingKey: 'shared:discord:bot:guild:preset:B' + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + laneA as unknown as TableRow, + laneB as unknown as TableRow + ] + } + }) + + const resolved = await service.resolveCommandConversation(createSession(), { + conversationId: laneB.id, + allPresetLanes: true, + permission: 'manage' + }) + + assert.equal(resolved?.id, laneB.id) +}) + +it('ConversationService keeps current lane binding untouched when switching across preset lanes', async () => { + const laneA = createConversation({ + id: 'conversation-switch-lane-a', + bindingKey: 'shared:discord:bot:guild:preset:A' + }) + const laneB = createConversation({ + id: 'conversation-switch-lane-b', + bindingKey: 'shared:discord:bot:guild:preset:B' + }) + + const { service, database } = await createService({ + tables: { + chatluna_conversation: [ + laneA as unknown as TableRow, + laneB as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: laneA.bindingKey, + activeConversationId: laneA.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ] + } + }) + + await service.switchConversation(createSession(), { + targetConversation: laneB.id, + allPresetLanes: true + }) + + const bindingA = database.tables.chatluna_binding.find( + (item) => item.bindingKey === laneA.bindingKey + ) as BindingRecord | undefined + const bindingB = database.tables.chatluna_binding.find( + (item) => item.bindingKey === laneB.bindingKey + ) as BindingRecord | undefined + + assert.equal(bindingA?.activeConversationId, laneA.id) + assert.equal(bindingB?.activeConversationId, laneB.id) }) it('ConversationService rejects ambiguous friendly conversation targets', async () => { @@ -739,6 +815,63 @@ it('ConversationService resolves ACL-backed cross-route targetConversation', asy assert.equal(byTitle?.id, remote.id) }) +it('ConversationService rejects ambiguous global exact title matches', async () => { + const local = createConversation({ + id: 'conversation-local-title', + bindingKey: 'shared:discord:bot:guild' + }) + const remoteA = createConversation({ + id: 'conversation-remote-title-a', + bindingKey: 'shared:discord:bot:other-guild-a', + title: 'Shared Topic' + }) + const remoteB = createConversation({ + id: 'conversation-remote-title-b', + bindingKey: 'shared:discord:bot:other-guild-b', + title: 'Shared Topic' + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + local as unknown as TableRow, + remoteA as unknown as TableRow, + remoteB as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: local.bindingKey, + activeConversationId: local.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ], + chatluna_acl: [ + { + conversationId: remoteA.id, + principalType: 'user', + principalId: 'user', + permission: 'manage' + } as unknown as TableRow, + { + conversationId: remoteB.id, + principalType: 'user', + principalId: 'user', + permission: 'manage' + } as unknown as TableRow + ] + } + }) + + await expectRejected( + service.resolveCommandConversation(createSession({ authority: 1 }), { + targetConversation: 'Shared Topic', + permission: 'manage' + }), + /Conversation target is ambiguous\./ + ) +}) + it('ConversationService keeps local bindings untouched for cross-route switch and reopen', async () => { const local = createConversation({ id: 'conversation-local-switch', diff --git a/packages/core/tests/conversation-target.spec.ts b/packages/core/tests/conversation-target.spec.ts index af68f771b..cd0917d8b 100644 --- a/packages/core/tests/conversation-target.spec.ts +++ b/packages/core/tests/conversation-target.spec.ts @@ -48,3 +48,45 @@ it('completeConversationTarget accepts exact display seq without suggest', async assert.equal(result, '2') assert.equal(called, false) }) + +it('completeConversationTarget accepts accessible target outside current list', async () => { + let called = false + + const result = await completeConversationTarget( + { + chatluna: { + conversation: { + listConversationEntries: async () => [ + { + displaySeq: 1, + conversation: { + id: 'conversation-1', + title: 'First', + seq: 1 + } + } + ], + resolveCommandConversation: async ( + _session, + opts: { targetConversation?: string } + ) => { + return opts.targetConversation === 'conversation-remote' + ? { id: 'conversation-remote' } + : null + } + } + } + } as never, + { + text: () => '', + suggest: async () => { + called = true + return 'suggested' + } + } as never, + 'conversation-remote' + ) + + assert.equal(result, 'conversation-remote') + assert.equal(called, false) +}) From ac6a7569c439a26f5fcdcb0eef6d2d35ca37836a Mon Sep 17 00:00:00 2001 From: dingyi Date: Fri, 3 Apr 2026 09:41:53 +0800 Subject: [PATCH 19/20] fix(core): keep route-family conversation state consistent Use route-family lookup keys when switching or restoring conversations, remove stale archives on delete, and run rollback under the conversation sync lock to avoid inconsistent history state. --- .../src/middlewares/chat/rollback_chat.ts | 270 +++++++++++------- packages/core/src/services/conversation.ts | 57 +++- packages/core/src/utils/archive.ts | 36 +-- .../core/tests/conversation-service.spec.ts | 189 ++++++++++++ packages/core/tests/rollback-chat.spec.ts | 105 +++++++ 5 files changed, 523 insertions(+), 134 deletions(-) create mode 100644 packages/core/tests/rollback-chat.spec.ts diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index e5bc15ee6..6064f656e 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -1,9 +1,12 @@ -import { Context } from 'koishi' +import type { Context, Session } from 'koishi' import { gzipDecode } from 'koishi-plugin-chatluna/utils/string' import { Config } from '../../config' -import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' +import { + ChainMiddlewareRunStatus, + type ChainMiddlewareContext, + type ChatChain +} from '../../chains/chain' import { MessageRecord } from '../../services/conversation_types' -import { logger } from '../..' import { checkAdmin, transformMessageContentToElements @@ -11,18 +14,6 @@ import { const MAX_ROLLBACK_HOPS = 1000 -async function decodeMessageContent(message: MessageRecord) { - try { - return JSON.parse( - message.content - ? await gzipDecode(message.content) - : (message.text ?? '""') - ) - } catch { - return message.text ?? '' - } -} - export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('rollback_chat', async (session, context) => { @@ -81,124 +72,183 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - const resolvedContext = - await ctx.chatluna.conversation.resolveContext(session, { - conversationId: conversation.id, - presetLane: context.options.presetLane, - bindingKey: conversation.bindingKey - }) - - if ( - resolvedContext.constraint.manageMode === 'admin' && - !(await checkAdmin(session)) - ) { - context.message = session.text('.conversation_not_exist') - return ChainMiddlewareRunStatus.STOP - } + context.options.conversationId = conversation.id - if (resolvedContext.constraint.lockConversation) { - context.message = session.text('.conversation_not_exist') - return ChainMiddlewareRunStatus.STOP + const result = + await ctx.chatluna.conversationRuntime.withConversationSync( + conversation, + () => + rollbackConversation( + ctx, + config, + session, + context, + conversation, + rollbackRound + ) + ) + + if (result.status !== ChainMiddlewareRunStatus.CONTINUE) { + context.message = result.msg + return result.status } - context.options.conversationId = conversation.id + context.options.inputMessage = result.inputMessage - await ctx.chatluna.conversationRuntime.clearConversationInterface( - conversation + await session.send( + session.text('.rollback_success', [rollbackRound]) ) - let parentId = conversation.latestMessageId - const messages: MessageRecord[] = [] - let humanMessage: MessageRecord | undefined - let humanCount = 0 - const seen = new Set() + ctx.logger.debug( + `rollback chat ${conversation.id} ${context.options.inputMessage}` + ) - while (parentId != null) { - if (seen.has(parentId)) { - logger.warn(`rollback cycle detected: ${parentId}`) - break - } + return ChainMiddlewareRunStatus.CONTINUE + }) + .after('lifecycle-handle_command') + .before('lifecycle-request_conversation') +} - if (seen.size >= MAX_ROLLBACK_HOPS) { - logger.warn( - `rollback hop limit reached: ${conversation.id}` - ) - break - } +async function decodeMessageContent(message: MessageRecord) { + try { + if (message.content != null) { + return JSON.parse(await gzipDecode(message.content)) + } + + return message.text ?? '' + } catch { + return message.text ?? '' + } +} - seen.add(parentId) +async function rollbackConversation( + ctx: Context, + config: Config, + session: Session, + context: ChainMiddlewareContext, + conversation: { id: string }, + rollbackRound: number +) { + const current = await ctx.chatluna.conversation.getConversation( + conversation.id + ) + + if (current == null) { + return { + status: ChainMiddlewareRunStatus.STOP, + msg: session.text('.conversation_not_exist') + } + } - const message = await ctx.database.get('chatluna_message', { - conversationId: conversation.id, - id: parentId - }) + const resolved = await ctx.chatluna.conversation.resolveContext(session, { + conversationId: current.id, + presetLane: context.options.presetLane, + bindingKey: current.bindingKey + }) + + if ( + resolved.constraint.manageMode === 'admin' && + !(await checkAdmin(session)) + ) { + return { + status: ChainMiddlewareRunStatus.STOP, + msg: session.text('.conversation_not_exist') + } + } - const currentMessage = message[0] + if (resolved.constraint.lockConversation) { + return { + status: ChainMiddlewareRunStatus.STOP, + msg: session.text('.conversation_not_exist') + } + } - if (currentMessage == null) { - break - } + await ctx.chatluna.conversationRuntime.clearConversationInterfaceLocked( + current + ) - parentId = currentMessage.parentId - messages.unshift(currentMessage) + let parentId = current.latestMessageId + const messages: MessageRecord[] = [] + let humanMessage: MessageRecord | undefined + let humanCount = 0 + const seen = new Set() - if (currentMessage.role === 'human') { - humanMessage = currentMessage - humanCount += 1 + while (parentId != null) { + if (seen.has(parentId)) { + ctx.logger.warn(`rollback cycle detected: ${parentId}`) + break + } - if (humanCount >= rollbackRound) { - break - } - } - } + if (seen.size >= MAX_ROLLBACK_HOPS) { + ctx.logger.warn(`rollback hop limit reached: ${current.id}`) + break + } - if (humanCount < rollbackRound || humanMessage == null) { - context.message = session.text('.no_chat_history') - return ChainMiddlewareRunStatus.STOP - } + seen.add(parentId) - const previousLatestId = humanMessage.parentId ?? null + const message = await ctx.database.get('chatluna_message', { + conversationId: current.id, + id: parentId + }) + const currentMessage = message[0] - await ctx.database.upsert('chatluna_conversation', [ - { - id: conversation.id, - latestMessageId: previousLatestId, - updatedAt: new Date() - } - ]) - - if ((context.options.message?.length ?? 0) < 1) { - const humanContent = await decodeMessageContent(humanMessage) - - context.options.inputMessage = - await ctx.chatluna.messageTransformer.transform( - session, - transformMessageContentToElements(humanContent), - resolvedContext.effectiveModel ?? conversation.model, - undefined, - { - quote: false, - includeQuoteReply: config.includeQuoteReply - } - ) + if (currentMessage == null) { + break + } + + parentId = currentMessage.parentId + messages.unshift(currentMessage) + + if (currentMessage.role === 'human') { + humanMessage = currentMessage + humanCount += 1 + + if (humanCount >= rollbackRound) { + break } + } + } - await ctx.database.remove('chatluna_message', { - id: messages.map((message) => message.id) - }) + if (humanCount < rollbackRound || humanMessage == null) { + return { + status: ChainMiddlewareRunStatus.STOP, + msg: session.text('.no_chat_history') + } + } - await session.send( - session.text('.rollback_success', [rollbackRound]) - ) + await ctx.database.upsert('chatluna_conversation', [ + { + id: current.id, + latestMessageId: humanMessage.parentId ?? null, + updatedAt: new Date() + } + ]) + + let inputMessage = context.options.inputMessage + + if ((context.options.message?.length ?? 0) < 1) { + const humanContent = await decodeMessageContent(humanMessage) + + inputMessage = await ctx.chatluna.messageTransformer.transform( + session, + transformMessageContentToElements(humanContent), + resolved.effectiveModel ?? current.model, + undefined, + { + quote: false, + includeQuoteReply: config.includeQuoteReply + } + ) + } - logger.debug( - `rollback chat ${conversation.id} ${context.options.inputMessage}` - ) + await ctx.database.remove('chatluna_message', { + id: messages.map((message) => message.id) + }) - return ChainMiddlewareRunStatus.CONTINUE - }) - .after('lifecycle-handle_command') - .before('lifecycle-request_conversation') + return { + status: ChainMiddlewareRunStatus.CONTINUE, + inputMessage + } } declare module '../../chains/chain' { diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index d6d704985..feaa9fc1a 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -7,6 +7,7 @@ import type { Config } from '../config' import { deserializeConversation, deserializeMessage, + removeArchive, readArchivePayload, serializeConversation, serializeMessage, @@ -590,13 +591,16 @@ export class ConversationService { const previousConversation = current.binding?.activeConversationId ? await this.getConversation(current.binding.activeConversationId) : null - const sameRouteBase = + const sameRoute = options.allPresetLanes && - getBaseBindingKey(conversation.bindingKey) === - getBaseBindingKey(resolved.bindingKey) + getLookupKeys( + session, + resolved.constraint.bindingKey, + true + ).includes(getBaseBindingKey(conversation.bindingKey)) const bindingKey = pickBindingKey(resolved, conversation) - if (sameRouteBase) { + if (sameRoute) { await this.updateManagedConstraint(session, { activePresetLane: getPresetLane(conversation.bindingKey) ?? null }) @@ -646,6 +650,20 @@ export class ConversationService { ) } + if ( + options.allPresetLanes && + getLookupKeys( + session, + resolved.constraint.bindingKey, + true + ).includes(getBaseBindingKey(conversation.bindingKey)) + ) { + await this.updateManagedConstraint(session, { + activePresetLane: + getPresetLane(conversation.bindingKey) ?? null + }) + } + await this.setActiveConversation( pickBindingKey(resolved, conversation), conversation.id @@ -973,7 +991,7 @@ export class ConversationService { async restoreConversation( session: Session, - options: ResolveConversationContextOptions & { + options: ResolveTargetConversationOptions & { archiveId?: string } = {} ) { @@ -1091,6 +1109,23 @@ export class ConversationService { throw new Error('Conversation restore failed.') } + if ( + options.allPresetLanes && + getLookupKeys( + session, + resolved.constraint.bindingKey, + true + ).includes( + getBaseBindingKey(updatedConversation.bindingKey) + ) + ) { + await this.updateManagedConstraint(session, { + activePresetLane: + getPresetLane(updatedConversation.bindingKey) ?? + null + }) + } + await this.setActiveConversation( updatedConversation.bindingKey, updatedConversation.id @@ -1213,9 +1248,12 @@ export class ConversationService { } ) + await removeArchive(this.ctx, current.archiveId) + const updated = await this.touchConversation(current.id, { status: 'deleted', - archivedAt: null + archivedAt: null, + archiveId: null }) await unbindConversation(this.ctx, current.id) await this.ctx.database.remove('chatluna_message', { @@ -1511,8 +1549,11 @@ export class ConversationService { if ( !( options.allPresetLanes === true && - getBaseBindingKey(conversation.bindingKey) === - getBaseBindingKey(resolved.bindingKey) + getLookupKeys( + session, + resolved.constraint.bindingKey, + true + ).includes(getBaseBindingKey(conversation.bindingKey)) ) && !(await hasConversationPermission( this.ctx, diff --git a/packages/core/src/utils/archive.ts b/packages/core/src/utils/archive.ts index 3639364e5..d1c48d8da 100644 --- a/packages/core/src/utils/archive.ts +++ b/packages/core/src/utils/archive.ts @@ -21,22 +21,7 @@ export async function purgeArchivedConversation( archiveId?: string | null } ) { - if (conversation.archiveId != null) { - const archive = await ctx.chatluna.conversation.getArchive( - conversation.archiveId - ) - - if (archive?.path) { - await fs.rm(archive.path, { - recursive: true, - force: true - }) - } - - await ctx.database.remove('chatluna_archive', { - id: conversation.archiveId - }) - } + await removeArchive(ctx, conversation.archiveId) await unbindConversation(ctx, conversation.id) await ctx.database.remove('chatluna_message', { @@ -50,6 +35,25 @@ export async function purgeArchivedConversation( }) } +export async function removeArchive(ctx: Context, archiveId?: string | null) { + if (archiveId == null) { + return + } + + const archive = await ctx.chatluna.conversation.getArchive(archiveId) + + if (archive?.path) { + await fs.rm(archive.path, { + recursive: true, + force: true + }) + } + + await ctx.database.remove('chatluna_archive', { + id: archiveId + }) +} + export async function unbindConversation(ctx: Context, conversationId: string) { const [active, last] = await Promise.all([ ctx.database.get('chatluna_binding', { diff --git a/packages/core/tests/conversation-service.spec.ts b/packages/core/tests/conversation-service.spec.ts index f30fd3f7d..69d9e1358 100644 --- a/packages/core/tests/conversation-service.spec.ts +++ b/packages/core/tests/conversation-service.spec.ts @@ -8,6 +8,7 @@ import type { ACLRecord, ArchiveRecord, BindingRecord, + ConversationRecord, ConstraintRecord } from '../src/services/conversation_types' import { gzipEncode } from '../src/utils/compression' @@ -348,6 +349,65 @@ it('ConversationService does not auto-restore archived conversation without mana ) }) +it('ConversationService clears archive data when deleting archived conversation', async () => { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-delete-archive-test-') + ) + const archiveDir = path.join(dir, 'archive-dir') + await fs.mkdir(archiveDir, { recursive: true }) + await fs.writeFile(path.join(archiveDir, 'manifest.json'), '{}', 'utf8') + + const conversation = createConversation({ + id: 'conversation-delete-archived', + status: 'archived', + archiveId: 'archive-delete', + archivedAt: new Date('2026-03-22T00:00:00.000Z') + }) + + const { service, database } = await createService({ + baseDir: dir, + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ], + chatluna_archive: [ + { + id: 'archive-delete', + conversationId: conversation.id, + path: archiveDir, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } as unknown as TableRow + ] + } + }) + + const deleted = await service.deleteConversation(createSession(), { + conversationId: conversation.id + }) + const stored = database.tables + .chatluna_conversation[0] as ConversationRecord + const binding = database.tables.chatluna_binding[0] as BindingRecord + + assert.equal(deleted.status, 'deleted') + assert.equal(deleted.archiveId, null) + assert.equal(stored.archiveId, null) + assert.equal(database.tables.chatluna_archive.length, 0) + assert.equal(binding.activeConversationId, null) + await expectRejected(fs.access(archiveDir)) +}) + it('ConversationService ensureActiveConversation respects personal default group route mode', async () => { const { service } = await createService({ config: { @@ -487,6 +547,7 @@ it('ConversationService lists and switches preset lanes across canonical and leg targetConversation: '2', allPresetLanes: true }) + const managed = await service.getManagedConstraint(session) const legacyBinding = database.tables.chatluna_binding.find( (item) => item.bindingKey === legacyBase ) as BindingRecord | undefined @@ -515,6 +576,7 @@ it('ConversationService lists and switches preset lanes across canonical and leg assert.equal(switched.id, 'conversation-chatgpt') assert.equal(legacyBinding?.activeConversationId, 'conversation-legacy') assert.equal(laneBinding?.activeConversationId, 'conversation-chatgpt') + assert.equal(managed?.activePresetLane, 'chatgpt') }) it('ConversationService allows exact id across preset lanes when allPresetLanes is enabled', async () => { @@ -545,6 +607,48 @@ it('ConversationService allows exact id across preset lanes when allPresetLanes assert.equal(resolved?.id, laneB.id) }) +it('ConversationService allows exact id across legacy and canonical route family', async () => { + const session = createSession({ + platform: 'onebot', + selfId: '1016049163', + guildId: '391122026', + channelId: '391122026' + }) + const legacy = createConversation({ + id: 'conversation-legacy-route', + bindingKey: 'shared:legacy:legacy:391122026' + }) + const canonical = createConversation({ + id: 'conversation-canonical-route', + bindingKey: 'shared:onebot:1016049163:391122026:preset:chatgpt' + }) + + const { service } = await createService({ + tables: { + chatluna_conversation: [ + legacy as unknown as TableRow, + canonical as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: legacy.bindingKey, + activeConversationId: legacy.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ] + } + }) + + const resolved = await service.resolveCommandConversation(session, { + conversationId: canonical.id, + allPresetLanes: true, + permission: 'manage' + }) + + assert.equal(resolved?.id, canonical.id) +}) + it('ConversationService keeps current lane binding untouched when switching across preset lanes', async () => { const laneA = createConversation({ id: 'conversation-switch-lane-a', @@ -588,6 +692,91 @@ it('ConversationService keeps current lane binding untouched when switching acro assert.equal(bindingB?.activeConversationId, laneB.id) }) +it('ConversationService syncs managed preset lane when reopening archived route-family conversation', async () => { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-reopen-lane-') + ) + const archivePath = path.join(dir, 'archive.json.gz') + const session = createSession({ + platform: 'onebot', + selfId: '1016049163', + guildId: '391122026', + channelId: '391122026' + }) + const legacy = createConversation({ + id: 'conversation-legacy-reopen', + bindingKey: 'shared:legacy:legacy:391122026' + }) + const archived = createConversation({ + id: 'conversation-archived-lane', + bindingKey: 'shared:onebot:1016049163:391122026:preset:chatgpt', + status: 'archived', + archiveId: 'archive-lane', + archivedAt: new Date('2026-03-25T00:00:00.000Z'), + latestMessageId: null + }) + await fs.writeFile( + archivePath, + await gzipEncode( + JSON.stringify({ + formatVersion: 1, + exportedAt: '2026-03-25T00:00:00.000Z', + conversation: { + ...archived, + status: 'active', + archiveId: null, + archivedAt: null, + createdAt: archived.createdAt.toISOString(), + updatedAt: archived.updatedAt.toISOString(), + lastChatAt: archived.lastChatAt?.toISOString() ?? null + }, + messages: [] + }) + ) + ) + + const { service } = await createService({ + baseDir: dir, + tables: { + chatluna_conversation: [ + legacy as unknown as TableRow, + archived as unknown as TableRow + ], + chatluna_binding: [ + { + bindingKey: legacy.bindingKey, + activeConversationId: legacy.id, + lastConversationId: null, + updatedAt: new Date() + } as unknown as TableRow + ], + chatluna_archive: [ + { + id: 'archive-lane', + conversationId: archived.id, + path: archivePath, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: (await fs.stat(archivePath)).size, + state: 'ready', + createdAt: new Date('2026-03-25T00:00:00.000Z'), + restoredAt: null + } as unknown as TableRow + ] + } + }) + + const reopened = await service.reopenConversation(session, { + conversationId: archived.id, + allPresetLanes: true + }) + const managed = await service.getManagedConstraint(session) + + assert.equal(reopened.status, 'active') + assert.equal(managed?.activePresetLane, 'chatgpt') +}) + it('ConversationService rejects ambiguous friendly conversation targets', async () => { const alpha = createConversation({ id: 'conversation-alpha', diff --git a/packages/core/tests/rollback-chat.spec.ts b/packages/core/tests/rollback-chat.spec.ts new file mode 100644 index 000000000..33804156f --- /dev/null +++ b/packages/core/tests/rollback-chat.spec.ts @@ -0,0 +1,105 @@ +/// + +import { assert } from 'chai' +import { ChainMiddlewareRunStatus } from '../src/chains/chain' +import { apply } from '../src/middlewares/chat/rollback_chat' +import { + createConversation, + createMemoryService, + createMessage, + createSession, + type TableRow +} from './helpers' + +it('rollback_chat keeps plain text rollback input as string and runs in sync lock', async () => { + const conversation = createConversation({ + id: 'conversation-rollback', + latestMessageId: 'message-rollback' + }) + const message = createMessage({ + id: 'message-rollback', + conversationId: conversation.id, + text: '123', + content: null + }) + const { app, ctx, database } = await createMemoryService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_message: [message as unknown as TableRow] + } + }) + + try { + const sent: string[] = [] + const syncCalls: string[] = [] + const session = createSession() as any + let run: + | (( + session: any, + context: any + ) => Promise) + | undefined + const withSync = + ctx.chatluna.conversationRuntime.withConversationSync.bind( + ctx.chatluna.conversationRuntime + ) + + ctx.chatluna.conversationRuntime.withConversationSync = async ( + current, + callback + ) => { + syncCalls.push(current.id) + return withSync(current, callback) + } + ctx.chatluna.messageTransformer.transform = async () => 'transformed' + session.text = (key, params) => + key === '.rollback_success' ? `${key}:${params?.[0]}` : key + session.send = async (msg) => { + sent.push(msg) + } + + apply( + ctx as never, + { + includeQuoteReply: false + } as never, + { + middleware: (_name, fn) => { + run = fn as never + return { + after() { + return this + }, + before() { + return this + } + } + } + } as never + ) + + const status = await run!(session, { + command: 'rollback', + message: '', + options: { + rollback_round: 1, + resolvedConversation: conversation + } + }) + + assert.equal(status, ChainMiddlewareRunStatus.CONTINUE) + assert.deepEqual(syncCalls, [conversation.id]) + assert.equal((await database.get('chatluna_message', {})).length, 0) + assert.equal( + ( + await database.get('chatluna_conversation', { + id: conversation.id + }) + )[0].latestMessageId, + null + ) + assert.deepEqual(sent, ['.rollback_success:1']) + } finally { + await app.stop() + } +}) From e1b366234a70fb42c26db54456ce3e47b2ae928b Mon Sep 17 00:00:00 2001 From: dingyi Date: Fri, 3 Apr 2026 10:32:50 +0800 Subject: [PATCH 20/20] fix(core): secure conversation restore and rollback targets Prevent explicit rollback and restore targets from falling back or bypassing route checks, and avoid overwriting manual titles. Add regression coverage for rollback failure paths and restore ACL checks. --- .../src/middlewares/chat/rollback_chat.ts | 23 ++- packages/core/src/services/conversation.ts | 32 ++-- .../core/tests/conversation-archive.spec.ts | 76 ++++++++ packages/core/tests/conversation-e2e.spec.ts | 1 + packages/core/tests/rollback-chat.spec.ts | 164 ++++++++++++++++++ 5 files changed, 265 insertions(+), 31 deletions(-) diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 6064f656e..2b50bde5d 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -46,7 +46,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } ) - if (conversation == null) { + if ( + conversation == null && + context.options.conversationId == null && + context.options.targetConversation == null && + context.options.resolvedConversation == null + ) { conversation = ( await ctx.chatluna.conversation.getCurrentConversation( session @@ -216,14 +221,6 @@ async function rollbackConversation( } } - await ctx.database.upsert('chatluna_conversation', [ - { - id: current.id, - latestMessageId: humanMessage.parentId ?? null, - updatedAt: new Date() - } - ]) - let inputMessage = context.options.inputMessage if ((context.options.message?.length ?? 0) < 1) { @@ -245,6 +242,14 @@ async function rollbackConversation( id: messages.map((message) => message.id) }) + await ctx.database.upsert('chatluna_conversation', [ + { + id: current.id, + latestMessageId: humanMessage.parentId ?? null, + updatedAt: new Date() + } + ]) + return { status: ChainMiddlewareRunStatus.CONTINUE, inputMessage diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index feaa9fc1a..ded80a602 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -995,15 +995,12 @@ export class ConversationService { archiveId?: string } = {} ) { - const resolved = await this.resolveContext(session, options) - const conversation = options.conversationId - ? ((await this.getConversation(options.conversationId)) ?? - resolved.conversation) - : resolved.conversation - - if (conversation == null) { - throw new Error('Conversation not found.') - } + const { resolved, conversation, managed } = await this.getTarget( + session, + options, + 'manage', + true + ) const archive = options.archiveId ? await this.getArchive(options.archiveId) @@ -1019,21 +1016,11 @@ export class ConversationService { throw new Error('Archive does not belong to conversation.') } - await assertManageAllowed(session, resolved.constraint) - - const target = await this.getManagedConstraintByBindingKey( - conversation.bindingKey - ) - - if (target != null) { - await assertManageAllowed(session, target) - } - - if (target?.lockConversation ?? resolved.constraint.lockConversation) { + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { throw new Error('Conversation restore is locked by constraint.') } - if (!(target?.allowArchive ?? resolved.constraint.allowArchive)) { + if (!(managed?.allowArchive ?? resolved.constraint.allowArchive)) { throw new Error('Conversation restore is disabled by constraint.') } @@ -1214,7 +1201,8 @@ export class ConversationService { } const updated = await this.touchConversation(conversation.id, { - title: options.title.trim() + title: options.title.trim(), + autoTitle: false }) return updated! } diff --git a/packages/core/tests/conversation-archive.spec.ts b/packages/core/tests/conversation-archive.spec.ts index 9ca25efa2..1bd24158d 100644 --- a/packages/core/tests/conversation-archive.spec.ts +++ b/packages/core/tests/conversation-archive.spec.ts @@ -5,6 +5,7 @@ import os from 'node:os' import path from 'node:path' import { assert } from 'chai' import { purgeArchivedConversation } from '../src/utils/archive' +import { gzipEncode } from '../src/utils/compression' import type { ArchiveRecord, BindingRecord, @@ -150,6 +151,81 @@ it('ConversationService rejects restoring archives from another conversation', a ) }) +it('ConversationService rejects restoring a foreign conversation by exact id', async () => { + const dir = await fs.mkdtemp( + path.join(os.tmpdir(), 'chatluna-foreign-restore-') + ) + const conversation = createConversation({ + id: 'conversation-foreign-restore', + bindingKey: 'shared:discord:bot:other-guild', + status: 'archived', + archiveId: 'archive-foreign-restore', + archivedAt: new Date('2026-03-22T00:00:00.000Z') + }) + const archivePath = path.join(dir, 'archive-foreign-restore.json.gz') + + await fs.writeFile( + archivePath, + await gzipEncode( + JSON.stringify({ + formatVersion: 1, + exportedAt: '2026-03-22T00:00:00.000Z', + conversation: { + ...conversation, + status: 'active', + archiveId: null, + archivedAt: null, + createdAt: conversation.createdAt.toISOString(), + updatedAt: conversation.updatedAt.toISOString(), + lastChatAt: conversation.lastChatAt?.toISOString() ?? null + }, + messages: [] + }) + ) + ) + + const { service } = await createService({ + baseDir: dir, + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_constraint: [ + { + id: 1, + name: 'allow-manage-current-route', + enabled: true, + priority: 10, + createdBy: 'admin', + createdAt: new Date(), + updatedAt: new Date(), + guildId: 'guild', + manageMode: 'anyone' + } as unknown as TableRow + ], + chatluna_archive: [ + { + id: conversation.archiveId, + conversationId: conversation.id, + path: archivePath, + formatVersion: 1, + messageCount: 0, + checksum: null, + size: 1, + state: 'ready', + createdAt: new Date(), + restoredAt: null + } as unknown as TableRow + ] + } + }) + + await expectRejected( + service.restoreConversation(createSession({ authority: 1 }), { + conversationId: conversation.id + }), + /Conversation does not belong to current route\./ + ) +}) + it('purgeArchivedConversation removes archive directory and clears both binding pointers', async () => { const dir = await fs.mkdtemp( path.join(os.tmpdir(), 'chatluna-purge-archive-') diff --git a/packages/core/tests/conversation-e2e.spec.ts b/packages/core/tests/conversation-e2e.spec.ts index 8851d2740..dd90112d9 100644 --- a/packages/core/tests/conversation-e2e.spec.ts +++ b/packages/core/tests/conversation-e2e.spec.ts @@ -47,6 +47,7 @@ it('ConversationService supports sampled end-to-end lifecycle flow', async () => assert.equal(listed.length, 1) assert.equal(renamed.title, 'Helper Session') + assert.equal(renamed.autoTitle, false) assert.equal(path.extname(exported.path), '.md') assert.equal(archived.conversation.status, 'archived') assert.equal(restored.status, 'active') diff --git a/packages/core/tests/rollback-chat.spec.ts b/packages/core/tests/rollback-chat.spec.ts index 33804156f..63e922828 100644 --- a/packages/core/tests/rollback-chat.spec.ts +++ b/packages/core/tests/rollback-chat.spec.ts @@ -8,6 +8,7 @@ import { createMemoryService, createMessage, createSession, + expectRejected, type TableRow } from './helpers' @@ -103,3 +104,166 @@ it('rollback_chat keeps plain text rollback input as string and runs in sync loc await app.stop() } }) + +it('rollback_chat does not fall back to current conversation for an explicit missing target', async () => { + const conversation = createConversation({ + id: 'conversation-current', + latestMessageId: 'message-current' + }) + const message = createMessage({ + id: 'message-current', + conversationId: conversation.id, + text: 'hello current', + content: null + }) + const { app, database, ctx } = await createMemoryService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_binding: [ + { + bindingKey: conversation.bindingKey, + activeConversationId: conversation.id, + lastConversationId: null, + updatedAt: new Date() + } as TableRow + ], + chatluna_message: [message as unknown as TableRow] + } + }) + + try { + const session = createSession() as any + let run: + | (( + session: any, + context: any + ) => Promise) + | undefined + + session.text = (key) => key + session.send = async () => {} + + apply( + ctx as never, + { + includeQuoteReply: false + } as never, + { + middleware: (_name, fn) => { + run = fn as never + return { + after() { + return this + }, + before() { + return this + } + } + } + } as never + ) + + const state = { + command: 'rollback', + message: '', + options: { + rollback_round: 1, + conversationId: 'missing-conversation' + } + } + const status = await run!(session, state) + + assert.equal(status, ChainMiddlewareRunStatus.STOP) + assert.equal(state.message, '.conversation_not_exist') + assert.equal((await database.get('chatluna_message', {})).length, 1) + assert.equal( + ( + await database.get('chatluna_conversation', { + id: conversation.id + }) + )[0].latestMessageId, + 'message-current' + ) + } finally { + await app.stop() + } +}) + +it('rollback_chat keeps history untouched when rebuilding the input fails', async () => { + const conversation = createConversation({ + id: 'conversation-transform-failure', + latestMessageId: 'message-transform-failure' + }) + const message = createMessage({ + id: 'message-transform-failure', + conversationId: conversation.id, + text: 'fail me', + content: null + }) + const { app, database, ctx } = await createMemoryService({ + tables: { + chatluna_conversation: [conversation as unknown as TableRow], + chatluna_message: [message as unknown as TableRow] + } + }) + + try { + const session = createSession() as any + let run: + | (( + session: any, + context: any + ) => Promise) + | undefined + + ctx.chatluna.messageTransformer.transform = async () => { + throw new Error('transform failed') + } + session.text = (key) => key + session.send = async () => {} + + apply( + ctx as never, + { + includeQuoteReply: false + } as never, + { + middleware: (_name, fn) => { + run = fn as never + return { + after() { + return this + }, + before() { + return this + } + } + } + } as never + ) + + await expectRejected( + run!(session, { + command: 'rollback', + message: '', + options: { + rollback_round: 1, + resolvedConversation: conversation + } + }), + /transform failed/ + ) + + assert.equal((await database.get('chatluna_message', {})).length, 1) + assert.equal( + ( + await database.get('chatluna_conversation', { + id: conversation.id + }) + )[0].latestMessageId, + 'message-transform-failure' + ) + } finally { + await app.stop() + } +})