import capitalize from 'lodash/capitalize'; import throttle from 'lodash/throttle'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Highlight from 'react-highlight'; import { Image } from '@opensumi/ide-components/lib/image'; 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'; import { insertSnippetWithMonacoEditor } from '@opensumi/ide-editor/lib/browser/editor-collection.service'; import { MonacoCommandRegistry } from '@opensumi/ide-editor/lib/browser/monaco-contrib/command/command.service'; import { ITheme, IThemeService } from '@opensumi/ide-theme'; import { WorkbenchThemeService } from '@opensumi/ide-theme/lib/browser/workbench.theme.service'; 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'; interface Props { input: string; relationId: string; language?: string; agentId?: string; command?: string; hideInsert?: boolean; } export const CodeEditorWithHighlight = (props: Props) => { const { input, language, relationId, agentId, command, hideInsert } = props; const ref = React.useRef(null); const monacoCommandRegistry = useInjectable(MonacoCommandRegistry); const clipboardService = useInjectable(IClipboardService); const workbenchThemeService = useInjectable(IThemeService); const aiReporter = useInjectable(IAIReporter); const [isCoping, setIsCoping] = useState(false); const useUUID = useMemo(() => uuid(12), [ref, ref.current]); useEffect(() => { const doToggleTheme = (newTheme: ITheme) => { if (newTheme.type === 'dark' || newTheme.type === 'hcDark') { import('highlight.js/styles/a11y-dark.css'); } else if (newTheme.type === 'light' || newTheme.type === 'hcLight') { import('highlight.js/styles/a11y-light.css'); } }; const dispose = workbenchThemeService.onThemeChange((newTheme) => { doToggleTheme(newTheme); }); const currentTheme = workbenchThemeService.getCurrentThemeSync(); doToggleTheme(currentTheme); return () => dispose.dispose(); }, []); const throttledScrollToBottom = useRef( throttle( () => { if (ref.current) { const highlightElement = ref.current; const codeElement = (highlightElement as any)?.el?.querySelector('code'); const childs = codeElement?.children; if (childs) { const lastChild = childs[childs.length - 1]; if (lastChild) { if ((lastChild as any).scrollIntoViewIfNeeded) { (lastChild as any).scrollIntoViewIfNeeded(); } else { lastChild.scrollIntoView(false); } } } } }, 150, { leading: true, trailing: true }, ), ).current; useEffect(() => { throttledScrollToBottom(); return () => { throttledScrollToBottom.cancel(); }; }, [input, throttledScrollToBottom]); const handleCopy = useCallback(async () => { setIsCoping(true); await clipboardService.writeText(input); aiReporter.end(relationId, { copy: true, code: input, language, agentId, command, actionSource: ActionSourceEnum.Chat, actionType: ActionTypeEnum.ChatCopyCode, }); runWhenIdle(() => { setIsCoping(false); }, 1000); }, [clipboardService, input, relationId]); const handleInsert = useCallback(() => { const editor = monacoCommandRegistry.getActiveCodeEditor(); if (editor) { const selection = editor.getSelection(); if (selection) { insertSnippetWithMonacoEditor(editor, input, [selection], { undoStopBefore: false, undoStopAfter: false }); aiReporter.end(relationId, { insert: true, code: input, language, agentId, command, actionSource: ActionSourceEnum.Chat, actionType: ActionTypeEnum.ChatInsertCode, }); } } }, [monacoCommandRegistry]); return (
{!hideInsert && ( handleInsert()} tabIndex={0} role='button' ariaLabel={localize('aiNative.chat.code.insert')} /> )} handleCopy()} tabIndex={0} role='button' ariaLabel={localize('aiNative.chat.code.copy')} />
{input}
); }; const CodeBlock = ({ content = '', relationId, 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(); const heighLightLang = highLightLanguageSupport.find((lang) => lang === language) || 'plaintext'; content = content.replace(/.*?\n/, ''); content = content.trim(); return (
{capitalize(heighLightLang)}
); }; const render = useMemo(() => { const blocks = content.split(rgBlockCode); const renderedContent: (string | React.ReactNode)[] = []; blocks.map((block: string, index) => { if (index % 2 === 0) { block.split(rgInlineCode).map((text, index) => { if (index % 2 === 0) { if (text.includes('```')) { const cutchunk = text.split(rgBlockCodeBefore).filter(Boolean); if (cutchunk.length === 2) { renderedContent.push(cutchunk[0]); renderedContent.push(renderCodeEditor(cutchunk[1])); return; } } if (renderText) { renderedContent.push(renderText(text)); } else { renderedContent.push(text); } } else { // 处理文件和文件夹标记 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 { renderedContent.push(renderCodeEditor(block)); } }); return renderedContent; }, [content, renderText]); return <>{render}; }; export const CodeBlockWrapper = ({ text, renderText, relationId, agentId, labelService, commandService, workspaceService, }: { text?: string; renderText?: (t: string) => React.ReactNode; relationId: string; agentId?: string; labelService?: LabelService; commandService?: CommandService; workspaceService?: IWorkspaceService; }) => (
); export const CodeBlockWrapperInput = ({ text, images, relationId, agentId, command, labelService, workspaceService, commandService, }: { text: string; images?: string[]; relationId: string; agentId?: string; command?: string; labelService?: LabelService; workspaceService?: IWorkspaceService; commandService?: CommandService; }) => { const chatFeatureRegistry = useInjectable(ChatFeatureRegistryToken); const [tag, setTag] = useState(''); const [txt, setTxt] = useState(text); React.useEffect(() => { const { value, nameWithSlash } = chatFeatureRegistry.parseSlashCommand(text); if (nameWithSlash) { setTag(nameWithSlash); setTxt(value); return; } else { // 恢复历史时,需要基于外部状态同步内部 text setTxt(text); } }, [text, chatFeatureRegistry]); return (
{images?.map((image) => (
))}
{tag && (
{tag}
)} {agentId && (
@{agentId}
)} {command &&
/ {command}
}
); };