import * as React from 'react'; import { MessageList } from 'react-chat-elements'; 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 { AIServiceType, ActionSourceEnum, ActionTypeEnum, CancellationToken, CancellationTokenSource, ChatFeatureRegistryToken, ChatMessageRole, ChatRenderRegistryToken, ChatServiceToken, CommandService, Disposable, DisposableCollection, IAIReporter, IChatComponent, IChatContent, URI, formatLocalize, localize, path, uuid, } from '@opensumi/ide-core-common'; 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, LLM_CONTEXT_KEY, LLM_CONTEXT_KEY_REGEX, } from '../../common/llm-context'; import { CodeBlockData } from '../../common/types'; import { cleanAttachedTextWrapper } from '../../common/utils'; import { FileChange, FileListDisplay } from '../components/ChangeList'; 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'; import { WelcomeMessage } from '../components/WelcomeMsg'; import { BaseApplyService } from '../mcp/base-apply.service'; import { ChatViewHeaderRender, IMCPServerRegistry, TSlashCommandCustomRender, TokenMCPServerRegistry } from '../types'; import { ChatRequestModel, ChatSlashCommandItemModel } from './chat-model'; import { ChatProxyService } from './chat-proxy.service'; import { ChatService } from './chat.api.service'; import { ChatFeatureRegistry } from './chat.feature.registry'; import { ChatInternalService } from './chat.internal.service'; import styles from './chat.module.less'; import { ChatRenderRegistry } from './chat.render.registry'; const SCROLL_CLASSNAME = 'chat_scroll'; interface TDispatchAction { type: 'add' | 'clear' | 'init'; payload?: MessageData[]; } const MAX_TITLE_LENGTH = 100; const getFileChanges = (codeBlocks: CodeBlockData[]) => codeBlocks .map((block) => { const rangesFromDiffHunk = block.applyResult?.diff.split('\n').reduce( ([del, add], line) => { if (line.startsWith('-')) { del += 1; } else if (line.startsWith('+')) { add += 1; } return [del, add]; }, [0, 0], ) || [0, 0]; return { path: block.relativePath, additions: rangesFromDiffHunk[1], deletions: rangesFromDiffHunk[0], status: block.status, }; }) .reduce((acc, curr) => { const existingFile = acc.find((file) => file.path === curr.path); if (existingFile) { existingFile.additions += curr.additions; existingFile.deletions += curr.deletions; // 使用最新的状态 existingFile.status = curr.status; } else { acc.push(curr); } return acc; }, [] as FileChange[]); export const AIChatView = () => { const aiChatService = useInjectable(IChatInternalService); const chatApiService = useInjectable(ChatServiceToken); const aiReporter = useInjectable(IAIReporter); const chatAgentService = useInjectable(IChatAgentService); 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; const containerRef = React.useRef(null); const autoScroll = React.useRef(true); const chatInputRef = React.useRef<{ setInputValue: (v: string) => void } | null>(null); 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 [sessionModelId, setSessionModelId] = React.useState(aiChatService.sessionModel.modelId); const [changeList, setChangeList] = React.useState(getFileChanges(applyService.getSessionCodeBlocks())); const [messageListData, dispatchMessage] = React.useReducer((state: MessageData[], action: TDispatchAction) => { switch (action.type) { case 'add': return [...state, ...(action.payload || [])]; case 'clear': return []; case 'init': return Array.isArray(action.payload) ? action.payload : []; default: return state; } }, []); const [loading, setLoading] = React.useState(false); const [agentId, setAgentId] = React.useState(''); const [defaultAgentId, setDefaultAgentId] = React.useState(''); const [command, setCommand] = React.useState(''); const [theme, setTheme] = React.useState(null); // 切换session或Agent输出状态变化时 React.useEffect(() => { setSessionModelId(aiChatService.sessionModel.modelId); }, [loading, aiChatService.sessionModel]); React.useEffect(() => { const disposer = new Disposable(); const doUpdate = () => { const fileChanges = getFileChanges(applyService.getSessionCodeBlocks()); setChangeList(fileChanges); }; disposer.addDispose(aiChatService.onChangeSession(doUpdate)); // TODO: 全量获取性能不好 disposer.addDispose(applyService.onCodeBlockUpdate(doUpdate)); return () => disposer.dispose(); }, []); React.useEffect(() => { const featureSlashCommands = chatFeatureRegistry.getAllShortcutSlashCommand(); const dispose = chatAgentService.onDidChangeAgents(() => { const agentSlashCommands = chatAgentService .getCommands() .filter((c) => c.isShortcut) .map( (c) => new ChatSlashCommandItemModel( { icon: '', name: `${c.name} `, description: c.description, isShortcut: c.isShortcut, }, c.name, c.agentId, ), ); setShortcutCommands(featureSlashCommands.concat(agentSlashCommands)); }); setShortcutCommands(featureSlashCommands); return () => dispose.dispose(); }, [chatFeatureRegistry, chatAgentService]); useUpdateOnEvent(aiChatService.onChangeSession); const ChatInputWrapperRender = React.useMemo(() => { if (chatRenderRegistry.chatInputRender) { return chatRenderRegistry.chatInputRender; } if (aiNativeConfigService.capabilities.supportsMCP) { return ChatMentionInput; } return ChatInput; }, [chatRenderRegistry.chatInputRender]); const firstMsg = React.useMemo( () => createMessageByAI({ id: uuid(6), relationId: '', text: , }), [], ); const onDidWheel = React.useCallback( (e: WheelEvent) => { // 向上滚动 if (e.deltaY < 0) { autoScroll.current = false; } else { autoScroll.current = true; } }, [autoScroll], ); React.useEffect(() => { if (containerRef.current) { containerRef.current.addEventListener('wheel', onDidWheel); return () => { containerRef.current?.removeEventListener('wheel', onDidWheel); }; } }, [autoScroll]); const scrollToBottom = React.useCallback(() => { if (containerRef && containerRef.current && autoScroll.current) { const lastElement = containerRef.current.lastElementChild; if (lastElement) { lastElement.scrollIntoView({ behavior: 'smooth', block: 'end' }); } // 出现滚动条时出现分割线 if (containerRef.current.scrollHeight > containerRef.current.clientHeight) { containerRef.current.classList.add(SCROLL_CLASSNAME); } } }, [containerRef, autoScroll]); const handleDispatchMessage = React.useCallback( (dispatch: TDispatchAction) => { dispatchMessage(dispatch); requestAnimationFrame(() => { scrollToBottom(); }); }, [dispatchMessage, scrollToBottom], ); React.useEffect(() => { handleDispatchMessage({ type: 'init', payload: [firstMsg] }); }, []); React.useEffect(() => { const disposer = new Disposable(); disposer.addDispose( chatApiService.onScrollToBottom(() => { requestAnimationFrame(() => { // scrollToBottom(); }); }), ); disposer.addDispose( chatApiService.onChatMessageLaunch(async (message) => { if (message.immediate !== false) { if (loading) { return; } await handleSend(message.message, message.images, message.agentId, message.command); } else { if (message.agentId) { setAgentId(message.agentId); } if (message.command) { setCommand(message.command); } chatInputRef?.current?.setInputValue(message.message); } }), ); disposer.addDispose( chatApiService.onChatReplyMessageLaunch((data) => { if (data.kind === 'content') { const relationId = aiReporter.start(AIServiceType.CustomReply, { message: data.content, sessionId: aiChatService.sessionModel.sessionId, }); msgHistoryManager.addAssistantMessage({ content: data.content, relationId, }); renderSimpleMarkdownReply({ chunk: data.content, relationId }); } else { const relationId = aiReporter.start(AIServiceType.CustomReply, { message: 'component#' + data.component, sessionId: aiChatService.sessionModel.sessionId, }); msgHistoryManager.addAssistantMessage({ componentId: data.component, componentValue: data.value, content: '', relationId, }); renderCustomComponent({ chunk: data, relationId }); } }), ); disposer.addDispose( chatApiService.onChatMessageListLaunch((list) => { const messageList: MessageData[] = []; list.forEach((item) => { const { role } = item; const relationId = aiReporter.start(AIServiceType.Chat, { message: '', sessionId: aiChatService.sessionModel.sessionId, }); if (role === 'assistant') { const newChunk = item as IChatComponent | IChatContent; messageList.push( createMessageByAI( { id: uuid(6), relationId, text: , }, styles.chat_notify, ), ); } if (role === 'user') { const { message } = item; const agentId = ChatProxyService.AGENT_ID; const ChatUserRoleRender = chatRenderRegistry.chatUserRoleRender; const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; messageList.push( createMessageByUser( { id: uuid(6), relationId, text: ChatUserRoleRender ? ( ) : ( ), }, styles.chat_message_code, ), ); } }); handleDispatchMessage({ type: 'add', payload: messageList }); setTimeout(scrollToBottom, 0); }), ); return () => disposer.dispose(); }, [chatApiService, chatRenderRegistry.chatAIRoleRender, msgHistoryManager]); React.useEffect(() => { const disposer = new Disposable(); disposer.addDispose( chatAgentService.onDidSendMessage((chunk) => { const newChunk = chunk as IChatComponent | IChatContent; const relationId = aiReporter.start(AIServiceType.Agent, { message: '', }); const notifyMessage = createMessageByAI( { id: uuid(6), relationId, text: , }, styles.chat_notify, ); handleDispatchMessage({ type: 'add', payload: [notifyMessage] }); }), ); disposer.addDispose( chatAgentService.onDidChangeAgents(async () => { const newDefaultAgentId = chatAgentService.getDefaultAgentId(); setDefaultAgentId(newDefaultAgentId ?? ''); }), ); return () => disposer.dispose(); }, [chatAgentService, msgHistoryManager, aiChatService]); const handleSlashCustomRender = React.useCallback( async (value: { userMessage: string; render: TSlashCommandCustomRender; relationId: string; requestId: string; startTime: number; command?: string; agentId?: string; }) => { const { userMessage, relationId, requestId, render, startTime, command, agentId } = value; msgHistoryManager.addAssistantMessage({ type: 'component', content: '', }); const aiMessage = createMessageByAI({ id: uuid(6), relationId, className: styles.chat_with_more_actions, text: ( ), }); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, [containerRef, msgHistoryManager], ); const renderUserMessage = React.useCallback( async (renderModel: { message: string; images?: string[]; agentId?: string; relationId: string; command?: string; }) => { const ChatUserRoleRender = chatRenderRegistry.chatUserRoleRender; const { message, images, agentId, relationId, command } = renderModel; const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; const userMessage = createMessageByUser( { id: uuid(6), relationId, text: ChatUserRoleRender ? ( ) : ( ), }, styles.chat_message_code, ); handleDispatchMessage({ type: 'add', payload: [userMessage] }); }, [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom], ); const renderReply = React.useCallback( async (renderModel: { message: string; agentId?: string; request: ChatRequestModel; relationId: string; command?: string; startTime: number; msgId: string; }) => { const { message, agentId, request, relationId, command, startTime, msgId } = renderModel; const visibleAgentId = agentId === ChatProxyService.AGENT_ID ? '' : agentId; if (agentId === ChatProxyService.AGENT_ID && command) { const commandHandler = chatFeatureRegistry.getSlashCommandHandler(command); if (commandHandler && commandHandler.providerRender) { setLoading(false); return handleSlashCustomRender({ userMessage: message, render: commandHandler.providerRender, relationId, requestId: request.requestId, startTime, agentId, command, }); } } const aiMessage = createMessageByAI({ id: uuid(6), relationId, className: styles.chat_with_more_actions, text: ( { scrollToBottom(); }} history={msgHistoryManager} onDone={() => { setLoading(false); }} onRegenerate={() => { if (request) { aiChatService.sendRequest(request, true); } }} msgId={msgId} /> ), }); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, [chatRenderRegistry, msgHistoryManager, scrollToBottom], ); const renderSimpleMarkdownReply = React.useCallback( (renderModel: { chunk: string; relationId: string }) => { const { chunk, relationId } = renderModel; let renderContent = ; if (chatRenderRegistry.chatAIRoleRender) { const ChatAIRoleRender = chatRenderRegistry.chatAIRoleRender; renderContent = ; } const aiMessage = createMessageByAI({ id: uuid(6), relationId, text: renderContent, className: styles.chat_with_more_actions, }); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, [chatRenderRegistry, msgHistoryManager, scrollToBottom], ); const renderCustomComponent = React.useCallback( (renderModel: { chunk: IChatComponent; relationId: string }) => { const { chunk, relationId } = renderModel; const aiMessage = createMessageByAI( { id: uuid(6), relationId, text: , }, styles.chat_notify, ); handleDispatchMessage({ type: 'add', payload: [aiMessage] }); }, [chatRenderRegistry, msgHistoryManager, scrollToBottom], ); const handleAgentReply = React.useCallback( async (value: IChatMessageStructure) => { const { message, images, agentId, command, reportExtra } = value; const { actionType, actionSource } = reportExtra || {}; const request = aiChatService.createRequest(message, agentId!, images, command); if (!request) { return; } setLoading(true); aiChatService.setLatestRequestId(request.requestId); const startTime = Date.now(); const reportType = ChatProxyService.AGENT_ID === agentId ? AIServiceType.Chat : AIServiceType.Agent; const relationId = aiReporter.start( command || reportType, { agentId, userMessage: message, actionType, actionSource, sessionId: aiChatService.sessionModel.sessionId, }, // 由于涉及 tool 调用,超时时间设置长一点 600 * 1000, ); msgHistoryManager.addUserMessage({ content: message.replaceAll(LLM_CONTEXT_KEY_REGEX, ''), images: images || [], agentId: agentId!, agentCommand: command!, relationId, }); await renderUserMessage({ relationId, message, images, command, agentId, }); aiChatService.sendRequest(request); const msgId = msgHistoryManager.addAssistantMessage({ content: '', relationId, requestId: request.requestId, replyStartTime: startTime, }); // 创建消息时,设置当前活跃的消息信息,便于toolCall打点 mcpServerRegistry.activeMessageInfo = { messageId: msgId, sessionId: aiChatService.sessionModel.sessionId, }; await renderReply({ startTime, relationId, message, agentId, command, request, msgId, }); }, [chatRenderRegistry, chatRenderRegistry.chatUserRoleRender, msgHistoryManager, scrollToBottom, loading], ); const handleSend = React.useCallback( async (message: string, images?: 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); const relativePath = (await workspaceService.asRelativePath(fileUri))?.path || fileUri.displayName; processedContent = processedContent.replace(match, `\`${LLM_CONTEXT_KEY.AttachedFile}${relativePath}\``); } } 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); const relativePath = (await workspaceService.asRelativePath(folderUri))?.path || folderUri.displayName; processedContent = processedContent.replace(match, `\`${LLM_CONTEXT_KEY.AttachedFolder}${relativePath}\``); } } const codePattern = /\{\{@code:(.*?)\}\}/g; const codeMatches = processedContent.match(codePattern); if (codeMatches) { for (const match of codeMatches) { const filePathWithLineRange = match.replace(/\{\{@code:(.*?)\}\}/, '$1'); const [filePath, lineRange] = filePathWithLineRange.split(':'); let range: [number, number] = [0, 0]; if (lineRange) { const [startLine, endLine] = lineRange.slice(1).split('-'); range = [parseInt(startLine, 10), parseInt(endLine, 10)]; } const fileUri = new URI(filePath); const relativePath = (await workspaceService.asRelativePath(fileUri))?.path || fileUri.displayName; processedContent = processedContent.replace( match, `\`${LLM_CONTEXT_KEY.AttachedFile}${relativePath}:L${range[0]}-${range[1]}\``, ); } } return handleAgentReply({ message: processedContent, images, agentId, command, reportExtra }); }, [handleAgentReply], ); const handleClear = React.useCallback(() => { aiChatService.clearSessionModel(); chatApiService.clearHistoryMessages(); clearChatContent(); }, [messageListData]); const clearChatContent = React.useCallback(() => { containerRef?.current?.classList.remove(SCROLL_CLASSNAME); handleDispatchMessage({ type: 'init', payload: [firstMsg] }); }, [messageListData]); const handleShortcutCommandClick = (commandModel: ChatSlashCommandItemModel) => { if (loading) { return; } setTheme(commandModel.nameWithSlash); setAgentId(commandModel.agentId!); setCommand(commandModel.command!); }; const handleCloseChatView = React.useCallback(() => { layoutService.toggleSlot(AI_CHAT_VIEW_ID); }, [layoutService]); const HeaderRender: ChatViewHeaderRender = chatRenderRegistry.chatViewHeaderRender || DefaultChatViewHeader; const recover = React.useCallback( async (cancellationToken: CancellationToken) => { for (const msg of msgHistoryManager.getMessages()) { if (cancellationToken.isCancellationRequested) { return; } if (msg.role === ChatMessageRole.User) { await renderUserMessage({ relationId: msg.relationId!, message: msg.content, agentId: msg.agentId, command: msg.agentCommand, images: msg.images, }); } else if (msg.role === ChatMessageRole.Assistant && msg.requestId) { const request = aiChatService.sessionModel.getRequest(msg.requestId)!; // 从storage恢复时,request为undefined if (request && !request.response.isComplete) { setLoading(true); } await renderReply({ msgId: msg.id, relationId: msg.relationId!, message: msg.content, agentId: msg.agentId, command: msg.agentCommand, startTime: msg.replyStartTime!, request, }); } else if (msg.role === ChatMessageRole.Assistant && msg.content) { await renderSimpleMarkdownReply({ relationId: msg.relationId!, chunk: msg.content, }); } else if (msg.role === ChatMessageRole.Assistant && msg.componentId) { await renderCustomComponent({ relationId: msg.relationId!, chunk: { kind: 'component', component: msg.componentId, value: msg.componentValue, }, }); } } }, [renderReply], ); React.useEffect(() => { // 尝试重新渲染历史记录 clearChatContent(); const cancellationTokenSource = new CancellationTokenSource(); setLoading(false); recover(cancellationTokenSource.token); return () => { cancellationTokenSource.cancel(); }; }, [aiChatService.sessionModel]); return (
{aiChatService.sessionModel.slicedMessageCount ? (
{formatLocalize( 'aiNative.chat.ai.assistant.limit.message', aiChatService.sessionModel.slicedMessageCount, )}
) : null}
{shortcutCommands.map((command) => (
handleShortcutCommandClick(command)}> {command.name}
))}
{changeList.length > 0 && ( { editorService.open(URI.file(path.join(appConfig.workspaceDir, filePath))); }} onRejectAll={() => { applyService.processAll('reject'); }} onAcceptAll={() => { applyService.processAll('accept'); }} /> )}
); }; export function DefaultChatViewHeader({ handleClear, handleCloseChatView, }: { handleClear: () => any; handleCloseChatView: () => any; }) { const aiChatService = useInjectable(IChatInternalService); const messageService = useInjectable(IMessageService); const [historyList, setHistoryList] = React.useState([]); const [currentTitle, setCurrentTitle] = React.useState(''); const handleNewChat = React.useCallback(() => { if (aiChatService.sessionModel.history.getMessages().length > 0) { try { aiChatService.createSessionModel(); } catch (error) { messageService.error(error.message); } } }, [aiChatService]); const handleHistoryItemSelect = React.useCallback( (item: IChatHistoryItem) => { aiChatService.activateSession(item.id); }, [aiChatService], ); const handleHistoryItemDelete = React.useCallback( (item: IChatHistoryItem) => { aiChatService.clearSessionModel(item.id); }, [aiChatService], ); React.useEffect(() => { const getHistoryList = () => { const currentMessages = aiChatService.sessionModel.history.getMessages(); const latestUserMessage = currentMessages.findLast((m) => m.role === ChatMessageRole.User); 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 ? 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 { id: session.sessionId, title, updatedAt, // TODO: 后续支持 loading: false, }; }), ); }; getHistoryList(); const toDispose = new DisposableCollection(); const sessionListenIds = new Set(); toDispose.push( aiChatService.onChangeSession((sessionId) => { getHistoryList(); if (sessionListenIds.has(sessionId)) { return; } sessionListenIds.add(sessionId); toDispose.push( aiChatService.sessionModel.history.onMessageChange(() => { getHistoryList(); }), ); }), ); toDispose.push( aiChatService.sessionModel.history.onMessageChange(() => { getHistoryList(); }), ); return () => { toDispose.dispose(); }; }, [aiChatService]); return (
{}} />
); }