feat: support code reference and reference bar (#4519)

* style: improve overflow style

* feat: support code reference and showing references

* fix: build

* chore: update lock

* chore: delete context by keybinding
This commit is contained in:
Dan 2025-04-27 14:04:40 +08:00 committed by GitHub
parent 24334e43cd
commit 909296f03c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 296 additions and 54 deletions

View File

@ -162,6 +162,9 @@
{
"path": "./references/tsconfig.addons.json"
},
{
"path": "./references/tsconfig.outline.json"
},
{
"path": "./references/tsconfig.ai-native.json"
},
@ -174,9 +177,6 @@
{
"path": "./references/tsconfig.keymaps.json"
},
{
"path": "./references/tsconfig.outline.json"
},
{
"path": "./references/tsconfig.startup.json"
},

View File

@ -37,6 +37,7 @@
"@opensumi/ide-main-layout": "workspace:*",
"@opensumi/ide-markers": "workspace:*",
"@opensumi/ide-monaco": "workspace:*",
"@opensumi/ide-outline": "workspace:*",
"@opensumi/ide-overlay": "workspace:*",
"@opensumi/ide-preferences": "workspace:*",
"@opensumi/ide-search": "workspace:*",

View File

@ -701,10 +701,7 @@ export const AIChatView = () => {
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, `\`<attached_file>${relativePath}\``);
}
}
@ -715,12 +712,29 @@ export const AIChatView = () => {
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, `\`<attached_folder>${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,
`\`<attached_file>${relativePath}:L${range[0]}-${range[1]}\``,
);
}
}
return handleAgentReply({ message: processedContent, images, agentId, command, reportExtra });
},
[handleAgentReply],
@ -879,6 +893,7 @@ export const AIChatView = () => {
defaultAgentId={defaultAgentId}
command={command}
setCommand={setCommand}
contextService={llmContextService}
ref={chatInputRef}
disableModelSelector={sessionModelId !== undefined || loading}
sessionModelId={sessionModelId}

View File

@ -1,18 +1,21 @@
import { DataContent } from 'ai';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Image } from '@opensumi/ide-components/lib/image';
import { LabelService, RecentFilesManager, useInjectable } from '@opensumi/ide-core-browser';
import { LabelService, RecentFilesManager, getSymbolIcon, useInjectable } from '@opensumi/ide-core-browser';
import { Icon, getIcon } from '@opensumi/ide-core-browser/lib/components';
import { ChatFeatureRegistryToken, URI, localize } from '@opensumi/ide-core-common';
import { CommandService } from '@opensumi/ide-core-common/lib/command';
import { defaultFilesWatcherExcludes } from '@opensumi/ide-core-common/lib/preferences/file-watch';
import { WorkbenchEditorService } from '@opensumi/ide-editor';
import { FileSearchServicePath, IFileSearchService } from '@opensumi/ide-file-search';
import { OutlineCompositeTreeNode, OutlineTreeNode } from '@opensumi/ide-outline/lib/browser/outline-node.define';
import { OutlineTreeService } from '@opensumi/ide-outline/lib/browser/services/outline-tree.service';
import { IMessageService } from '@opensumi/ide-overlay';
import { IWorkspaceService } from '@opensumi/ide-workspace';
import { IChatInternalService } from '../../common';
import { LLMContextService } from '../../common/llm-context';
import { ChatFeatureRegistry } from '../chat/chat.feature.registry';
import { ChatInternalService } from '../chat/chat.internal.service';
import { OPEN_MCP_CONFIG_COMMAND } from '../mcp/config/mcp-config.commands';
@ -48,11 +51,11 @@ export interface IChatMentionInputProps {
setCommand: (command: string) => void;
disableModelSelector?: boolean;
sessionModelId?: string;
contextService?: LLMContextService;
}
// 指令命令激活组件
export const ChatMentionInput = (props: IChatMentionInputProps) => {
const { onSend, disabled = false } = props;
const { onSend, disabled = false, contextService } = props;
const [value, setValue] = useState(props.value || '');
const [images, setImages] = useState(props.images || []);
@ -65,6 +68,8 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => {
const labelService = useInjectable<LabelService>(LabelService);
const messageService = useInjectable<IMessageService>(IMessageService);
const chatFeatureRegistry = useInjectable<ChatFeatureRegistry>(ChatFeatureRegistryToken);
const outlineTreeService = useInjectable<OutlineTreeService>(OutlineTreeService);
const prevOutlineItems = useRef<MentionItem[]>([]);
const handleShowMCPConfig = React.useCallback(() => {
commandService.executeCommand(OPEN_MCP_CONFIG_COMMAND.id);
}, [commandService]);
@ -75,27 +80,76 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => {
}
}, [props.value]);
const resolveSymbols = useCallback(
async (parent?: OutlineCompositeTreeNode, symbols: (OutlineTreeNode | OutlineCompositeTreeNode)[] = []) => {
if (!parent) {
parent = (await outlineTreeService.resolveChildren())[0] as OutlineCompositeTreeNode;
}
const children = (await outlineTreeService.resolveChildren(parent)) as (
| OutlineTreeNode
| OutlineCompositeTreeNode
)[];
for (const child of children) {
symbols.push(child);
if (OutlineCompositeTreeNode.is(child)) {
await resolveSymbols(child, symbols);
}
}
return symbols;
},
[outlineTreeService],
);
// 默认菜单项
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: 'code',
type: 'code',
text: 'Code',
icon: getIcon('codebraces'),
getHighestLevelItems: () => [],
getItems: async (searchText: string) => {
if (!searchText || prevOutlineItems.current.length === 0) {
const uri = outlineTreeService.currentUri;
if (!uri) {
return [];
}
const treeNodes = await resolveSymbols();
prevOutlineItems.current = await Promise.all(
treeNodes.map(async (treeNode) => {
const relativePath = await workspaceService.asRelativePath(uri);
return {
id: treeNode.raw.id,
type: MentionType.CODE,
text: treeNode.raw.name,
symbol: treeNode.raw,
value: treeNode.raw.id,
description: `${relativePath?.root ? relativePath.path : ''}:L${treeNode.raw.range.startLineNumber}-${
treeNode.raw.range.endLineNumber
}`,
kind: treeNode.raw.kind,
contextId: `${outlineTreeService.currentUri?.codeUri.fsPath}:L${treeNode.raw.range.startLineNumber}-${treeNode.raw.range.endLineNumber}`,
icon: getSymbolIcon(treeNode.raw.kind) + ' outline-icon',
};
}),
);
return prevOutlineItems.current;
} else {
searchText = searchText.toLocaleLowerCase();
return prevOutlineItems.current.sort((a, b) => {
if (a.text.toLocaleLowerCase().includes(searchText) && b.text.toLocaleLowerCase().includes(searchText)) {
return 0;
}
if (a.text.toLocaleLowerCase().includes(searchText)) {
return -1;
} else if (b.text.toLocaleLowerCase().includes(searchText)) {
return 1;
}
return 0;
});
}
},
},
{
id: MentionType.FILE,
type: MentionType.FILE,
@ -194,7 +248,13 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => {
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)));
const recentFolder = Array.from(
new Set(
recentFile
.map((file) => new URI(file).parent.codeUri.fsPath)
.filter((folder) => folder !== workspaceService.workspace?.uri.toString() && folder !== '/'),
),
);
folders = await Promise.all(
recentFolder.map(async (folder) => {
const uri = new URI(folder);
@ -354,6 +414,7 @@ export const ChatMentionInput = (props: IChatMentionInputProps) => {
placeholder={localize('aiNative.chat.input.placeholder.default')}
footerConfig={defaultMentionInputFooterOptions}
onImageUpload={handleImageUpload}
contextService={contextService}
/>
</div>
);

View File

@ -12,8 +12,6 @@
.editor_area {
position: relative;
padding: 0 15px;
min-height: 42px;
max-height: 105px;
}
.editor {
@ -21,11 +19,11 @@
background-color: transparent;
border: none;
font-size: 14px;
line-height: 1.5;
line-height: 24px;
min-height: 72px;
max-height: 120px;
outline: none;
resize: none;
min-height: 24px;
max-height: 120px;
overflow-y: auto;
border-radius: 4px;
word-break: break-word;
@ -142,6 +140,43 @@
display: flex;
justify-content: center;
}
.context_container {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.context_icon {
flex-grow: 0;
flex-shrink: 0;
:global(.kt-icon) {
font-size: 12px;
}
:global(.kticon-close) {
display: none;
}
}
&:hover {
.context_icon {
:global(.kticon-close) {
display: block;
}
:global(.kticon-out-link) {
display: none;
}
}
}
.context_description {
flex: 1;
margin-left: 3px;
margin-right: 10px;
text-align: left;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--descriptionForeground);
}
}
.mention_panel {
background-color: var(--editor-background);
@ -191,6 +226,7 @@
.mention_item_left {
display: flex;
max-width: 100%;
align-items: center;
flex: 1;
}
@ -198,7 +234,8 @@
.mention_item_icon {
margin-right: 8px;
width: 18px;
height: 18px;
height: 22px;
line-height: 22px !important;
display: flex;
align-items: center;
justify-content: center;
@ -216,6 +253,7 @@
font-size: 13px;
display: inline;
flex: 1;
direction: rtl;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@ -236,6 +274,40 @@
align-items: center;
}
.context_item {
display: flex;
align-items: center;
background-color: var(--badge-background);
color: var(--badge-foreground);
border-radius: 4px;
padding: 2px 6px;
font-size: 12px;
}
.context_item_icon {
margin-right: 4px;
}
.context_item_text {
margin-right: 4px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context_item_remove {
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.back_button {
background: none;
border: none;

View File

@ -1,13 +1,23 @@
import cls from 'classnames';
import * as React from 'react';
import { Popover, PopoverPosition, Select, getIcon } from '@opensumi/ide-core-browser/lib/components';
import { formatLocalize, getSymbolIcon, localize } from '@opensumi/ide-core-browser';
import { Icon, 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 { FileContext } from '../../../common/llm-context';
import styles from './mention-input.module.less';
import { MentionPanel } from './mention-panel';
import { FooterButtonPosition, MENTION_KEYWORD, MentionInputProps, MentionItem, MentionState } from './types';
import {
FooterButtonPosition,
MENTION_KEYWORD,
MentionInputProps,
MentionItem,
MentionState,
MentionType,
} from './types';
export const WHITE_SPACE_TEXT = '&nbsp;';
@ -26,6 +36,7 @@ export const MentionInput: React.FC<MentionInputProps> = ({
buttons: [],
showModelSelector: false,
},
contextService,
}) => {
const editorRef = React.useRef<HTMLDivElement>(null);
const [mentionState, setMentionState] = React.useState<MentionState>({
@ -53,8 +64,14 @@ export const MentionInput: React.FC<MentionInputProps> = ({
const [historyIndex, setHistoryIndex] = React.useState<number>(-1);
const [currentInput, setCurrentInput] = React.useState<string>('');
const [isNavigatingHistory, setIsNavigatingHistory] = React.useState<boolean>(false);
const [attachedFiles, setAttachedFiles] = React.useState<{
files: FileContext[];
folders: FileContext[];
}>({
files: [],
folders: [],
});
// 获取当前菜单项
const getCurrentItems = (): MentionItem[] => {
if (mentionState.level === 0) {
return mentionItems;
@ -70,7 +87,6 @@ export const MentionInput: React.FC<MentionInputProps> = ({
return [];
};
// 添加防抖函数
const useDebounce = <T,>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
@ -87,14 +103,12 @@ export const MentionInput: React.FC<MentionInputProps> = ({
return debouncedValue;
};
// 使用防抖处理搜索文本
const debouncedSecondLevelFilter = useDebounce(mentionState.secondLevelFilter, 300);
React.useEffect(() => {
setSelectedModel(footerConfig.defaultModel || '');
}, [footerConfig.defaultModel]);
// 监听搜索文本变化,实时更新二级菜单
React.useEffect(() => {
if (mentionState.level === 1 && mentionState.parentType && debouncedSecondLevelFilter !== undefined) {
// 查找父级菜单项
@ -162,6 +176,16 @@ export const MentionInput: React.FC<MentionInputProps> = ({
}
}, [debouncedSecondLevelFilter, mentionState.level, mentionState.parentType]);
React.useEffect(() => {
const disposable = contextService?.onDidContextFilesChangeEvent(({ attached, attachedFolders }) => {
setAttachedFiles({ files: attached, folders: attachedFolders });
});
return () => {
disposable?.dispose();
};
}, [contextService]);
// 获取光标位置
const getCursorPosition = (element: HTMLElement): number => {
const selection = window.getSelection();
@ -176,7 +200,6 @@ export const MentionInput: React.FC<MentionInputProps> = ({
return preCaretRange.toString().length;
};
// 处理输入事件
const handleInput = () => {
// 如果用户开始输入,退出历史导航模式
if (isNavigatingHistory) {
@ -262,7 +285,7 @@ export const MentionInput: React.FC<MentionInputProps> = ({
// 检查输入框高度,如果超过最大高度则添加滚动条
if (editorRef.current) {
const editorHeight = editorRef.current.scrollHeight;
if (editorHeight > 120) {
if (editorHeight >= 120) {
editorRef.current.style.overflowY = 'auto';
} else {
editorRef.current.style.overflowY = 'hidden';
@ -305,6 +328,15 @@ export const MentionInput: React.FC<MentionInputProps> = ({
return;
}
// 当输入框为空时,处理删除键 (Backspace) 或 Delete 键来删除上下文内容
if (
(e.key === 'Backspace' || e.key === 'Delete') &&
editorRef.current &&
(!editorRef.current.textContent || editorRef.current.textContent.trim() === '')
) {
contextService?.cleanFileContext();
}
// 添加对 @ 键的监听,支持在任意位置触发菜单
if (e.key === MENTION_KEYWORD && !mentionState.active && !mentionState.inlineSearchActive && editorRef.current) {
const cursorPos = getCursorPosition(editorRef.current);
@ -665,15 +697,30 @@ export const MentionInput: React.FC<MentionInputProps> = ({
mentionTag.dataset.contextId = item.contextId || '';
mentionTag.contentEditable = 'false';
// 为 file 和 folder 类型添加图标
if (item.type === 'file' || item.type === 'folder') {
if (item.type === MentionType.FILE || item.type === MentionType.FOLDER) {
// 创建图标容器
const iconSpan = document.createElement('span');
iconSpan.className = cls(
styles.mention_icon,
item.type === 'file' ? labelService?.getIcon(new URI(item.text)) : getIcon('folder'),
item.type === MentionType.FILE ? labelService?.getIcon(new URI(item.text)) : getIcon('folder'),
);
mentionTag.appendChild(iconSpan);
if (item.type === MentionType.FOLDER) {
contextService?.addFolderToContext(new URI(item.contextId), true);
} else {
contextService?.addFileToContext(new URI(item.contextId), undefined, true);
}
} else if (item.type === MentionType.CODE) {
const iconSpan = document.createElement('span');
iconSpan.className = cls(styles.mention_icon, item.kind && getSymbolIcon(item.kind) + ' outline-icon');
mentionTag.appendChild(iconSpan);
if (item.symbol) {
contextService?.addFileToContext(
new URI(item.contextId),
[item.symbol.range.startLineNumber, item.symbol.range.endLineNumber],
true,
);
}
}
const workspace = workspaceService?.workspace;
let relativePath = item.text;
@ -913,7 +960,6 @@ export const MentionInput: React.FC<MentionInputProps> = ({
setHistoryIndex(-1);
setIsNavigatingHistory(false);
}
if (onSend) {
// 传递当前选择的模型和其他配置信息
onSend(processedContent, {
@ -931,6 +977,10 @@ export const MentionInput: React.FC<MentionInputProps> = ({
}
};
const handleClearContext = React.useCallback(() => {
contextService?.cleanFileContext();
}, [contextService]);
const handleStop = React.useCallback(() => {
if (onStop) {
onStop();
@ -962,6 +1012,11 @@ export const MentionInput: React.FC<MentionInputProps> = ({
[footerConfig.buttons],
);
const hasContext = React.useMemo(
() => attachedFiles.files.length > 0 || attachedFiles.folders.length > 0,
[attachedFiles],
);
return (
<div className={styles.input_container}>
{mentionState.active && (
@ -1005,6 +1060,25 @@ export const MentionInput: React.FC<MentionInputProps> = ({
</div>
<div className={styles.right_control}>
{renderButtons(FooterButtonPosition.RIGHT)}
<Popover
overlayClassName={styles.popover_icon}
id={'ai-chat-clear-context'}
position={PopoverPosition.top}
content={localize('aiNative.chat.context.clear')}
>
<div className={styles.context_container} onClick={handleClearContext}>
<div className={styles.context_icon}>
<Icon icon='out-link' />
<Icon icon='close' />
</div>
<div className={styles.context_description}>
{formatLocalize(
'aiNative.chat.context.description',
attachedFiles.files.length + attachedFiles.folders.length,
)}
</div>
</div>
</Popover>
<Popover
overlayClassName={styles.popover_icon}
id={'ai-chat-send'}

View File

@ -1,3 +1,7 @@
import { DocumentSymbol, SymbolKind } from '@opensumi/ide-monaco';
import { LLMContextService } from '../../../common/llm-context';
import type { LabelService } from '@opensumi/ide-core-browser';
import type { IWorkspaceService } from '@opensumi/ide-workspace';
@ -8,7 +12,9 @@ export interface MentionItem {
value?: string;
description?: string;
contextId?: string;
symbol?: DocumentSymbol;
icon?: string;
kind?: SymbolKind;
getHighestLevelItems?: () => MentionItem[];
getItems?: (searchText: string) => Promise<MentionItem[]>;
}
@ -82,6 +88,7 @@ export interface MentionInputProps {
mentionKeyword?: string;
labelService?: LabelService;
workspaceService?: IWorkspaceService;
contextService?: LLMContextService;
}
export const MENTION_KEYWORD = '@';

View File

@ -45,6 +45,7 @@ export class LLMContextServiceImpl extends WithEventBus implements LLMContextSer
private readonly onDidContextFilesChangeEmitter = new Emitter<{
viewed: FileContext[];
attached: FileContext[];
attachedFolders: FileContext[];
version: number;
}>();
onDidContextFilesChangeEvent = this.onDidContextFilesChangeEmitter.event;

View File

@ -28,6 +28,7 @@ import { SumiReadableStream } from '@opensumi/ide-utils/lib/stream';
import { IMarker } from '@opensumi/monaco-editor-core/esm/vs/platform/markers/common/markers';
import { IChatWelcomeMessageContent, ISampleQuestions, ITerminalCommandSuggestionDesc } from '../common';
import { LLMContextService } from '../common/llm-context';
import {
ICodeEditsContextBean,
@ -166,6 +167,7 @@ export type ChatInputRender = (props: {
defaultAgentId?: string;
command: string;
setCommand: (theme: string) => void;
contextService?: LLMContextService;
}) => React.ReactElement | React.JSX.Element;
export type ChatViewHeaderRender = (props: {
handleClear: () => any;

View File

@ -1,5 +1,3 @@
import { DataContent } from 'ai';
import { Event, URI } from '@opensumi/ide-core-common/lib/utils';
export interface LLMContextService {
@ -31,7 +29,12 @@ export interface LLMContextService {
/**
*
*/
onDidContextFilesChangeEvent: Event<{ viewed: FileContext[]; attached: FileContext[]; version: number }>;
onDidContextFilesChangeEvent: Event<{
viewed: FileContext[];
attached: FileContext[];
attachedFolders: FileContext[];
version: number;
}>;
/**
* context

View File

@ -1463,7 +1463,9 @@ export const localizationBundle = {
'aiNative.chat.defaultContextFolder': 'Current Folder',
'aiNative.chat.thinking': 'Deep Think',
'aiNative.chat.imageUpload': 'Upload Image',
'aiNative.chat.clearContext': 'Clear Context',
'aiNative.chat.context.description': 'Total {0} References',
'aiNative.chat.context.clear': 'Clear References',
'aiNative.inline.chat.operate.chat.title': 'Chat({0})',
'aiNative.inline.chat.operate.check.title': 'Check',
'aiNative.inline.chat.operate.thumbsup.title': 'Thumbs up',

View File

@ -1231,6 +1231,9 @@ export const localizationBundle = {
'aiNative.chat.defaultContextFolder': '当前文件夹',
'aiNative.chat.thinking': '深度思考',
'aiNative.chat.imageUpload': '上传图片',
'aiNative.chat.clearContext': '清空上下文',
'aiNative.chat.context.description': '共 {0} 个引用',
'aiNative.chat.context.clear': '点击清空引用',
'aiNative.inline.chat.operate.chat.title': 'Chat({0})',
'aiNative.inline.chat.operate.check.title': '采纳',

View File

@ -3409,6 +3409,7 @@ __metadata:
"@opensumi/ide-main-layout": "workspace:*"
"@opensumi/ide-markers": "workspace:*"
"@opensumi/ide-monaco": "workspace:*"
"@opensumi/ide-outline": "workspace:*"
"@opensumi/ide-overlay": "workspace:*"
"@opensumi/ide-preferences": "workspace:*"
"@opensumi/ide-search": "workspace:*"