diff --git a/package.json b/package.json index ff7ea1bc8b..42f8b96655 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build:all": "yarn build:webview-prebuilt && yarn run build && yarn run build:worker-host && yarn run build:ext-host && yarn build:watcher-host && yarn run build:components && yarn build:monaco-worker", "build:cli-engine": "cd tools/cli-engine && yarn run build", "build:components": "cd packages/components && yarn run build:dist", + "build:components:lib": "cd packages/components && yarn run build", "build:ext-host": "cd packages/extension && yarn run build:ext-host", "build:watcher-host": "cd packages/file-service && yarn run build:watcher-host", "build:monaco-worker": "cd packages/monaco && yarn run build:worker", diff --git a/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts b/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts index ff3ff1ff99..549992cabe 100644 --- a/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts +++ b/packages/ai-native/__test__/browser/chat/chat-agent.service.test.ts @@ -25,7 +25,7 @@ describe('ChatAgentService', () => { { token: ChatAgentPromptProvider, useValue: { - provideContextPrompt: (val, msg) => msg, + provideContextPrompt: async (val, msg) => msg, }, }, { diff --git a/packages/ai-native/src/browser/chat/chat-agent.service.ts b/packages/ai-native/src/browser/chat/chat-agent.service.ts index 30d236656d..3eaede5263 100644 --- a/packages/ai-native/src/browser/chat/chat-agent.service.ts +++ b/packages/ai-native/src/browser/chat/chat-agent.service.ts @@ -59,7 +59,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private readonly aiReporter: IAIReporter; @Autowired(LLMContextServiceToken) - protected readonly contextService: LLMContextService; + protected readonly llmContextService: LLMContextService; @Autowired(ChatAgentPromptProvider) protected readonly promptProvider: ChatAgentPromptProvider; @@ -74,7 +74,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { super(); this.addDispose(this._onDidChangeAgents); this.addDispose( - this.contextService.onDidContextFilesChangeEvent((event) => { + this.llmContextService.onDidContextFilesChangeEvent((event) => { if (event.version !== this.contextVersion) { this.contextVersion = event.version; this.shouldUpdateContext = true; @@ -152,9 +152,9 @@ export class ChatAgentService extends Disposable implements IChatAgentService { if (!this.initialUserMessageMap.has(request.sessionId)) { this.initialUserMessageMap.set(request.sessionId, request.message); const rawMessage = request.message; - request.message = this.provideContextMessage(rawMessage, request.sessionId); + request.message = await this.provideContextMessage(rawMessage, request.sessionId); } else if (this.shouldUpdateContext || request.regenerate || history.length === 0) { - request.message = this.provideContextMessage(request.message, request.sessionId); + request.message = await this.provideContextMessage(request.message, request.sessionId); this.shouldUpdateContext = false; } @@ -162,9 +162,9 @@ export class ChatAgentService extends Disposable implements IChatAgentService { return result; } - private provideContextMessage(message: string, sessionId: string) { - const context = this.contextService.serialize(); - const fullMessage = this.promptProvider.provideContextPrompt(context, message); + private async provideContextMessage(message: string, sessionId: string) { + const context = await this.llmContextService.serialize(); + const fullMessage = await this.promptProvider.provideContextPrompt(context, message); this.aiReporter.send({ msgType: AIServiceType.Chat, actionType: ActionTypeEnum.ContextEnhance, diff --git a/packages/ai-native/src/browser/chat/chat.view.tsx b/packages/ai-native/src/browser/chat/chat.view.tsx index b4e142efad..462bb11a3e 100644 --- a/packages/ai-native/src/browser/chat/chat.view.tsx +++ b/packages/ai-native/src/browser/chat/chat.view.tsx @@ -1,7 +1,14 @@ import * as React from 'react'; import { MessageList } from 'react-chat-elements'; -import { AppConfig, getIcon, useInjectable, useUpdateOnEvent } from '@opensumi/ide-core-browser'; +import { + AINativeConfigService, + AppConfig, + LabelService, + getIcon, + useInjectable, + useUpdateOnEvent, +} from '@opensumi/ide-core-browser'; import { Popover, PopoverPosition } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { @@ -14,6 +21,7 @@ import { ChatMessageRole, ChatRenderRegistryToken, ChatServiceToken, + CommandService, Disposable, DisposableCollection, IAIReporter, @@ -28,16 +36,19 @@ import { import { WorkbenchEditorService } from '@opensumi/ide-editor'; import { IMainLayoutService } from '@opensumi/ide-main-layout'; import { IMessageService } from '@opensumi/ide-overlay'; - import 'react-chat-elements/dist/main.css'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + import { AI_CHAT_VIEW_ID, IChatAgentService, IChatInternalService, IChatMessageStructure } from '../../common'; +import { LLMContextService, LLMContextServiceToken } from '../../common/llm-context'; import { CodeBlockData } from '../../common/types'; +import { cleanAttachedTextWrapper } from '../../common/utils'; import { FileChange, FileListDisplay } from '../components/ChangeList'; -import { ChatContext } from '../components/ChatContext'; import { CodeBlockWrapperInput } from '../components/ChatEditor'; import ChatHistory, { IChatHistoryItem } from '../components/ChatHistory'; import { ChatInput } from '../components/ChatInput'; import { ChatMarkdown } from '../components/ChatMarkdown'; +import { ChatMentionInput } from '../components/ChatMentionInput'; import { ChatNotify, ChatReply } from '../components/ChatReply'; import { SlashCustomRender } from '../components/SlashCustomRender'; import { MessageData, createMessageByAI, createMessageByUser } from '../components/utils'; @@ -105,6 +116,8 @@ export const AIChatView = () => { const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); const chatRenderRegistry = useInjectable(ChatRenderRegistryToken); const mcpServerRegistry = useInjectable(TokenMCPServerRegistry); + const aiNativeConfigService = useInjectable(AINativeConfigService); + const llmContextService = useInjectable(LLMContextServiceToken); const layoutService = useInjectable(IMainLayoutService); const msgHistoryManager = aiChatService.sessionModel.history; @@ -114,6 +127,9 @@ export const AIChatView = () => { const editorService = useInjectable(WorkbenchEditorService); const appConfig = useInjectable(AppConfig); const applyService = useInjectable(BaseApplyService); + const labelService = useInjectable(LabelService); + const workspaceService = useInjectable(IWorkspaceService); + const commandService = useInjectable(CommandService); const [shortcutCommands, setShortcutCommands] = React.useState([]); const [changeList, setChangeList] = React.useState(getFileChanges(applyService.getSessionCodeBlocks())); @@ -184,6 +200,9 @@ export const AIChatView = () => { if (chatRenderRegistry.chatInputRender) { return chatRenderRegistry.chatInputRender; } + if (aiNativeConfigService.capabilities.supportsMCP) { + return ChatMentionInput; + } return ChatInput; }, [chatRenderRegistry.chatInputRender]); @@ -262,7 +281,7 @@ export const AIChatView = () => { if (loading) { return; } - await handleSend(message); + await handleSend(message.message, message.agentId, message.command); } else { if (message.agentId) { setAgentId(message.agentId); @@ -349,6 +368,9 @@ export const AIChatView = () => { text={message} agentId={visibleAgentId} command={command} + labelService={labelService} + workspaceService={workspaceService} + commandService={commandService} /> ), }, @@ -454,7 +476,15 @@ export const AIChatView = () => { text: ChatUserRoleRender ? ( ) : ( - + ), }, styles.chat_message_code, @@ -634,15 +664,50 @@ export const AIChatView = () => { msgId, }); }, - [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom], + [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom, loading], ); const handleSend = React.useCallback( - async (value: IChatMessageStructure) => { - const { message, command, reportExtra } = value; + async (message: string, agentId?: string, command?: string) => { + const reportExtra = { + actionSource: ActionSourceEnum.Chat, + actionType: ActionTypeEnum.Send, + }; + agentId = agentId ? agentId : ChatProxyService.AGENT_ID; + // 提取并替换 {{@file:xxx}} 中的文件内容 + let processedContent = message; + const filePattern = /\{\{@file:(.*?)\}\}/g; + const fileMatches = message.match(filePattern); + let isCleanContext = false; + if (fileMatches) { + for (const match of fileMatches) { + const filePath = match.replace(/\{\{@file:(.*?)\}\}/, '$1'); + if (filePath && !isCleanContext) { + isCleanContext = true; + llmContextService.cleanFileContext(); + } + const fileUri = new URI(filePath); + llmContextService.addFileToContext(fileUri, undefined, true); + const relativePath = (await workspaceService.asRelativePath(fileUri))?.path || fileUri.displayName; + // 获取文件内容 + // 替换占位符,后续支持自定义渲染时可替换为自定义渲染标签 + processedContent = processedContent.replace(match, `\`${relativePath}\``); + } + } - const agentId = value.agentId ? value.agentId : ChatProxyService.AGENT_ID; - return handleAgentReply({ message, agentId, command, reportExtra }); + const folderPattern = /\{\{@folder:(.*?)\}\}/g; + const folderMatches = processedContent.match(folderPattern); + if (folderMatches) { + for (const match of folderMatches) { + const folderPath = match.replace(/\{\{@folder:(.*?)\}\}/, '$1'); + const folderUri = new URI(folderPath); + llmContextService.addFolderToContext(folderUri); + const relativePath = (await workspaceService.asRelativePath(folderUri))?.path || folderUri.displayName; + // 替换占位符,后续支持自定义渲染时可替换为自定义渲染标签 + processedContent = processedContent.replace(match, `\`${relativePath}\``); + } + } + return handleAgentReply({ message: processedContent, agentId, command, reportExtra }); }, [handleAgentReply], ); @@ -759,7 +824,6 @@ export const AIChatView = () => { ) : null}
-
{shortcutCommands.map((command) => ( @@ -790,17 +854,7 @@ export const AIChatView = () => { /> )} - handleSend({ - message: value, - agentId, - command, - reportExtra: { - actionSource: ActionSourceEnum.Chat, - actionType: ActionTypeEnum.Send, - }, - }) - } + onSend={handleSend} disabled={loading} enableOptions={true} theme={theme} @@ -857,12 +911,15 @@ export function DefaultChatViewHeader({ const getHistoryList = () => { const currentMessages = aiChatService.sessionModel.history.getMessages(); const latestUserMessage = currentMessages.findLast((m) => m.role === ChatMessageRole.User); - setCurrentTitle(latestUserMessage ? latestUserMessage.content.slice(0, MAX_TITLE_LENGTH) : ''); + setCurrentTitle( + latestUserMessage ? cleanAttachedTextWrapper(latestUserMessage.content).slice(0, MAX_TITLE_LENGTH) : '', + ); setHistoryList( aiChatService.getSessions().map((session) => { const history = session.history; const messages = history.getMessages(); - const title = messages.length > 0 ? messages[0].content.slice(0, MAX_TITLE_LENGTH) : ''; + const title = + messages.length > 0 ? cleanAttachedTextWrapper(messages[0].content).slice(0, MAX_TITLE_LENGTH) : ''; const updatedAt = messages.length > 0 ? messages[messages.length - 1].replyStartTime || 0 : 0; // const loading = session.requests[session.requests.length - 1]?.response.isComplete; return { diff --git a/packages/ai-native/src/browser/components/ChatEditor.tsx b/packages/ai-native/src/browser/components/ChatEditor.tsx index e7c7470c08..4035b66e97 100644 --- a/packages/ai-native/src/browser/components/ChatEditor.tsx +++ b/packages/ai-native/src/browser/components/ChatEditor.tsx @@ -2,14 +2,24 @@ import capitalize from 'lodash/capitalize'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Highlight from 'react-highlight'; -import { IClipboardService, getIcon, useInjectable, uuid } from '@opensumi/ide-core-browser'; -import { Popover } from '@opensumi/ide-core-browser/lib/components'; +import { + EDITOR_COMMANDS, + FILE_COMMANDS, + IClipboardService, + LabelService, + getIcon, + useInjectable, + uuid, +} from '@opensumi/ide-core-browser'; +import { Icon, Popover } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { ActionSourceEnum, ActionTypeEnum, ChatFeatureRegistryToken, + CommandService, IAIReporter, + URI, localize, runWhenIdle, } from '@opensumi/ide-core-common'; @@ -22,6 +32,9 @@ import { ChatFeatureRegistry } from '../chat/chat.feature.registry'; import styles from './components.module.less'; import { highLightLanguageSupport } from './highLight'; +import { MentionType } from './mention-input/types'; + +import type { IWorkspaceService } from '@opensumi/ide-workspace'; import './highlightTheme.less'; @@ -139,16 +152,56 @@ const CodeBlock = ({ renderText, agentId = '', command = '', + labelService, + commandService, + workspaceService, }: { content?: string; relationId: string; renderText?: (t: string) => React.ReactNode; agentId?: string; command?: string; + labelService?: LabelService; + commandService?: CommandService; + workspaceService?: IWorkspaceService; }) => { const rgInlineCode = /`([^`]+)`/g; const rgBlockCode = /```([^]+?)```/g; const rgBlockCodeBefore = /```([^]+)?/g; + const rgAttachedFile = /(.*)/g; + const rgAttachedFolder = /(.*)/g; + const handleAttachmentClick = useCallback( + async (text: string, type: MentionType) => { + const roots = await workspaceService?.roots; + let uri; + if (!roots) { + return; + } + for (const root of roots) { + uri = new URI(root.uri).resolve(text); + try { + await commandService?.executeCommand(FILE_COMMANDS.REVEAL_IN_EXPLORER.id, uri); + if (type === MentionType.FILE) { + await commandService?.executeCommand(EDITOR_COMMANDS.OPEN_RESOURCE.id, uri); + } + break; + } catch { + continue; + } + } + }, + [commandService, workspaceService], + ); + const renderAttachment = (text: string, isFolder = false, key: string) => ( + handleAttachmentClick(text, isFolder ? MentionType.FOLDER : MentionType.FILE)} + > + + {text} + + ); const renderCodeEditor = (content: string) => { const language = content.split('\n')[0].trim().toLowerCase(); @@ -193,11 +246,49 @@ const CodeBlock = ({ renderedContent.push(text); } } else { - renderedContent.push( - - {text} - , - ); + // 处理文件和文件夹标记 + const processedText = text; + const fileMatches = [...text.matchAll(rgAttachedFile)]; + const folderMatches = [...text.matchAll(rgAttachedFolder)]; + if (fileMatches.length || folderMatches.length) { + let lastIndex = 0; + const fragments: (string | React.ReactNode)[] = []; + + // 通用处理函数 + const processMatches = (matches: RegExpMatchArray[], isFolder: boolean) => { + matches.forEach((match, matchIndex) => { + if (match.index !== undefined) { + const spanText = processedText.slice(lastIndex, match.index); + if (spanText) { + fragments.push( + {spanText}, + ); + } + fragments.push( + renderAttachment( + match[1], + isFolder, + `${index}-tag-${matchIndex}-${isFolder ? 'folder' : 'file'}`, + ), + ); + lastIndex = match.index + match[0].length; + } + }); + }; + + // 处理文件标记 + processMatches(fileMatches, false); + processMatches(folderMatches, true); + + fragments.push(processedText.slice(lastIndex)); + renderedContent.push(...fragments); + } else { + renderedContent.push( + + {text} + , + ); + } } }); } else { @@ -216,15 +307,29 @@ export const CodeBlockWrapper = ({ renderText, relationId, agentId, + labelService, + commandService, + workspaceService, }: { text?: string; relationId: string; renderText?: (t: string) => React.ReactNode; agentId?: string; + labelService?: LabelService; + commandService?: CommandService; + workspaceService?: IWorkspaceService; }) => (
- +
); @@ -234,11 +339,17 @@ export const CodeBlockWrapperInput = ({ relationId, agentId, command, + labelService, + workspaceService, + commandService, }: { text: string; relationId: string; agentId?: string; command?: string; + labelService?: LabelService; + workspaceService?: IWorkspaceService; + commandService?: CommandService; }) => { const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); const [tag, setTag] = useState(''); @@ -271,7 +382,15 @@ export const CodeBlockWrapperInput = ({
)} {command &&
/ {command}
} - +
); diff --git a/packages/ai-native/src/browser/components/ChatHistory.tsx b/packages/ai-native/src/browser/components/ChatHistory.tsx index ce44a5f442..1a2f9b560f 100644 --- a/packages/ai-native/src/browser/components/ChatHistory.tsx +++ b/packages/ai-native/src/browser/components/ChatHistory.tsx @@ -163,25 +163,22 @@ const ChatHistory: FC = memo( (item: IChatHistoryItem) => (
handleHistoryItemSelect(item)} > -
+
{item.loading ? ( ) : ( )} {!historyTitleEditable?.[item.id] ? ( - + {item.title} ) : ( { @@ -191,18 +188,9 @@ const ChatHistory: FC = memo( /> )}
-
- {/* { - e.preventDefault(); - e.stopPropagation(); - handleTitleEdit(item); - }} - /> */} +
{ e.preventDefault(); e.stopPropagation(); @@ -237,14 +225,14 @@ const ChatHistory: FC = memo(
-
+
{groupedHistoryList.map((group) => (
-
{group.key}
+
{group.key}
{group.items.map(renderHistoryItem)}
))} @@ -257,13 +245,13 @@ const ChatHistory: FC = memo( const getPopupContainer = useCallback((triggerNode: HTMLElement) => triggerNode.parentElement!, []); return ( -
-
+
+
{title}
-
+
= memo( getPopupContainer={getPopupContainer} >
- +
= memo( title={localize('aiNative.operate.newChat.title')} > diff --git a/packages/ai-native/src/browser/components/ChatMentionInput.tsx b/packages/ai-native/src/browser/components/ChatMentionInput.tsx new file mode 100644 index 0000000000..43e4720a9a --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatMentionInput.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { LabelService, RecentFilesManager, useInjectable } from '@opensumi/ide-core-browser'; +import { getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { URI, localize } from '@opensumi/ide-core-common'; +import { CommandService } from '@opensumi/ide-core-common/lib/command'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search'; +import { IWorkspaceService } from '@opensumi/ide-workspace'; + +import { IChatInternalService } from '../../common'; +import { ChatInternalService } from '../chat/chat.internal.service'; +import { OPEN_MCP_CONFIG_COMMAND } from '../mcp/config/mcp-config.commands'; + +import styles from './components.module.less'; +import { MentionInput } from './mention-input/mention-input'; +import { FooterButtonPosition, FooterConfig, MentionItem, MentionType } from './mention-input/types'; + +export interface IChatMentionInputProps { + onSend: (value: string, agentId?: string, command?: string, option?: { model: string; [key: string]: any }) => void; + onValueChange?: (value: string) => void; + onExpand?: (value: boolean) => void; + placeholder?: string; + enableOptions?: boolean; + disabled?: boolean; + sendBtnClassName?: string; + defaultHeight?: number; + value?: string; + autoFocus?: boolean; + theme?: string | null; + setTheme: (theme: string | null) => void; + agentId: string; + setAgentId: (id: string) => void; + defaultAgentId?: string; + command: string; + setCommand: (command: string) => void; +} + +// 指令命令激活组件 +export const ChatMentionInput = (props: IChatMentionInputProps) => { + const { onSend, disabled = false } = props; + + const [value, setValue] = useState(props.value || ''); + const aiChatService = useInjectable(IChatInternalService); + const commandService = useInjectable(CommandService); + const searchService = useInjectable(FileSearchServicePath); + const recentFilesManager = useInjectable(RecentFilesManager); + const workspaceService = useInjectable(IWorkspaceService); + const editorService = useInjectable(WorkbenchEditorService); + const labelService = useInjectable(LabelService); + + const handleShowMCPConfig = React.useCallback(() => { + commandService.executeCommand(OPEN_MCP_CONFIG_COMMAND.id); + }, [commandService]); + + useEffect(() => { + if (props.value !== value) { + setValue(props.value || ''); + } + }, [props.value]); + + // 默认菜单项 + const defaultMenuItems: MentionItem[] = [ + // { + // id: 'code', + // type: 'code', + // text: 'Code', + // icon: getIcon('codebraces'), + // getHighestLevelItems: () => [], + // getItems: async (searchText: string) => { + // const currentEditor = editorService.currentEditor; + // if (!currentEditor) { + // return []; + // } + // const currentDocumentModel = currentEditor.currentDocumentModel; + // if (!currentDocumentModel) { + // return []; + // } + // const symbols = await commandService.executeCommand('_executeFormatDocumentProvider', currentDocumentModel.uri.codeUri); + // return []; + // }, + // }, + { + id: MentionType.FILE, + type: MentionType.FILE, + text: 'File', + icon: getIcon('file'), + getHighestLevelItems: () => { + const currentEditor = editorService.currentEditor; + const currentUri = currentEditor?.currentUri; + if (!currentUri) { + return []; + } + return [ + { + id: currentUri.codeUri.fsPath, + type: MentionType.FILE, + text: currentUri.displayName, + value: currentUri.codeUri.fsPath, + description: `(${localize('aiNative.chat.defaultContextFile')})`, + contextId: currentUri.codeUri.fsPath, + icon: labelService.getIcon(currentUri), + }, + ]; + }, + getItems: async (searchText: string) => { + if (!searchText) { + const recentFile = await recentFilesManager.getMostRecentlyOpenedFiles(); + return Promise.all( + recentFile.map(async (file) => { + const uri = new URI(file); + const relatveParentPath = (await workspaceService.asRelativePath(uri.parent))?.path; + return { + id: uri.codeUri.fsPath, + type: MentionType.FILE, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relatveParentPath || '', + contextId: uri.codeUri.fsPath, + icon: labelService.getIcon(uri), + }; + }), + ); + } else { + const rootUris = (await workspaceService.roots).map((root) => new URI(root.uri).codeUri.fsPath.toString()); + const results = await searchService.find(searchText, { + rootUris, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + limit: 10, + }); + return Promise.all( + results.map(async (file) => { + const uri = new URI(file); + const relatveParentPath = (await workspaceService.asRelativePath(uri.parent))?.path; + return { + id: uri.codeUri.fsPath, + type: MentionType.FILE, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relatveParentPath || '', + contextId: uri.codeUri.fsPath, + icon: labelService.getIcon(uri), + }; + }), + ); + } + }, + }, + { + id: MentionType.FOLDER, + type: MentionType.FOLDER, + text: 'Folder', + icon: getIcon('folder'), + getHighestLevelItems: () => { + const currentEditor = editorService.currentEditor; + const currentFolderUri = currentEditor?.currentUri?.parent; + if (!currentFolderUri) { + return []; + } + if (currentFolderUri.toString() === workspaceService.workspace?.uri) { + return []; + } + return [ + { + id: currentFolderUri.codeUri.fsPath, + type: MentionType.FOLDER, + text: currentFolderUri.displayName, + value: currentFolderUri.codeUri.fsPath, + description: `(${localize('aiNative.chat.defaultContextFolder')})`, + contextId: currentFolderUri.codeUri.fsPath, + icon: getIcon('folder'), + }, + ]; + }, + getItems: async (searchText: string) => { + let folders: MentionItem[] = []; + if (!searchText) { + const recentFile = await recentFilesManager.getMostRecentlyOpenedFiles(); + const recentFolder = Array.from(new Set(recentFile.map((file) => new URI(file).parent.codeUri.fsPath))); + folders = await Promise.all( + recentFolder.map(async (folder) => { + const uri = new URI(folder); + const relativePath = await workspaceService.asRelativePath(uri); + return { + id: uri.codeUri.fsPath, + type: MentionType.FOLDER, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: relativePath?.root ? relativePath.path : '', + contextId: uri.codeUri.fsPath, + icon: getIcon('folder'), + }; + }), + ); + } else { + const rootUris = (await workspaceService.roots).map((root) => new URI(root.uri).codeUri.fsPath); + const results = await searchService.find(searchText, { + rootUris, + useGitIgnore: true, + noIgnoreParent: true, + fuzzyMatch: true, + limit: 10, + onlyFolders: true, + }); + folders = await Promise.all( + results.map(async (folder) => { + const uri = new URI(folder); + return { + id: uri.codeUri.fsPath, + type: MentionType.FOLDER, + text: uri.displayName, + value: uri.codeUri.fsPath, + description: (await workspaceService.asRelativePath(uri.parent))?.path || '', + contextId: uri.codeUri.fsPath, + icon: getIcon('folder'), + }; + }), + ); + } + return folders.filter((folder) => folder.id !== new URI(workspaceService.workspace?.uri).codeUri.fsPath); + }, + }, + ]; + + const defaultMentionInputFooterOptions: FooterConfig = useMemo( + () => ({ + modelOptions: [ + { label: 'QWQ 32B', value: 'qwq-32b' }, + { label: 'DeepSeek R1', value: 'deepseek-r1' }, + ], + defaultModel: 'deepseek-r1', + buttons: [ + { + id: 'mcp-server', + icon: 'mcp', + title: 'MCP Server', + onClick: handleShowMCPConfig, + position: FooterButtonPosition.LEFT, + }, + ], + showModelSelector: true, + }), + [handleShowMCPConfig], + ); + + const handleStop = useCallback(() => { + aiChatService.cancelRequest(); + }, []); + + const handleSend = useCallback( + async (content: string, option?: { model: string; [key: string]: any }) => { + if (disabled) { + return; + } + onSend(content, undefined, undefined, option); + }, + [onSend, editorService, disabled], + ); + + return ( +
+ +
+ ); +}; diff --git a/packages/ai-native/src/browser/components/ChatThinking.tsx b/packages/ai-native/src/browser/components/ChatThinking.tsx index 4c9ad137fd..c7e542f01a 100644 --- a/packages/ai-native/src/browser/components/ChatThinking.tsx +++ b/packages/ai-native/src/browser/components/ChatThinking.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useInjectable } from '@opensumi/ide-core-browser'; -import { Icon, getIcon } from '@opensumi/ide-core-browser/lib/components'; import { EnhanceIcon, Thumbs } from '@opensumi/ide-core-browser/lib/components/ai-native'; import { Progress } from '@opensumi/ide-core-browser/lib/progress/progress-bar'; import { ChatRenderRegistryToken, isUndefined, localize } from '@opensumi/ide-core-common'; @@ -13,7 +12,7 @@ import { ChatRenderRegistry } from '../chat/chat.render.registry'; import styles from './components.module.less'; interface ITinkingProps { - children?: React.ReactNode; + children?: React.ReactNode | string | React.ReactNode[]; hasMessage?: boolean; message?: string; onRegenerate?: () => void; @@ -32,8 +31,15 @@ export const ChatThinking = (props: ITinkingProps) => { [chatRenderRegistry, chatRenderRegistry.chatThinkingRender], ); + const isEmptyChildren = useMemo(() => { + if (Array.isArray(children)) { + return children.length === 0; + } + return !children; + }, [children]); + const renderContent = useCallback(() => { - if (!children) { + if (isEmptyChildren) { if (CustomThinkingRender) { return ; } @@ -52,7 +58,7 @@ export const ChatThinking = (props: ITinkingProps) => { {!CustomThinkingRender && ( {/* 保持动画效果一致 */} - {!children && } + {isEmptyChildren && } )} {/* {showStop && ( diff --git a/packages/ai-native/src/browser/components/change-list.module.less b/packages/ai-native/src/browser/components/change-list.module.less index 83daffe152..539f893787 100644 --- a/packages/ai-native/src/browser/components/change-list.module.less +++ b/packages/ai-native/src/browser/components/change-list.module.less @@ -103,6 +103,8 @@ .fileStats { font-size: 12px; display: flex; + flex: 1; + justify-content: flex-end; align-items: center; gap: 4px; } diff --git a/packages/ai-native/src/browser/components/ChatContext/ContextSelector.tsx b/packages/ai-native/src/browser/components/chat-context/context-selector.tsx similarity index 100% rename from packages/ai-native/src/browser/components/ChatContext/ContextSelector.tsx rename to packages/ai-native/src/browser/components/chat-context/context-selector.tsx diff --git a/packages/ai-native/src/browser/components/ChatContext/index.tsx b/packages/ai-native/src/browser/components/chat-context/index.tsx similarity index 99% rename from packages/ai-native/src/browser/components/ChatContext/index.tsx rename to packages/ai-native/src/browser/components/chat-context/index.tsx index d38aa07031..a730675055 100644 --- a/packages/ai-native/src/browser/components/ChatContext/index.tsx +++ b/packages/ai-native/src/browser/components/chat-context/index.tsx @@ -15,7 +15,7 @@ import { WorkbenchEditorService } from '@opensumi/ide-editor/lib/browser/types'; import { FileContext, LLMContextService, LLMContextServiceToken } from '../../../common/llm-context'; -import { ContextSelector } from './ContextSelector'; +import { ContextSelector } from './context-selector'; import styles from './style.module.less'; const getCollapsedHeight = () => ({ height: 0, opacity: 0 }); diff --git a/packages/ai-native/src/browser/components/ChatContext/style.module.less b/packages/ai-native/src/browser/components/chat-context/style.module.less similarity index 100% rename from packages/ai-native/src/browser/components/ChatContext/style.module.less rename to packages/ai-native/src/browser/components/chat-context/style.module.less diff --git a/packages/ai-native/src/browser/components/chat-history.module.less b/packages/ai-native/src/browser/components/chat-history.module.less index 3f09512005..20b01f367a 100644 --- a/packages/ai-native/src/browser/components/chat-history.module.less +++ b/packages/ai-native/src/browser/components/chat-history.module.less @@ -1,4 +1,4 @@ -.dm-chat-history-header { +.chat_history_header { display: flex; align-items: center; justify-content: space-between; @@ -8,7 +8,7 @@ text-overflow: ellipsis; white-space: nowrap; - .dm-chat-history-header-title { + .chat_history_header_title { opacity: 0.6; display: flex; align-items: center; @@ -22,22 +22,34 @@ } } - .dm-chat-history-header-actions { + .chat_history_header_actions { display: flex; align-items: center; font-size: 12px; - .dm-chat-history-header-actions-history { + .chat_history_header_actions_history { cursor: pointer; } - .dm-chat-history-header-actions-new { + .chat_history_header_actions_new { margin-left: 2px; cursor: pointer; } - .kt-popover-title { - margin-bottom: 8px; + :global { + .kt-popover-title { + margin-bottom: 8px; + } + .kt-popover-content { + background-color: var(--editor-background); + color: var(--editor-foreground); + } + .kt-popover-arrow { + border-bottom-color: var(--editor-background); + } + .kt-popover { + opacity: 1; + } } } @@ -75,7 +87,12 @@ } } - .dm-chat-history-list { + .chat_history_search { + width: 100%; + border-radius: 4px; + } + + .chat_history_list { overflow: auto; max-height: 400px; width: 300px; @@ -83,12 +100,12 @@ font-size: 13px; } - .dm-chat-history-time { + .chat_history_time { opacity: 0.6; padding-left: 4px; } - .dm-chat-history-item { + .chat_history_item { display: flex; align-items: center; justify-content: space-between; @@ -97,7 +114,7 @@ margin-top: 2px; border-radius: 3px; - .dm-chat-history-item-content { + .chat_history_item_content { display: flex; align-items: center; width: 100%; @@ -105,28 +122,28 @@ height: 24px; } - .dm-chat-history-item-title { + .chat_history_item_title { overflow: hidden; text-overflow: ellipsis; display: inline-block; white-space: nowrap; } - .dm-chat-history-item-actions { + .chat_history_item_actions { display: none; } - .dm-chat-history-item-selected { + .chat_history_item_selected { background: var(--textPreformat-background); } &:hover { background: var(--textPreformat-background); - .dm-chat-history-item-actions { + .chat_history_item_actions { display: block; } - .dm-chat-history-item-content { + .chat_history_item_content { max-width: calc(100% - 50px); } } diff --git a/packages/ai-native/src/browser/components/components.module.less b/packages/ai-native/src/browser/components/components.module.less index a3de2fb5bf..0f35ff7632 100644 --- a/packages/ai-native/src/browser/components/components.module.less +++ b/packages/ai-native/src/browser/components/components.module.less @@ -4,9 +4,9 @@ height: 100%; .stop { position: absolute; - bottom: -38px; + bottom: -15px; padding-top: 12px; - left: -8px; + left: -9px; width: 105%; } @@ -453,7 +453,13 @@ h3, h4, h5 { - color: #fff; + color: var(--foreground); + } + + hr { + border-bottom: 0; + opacity: 0.3; + border-color: var(--descriptionForeground); } p { @@ -519,6 +525,7 @@ display: flex; font-size: 11px; align-items: center; + min-width: 150px; } .mcp_desc { @@ -565,3 +572,32 @@ color: var(--descriptionForeground); } } + +.attachment { + display: inline-flex; + align-items: center; + padding: 0 4px; + margin: 0 2px; + background: var(--badge-background); + color: var(--badge-foreground); + border-radius: 3px; + vertical-align: middle; + font-size: 12px; + cursor: pointer; + :global { + .kt-icon { + font-size: 12px; + margin-right: 3px; + } + } + &:hover { + background-color: var(--chat-slashCommandBackground); + color: var(--chat-slashCommandForeground); + } +} + +.attachment_text { + line-height: 20px; + vertical-align: middle; + font-size: 12px; +} diff --git a/packages/ai-native/src/browser/components/mention-input/mention-input.module.less b/packages/ai-native/src/browser/components/mention-input/mention-input.module.less new file mode 100644 index 0000000000..e1d4b5a1f5 --- /dev/null +++ b/packages/ai-native/src/browser/components/mention-input/mention-input.module.less @@ -0,0 +1,333 @@ +.input_container { + position: relative; + width: 100%; + margin: 0 auto; + border-radius: 4px; + + .model_selector { + margin-right: 5px; + min-width: 150px; + } + + .editor_area { + position: relative; + padding: 0 15px; + min-height: 42px; + max-height: 105px; + } + + .editor { + width: 100%; + background-color: transparent; + border: none; + font-size: 14px; + line-height: 1.5; + outline: none; + resize: none; + min-height: 24px; + max-height: 120px; + overflow-y: auto; + border-radius: 4px; + word-break: break-word; + + .mention_tag { + margin: 0 2px; + vertical-align: middle; + } + } + + .editor:empty:before, + .editor[data-content=''] + .editor:before { + content: attr(data-placeholder); + color: var(--descriptionForeground); + pointer-events: none; + display: block; + } + + .ai_enhance_icon { + border-radius: 6px; + padding: 2px 3px; + display: flex; + align-items: center; + cursor: pointer; + white-space: nowrap; + box-sizing: border-box; + + span { + color: var(--design-text-foreground); + } + + &:hover { + background-color: var(--badge-background); + + span { + color: var(--badge-foreground); + } + } + + > span { + &::before { + font-size: 14px; + } + } + } + + .footer { + padding: 12px 15px; + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: row; + + .left_control { + display: flex; + align-items: center; + justify-content: flex-start; + flex-direction: row; + flex: 1; + } + + .right_control { + display: flex; + align-items: center; + justify-content: flex-end; + flex-direction: row; + + .send_logo, + .stop_logo { + background-color: var(--badge-background); + color: var(--badge-foreground); + .stop_logo_icon { + line-height: 16px; + color: var(--kt-dangerButton-background); + } + } + .send_logo { + &:hover { + background-color: var(--kt-primaryButton-background); + + .send_logo_icon { + color: var(--kt-primaryButton-foreground); + } + } + } + } + } + + .send_button { + color: var(--icon-foreground); + border: none; + border-radius: 4px; + padding: 6px 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + } + + .send_button:hover { + background-color: var(--notification-success-background); + } +} + +.mention_panel_container { + position: absolute; + top: -20px; + left: 0; + right: 0; + width: 100%; + z-index: 1000; + transform: translateY(-100%); + display: flex; + justify-content: center; +} + +.mention_panel { + background-color: var(--editor-background); + color: var(--editor-foreground); + border-radius: 6px; + max-height: 300px; + overflow-y: auto; + z-index: 1000; + padding: 8px 4px; + width: 100%; +} + +.mention_item { + padding: 0 8px; + border-radius: 8px; + cursor: pointer; + line-height: 22px; + height: 22px; + display: flex; + align-items: center; + transition: all 0.2s ease; + justify-content: space-between; + color: var(--foreground); + border-radius: 4px; + margin-bottom: 5px; + + &:last-child { + margin-bottom: 0; + } +} + +.mention_list { + margin: 0; + padding: 0; + list-style: none; +} + +.mention_item.active { + color: var(--kt-tree-inactiveSelectionForeground); + background: var(--kt-tree-inactiveSelectionBackground); +} + +.mention_item:hover:not(.active) { + color: var(--kt-tree-inactiveSelectionForeground); + background: var(--kt-tree-inactiveSelectionBackground); +} + +.mention_item_left { + display: flex; + align-items: center; + flex: 1; +} + +.mention_item_icon { + margin-right: 8px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; +} + +.mention_item_text { + font-size: 13px; + margin-right: 6px; + display: inline; + white-space: pre; +} + +.mention_item_description { + color: var(--descriptionForeground); + font-size: 13px; + display: inline; + flex: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + margin-left: 5px; + text-align: right; +} + +.mention_item_right { + color: #8b949e; + font-size: 12px; +} + +.mention_panel_title { + padding: 8px 12px; + font-size: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.back_button { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; +} + +.back_button:hover { + text-decoration: underline; +} + +.mention_tag { + background-color: var(--chat-slashCommandBackground); + color: var(--chat-slashCommandForeground); + border-radius: 4px; + padding: 0 4px; + margin: 0 3px; + user-select: all; + cursor: default; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.mention_icon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 3px; + font-size: 12px; +} + +.empty_state { + padding: 5px 10px; + text-align: center; + color: var(--descriptionForeground); + font-size: 14px; +} + +.model_selector { + margin-right: 5px; +} + +.mcp_logo { + margin-right: 5px; +} + +.send_logo { + color: var(--icon-foreground); + cursor: pointer; + margin-left: auto; + + &:hover { + color: var(--notification-success-foreground); + } +} + +.popover_icon { + // 移除 margin-left: auto +} + +.loading_container { + display: none; +} + +.loading_bar { + position: absolute; + top: 0; + left: 0; + height: 2px; + width: 100%; + background: linear-gradient(90deg, transparent, var(--kt-primaryButton-background), transparent); + background-size: 200% 100%; + animation: loading-bar 1.5s infinite; + z-index: 10; +} + +@keyframes loading-bar { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: -100% 0; + } +} + +.no_results { + padding: 12px; + text-align: center; + color: #666; + font-style: italic; +} diff --git a/packages/ai-native/src/browser/components/mention-input/mention-input.tsx b/packages/ai-native/src/browser/components/mention-input/mention-input.tsx new file mode 100644 index 0000000000..d162ebeeb9 --- /dev/null +++ b/packages/ai-native/src/browser/components/mention-input/mention-input.tsx @@ -0,0 +1,952 @@ +import cls from 'classnames'; +import * as React from 'react'; + +import { Popover, PopoverPosition, Select, getIcon } from '@opensumi/ide-core-browser/lib/components'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; +import { URI } from '@opensumi/ide-utils'; + +import styles from './mention-input.module.less'; +import { MentionPanel } from './mention-panel'; +import { FooterButtonPosition, MENTION_KEYWORD, MentionInputProps, MentionItem, MentionState } from './types'; + +export const WHITE_SPACE_TEXT = ' '; + +export const MentionInput: React.FC = ({ + mentionItems = [], + onSend, + onStop, + loading = false, + mentionKeyword = MENTION_KEYWORD, + onSelectionChange, + labelService, + workspaceService, + placeholder = 'Ask anything, @ to mention', + footerConfig = { + buttons: [], + showModelSelector: false, + }, +}) => { + const editorRef = React.useRef(null); + const [mentionState, setMentionState] = React.useState({ + active: false, + startPos: null, + filter: '', + position: { top: 0, left: 0 }, + activeIndex: 0, + level: 0, // 0: 一级菜单, 1: 二级菜单 + parentType: null, // 二级菜单的父类型 + secondLevelFilter: '', // 二级菜单的筛选文本 + inlineSearchActive: false, // 是否在输入框中进行二级搜索 + inlineSearchStartPos: null, // 内联搜索的起始位置 + loading: false, // 添加加载状态 + }); + + // 添加模型选择状态 + const [selectedModel, setSelectedModel] = React.useState(footerConfig.defaultModel || ''); + + // 添加缓存状态,用于存储二级菜单项 + const [secondLevelCache, setSecondLevelCache] = React.useState>({}); + + // 添加历史记录状态 + const [history, setHistory] = React.useState([]); + const [historyIndex, setHistoryIndex] = React.useState(-1); + const [currentInput, setCurrentInput] = React.useState(''); + const [isNavigatingHistory, setIsNavigatingHistory] = React.useState(false); + + // 获取当前菜单项 + const getCurrentItems = (): MentionItem[] => { + if (mentionState.level === 0) { + return mentionItems; + } else if (mentionState.parentType) { + // 如果正在加载,返回缓存的项目 + if (mentionState.loading) { + return secondLevelCache[mentionState.parentType] || []; + } + + // 返回缓存的项目 + return secondLevelCache[mentionState.parentType] || []; + } + return []; + }; + + // 添加防抖函数 + const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; + }; + + // 使用防抖处理搜索文本 + const debouncedSecondLevelFilter = useDebounce(mentionState.secondLevelFilter, 300); + + // 监听搜索文本变化,实时更新二级菜单 + React.useEffect(() => { + if (mentionState.level === 1 && mentionState.parentType && debouncedSecondLevelFilter !== undefined) { + // 查找父级菜单项 + const parentItem = mentionItems.find((item) => item.id === mentionState.parentType); + if (!parentItem) { + return; + } + + // 设置加载状态 + setMentionState((prev) => ({ ...prev, loading: true })); + + // 异步加载 + const fetchItems = async () => { + try { + // 首先显示高优先级项目(如果有) + const items: MentionItem[] = []; + if (parentItem.getHighestLevelItems) { + const highestLevelItems = parentItem.getHighestLevelItems(); + for (const item of highestLevelItems) { + if (!items.some((i) => i.id === item.id)) { + items.push(item); + } + } + // 立即更新缓存,显示高优先级项目 + setSecondLevelCache((prev) => ({ + ...prev, + [mentionState.parentType!]: highestLevelItems, + })); + } + + // 然后异步加载更多项目 + if (parentItem.getItems) { + try { + // 获取子菜单项 + const newItems = await parentItem.getItems(debouncedSecondLevelFilter); + + // 去重合并 + const combinedItems: MentionItem[] = [...items]; + + for (const item of newItems) { + if (!combinedItems.some((i) => i.id === item.id)) { + combinedItems.push(item); + } + } + + // 更新缓存 + setSecondLevelCache((prev) => ({ + ...prev, + [mentionState.parentType!]: combinedItems, + })); + } catch (error) { + // 如果异步加载失败,至少保留高优先级项目 + setMentionState((prev) => ({ ...prev, loading: false })); + } + } + + // 最后清除加载状态 + setMentionState((prev) => ({ ...prev, loading: false })); + } catch (error) { + setMentionState((prev) => ({ ...prev, loading: false })); + } + }; + + fetchItems(); + } + }, [debouncedSecondLevelFilter, mentionState.level, mentionState.parentType]); + + // 获取光标位置 + const getCursorPosition = (element: HTMLElement): number => { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return 0; + } + + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.endContainer, range.endOffset); + return preCaretRange.toString().length; + }; + + // 处理输入事件 + const handleInput = () => { + // 如果用户开始输入,退出历史导航模式 + if (isNavigatingHistory) { + setIsNavigatingHistory(false); + setHistoryIndex(-1); + } + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount || !editorRef.current) { + return; + } + + const text = editorRef.current.textContent || ''; + const cursorPos = getCursorPosition(editorRef.current); + + // 判断是否刚输入了 @ + if (text[cursorPos - 1] === mentionKeyword && !mentionState.active && !mentionState.inlineSearchActive) { + setMentionState({ + active: true, + startPos: cursorPos, + filter: mentionKeyword, + position: { top: 0, left: 0 }, // 固定位置,不再需要动态计算 + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + }); + } + + // 如果已激活提及面板且在一级菜单,更新过滤内容 + if (mentionState.active && mentionState.level === 0 && mentionState.startPos !== null) { + if (cursorPos < mentionState.startPos) { + // 如果光标移到了 @ 之前,关闭面板 + setMentionState((prev) => ({ ...prev, active: false })); + } else { + const newFilter = text.substring(mentionState.startPos - 1, cursorPos); + setMentionState((prev) => ({ + ...prev, + filter: newFilter, + activeIndex: 0, + })); + } + } + + // 如果在输入框中进行二级搜索 + if (mentionState.inlineSearchActive && mentionState.inlineSearchStartPos !== null && mentionState.parentType) { + // 获取父级类型 + const parentItem = mentionItems.find((i) => i.id === mentionState.parentType); + if (!parentItem) { + return; + } + + // 检查光标是否在 @type: 之后 + const typePrefix = `@${parentItem.type}:`; + const prefixPos = mentionState.inlineSearchStartPos - typePrefix.length; + + if (prefixPos >= 0 && cursorPos > prefixPos + typePrefix.length) { + // 提取搜索文本 + const searchText = text.substring(prefixPos + typePrefix.length, cursorPos); + + // 只有当搜索文本变化时才更新状态 + if (searchText !== mentionState.secondLevelFilter) { + setMentionState((prev) => ({ + ...prev, + secondLevelFilter: searchText, + active: true, + activeIndex: 0, + })); + } + } else if (cursorPos <= prefixPos) { + // 如果光标移到了 @type: 之前,关闭内联搜索 + setMentionState((prev) => ({ + ...prev, + inlineSearchActive: false, + active: false, + })); + } + } + + // 检查输入框高度,如果超过最大高度则添加滚动条 + if (editorRef.current) { + const editorHeight = editorRef.current.scrollHeight; + if (editorHeight > 120) { + editorRef.current.style.overflowY = 'auto'; + } else { + editorRef.current.style.overflowY = 'hidden'; + } + } + + // 检查编辑器内容,处理只有
标签的情况 + if (editorRef.current) { + const content = editorRef.current.innerHTML; + // 如果内容为空或只有
标签 + if (content === '' || content === '
' || content === '
') { + // 清空编辑器内容 + editorRef.current.innerHTML = ''; + } + } + }; + + // 处理键盘事件 + const handleKeyDown = (e: React.KeyboardEvent) => { + // 如果按下ESC键且提及面板处于活动状态或内联搜索处于活动状态 + if (e.key === 'Escape' && (mentionState.active || mentionState.inlineSearchActive)) { + // 如果在二级菜单,返回一级菜单 + if (mentionState.level > 0) { + setMentionState((prev) => ({ + ...prev, + level: 0, + activeIndex: 0, + secondLevelFilter: '', + inlineSearchActive: false, + })); + } else { + // 如果在一级菜单,完全关闭面板 + setMentionState((prev) => ({ + ...prev, + active: false, + inlineSearchActive: false, + })); + } + e.preventDefault(); + return; + } + + // 添加对 @ 键的监听,支持在任意位置触发菜单 + if (e.key === MENTION_KEYWORD && !mentionState.active && !mentionState.inlineSearchActive && editorRef.current) { + const cursorPos = getCursorPosition(editorRef.current); + + // 立即设置菜单状态,不等待 handleInput + setMentionState({ + active: true, + startPos: cursorPos + 1, // +1 因为 @ 还没有被插入 + filter: mentionKeyword, + position: { top: 0, left: 0 }, // 固定位置 + activeIndex: 0, + level: 0, + parentType: null, + secondLevelFilter: '', + inlineSearchActive: false, + inlineSearchStartPos: null, + loading: false, + }); + + // 不要阻止默认行为,让 @ 正常输入到编辑器中 + } + + // 添加对 Enter 键的处理,只有在按下 Shift+Enter 时才允许换行 + if (e.key === 'Enter') { + // 检查是否是输入法的回车键 + // isComposing 属性表示是否正在进行输入法组合输入 + if (e.nativeEvent.isComposing) { + return; // 如果是输入法组合输入过程中的回车,不做任何处理 + } + + if (!e.shiftKey) { + e.preventDefault(); + if (!mentionState.active) { + handleSend(); + return; + } + } + } + + // 处理上下方向键导航历史记录 + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + // 只有在非提及面板激活状态下才处理历史导航 + if (!mentionState.active && !mentionState.inlineSearchActive && editorRef.current) { + e.preventDefault(); + + // 如果是第一次按上下键,保存当前输入 + if (!isNavigatingHistory) { + setCurrentInput(editorRef.current.innerHTML); + setIsNavigatingHistory(true); + } + + // 计算新的历史索引 + let newIndex = historyIndex; + if (e.key === 'ArrowUp') { + // 向上导航到较早的历史记录 + newIndex = Math.min(history.length - 1, historyIndex + 1); + } else { + // 向下导航到较新的历史记录 + newIndex = Math.max(-1, historyIndex - 1); + } + + setHistoryIndex(newIndex); + + // 更新编辑器内容 + if (newIndex === -1) { + // 恢复到当前输入 + editorRef.current.innerHTML = currentInput; + } else { + // 显示历史记录 + editorRef.current.innerHTML = history[history.length - 1 - newIndex]; + } + + // 将光标移到末尾 + const range = document.createRange(); + range.selectNodeContents(editorRef.current); + range.collapse(false); + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + + return; + } + } else if (isNavigatingHistory && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') { + // 如果用户在浏览历史记录后开始输入,退出历史导航模式 + setIsNavigatingHistory(false); + setHistoryIndex(-1); + } + + // 如果提及面板未激活,不处理其他键盘事件 + if (!mentionState.active) { + return; + } + + // 获取当前过滤后的项目 + let filteredItems = getCurrentItems(); + + // 一级菜单过滤 + if (mentionState.level === 0 && mentionState.filter && mentionState.filter.length > 1) { + const searchText = mentionState.filter.substring(1).toLowerCase(); + filteredItems = filteredItems.filter((item) => item.text.toLowerCase().includes(searchText)); + } + + // 二级菜单过滤已经在 getCurrentItems 中处理 + + if (filteredItems.length === 0) { + return; + } + + if (e.key === 'ArrowDown') { + // 向下导航 + setMentionState((prev) => ({ + ...prev, + activeIndex: (prev.activeIndex + 1) % filteredItems.length, + })); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + // 向上导航 + setMentionState((prev) => ({ + ...prev, + activeIndex: (prev.activeIndex - 1 + filteredItems.length) % filteredItems.length, + })); + e.preventDefault(); + } else if (e.key === 'Enter' || e.key === 'Tab') { + // 确认选择 + if (filteredItems.length > 0) { + handleSelectItem(filteredItems[mentionState.activeIndex]); + e.preventDefault(); + } + } + + // 处理 Backspace 键,检查是否需要清空编辑器 + if (e.key === 'Backspace' && editorRef.current) { + const content = editorRef.current.innerHTML; + if (content === '
' || content === '
') { + editorRef.current.innerHTML = ''; + } + } + }; + + // 添加对输入法事件的处理 + const handleCompositionEnd = () => { + // 输入法输入完成后的处理 + // 这里可以添加额外的逻辑,如果需要的话 + }; + + // 初始化编辑器 + React.useEffect(() => { + if (editorRef.current) { + // 设置初始占位符 + if (placeholder && !editorRef.current.textContent) { + editorRef.current.setAttribute('data-placeholder', placeholder); + } + } + }, [placeholder]); + + // 处理点击事件 + const handleDocumentClick = (e: MouseEvent) => { + if (mentionState.active && !document.querySelector(`.${styles.mention_panel}`)?.contains(e.target as Node)) { + setMentionState((prev) => ({ + ...prev, + active: false, + inlineSearchActive: false, + })); + } + }; + + // 添加和移除全局点击事件监听器 + React.useEffect(() => { + document.addEventListener('click', handleDocumentClick, true); + return () => { + document.removeEventListener('click', handleDocumentClick, true); + }; + }, [mentionState.active]); + + // 选择提及项目 + const handleSelectItem = (item: MentionItem, isTriggerByClick = false) => { + if (!editorRef.current) { + return; + } + + // 如果项目有子菜单,进入二级菜单 + if (item.getItems) { + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + // 如果是从一级菜单选择了带子菜单的项目 + if (mentionState.level === 0 && mentionState.startPos !== null) { + // 更安全地处理文本替换 + let textNode; + let startOffset; + let endOffset; + + // 找到包含 @ 符号的文本节点 + const walker = document.createTreeWalker(editorRef.current, NodeFilter.SHOW_TEXT); + let charCount = 0; + let node; + + while ((node = walker.nextNode())) { + const nodeLength = node.textContent?.length || 0; + + // 检查 @ 符号是否在这个节点中 + if (mentionState.startPos - 1 >= charCount && mentionState.startPos - 1 < charCount + nodeLength) { + textNode = node; + startOffset = mentionState.startPos - 1 - charCount; + + // 确保不会超出节点范围 + const cursorPos = isTriggerByClick + ? mentionState.startPos + mentionState.filter.length - 1 + : getCursorPosition(editorRef.current); + endOffset = Math.min(cursorPos - charCount, nodeLength); + break; + } + + charCount += nodeLength; + } + + if (textNode) { + // 创建一个新的范围来替换文本 + const tempRange = document.createRange(); + tempRange.setStart(textNode, startOffset); + tempRange.setEnd(textNode, endOffset); + + // 替换为 @type: + tempRange.deleteContents(); + const typePrefix = document.createTextNode(`${mentionKeyword}${item.type}:`); + tempRange.insertNode(typePrefix); + + // 将光标移到 @type: 后面 + const newRange = document.createRange(); + newRange.setStartAfter(typePrefix); + newRange.setEndAfter(typePrefix); + selection.removeAllRanges(); + selection.addRange(newRange); + // 激活内联搜索模式 + setMentionState((prev) => ({ + ...prev, + active: true, + level: 1, + parentType: item.id, + inlineSearchActive: true, + inlineSearchStartPos: getCursorPosition(editorRef.current as HTMLElement), + secondLevelFilter: '', + activeIndex: 0, + })); + editorRef.current.focus(); + return; + } + } + + return; + } + + const selection = window.getSelection(); + if (!selection || !selection.rangeCount) { + return; + } + + // 如果是在内联搜索模式下选择项目 + if (mentionState.inlineSearchActive && mentionState.parentType && mentionState.inlineSearchStartPos !== null) { + // 找到 @type: 的位置 + const parentItem = mentionItems.find((i) => i.id === mentionState.parentType); + if (!parentItem) { + return; + } + + const typePrefix = `${mentionKeyword}${parentItem.type}:`; + const prefixPos = mentionState.inlineSearchStartPos - typePrefix.length; + + if (prefixPos >= 0) { + // 创建一个带样式的提及标签 + const mentionTag = document.createElement('span'); + mentionTag.className = styles.mention_tag; + mentionTag.dataset.id = item.id; + mentionTag.dataset.type = item.type; + mentionTag.dataset.contextId = item.contextId || ''; + mentionTag.contentEditable = 'false'; + + // 为 file 和 folder 类型添加图标 + if (item.type === 'file' || item.type === 'folder') { + // 创建图标容器 + const iconSpan = document.createElement('span'); + iconSpan.className = cls( + styles.mention_icon, + item.type === 'file' ? labelService?.getIcon(new URI(item.text)) : getIcon('folder'), + ); + mentionTag.appendChild(iconSpan); + } + const workspace = workspaceService?.workspace; + let relativePath = item.text; + if (workspace && item.contextId) { + relativePath = item.contextId.replace(new URI(workspace.uri).codeUri.fsPath, '').slice(1); + } + // 创建文本内容容器 + const textSpan = document.createTextNode(relativePath); + mentionTag.appendChild(textSpan); + + // 创建一个范围从 @type: 开始到当前光标 + const tempRange = document.createRange(); + + // 定位到 @type: 的位置 + let charIndex = 0; + let foundStart = false; + const textNodes: Array<{ node: Node; start: number; end: number }> = []; + + function findPosition(node: Node) { + if (node.nodeType === 3) { + // 文本节点 + textNodes.push({ + node, + start: charIndex, + end: charIndex + node.textContent!.length, + }); + charIndex += node.textContent!.length; + } else if (node.nodeType === 1) { + // 元素节点 + const children = node.childNodes || []; + for (const child of Array.from(children)) { + findPosition(child); + } + } + } + + findPosition(editorRef.current); + + for (const textNode of textNodes) { + if (prefixPos >= textNode.start && prefixPos <= textNode.end) { + const startOffset = prefixPos - textNode.start; + tempRange.setStart(textNode.node, startOffset); + foundStart = true; + } + + if (foundStart) { + // 如果是点击触发,使用过滤文本的长度来确定结束位置 + const cursorPos = isTriggerByClick + ? prefixPos + typePrefix.length + mentionState.secondLevelFilter.length + : getCursorPosition(editorRef.current); + + if (cursorPos >= textNode.start && cursorPos <= textNode.end) { + const endOffset = cursorPos - textNode.start; + tempRange.setEnd(textNode.node, endOffset); + break; + } + } + } + + if (foundStart) { + tempRange.deleteContents(); + tempRange.insertNode(mentionTag); + + // 将光标移到提及标签后面 + const newRange = document.createRange(); + newRange.setStartAfter(mentionTag); + newRange.setEndAfter(mentionTag); + selection.removeAllRanges(); + selection.addRange(newRange); + + // 添加一个空格,增加间隔 + const spaceNode = document.createTextNode('\u00A0'); // 使用不间断空格 + newRange.insertNode(spaceNode); + newRange.setStartAfter(spaceNode); + newRange.setEndAfter(spaceNode); + selection.removeAllRanges(); + selection.addRange(newRange); + } + + setMentionState((prev) => ({ + ...prev, + active: false, + inlineSearchActive: false, + })); + editorRef.current.focus(); + return; + } + } + + // 原有的处理逻辑(用于非内联搜索情况) + // 创建一个带样式的提及标签 + const mentionTag = document.createElement('span'); + mentionTag.className = styles.mention_tag; + mentionTag.dataset.id = item.id; + mentionTag.dataset.type = item.type; + mentionTag.dataset.contextId = item.contextId || ''; + mentionTag.contentEditable = 'false'; + + // 为 file 和 folder 类型添加图标 + if (item.type === 'file' || item.type === 'folder') { + // 创建图标容器 + const iconSpan = document.createElement('span'); + iconSpan.className = cls( + styles.mention_icon, + item.type === 'file' ? labelService?.getIcon(new URI(item.text)) : getIcon('folder'), + ); + mentionTag.appendChild(iconSpan); + } + const workspace = workspaceService?.workspace; + let relativePath = item.text; + if (workspace && item.contextId) { + relativePath = item.contextId.replace(new URI(workspace.uri).codeUri.fsPath, '').slice(1); + } + // 创建文本内容容器 + const textSpan = document.createTextNode(relativePath); + mentionTag.appendChild(textSpan); + + // 定位到 @ 符号的位置 + let charIndex = 0; + let foundStart = false; + const textNodes: Array<{ node: Node; start: number; end: number }> = []; + + function findPosition(node: Node) { + if (node.nodeType === 3) { + // 文本节点 + textNodes.push({ + node, + start: charIndex, + end: charIndex + node.textContent!.length, + }); + charIndex += node.textContent!.length; + } else if (node.nodeType === 1) { + // 元素节点 + const children = node.childNodes; + for (const child of Array.from(children)) { + findPosition(child); + } + } + } + + findPosition(editorRef.current); + + const tempRange = document.createRange(); + + if (mentionState.startPos !== null) { + for (const textNode of textNodes) { + if (mentionState.startPos - 1 >= textNode.start && mentionState.startPos - 1 <= textNode.end) { + const startOffset = mentionState.startPos - 1 - textNode.start; + tempRange.setStart(textNode.node, startOffset); + foundStart = true; + } + + if (foundStart) { + // 如果是点击触发,使用过滤文本的长度来确定结束位置 + const cursorPos = isTriggerByClick + ? mentionState.startPos + mentionState.filter.length - 1 + : getCursorPosition(editorRef.current); + + if (cursorPos >= textNode.start && cursorPos <= textNode.end) { + const endOffset = cursorPos - textNode.start; + tempRange.setEnd(textNode.node, endOffset); + break; + } + } + } + } + + if (foundStart) { + tempRange.deleteContents(); + tempRange.insertNode(mentionTag); + + // 将光标移到提及标签后面 + const newRange = document.createRange(); + newRange.setStartAfter(mentionTag); + newRange.setEndAfter(mentionTag); + selection.removeAllRanges(); + selection.addRange(newRange); + + // 添加一个空格,增加间隔 + const spaceNode = document.createTextNode('\u00A0'); // 使用不间断空格 + newRange.insertNode(spaceNode); + newRange.setStartAfter(spaceNode); + newRange.setEndAfter(spaceNode); + selection.removeAllRanges(); + selection.addRange(newRange); + } + setMentionState((prev) => ({ ...prev, active: false })); + editorRef.current.focus(); + }; + + // 处理模型选择变更 + const handleModelChange = React.useCallback( + (value: string) => { + setSelectedModel(value); + onSelectionChange?.(value); + }, + [selectedModel, onSelectionChange], + ); + + // 修改 handleSend 函数 + const handleSend = () => { + if (!editorRef.current) { + return; + } + + // 获取原始HTML内容 + const rawContent = editorRef.current.innerHTML; + if (!rawContent) { + return; + } + + // 创建一个临时元素来处理内容 + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = rawContent; + + // 查找所有提及标签并替换为对应的contextId + const mentionTags = tempDiv.querySelectorAll(`.${styles.mention_tag}`); + mentionTags.forEach((tag) => { + const contextId = tag.getAttribute('data-context-id'); + if (contextId) { + // 替换为contextId + const replacement = document.createTextNode( + `{{${mentionKeyword}${tag.getAttribute('data-type')}:${contextId}}}`, + ); + // 替换内容 + tag.parentNode?.replaceChild(replacement, tag); + } + }); + + // 获取处理后的内容 + let processedContent = tempDiv.innerHTML; + processedContent = processedContent.trim().replaceAll(WHITE_SPACE_TEXT, ' '); + // 添加到历史记录 + if (rawContent) { + setHistory((prev) => [...prev, rawContent]); + // 重置历史导航状态 + setHistoryIndex(-1); + setIsNavigatingHistory(false); + } + + if (onSend) { + // 传递当前选择的模型和其他配置信息 + onSend(processedContent, { + model: selectedModel, + ...footerConfig, + }); + } + + editorRef.current.innerHTML = ''; + + // 重置编辑器高度和滚动条 + if (editorRef.current) { + editorRef.current.style.overflowY = 'hidden'; + editorRef.current.style.height = 'auto'; + } + }; + + const handleStop = React.useCallback(() => { + if (onStop) { + onStop(); + } + }, [onStop]); + + // 渲染自定义按钮 + const renderButtons = React.useCallback( + (position: FooterButtonPosition) => + (footerConfig.buttons || []) + .filter((button) => button.position === position) + .map((button) => ( + + + + )), + [footerConfig.buttons], + ); + + return ( +
+ {mentionState.active && ( +
+ handleSelectItem(item, true)} + position={{ top: 0, left: 0 }} + filter={mentionState.level === 0 ? mentionState.filter : mentionState.secondLevelFilter} + visible={true} + level={mentionState.level} + loading={mentionState.loading} + /> +
+ )} +
+
+
+
+
+ {footerConfig.showModelSelector && ( +