diff --git a/mise.toml b/mise.toml index 40ca167b6..8b933227e 100644 --- a/mise.toml +++ b/mise.toml @@ -1,3 +1,3 @@ [tools] node = "24" -pnpm = "latest" +pnpm = "10.13.1" diff --git a/src/main/contextMenuHelper.ts b/src/main/contextMenuHelper.ts index af92959f0..0d6e3138e 100644 --- a/src/main/contextMenuHelper.ts +++ b/src/main/contextMenuHelper.ts @@ -243,6 +243,19 @@ export default function contextMenu(options: ContextMenuOptions): () => void { // 添加分隔符 menuItems.push({ type: 'separator' }) + menuItems.push({ + id: 'newThreadFromSelection', + label: options.labels?.newThreadFromSelection || 'New Thread from Selection', + click: () => { + options.webContents.send( + 'context-menu-new-thread', + params.selectionText, + params.x, + params.y + ) + } + }) + // 添加翻译选项 menuItems.push({ id: 'translate', diff --git a/src/main/events.ts b/src/main/events.ts index 7e1911206..a479df41f 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -58,6 +58,7 @@ export const CONVERSATION_EVENTS = { ACTIVATED: 'conversation:activated', // 替代 conversation-activated DEACTIVATED: 'conversation:deactivated', // 替代 active-conversation-cleared MESSAGE_EDITED: 'conversation:message-edited', // 替代 message-edited + SCROLL_TO_MESSAGE: 'conversation:scroll-to-message', MESSAGE_GENERATED: 'conversation:message-generated' // 主进程内部事件,一条完整的消息已生成 } diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index 4dd9c734a..8db2de41b 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -281,6 +281,18 @@ export class SQLitePresenter implements ISQLitePresenter { return this.conversationsTable.list(page, pageSize) } + public async listChildConversationsByParent( + parentConversationId: string + ): Promise { + return this.conversationsTable.listByParentConversationId(parentConversationId) + } + + public async listChildConversationsByMessageIds( + parentMessageIds: string[] + ): Promise { + return this.conversationsTable.listByParentMessageIds(parentMessageIds) + } + // 获取对话总数 public async getConversationCount(): Promise { return this.conversationsTable.count() diff --git a/src/main/presenter/sqlitePresenter/tables/conversations.ts b/src/main/presenter/sqlitePresenter/tables/conversations.ts index 21a0a7bea..c6fcd68c0 100644 --- a/src/main/presenter/sqlitePresenter/tables/conversations.ts +++ b/src/main/presenter/sqlitePresenter/tables/conversations.ts @@ -25,6 +25,9 @@ type ConversationRow = { forced_search: number | null search_strategy: string | null context_chain: string | null + parent_conversation_id: string | null + parent_message_id: string | null + parent_selection: string | null } // 解析 JSON 字段 @@ -126,12 +129,22 @@ export class ConversationsTable extends BaseTable { ALTER TABLE conversations ADD COLUMN acp_workdir_map TEXT DEFAULT NULL; ` } + if (version === 9) { + return ` + -- 添加 parent 相关字段 + ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT DEFAULT NULL; + ALTER TABLE conversations ADD COLUMN parent_message_id TEXT DEFAULT NULL; + ALTER TABLE conversations ADD COLUMN parent_selection TEXT DEFAULT NULL; + CREATE INDEX idx_conversations_parent ON conversations(parent_conversation_id); + CREATE INDEX idx_conversations_parent_message ON conversations(parent_message_id); + ` + } return null } getLatestVersion(): number { - return 8 + return 9 } async create(title: string, settings: Partial = {}): Promise { @@ -159,9 +172,12 @@ export class ConversationsTable extends BaseTable { search_strategy, context_chain, agent_workspace_path, - acp_workdir_map + acp_workdir_map, + parent_conversation_id, + parent_message_id, + parent_selection ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const conv_id = nanoid() const now = Date.now() @@ -190,7 +206,10 @@ export class ConversationsTable extends BaseTable { settings.agentWorkspacePath !== undefined && settings.agentWorkspacePath !== null ? settings.agentWorkspacePath : null, - settings.acpWorkdirMap ? JSON.stringify(settings.acpWorkdirMap) : null + settings.acpWorkdirMap ? JSON.stringify(settings.acpWorkdirMap) : null, + null, + null, + null ) return conv_id } @@ -222,7 +241,10 @@ export class ConversationsTable extends BaseTable { search_strategy, context_chain, agent_workspace_path, - acp_workdir_map + acp_workdir_map, + parent_conversation_id, + parent_message_id, + parent_selection FROM conversations WHERE conv_id = ? ` @@ -270,7 +292,10 @@ export class ConversationsTable extends BaseTable { updatedAt: result.updatedAt, is_new: result.is_new, is_pinned: result.is_pinned, - settings + settings, + parentConversationId: result.parent_conversation_id, + parentMessageId: result.parent_message_id, + parentSelection: getJsonField(result.parent_selection, undefined) } } @@ -367,6 +392,24 @@ export class ConversationsTable extends BaseTable { ) } } + if (data.parentConversationId !== undefined) { + updates.push('parent_conversation_id = ?') + params.push(data.parentConversationId ?? null) + } + if (data.parentMessageId !== undefined) { + updates.push('parent_message_id = ?') + params.push(data.parentMessageId ?? null) + } + if (data.parentSelection !== undefined) { + updates.push('parent_selection = ?') + if (data.parentSelection === null) { + params.push(null) + } else if (typeof data.parentSelection === 'string') { + params.push(data.parentSelection) + } else { + params.push(JSON.stringify(data.parentSelection)) + } + } if (updates.length > 0 || data.updatedAt) { updates.push('updated_at = ?') params.push(data.updatedAt || Date.now()) @@ -419,7 +462,10 @@ export class ConversationsTable extends BaseTable { search_strategy, context_chain, agent_workspace_path, - acp_workdir_map + acp_workdir_map, + parent_conversation_id, + parent_message_id, + parent_selection FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ? @@ -464,11 +510,175 @@ export class ConversationsTable extends BaseTable { ? row.agent_workspace_path : undefined, acpWorkdirMap: getJsonField(row.acp_workdir_map, undefined) - } + }, + parentConversationId: row.parent_conversation_id, + parentMessageId: row.parent_message_id, + parentSelection: getJsonField(row.parent_selection, undefined) })) } } + async listByParentConversationId(parentConversationId: string): Promise { + const results = this.db + .prepare( + ` + SELECT + conv_id as id, + title, + created_at as createdAt, + updated_at as updatedAt, + system_prompt as systemPrompt, + temperature, + context_length as contextLength, + max_tokens as maxTokens, + provider_id as providerId, + model_id as modelId, + is_new, + artifacts, + is_pinned, + enabled_mcp_tools, + thinking_budget, + reasoning_effort, + verbosity, + enable_search, + forced_search, + search_strategy, + context_chain, + agent_workspace_path, + acp_workdir_map, + parent_conversation_id, + parent_message_id, + parent_selection + FROM conversations + WHERE parent_conversation_id = ? + ORDER BY updated_at DESC + ` + ) + .all(parentConversationId) as (ConversationRow & { + agent_workspace_path: string | null + acp_workdir_map: string | null + })[] + + return results.map((row) => ({ + id: row.id, + title: row.title, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + is_new: row.is_new, + is_pinned: row.is_pinned, + settings: { + systemPrompt: row.systemPrompt, + temperature: row.temperature, + contextLength: row.contextLength, + maxTokens: row.maxTokens, + providerId: row.providerId, + modelId: row.modelId, + artifacts: row.artifacts as 0 | 1, + enabledMcpTools: getJsonField(row.enabled_mcp_tools, undefined), + thinkingBudget: row.thinking_budget !== null ? row.thinking_budget : undefined, + reasoningEffort: row.reasoning_effort + ? (row.reasoning_effort as 'minimal' | 'low' | 'medium' | 'high') + : undefined, + verbosity: row.verbosity ? (row.verbosity as 'low' | 'medium' | 'high') : undefined, + enableSearch: row.enable_search !== null ? Boolean(row.enable_search) : undefined, + forcedSearch: row.forced_search !== null ? Boolean(row.forced_search) : undefined, + searchStrategy: row.search_strategy ? (row.search_strategy as 'turbo' | 'max') : undefined, + selectedVariantsMap: getJsonField(row.context_chain, undefined), + agentWorkspacePath: + row.agent_workspace_path !== null && row.agent_workspace_path !== undefined + ? row.agent_workspace_path + : undefined, + acpWorkdirMap: getJsonField(row.acp_workdir_map, undefined) + }, + parentConversationId: row.parent_conversation_id, + parentMessageId: row.parent_message_id, + parentSelection: getJsonField(row.parent_selection, undefined) + })) + } + + async listByParentMessageIds(parentMessageIds: string[]): Promise { + if (parentMessageIds.length === 0) { + return [] + } + + const placeholders = parentMessageIds.map(() => '?').join(', ') + const results = this.db + .prepare( + ` + SELECT + conv_id as id, + title, + created_at as createdAt, + updated_at as updatedAt, + system_prompt as systemPrompt, + temperature, + context_length as contextLength, + max_tokens as maxTokens, + provider_id as providerId, + model_id as modelId, + is_new, + artifacts, + is_pinned, + enabled_mcp_tools, + thinking_budget, + reasoning_effort, + verbosity, + enable_search, + forced_search, + search_strategy, + context_chain, + agent_workspace_path, + acp_workdir_map, + parent_conversation_id, + parent_message_id, + parent_selection + FROM conversations + WHERE parent_message_id IN (${placeholders}) + ORDER BY updated_at DESC + ` + ) + .all(...parentMessageIds) as (ConversationRow & { + agent_workspace_path: string | null + acp_workdir_map: string | null + })[] + + return results.map((row) => ({ + id: row.id, + title: row.title, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + is_new: row.is_new, + is_pinned: row.is_pinned, + settings: { + systemPrompt: row.systemPrompt, + temperature: row.temperature, + contextLength: row.contextLength, + maxTokens: row.maxTokens, + providerId: row.providerId, + modelId: row.modelId, + artifacts: row.artifacts as 0 | 1, + enabledMcpTools: getJsonField(row.enabled_mcp_tools, undefined), + thinkingBudget: row.thinking_budget !== null ? row.thinking_budget : undefined, + reasoningEffort: row.reasoning_effort + ? (row.reasoning_effort as 'minimal' | 'low' | 'medium' | 'high') + : undefined, + verbosity: row.verbosity ? (row.verbosity as 'low' | 'medium' | 'high') : undefined, + enableSearch: row.enable_search !== null ? Boolean(row.enable_search) : undefined, + forcedSearch: row.forced_search !== null ? Boolean(row.forced_search) : undefined, + searchStrategy: row.search_strategy ? (row.search_strategy as 'turbo' | 'max') : undefined, + selectedVariantsMap: getJsonField(row.context_chain, undefined), + agentWorkspacePath: + row.agent_workspace_path !== null && row.agent_workspace_path !== undefined + ? row.agent_workspace_path + : undefined, + acpWorkdirMap: getJsonField(row.acp_workdir_map, undefined) + }, + parentConversationId: row.parent_conversation_id, + parentMessageId: row.parent_message_id, + parentSelection: getJsonField(row.parent_selection, undefined) + })) + } + async rename(conversationId: string, title: string): Promise { // 新增 updatedAt 更新 const updateStmt = this.db.prepare(` diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index c33759a4a..84ac050ec 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -2,6 +2,7 @@ import { IThreadPresenter, CONVERSATION, CONVERSATION_SETTINGS, + ParentSelection, MESSAGE_ROLE, MESSAGE_STATUS, MESSAGE_METADATA, @@ -18,7 +19,7 @@ import { MessageManager } from './managers/messageManager' import { eventBus } from '@/eventbus' import { AssistantMessage, Message, SearchEngineTemplate } from '@shared/chat' import { SearchManager } from './managers/searchManager' -import { TAB_EVENTS } from '@/events' +import { TAB_EVENTS, CONVERSATION_EVENTS } from '@/events' import { ConversationExportFormat, buildNowledgeMemExportData @@ -249,6 +250,82 @@ export class ThreadPresenter implements IThreadPresenter { await this.conversationManager.setActiveConversation(conversationId, tabId) } + async openConversationInNewTab(payload: { + conversationId: string + tabId?: number + messageId?: string + childConversationId?: string + }): Promise { + const { conversationId, tabId, messageId, childConversationId } = payload + + await this.sqlitePresenter.getConversation(conversationId) + + const existingTabId = await this.conversationManager.findTabForConversation(conversationId) + if (existingTabId !== null) { + await presenter.tabPresenter.switchTab(existingTabId) + if (messageId || childConversationId) { + eventBus.sendToTab(existingTabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { + conversationId, + messageId, + childConversationId + }) + } + return existingTabId + } + + const sourceWindowId = + typeof tabId === 'number' + ? presenter.tabPresenter.getWindowIdByWebContentsId(tabId) + : undefined + const fallbackWindowId = presenter.windowPresenter.getFocusedWindow()?.id + const windowId = sourceWindowId ?? fallbackWindowId + + if (!windowId) { + if (typeof tabId === 'number') { + await this.conversationManager.setActiveConversation(conversationId, tabId) + if (messageId || childConversationId) { + eventBus.sendToTab(tabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { + conversationId, + messageId, + childConversationId + }) + } + return tabId + } + return null + } + + const newTabId = await presenter.tabPresenter.createTab(windowId, 'local://chat', { + active: true + }) + + if (!newTabId) { + if (typeof tabId === 'number') { + await this.conversationManager.setActiveConversation(conversationId, tabId) + if (messageId || childConversationId) { + eventBus.sendToTab(tabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { + conversationId, + messageId, + childConversationId + }) + } + return tabId + } + return null + } + + await this.waitForTabReady(newTabId) + await this.conversationManager.setActiveConversation(conversationId, newTabId) + if (messageId || childConversationId) { + eventBus.sendToTab(newTabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { + conversationId, + messageId, + childConversationId + }) + } + return newTabId + } + async getActiveConversation(tabId: number): Promise { return this.conversationManager.getActiveConversation(tabId) } @@ -696,6 +773,118 @@ export class ThreadPresenter implements IThreadPresenter { ) } + async createChildConversationFromSelection(payload: { + parentConversationId: string + parentMessageId: string + parentSelection: ParentSelection | string + title: string + settings?: Partial + tabId?: number + openInNewTab?: boolean + }): Promise { + const { + parentConversationId, + parentMessageId, + parentSelection, + title, + settings, + tabId, + openInNewTab + } = payload + + const parentConversation = await this.sqlitePresenter.getConversation(parentConversationId) + if (!parentConversation) { + throw new Error('Parent conversation not found') + } + + await this.messageManager.getMessage(parentMessageId) + + const mergedSettings = { + ...parentConversation.settings, + ...settings + } + mergedSettings.selectedVariantsMap = {} + + const newConversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) + const resolvedParentSelection = + typeof parentSelection === 'string' + ? (() => { + try { + return JSON.parse(parentSelection) as ParentSelection + } catch { + throw new Error('Invalid parent selection payload') + } + })() + : parentSelection + await this.sqlitePresenter.updateConversation(newConversationId, { + is_new: 0, + parentConversationId, + parentMessageId, + parentSelection: resolvedParentSelection + }) + + const shouldOpenInNewTab = openInNewTab ?? true + if (shouldOpenInNewTab) { + const sourceWindowId = + typeof tabId === 'number' + ? presenter.tabPresenter.getWindowIdByWebContentsId(tabId) + : undefined + const fallbackWindowId = presenter.windowPresenter.getFocusedWindow()?.id + const windowId = sourceWindowId ?? fallbackWindowId + + if (windowId) { + const newTabId = await presenter.tabPresenter.createTab(windowId, 'local://chat', { + active: true + }) + if (newTabId) { + await this.waitForTabReady(newTabId) + await this.conversationManager.setActiveConversation(newConversationId, newTabId) + await this.broadcastThreadListUpdate() + return newConversationId + } + } + } + + if (typeof tabId === 'number') { + await this.conversationManager.setActiveConversation(newConversationId, tabId) + } + + await this.broadcastThreadListUpdate() + return newConversationId + } + + async listChildConversationsByParent(parentConversationId: string): Promise { + return this.sqlitePresenter.listChildConversationsByParent(parentConversationId) + } + + async listChildConversationsByMessageIds(parentMessageIds: string[]): Promise { + return this.sqlitePresenter.listChildConversationsByMessageIds(parentMessageIds) + } + + private async waitForTabReady(tabId: number): Promise { + return new Promise((resolve) => { + let resolved = false + const onTabReady = (readyTabId: number) => { + if (readyTabId === tabId && !resolved) { + resolved = true + eventBus.off(TAB_EVENTS.RENDERER_TAB_READY, onTabReady) + clearTimeout(timeoutId) + resolve() + } + } + + eventBus.on(TAB_EVENTS.RENDERER_TAB_READY, onTabReady) + + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true + eventBus.off(TAB_EVENTS.RENDERER_TAB_READY, onTabReady) + resolve() + } + }, 3000) + }) + } + // 翻译文本 async translateText(text: string, tabId: number): Promise { return this.utilityHandler.translateText(text, tabId) diff --git a/src/main/presenter/threadPresenter/managers/messageManager.ts b/src/main/presenter/threadPresenter/managers/messageManager.ts index 148a5f198..48370d923 100644 --- a/src/main/presenter/threadPresenter/managers/messageManager.ts +++ b/src/main/presenter/threadPresenter/managers/messageManager.ts @@ -14,7 +14,6 @@ import { UserMessageMentionBlock, UserMessageCodeBlock } from '@shared/chat' -import { formatUserMessageContent } from '../utils/messageContent' import { eventBus, SendTarget } from '@/eventbus' import { CONVERSATION_EVENTS } from '@/events' @@ -29,7 +28,17 @@ export class MessageManager implements IMessageManager { msgContentBlock: (UserMessageTextBlock | UserMessageMentionBlock | UserMessageCodeBlock)[] ): string { if (!Array.isArray(msgContentBlock)) return '' - return msgContentBlock.map((block) => block.content || '').join('') + return msgContentBlock + .map((block) => { + if (block.type === 'mention') { + if (block.category === 'context') { + const label = block.id?.trim() || 'context' + return `@${label}` + } + } + return block.content || '' + }) + .join('') } private convertToMessage(sqliteMessage: SQLITE_MESSAGE): Message { @@ -266,7 +275,7 @@ export class MessageManager implements IMessageManager { ...msg, content: { ...userContent, - text: formatUserMessageContent(userContent.content) + text: this.formatUserMessageContentForDisplay(userContent.content) } } } diff --git a/src/main/presenter/threadPresenter/utils/messageContent.ts b/src/main/presenter/threadPresenter/utils/messageContent.ts index 0932376e2..11034d7c0 100644 --- a/src/main/presenter/threadPresenter/utils/messageContent.ts +++ b/src/main/presenter/threadPresenter/utils/messageContent.ts @@ -88,6 +88,8 @@ export function formatUserMessageContent(msgContentBlock: UserMessageRichBlock[] return `@${block.id}` } else if (block.category === 'files') { return `@${block.id}` + } else if (block.category === 'context') { + return block.content } else if (block.category === 'prompts') { try { const promptData = JSON.parse(block.content) diff --git a/src/main/presenter/workspacePresenter/workspaceFileSearch.ts b/src/main/presenter/workspacePresenter/workspaceFileSearch.ts index cab82a904..16842fb6e 100644 --- a/src/main/presenter/workspacePresenter/workspaceFileSearch.ts +++ b/src/main/presenter/workspacePresenter/workspaceFileSearch.ts @@ -7,7 +7,7 @@ import { checkSensitiveFile, isBinaryFile } from './fileSecurity' const DEFAULT_RESULT_LIMIT = 50 -const escapeGlob = (input: string) => input.replace(/[\\*?\[\]]/g, '\\$&') +const escapeGlob = (input: string) => input.replace(/[[\\*?\]]/g, '\\$&') const buildFileNode = (filePath: string): WorkspaceFileNode => ({ name: path.basename(filePath), diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index 007c78ed5..faf46c656 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -64,6 +64,7 @@ import { useChatStore } from '@/stores/chat' import { useWorkspaceStore } from '@/stores/workspace' import { useCleanDialog } from '@/composables/message/useCleanDialog' import { useI18n } from 'vue-i18n' +import type { CategorizedData } from '@/components/editor/mention/suggestion' import { Dialog, DialogContent, @@ -145,6 +146,27 @@ const onCleanChatHistory = () => { cleanDialog.open() } +watch( + () => [chatStore.activeContextMention, chatInput.value] as const, + ([mention, input]) => { + if (!mention || !input) return + const activeThreadId = chatStore.getActiveThreadId() + if (!activeThreadId) return + const mentionData: CategorizedData = { + id: mention.id, + label: mention.label, + category: mention.category, + type: 'item', + content: mention.content + } + const inserted = input.appendCustomMention?.(mentionData) + if (inserted) { + chatStore.consumeContextMention(activeThreadId) + } + }, + { immediate: true } +) + // 监听流式响应 onMounted(async () => { window.electron.ipcRenderer.on(STREAM_EVENTS.END, onStreamEnd) diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 6f88ad30b..51f8074ed 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -430,6 +430,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@shadcn/components/ui/popover' import { Icon } from '@iconify/vue' import { Editor, EditorContent } from '@tiptap/vue-3' +import { TextSelection } from '@tiptap/pm/state' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' @@ -472,7 +473,7 @@ import suggestion, { setPromptFilesHandler, setWorkspaceMention } from '../editor/mention/suggestion' -import { mentionData } from '../editor/mention/suggestion' +import { mentionData, type CategorizedData } from '../editor/mention/suggestion' import { useEventListener } from '@vueuse/core' // === Props & Emits === @@ -878,6 +879,57 @@ const handleVisibilityChange = () => { } } +const hasContextMention = () => { + let found = false + editor.state.doc.descendants((node) => { + if (node.type.name === 'mention' && node.attrs?.category === 'context') { + found = true + return false + } + return true + }) + return found +} + +const setCaretToEnd = () => { + if (editor.isDestroyed) return + const { state, view } = editor + const endSelection = TextSelection.atEnd(state.doc) + view.dispatch(state.tr.setSelection(endSelection)) + editor.commands.focus() +} + +const scheduleCaretToEnd = () => { + const delays = [0, 50, 120] + for (const delay of delays) { + setTimeout(() => { + if (editor.isDestroyed) return + requestAnimationFrame(() => { + setCaretToEnd() + }) + }, delay) + } +} + +const appendCustomMention = (mention: CategorizedData) => { + const shouldInsertAtEnd = mention.category === 'context' + if (shouldInsertAtEnd && hasContextMention()) { + scheduleCaretToEnd() + return true + } + if (shouldInsertAtEnd) { + setCaretToEnd() + } + const insertPosition = shouldInsertAtEnd + ? editor.state.selection.to + : editor.state.selection.anchor + const inserted = editorComposable.insertMentionToEditor(mention, insertPosition) + if (inserted && shouldInsertAtEnd) { + scheduleCaretToEnd() + } + return inserted +} + useEventListener(window, 'resize', updateFakeCaretPosition) useEventListener(editorContainer, 'scroll', updateFakeCaretPosition) @@ -983,6 +1035,7 @@ defineExpose({ clearContent: editorComposable.clearContent, appendText: editorComposable.appendText, appendMention: (name: string) => editorComposable.appendMention(name, mentionData), + appendCustomMention, restoreFocus, getAgentWorkspacePath: () => { const mode = chatMode.currentMode.value diff --git a/src/renderer/src/components/chat-input/composables/usePromptInputEditor.ts b/src/renderer/src/components/chat-input/composables/usePromptInputEditor.ts index d4e0a0806..ac6bcf07f 100644 --- a/src/renderer/src/components/chat-input/composables/usePromptInputEditor.ts +++ b/src/renderer/src/components/chat-input/composables/usePromptInputEditor.ts @@ -107,6 +107,10 @@ export function usePromptInputEditor( fetchingMcpEntry.value = false } + if (subBlock.attrs?.category === 'context' && subBlock.attrs?.content) { + content = subBlock.attrs.content as string + } + if (subBlock.attrs?.category === 'prompts') { fetchingMcpEntry.value = true try { @@ -266,11 +270,13 @@ export function usePromptInputEditor( */ const insertMentionToEditor = (mentionData: CategorizedData, position: number): boolean => { try { + const mentionContent = + mentionData.content ?? (mentionData.mcpEntry ? JSON.stringify(mentionData.mcpEntry) : '') const mentionAttrs = { id: mentionData.id, label: mentionData.label, category: mentionData.category, - content: mentionData.mcpEntry ? JSON.stringify(mentionData.mcpEntry) : '' + content: mentionContent } const success = editor diff --git a/src/renderer/src/components/editor/mention/suggestion.ts b/src/renderer/src/components/editor/mention/suggestion.ts index e23ec847d..7c7c780b2 100644 --- a/src/renderer/src/components/editor/mention/suggestion.ts +++ b/src/renderer/src/components/editor/mention/suggestion.ts @@ -14,10 +14,12 @@ export interface CategorizedData { category?: string description?: string mcpEntry?: ResourceListEntry | PromptListEntry + content?: string } // Sample categorized items const categorizedData: CategorizedData[] = [ + { label: 'context', icon: 'lucide:quote', type: 'category' }, { label: 'files', icon: 'lucide:files', type: 'category' }, { label: 'resources', icon: 'lucide:swatch-book', type: 'category' }, { label: 'tools', icon: 'lucide:hammer', type: 'category' }, diff --git a/src/renderer/src/components/message/MessageContent.vue b/src/renderer/src/components/message/MessageContent.vue index 78707bbdd..c3d809987 100644 --- a/src/renderer/src/components/message/MessageContent.vue +++ b/src/renderer/src/components/message/MessageContent.vue @@ -8,11 +8,11 @@ - {{ block.category === 'prompts' ? block.id : block.content }} + {{ getMentionLabel(block) }} @@ -59,6 +59,7 @@ const handleMentionClick = (block: UserMessageMentionBlock) => { // 根据 category 获取对应的图标 const getMentionIcon = (category: string) => { const iconMap: Record = { + context: 'lucide:quote', prompts: 'lucide:message-square-quote', files: 'lucide:file-text', tools: 'lucide:wrench', @@ -72,4 +73,21 @@ const getMentionIcon = (category: string) => { return iconMap[category] || iconMap.default } + +const getMentionLabel = (block: UserMessageMentionBlock) => { + if (block.category === 'prompts') { + return block.id || block.content + } + if (block.category === 'context') { + return block.id || block.category + } + return block.content +} + +const getMentionTitle = (block: UserMessageMentionBlock) => { + if (block.category === 'context') { + return block.id || '' + } + return block.content +} diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index 5db60d49e..754b16387 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -22,7 +22,7 @@
-
+