mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 07:19:02 +08:00
feat(api): enhance message deletion functionality with activeNodeId management
- Introduced `ActiveNodeStrategy` type to define strategies for updating `activeNodeId` when a message is deleted. - Updated `DeleteMessageResponse` to include `newActiveNodeId` for tracking changes to the active node after deletion. - Modified the `DELETE` endpoint to accept `activeNodeStrategy` as a query parameter, allowing for flexible handling of active node updates. - Enhanced the `delete` method in `MessageService` to implement the new strategies, ensuring consistent behavior during message deletions.
This commit is contained in:
parent
44b85fa661
commit
3d0e7a6c15
@ -64,6 +64,11 @@ export interface UpdateMessageDto {
|
|||||||
status?: MessageStatus
|
status?: MessageStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strategy for updating activeNodeId when the active message is deleted
|
||||||
|
*/
|
||||||
|
export type ActiveNodeStrategy = 'parent' | 'clear'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response for delete operation
|
* Response for delete operation
|
||||||
*/
|
*/
|
||||||
@ -72,6 +77,8 @@ export interface DeleteMessageResponse {
|
|||||||
deletedIds: string[]
|
deletedIds: string[]
|
||||||
/** IDs of reparented children (only when cascade=false) */
|
/** IDs of reparented children (only when cascade=false) */
|
||||||
reparentedIds?: string[]
|
reparentedIds?: string[]
|
||||||
|
/** New activeNodeId for the topic (only if activeNodeId was affected by deletion) */
|
||||||
|
newActiveNodeId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -168,10 +175,19 @@ export interface MessageSchemas {
|
|||||||
body: UpdateMessageDto
|
body: UpdateMessageDto
|
||||||
response: Message
|
response: Message
|
||||||
}
|
}
|
||||||
/** Delete a message (cascade=true deletes descendants, cascade=false reparents children) */
|
/**
|
||||||
|
* Delete a message
|
||||||
|
* - cascade=true: deletes message and all descendants
|
||||||
|
* - cascade=false: reparents children to grandparent
|
||||||
|
* - activeNodeStrategy='parent' (default): sets activeNodeId to parent if affected
|
||||||
|
* - activeNodeStrategy='clear': sets activeNodeId to null if affected
|
||||||
|
*/
|
||||||
DELETE: {
|
DELETE: {
|
||||||
params: { id: string }
|
params: { id: string }
|
||||||
query?: { cascade?: boolean }
|
query?: {
|
||||||
|
cascade?: boolean
|
||||||
|
activeNodeStrategy?: ActiveNodeStrategy
|
||||||
|
}
|
||||||
response: DeleteMessageResponse
|
response: DeleteMessageResponse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,12 @@
|
|||||||
|
|
||||||
import { messageService } from '@data/services/MessageService'
|
import { messageService } from '@data/services/MessageService'
|
||||||
import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes'
|
import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes'
|
||||||
import type { BranchMessagesQueryParams, MessageSchemas, TreeQueryParams } from '@shared/data/api/schemas/messages'
|
import type {
|
||||||
|
ActiveNodeStrategy,
|
||||||
|
BranchMessagesQueryParams,
|
||||||
|
MessageSchemas,
|
||||||
|
TreeQueryParams
|
||||||
|
} from '@shared/data/api/schemas/messages'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler type for a specific message endpoint
|
* Handler type for a specific message endpoint
|
||||||
@ -61,9 +66,10 @@ export const messageHandlers: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
DELETE: async ({ params, query }) => {
|
DELETE: async ({ params, query }) => {
|
||||||
const q = (query || {}) as { cascade?: boolean }
|
const q = (query || {}) as { cascade?: boolean; activeNodeStrategy?: ActiveNodeStrategy }
|
||||||
const cascade = q.cascade ?? false
|
const cascade = q.cascade ?? false
|
||||||
return await messageService.delete(params.id, cascade)
|
const activeNodeStrategy = q.activeNodeStrategy ?? 'parent'
|
||||||
|
return await messageService.delete(params.id, cascade, activeNodeStrategy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|||||||
|
|
||||||
import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers'
|
import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers'
|
||||||
import { groupTable } from './group'
|
import { groupTable } from './group'
|
||||||
// import { messageTable } from './message'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Topic table - stores conversation topics/threads
|
* Topic table - stores conversation topics/threads
|
||||||
@ -25,9 +24,7 @@ export const topicTable = sqliteTable(
|
|||||||
// Topic-specific prompt override
|
// Topic-specific prompt override
|
||||||
prompt: text(),
|
prompt: text(),
|
||||||
// Active node ID in the message tree
|
// Active node ID in the message tree
|
||||||
// SET NULL: reset to null when the referenced message is deleted
|
|
||||||
activeNodeId: text(),
|
activeNodeId: text(),
|
||||||
// .references(() => messageTable.id, { onDelete: 'set null' }),
|
|
||||||
|
|
||||||
// FK to group table for organization
|
// FK to group table for organization
|
||||||
// SET NULL: preserve topic when group is deleted
|
// SET NULL: preserve topic when group is deleted
|
||||||
|
|||||||
@ -13,7 +13,12 @@ import { messageTable } from '@data/db/schemas/message'
|
|||||||
import { topicTable } from '@data/db/schemas/topic'
|
import { topicTable } from '@data/db/schemas/topic'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { DataApiErrorFactory } from '@shared/data/api'
|
import { DataApiErrorFactory } from '@shared/data/api'
|
||||||
import type { CreateMessageDto, UpdateMessageDto } from '@shared/data/api/schemas/messages'
|
import type {
|
||||||
|
ActiveNodeStrategy,
|
||||||
|
CreateMessageDto,
|
||||||
|
DeleteMessageResponse,
|
||||||
|
UpdateMessageDto
|
||||||
|
} from '@shared/data/api/schemas/messages'
|
||||||
import type {
|
import type {
|
||||||
BranchMessage,
|
BranchMessage,
|
||||||
BranchMessagesResponse,
|
BranchMessagesResponse,
|
||||||
@ -431,13 +436,42 @@ export class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a message (hard delete)
|
* Delete a message (hard delete)
|
||||||
|
*
|
||||||
|
* Supports two modes:
|
||||||
|
* - cascade=true: Delete the message and all its descendants
|
||||||
|
* - cascade=false: Delete only this message, reparent children to grandparent
|
||||||
|
*
|
||||||
|
* When the deleted message(s) include the topic's activeNodeId, it will be
|
||||||
|
* automatically updated based on activeNodeStrategy:
|
||||||
|
* - 'parent' (default): Sets activeNodeId to the deleted message's parent
|
||||||
|
* - 'clear': Sets activeNodeId to null
|
||||||
|
*
|
||||||
|
* All operations are performed within a transaction for consistency.
|
||||||
|
*
|
||||||
|
* @param id - Message ID to delete
|
||||||
|
* @param cascade - If true, delete descendants; if false, reparent children (default: false)
|
||||||
|
* @param activeNodeStrategy - Strategy for updating activeNodeId if affected (default: 'parent')
|
||||||
|
* @returns Deletion result including deletedIds, reparentedIds, and newActiveNodeId
|
||||||
|
* @throws NOT_FOUND if message doesn't exist
|
||||||
|
* @throws INVALID_OPERATION if deleting root without cascade=true
|
||||||
*/
|
*/
|
||||||
async delete(id: string, cascade: boolean = false): Promise<{ deletedIds: string[]; reparentedIds?: string[] }> {
|
async delete(
|
||||||
|
id: string,
|
||||||
|
cascade: boolean = false,
|
||||||
|
activeNodeStrategy: ActiveNodeStrategy = 'parent'
|
||||||
|
): Promise<DeleteMessageResponse> {
|
||||||
const db = dbService.getDb()
|
const db = dbService.getDb()
|
||||||
|
|
||||||
// Get the message
|
// Get the message
|
||||||
const message = await this.getById(id)
|
const message = await this.getById(id)
|
||||||
|
|
||||||
|
// Get topic to check activeNodeId
|
||||||
|
const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, message.topicId)).limit(1)
|
||||||
|
|
||||||
|
if (!topic) {
|
||||||
|
throw DataApiErrorFactory.notFound('Topic', message.topicId)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a root message
|
// Check if it's a root message
|
||||||
const isRoot = message.parentId === null
|
const isRoot = message.parentId === null
|
||||||
|
|
||||||
@ -445,34 +479,76 @@ export class MessageService {
|
|||||||
throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required')
|
throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all descendant IDs before transaction (for cascade delete)
|
||||||
|
let descendantIds: string[] = []
|
||||||
if (cascade) {
|
if (cascade) {
|
||||||
// Get all descendants
|
descendantIds = await this.getDescendantIds(id)
|
||||||
const descendantIds = await this.getDescendantIds(id)
|
}
|
||||||
const allIds = [id, ...descendantIds]
|
|
||||||
|
|
||||||
// Hard delete all
|
// Use transaction for atomic delete + activeNodeId update
|
||||||
await db.delete(messageTable).where(inArray(messageTable.id, allIds))
|
return await db.transaction(async (tx) => {
|
||||||
|
let deletedIds: string[]
|
||||||
|
let reparentedIds: string[] | undefined
|
||||||
|
let newActiveNodeId: string | null | undefined
|
||||||
|
|
||||||
logger.info('Cascade deleted messages', { rootId: id, count: allIds.length })
|
if (cascade) {
|
||||||
|
deletedIds = [id, ...descendantIds]
|
||||||
|
|
||||||
return { deletedIds: allIds }
|
// Check if activeNodeId is affected
|
||||||
} else {
|
if (topic.activeNodeId && deletedIds.includes(topic.activeNodeId)) {
|
||||||
// Reparent children to this message's parent
|
newActiveNodeId = activeNodeStrategy === 'clear' ? null : message.parentId
|
||||||
const children = await db.select({ id: messageTable.id }).from(messageTable).where(eq(messageTable.parentId, id))
|
}
|
||||||
|
|
||||||
const childIds = children.map((c) => c.id)
|
// Hard delete all
|
||||||
|
await tx.delete(messageTable).where(inArray(messageTable.id, deletedIds))
|
||||||
|
|
||||||
if (childIds.length > 0) {
|
logger.info('Cascade deleted messages', { rootId: id, count: deletedIds.length })
|
||||||
await db.update(messageTable).set({ parentId: message.parentId }).where(inArray(messageTable.id, childIds))
|
} else {
|
||||||
|
// Reparent children to this message's parent
|
||||||
|
const children = await tx
|
||||||
|
.select({ id: messageTable.id })
|
||||||
|
.from(messageTable)
|
||||||
|
.where(eq(messageTable.parentId, id))
|
||||||
|
|
||||||
|
reparentedIds = children.map((c) => c.id)
|
||||||
|
|
||||||
|
if (reparentedIds.length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(messageTable)
|
||||||
|
.set({ parentId: message.parentId })
|
||||||
|
.where(inArray(messageTable.id, reparentedIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedIds = [id]
|
||||||
|
|
||||||
|
// Check if activeNodeId is affected
|
||||||
|
if (topic.activeNodeId === id) {
|
||||||
|
newActiveNodeId = activeNodeStrategy === 'clear' ? null : message.parentId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard delete this message
|
||||||
|
await tx.delete(messageTable).where(eq(messageTable.id, id))
|
||||||
|
|
||||||
|
logger.info('Deleted message with reparenting', { id, reparentedCount: reparentedIds.length })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard delete this message
|
// Update topic.activeNodeId if needed
|
||||||
await db.delete(messageTable).where(eq(messageTable.id, id))
|
if (newActiveNodeId !== undefined) {
|
||||||
|
await tx.update(topicTable).set({ activeNodeId: newActiveNodeId }).where(eq(topicTable.id, message.topicId))
|
||||||
|
|
||||||
logger.info('Deleted message with reparenting', { id, reparentedCount: childIds.length })
|
logger.info('Updated topic activeNodeId after message deletion', {
|
||||||
|
topicId: message.topicId,
|
||||||
|
oldActiveNodeId: topic.activeNodeId,
|
||||||
|
newActiveNodeId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { deletedIds: [id], reparentedIds: childIds }
|
return {
|
||||||
}
|
deletedIds,
|
||||||
|
reparentedIds: reparentedIds?.length ? reparentedIds : undefined,
|
||||||
|
newActiveNodeId
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user