From 70814bb1e8859932fd6c909cdcec709e761ac187 Mon Sep 17 00:00:00 2001 From: CookSleep Date: Fri, 3 Apr 2026 13:29:48 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(agent):=20=E6=98=8E=E7=A1=AE=20pending?= =?UTF-8?q?=20=E6=B6=88=E6=81=AF=E6=B6=88=E8=B4=B9=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/llm-core/agent/legacy-executor.ts | 31 +++++++++++++++++-- packages/core/src/llm-core/agent/sub-agent.ts | 1 + packages/core/src/llm-core/agent/types.ts | 3 +- packages/core/src/services/chat.ts | 15 ++++----- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/core/src/llm-core/agent/legacy-executor.ts b/packages/core/src/llm-core/agent/legacy-executor.ts index 94765e423..30b9224ba 100644 --- a/packages/core/src/llm-core/agent/legacy-executor.ts +++ b/packages/core/src/llm-core/agent/legacy-executor.ts @@ -234,7 +234,7 @@ export async function* runAgent( yield { type: 'round-decision', - canContinue: false + willConsumePendingMessages: false } const pending = queue?.drain() ?? [] @@ -262,7 +262,7 @@ export async function* runAgent( yield { type: 'round-decision', - canContinue: !tool?.returnDirect + willConsumePendingMessages: !tool?.returnDirect } yield { @@ -293,6 +293,31 @@ export async function* runAgent( const last = newSteps[newSteps.length - 1] const tool = last ? toolMap[last.action.tool?.toLowerCase()] : undefined + if (last?.observation === '__character_reply_final__') { + yield { + type: 'round-decision', + willConsumePendingMessages: false + } + + const pending = queue?.drain() ?? [] + if (pending.length > 0) { + yield { + type: 'human-update', + messages: pending + } + } + + yield { + type: 'done', + output: '', + log: last.action.log, + steps, + replyEmitted: true + } + + return + } + if (tool?.returnDirect && last != null) { const pending = queue?.drain() ?? [] if (pending.length > 0) { @@ -317,7 +342,7 @@ export async function* runAgent( yield { type: 'round-decision', - canContinue: false + willConsumePendingMessages: false } yield { diff --git a/packages/core/src/llm-core/agent/sub-agent.ts b/packages/core/src/llm-core/agent/sub-agent.ts index c8ecb7ab0..7bf74d242 100644 --- a/packages/core/src/llm-core/agent/sub-agent.ts +++ b/packages/core/src/llm-core/agent/sub-agent.ts @@ -784,6 +784,7 @@ async function onTaskEvent( at: Date.now(), title: '最终输出', text: + (event.replyEmitted ? '最终回复已由工具发送。' : '') || getMessageContent(event.message?.content ?? '') || event.output || event.log diff --git a/packages/core/src/llm-core/agent/types.ts b/packages/core/src/llm-core/agent/types.ts index 9ef3a757c..f96253256 100644 --- a/packages/core/src/llm-core/agent/types.ts +++ b/packages/core/src/llm-core/agent/types.ts @@ -244,7 +244,7 @@ export type AgentEvent = } | { type: 'round-decision' - canContinue?: boolean + willConsumePendingMessages?: boolean } | { type: 'done' @@ -252,6 +252,7 @@ export type AgentEvent = log: string steps: AgentStep[] message?: AIMessage + replyEmitted?: boolean } export interface AgentRuntimeConfigurable { diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index 94be56139..bba313500 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -968,7 +968,7 @@ type ActiveRequest = { abortController: AbortController chatMode: string messageQueue: MessageQueue - roundDecisionResolvers: ((canContinue: boolean) => void)[] + roundDecisionResolvers: ((willConsume: boolean) => void)[] lastDecision?: boolean } @@ -1096,13 +1096,14 @@ class ChatInterfaceWrapper { toolMask: mask, onAgentEvent: async (agentEvent) => { if (agentEvent.type === 'round-decision') { - activeRequest.lastDecision = agentEvent.canContinue - if (agentEvent.canContinue == null) { + activeRequest.lastDecision = + agentEvent.willConsumePendingMessages + if (agentEvent.willConsumePendingMessages == null) { return } for (const resolve of activeRequest.roundDecisionResolvers) { - resolve(agentEvent.canContinue) + resolve(agentEvent.willConsumePendingMessages) } activeRequest.roundDecisionResolvers = [] } @@ -1202,11 +1203,11 @@ class ChatInterfaceWrapper { } return new Promise((resolve) => { - activeRequest.roundDecisionResolvers.push((canContinue) => { - if (canContinue) { + activeRequest.roundDecisionResolvers.push((willConsume) => { + if (willConsume) { activeRequest.messageQueue.push(message) } - resolve(canContinue) + resolve(willConsume) }) }) } From 0cc2c113be5320861dc73a66e92ca7b56a5c3a2e Mon Sep 17 00:00:00 2001 From: CookSleep Date: Sun, 5 Apr 2026 20:05:45 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(agent):=20=E6=94=B9=E4=B8=BA=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=20direct=20tool=20output=20=E5=86=B3=E5=AE=9A?= =?UTF-8?q?=E7=BB=88=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/llm-core/agent/legacy-executor.ts | 26 ++++++++++++------- packages/core/src/llm-core/agent/types.ts | 8 ++++-- packages/core/src/services/chat.ts | 7 +++-- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/core/src/llm-core/agent/legacy-executor.ts b/packages/core/src/llm-core/agent/legacy-executor.ts index 30b9224ba..cc2593ee0 100644 --- a/packages/core/src/llm-core/agent/legacy-executor.ts +++ b/packages/core/src/llm-core/agent/legacy-executor.ts @@ -1,5 +1,6 @@ import { CallbackManagerForChainRun } from '@langchain/core/callbacks/manager' import { AIMessage, AIMessageChunk } from '@langchain/core/messages' +import { isDirectToolOutput } from '@langchain/core/messages/tool' import { OutputParserException } from '@langchain/core/output_parsers' import { patchConfig, @@ -234,7 +235,7 @@ export async function* runAgent( yield { type: 'round-decision', - willConsumePendingMessages: false + canContinue: false } const pending = queue?.drain() ?? [] @@ -257,12 +258,8 @@ export async function* runAgent( } if (output.length > 0) { - const last = output[output.length - 1] - const tool = toolMap[last.tool?.toLowerCase()] - yield { - type: 'round-decision', - willConsumePendingMessages: !tool?.returnDirect + type: 'round-decision' } yield { @@ -291,12 +288,11 @@ export async function* runAgent( } const last = newSteps[newSteps.length - 1] - const tool = last ? toolMap[last.action.tool?.toLowerCase()] : undefined if (last?.observation === '__character_reply_final__') { yield { type: 'round-decision', - willConsumePendingMessages: false + canContinue: false } const pending = queue?.drain() ?? [] @@ -318,7 +314,12 @@ export async function* runAgent( return } - if (tool?.returnDirect && last != null) { + if (last != null && isDirectToolOutput(last.observation)) { + yield { + type: 'round-decision', + canContinue: false + } + const pending = queue?.drain() ?? [] if (pending.length > 0) { yield { @@ -337,12 +338,17 @@ export async function* runAgent( return } + yield { + type: 'round-decision', + canContinue: true + } + iterations += 1 } yield { type: 'round-decision', - willConsumePendingMessages: false + canContinue: false } yield { diff --git a/packages/core/src/llm-core/agent/types.ts b/packages/core/src/llm-core/agent/types.ts index f96253256..75d33b675 100644 --- a/packages/core/src/llm-core/agent/types.ts +++ b/packages/core/src/llm-core/agent/types.ts @@ -5,6 +5,7 @@ import type { MessageContentImageUrl, MessageContentText } from '@langchain/core/messages' +import type { DirectToolOutput } from '@langchain/core/messages/tool' import type { MessageContentAudio, MessageContentFileUrl, @@ -193,7 +194,10 @@ export type AgentObservationComplexContent = | MessageContentAudio | MessageContentVideo -export type AgentObservation = AgentObservationComplexContent[] | string +export type AgentObservation = + | AgentObservationComplexContent[] + | DirectToolOutput + | string export interface ToolMask { mode: 'all' | 'allow' | 'deny' @@ -244,7 +248,7 @@ export type AgentEvent = } | { type: 'round-decision' - willConsumePendingMessages?: boolean + canContinue?: boolean } | { type: 'done' diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index bba313500..09af8dff5 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -1096,14 +1096,13 @@ class ChatInterfaceWrapper { toolMask: mask, onAgentEvent: async (agentEvent) => { if (agentEvent.type === 'round-decision') { - activeRequest.lastDecision = - agentEvent.willConsumePendingMessages - if (agentEvent.willConsumePendingMessages == null) { + activeRequest.lastDecision = agentEvent.canContinue + if (agentEvent.canContinue == null) { return } for (const resolve of activeRequest.roundDecisionResolvers) { - resolve(agentEvent.willConsumePendingMessages) + resolve(agentEvent.canContinue) } activeRequest.roundDecisionResolvers = [] } From 4934e901272bea392f8bb4df3d937b13e709a7fa Mon Sep 17 00:00:00 2001 From: CookSleep Date: Sun, 5 Apr 2026 20:45:39 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(agent):=20=E7=94=A8=20direct=20tool=20o?= =?UTF-8?q?utput=20=E7=BB=93=E6=9D=9F=E4=BC=AA=E8=A3=85=E5=9B=9E=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/llm-core/agent/legacy-executor.ts | 33 ++++--------------- packages/core/src/llm-core/agent/types.ts | 2 +- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/core/src/llm-core/agent/legacy-executor.ts b/packages/core/src/llm-core/agent/legacy-executor.ts index cc2593ee0..914c28b17 100644 --- a/packages/core/src/llm-core/agent/legacy-executor.ts +++ b/packages/core/src/llm-core/agent/legacy-executor.ts @@ -289,31 +289,6 @@ export async function* runAgent( const last = newSteps[newSteps.length - 1] - if (last?.observation === '__character_reply_final__') { - yield { - type: 'round-decision', - canContinue: false - } - - const pending = queue?.drain() ?? [] - if (pending.length > 0) { - yield { - type: 'human-update', - messages: pending - } - } - - yield { - type: 'done', - output: '', - log: last.action.log, - steps, - replyEmitted: true - } - - return - } - if (last != null && isDirectToolOutput(last.observation)) { yield { type: 'round-decision', @@ -330,9 +305,13 @@ export async function* runAgent( yield { type: 'done', - output: toOutput(last.observation), + output: + last.observation.replyEmitted === true + ? '' + : toOutput(last.observation), log: last.action.log, - steps + steps, + replyEmitted: last.observation.replyEmitted === true } return diff --git a/packages/core/src/llm-core/agent/types.ts b/packages/core/src/llm-core/agent/types.ts index 75d33b675..c210c06af 100644 --- a/packages/core/src/llm-core/agent/types.ts +++ b/packages/core/src/llm-core/agent/types.ts @@ -196,7 +196,7 @@ export type AgentObservationComplexContent = export type AgentObservation = | AgentObservationComplexContent[] - | DirectToolOutput + | (DirectToolOutput & { replyEmitted?: boolean }) | string export interface ToolMask { From 98c4c73a5cb865adfcc0b4b5de810bc4147631a6 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sun, 5 Apr 2026 21:28:55 +0800 Subject: [PATCH 4/6] fix(core,adapter-gemini): stabilize pending requests and model capability checks --- packages/adapter-gemini/src/utils.ts | 2 +- .../src/llm-core/agent/legacy-executor.ts | 11 +- .../core/src/llm-core/platform/service.ts | 31 +++- .../core/src/middlewares/chat/stop_chat.ts | 6 +- .../conversation/request_conversation.ts | 7 +- packages/core/src/services/chat.ts | 23 +-- packages/core/src/services/conversation.ts | 167 ++++++++---------- .../core/src/services/conversation_runtime.ts | 133 +++++++------- .../core/src/services/message_transform.ts | 30 ++-- packages/core/src/services/types.ts | 19 +- packages/core/src/utils/chat_request.ts | 30 ---- .../core/tests/conversation-runtime.spec.ts | 18 +- .../src/service/permissions.ts | 28 +-- .../extension-agent/src/service/sub_agent.ts | 3 +- packages/shared-adapter/src/client.ts | 4 +- 15 files changed, 238 insertions(+), 274 deletions(-) delete mode 100644 packages/core/src/utils/chat_request.ts diff --git a/packages/adapter-gemini/src/utils.ts b/packages/adapter-gemini/src/utils.ts index d912c1a64..3875a1b95 100644 --- a/packages/adapter-gemini/src/utils.ts +++ b/packages/adapter-gemini/src/utils.ts @@ -212,7 +212,7 @@ function processImageParts( !( (model.includes('vision') || model.includes('gemini') || - model.includes('gemma')) && + model.includes('gemma2')) && !model.includes('gemini-1.0') ) ) { diff --git a/packages/core/src/llm-core/agent/legacy-executor.ts b/packages/core/src/llm-core/agent/legacy-executor.ts index 914c28b17..e65042d9d 100644 --- a/packages/core/src/llm-core/agent/legacy-executor.ts +++ b/packages/core/src/llm-core/agent/legacy-executor.ts @@ -288,8 +288,12 @@ export async function* runAgent( } const last = newSteps[newSteps.length - 1] + const tool = last ? toolMap[last.action.tool?.toLowerCase()] : undefined - if (last != null && isDirectToolOutput(last.observation)) { + if ( + last != null && + (tool?.returnDirect || isDirectToolOutput(last.observation)) + ) { yield { type: 'round-decision', canContinue: false @@ -306,12 +310,13 @@ export async function* runAgent( yield { type: 'done', output: - last.observation.replyEmitted === true + // TODO: remove this property + last.observation['replyEmitted'] === true ? '' : toOutput(last.observation), log: last.action.log, steps, - replyEmitted: last.observation.replyEmitted === true + replyEmitted: last.observation['replyEmitted'] === true } return diff --git a/packages/core/src/llm-core/platform/service.ts b/packages/core/src/llm-core/platform/service.ts index 80589f339..80dbea68d 100644 --- a/packages/core/src/llm-core/platform/service.ts +++ b/packages/core/src/llm-core/platform/service.ts @@ -1,4 +1,4 @@ -import { Context, Dict } from 'koishi' +import { Awaitable, Context, Dict, Session } from 'koishi' import { BasePlatformClient, PlatformEmbeddingsClient, @@ -28,6 +28,7 @@ import { computed, ComputedRef, reactive } from '@vue/reactivity' import { randomUUID } from 'crypto' import { RunnableConfig } from '@langchain/core/runnables' import { ToolMask } from '../agent' +import type { ConversationRecord } from '../../services/conversation_types' export class PlatformService { private _platformClients: Record = reactive({}) @@ -36,6 +37,7 @@ export class PlatformService { private _tools: Record = reactive({}) private _tmpTools: Record = reactive({}) + private _toolMaskResolvers: Record = {} private _models: Record = reactive({}) private _chatChains: Record = reactive({}) private _vectorStore: Record = reactive( @@ -218,6 +220,23 @@ export class PlatformService { return allNames.filter((name) => !mask.deny.includes(name)) } + registerToolMaskResolver(name: string, resolver: ToolMaskResolver) { + this._toolMaskResolvers[name] = resolver + + return () => { + delete this._toolMaskResolvers[name] + } + } + + async resolveToolMask(arg: ToolMaskArg) { + for (const name in this._toolMaskResolvers) { + const mask = await this._toolMaskResolvers[name](arg) + if (mask) { + return mask + } + } + } + static buildToolMask(rule: { mode?: 'inherit' | 'all' | 'allow' | 'deny' allow?: string[] @@ -453,3 +472,13 @@ declare module 'koishi' { 'chatluna/tool-updated': (service: PlatformService) => void } } + +export interface ToolMaskArg { + session: Session + conversation?: ConversationRecord + bindingKey?: string +} + +export type ToolMaskResolver = ( + arg: ToolMaskArg +) => Awaitable diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index cbd8cf1a2..e30ed00f3 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -1,7 +1,6 @@ import { Context } from 'koishi' import { Config } from '../../config' import { ChainMiddlewareRunStatus, ChatChain } from '../../chains/chain' -import { getRequestId } from '../../utils/chat_request' import { checkAdmin } from 'koishi-plugin-chatluna/utils/koishi' export function apply(ctx: Context, config: Config, chain: ChatChain) { @@ -82,7 +81,10 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } context.options.conversationId = conversation.id - const requestId = getRequestId(session, conversation.id) + const requestId = ctx.chatluna.conversationRuntime.getRequestId( + session, + conversation.id + ) if (requestId == null) { context.message = session.text('.no_active_chat') diff --git a/packages/core/src/middlewares/conversation/request_conversation.ts b/packages/core/src/middlewares/conversation/request_conversation.ts index ab0b2e6ff..73aa60583 100644 --- a/packages/core/src/middlewares/conversation/request_conversation.ts +++ b/packages/core/src/middlewares/conversation/request_conversation.ts @@ -31,7 +31,6 @@ import { MessageContent, MessageContentComplex } from '@langchain/core/messages' -import { createRequestId } from '../../utils/chat_request' import { AgentAction } from 'koishi-plugin-chatluna/llm-core/agent' let logger: Logger @@ -127,11 +126,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { inputMessage.name = session.author?.name ?? session.author?.id ?? session.username - const requestId = createRequestId( - session, - conversation.id, - context.options.messageId - ) + const requestId = context.options.messageId const chatCallbacks = createChatCallbacks( context, diff --git a/packages/core/src/services/chat.ts b/packages/core/src/services/chat.ts index 265313b6b..18e568777 100644 --- a/packages/core/src/services/chat.ts +++ b/packages/core/src/services/chat.ts @@ -34,7 +34,11 @@ import { ChatLunaBaseEmbeddings, ChatLunaChatModel } from 'koishi-plugin-chatluna/llm-core/platform/model' -import { PlatformService } from 'koishi-plugin-chatluna/llm-core/platform/service' +import { + PlatformService, + ToolMaskArg, + ToolMaskResolver +} from 'koishi-plugin-chatluna/llm-core/platform/service' import { ChatLunaTool, CreateChatLunaLLMChainParams, @@ -49,7 +53,7 @@ import { ChatLunaErrorCode } from 'koishi-plugin-chatluna/utils/error' import { MessageTransformer } from './message_transform' -import { ChatEvents, ToolMaskArg, ToolMaskResolver } from './types' +import { ChatEvents } from './types' import { ConversationService } from './conversation' import { ConversationRuntime } from './conversation_runtime' import { ConstraintRecord, ConversationRecord } from './conversation_types' @@ -83,8 +87,6 @@ export class ChatLunaService extends Service { private readonly _contextManager: ChatLunaContextManagerService private readonly _conversation: ConversationService private readonly _conversationRuntime: ConversationRuntime - private _toolMaskResolvers: Record = {} - declare public config: Config declare public currentConfig: Config @@ -205,20 +207,11 @@ export class ChatLunaService extends Service { } registerToolMaskResolver(name: string, resolver: ToolMaskResolver) { - this._toolMaskResolvers[name] = resolver - - return () => { - delete this._toolMaskResolvers[name] - } + return this._platformService.registerToolMaskResolver(name, resolver) } async resolveToolMask(arg: ToolMaskArg) { - for (const name in this._toolMaskResolvers) { - const mask = await this._toolMaskResolvers[name](arg) - if (mask) { - return mask - } - } + return this._platformService.resolveToolMask(arg) } getPlugin(platformName: string) { diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index ded80a602..12c451c80 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1,14 +1,13 @@ import { createHash, randomUUID } from 'crypto' import fs from 'fs/promises' import path from 'path' -import type { MessageContent } from '@langchain/core/messages' import type { Context, Session } from 'koishi' import type { Config } from '../config' import { deserializeConversation, deserializeMessage, - removeArchive, readArchivePayload, + removeArchive, serializeConversation, serializeMessage, unbindConversation @@ -38,8 +37,8 @@ import { computeBaseBindingKey, ConstraintPermission, ConstraintRecord, - ConversationListEntry, ConversationCompressionRecord, + ConversationListEntry, ConversationRecord, getBaseBindingKey, getPresetLane, @@ -367,57 +366,52 @@ export class ConversationService { chatMode: string } ) { - return getLock(this._bindingLocks, options.bindingKey).runLocked( - async () => { - const now = new Date() - const conversation: ConversationRecord = { - id: randomUUID(), - seq: await this.allocateConversationSeq(options.bindingKey), - bindingKey: options.bindingKey, - title: options.title, - model: options.model, - preset: options.preset, - chatMode: options.chatMode, - createdBy: session.userId, - createdAt: now, - updatedAt: now, - lastChatAt: now, - status: 'active', - latestMessageId: null, - additional_kwargs: null, - compression: null, - archivedAt: null, - archiveId: null, - legacyRoomId: null, - legacyMeta: null, - autoTitle: true - } - - await this.ctx.root.parallel( - 'chatluna/conversation-before-create', - { - conversation, - bindingKey: options.bindingKey - } - ) - await this.ctx.database.create( - 'chatluna_conversation', - conversation - ) - await this.setActiveConversation( - options.bindingKey, - conversation.id - ) - await this.ctx.root.parallel( - 'chatluna/conversation-after-create', - { - conversation, - bindingKey: options.bindingKey - } - ) - return conversation + return runLock(this._bindingLocks, options.bindingKey, async () => { + const now = new Date() + const conversation: ConversationRecord = { + id: randomUUID(), + seq: await this.allocateConversationSeq(options.bindingKey), + bindingKey: options.bindingKey, + title: options.title, + model: options.model, + preset: options.preset, + chatMode: options.chatMode, + createdBy: session.userId, + createdAt: now, + updatedAt: now, + lastChatAt: now, + status: 'active', + latestMessageId: null, + additional_kwargs: null, + compression: null, + archivedAt: null, + archiveId: null, + legacyRoomId: null, + legacyMeta: null, + autoTitle: true } - ) + + await this.ctx.root.parallel( + 'chatluna/conversation-before-create', + { + conversation, + bindingKey: options.bindingKey + } + ) + await this.ctx.database.create( + 'chatluna_conversation', + conversation + ) + await this.setActiveConversation( + options.bindingKey, + conversation.id + ) + await this.ctx.root.parallel('chatluna/conversation-after-create', { + conversation, + bindingKey: options.bindingKey + }) + return conversation + }) } async setActiveConversation(bindingKey: string, conversationId: string) { @@ -463,7 +457,7 @@ export class ConversationService { } async claimAutoTitle(conversationId: string) { - return getLock(this._titleLocks, conversationId).runLocked(async () => { + return runLock(this._titleLocks, conversationId, async () => { const conversation = await this.getConversation(conversationId) if (conversation == null || !conversation.autoTitle) { return false @@ -1351,7 +1345,10 @@ export class ConversationService { return undefined } - const current = parseCompressionRecord(conversation.compression) + const current = JSON.parse( + conversation.compression ?? 'null' + ) as ConversationCompressionRecord + const summaryMessage = ( (await this.ctx.database.get( 'chatluna_message', @@ -1828,12 +1825,12 @@ function isConstraintMatched(constraint: ConstraintRecord, session: Session) { return false } - const users = parseJsonArray(constraint.users) + const users = JSON.parse(constraint.users ?? '[]') if (users != null && !users.includes(session.userId)) { return false } - const excludeUsers = parseJsonArray(constraint.excludeUsers) + const excludeUsers = JSON.parse(constraint.excludeUsers ?? '[]') if (excludeUsers != null && excludeUsers.includes(session.userId)) { return false } @@ -1900,45 +1897,8 @@ async function hasConversationPermission( }) } -function parseJsonArray(value?: string | null) { - if (value == null || value.length === 0) { - return null - } - - try { - const parsed = JSON.parse(value) - return Array.isArray(parsed) ? parsed.map(String) : null - } catch { - return null - } -} - -function parseCompressionRecord(value?: string | null) { - if (value == null || value.length === 0) { - return null - } - - try { - return JSON.parse(value) as ConversationCompressionRecord - } catch { - return null - } -} - -async function parseContent(message: MessageRecord) { - if (message.content == null) { - return null - } - - try { - return JSON.parse(await gzipDecode(message.content)) as MessageContent - } catch { - return null - } -} - async function readText(message: MessageRecord) { - const content = await parseContent(message) + const content = await JSON.parse(await gzipDecode(message.content)) if (content == null) { return message.text ?? '' @@ -1956,7 +1916,7 @@ function formatUrl(url: string) { } async function formatMessage(message: MessageRecord) { - const content = await parseContent(message) + const content = await JSON.parse(await gzipDecode(message.content)) const text = content == null @@ -2136,11 +2096,22 @@ function firstBoolean( return fallback } -function getLock(locks: Map, key: string) { +async function runLock( + locks: Map, + key: string, + fn: () => Promise +) { let lock = locks.get(key) if (lock == null) { lock = new ObjectLock() locks.set(key, lock) } - return lock + + try { + return await lock.runLocked(fn) + } finally { + if (!lock.isLocked) { + locks.delete(key) + } + } } diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts index 8c48414ca..7a1ee467e 100644 --- a/packages/core/src/services/conversation_runtime.ts +++ b/packages/core/src/services/conversation_runtime.ts @@ -19,21 +19,14 @@ import { type UsageMetadata } from '@langchain/core/messages' export class ConversationRuntime { readonly interfaces = new LRUCache({ max: 20, - dispose: (value, key) => { - const [platform] = parseRawModelName(value.conversation.model) - if (platform != null) { - this.unregisterPlatformConversation(platform, key) - } + dispose: (value) => { value.chatInterface.dispose?.() } }) readonly modelQueue = new RequestIdQueue() readonly conversationQueue = new RequestIdQueue() - readonly requestsById = new Map() readonly activeByConversation = new Map() - readonly requestBySession = new Map() - readonly platformIndex = new Map>() constructor(private readonly service: ChatLunaService) {} @@ -79,7 +72,6 @@ export class ConversationRuntime { ) ) } - this.registerPlatformConversation(platform, conversation.id) const chatInterface = await this.ensureChatInterface(conversation) const abortController = new AbortController() @@ -87,6 +79,7 @@ export class ConversationRuntime { conversation.id, requestId, conversation.chatMode, + platform, abortController, session ) @@ -104,7 +97,7 @@ export class ConversationRuntime { const mask = toolMask ?? - (await this.service.resolveToolMask({ + (await this.platformService.resolveToolMask({ session, conversation, bindingKey: conversation.bindingKey @@ -174,7 +167,7 @@ export class ConversationRuntime { additionalReplyMessages } } finally { - this.completeRequest(conversation.id, requestId, session) + this.completeRequest(conversation.id, requestId) } }) } @@ -271,58 +264,37 @@ export class ConversationRuntime { } } - registerPlatformConversation(platform: string, conversationId: string) { - const values = this.platformIndex.get(platform) ?? new Set() - values.add(conversationId) - this.platformIndex.set(platform, values) - } - - unregisterPlatformConversation(platform: string, conversationId: string) { - const values = this.platformIndex.get(platform) - if (values == null) { - return - } - values.delete(conversationId) - if (values.size === 0) { - this.platformIndex.delete(platform) - } - } - registerRequest( conversationId: string, requestId: string, chatMode: string, + platform: string, abortController: AbortController, session?: Session ) { const activeRequest: ActiveRequest = { requestId, conversationId, - sessionId: session?.sid, + requestKey: + session == null + ? undefined + : JSON.stringify([ + session.userId, + session.guildId ?? '', + conversationId + ]), + platform, abortController, chatMode, messageQueue: new MessageQueue(), roundDecisionResolvers: [] } - this.requestsById.set(requestId, abortController) this.activeByConversation.set(conversationId, activeRequest) - if (session?.sid != null) { - this.requestBySession.set(session.sid, requestId) - } return activeRequest } - completeRequest( - conversationId: string, - requestId: string, - session?: Session - ) { - this.requestsById.delete(requestId) - if (session?.sid != null) { - this.requestBySession.delete(session.sid) - } - + completeRequest(conversationId: string, requestId: string) { const active = this.activeByConversation.get(conversationId) if (active?.requestId === requestId) { for (const resolve of active.roundDecisionResolvers) { @@ -333,14 +305,18 @@ export class ConversationRuntime { } stopRequest(requestId: string) { - const abortController = this.requestsById.get(requestId) - if (abortController == null) { + const active = Array.from(this.activeByConversation.values()).find( + (item) => item.requestId === requestId + ) + if (active == null) { + return false + } + if (active.abortController.signal.aborted) { return false } - abortController.abort( + active.abortController.abort( new ChatLunaError(ChatLunaErrorCode.ABORTED, undefined, true) ) - this.requestsById.delete(requestId) return true } @@ -353,11 +329,22 @@ export class ConversationRuntime { return this.stopRequest(activeRequest.requestId) } - getRequestIdBySession(session: Session) { - if (session.sid == null) { + getRequestId(session: Session, conversationId: string) { + const active = this.activeByConversation.get(conversationId) + if (active == null) { return undefined } - return this.requestBySession.get(session.sid) + if ( + active.requestKey !== + JSON.stringify([ + session.userId, + session.guildId ?? '', + conversationId + ]) + ) { + return undefined + } + return active.requestId } async appendPendingMessage( @@ -461,35 +448,43 @@ export class ConversationRuntime { dispose(platform?: string) { if (platform == null) { - for (const requestId of Array.from(this.requestsById.keys())) { - this.stopRequest(requestId) + for (const active of Array.from( + this.activeByConversation.values() + )) { + active.abortController.abort( + new ChatLunaError( + ChatLunaErrorCode.ABORTED, + undefined, + true + ) + ) } this.interfaces.clear() - this.requestsById.clear() this.activeByConversation.clear() - this.requestBySession.clear() - this.platformIndex.clear() return } - const conversationIds = this.platformIndex.get(platform) - if (conversationIds == null) { - return + for (const active of Array.from(this.activeByConversation.values())) { + if (active.platform === platform) { + active.abortController.abort( + new ChatLunaError( + ChatLunaErrorCode.ABORTED, + undefined, + true + ) + ) + this.activeByConversation.delete(active.conversationId) + this.interfaces.delete(active.conversationId) + } } - for (const conversationId of Array.from(conversationIds)) { - const active = this.activeByConversation.get(conversationId) - if (active != null) { - this.stopRequest(active.requestId) - this.activeByConversation.delete(conversationId) - if (active.sessionId != null) { - this.requestBySession.delete(active.sessionId) - } + for (const [conversationId, entry] of Array.from( + this.interfaces.entries() + )) { + if (parseRawModelName(entry.conversation.model)[0] === platform) { + this.interfaces.delete(conversationId) } - this.interfaces.delete(conversationId) } - - this.platformIndex.delete(platform) } } diff --git a/packages/core/src/services/message_transform.ts b/packages/core/src/services/message_transform.ts index ce7ac6afa..877c919bd 100644 --- a/packages/core/src/services/message_transform.ts +++ b/packages/core/src/services/message_transform.ts @@ -11,21 +11,6 @@ import { } from 'koishi-plugin-chatluna/utils/string' import { MessageContent } from '@langchain/core/messages' -interface TransformFunctionWithPriority { - func: MessageTransformFunction - priority: number -} - -interface BeforeTransformFunctionWithPriority { - func: BeforeMessageTransformFunction - priority: number -} - -export interface MessageTransformOptions { - quote: boolean - includeQuoteReply: boolean -} - export class MessageTransformer { private _beforeTransformFunctions: BeforeTransformFunctionWithPriority[] = [] @@ -354,3 +339,18 @@ export type MessageTransformFunction = ( message: Message, model?: string ) => Promise + +interface TransformFunctionWithPriority { + func: MessageTransformFunction + priority: number +} + +interface BeforeTransformFunctionWithPriority { + func: BeforeMessageTransformFunction + priority: number +} + +export interface MessageTransformOptions { + quote: boolean + includeQuoteReply: boolean +} diff --git a/packages/core/src/services/types.ts b/packages/core/src/services/types.ts index c77447b1e..191028652 100644 --- a/packages/core/src/services/types.ts +++ b/packages/core/src/services/types.ts @@ -1,4 +1,4 @@ -import { Awaitable, Session } from 'koishi' +import { Session } from 'koishi' import { ACLRecord, ArchiveRecord, @@ -24,6 +24,10 @@ import { } from 'koishi-plugin-chatluna/llm-core/agent' import type { ChatInterface } from '../llm-core/chat/app' import { MessageQueue } from '../llm-core/agent/types' +import type { + ToolMaskArg, + ToolMaskResolver +} from '../llm-core/platform/service' export interface LegacyConversationRecord { id: string @@ -103,7 +107,8 @@ export interface RuntimeConversationEntry { export interface ActiveRequest { requestId: string conversationId: string - sessionId?: string + requestKey?: string + platform: string abortController: AbortController chatMode: string messageQueue: MessageQueue @@ -195,12 +200,4 @@ declare module '@chatluna/shared-prompt-renderer' { export * from '@chatluna/shared-prompt-renderer' -export interface ToolMaskArg { - session: Session - conversation?: ConversationRecord - bindingKey?: string -} - -export type ToolMaskResolver = ( - arg: ToolMaskArg -) => Awaitable +export type { ToolMaskArg, ToolMaskResolver } diff --git a/packages/core/src/utils/chat_request.ts b/packages/core/src/utils/chat_request.ts deleted file mode 100644 index 102ee6ef0..000000000 --- a/packages/core/src/utils/chat_request.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { randomUUID } from 'crypto' -import type { Session } from 'koishi' - -const requestIdCache = new Map() - -function getRequestCacheKey(session: Session, conversationId: string) { - return JSON.stringify([ - session.userId, - session.guildId ?? '', - conversationId - ]) -} - -export function getRequestId(session: Session, conversationId: string) { - return requestIdCache.get(getRequestCacheKey(session, conversationId)) -} - -export function createRequestId( - session: Session, - conversationId: string, - requestId: string = randomUUID() -) { - requestIdCache.set(getRequestCacheKey(session, conversationId), requestId) - - return requestId -} - -export function deleteRequestId(session: Session, conversationId: string) { - requestIdCache.delete(getRequestCacheKey(session, conversationId)) -} diff --git a/packages/core/tests/conversation-runtime.spec.ts b/packages/core/tests/conversation-runtime.spec.ts index 05c52d2ae..4ce736d36 100644 --- a/packages/core/tests/conversation-runtime.spec.ts +++ b/packages/core/tests/conversation-runtime.spec.ts @@ -14,17 +14,18 @@ it('ConversationRuntime registers, resolves, and stops active requests', () => { 'conversation-1', 'request-1', 'plugin', + 'platform', abortController, session ) - assert.equal(runtime.getRequestIdBySession(session), 'request-1') + assert.equal(runtime.getRequestId(session, 'conversation-1'), 'request-1') assert.equal(runtime.stopRequest('request-1'), true) assert.equal(abortController.signal.aborted, true) assert.equal(runtime.stopRequest('missing-request'), false) - runtime.completeRequest('conversation-1', 'request-1', session) - assert.equal(runtime.getRequestIdBySession(session), undefined) + runtime.completeRequest('conversation-1', 'request-1') + assert.equal(runtime.getRequestId(session, 'conversation-1'), undefined) }) it('ConversationRuntime chat preserves additional kwargs metadata', async () => { @@ -34,12 +35,12 @@ it('ConversationRuntime chat preserves additional kwargs metadata', async () => message: new HumanMessage('placeholder') }) }), - resolveToolMask: async () => undefined, awaitLoadPlatform: async () => {}, currentConfig: { showThoughtMessage: true }, platform: { + resolveToolMask: async () => undefined, getClient: async () => ({ value: { configPool: { @@ -108,6 +109,7 @@ it('ConversationRuntime appendPendingMessage waits for plugin round decisions', 'conversation-1', 'request-1', 'plugin', + 'platform', new AbortController(), createSession() ) @@ -216,11 +218,11 @@ it('ConversationRuntime dispose clears platform-scoped and global state', () => conversation, chatInterface: {} as never }) - runtime.registerPlatformConversation('platform-a', conversation.id) runtime.registerRequest( conversation.id, 'request-dispose', 'plugin', + 'platform-a', new AbortController(), session ) @@ -233,11 +235,11 @@ it('ConversationRuntime dispose clears platform-scoped and global state', () => 'conversation-2', 'request-2', 'plugin', + 'platform-b', new AbortController(), createSession({ sid: 'sid-2' }) ) runtime.dispose() - assert.equal(runtime.requestsById.size, 0) - assert.equal(runtime.requestBySession.size, 0) - assert.equal(runtime.platformIndex.size, 0) + assert.equal(runtime.activeByConversation.size, 0) + assert.equal(runtime.interfaces.size, 0) }) diff --git a/packages/extension-agent/src/service/permissions.ts b/packages/extension-agent/src/service/permissions.ts index 51e3f7699..786ff5289 100644 --- a/packages/extension-agent/src/service/permissions.ts +++ b/packages/extension-agent/src/service/permissions.ts @@ -33,20 +33,24 @@ export class ChatLunaAgentPermissionService { ) {} async start() { - this._toolMaskDispose = this.ctx.chatluna.registerToolMaskResolver( - 'agent', - async ({ conversation, session }) => { - if (conversation && conversation.chatMode !== 'plugin') { - return - } + this._toolMaskDispose = + this.ctx.chatluna.platform.registerToolMaskResolver( + 'agent', + async ({ conversation, session }) => { + if (conversation && conversation.chatMode !== 'plugin') { + return + } - const mask = this.createMainToolMask() - return { - ...mask, - toolCallMask: await this.createToolCallMask(session, mask) + const mask = this.createMainToolMask() + return { + ...mask, + toolCallMask: await this.createToolCallMask( + session, + mask + ) + } } - } - ) + ) } async stop() { diff --git a/packages/extension-agent/src/service/sub_agent.ts b/packages/extension-agent/src/service/sub_agent.ts index 90a6067ed..3b25e276d 100644 --- a/packages/extension-agent/src/service/sub_agent.ts +++ b/packages/extension-agent/src/service/sub_agent.ts @@ -4,8 +4,7 @@ import { Context } from 'koishi' import { type AgentTaskToolRuntime, createTaskTool, - renderAvailableAgents, - type ToolMask + renderAvailableAgents } from 'koishi-plugin-chatluna/llm-core/agent' import { ChatLunaToolRunnable } from 'koishi-plugin-chatluna/llm-core/platform/types' import { diff --git a/packages/shared-adapter/src/client.ts b/packages/shared-adapter/src/client.ts index 60b0969a6..491c5a1a5 100644 --- a/packages/shared-adapter/src/client.ts +++ b/packages/shared-adapter/src/client.ts @@ -110,6 +110,7 @@ export function getModelMaxContextSize(info: ModelInfo): number { 'gemini-2.0-pro': 2097152, 'gemini-2.5': 2097152, 'gemini-3.0-pro': 1_097_152, + 'gemini-3.1-pro': 1_097_152, 'gemini-2.0': 2097152, deepseek: 128000, 'llama3.1': 128000, @@ -128,7 +129,7 @@ export function getModelMaxContextSize(info: ModelInfo): number { } } - return getModelContextSize('o1-mini') + return 200_000 } function createGlobMatcher(pattern: string): (text: string) => boolean { @@ -148,6 +149,7 @@ const imageModelMatchers = [ 'gemini', 'qwen-vl', 'omni', + 'gemma', 'qwen*-omni', 'qwen-omni', 'qwen*-vl', From 14756cb93dbc47fc954ca70a67fd5b9eac18fa14 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sun, 5 Apr 2026 21:38:48 +0800 Subject: [PATCH 5/6] fix(core): narrow agent observation typing --- packages/core/src/llm-core/agent/types.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/llm-core/agent/types.ts b/packages/core/src/llm-core/agent/types.ts index c210c06af..59119623a 100644 --- a/packages/core/src/llm-core/agent/types.ts +++ b/packages/core/src/llm-core/agent/types.ts @@ -5,7 +5,6 @@ import type { MessageContentImageUrl, MessageContentText } from '@langchain/core/messages' -import type { DirectToolOutput } from '@langchain/core/messages/tool' import type { MessageContentAudio, MessageContentFileUrl, @@ -194,10 +193,7 @@ export type AgentObservationComplexContent = | MessageContentAudio | MessageContentVideo -export type AgentObservation = - | AgentObservationComplexContent[] - | (DirectToolOutput & { replyEmitted?: boolean }) - | string +export type AgentObservation = AgentObservationComplexContent[] | string export interface ToolMask { mode: 'all' | 'allow' | 'deny' From 2e6764cb16731ba60cbb0779f79a5dc2e75976e2 Mon Sep 17 00:00:00 2001 From: dingyi Date: Sun, 5 Apr 2026 22:35:54 +0800 Subject: [PATCH 6/6] fix(core,agent): normalize chat stop and mcp server validation --- .../src/middlewares/chat/rollback_chat.ts | 2 +- .../core/src/middlewares/chat/stop_chat.ts | 18 +- packages/core/src/services/conversation.ts | 8 +- .../core/src/services/conversation_runtime.ts | 21 +- packages/core/src/utils/conversation.ts | 2 +- .../components/mcp/mcp-servers-view.vue | 194 +++++++++++++----- packages/extension-tools/src/plugins/group.ts | 10 +- 7 files changed, 175 insertions(+), 80 deletions(-) diff --git a/packages/core/src/middlewares/chat/rollback_chat.ts b/packages/core/src/middlewares/chat/rollback_chat.ts index 2b50bde5d..91d04440b 100644 --- a/packages/core/src/middlewares/chat/rollback_chat.ts +++ b/packages/core/src/middlewares/chat/rollback_chat.ts @@ -2,8 +2,8 @@ import type { Context, Session } from 'koishi' import { gzipDecode } from 'koishi-plugin-chatluna/utils/string' import { Config } from '../../config' import { - ChainMiddlewareRunStatus, type ChainMiddlewareContext, + ChainMiddlewareRunStatus, type ChatChain } from '../../chains/chain' import { MessageRecord } from '../../services/conversation_types' diff --git a/packages/core/src/middlewares/chat/stop_chat.ts b/packages/core/src/middlewares/chat/stop_chat.ts index e30ed00f3..3ad02a654 100644 --- a/packages/core/src/middlewares/chat/stop_chat.ts +++ b/packages/core/src/middlewares/chat/stop_chat.ts @@ -81,23 +81,13 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { } context.options.conversationId = conversation.id - const requestId = ctx.chatluna.conversationRuntime.getRequestId( - session, - conversation.id - ) - - if (requestId == null) { - context.message = session.text('.no_active_chat') - return ChainMiddlewareRunStatus.STOP - } - const status = - await ctx.chatluna.conversationRuntime.stopRequest(requestId) + ctx.chatluna.conversationRuntime.stopConversationRequest( + conversation.id + ) - if (status === null) { + if (!status) { context.message = session.text('.no_active_chat') - } else if (!status) { - context.message = session.text('.stop_failed') } return ChainMiddlewareRunStatus.STOP diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index 12c451c80..ce9d5095f 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -1825,12 +1825,16 @@ function isConstraintMatched(constraint: ConstraintRecord, session: Session) { return false } - const users = 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 = JSON.parse(constraint.excludeUsers ?? '[]') + const excludeUsers = + constraint.excludeUsers === null + ? null + : JSON.parse(constraint.excludeUsers) if (excludeUsers != null && excludeUsers.includes(session.userId)) { return false } diff --git a/packages/core/src/services/conversation_runtime.ts b/packages/core/src/services/conversation_runtime.ts index 7a1ee467e..de2184b98 100644 --- a/packages/core/src/services/conversation_runtime.ts +++ b/packages/core/src/services/conversation_runtime.ts @@ -122,10 +122,10 @@ export class ConversationRuntime { return } - for (const resolve of activeRequest.roundDecisionResolvers) { - resolve(agentEvent.canContinue) - } - activeRequest.roundDecisionResolvers = [] + flushRoundDecision( + activeRequest, + agentEvent.canContinue + ) } } }) @@ -297,9 +297,7 @@ export class ConversationRuntime { completeRequest(conversationId: string, requestId: string) { const active = this.activeByConversation.get(conversationId) if (active?.requestId === requestId) { - for (const resolve of active.roundDecisionResolvers) { - resolve(false) - } + flushRoundDecision(active, false) this.activeByConversation.delete(conversationId) } } @@ -451,6 +449,7 @@ export class ConversationRuntime { for (const active of Array.from( this.activeByConversation.values() )) { + flushRoundDecision(active, false) active.abortController.abort( new ChatLunaError( ChatLunaErrorCode.ABORTED, @@ -466,6 +465,7 @@ 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, @@ -529,6 +529,13 @@ function formatUsageMetadataMessage(usage: UsageMetadata) { ].join('\n') } +function flushRoundDecision(active: ActiveRequest, canContinue: boolean) { + for (const resolve of active.roundDecisionResolvers) { + resolve(canContinue) + } + active.roundDecisionResolvers = [] +} + export type { ChatEvents, RuntimeConversationEntry, diff --git a/packages/core/src/utils/conversation.ts b/packages/core/src/utils/conversation.ts index 493f75df9..b4f25af30 100644 --- a/packages/core/src/utils/conversation.ts +++ b/packages/core/src/utils/conversation.ts @@ -1,7 +1,7 @@ import type { Context, Session } from 'koishi' import { - getBaseBindingKey, type ConversationRecord, + getBaseBindingKey, type ResolvedConversationContext } from '../services/conversation_types' diff --git a/packages/extension-agent/client/components/mcp/mcp-servers-view.vue b/packages/extension-agent/client/components/mcp/mcp-servers-view.vue index 0ddd2cc75..e3c434ef9 100644 --- a/packages/extension-agent/client/components/mcp/mcp-servers-view.vue +++ b/packages/extension-agent/client/components/mcp/mcp-servers-view.vue @@ -329,7 +329,7 @@ @@ -427,6 +427,9 @@ placeholder="粘贴单个服务器配置,或包含 mcpServers 的完整对象" @update:model-value="syncJsonToForm" /> +
+ {{ jsonError }} +
@@ -651,8 +654,75 @@ function parseRecord(text: string) { ) } +function splitCommand(text: string) { + const args: string[] = [] + let part = '' + let quote = '' + + for (const ch of text.trim()) { + if (quote) { + if (ch === quote) { + quote = '' + } else { + part += ch + } + continue + } + + if (ch === '"' || ch === "'") { + quote = ch + continue + } + + if (/\s/.test(ch)) { + if (part) { + args.push(part) + part = '' + } + continue + } + + part += ch + } + + if (part) { + args.push(part) + } + + return args +} + +function normalizeServer(config: McpServerConfig) { + const next = { ...config } + const type = getServerType(next) + + if (type === 'stdio' && next.command?.trim()) { + const raw = next.command.trim() + + if (!next.args?.length && /\s/.test(raw)) { + const args = splitCommand(raw) + + if (args.length > 1) { + next.command = args[0] + next.args = args.slice(1) + } else { + next.command = raw + } + } else { + next.command = raw + } + } + + if (type !== 'stdio' && next.url?.trim()) { + next.url = next.url.trim() + } + + return next +} + function formatServerJson(name: string, config: McpServerConfig) { - const value = name ? { name, ...config } : config + const next = normalizeServer(config) + const value = name ? { name, ...next } : next return JSON.stringify(value, null, 2) } @@ -669,6 +739,29 @@ function withEnv( } } +function validateServer(name: string, config: McpServerConfig) { + const next = normalizeServer(config) + const server = getServerType(next) + const key = name.trim() + + if (!key) { + throw new Error('请填写名称,或在 JSON 中提供 name 字段') + } + + if (server === 'stdio' && !next.command?.trim()) { + throw new Error('请填写启动命令') + } + + if (server !== 'stdio' && !next.url?.trim()) { + throw new Error('请填写服务地址') + } + + return { + name: key, + config: next + } +} + function parseServerJson(text: string, rawName: string) { const parsed = parseJson(text) @@ -679,7 +772,7 @@ function parseServerJson(text: string, rawName: string) { if (name && servers[name]) { return { name, - config: withEnv(servers[name]) + config: normalizeServer(withEnv(servers[name])) } } @@ -687,7 +780,7 @@ function parseServerJson(text: string, rawName: string) { if (list.length === 1) { return { name: name || list[0][0], - config: withEnv(list[0][1]) + config: normalizeServer(withEnv(list[0][1])) } } @@ -698,10 +791,6 @@ function parseServerJson(text: string, rawName: string) { const next = { ...parsed } as Record const name = rawName.trim() || String(parsed.name || '') - if (!name) { - throw new Error('请填写名称,或在 JSON 中提供 name 字段') - } - if (next.environment != null && next.env == null) { next.env = next.environment } @@ -711,7 +800,7 @@ function parseServerJson(text: string, rawName: string) { return { name, - config: withEnv(next as McpServerConfig) + config: normalizeServer(withEnv(next as McpServerConfig)) } } @@ -725,7 +814,7 @@ function parseServerJson(text: string, rawName: string) { ) { return { name: rawName.trim() || list[0][0], - config: withEnv(list[0][1] as McpServerConfig) + config: normalizeServer(withEnv(list[0][1] as McpServerConfig)) } } @@ -800,8 +889,22 @@ watch( } ) +const jsonError = computed(() => { + if (!serverJson.value.trim()) return '' + + try { + parseJson(serverJson.value) + return '' + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +}) + const jsonValid = computed(() => { - if (!serverJson.value.trim()) return false + if (jsonError.value || !serverJson.value.trim()) { + return false + } + try { parseServerJson(serverJson.value, form.name) return true @@ -897,21 +1000,23 @@ function getFormConfig() { config.proxy = form.proxy.trim() } - return config + return normalizeServer(config) } function fillForm(name: string, server: McpServerConfig) { + const next = normalizeServer(server) + form.name = name - form.type = server.type ?? (server.url ? 'http' : 'stdio') - form.command = server.command ?? '' - form.args = (server.args ?? []).join('\n') - form.env = JSON.stringify(server.env ?? {}, null, 2) - form.url = server.url ?? '' - form.headers = JSON.stringify(server.headers ?? {}, null, 2) - form.timeout = server.timeout ?? 60 - form.cwd = server.cwd ?? '' - form.proxy = server.proxy ?? '' - serverJson.value = formatServerJson(name, server) + form.type = next.type ?? (next.url ? 'http' : 'stdio') + form.command = next.command ?? '' + form.args = (next.args ?? []).join('\n') + form.env = JSON.stringify(next.env ?? {}, null, 2) + form.url = next.url ?? '' + form.headers = JSON.stringify(next.headers ?? {}, null, 2) + form.timeout = next.timeout ?? 60 + form.cwd = next.cwd ?? '' + form.proxy = next.proxy ?? '' + serverJson.value = formatServerJson(name, next) } function resetServerForm() { @@ -1065,34 +1170,18 @@ async function saveServer() { savingServer.value = true try { - let name = form.name.trim() - let config = getFormConfig() - - if (serverMode.value === 'json') { - const parsed = parseServerJson(serverJson.value, name) - name = parsed.name - config = parsed.config - } - - if (!name) { - ElMessage.warning('请先填写服务器名称。') - return - } - - if (config.type === 'stdio' && !config.command?.trim()) { - ElMessage.warning('请填写启动命令。') - return - } - - if (config.type !== 'stdio' && !config.url?.trim()) { - ElMessage.warning('请填写服务地址。') - return - } + const parsed = + serverMode.value === 'json' + ? (() => { + const item = parseServerJson(serverJson.value, form.name) + return validateServer(item.name, item.config) + })() + : validateServer(form.name, getFormConfig()) await send('chatluna-agent/saveMcpServer', { oldName: editing.value || undefined, - name, - config + name: parsed.name, + config: parsed.config }) ElMessage.success(editing.value ? '已更新服务器。' : '已创建服务器。') @@ -1674,6 +1763,17 @@ async function saveTool() { color: var(--k-text-light); } +.json-error { + padding: 10px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--el-color-warning), transparent 92%); + color: color-mix(in srgb, var(--el-color-warning), var(--k-text-dark) 32%); + font-size: 12px; + line-height: 1.6; + word-break: break-word; + overflow-wrap: anywhere; +} + .dialog-copy { display: flex; flex-direction: column; diff --git a/packages/extension-tools/src/plugins/group.ts b/packages/extension-tools/src/plugins/group.ts index f231963f1..77f7cc060 100644 --- a/packages/extension-tools/src/plugins/group.ts +++ b/packages/extension-tools/src/plugins/group.ts @@ -57,14 +57,8 @@ export class GroupMuteTool extends StructuredTool { schema = z.object({ userIds: z .array(z.string()) - .describe( - 'User IDs to mute or unmute, one or more.' - ), - muteTime: z - .number() - .describe( - 'Duration in seconds. Use 0 to unmute.' - ), + .describe('User IDs to mute or unmute, one or more.'), + muteTime: z.number().describe('Duration in seconds. Use 0 to unmute.'), operatorUserId: z .string() .optional()