diff --git a/packages/core/src/middlewares/conversation/resolve_conversation.ts b/packages/core/src/middlewares/conversation/resolve_conversation.ts index 648e6d3d9..99b361d53 100644 --- a/packages/core/src/middlewares/conversation/resolve_conversation.ts +++ b/packages/core/src/middlewares/conversation/resolve_conversation.ts @@ -10,22 +10,24 @@ import { ConversationResolutionError } from '../../services/conversation_types' export function apply(ctx: Context, config: Config, chain: ChatChain) { chain .middleware('resolve_conversation', async (session, context) => { - const presetLane = getPresetLane(context) - const targetConversation = getTargetConversation(context) - const explicitConversationId = getExplicitConversationId(context) + const { options } = context + const presetLane = + options.conversation_manage?.presetLane ?? options.presetLane + const targetConversation = + options.conversation_manage?.targetConversation ?? + options.targetConversation + const explicitConversationId = + options.conversation?.conversationId ?? + options.conversation?.conversation?.id const hasExplicitTarget = targetConversation != null || explicitConversationId != null - const conversationId = getConversationId( - targetConversation != null, - explicitConversationId - ) + const conversationId = + targetConversation != null ? undefined : explicitConversationId const targetValue = targetConversation ?? explicitConversationId ?? conversationId - const includeArchived = - context.options.conversation_manage?.includeArchived - const useRoutePresetLane = presetLane == null && !hasExplicitTarget + const includeArchived = options.conversation_manage?.includeArchived - context.options.presetLane = presetLane + options.presetLane = presetLane try { const resolved = @@ -37,16 +39,17 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { targetConversation, presetLane, includeArchived, - allPresetLanes: context.options.allPresetLanes, - useRoutePresetLane + allPresetLanes: options.allPresetLanes, + useRoutePresetLane: + presetLane == null && !hasExplicitTarget } ) if (hasExplicitTarget && resolved.conversation == null) { context.message = targetValue == null - ? getNotFoundMessage(session, context) - : await getTargetNotFoundMessage( + ? notFoundMessage(session, context) + : await targetNotFoundMessage( ctx, session, context, @@ -57,30 +60,17 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP } - context.options.conversation = resolved - + options.conversation = resolved return ChainMiddlewareRunStatus.CONTINUE } catch (error) { - if ( - error instanceof ConversationResolutionError && - error.code === 'ambiguous_target' - ) { - context.message = session.text( - 'chatluna.conversation.messages.target_ambiguous' - ) - return ChainMiddlewareRunStatus.STOP - } - - if ( - error instanceof ConversationResolutionError && - error.code === 'target_outside_route' - ) { + if (error instanceof ConversationResolutionError) { context.message = session.text( - 'chatluna.conversation.messages.target_outside_route' + error.code === 'ambiguous_target' + ? 'chatluna.conversation.messages.target_ambiguous' + : 'chatluna.conversation.messages.target_outside_route' ) return ChainMiddlewareRunStatus.STOP } - throw error } }) @@ -99,39 +89,7 @@ declare module '../../chains/chain' { } } -function getPresetLane(context: ChainMiddlewareContext) { - return ( - context.options.conversation_manage?.presetLane ?? - context.options.presetLane - ) -} - -function getTargetConversation(context: ChainMiddlewareContext) { - return ( - context.options.conversation_manage?.targetConversation ?? - context.options.targetConversation - ) -} - -function getConversationId( - hasTargetConversation: boolean, - explicitConversationId?: string -) { - if (hasTargetConversation) { - return undefined - } - - return explicitConversationId -} - -function getExplicitConversationId(context: ChainMiddlewareContext) { - return ( - context.options.conversation?.conversationId ?? - context.options.conversation?.conversation?.id - ) -} - -function getNotFoundMessage( +function notFoundMessage( session: ChainMiddlewareContext['session'], context: ChainMiddlewareContext ) { @@ -140,29 +98,24 @@ function getNotFoundMessage( : session.text('commands.chatluna.chat.messages.conversation_not_exist') } -function getTargetSuffixKey(context: ChainMiddlewareContext) { - const key = { - conversation_switch: 'commands.chatluna.switch.arguments.conversation', - conversation_archive: - 'commands.chatluna.archive.arguments.conversation', - conversation_restore: - 'commands.chatluna.restore.arguments.conversation', - conversation_export: 'commands.chatluna.export.arguments.conversation', - conversation_compress: - 'commands.chatluna.compress.arguments.conversation', - conversation_delete: 'commands.chatluna.delete.arguments.conversation' - }[context.command ?? ''] - - if (key != null) { - return key - } +const TARGET_SUFFIX_BY_COMMAND: Record = { + conversation_switch: 'commands.chatluna.switch.arguments.conversation', + conversation_archive: 'commands.chatluna.archive.arguments.conversation', + conversation_restore: 'commands.chatluna.restore.arguments.conversation', + conversation_export: 'commands.chatluna.export.arguments.conversation', + conversation_compress: 'commands.chatluna.compress.arguments.conversation', + conversation_delete: 'commands.chatluna.delete.arguments.conversation' +} +function targetSuffixKey(context: ChainMiddlewareContext) { + const exact = TARGET_SUFFIX_BY_COMMAND[context.command ?? ''] + if (exact != null) return exact return context.command?.startsWith('conversation_') ? 'commands.chatluna.conversation.options.conversation' : 'commands.chatluna.chat.text.options.conversation' } -async function getTargetNotFoundMessage( +async function targetNotFoundMessage( ctx: Context, session: ChainMiddlewareContext['session'], context: ChainMiddlewareContext, @@ -189,12 +142,12 @@ async function getTargetNotFoundMessage( ).filter((item) => item.length > 0) if (expect.length === 0 || typeof session.suggest !== 'function') { - return getNotFoundMessage(session, context) + return notFoundMessage(session, context) } return session.suggest({ actual: targetValue, expect, - suffix: session.text(getTargetSuffixKey(context)) + suffix: session.text(targetSuffixKey(context)) }) } diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index d9df2faba..f0246cafc 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -6,11 +6,17 @@ import { } from '../../chains/chain' import { Config } from '../../config' import { + AdminRequiredError, + ConstraintDisabledError, + ConstraintFixedError, + ConstraintLockedError, ConversationListEntry, + ConversationNotFoundError, ConversationRecord, ConversationResolutionError, getBaseBindingKey, getPresetLane, + InvalidChatModeError, ResolvedConversationContext } from '../../services/conversation_types' import { Pagination } from 'koishi-plugin-chatluna/utils/pagination' @@ -41,7 +47,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_new', async (session, context) => { const presetLane = context.options.presetLane const resolved = context.options.conversation - const createPreset = context.options.conversation_create?.preset + const create = context.options.conversation_create if (resolved == null) { context.message = session.text( @@ -65,56 +71,30 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { resolved.constraint.lockConversation && resolved.binding?.activeConversationId != null ) { - context.message = session.text( - 'chatluna.conversation.messages.action_locked', - [session.text('chatluna.conversation.action.create')] - ) + context.message = actionLocked(session, 'create') 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 ( - createPreset != null && - resolved.constraint.fixedPreset != null && - createPreset !== resolved.constraint.fixedPreset - ) { - context.message = session.text( - 'chatluna.conversation.messages.fixed_preset', - [resolved.constraint.fixedPreset] - ) - 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 + for (const field of ['model', 'preset', 'chatMode'] as const) { + const value = create?.[field] + const fixedKey = + field === 'model' + ? 'fixedModel' + : field === 'preset' + ? 'fixedPreset' + : 'fixedChatMode' + const fixed = resolved.constraint[fixedKey] + if (value != null && fixed != null && value !== fixed) { + context.message = session.text( + `chatluna.conversation.messages.${FIXED_FIELD_MSG_KEY[field]}`, + [fixed] + ) + return ChainMiddlewareRunStatus.STOP + } } if (!resolved.constraint.allowNew) { - context.message = session.text( - 'chatluna.conversation.messages.action_disabled', - [session.text('chatluna.conversation.action.create')] - ) + context.message = actionDisabled(session, 'create') return ChainMiddlewareRunStatus.STOP } @@ -123,22 +103,22 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { { bindingKey: resolved.bindingKey, title: - context.options.conversation_create?.title ?? + create?.title ?? presetLane ?? session.text('chatluna.conversation.default_title'), model: - createModel ?? + create?.model ?? ctx.chatluna.conversation.pickModel( resolved.constraint, resolved.conversation ) ?? config.defaultModel, preset: - createPreset ?? + create?.preset ?? resolved.effectivePreset ?? config.defaultPreset, chatMode: - createChatMode ?? + create?.chatMode ?? resolved.effectiveChatMode ?? config.defaultChatMode } @@ -146,20 +126,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.message = session.text( 'chatluna.conversation.messages.new_success', - [ - conversation.title, - conversation.seq ?? conversation.id, - conversation.id - ] + conversationSummary(conversation) ) return ChainMiddlewareRunStatus.STOP }) middleware('conversation_switch', async (session, context) => { const presetLane = context.options.conversation_manage?.presetLane - const resolved = context.options.conversation - const conversationId = - resolved?.conversationId ?? resolved?.conversation?.id + const conversationId = resolvedConversationId(context) if (conversationId == null) { context.message = session.text( @@ -178,11 +152,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.message = session.text( 'chatluna.conversation.messages.switch_success', - [ - conversation.title, - conversation.seq ?? conversation.id, - conversation.id - ] + conversationSummary(conversation) ) } catch (error) { context.message = session.text( @@ -197,9 +167,8 @@ 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 { presetLane, includeArchived, allPresetLanes } = + getManageOptions(context) const resolved = context.options.conversation if (resolved == null) { @@ -212,7 +181,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const conversations = await ctx.chatluna.conversation.listConversationEntries(session, { presetLane, - allPresetLanes: presetLane == null, + allPresetLanes, includeArchived }) @@ -244,7 +213,11 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } }) - const key = `${presetLane == null ? getBaseBindingKey(resolved.bindingKey) : resolved.bindingKey}:${includeArchived ? 'all' : 'active'}` + const scope = + presetLane == null + ? getBaseBindingKey(resolved.bindingKey) + : resolved.bindingKey + const key = `${scope}:${includeArchived === true ? 'all' : 'active'}` await pagination.push(conversations, key) context.message = await pagination.getFormattedPage(page, limit, key) return ChainMiddlewareRunStatus.STOP @@ -253,14 +226,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_current', async (session, context) => { const resolved = context.options.conversation - if (resolved == null) { - context.message = session.text( - 'chatluna.conversation.messages.current_empty' - ) - return ChainMiddlewareRunStatus.STOP - } - - if (resolved.conversation == null) { + if (resolved == null || resolved.conversation == null) { context.message = session.text( 'chatluna.conversation.messages.current_empty' ) @@ -291,20 +257,14 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { try { const conversation = await ctx.chatluna.conversation.renameConversation(session, { - conversationId: - context.options.conversation?.conversationId ?? - context.options.conversation?.conversation?.id, + conversationId: resolvedConversationId(context), presetLane: context.options.conversation_manage?.presetLane, title }) context.message = session.text( 'chatluna.conversation.messages.rename_success', - [ - conversation.title, - conversation.seq ?? conversation.id, - conversation.id - ] + conversationSummary(conversation) ) } catch (error) { context.message = session.text( @@ -318,26 +278,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { middleware('conversation_delete', async (session, context) => { try { - const presetLane = context.options.conversation_manage?.presetLane - const includeArchived = - context.options.conversation_manage?.includeArchived === true + const { presetLane, includeArchived, allPresetLanes } = + getManageOptions(context) const conversation = await ctx.chatluna.conversation.deleteConversation(session, { - conversationId: - context.options.conversation?.conversationId ?? - context.options.conversation?.conversation?.id, + conversationId: resolvedConversationId(context), presetLane, - includeArchived: includeArchived || undefined, - allPresetLanes: presetLane == null + includeArchived, + allPresetLanes }) context.message = session.text( 'chatluna.conversation.messages.delete_success', - [ - conversation.title, - conversation.seq ?? conversation.id, - conversation.id - ] + conversationSummary(conversation) ) } catch (error) { context.message = session.text( @@ -349,60 +302,27 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { 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) => { + for (const { cmd, field, successKey, failKey } of USE_FIELDS) { + middleware(cmd, async (session, context) => { try { const conversation = await ctx.chatluna.conversation.updateConversationUsage( session, { - conversationId: - context.options.conversation?.conversationId ?? - context.options.conversation?.conversation?.id, + conversationId: resolvedConversationId(context), presetLane: context.options.conversation_manage?.presetLane, - [fieldMap.optKey]: - context.options.conversation_use?.[ - fieldMap.optKey - ] + [field]: context.options.conversation_use?.[field] } ) context.message = session.text( - `chatluna.conversation.messages.${fieldMap.successKey}`, - [ - conversation[fieldMap.recordKey], - conversation.title, - conversation.id - ] + `chatluna.conversation.messages.${successKey}`, + [conversation[field], conversation.title, conversation.id] ) } catch (error) { context.message = session.text( - `chatluna.conversation.messages.${fieldMap.failKey}`, + `chatluna.conversation.messages.${failKey}`, [formatConversationError(session, error, 'update')] ) } @@ -427,27 +347,21 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } try { - const presetLane = context.options.conversation_manage?.presetLane - const includeArchived = - context.options.conversation_manage?.includeArchived === true + const { presetLane, includeArchived, allPresetLanes } = + getManageOptions(context) const result = await ctx.chatluna.conversation.archiveConversation( session, { conversationId: conversation.id, presetLane, - includeArchived: includeArchived || undefined, - allPresetLanes: presetLane == null + includeArchived, + allPresetLanes } ) context.message = session.text( 'chatluna.conversation.messages.archive_success', - [ - result.conversation.title, - result.conversation.seq ?? result.conversation.id, - result.conversation.id, - result.archive.id - ] + [...conversationSummary(result.conversation), result.archive.id] ) } catch (error) { context.message = session.text( @@ -475,24 +389,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } try { - const presetLane = context.options.conversation_manage?.presetLane - const includeArchived = - context.options.conversation_manage?.includeArchived === true + const { presetLane, includeArchived, allPresetLanes } = + getManageOptions(context) const conversation = await ctx.chatluna.conversation.reopenConversation(session, { conversationId: current.id, presetLane, - allPresetLanes: presetLane == null, - includeArchived: includeArchived || undefined + allPresetLanes, + includeArchived }) context.message = session.text( 'chatluna.conversation.messages.restore_success', - [ - conversation.title, - conversation.seq ?? conversation.id, - conversation.id - ] + conversationSummary(conversation) ) } catch (error) { context.message = session.text( @@ -520,16 +429,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } try { - const presetLane = context.options.conversation_manage?.presetLane - const includeArchived = - context.options.conversation_manage?.includeArchived === true + const { presetLane, includeArchived, allPresetLanes } = + getManageOptions(context) const result = await ctx.chatluna.conversation.exportConversation( session, { conversationId: conversation.id, presetLane, - allPresetLanes: presetLane == null, - includeArchived: includeArchived || undefined + allPresetLanes, + includeArchived } ) @@ -547,9 +455,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.message = session.text( 'chatluna.conversation.messages.export_success', [ - result.conversation.title, - result.conversation.seq ?? result.conversation.id, - result.conversation.id, + ...conversationSummary(result.conversation), result.path, result.size ] @@ -564,32 +470,26 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { return ChainMiddlewareRunStatus.STOP }) - 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_status' - }, - mode: { - cmd: 'conversation_rule_mode' as const, - optKey: 'chatMode' as const, - defaultKey: 'defaultChatMode' as const, - constraintKey: 'fixedChatMode' as const, - msgKey: 'rule_mode_status' - } - }[ruleField] - - middleware(ruleMap.cmd, async (session, context) => { - const value = context.options.conversation_rule?.[ruleMap.optKey] + for (const { + cmd, + field, + defaultKey, + constraintKey, + msgKey + } of RULE_FIELDS) { + middleware(cmd, async (session, context) => { + const value = context.options.conversation_rule?.[field] const clear = context.options.conversation_rule?.clear === true || value === 'reset' const force = context.options.conversation_rule?.force === true try { + const patch = clear + ? { [defaultKey]: null, [constraintKey]: null } + : force + ? { [constraintKey]: value } + : { [defaultKey]: value } const record = value == null && !clear ? await ctx.chatluna.conversation.getManagedConstraint( @@ -597,33 +497,18 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) : await ctx.chatluna.conversation.updateManagedConstraint( session, - clear - ? { - [ruleMap.defaultKey]: null, - [ruleMap.constraintKey]: null - } - : force - ? { - [ruleMap.constraintKey]: value - } - : { - [ruleMap.defaultKey]: value - } + patch ) context.message = session.text( - `chatluna.conversation.messages.${ruleMap.msgKey}`, + `chatluna.conversation.messages.${msgKey}`, [ - record?.[ruleMap.defaultKey] ?? 'reset', - record?.[ruleMap.constraintKey] ?? 'reset' + record?.[defaultKey] ?? 'reset', + record?.[constraintKey] ?? 'reset' ] ) } catch (error) { - context.message = formatConversationError( - session, - error, - ruleField - ) + context.message = formatConversationError(session, error, field) } return ChainMiddlewareRunStatus.STOP @@ -638,6 +523,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const newOnly = context.options.conversation_rule?.newOnly === true try { + const patch = clear + ? { + activePresetLane: null, + defaultPreset: null, + fixedPreset: null + } + : newOnly + ? { defaultPreset: value, fixedPreset: null } + : { activePresetLane: value, fixedPreset: null } const record = value == null && !clear ? await ctx.chatluna.conversation.getManagedConstraint( @@ -645,21 +539,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { ) : await ctx.chatluna.conversation.updateManagedConstraint( session, - clear - ? { - activePresetLane: null, - defaultPreset: null, - fixedPreset: null - } - : newOnly - ? { - defaultPreset: value, - fixedPreset: null - } - : { - activePresetLane: value, - fixedPreset: null - } + patch ) context.message = session.text( @@ -714,14 +594,12 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { const current = await ctx.chatluna.conversation.updateManagedConstraint( session, - { - routeMode - } + { routeMode } ) - const nextRouteMode = current.routeMode - ? current.routeMode - : (await ctx.chatluna.conversation.resolveConstraint(session)) - .routeMode + const nextRouteMode = + current.routeMode ?? + (await ctx.chatluna.conversation.resolveConstraint(session)) + .routeMode context.message = session.text( 'chatluna.conversation.messages.rule_share_status', @@ -735,20 +613,8 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) 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) - lock = !(current?.lockConversation === true) - } else { + const lock = parseLockOption(context.options.conversation_rule?.lock) + if (lock === undefined) { context.message = session.text( 'chatluna.conversation.messages.rule_lock_failed', [session.text('chatluna.conversation.messages.rule_lock_hint')] @@ -757,10 +623,20 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } try { + const next = + lock === 'toggle' + ? !( + ( + await ctx.chatluna.conversation.getManagedConstraint( + session + ) + )?.lockConversation === true + ) + : lock const record = await ctx.chatluna.conversation.updateManagedConstraint( session, - { lockConversation: lock } + { lockConversation: next } ) context.message = session.text( @@ -838,9 +714,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { context.message = session.text(`${key}.failed`, [ conversation.title, conversation.id, - session.text('chatluna.conversation.messages.action_locked', [ - session.text('chatluna.conversation.action.compress') - ]) + actionLocked(session, 'compress') ]) return ChainMiddlewareRunStatus.STOP } @@ -873,6 +747,54 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) } +function resolvedConversationId(context: ChainMiddlewareContext) { + return ( + context.options.conversation?.conversationId ?? + context.options.conversation?.conversation?.id + ) +} + +function getManageOptions(context: ChainMiddlewareContext) { + const presetLane = context.options.conversation_manage?.presetLane + const includeArchived = + context.options.conversation_manage?.includeArchived === true + return { + presetLane, + includeArchived: includeArchived || undefined, + allPresetLanes: presetLane == null + } +} + +function conversationSummary(conversation: ConversationRecord) { + return [ + conversation.title, + conversation.seq ?? conversation.id, + conversation.id + ] +} + +function actionLocked(session: Session, action: string) { + return session.text('chatluna.conversation.messages.action_locked', [ + session.text(`chatluna.conversation.action.${action}`) + ]) +} + +function actionDisabled(session: Session, action: string) { + return session.text('chatluna.conversation.messages.action_disabled', [ + session.text(`chatluna.conversation.action.${action}`) + ]) +} + +function parseLockOption( + raw: string | undefined +): boolean | null | 'toggle' | undefined { + if (raw === 'reset') return null + if (raw === 'true' || raw === 'on' || raw === 'lock') return true + if (raw === 'false' || raw === 'off' || raw === 'unlock') return false + if (raw === 'toggle') return 'toggle' + return undefined +} + function formatConversationStatus( session: Session, conversation: ConversationRecord, @@ -901,19 +823,15 @@ function formatRouteScope(bindingKey: string) { 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}` } @@ -922,77 +840,41 @@ function formatConversationError( error: Error, action?: string ) { - if ( - error instanceof ConversationResolutionError && - error.code === 'ambiguous_target' - ) { - return session.text('chatluna.conversation.messages.target_ambiguous') - } - - if ( - error instanceof ConversationResolutionError && - error.code === 'target_outside_route' - ) { + if (error instanceof ConversationResolutionError) { return session.text( - 'chatluna.conversation.messages.target_outside_route' + error.code === 'ambiguous_target' + ? 'chatluna.conversation.messages.target_ambiguous' + : 'chatluna.conversation.messages.target_outside_route' ) } - if (error.message === 'Conversation not found.') { + if (error instanceof ConversationNotFoundError) { return session.text('chatluna.conversation.messages.target_not_found') } - if ( - error.message === - 'Conversation management requires administrator permission.' - ) { + if (error instanceof AdminRequiredError) { 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] - ]) + if (error instanceof ConstraintLockedError) { + return actionLocked(session, error.action) } - const fixedPreset = error.message.match(/^Preset is fixed to (.+)\.$/) - if (fixedPreset) { - return session.text('chatluna.conversation.messages.fixed_preset', [ - fixedPreset[1] - ]) + if (error instanceof ConstraintDisabledError) { + return actionDisabled(session, error.action) } - const fixedMode = error.message.match(/^Chat mode is fixed to (.+)\.$/) - if (fixedMode) { - return session.text('chatluna.conversation.messages.fixed_chat_mode', [ - fixedMode[1] - ]) + if (error instanceof ConstraintFixedError) { + return session.text( + `chatluna.conversation.messages.${FIXED_FIELD_MSG_KEY[error.field]}`, + [error.value] + ) } - const invalidMode = error.message.match(/^Chat mode (.+) not found\.$/) - if (invalidMode) { + if (error instanceof InvalidChatModeError) { return session.text( 'chatluna.conversation.messages.invalid_chat_mode', - [invalidMode[1]] + [error.mode] ) } @@ -1073,6 +955,50 @@ function formatPresetLane(session: Session, presetLane?: string | null) { : presetLane } +const USE_FIELDS = [ + { + cmd: 'conversation_use_model' as const, + field: 'model' as const, + successKey: 'use_model_success', + failKey: 'use_model_failed' + }, + { + cmd: 'conversation_use_preset' as const, + field: 'preset' as const, + successKey: 'use_preset_success', + failKey: 'use_preset_failed' + }, + { + cmd: 'conversation_use_mode' as const, + field: 'chatMode' as const, + successKey: 'use_mode_success', + failKey: 'use_mode_failed' + } +] as const + +const RULE_FIELDS = [ + { + cmd: 'conversation_rule_model' as const, + field: 'model' as const, + defaultKey: 'defaultModel' as const, + constraintKey: 'fixedModel' as const, + msgKey: 'rule_model_status' + }, + { + cmd: 'conversation_rule_mode' as const, + field: 'chatMode' as const, + defaultKey: 'defaultChatMode' as const, + constraintKey: 'fixedChatMode' as const, + msgKey: 'rule_mode_status' + } +] as const + +const FIXED_FIELD_MSG_KEY = { + model: 'fixed_model', + preset: 'fixed_preset', + chatMode: 'fixed_chat_mode' +} as const + declare module '../../chains/chain' { interface ChainMiddlewareName { conversation_new: never diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 2e89e90dd..0f231193e 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -34,19 +34,27 @@ import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' import { ObjectLock } from 'koishi-plugin-chatluna/utils/lock' import { ACLRecord, + AdminRequiredError, applyPresetLane, ArchiveRecord, BindingRecord, computeBaseBindingKey, + ConstraintAction, + ConstraintDisabledError, + ConstraintFixedError, + ConstraintFixedField, + ConstraintLockedError, ConstraintPermission, ConstraintRecord, ConversationCompressionRecord, ConversationListEntry, + ConversationNotFoundError, ConversationRecord, ConversationResolution, ConversationResolutionError, getBaseBindingKey, getPresetLane, + InvalidChatModeError, MessageRecord, ResolveConversationOptions, ResolvedConstraint, @@ -60,6 +68,21 @@ import { } from './types' import type { ConversationRuntime } from './conversation_runtime' +const EMPTY_MODEL_NAMES = new Set(['', '无', 'empty']) + +const FIXED_FIELDS: readonly { + key: 'model' | 'preset' | 'chatMode' + constraintKey: keyof Pick< + ConstraintRecord, + 'fixedModel' | 'fixedPreset' | 'fixedChatMode' + > + label: ConstraintFixedField +}[] = [ + { key: 'model', constraintKey: 'fixedModel', label: 'model' }, + { key: 'preset', constraintKey: 'fixedPreset', label: 'preset' }, + { key: 'chatMode', constraintKey: 'fixedChatMode', label: 'chatMode' } +] + export class ConversationService { private readonly _bindingLocks = new Map() private readonly _titleLocks = new Map() @@ -72,27 +95,27 @@ export class ConversationService { ) {} async getConversation(id: string) { - return ( - await this.ctx.database.get('chatluna_conversation', { id }) - )[0] as ConversationRecord | undefined + return this.firstRow('chatluna_conversation', { id }) as Promise< + ConversationRecord | undefined + > } async getBinding(bindingKey: string) { - return ( - await this.ctx.database.get('chatluna_binding', { bindingKey }) - )[0] as BindingRecord | undefined + return this.firstRow('chatluna_binding', { bindingKey }) as Promise< + BindingRecord | undefined + > } async getArchive(id: string) { - return (await this.ctx.database.get('chatluna_archive', { id }))[0] as - | ArchiveRecord - | undefined + return this.firstRow('chatluna_archive', { id }) as Promise< + ArchiveRecord | undefined + > } async getArchiveByConversationId(conversationId: string) { - return ( - await this.ctx.database.get('chatluna_archive', { conversationId }) - )[0] as ArchiveRecord | undefined + return this.firstRow('chatluna_archive', { conversationId }) as Promise< + ArchiveRecord | undefined + > } async listConstraints() { @@ -120,12 +143,11 @@ export class ConversationService { routeMode, routed?.routeKey ) - let bindingKey = - options.bindingKey == null - ? applyPresetLane(baseKey, options.presetLane) - : options.bindingKey.includes(':preset:') - ? options.bindingKey - : applyPresetLane(options.bindingKey, options.presetLane) + let bindingKey = this.normalizeBindingKey( + baseKey, + options.bindingKey, + options.presetLane + ) if (options.bindingKey != null) { constraints = constraints.filter( @@ -193,6 +215,20 @@ export class ConversationService { } } + private normalizeBindingKey( + baseKey: string, + explicit?: string, + presetLane?: string + ): string { + if (explicit == null) { + return applyPresetLane(baseKey, presetLane) + } + + return explicit.includes(':preset:') + ? explicit + : applyPresetLane(explicit, presetLane) + } + private async resolveConversationContext( session: Session, options: ResolveConversationOptions = {} @@ -215,7 +251,7 @@ export class ConversationService { : binding?.activeConversationId ? await this.getConversation(binding.activeConversationId) : undefined - const allowedConversation = + const allowed = conversation != null && (await hasConversationPermission( this.ctx, @@ -226,30 +262,13 @@ export class ConversationService { )) ? conversation : null - const effectiveModel = this.pickModel(constraint, allowedConversation) - return { + return this.buildContext( + constraint, bindingKey, - presetLane: getPresetLane(bindingKey), - binding: binding ?? null, - conversation: - allowedConversation != null && effectiveModel != null - ? { ...allowedConversation, model: effectiveModel } - : allowedConversation, - effectiveModel, - effectivePreset: - constraint.fixedPreset ?? - allowedConversation?.preset ?? - getPresetLane(bindingKey) ?? - constraint.defaultPreset ?? - this.config.defaultPreset, - effectiveChatMode: - constraint.fixedChatMode ?? - allowedConversation?.chatMode ?? - constraint.defaultChatMode ?? - this.config.defaultChatMode, - constraint - } + binding ?? null, + allowed + ) } async resolveConversation( @@ -258,22 +277,6 @@ export class ConversationService { ): Promise { const mode = options.mode ?? 'context' const resolved = await this.resolveConversationContext(session, options) - const resolveTarget = async (conversation: ConversationRecord) => { - const target = await this.resolveConversationContext(session, { - ...options, - bindingKey: conversation.bindingKey, - conversationId: conversation.id, - presetLane: getPresetLane(conversation.bindingKey), - useRoutePresetLane: false - }) - - return { - ...target, - mode, - conversation: target.conversation ?? conversation, - conversationId: conversation.id - } - } if (mode === 'context') { return { @@ -284,163 +287,149 @@ export class ConversationService { } if (mode === 'active') { - let current = resolved + return this.resolveActiveMode(session, resolved, options) + } + + return this.resolveTargetMode(session, resolved, options) + } + + private async resolveActiveMode( + session: Session, + initial: ResolvedConversationContext, + options: ResolveConversationOptions + ): Promise { + let current = initial + if ( + current.constraint.lockConversation && + current.binding?.activeConversationId != null + ) { + const conversation = await this.getConversation( + current.binding.activeConversationId + ) if ( - current.constraint.lockConversation && - current.binding?.activeConversationId != null + conversation != null && + conversation.status !== 'deleted' && + conversation.status !== 'broken' && + (await hasConversationPermission( + this.ctx, + session, + conversation, + 'view', + current.bindingKey + )) ) { - const conversation = await this.getConversation( - current.binding.activeConversationId + current = this.buildContext( + current.constraint, + current.bindingKey, + current.binding, + conversation ) - - if ( - conversation != null && - conversation.status !== 'deleted' && - conversation.status !== 'broken' && - (await hasConversationPermission( - this.ctx, - session, - conversation, - 'view', - current.bindingKey - )) - ) { - const effectiveModel = this.pickModel( - current.constraint, - conversation - ) - - current = { - ...current, - conversation: - effectiveModel != null - ? { ...conversation, model: effectiveModel } - : conversation, - effectiveModel, - effectivePreset: - current.constraint.fixedPreset ?? - conversation.preset, - effectiveChatMode: - current.constraint.fixedChatMode ?? - conversation.chatMode - } - } } + } - if (current.conversation != null) { - if (current.conversation.status === 'archived') { - await assertManageAllowed(session, current.constraint) - - if (!current.constraint.allowArchive) { - throw new Error( - 'Conversation restore is disabled by constraint.' - ) - } - - const conversation = await this.restoreConversation( - session, - { - conversationId: current.conversation.id - } - ) - const effectiveModel = this.pickModel( - current.constraint, - conversation - ) - - return { - ...current, - mode, - conversation: - effectiveModel != null - ? { ...conversation, model: effectiveModel } - : conversation, - conversationId: conversation.id, - effectiveModel, - effectivePreset: conversation.preset, - effectiveChatMode: conversation.chatMode - } - } - + if (current.conversation != null) { + if (current.conversation.status !== 'archived') { return { ...current, - mode, + mode: 'active', conversationId: current.conversation.id } } - if (!current.constraint.allowNew) { - throw new Error( - 'Conversation creation is disabled by constraint.' - ) + await assertManageAllowed(session, current.constraint) + if (!current.constraint.allowArchive) { + throw new ConstraintDisabledError('restore') } - const conversation = await this.createConversation(session, { - bindingKey: current.bindingKey, - preset: current.effectivePreset, - model: current.effectiveModel, - chatMode: current.effectiveChatMode, - title: current.presetLane ?? 'New Conversation' + const restored = await this.restoreConversation(session, { + conversationId: current.conversation.id }) - + const refreshed = this.buildContext( + current.constraint, + current.bindingKey, + current.binding, + restored + ) return { - ...current, - mode, - conversation, - conversationId: conversation.id + ...refreshed, + mode: 'active', + conversationId: restored.id } } - let conversation: ConversationRecord | null = null + if (!current.constraint.allowNew) { + throw new ConstraintDisabledError('create') + } - if (options.conversationId != null) { - conversation = - (await this.getConversation(options.conversationId)) ?? null + const conversation = await this.createConversation(session, { + bindingKey: current.bindingKey, + preset: current.effectivePreset, + model: current.effectiveModel, + chatMode: current.effectiveChatMode, + title: current.presetLane ?? 'New Conversation' + }) - if (conversation == null) { - return { - ...resolved, - mode, - conversationId: null, - conversation: null - } - } + return { + ...current, + mode: 'active', + conversation, + conversationId: conversation.id + } + } + + private async resolveTargetMode( + session: Session, + resolved: ResolvedConversationContext, + options: ResolveConversationOptions + ): Promise { + const mode = 'target' as const + const finalize = (conversation: ConversationRecord | null) => + ({ + ...resolved, + mode, + conversation, + conversationId: conversation?.id ?? null + }) as ConversationResolution + + const promoteTarget = async (conversation: ConversationRecord) => { + const target = await this.resolveConversationContext(session, { + ...options, + bindingKey: conversation.bindingKey, + conversationId: conversation.id, + presetLane: getPresetLane(conversation.bindingKey), + useRoutePresetLane: false + }) + return { + ...target, + mode, + conversation: target.conversation ?? conversation, + conversationId: conversation.id + } as ConversationResolution + } + if (options.conversationId != null) { + const conversation = await this.getConversation( + options.conversationId + ) if ( + conversation == null || conversation.status === 'deleted' || conversation.status === 'broken' ) { - return { - ...resolved, - mode, - conversationId: null, - conversation: null - } + return finalize(null) } - if ( - conversation.status === 'archived' && - !options.includeArchived && - mode !== 'target' - ) { - return { - ...resolved, - mode, - conversationId: null, - conversation: null - } - } + const inLookupKeys = + options.allPresetLanes === true && + getLookupKeys( + session, + resolved.constraint.bindingKey, + true + ).includes(getBaseBindingKey(conversation.bindingKey)) if ( - !( - options.allPresetLanes === true && - getLookupKeys( - session, - resolved.constraint.bindingKey, - true - ).includes(getBaseBindingKey(conversation.bindingKey)) - ) && + !inLookupKeys && !(await hasConversationPermission( this.ctx, session, @@ -452,27 +441,20 @@ export class ConversationService { throw new ConversationResolutionError('target_outside_route') } - return resolveTarget(conversation) + return promoteTarget(conversation) } - const hasTarget = options.targetConversation != null - const target = options.targetConversation?.trim() - - if (hasTarget && (target == null || target.length === 0)) { + if (options.targetConversation == null) { return { ...resolved, mode, - conversation: null, - conversationId: null + conversationId: resolved.conversation?.id ?? null } } - if (!hasTarget) { - return { - ...resolved, - mode, - conversationId: resolved.conversation?.id ?? null - } + const target = options.targetConversation.trim() + if (target.length === 0) { + return finalize(null) } const entries = await this.listConversationEntries(session, { @@ -480,59 +462,74 @@ export class ConversationService { allPresetLanes: options.allPresetLanes, includeArchived: options.includeArchived }) - const conversations = entries.map((item) => item.conversation) const normalized = target.toLocaleLowerCase() - - conversation = - matchTargetConversation( - target, - normalized, - conversations, - entries - ) ?? null - if (conversation != null) { - return resolveTarget(conversation) + const local = matchTargetConversation( + target, + normalized, + entries.map((item) => item.conversation), + entries + ) + if (local != null) { + return promoteTarget(local) } - const globalMatches = await this.findAccessibleConversations(session, { + const global = await this.findAccessibleConversations(session, { ...options, bindingKey: resolved.bindingKey, includeArchived: options.includeArchived, query: normalized, exactId: target }) - - conversation = - matchTargetConversation(target, normalized, globalMatches) ?? null - if (conversation != null) { - return resolveTarget(conversation) + const remote = matchTargetConversation(target, normalized, global) + if (remote != null) { + return promoteTarget(remote) } + return finalize(null) + } + + private buildContext( + constraint: ResolvedConstraint, + bindingKey: string, + binding: BindingRecord | null, + conversation: ConversationRecord | null + ): ResolvedConversationContext { + const effectiveModel = this.pickModel(constraint, conversation) + const withModel = + conversation != null && effectiveModel != null + ? { ...conversation, model: effectiveModel } + : conversation return { - ...resolved, - mode, - conversation: null, - conversationId: null + bindingKey, + presetLane: getPresetLane(bindingKey), + binding, + conversation: withModel, + effectiveModel, + effectivePreset: + constraint.fixedPreset ?? + conversation?.preset ?? + getPresetLane(bindingKey) ?? + constraint.defaultPreset ?? + this.config.defaultPreset, + effectiveChatMode: + constraint.fixedChatMode ?? + conversation?.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 - } + return { bindingKey, binding } } for (const key of getFallbackBindingKeys(session, bindingKey)) { const legacyBinding = await this.getBinding(key) if (legacyBinding != null) { - return { - bindingKey: key, - binding: legacyBinding - } + return { bindingKey: key, binding: legacyBinding } } } @@ -547,7 +544,6 @@ export class ConversationService { ...options, mode: 'active' }) - return resolved as ConversationResolution & { conversation: ConversationRecord } @@ -666,9 +662,7 @@ export class ConversationService { return false } - await this.touchConversation(conversationId, { - autoTitle: false - }) + await this.touchConversation(conversationId, { autoTitle: false }) return true }) } @@ -686,52 +680,40 @@ export class ConversationService { resolved.constraint.bindingKey, options.allPresetLanes ) + const all = (await this.ctx.database.get( + 'chatluna_conversation', + options.allPresetLanes + ? {} + : { bindingKey: keys.length === 1 ? keys[0] : { $in: keys } } + )) as ConversationRecord[] + const conversations = options.allPresetLanes - ? ( - (await this.ctx.database.get( - 'chatluna_conversation', - {} - )) as ConversationRecord[] - ).filter((conversation) => { - return keys.some((key) => { - return ( - conversation.bindingKey === key || - conversation.bindingKey.startsWith(key + ':preset:') - ) - }) - }) - : ((await this.ctx.database.get('chatluna_conversation', { - bindingKey: keys.length === 1 ? keys[0] : { $in: keys } - })) as ConversationRecord[]) + ? all.filter((c) => + keys.some( + (key) => + c.bindingKey === key || + c.bindingKey.startsWith(key + ':preset:') + ) + ) + : all const filtered = conversations.filter( - (conversation) => - conversation.status !== 'deleted' && - conversation.status !== 'broken' && - (options.includeArchived || conversation.status !== 'archived') + (c) => + c.status !== 'deleted' && + c.status !== 'broken' && + (options.includeArchived || c.status !== 'archived') ) - const merged = - new Set(filtered.map((conversation) => conversation.bindingKey)) - .size > 1 + const merged = new Set(filtered.map((c) => c.bindingKey)).size > 1 return filtered.sort((a, b) => { if (merged) { const key = a.bindingKey.localeCompare(b.bindingKey) - if (key !== 0) { - return key - } + if (key !== 0) return key } - const seq = (a.seq ?? 0) - (b.seq ?? 0) - if (seq !== 0) { - return seq - } - + if (seq !== 0) return seq const created = a.createdAt.getTime() - b.createdAt.getTime() - if (created !== 0) { - return created - } - + if (created !== 0) return created return a.id.localeCompare(b.id) }) } @@ -741,10 +723,7 @@ export class ConversationService { options: ListConversationsOptions = {} ): Promise { const conversations = await this.listConversations(session, options) - const merged = - new Set( - conversations.map((conversation) => conversation.bindingKey) - ).size > 1 + const merged = new Set(conversations.map((c) => c.bindingKey)).size > 1 return conversations.map((conversation, idx) => ({ conversation, @@ -768,7 +747,7 @@ export class ConversationService { const conversation = resolved.conversation if (conversation == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } const managed = await this.getManagedConstraintByBindingKey( @@ -779,11 +758,7 @@ export class ConversationService { await assertManageAllowed(session, managed) } - return { - resolved, - conversation, - managed - } + return { resolved, conversation, managed } } async switchConversation( @@ -802,19 +777,15 @@ export class ConversationService { }) : resolved - if (managed?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation switch is locked by constraint.') - } - - if (!(managed?.allowSwitch ?? resolved.constraint.allowSwitch)) { - throw new Error('Conversation switch is disabled by constraint.') - } + assertActionAllowed('switch', resolved, managed, { + needsAllow: 'switch' + }) const previousConversation = current.binding?.activeConversationId ? await this.getConversation(current.binding.activeConversationId) : null const sameRoute = - options.allPresetLanes && + options.allPresetLanes === true && getLookupKeys( session, resolved.constraint.bindingKey, @@ -861,14 +832,12 @@ export class ConversationService { ) if (managed?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation restore is locked by constraint.') + throw new ConstraintLockedError('restore') } if (conversation.status !== 'archived') { if (!(managed?.allowSwitch ?? resolved.constraint.allowSwitch)) { - throw new Error( - 'Conversation switch is disabled by constraint.' - ) + throw new ConstraintDisabledError('switch') } if ( @@ -901,9 +870,7 @@ export class ConversationService { async listMessages(conversationId: string) { const [conversation, messages] = await Promise.all([ this.getConversation(conversationId), - this.ctx.database.get('chatluna_message', { - conversationId - }) + this.ctx.database.get('chatluna_message', { conversationId }) ]) const records = messages as MessageRecord[] @@ -911,43 +878,31 @@ export class ConversationService { return records } + const sortByCreatedAt = () => + [...records].sort( + (a, b) => + (a.createdAt?.getTime() ?? 0) - + (b.createdAt?.getTime() ?? 0) + ) + if (conversation?.latestMessageId == null) { - return records.sort((a, b) => { - const left = a.createdAt?.getTime() ?? 0 - const right = b.createdAt?.getTime() ?? 0 - return left - right - }) + return sortByCreatedAt() } - const map = new Map(records.map((message) => [message.id, message])) + const map = new Map(records.map((m) => [m.id, m])) const ordered: MessageRecord[] = [] const seen = new Set() let currentId: string | null | undefined = conversation.latestMessageId - while (currentId != null) { - if (seen.has(currentId)) { - break - } - + while (currentId != null && !seen.has(currentId)) { const message = map.get(currentId) - if (message == null) { - break - } - + 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 - }) + return ordered.length === records.length ? ordered : sortByCreatedAt() } async listAcl(conversationId: string) { @@ -966,12 +921,8 @@ export class ConversationService { await this.ctx.database.upsert( 'chatluna_acl', - records.map((record) => ({ - conversationId, - ...record - })) + records.map((record) => ({ conversationId, ...record })) ) - return this.listAcl(conversationId) } @@ -979,10 +930,7 @@ export class ConversationService { conversationId: string, records: Omit[] ) { - await this.ctx.database.remove('chatluna_acl', { - conversationId - }) - + await this.ctx.database.remove('chatluna_acl', { conversationId }) return this.upsertAcl(conversationId, records) } @@ -991,14 +939,12 @@ export class ConversationService { records?: Partial>[] ) { if (records == null || records.length === 0) { - await this.ctx.database.remove('chatluna_acl', { - conversationId - }) + await this.ctx.database.remove('chatluna_acl', { conversationId }) return [] as ACLRecord[] } const current = await this.listAcl(conversationId) - const removed = current.filter((item) => + const matches = (item: ACLRecord) => records.some( (record) => (record.principalType == null || @@ -1008,9 +954,8 @@ export class ConversationService { (record.permission == null || record.permission === item.permission) ) - ) - for (const item of removed) { + for (const item of current.filter(matches)) { await this.ctx.database.remove('chatluna_acl', item) } @@ -1031,7 +976,7 @@ export class ConversationService { ) if (!(managed?.allowExport ?? resolved.constraint.allowExport)) { - throw new Error('Conversation export is disabled by constraint.') + throw new ConstraintDisabledError('export') } const markdown = await this.exportMarkdown(conversation) @@ -1062,11 +1007,11 @@ export class ConversationService { ) if (managed?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation archive is locked by constraint.') + throw new ConstraintLockedError('archive') } if (!(managed?.allowArchive ?? resolved.constraint.allowArchive)) { - throw new Error('Conversation archive is disabled by constraint.') + throw new ConstraintDisabledError('archive') } return this.archiveConversationById(conversation.id) @@ -1078,13 +1023,13 @@ export class ConversationService { ) { const conversation = await this.getConversation(conversationId) if (conversation == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } return this.runtime.withConversationSync(conversation, async () => { const current = await this.getConversation(conversationId) if (current == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } if ( @@ -1108,9 +1053,7 @@ export class ConversationService { await this.ctx.root.parallel( 'chatluna/before-conversation-archive', - { - conversation: current - } + { conversation: current } ) const archiveDir = await this.ensureDataDir( @@ -1123,24 +1066,14 @@ export class ConversationService { conversation: serializeConversation(current), messages: messages.map(serializeMessage) } - const messageLines = payload.messages - .map((message) => JSON.stringify(message)) - .join('\n') - const messageBuffer = await gzipEncode(messageLines) + const messageBuffer = await gzipEncode( + payload.messages + .map((message) => JSON.stringify(message)) + .join('\n') + ) 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 now = new Date() const manifest: ArchiveManifest = { format: 'chatluna-archive', @@ -1151,6 +1084,16 @@ export class ConversationService { size: messageBuffer.byteLength, createdAt: now.toISOString() } + + 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 fs.writeFile( path.join(archiveDir, 'manifest.json'), JSON.stringify(manifest, null, 2), @@ -1181,24 +1124,14 @@ export class ConversationService { conversationId: current.id }) - const updatedConversation = await this.getConversation(current.id) - await this.runtime.clearConversationInterfaceLocked( - updatedConversation ?? current - ) + const updated = (await this.getConversation(current.id)) ?? current + await this.runtime.clearConversationInterfaceLocked(updated) await this.ctx.root.parallel( 'chatluna/after-conversation-archive', - { - conversation: updatedConversation ?? current, - archive, - path: archiveDir - } + { conversation: updated, archive, path: archiveDir } ) - return { - conversation: updatedConversation ?? current, - archive, - path: archiveDir - } + return { conversation: updated, archive, path: archiveDir } }) } @@ -1230,39 +1163,31 @@ export class ConversationService { } if (managed?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation restore is locked by constraint.') + throw new ConstraintLockedError('restore') } if (!(managed?.allowArchive ?? resolved.constraint.allowArchive)) { - throw new Error('Conversation restore is disabled by constraint.') + throw new ConstraintDisabledError('restore') } return this.runtime.withConversationSync(conversation, async () => { const current = await this.getConversation(conversation.id) if (current == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } await this.ctx.root.parallel( 'chatluna/before-conversation-restore', - { - conversation: current, - archive - } + { conversation: current, archive } ) await this.ctx.database.upsert('chatluna_archive', [ - { - ...archive, - state: 'restoring' - } + { ...archive, state: 'restoring' } ]) try { const payload = await readArchivePayload(archive.path) - const restoredConversation = deserializeConversation( - payload.conversation - ) + const restored = deserializeConversation(payload.conversation) const restoredMessages = payload.messages.map((message) => ({ ...deserializeMessage(message), conversationId: current.id @@ -1271,18 +1196,16 @@ export class ConversationService { await this.ctx.database.remove('chatluna_message', { conversationId: current.id }) - if (restoredMessages.length > 0) { await this.ctx.database.upsert( 'chatluna_message', restoredMessages ) } - await this.ctx.database.upsert('chatluna_conversation', [ { ...current, - ...restoredConversation, + ...restored, id: current.id, status: 'active', archivedAt: null, @@ -1291,17 +1214,11 @@ export class ConversationService { } ]) await this.ctx.database.upsert('chatluna_archive', [ - { - ...archive, - state: 'ready', - restoredAt: new Date() - } + { ...archive, state: 'ready', restoredAt: new Date() } ]) - const updatedConversation = await this.getConversation( - current.id - ) - if (updatedConversation == null) { + const updated = await this.getConversation(current.id) + if (updated == null) { throw new Error('Conversation restore failed.') } @@ -1311,39 +1228,25 @@ export class ConversationService { session, resolved.constraint.bindingKey, true - ).includes( - getBaseBindingKey(updatedConversation.bindingKey) - ) + ).includes(getBaseBindingKey(updated.bindingKey)) ) { await this.updateManagedConstraint(session, { activePresetLane: - getPresetLane(updatedConversation.bindingKey) ?? - null + getPresetLane(updated.bindingKey) ?? null }) } - await this.setActiveConversation( - updatedConversation.bindingKey, - updatedConversation.id - ) - await this.runtime.clearConversationInterfaceLocked( - updatedConversation - ) + await this.setActiveConversation(updated.bindingKey, updated.id) + await this.runtime.clearConversationInterfaceLocked(updated) await this.ctx.root.parallel( 'chatluna/after-conversation-restore', - { - conversation: updatedConversation, - archive - } + { conversation: updated, archive } ) - return updatedConversation + return updated } catch (error) { await this.ctx.database.upsert('chatluna_archive', [ - { - ...archive, - state: 'broken' - } + { ...archive, state: 'broken' } ]) throw error } @@ -1351,22 +1254,7 @@ export class ConversationService { } async exportMarkdown(conversation: ConversationRecord) { - let messages: MessageRecord[] - - if (conversation.status === 'archived' && conversation.archiveId) { - const archive = await this.getArchive(conversation.archiveId) - if (archive != null) { - const payload = await readArchivePayload(archive.path) - messages = payload.messages.map((message) => ({ - ...deserializeMessage(message), - conversationId: conversation.id - })) - } else { - messages = await this.listMessages(conversation.id) - } - } else { - messages = await this.listMessages(conversation.id) - } + const messages = await this.loadMessagesForExport(conversation) return [ `# ${conversation.title}`, @@ -1393,6 +1281,26 @@ export class ConversationService { ].join('\n') } + private async loadMessagesForExport(conversation: ConversationRecord) { + if ( + conversation.status !== 'archived' || + conversation.archiveId == null + ) { + return this.listMessages(conversation.id) + } + + const archive = await this.getArchive(conversation.archiveId) + if (archive == null) { + return this.listMessages(conversation.id) + } + + const payload = await readArchivePayload(archive.path) + return payload.messages.map((message) => ({ + ...deserializeMessage(message), + conversationId: conversation.id + })) + } + async renameConversation( session: Session, options: ResolveConversationOptions & { @@ -1405,7 +1313,7 @@ export class ConversationService { 'manage' ) if (managed?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation rename is locked by constraint.') + throw new ConstraintLockedError('rename') } const updated = await this.touchConversation(conversation.id, { @@ -1426,20 +1334,18 @@ export class ConversationService { true ) if (managed?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation delete is locked by constraint.') + throw new ConstraintLockedError('delete') } return this.runtime.withConversationSync(conversation, async () => { const current = await this.getConversation(conversation.id) if (current == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } await this.ctx.root.parallel( 'chatluna/before-conversation-delete', - { - conversation: current - } + { conversation: current } ) await removeArchive(this.ctx, current.archiveId) @@ -1472,30 +1378,18 @@ export class ConversationService { chatMode?: string } ) { - const resolved = await this.resolveConversation(session, { + const lookupMode = options.conversationId == null ? 'active' : 'target' + const lookupOptions: ResolveConversationOptions = { ...options, - mode: 'context' - }) + ...(lookupMode === 'target' ? { permission: 'manage' } : {}), + mode: lookupMode + } + const resolved = await this.resolveConversation(session, lookupOptions) await assertManageAllowed(session, resolved.constraint) - const conversation = - options.conversationId == null - ? ( - await this.resolveConversation(session, { - ...options, - mode: 'active' - }) - ).conversation - : ( - await this.resolveConversation(session, { - ...options, - permission: 'manage', - mode: 'target' - }) - ).conversation - + const conversation = resolved.conversation if (conversation == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } const target = await this.getManagedConstraintByBindingKey( @@ -1506,20 +1400,16 @@ export class ConversationService { await 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] - + for (const { key, constraintKey, label } of FIXED_FIELDS) { + const fixed = + target?.[constraintKey] ?? resolved.constraint[constraintKey] if (options[key] != null && fixed != null) { - throw new Error(`${label} is fixed to ${fixed}.`) + throw new ConstraintFixedError(label, fixed) } } if (target?.lockConversation ?? resolved.constraint.lockConversation) { - throw new Error('Conversation update is locked by constraint.') + throw new ConstraintLockedError('update') } this.checkChatMode(options.chatMode) @@ -1531,7 +1421,7 @@ export class ConversationService { }) if (updated == null) { - throw new Error('Conversation not found.') + throw new ConversationNotFoundError() } await this.runtime.clearConversationInterface(updated) @@ -1554,7 +1444,6 @@ export class ConversationService { if (!result.compressed) { return conversation } - if (conversation == null) { return undefined } @@ -1562,20 +1451,11 @@ export class ConversationService { const current = JSON.parse( conversation.compression ?? 'null' ) as ConversationCompressionRecord - const summaryMessage = ( (await this.ctx.database.get( 'chatluna_message', - { - conversationId, - name: 'infinite_context' - }, - { - limit: 1, - sort: { - createdAt: 'desc' - } - } + { conversationId, name: 'infinite_context' }, + { limit: 1, sort: { createdAt: 'desc' } } )) as MessageRecord[] )[0] const summary = @@ -1608,45 +1488,21 @@ export class ConversationService { } 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 + const name = buildManagedConstraintName(session) + return this.firstRow('chatluna_constraint', { name }) as Promise< + 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]}` - } - + const name = managedNameFromBindingKey(bindingKey) if (name == null) { return undefined } - return ( - await this.ctx.database.get('chatluna_constraint', { - name - }) - )[0] as ConstraintRecord | undefined + return this.firstRow('chatluna_constraint', { name }) as Promise< + ConstraintRecord | undefined + > } async updateManagedConstraint( @@ -1658,21 +1514,19 @@ export class ConversationService { const current = await this.getManagedConstraint(session) const now = new Date() - const route = session.isDirect - ? `direct:${session.userId}` - : `guild:${session.guildId ?? session.channelId ?? 'unknown'}` + const guildId = session.isDirect + ? null + : (session.guildId ?? session.channelId ?? null) const record: ConstraintRecord = { id: current?.id, - name: `managed:${session.platform}:${session.selfId}:${route}`, + name: buildManagedConstraintName(session), enabled: true, priority: 1000, createdBy: session.userId, createdAt: now, platform: session.platform, selfId: session.selfId, - guildId: session.isDirect - ? null - : (session.guildId ?? session.channelId ?? null), + guildId, channelId: null, direct: session.isDirect, users: session.isDirect ? JSON.stringify([session.userId]) : null, @@ -1704,11 +1558,105 @@ export class ConversationService { return (await this.getManagedConstraint(session)) ?? record } + pickModel( + constraint: ResolvedConstraint, + conversation?: ConversationRecord | null + ) { + const candidates = [ + constraint.fixedModel, + conversation?.model, + constraint.defaultModel, + this.config.defaultModel + ] + + for (const model of candidates) { + if (model == null) continue + const trimmed = model.trim() + if (EMPTY_MODEL_NAMES.has(trimmed)) continue + + const [platform, name] = parseRawModelName(model) + if (platform == null || name == null) continue + + const models = this.platform.listPlatformModels( + platform, + ModelType.llm + ).value + if (models.length > 0 && models.some((m) => m.name === name)) { + return model + } + } + + return null + } + + private async firstRow( + table: 'chatluna_conversation', + query: Partial + ): Promise + + private async firstRow( + table: 'chatluna_binding', + query: Partial + ): Promise + + private async firstRow( + table: 'chatluna_archive', + query: Partial + ): Promise + + private async firstRow( + table: 'chatluna_constraint', + query: Partial + ): Promise + + private async firstRow( + table: + | 'chatluna_conversation' + | 'chatluna_binding' + | 'chatluna_archive' + | 'chatluna_constraint', + query: + | Partial + | Partial + | Partial + | Partial + ) { + if (table === 'chatluna_conversation') { + return ( + await this.ctx.database.get( + 'chatluna_conversation', + query as Partial + ) + )[0] + } + if (table === 'chatluna_binding') { + return ( + await this.ctx.database.get( + 'chatluna_binding', + query as Partial + ) + )[0] + } + if (table === 'chatluna_archive') { + return ( + await this.ctx.database.get( + 'chatluna_archive', + query as Partial + ) + )[0] + } + return ( + await this.ctx.database.get( + 'chatluna_constraint', + query as Partial + ) + )[0] + } + private getDefaultRouteMode(session: Session): RouteMode { if (session.isDirect) { return 'personal' } - return this.config.defaultGroupRouteMode ?? 'shared' } @@ -1717,7 +1665,7 @@ export class ConversationService { mode != null && !this.platform.chatChains.value.some((chain) => chain.name === mode) ) { - throw new Error(`Chat mode ${mode} not found.`) + throw new InvalidChatModeError(mode) } } @@ -1739,7 +1687,6 @@ export class ConversationService { seq?: number } ) { - const required = options.permission ?? 'view' const matched = (conversation: ConversationRecord) => { if ( conversation.bindingKey === options.bindingKey || @@ -1767,84 +1714,65 @@ export class ConversationService { } } - if (options.seq != null) { - const conversations = (await this.ctx.database.get( - 'chatluna_conversation', - { - seq: options.seq - } - )) as ConversationRecord[] - return conversations.filter(matched) - } - + const filter = options.seq != null ? { seq: options.seq } : {} const conversations = (await this.ctx.database.get( 'chatluna_conversation', - {} + filter )) as ConversationRecord[] return conversations.filter(matched) } - const acl = [ - ...((await this.ctx.database.get('chatluna_acl', { - principalType: 'user', - principalId: session.userId, - permission: 'manage' - })) as ACLRecord[]) - ] + const ids = await this.collectAclConversationIds( + session, + options.permission ?? 'view' + ) - if (required === 'view') { - acl.push( - ...((await this.ctx.database.get('chatluna_acl', { - principalType: 'user', - principalId: session.userId, - permission: 'view' - })) as ACLRecord[]) - ) + const matches: ConversationRecord[] = [] + for (let i = 0; i < ids.length; i += 200) { + const slice = ids.slice(i, i + 200) + const conversations = (await this.ctx.database.get( + 'chatluna_conversation', + { id: { $in: slice } } + )) as ConversationRecord[] + matches.push(...conversations.filter(matched)) } - const guildId = session.guildId ?? session.channelId + return matches + } - if (guildId != null) { + private async collectAclConversationIds( + session: Session, + required: ConstraintPermission + ) { + const acl: ACLRecord[] = [] + const fetch = async ( + principalType: 'user' | 'guild', + principalId: string, + permission: ConstraintPermission + ) => { acl.push( ...((await this.ctx.database.get('chatluna_acl', { - principalType: 'guild', - principalId: guildId, - permission: 'manage' + principalType, + principalId, + permission })) 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[] + await fetch('user', session.userId, 'manage') + if (required === 'view') { + await fetch('user', session.userId, 'view') + } - matches.push(...conversations.filter(matched)) + const guildId = session.guildId ?? session.channelId + if (guildId != null) { + await fetch('guild', guildId, 'manage') + if (required === 'view') { + await fetch('guild', guildId, 'view') + } } - return matches + return Array.from(new Set(acl.map((item) => item.conversationId))) } private async ensureDataDir(name: string) { @@ -1852,47 +1780,32 @@ export class ConversationService { await fs.mkdir(target, { recursive: true }) return target } +} - pickModel( - constraint: ResolvedConstraint, - conversation?: ConversationRecord | null - ) { - for (const model of [ - constraint.fixedModel, - conversation?.model, - constraint.defaultModel, - this.config.defaultModel - ]) { - if ( - model == null || - model.trim().length < 1 || - model === '无' || - model === 'empty' - ) { - continue - } - - const [platform, name] = parseRawModelName(model) +function buildManagedConstraintName(session: Session) { + const route = session.isDirect + ? `direct:${session.userId}` + : `guild:${session.guildId ?? session.channelId ?? 'unknown'}` + return `managed:${session.platform}:${session.selfId}:${route}` +} - if (platform == null || name == null) { - continue - } +function managedNameFromBindingKey(bindingKey: string): string | undefined { + const target = bindingKey.includes(':preset:') + ? bindingKey.slice(0, bindingKey.indexOf(':preset:')) + : bindingKey + const parts = target.split(':') - const platformModels = this.platform.listPlatformModels( - platform, - ModelType.llm - ).value - - if ( - platformModels.length > 0 && - platformModels.some((m) => m.name === name) - ) { - return model - } - } + if (parts[0] === 'shared' && parts.length >= 4) { + return `managed:${parts[1]}:${parts[2]}:guild:${parts[3]}` + } - return null + if (parts[0] === 'personal' && parts.length >= 5) { + const scope = + parts[3] === 'direct' ? `direct:${parts[4]}` : `guild:${parts[3]}` + return `managed:${parts[1]}:${parts[2]}:${scope}` } + + return undefined } function isConstraintMatched(constraint: ConstraintRecord, session: Session) { @@ -1918,14 +1831,13 @@ function isConstraintMatched(constraint: ConstraintRecord, session: Session) { return false } - const users = - constraint.users === null ? null : JSON.parse(constraint.users) + const users = constraint.users == null ? null : JSON.parse(constraint.users) if (users != null && !users.includes(session.userId)) { return false } const excludeUsers = - constraint.excludeUsers === null + constraint.excludeUsers == null ? null : JSON.parse(constraint.excludeUsers) if (excludeUsers != null && excludeUsers.includes(session.userId)) { @@ -1942,14 +1854,38 @@ async function assertManageAllowed( if (constraint.manageMode !== 'admin') { return } - if (await checkAdmin(session)) { return } + throw new AdminRequiredError() +} - throw new Error( - 'Conversation management requires administrator permission.' - ) +function assertActionAllowed( + action: ConstraintAction, + resolved: { constraint: ResolvedConstraint }, + managed: ConstraintRecord | null | undefined, + options: { + needsAllow?: 'switch' | 'archive' | 'export' | 'new' + } = {} +) { + if (managed?.lockConversation ?? resolved.constraint.lockConversation) { + throw new ConstraintLockedError(action) + } + if (options.needsAllow == null) { + return + } + + const allowKey = + options.needsAllow === 'switch' + ? 'allowSwitch' + : options.needsAllow === 'archive' + ? 'allowArchive' + : options.needsAllow === 'export' + ? 'allowExport' + : 'allowNew' + if (!(managed?.[allowKey] ?? resolved.constraint[allowKey])) { + throw new ConstraintDisabledError(action) + } } async function hasConversationPermission( @@ -1962,7 +1898,6 @@ async function hasConversationPermission( if (conversation.bindingKey === bindingKey) { return true } - if (await checkAdmin(session)) { return true } @@ -1974,37 +1909,36 @@ async function hasConversationPermission( return false } - const principalIds = [ + const principals: readonly (readonly [ + 'user' | 'guild', + string | undefined + ])[] = [ ['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 - ) - }) + return acl.some( + (item) => + required.includes(item.permission) && + principals.some( + ([type, id]) => + id != null && + item.principalType === type && + item.principalId === id + ) + ) } async function readText(message: MessageRecord) { - const content = await JSON.parse(await gzipDecode(message.content)) + const content = JSON.parse(await gzipDecode(message.content)) if (content == null) { return message.text ?? '' } - if (typeof content === 'string') { return content } - return getMessageContent(content) } @@ -2012,8 +1946,99 @@ function formatUrl(url: string) { return url.length > 120 ? url.slice(0, 117) + '...' : url } +function formatMediaPart( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + part: any +): string | null { + 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}]` +} + +function formatToolBlock(body: string): string[] { + let block = body + let lang = 'text' + try { + block = JSON.stringify(JSON.parse(body), null, 2) + lang = 'json' + } catch {} + return ['```' + lang, block, '```'] +} + +function formatToolCalls(toolCalls: unknown[]): string[] { + const parts: string[] = ['Tool calls:'] + + toolCalls.forEach((entry, index) => { + const tool = entry 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 (index > 0) parts.push('') + parts.push(`- \`${name}\`${id.length > 0 ? ` (\`${id}\`)` : ''}`) + parts.push('```' + lang) + parts.push(block.length > 0 ? block : '{}') + parts.push('```') + }) + + return parts +} + async function formatMessage(message: MessageRecord) { - const content = await JSON.parse(await gzipDecode(message.content)) + const content = JSON.parse(await gzipDecode(message.content)) const text = content == null @@ -2024,46 +2049,8 @@ async function formatMessage(message: MessageRecord) { 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) + .map(formatMediaPart) + .filter((line): line is string => line != null) : [] const parts: string[] = [] @@ -2071,99 +2058,38 @@ async function formatMessage(message: MessageRecord) { 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('```') + if (parts.length > 0) parts.push('') + parts.push(...formatToolBlock(body)) } } else { if (text.length > 0) { parts.push(text) } - if (media.length > 0) { - if (parts.length > 0) { - parts.push('') - } - + 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) parts.push('') + parts.push(...formatToolCalls(message.tool_calls)) } if (parts.length > 0) { return parts.join('\n') } - if (content != null && typeof content === 'string') { return content } - return message.text ?? '' } @@ -2221,30 +2147,19 @@ function matchTargetConversation( ) { const pick = (matches: ConversationRecord[]) => { const active = matches.filter((c) => c.status !== 'archived') - - if (active.length === 1) { - return active[0] - } - + if (active.length === 1) return active[0] if (active.length > 1) { throw new ConversationResolutionError('ambiguous_target') } - - if (matches.length === 1) { - return matches[0] - } - + if (matches.length === 1) return matches[0] if (matches.length > 1) { throw new ConversationResolutionError('ambiguous_target') } - return null } const byId = conversations.find((c) => c.id === target) - if (byId != null) { - return byId - } + if (byId != null) return byId if (entries != null && /^\d+$/.test(target)) { const seq = Number(target) @@ -2252,18 +2167,13 @@ function matchTargetConversation( .filter((item) => item.displaySeq === seq) .map((item) => item.conversation) const match = pick(bySeq) - - if (match != null) { - return match - } + if (match != null) return match } const exact = pick( conversations.filter((c) => c.title.toLocaleLowerCase() === normalized) ) - if (exact != null) { - return exact - } + if (exact != null) return exact return pick( conversations.filter((c) => diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts index b5190d1a8..3ac924e1d 100644 --- a/packages/core/src/services/conversation_runtime.ts +++ b/packages/core/src/services/conversation_runtime.ts @@ -91,26 +91,11 @@ export class ConversationRuntime { options: ChatOptions ): Promise { const requestId = options.requestId ?? randomUUID() - - const [platform] = parseRawModelName(conversation.model) - if (platform == null) { - throw new ChatLunaError( - ChatLunaErrorCode.UNKNOWN_ERROR, - new Error(`Invalid conversation model: ${conversation.model}`) - ) - } + const platform = requirePlatform(conversation) const chatInterface = await this.ensureChatInterface(conversation) const abortController = new AbortController() - const sig = options.signal - let onAbort: (() => void) | undefined - if (sig != null) { - if (sig.aborted) abortController.abort(sig.reason) - else { - onAbort = () => abortController.abort(sig.reason) - sig.addEventListener('abort', onAbort, { once: true }) - } - } + const releaseSignal = linkAbortSignal(abortController, options.signal) const activeRequest = this.registerRequest( conversation.id, requestId, @@ -121,15 +106,14 @@ export class ConversationRuntime { ) let lastActiveAt = Date.now() - let idleDisponse: () => void = () => {} const touch = () => { lastActiveAt = Date.now() } - const events = wrapEvents(options.event, touch) + let releaseIdleTimer: () => void = () => {} if (config.timeout > 0) { - idleDisponse = this.service.ctx.setInterval( + releaseIdleTimer = this.service.ctx.setInterval( () => { if (abortController.signal.aborted) return if (Date.now() - lastActiveAt < config.timeout) return @@ -146,17 +130,13 @@ export class ConversationRuntime { } try { - const humanMessage = new HumanMessage({ - content: message.content, - name: message.name, - id: session.userId, - additional_kwargs: { - ...message.additional_kwargs, - preset: conversation.preset - } - }) - markChatLunaUserMessage(humanMessage) - + const humanMessage = buildHumanMessage( + session, + message, + conversation + ) + const stream = options.stream ?? false + const variables = options.variables ?? {} const mask = options.toolMask ?? (await this.platformService.resolveToolMask({ @@ -164,8 +144,6 @@ export class ConversationRuntime { conversation, bindingKey: conversation.bindingKey })) - const variables = options.variables ?? {} - const stream = options.stream ?? false const chainValues = await chatInterface.chat({ message: humanMessage, @@ -193,56 +171,54 @@ export class ConversationRuntime { }), onAgentEvent: async (agentEvent) => { touch() - if (agentEvent.type === 'round-decision') { - activeRequest.lastDecision = agentEvent.canContinue - if (agentEvent.canContinue == null) return - flushRoundDecision( - activeRequest, - agentEvent.canContinue - ) - } + if (agentEvent.type !== 'round-decision') return + activeRequest.lastDecision = agentEvent.canContinue + if (agentEvent.canContinue == null) return + flushRoundDecision(activeRequest, agentEvent.canContinue) } }) - const aiMessage = chainValues.message as AIMessage - const reasoning = aiMessage.additional_kwargs?.reasoning_content as - | string - | undefined - const reasoningTime = aiMessage.additional_kwargs - ?.reasoning_time as number | undefined - const usage = aiMessage.usage_metadata - const additionalReplyMessages: Message[] = [] - const showThought = this.service.currentConfig.showThoughtMessage - - if (showThought && reasoning != null && reasoning.length > 0) { - additionalReplyMessages.push({ - content: - reasoningTime != null - ? `Thought for ${reasoningTime / 1000} seconds: \n\n${reasoning}` - : `Thought: \n\n${reasoning}` - }) - } - - if (showThought && usage != null && usage.total_tokens > 0) { - additionalReplyMessages.push({ - content: formatUsageMetadataMessage(usage) - }) - } - - return { - content: aiMessage.content as string, - additional_kwargs: aiMessage.additional_kwargs, - additionalReplyMessages - } + return this.buildReply(chainValues.message as AIMessage) } finally { - idleDisponse() - if (sig != null && onAbort != null) { - sig.removeEventListener('abort', onAbort) - } + releaseIdleTimer() + releaseSignal() this.completeRequest(conversation.id, requestId) } } + private buildReply(aiMessage: AIMessage): Message { + const reasoning = aiMessage.additional_kwargs?.reasoning_content as + | string + | undefined + const reasoningTime = aiMessage.additional_kwargs?.reasoning_time as + | number + | undefined + const usage = aiMessage.usage_metadata + const showThought = this.service.currentConfig.showThoughtMessage + const additionalReplyMessages: Message[] = [] + + if (showThought && reasoning != null && reasoning.length > 0) { + additionalReplyMessages.push({ + content: + reasoningTime != null + ? `Thought for ${reasoningTime / 1000} seconds: \n\n${reasoning}` + : `Thought: \n\n${reasoning}` + }) + } + + if (showThought && usage != null && usage.total_tokens > 0) { + additionalReplyMessages.push({ + content: formatUsageMetadataMessage(usage) + }) + } + + return { + content: aiMessage.content as string, + additional_kwargs: aiMessage.additional_kwargs, + additionalReplyMessages + } + } + updateConversationRecord(conversation: ConversationRecord) { const cached = this.interfaces.get(conversation.id) if (cached != null) { @@ -289,19 +265,12 @@ export class ConversationRuntime { ): Promise { 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 platform = requirePlatform(conversation) 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, @@ -316,7 +285,6 @@ export class ConversationRuntime { this.conversationQueue.add(conversation.id, requestId), this.modelQueue.add(platform, modelRequestId) ]) - await Promise.all([ this.conversationQueue.wait( conversation.id, @@ -331,7 +299,6 @@ export class ConversationRuntime { config.timeout ) ]) - return await callback(config) } finally { await Promise.all([ @@ -355,11 +322,7 @@ export class ConversationRuntime { requestKey: session == null ? undefined - : JSON.stringify([ - session.userId, - session.guildId ?? '', - conversationId - ]), + : buildRequestKey(session, conversationId), platform, abortController, chatMode, @@ -383,10 +346,7 @@ export class ConversationRuntime { const active = Array.from(this.activeByConversation.values()).find( (item) => item.requestId === requestId ) - if (active == null) { - return false - } - if (active.abortController.signal.aborted) { + if (active == null || active.abortController.signal.aborted) { return false } active.abortController.abort( @@ -397,26 +357,15 @@ export class ConversationRuntime { stopConversationRequest(conversationId: string) { const activeRequest = this.activeByConversation.get(conversationId) - if (activeRequest == null) { - return false - } - - return this.stopRequest(activeRequest.requestId) + return activeRequest == null + ? false + : this.stopRequest(activeRequest.requestId) } getRequestId(session: Session, conversationId: string) { const active = this.activeByConversation.get(conversationId) - if (active == null) { - return undefined - } - if ( - active.requestKey !== - JSON.stringify([ - session.userId, - session.guildId ?? '', - conversationId - ]) - ) { + if (active == null) return undefined + if (active.requestKey !== buildRequestKey(session, conversationId)) { return undefined } return active.requestId @@ -432,7 +381,6 @@ export class ConversationRuntime { } const activeRequest = this.activeByConversation.get(conversationId) - if (activeRequest == null || activeRequest.chatMode !== 'plugin') { return false } @@ -463,10 +411,7 @@ export class ConversationRuntime { const chatInterface = await this.ensureChatInterface(conversation) await this.service.ctx.root.parallel( 'chatluna/before-conversation-clear-history', - { - conversation, - chatInterface - } + { conversation, chatInterface } ) await this.service.ctx.root.parallel( 'chatluna/clear-chat-history', @@ -477,10 +422,7 @@ export class ConversationRuntime { this.interfaces.delete(conversation.id) await this.service.ctx.root.parallel( 'chatluna/after-conversation-clear-history', - { - conversation, - chatInterface - } + { conversation, chatInterface } ) }) } @@ -500,40 +442,33 @@ export class ConversationRuntime { const existed = cached != null await this.service.ctx.root.parallel( 'chatluna/before-conversation-cache-clear', - { - conversation, - chatInterface: cached?.chatInterface - } + { conversation, chatInterface: cached?.chatInterface } ) this.interfaces.delete(conversation.id) await this.service.ctx.root.parallel( 'chatluna/after-conversation-cache-clear', - { - conversation - } + { conversation } ) return existed } async clearConversationInterface(conversation: ConversationRecord) { - return this.withConversationLock(conversation.id, async () => { - return this.clearConversationInterfaceLocked(conversation) - }) + return this.withConversationLock(conversation.id, () => + this.clearConversationInterfaceLocked(conversation) + ) } dispose(platform?: string) { + const abortActive = (active: ActiveRequest) => { + flushRoundDecision(active, false) + active.abortController.abort( + new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) + ) + } + if (platform == null) { - for (const active of Array.from( - this.activeByConversation.values() - )) { - flushRoundDecision(active, false) - active.abortController.abort( - new ChatLunaError( - ChatLunaErrorCode.ABORTED, - undefined, - true - ) - ) + for (const active of this.activeByConversation.values()) { + abortActive(active) } this.interfaces.clear() this.activeByConversation.clear() @@ -541,18 +476,10 @@ export class ConversationRuntime { } for (const active of Array.from(this.activeByConversation.values())) { - if (active.platform === platform) { - flushRoundDecision(active, false) - active.abortController.abort( - new ChatLunaError( - ChatLunaErrorCode.ABORTED, - undefined, - true - ) - ) - this.activeByConversation.delete(active.conversationId) - this.interfaces.delete(active.conversationId) - } + if (active.platform !== platform) continue + abortActive(active) + this.activeByConversation.delete(active.conversationId) + this.interfaces.delete(active.conversationId) } for (const [conversationId, entry] of Array.from( @@ -565,7 +492,7 @@ export class ConversationRuntime { } } -const EVENT_KEYS: (keyof ChatEvents)[] = [ +const EVENT_KEYS: readonly (keyof ChatEvents)[] = [ 'llm-new-token', 'llm-queue-waiting', 'llm-used-token-count', @@ -588,45 +515,91 @@ function wrapEvents(source: ChatEvents | undefined, touch: () => void) { return out as ChatEvents } -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?.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 && - usage.output_token_details?.audio > 0 - ? [`audio=${usage.output_token_details.audio}`] - : []), - ...(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}`] - : []) - ] +function linkAbortSignal(controller: AbortController, upstream?: AbortSignal) { + if (upstream == null) return () => {} + if (upstream.aborted) { + controller.abort(upstream.reason) + return () => {} + } + const onAbort = () => controller.abort(upstream.reason) + upstream.addEventListener('abort', onAbort, { once: true }) + return () => upstream.removeEventListener('abort', onAbort) +} + +function buildHumanMessage( + session: Session, + message: Message, + conversation: ConversationRecord +) { + const humanMessage = new HumanMessage({ + content: message.content, + name: message.name, + id: session.userId, + additional_kwargs: { + ...message.additional_kwargs, + preset: conversation.preset + } + }) + markChatLunaUserMessage(humanMessage) + return humanMessage +} - return [ +function buildRequestKey(session: Session, conversationId: string) { + return JSON.stringify([ + session.userId, + session.guildId ?? '', + conversationId + ]) +} + +function requirePlatform(conversation: ConversationRecord) { + const [platform] = parseRawModelName(conversation.model) + if (platform == null) { + throw new ChatLunaError( + ChatLunaErrorCode.UNKNOWN_ERROR, + new Error(`Invalid conversation model: ${conversation.model}`) + ) + } + return platform +} + +function formatTokenDetail( + detail: Record | undefined, + fields: readonly { key: string; positiveOnly?: boolean }[] +) { + if (detail == null) return [] + const parts: string[] = [] + for (const { key, positiveOnly } of fields) { + const value = detail[key] + if (value == null) continue + if (positiveOnly && !(value > 0)) continue + parts.push(`${key}=${value}`) + } + return parts +} + +function formatUsageMetadataMessage(usage: UsageMetadata) { + const input = formatTokenDetail(usage.input_token_details, [ + { key: 'audio', positiveOnly: true }, + { key: 'image', positiveOnly: true }, + { key: 'cache_read' }, + { key: 'cache_creation' } + ]) + const output = formatTokenDetail(usage.output_token_details, [ + { key: 'audio', positiveOnly: true }, + { key: 'image', positiveOnly: true }, + { key: 'reasoning' } + ]) + + const lines = [ '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') + `- total: ${usage.total_tokens}` + ] + if (input.length > 0) lines.push(`- input details: ${input.join(', ')}`) + if (output.length > 0) lines.push(`- output details: ${output.join(', ')}`) + return lines.join('\n') } function flushRoundDecision(active: ActiveRequest, canContinue: boolean) { diff --git a/packages/core/src/services/conversation_types.ts b/packages/core/src/services/conversation_types.ts index 478898210..6a785052c 100644 --- a/packages/core/src/services/conversation_types.ts +++ b/packages/core/src/services/conversation_types.ts @@ -184,6 +184,70 @@ export class ConversationResolutionError extends Error { } } +export class ConversationNotFoundError extends Error { + constructor() { + super('Conversation not found.') + this.name = 'ConversationNotFoundError' + } +} + +export type ConstraintAction = + | 'create' + | 'switch' + | 'rename' + | 'delete' + | 'archive' + | 'restore' + | 'export' + | 'update' + | 'compress' + +export class ConstraintLockedError extends Error { + constructor(public readonly action: ConstraintAction) { + super(`Conversation ${action} is locked by constraint.`) + this.name = 'ConstraintLockedError' + } +} + +export class ConstraintDisabledError extends Error { + constructor(public readonly action: ConstraintAction) { + super(`Conversation ${action} is disabled by constraint.`) + this.name = 'ConstraintDisabledError' + } +} + +export type ConstraintFixedField = 'model' | 'preset' | 'chatMode' + +const FIXED_FIELD_LABEL: Record = { + model: 'Model', + preset: 'Preset', + chatMode: 'Chat mode' +} + +export class ConstraintFixedError extends Error { + constructor( + public readonly field: ConstraintFixedField, + public readonly value: string + ) { + super(`${FIXED_FIELD_LABEL[field]} is fixed to ${value}.`) + this.name = 'ConstraintFixedError' + } +} + +export class InvalidChatModeError extends Error { + constructor(public readonly mode: string) { + super(`Chat mode ${mode} not found.`) + this.name = 'InvalidChatModeError' + } +} + +export class AdminRequiredError extends Error { + constructor() { + super('Conversation management requires administrator permission.') + this.name = 'AdminRequiredError' + } +} + export interface ResolveConversationOptions { mode?: ConversationResolveMode presetLane?: string