From f7ea824062ae461394ab71f99866298a261ed504 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 15 Aug 2026 17:16:30 +0800 Subject: [PATCH 1/6] fix(core): make autoUpdateConversationModel work without rule The trigger condition required resolved.constraint.autoUpdateModel === true, but autoUpdateModel defaulted to undefined when no rule was configured, so the global autoUpdateConversationModel switch never took effect. Default it to true so the global switch works; rule autoUpdateModel/fixedModel can still opt out per-route. Refs #1012 --- packages/core/src/services/conversation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 4df3212bf..94250551b 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -207,7 +207,8 @@ export class ConversationService { fixedModel: firstDefined(constraints, 'fixedModel'), fixedPreset: firstDefined(constraints, 'fixedPreset'), fixedChatMode: firstDefined(constraints, 'fixedChatMode'), - autoUpdateModel: firstDefined(constraints, 'autoUpdateModel'), + autoUpdateModel: + firstDefined(constraints, 'autoUpdateModel') ?? true, lockConversation: firstBoolean( constraints, 'lockConversation', From bd42995d985df772f9f584b573cd6cf0776c666a Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 15 Aug 2026 17:19:30 +0800 Subject: [PATCH 2/6] feat(core): auto opt out of model auto-update on manual selection use.model now writes rule autoUpdateModel: false so the manually picked model is not overwritten by the global autoUpdateConversationModel switch; use.model -a restores following and switches to the default model. No modelMode field needed - route-level rule controls the behavior. Refs #1012 --- packages/core/src/commands/conversation.ts | 18 ++++++++++++++++-- packages/core/src/locales/en-US.yml | 4 ++++ packages/core/src/locales/zh-CN.yml | 4 ++++ .../middlewares/system/conversation_manage.ts | 8 +++++++- packages/core/src/services/conversation.ts | 16 +++++++++++++++- 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index a8189e514..37c5a6fda 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -259,11 +259,23 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { ) }) - ctx.command('chatluna.use.model ', { + ctx.command('chatluna.use.model [model:string]', { authority: 1 }) .option('preset', '-p ') + .option('auto', '-a') .action(async ({ options, session }, model) => { + const value = model?.trim() || undefined + if (options.auto && value != null) { + return session.text( + 'commands.chatluna.use.model.messages.auto_with_model' + ) + } + if (!options.auto && value == null) { + return session.text( + 'commands.chatluna.use.model.messages.model_required' + ) + } await chain.receiveCommand( session, 'conversation_use_model', @@ -272,7 +284,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { presetLane: options.preset?.trim() || undefined }, conversation_use: { - model: model?.trim() || undefined + model: value, + auto: options.auto || undefined } }, ctx @@ -435,6 +448,7 @@ declare module '../chains/chain' { } conversation_use?: { model?: string + auto?: boolean preset?: string chatMode?: string } diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index f6c12d080..1fb77ba3a 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -93,6 +93,10 @@ commands: model: Target model name. options: preset: Preset lane. + auto: Resume following the default model. + messages: + model_required: Specify a target model, or use `-a` to resume following the default model. + auto_with_model: Cannot specify a model together with `-a`. preset: description: Switch the conversation preset. arguments: diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 072a7ed27..4f5696e5f 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -93,6 +93,10 @@ commands: model: 目标模型名称。 options: preset: 预设分流。 + auto: 恢复当前会话跟随默认模型。 + messages: + model_required: 请指定目标模型,或使用 `-a` 恢复跟随默认模型。 + auto_with_model: 不能同时指定模型和使用 `-a`。 preset: description: 切换当前会话使用的预设。 arguments: diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index 9eb98ee99..7ebfd528a 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -381,7 +381,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { conversationId: resolvedConversationId(context), presetLane: context.options.conversation_manage?.presetLane, - [field]: context.options.conversation_use?.[field] + [field]: context.options.conversation_use?.[field], + ...(field === 'model' + ? { + auto: context.options.conversation_use + ?.auto + } + : {}) } ) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 94250551b..7d78bc8af 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1667,6 +1667,7 @@ export class ConversationService { session: Session, options: ResolveConversationOptions & { model?: string + auto?: boolean preset?: string chatMode?: string } @@ -1713,6 +1714,16 @@ export class ConversationService { this.checkChatMode(options.chatMode) + if (options.auto === true) { + await this.updateManagedConstraint(session, { + autoUpdateModel: true + }) + } else if (options.model != null) { + await this.updateManagedConstraint(session, { + autoUpdateModel: false + }) + } + const updated = await this.runtime.withConversationLock( conversation.id, async () => { @@ -1721,7 +1732,10 @@ export class ConversationService { await this.runtime.clearConversationInterfaceLocked(current) return this.touchConversation(conversation.id, { - model: options.model?.trim(), + model: + options.auto === true + ? this.pickModel(resolved.constraint, null) + : options.model?.trim(), preset: options.preset, chatMode: options.chatMode }) From 20473bff6465530987a8aa8cac7cc0afb7b34faa Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 15 Aug 2026 18:58:24 +0800 Subject: [PATCH 3/6] fix(core): update managed constraint by bindingKey, skip null model - use.model now writes autoUpdateModel to the target conversation's managed constraint via bindingKey instead of the current session, so cross-binding operations affect the right route. - auto mode skips writing model when pickModel returns null, matching applyAutoModelUpdate's no-op behavior instead of persisting null. Refs #1012 --- packages/core/src/services/conversation.ts | 84 +++++++++++++++++----- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 7d78bc8af..7ec3f93aa 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1715,15 +1715,24 @@ export class ConversationService { this.checkChatMode(options.chatMode) if (options.auto === true) { - await this.updateManagedConstraint(session, { - autoUpdateModel: true - }) + await this.updateManagedConstraintByBindingKey( + session, + conversation.bindingKey, + { autoUpdateModel: true } + ) } else if (options.model != null) { - await this.updateManagedConstraint(session, { - autoUpdateModel: false - }) + await this.updateManagedConstraintByBindingKey( + session, + conversation.bindingKey, + { autoUpdateModel: false } + ) } + const autoModel = + options.auto === true + ? this.pickModel(resolved.constraint, null) + : undefined + const updated = await this.runtime.withConversationLock( conversation.id, async () => { @@ -1732,10 +1741,10 @@ export class ConversationService { await this.runtime.clearConversationInterfaceLocked(current) return this.touchConversation(conversation.id, { - model: - options.auto === true - ? this.pickModel(resolved.constraint, null) - : options.model?.trim(), + ...(autoModel != null ? { model: autoModel } : {}), + ...(options.model != null + ? { model: options.model.trim() } + : {}), preset: options.preset, chatMode: options.chatMode }) @@ -1856,28 +1865,65 @@ export class ConversationService { async updateManagedConstraint( session: Session, patch: Partial + ) { + return this.updateManagedConstraintFor( + session, + session, + buildManagedConstraintName(session), + patch + ) + } + + async updateManagedConstraintByBindingKey( + session: Session, + bindingKey: string, + patch: Partial + ) { + const name = managedNameFromBindingKey(bindingKey) + if (name == null) { + return undefined + } + + const parts = getBaseBindingKey(bindingKey).split(':') + const direct = parts[0] === 'personal' && parts[3] === 'direct' + const target = { + ...session, + isDirect: direct, + userId: direct ? (parts[4] as string) : session.userId, + guildId: direct ? undefined : (parts[3] as string), + channelId: undefined + } as Session + + return this.updateManagedConstraintFor(session, target, name, patch) + } + + private async updateManagedConstraintFor( + session: Session, + target: Session, + name: string, + patch: Partial ) { this.checkChatMode(patch.defaultChatMode) this.checkChatMode(patch.fixedChatMode) - const current = await this.getManagedConstraint(session) + const current = await this.firstRow('chatluna_constraint', { name }) const now = new Date() - const guildId = session.isDirect + const guildId = target.isDirect ? null - : (session.guildId ?? session.channelId ?? null) + : (target.guildId ?? target.channelId ?? null) const record: ConstraintRecord = { id: current?.id, - name: buildManagedConstraintName(session), + name, enabled: true, priority: 1000, createdBy: session.userId, createdAt: now, - platform: session.platform, - selfId: session.selfId, + platform: target.platform, + selfId: target.selfId, guildId, channelId: null, - direct: session.isDirect, - users: session.isDirect ? JSON.stringify([session.userId]) : null, + direct: target.isDirect, + users: target.isDirect ? JSON.stringify([target.userId]) : null, excludeUsers: null, routeMode: null, routeKey: null, @@ -1903,7 +1949,7 @@ export class ConversationService { await this.ctx.root.parallel('chatluna/after-constraint-update', { constraint: record }) - return (await this.getManagedConstraint(session)) ?? record + return (await this.firstRow('chatluna_constraint', { name })) ?? record } pickModel( From f6a1ef5a2f4a1db8120fc97539ad17dcfaf26ff6 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 15 Aug 2026 19:56:27 +0800 Subject: [PATCH 4/6] fix(core): derive platform/selfId from bindingKey in managed constraint updateManagedConstraintByBindingKey kept the caller session's platform and selfId while the constraint name was built from the target bindingKey, persisting records whose scope metadata mismatched their name. Parse platform/selfId from the bindingKey so the constraint matches the target route. Refs #1012 --- packages/core/src/services/conversation.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 7ec3f93aa..0f9ed3a67 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1888,6 +1888,8 @@ export class ConversationService { const direct = parts[0] === 'personal' && parts[3] === 'direct' const target = { ...session, + platform: parts[1] as string, + selfId: parts[2] as string, isDirect: direct, userId: direct ? (parts[4] as string) : session.userId, guildId: direct ? undefined : (parts[3] as string), From cdbe82876933baaeec93d6c3897078ad9ef4783b Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 15 Aug 2026 20:11:21 +0800 Subject: [PATCH 5/6] feat(core): prompt for empty chat message, test global default model - chatluna.chat without text now returns a message_required hint instead of koishi's generic missing-argument error. - chatluna.model.test without a model falls back to the global defaultModel; if neither is set, show a model_required hint. Refs #1012 --- packages/core/src/commands/chat.ts | 6 +++++- packages/core/src/commands/model.ts | 2 +- packages/core/src/locales/en-US.yml | 2 ++ packages/core/src/locales/zh-CN.yml | 2 ++ .../core/src/middlewares/model/test_model.ts | 18 ++++++++++++------ 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/core/src/commands/chat.ts b/packages/core/src/commands/chat.ts index 1fd0be9b2..819194b1b 100644 --- a/packages/core/src/commands/chat.ts +++ b/packages/core/src/commands/chat.ts @@ -13,11 +13,15 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { hideSlashGroups(root) - ctx.command('chatluna.chat ') + ctx.command('chatluna.chat [message:text]') .option('conversation', '-c ') .option('preset', '-p ') .option('type', '-t ') .action(async ({ options, session }, message) => { + if (message == null || message.trim().length === 0) { + return session.text('.messages.message_required') + } + const renderType = options.type ?? config.outputMode const presetLane = options.preset?.trim() || undefined const allPresetLanes = presetLane == null diff --git a/packages/core/src/commands/model.ts b/packages/core/src/commands/model.ts index c624c4c44..01b85a36f 100644 --- a/packages/core/src/commands/model.ts +++ b/packages/core/src/commands/model.ts @@ -28,7 +28,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { }) }) - ctx.command('chatluna.model.test ').action( + ctx.command('chatluna.model.test [model:string]').action( async ({ session }, model) => { await chain.receiveCommand(session, 'test_model', { model diff --git a/packages/core/src/locales/en-US.yml b/packages/core/src/locales/en-US.yml index 1fb77ba3a..0b6e0bcf1 100644 --- a/packages/core/src/locales/en-US.yml +++ b/packages/core/src/locales/en-US.yml @@ -151,6 +151,7 @@ commands: message: Message content to send. messages: invalid-render-type: 'Invalid rendering type. Supported types: `text` and `voice`.' + message_required: 'Please enter a message to send. Usage: `chatluna chat Hello, world!`' rollback: description: Regenerate last conversation content. options: @@ -242,6 +243,7 @@ commands: model: Model name (e.g., openai/gpt-3.5-turbo) or platform name (e.g., openai). usage: 'chatluna model test openai/gpt-3.5-turbo' messages: + model_required: 'No model specified and no global default model set. Usage: `chatluna model test openai/gpt-3.5-turbo`.' platform_not_found: 'Platform {0} not found. Please check the platform name or ensure the adapter is installed.' platform_unavailable: 'Platform {0} is unavailable. Please check the configuration or wait for the platform to load.' model_not_found: 'Model {0} not found. Please verify the model name is correct.' diff --git a/packages/core/src/locales/zh-CN.yml b/packages/core/src/locales/zh-CN.yml index 4f5696e5f..8728f708a 100644 --- a/packages/core/src/locales/zh-CN.yml +++ b/packages/core/src/locales/zh-CN.yml @@ -151,6 +151,7 @@ commands: message: 要发送的消息内容。 messages: invalid-render-type: 渲染类型无效。请使用有效的渲染类型,目前支持的类有 `text` 和 `voice`。 + message_required: 请输入要发送的消息内容。用法:`chatluna chat 你好,世界!` rollback: description: 重新生成上一次的对话内容。 options: @@ -242,6 +243,7 @@ commands: model: 模型名称(如 openai/gpt-3.5-turbo)或平台名称(如 openai)。 usage: 'chatluna model test openai/gpt-3.5-turbo' messages: + model_required: 未指定模型且未设置全局默认模型。用法:`chatluna model test openai/gpt-3.5-turbo`。 platform_not_found: '未找到平台 {0},请检查平台名称或确认平台适配器已安装。' platform_unavailable: '平台 {0} 不可用,请检查配置或等待平台加载。' model_not_found: '未找到模型 {0},请检查模型名称是否正确。' diff --git a/packages/core/src/middlewares/model/test_model.ts b/packages/core/src/middlewares/model/test_model.ts index 9237d1938..a14586776 100644 --- a/packages/core/src/middlewares/model/test_model.ts +++ b/packages/core/src/middlewares/model/test_model.ts @@ -21,13 +21,19 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { let platformName: string let modelName: string + const target = model?.trim() || config.defaultModel + + if (!target || target === '无' || target === 'empty') { + context.message = session.text('.model_required') + return ChainMiddlewareRunStatus.STOP + } try { // Check if the input contains '/' - if (!model.includes('/')) { + if (!target.includes('/')) { // Get all models from the specified platform const platformModels = services.listPlatformModels( - model, + target, ModelType.llm ) @@ -36,7 +42,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { platformModels.value.length === 0 ) { context.message = session.text('.platform_not_found', [ - model + target ]) return ChainMiddlewareRunStatus.STOP } @@ -46,17 +52,17 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { Math.random() * platformModels.value.length ) const selectedModel = platformModels.value[randomIndex] - platformName = model + platformName = target modelName = selectedModel.name } else { // Parse the full model name - ;[platformName, modelName] = parseRawModelName(model) + ;[platformName, modelName] = parseRawModelName(target) // Validate that parsing succeeded if (!platformName || !modelName) { context.message = session.text( '.invalid_model_format', - [model] + [target] ) return ChainMiddlewareRunStatus.STOP } From 0fbcab86b6456d27e7f6fd5acc289c8a04ad6021 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sat, 15 Aug 2026 20:18:54 +0800 Subject: [PATCH 6/6] fix(core): only fall back to default model when arg omitted model?.trim() ?? config.defaultModel.trim() distinguishes an omitted argument (undefined -> default) from an explicitly empty/whitespace argument ('' -> model_required), instead of || which silently fell back for empty input too. Refs #1012 --- packages/core/src/middlewares/model/test_model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/middlewares/model/test_model.ts b/packages/core/src/middlewares/model/test_model.ts index a14586776..389542f61 100644 --- a/packages/core/src/middlewares/model/test_model.ts +++ b/packages/core/src/middlewares/model/test_model.ts @@ -21,7 +21,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { let platformName: string let modelName: string - const target = model?.trim() || config.defaultModel + const target = model?.trim() ?? config.defaultModel.trim() if (!target || target === '无' || target === 'empty') { context.message = session.text('.model_required')