diff --git a/packages/core/src/commands/conversation.ts b/packages/core/src/commands/conversation.ts index 5802b03dd..b5c97c9fa 100644 --- a/packages/core/src/commands/conversation.ts +++ b/packages/core/src/commands/conversation.ts @@ -240,6 +240,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { .option('all', '--all') .action(async ({ options, session }, conversation) => { const presetLane = options.preset?.trim() || undefined + const target = conversation?.trim() || undefined + const seqs = target == null ? undefined : parseSeqs(target) const includeArchived = options.archived === true || options.all === true await chain.receiveCommand( @@ -248,7 +250,8 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { { allPresetLanes: presetLane == null, conversation_manage: { - targetConversation: conversation?.trim() || undefined, + targetConversation: seqs == null ? target : undefined, + targetConversationSeqs: seqs, presetLane, includeArchived } @@ -415,6 +418,27 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { }) } +function parseSeqs(input: string) { + if (!input.includes(',') && !input.includes('..')) return undefined + if (!/^\d+(?:\.\.\d+)?(?:,\d+(?:\.\.\d+)?)*$/.test(input)) { + return undefined + } + + const seqs = new Set() + for (const part of input.split(',')) { + const [start, end = start] = part.split('..').map(Number) + for ( + let seq = Math.min(start, end); + seq <= Math.max(start, end); + seq += 1 + ) { + seqs.add(seq) + } + } + + return [...seqs] +} + declare module '../chains/chain' { interface ChainMiddlewareContextOptions { conversation_create?: { @@ -425,6 +449,7 @@ declare module '../chains/chain' { } conversation_manage?: { targetConversation?: string + targetConversationSeqs?: number[] presetLane?: string includeArchived?: boolean title?: string diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index de6929ece..3cf23f766 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -280,6 +280,18 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { try { const { presetLane, includeArchived, allPresetLanes } = getManageOptions(context) + const seqs = + context.options.conversation_manage?.targetConversationSeqs + + if (seqs != null) { + context.message = await deleteBySeqs(ctx, session, seqs, { + presetLane, + includeArchived, + allPresetLanes + }) + return ChainMiddlewareRunStatus.STOP + } + const conversation = await ctx.chatluna.conversation.deleteConversation(session, { conversationId: resolvedConversationId(context), @@ -765,6 +777,36 @@ function getManageOptions(context: ChainMiddlewareContext) { } } +async function deleteBySeqs( + ctx: Context, + session: Session, + seqs: number[], + opts: ReturnType +) { + const entries = await ctx.chatluna.conversation.listConversationEntries( + session, + opts + ) + const targets = entries.filter((item) => seqs.includes(item.displaySeq)) + if (targets.length !== seqs.length) { + return session.text('chatluna.conversation.messages.target_not_found') + } + + const deleted: ConversationRecord[] = [] + for (const target of targets) { + deleted.push( + await ctx.chatluna.conversation.deleteConversation(session, { + conversationId: target.conversation.id, + ...opts + }) + ) + } + + return session.text('chatluna.conversation.messages.delete_success', [ + deleted.map((item) => item.title).join('\n') + ]) +} + function conversationSummary(conversation: ConversationRecord) { return [ conversation.title, diff --git a/packages/core/tests/conversation-resolve.spec.ts b/packages/core/tests/conversation-resolve.spec.ts index 042b58a02..9fe8e1e21 100644 --- a/packages/core/tests/conversation-resolve.spec.ts +++ b/packages/core/tests/conversation-resolve.spec.ts @@ -10,6 +10,7 @@ import { apply as applyResolve } from '../src/middlewares/conversation/resolve_c import { apply as applyRequest } from '../src/middlewares/conversation/request_conversation' import { apply as applyManage } from '../src/middlewares/system/conversation_manage' import { apply as applyLifecycle } from '../src/middlewares/system/lifecycle' +import { apply as applyCommands } from '../src/commands/conversation' import { ConversationResolutionError } from '../src/types' import { createConfig, @@ -725,6 +726,97 @@ it('conversation_switch accepts resolved direct conversation ids', async () => { } }) +it('chatluna.delete removes range and list selectors', async () => { + const conversations = [ + createConversation({ id: 'conversation-first', title: 'First Topic' }), + createConversation({ + id: 'conversation-second', + title: 'Second Topic' + }), + createConversation({ id: 'conversation-third', title: 'Third Topic' }) + ] + const actions = new Map Promise>() + const deleted: string[] = [] + const messages: string[] = [] + let run: + | ((session: any, context: any) => Promise) + | undefined + const ctx = { + command: (decl: string) => { + const cmd = { + alias: () => cmd, + option: () => cmd, + action: (fn: (...args: any[]) => Promise) => { + actions.set(decl, fn) + return cmd + } + } + return cmd + }, + chatluna: { + conversation: { + listConversationEntries: async () => + conversations.map((conversation, idx) => ({ + conversation, + displaySeq: idx + 1 + })), + deleteConversation: async (_session, opts) => { + deleted.push(opts.conversationId) + return conversations.find( + (item) => item.id === opts.conversationId + ) + } + } + } + } + const chain = { + middleware: (name, fn) => { + if (name === 'conversation_delete') run = fn + return { + after() { + return this + }, + before() { + return this + } + } + } + } + + applyManage(ctx as never, {} as never, chain as never) + applyCommands( + ctx as never, + {} as never, + { + receiveCommand: async (session, name, opts) => { + await run!(session, { command: name, options: opts }) + } + } as never + ) + + const session = createSession() as any + session.text = (key, params) => { + const msg = params == null ? key : `${key}:${params.join(',')}` + messages.push(msg) + return msg + } + + const action = actions.get('chatluna.delete [conversation:string]')! + await action({ options: {}, session }, '1..2') + await action({ options: {}, session }, '1,3') + + assert.deepEqual(deleted, [ + 'conversation-first', + 'conversation-second', + 'conversation-first', + 'conversation-third' + ]) + assert.deepEqual(messages, [ + 'chatluna.conversation.messages.delete_success:First Topic\nSecond Topic', + 'chatluna.conversation.messages.delete_success:First Topic\nThird Topic' + ]) +}) + it('conversation_switch preserves explicit chain conversation through middleware order', async () => { const { app, ctx } = await createMemoryService()