diff --git a/packages/ai-native/src/browser/ai-core.contribution.ts b/packages/ai-native/src/browser/ai-core.contribution.ts index b341506658..e28a7727c7 100644 --- a/packages/ai-native/src/browser/ai-core.contribution.ts +++ b/packages/ai-native/src/browser/ai-core.contribution.ts @@ -73,12 +73,16 @@ import { AI_CHAT_VIEW_ID, AI_MENU_BAR_DEBUG_TOOLBAR, ChatProxyServiceToken, + IChatInternalService, + IChatManagerService, ISumiMCPServerBackend, SumiMCPServerProxyServicePath, } from '../common'; import { MCPServerDescription } from '../common/mcp-server-manager'; +import { ChatManagerService } from './chat/chat-manager.service'; import { ChatProxyService } from './chat/chat-proxy.service'; +import { ChatInternalService } from './chat/chat.internal.service'; import { AIChatView } from './chat/chat.view'; import { CodeActionSingleHandler } from './contrib/code-action/code-action.handler'; import { AIInlineCompletionsProvider } from './contrib/inline-completions/completeProvider'; @@ -226,17 +230,25 @@ export class AINativeBrowserContribution @Autowired(WorkbenchEditorService) private readonly workbenchEditorService: WorkbenchEditorServiceImpl; + @Autowired(IChatManagerService) + private readonly chatManagerService: ChatManagerService; + + @Autowired(IChatInternalService) + private readonly chatInternalService: ChatInternalService; + constructor() { this.registerFeature(); } - initialize() { + async initialize() { const { supportsChatAssistant } = this.aiNativeConfigService.capabilities; if (supportsChatAssistant) { ComponentRegistryImpl.addLayoutModule(this.appConfig.layoutConfig, AI_CHAT_VIEW_ID, AI_CHAT_CONTAINER_ID); ComponentRegistryImpl.addLayoutModule(this.appConfig.layoutConfig, DESIGN_MENU_BAR_RIGHT, AI_CHAT_LOGO_AVATAR_ID); this.chatProxyService.registerDefaultAgent(); + this.chatInternalService.init(); + await this.chatManagerService.init(); } } diff --git a/packages/ai-native/src/browser/chat/chat-manager.service.ts b/packages/ai-native/src/browser/chat/chat-manager.service.ts index bbe658c1a2..17861bb903 100644 --- a/packages/ai-native/src/browser/chat/chat-manager.service.ts +++ b/packages/ai-native/src/browser/chat/chat-manager.service.ts @@ -4,19 +4,41 @@ import { CancellationTokenSource, Disposable, DisposableMap, + Emitter, IChatProgress, + IStorage, + STORAGE_NAMESPACE, + StorageProvider, } from '@opensumi/ide-core-common'; -import { ChatMessageRole } from '@opensumi/ide-core-common/lib/types/ai-native'; -import { IChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; - -import { IChatAgentService } from '../../common'; - -import { ChatModel, ChatRequestModel } from './chat-model'; +import { ChatMessageRole, IChatMessage, IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-native'; + +import { IChatAgentService, IChatFollowup, IChatRequestMessage, IChatResponseErrorDetails } from '../../common'; +import { MsgHistoryManager } from '../model/msg-history-manager'; + +import { ChatModel, ChatRequestModel, ChatResponseModel, IChatProgressResponseContent } from './chat-model'; + +interface ISessionModel { + sessionId: string; + history: { additional: Record; messages: IHistoryChatMessage[] }; + requests: { + requestId: string; + message: IChatRequestMessage; + response: { + isCanceled: boolean; + responseText: string; + responseContents: IChatProgressResponseContent[]; + errorDetails: IChatResponseErrorDetails | undefined; + followups: IChatFollowup[]; + }; + }[]; +} @Injectable() export class ChatManagerService extends Disposable { #sessionModels = this.registerDispose(new DisposableMap()); #pendingRequests = this.registerDispose(new DisposableMap()); + private storageInitEmitter = new Emitter(); + public onStorageInit = this.storageInitEmitter.event; @Autowired(INJECTOR_TOKEN) injector: Injector; @@ -24,12 +46,59 @@ export class ChatManagerService extends Disposable { @Autowired(IChatAgentService) chatAgentService: IChatAgentService; + @Autowired(StorageProvider) + private storageProvider: StorageProvider; + + private _chatStorage: IStorage; + + protected fromJSON(data: ISessionModel[]) { + // TODO: 支持ApplyService恢复 + return data.map((item) => { + const model = new ChatModel({ + sessionId: item.sessionId, + history: new MsgHistoryManager(item.history), + }); + const requests = item.requests.map( + (request) => + new ChatRequestModel( + request.requestId, + model, + request.message, + new ChatResponseModel(request.requestId, model, request.message.agentId, { + responseContents: request.response.responseContents, + isComplete: true, + responseText: request.response.responseText, + errorDetails: request.response.errorDetails, + followups: request.response.followups, + isCanceled: request.response.isCanceled, + }), + ), + ); + model.restoreRequests(requests); + return model; + }); + } + constructor() { super(); } + async init() { + this._chatStorage = await this.storageProvider(STORAGE_NAMESPACE.CHAT); + const sessionsModelData = this._chatStorage.get('sessionModels', []); + const savedSessions = this.fromJSON(sessionsModelData); + savedSessions.forEach((session) => { + this.#sessionModels.set(session.sessionId, session); + }); + await this.storageInitEmitter.fireAndAwait(); + } + + getSessions() { + return Array.from(this.#sessionModels.values()); + } + startSession() { - const model = this.injector.get(ChatModel); + const model = new ChatModel(); this.#sessionModels.set(model.sessionId, model); return model; } @@ -46,6 +115,7 @@ export class ChatManagerService extends Disposable { this.#sessionModels.disposeKey(sessionId); this.#pendingRequests.get(sessionId)?.cancel(); this.#pendingRequests.disposeKey(sessionId); + this.saveSessions(); } createRequest(sessionId: string, message: string, agentId: string, command?: string) { @@ -122,11 +192,17 @@ export class ChatManagerService extends Disposable { } finally { listener.dispose(); this.#pendingRequests.disposeKey(model.sessionId); + this.saveSessions(); } } + protected saveSessions() { + this._chatStorage.set('sessionModels', this.getSessions()); + } + cancelRequest(sessionId: string) { this.#pendingRequests.get(sessionId)?.cancel(); this.#pendingRequests.disposeKey(sessionId); + this.saveSessions(); } } diff --git a/packages/ai-native/src/browser/chat/chat-model.ts b/packages/ai-native/src/browser/chat/chat-model.ts index 77267e47ea..b8f661ae96 100644 --- a/packages/ai-native/src/browser/chat/chat-model.ts +++ b/packages/ai-native/src/browser/chat/chat-model.ts @@ -80,9 +80,29 @@ export class ChatResponseModel extends Disposable { return this.#onDidChange.event; } - constructor(requestId: string, public readonly session: IChatModel, public readonly agentId: string) { + constructor( + requestId: string, + public readonly session: IChatModel, + public readonly agentId: string, + initParams?: { + isComplete: boolean; + isCanceled: boolean; + responseContents: IChatProgressResponseContent[]; + responseText: string; + errorDetails: IChatResponseErrorDetails | undefined; + followups: IChatFollowup[] | undefined; + }, + ) { super(); this.#requestId = requestId; + if (initParams) { + this.#responseContents = initParams.responseContents; + this.#responseText = initParams.responseText; + this.#isComplete = initParams.isComplete; + this.#isCanceled = initParams.isCanceled; + this.#errorDetails = initParams.errorDetails; + this.#followups = initParams.followups; + } } updateContent(progress: IChatProgress, quiet?: boolean): void { @@ -215,6 +235,16 @@ export class ChatResponseModel extends Disposable { this.#followups = followups; this.#onDidChange.fire(); } + + toJSON() { + return { + isCanceled: this.isCanceled, + responseContents: this.responseContents, + responseText: this.responseText, + errorDetails: this.errorDetails, + followups: this.followups, + }; + } } export class ChatRequestModel implements IChatRequestModel { @@ -231,19 +261,29 @@ export class ChatRequestModel implements IChatRequestModel { ) { this.#requestId = requestId; } + + toJSON() { + return { + requestId: this.requestId, + message: this.message, + response: this.response, + }; + } } -@Injectable({ multiple: true }) export class ChatModel extends Disposable implements IChatModel { private static requestIdPool = 0; - @Autowired(ILogger) - protected readonly logger: ILogger; - - @Autowired(INJECTOR_TOKEN) - injector: Injector; + constructor(initParams?: { sessionId?: string; history?: MsgHistoryManager; requests?: ChatRequestModel[] }) { + super(); + this.#sessionId = initParams?.sessionId ?? uuid(); + this.history = initParams?.history ?? new MsgHistoryManager(); + if (initParams?.requests) { + this.#requests = new Map(initParams.requests.map((r) => [r.requestId, r])); + } + } - #sessionId: string = uuid(); + #sessionId: string; get sessionId(): string { return this.#sessionId; } @@ -253,11 +293,12 @@ export class ChatModel extends Disposable implements IChatModel { return Array.from(this.#requests.values()); } - @memoize - get history(): MsgHistoryManager { - return this.injector.get(MsgHistoryManager, []); + restoreRequests(requests: ChatRequestModel[]): void { + this.#requests = new Map(requests.map((r) => [r.requestId, r])); } + readonly history: MsgHistoryManager; + addRequest(message: IChatRequestMessage): ChatRequestModel { const requestId = `${this.sessionId}_request_${ChatModel.requestIdPool++}`; const response = new ChatResponseModel(requestId, this, message.agentId); @@ -279,7 +320,8 @@ export class ChatModel extends Disposable implements IChatModel { if (basicKind.includes(kind)) { request.response.updateContent(progress, quiet); } else { - this.logger.error(`Couldn't handle progress: ${JSON.stringify(progress)}`); + // eslint-disable-next-line no-console + console.error(`Couldn't handle progress: ${JSON.stringify(progress)}`); } } @@ -291,6 +333,14 @@ export class ChatModel extends Disposable implements IChatModel { super.dispose(); this.#requests.forEach((r) => r.response.dispose()); } + + toJSON() { + return { + sessionId: this.sessionId, + history: this.history, + requests: this.requests, + }; + } } @Injectable({ multiple: true }) diff --git a/packages/ai-native/src/browser/chat/chat-proxy.service.ts b/packages/ai-native/src/browser/chat/chat-proxy.service.ts index 4c0df8a509..d9b95db3f0 100644 --- a/packages/ai-native/src/browser/chat/chat-proxy.service.ts +++ b/packages/ai-native/src/browser/chat/chat-proxy.service.ts @@ -39,7 +39,7 @@ import { ChatFeatureRegistry } from './chat.feature.registry'; @Injectable() export class ChatProxyService extends Disposable { // 避免和插件注册的 agent id 冲突 - static readonly AGENT_ID = 'Default_Chat_Agent_' + uuid(6); + static readonly AGENT_ID = 'Default_Chat_Agent'; @Autowired(IChatAgentService) private readonly chatAgentService: IChatAgentService; diff --git a/packages/ai-native/src/browser/chat/chat.internal.service.ts b/packages/ai-native/src/browser/chat/chat.internal.service.ts index 5f5ec6a103..dff02a3d46 100644 --- a/packages/ai-native/src/browser/chat/chat.internal.service.ts +++ b/packages/ai-native/src/browser/chat/chat.internal.service.ts @@ -43,9 +43,15 @@ export class ChatInternalService extends Disposable { return this.#sessionModel; } - constructor() { - super(); - this.#sessionModel = this.chatManagerService.startSession(); + init() { + this.chatManagerService.onStorageInit(() => { + const sessions = this.chatManagerService.getSessions(); + if (sessions.length > 0) { + this.activateSession(sessions[sessions.length - 1].sessionId); + } else { + this.createSessionModel(); + } + }); } public setLatestRequestId(id: string): void { @@ -70,12 +76,24 @@ export class ChatInternalService extends Disposable { this._onCancelRequest.fire(); } - clearSessionModel() { - this.chatManagerService.clearSession(this.#sessionModel.sessionId); + createSessionModel() { this.#sessionModel = this.chatManagerService.startSession(); this._onChangeSession.fire(this.#sessionModel.sessionId); } + clearSessionModel(sessionId?: string) { + sessionId = sessionId || this.#sessionModel.sessionId; + this.chatManagerService.clearSession(sessionId); + if (sessionId === this.#sessionModel.sessionId) { + this.#sessionModel = this.chatManagerService.startSession(); + } + this._onChangeSession.fire(this.#sessionModel.sessionId); + } + + getSessions() { + return this.chatManagerService.getSessions(); + } + activateSession(sessionId: string) { const targetSession = this.chatManagerService.getSession(sessionId); if (!targetSession) { diff --git a/packages/ai-native/src/browser/chat/chat.module.less b/packages/ai-native/src/browser/chat/chat.module.less index cc3c0e1926..43626a724d 100644 --- a/packages/ai-native/src/browser/chat/chat.module.less +++ b/packages/ai-native/src/browser/chat/chat.module.less @@ -75,53 +75,14 @@ .header_container { height: 36px; - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 0 12px 16px; + padding: 8px 0 8px 16px; box-sizing: border-box; background-color: var(--editorGroupHeader-tabsBackground); user-select: none; - .left { - display: flex; - align-items: center; - height: 16px; - color: var(--design-title-foreground); - font-size: 12px; - - .ai_avatar_icon { - width: 18px; - height: 18px; - } - - .avatar_icon_normal { - width: 18px; - height: 18px; - font-size: 14px !important; - } - - & > * { - margin-right: 8px; - } - } - - .right { + .header { display: flex; align-items: center; - padding-right: 10px; - .popover_icon { - margin-left: 2px; - } - - .action_btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - } } } @@ -318,6 +279,7 @@ } } + .chat_tips_container { display: flex; align-items: center; @@ -326,3 +288,8 @@ font-size: 12px; margin-top: 16px; } + +.chat_history { + width: calc(100% - 60px); + color: var(--design-text-foreground); +} diff --git a/packages/ai-native/src/browser/chat/chat.view.tsx b/packages/ai-native/src/browser/chat/chat.view.tsx index 554a61fc6d..ec8ad40adb 100644 --- a/packages/ai-native/src/browser/chat/chat.view.tsx +++ b/packages/ai-native/src/browser/chat/chat.view.tsx @@ -21,6 +21,7 @@ import { ChatRenderRegistryToken, ChatServiceToken, Disposable, + DisposableCollection, IAIReporter, IChatComponent, IChatContent, @@ -44,6 +45,7 @@ import { LLMContextService, LLMContextServiceToken } from '../../common/llm-cont import { ChatAgentPromptProvider } from '../../common/prompts/context-prompt-provider'; 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 { ChatNotify, ChatReply } from '../components/ChatReply'; @@ -68,6 +70,8 @@ interface TDispatchAction { payload?: MessageData[]; } +const MAX_TITLE_LENGTH = 100; + export const AIChatView = () => { const aiChatService = useInjectable(IChatInternalService); const chatApiService = useInjectable(ChatServiceToken); @@ -449,7 +453,9 @@ export const AIChatView = () => { setLoading(false); }} onRegenerate={() => { - aiChatService.sendRequest(request, true); + if (request) { + aiChatService.sendRequest(request, true); + } }} msgId={msgId} /> @@ -612,7 +618,8 @@ export const AIChatView = () => { }); } else if (msg.role === ChatMessageRole.Assistant && msg.requestId) { const request = aiChatService.sessionModel.getRequest(msg.requestId)!; - if (!request.response.isComplete) { + // 从storage恢复时,request为undefined + if (request && !request.response.isComplete) { setLoading(true); } await renderReply({ @@ -622,7 +629,7 @@ export const AIChatView = () => { agentId: msg.agentId, command: msg.agentCommand, startTime: msg.replyStartTime!, - request: aiChatService.sessionModel.getRequest(msg.requestId)!, + request, }); } else if (msg.role === ChatMessageRole.Assistant && msg.content) { await renderSimpleMarkdownReply({ @@ -758,44 +765,121 @@ export function DefaultChatViewHeader({ handleClear: () => any; handleCloseChatView: () => any; }) { - const aiAssistantName = React.useMemo(() => localize('aiNative.chat.ai.assistant.name'), []); + const aiChatService = useInjectable(IChatInternalService); + const [historyList, setHistoryList] = React.useState([]); + const [currentTitle, setCurrentTitle] = React.useState(''); + const handleNewChat = React.useCallback(() => { + if (aiChatService.sessionModel.history.getMessages().length > 0) { + aiChatService.createSessionModel(); + } + }, [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(); + setCurrentTitle( + currentMessages.length > 0 + ? currentMessages[currentMessages.length - 1].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 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 ( - <> -
- {aiAssistantName} -
-
- - - - - - -
- +
+ {}} + /> + + + + + + +
); } diff --git a/packages/ai-native/src/browser/components/ChatHistory.tsx b/packages/ai-native/src/browser/components/ChatHistory.tsx new file mode 100644 index 0000000000..eed1915494 --- /dev/null +++ b/packages/ai-native/src/browser/components/ChatHistory.tsx @@ -0,0 +1,292 @@ +import cls from 'classnames'; +import React, { FC, memo, useCallback, useEffect, useRef, useState } from 'react'; + +import { Icon, Input, Loading, Popover, PopoverPosition, PopoverTriggerType, getIcon } from '@opensumi/ide-components'; +import './chat-history.css'; +import { localize } from '@opensumi/ide-core-browser'; +import { EnhanceIcon } from '@opensumi/ide-core-browser/lib/components/ai-native'; + +export interface IChatHistoryItem { + id: string; + title: string; + updatedAt: number; + loading: boolean; +} + +export interface IChatHistoryProps { + title: string; + historyList: IChatHistoryItem[]; + currentId?: string; + className?: string; + onNewChat: () => void; + onHistoryItemSelect: (item: IChatHistoryItem) => void; + onHistoryItemDelete: (item: IChatHistoryItem) => void; + onHistoryItemChange: (item: IChatHistoryItem, title: string) => void; +} + +// 最大历史记录数 +const MAX_HISTORY_LIST = 100; + +const ChatHistory: FC = memo( + ({ + title, + historyList, + currentId, + onNewChat, + onHistoryItemSelect, + onHistoryItemChange, + onHistoryItemDelete, + className, + }) => { + const [historyTitleEditable, setHistoryTitleEditable] = useState<{ + [key: string]: boolean; + } | null>(null); + const [searchValue, setSearchValue] = useState(''); + const inputRef = useRef(null); + + // 处理搜索输入变化 + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + setSearchValue(event.target.value); + }, + [searchValue], + ); + + // 处理历史记录项选择 + const handleHistoryItemSelect = useCallback( + (item: IChatHistoryItem) => { + onHistoryItemSelect(item); + setSearchValue(''); + }, + [onHistoryItemSelect, searchValue], + ); + + // 处理标题编辑 + const handleTitleEdit = useCallback( + (item: IChatHistoryItem) => { + setHistoryTitleEditable({ + [item.id]: true, + }); + }, + [historyTitleEditable], + ); + + // 处理标题编辑完成 + const handleTitleEditComplete = useCallback( + (item: IChatHistoryItem, newTitle: string) => { + setHistoryTitleEditable({ + [item.id]: false, + }); + onHistoryItemChange(item, newTitle); + }, + [onHistoryItemChange, historyTitleEditable], + ); + + // 处理标题编辑取消 + const handleTitleEditCancel = useCallback( + (item: IChatHistoryItem) => { + setHistoryTitleEditable({ + [item.id]: false, + }); + }, + [historyTitleEditable], + ); + + // 处理新建聊天 + const handleNewChat = useCallback(() => { + onNewChat(); + }, [onNewChat]); + + useEffect(() => { + if (historyTitleEditable) { + inputRef.current?.focus({ cursor: 'end' }); + } + }, [historyTitleEditable]); + + // 处理删除历史记录 + const handleHistoryItemDelete = useCallback( + (item: IChatHistoryItem) => { + onHistoryItemDelete(item); + }, + [onHistoryItemDelete], + ); + + // 获取时间标签 + const getTimeKey = useCallback((diff: number): string => { + if (diff < 60 * 60 * 1000) { + const minutes = Math.floor(diff / (60 * 1000)); + return minutes === 0 ? 'Just now' : `${minutes}m ago`; + } else if (diff < 24 * 60 * 60 * 1000) { + const hours = Math.floor(diff / (60 * 60 * 1000)); + return `${hours}h ago`; + } else if (diff < 7 * 24 * 60 * 60 * 1000) { + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); + return `${days}d ago`; + } else if (diff < 30 * 24 * 60 * 60 * 1000) { + const weeks = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)); + return `${weeks}w ago`; + } else if (diff < 365 * 24 * 60 * 60 * 1000) { + const months = Math.floor(diff / (30 * 24 * 60 * 60 * 1000)); + return `${months}mo ago`; + } + const years = Math.floor(diff / (365 * 24 * 60 * 60 * 1000)); + return `${years}y ago`; + }, []); + + // 格式化历史记录 + const formatHistory = useCallback( + (list: IChatHistoryItem[]) => { + const now = new Date(); + const result = [] as { key: string; items: typeof list }[]; + + list.forEach((item: IChatHistoryItem) => { + const updatedAt = new Date(item.updatedAt); + const diff = now.getTime() - updatedAt.getTime(); + const key = getTimeKey(diff); + + const existingGroup = result.find((group) => group.key === key); + if (existingGroup) { + existingGroup.items.push(item); + } else { + result.push({ key, items: [item] }); + } + }); + + return result; + }, + [getTimeKey], + ); + + // 渲染历史记录项 + const renderHistoryItem = useCallback( + (item: IChatHistoryItem) => ( +
handleHistoryItemSelect(item)} + > +
+ {item.loading ? ( + + ) : ( + + )} + {!historyTitleEditable?.[item.id] ? ( + + {item.title} + + ) : ( + { + handleTitleEditComplete(item, e.target.value); + }} + onBlur={() => handleTitleEditCancel(item)} + /> + )} +
+
+ {/* { + e.preventDefault(); + e.stopPropagation(); + handleTitleEdit(item); + }} + /> */} + { + e.preventDefault(); + e.stopPropagation(); + handleHistoryItemDelete(item); + }} + ariaLabel={localize('aiNative.operate.chatHistory.delete')} + /> +
+
+ ), + [ + historyTitleEditable, + handleHistoryItemSelect, + handleTitleEditComplete, + handleTitleEditCancel, + handleTitleEdit, + handleHistoryItemDelete, + currentId, + inputRef, + ], + ); + + // 渲染历史记录列表 + const renderHistory = useCallback(() => { + const filteredList = historyList + .slice(0, MAX_HISTORY_LIST) + .filter((item) => item.title && item.title.includes(searchValue)); + + const groupedHistoryList = formatHistory(filteredList); + + return ( +
+ +
+ {groupedHistoryList.map((group) => ( +
+
{group.key}
+ {group.items.map(renderHistoryItem)} +
+ ))} +
+
+ ); + }, [historyList, searchValue, formatHistory, handleSearchChange, renderHistoryItem]); + + // getPopupContainer 处理函数 + const getPopupContainer = useCallback((triggerNode: HTMLElement) => triggerNode.parentElement!, []); + + return ( +
+
+ {title} +
+
+ +
+ +
+
+ + + +
+
+ ); + }, +); + +export default ChatHistory; diff --git a/packages/ai-native/src/browser/components/ChatReply.tsx b/packages/ai-native/src/browser/components/ChatReply.tsx index 7a43d990a5..5515e77c1a 100644 --- a/packages/ai-native/src/browser/components/ChatReply.tsx +++ b/packages/ai-native/src/browser/components/ChatReply.tsx @@ -360,7 +360,11 @@ export const ChatReply = (props: IChatReplyProps) => { return ( 0 || !!request.response.errorDetails?.message} + hasMessage={ + request.response.responseParts.length > 0 || + request.response.responseContents.length > 0 || + !!request.response.errorDetails?.message + } onRegenerate={handleRegenerate} requestId={request.requestId} > diff --git a/packages/ai-native/src/browser/components/ChatThinking.tsx b/packages/ai-native/src/browser/components/ChatThinking.tsx index 93e68a183d..f6773cefde 100644 --- a/packages/ai-native/src/browser/components/ChatThinking.tsx +++ b/packages/ai-native/src/browser/components/ChatThinking.tsx @@ -118,7 +118,7 @@ export const ChatThinkingResult = ({ const isRenderRegenerate = useMemo(() => { if (isUndefined(showRegenerate)) { - return latestRequestId === requestId; + return latestRequestId === requestId && !!requestId; } return !!showRegenerate; diff --git a/packages/ai-native/src/browser/components/chat-history.css b/packages/ai-native/src/browser/components/chat-history.css new file mode 100644 index 0000000000..633a3f0547 --- /dev/null +++ b/packages/ai-native/src/browser/components/chat-history.css @@ -0,0 +1,139 @@ +.dm-chat-history-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + padding: 0 4px 0 12px; + color: var(--editor-foreground); + text-overflow: ellipsis; + white-space: nowrap; + + .dm-chat-history-header-title { + opacity: 0.6; + display: flex; + align-items: center; + overflow: hidden; + span { + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100vw - 80px); + display: inline-block; + white-space: nowrap; + } + } + + .dm-chat-history-header-actions { + display: flex; + align-items: center; + font-size: 14px; + + .dm-chat-history-header-actions-history { + cursor: pointer; + } + + .dm-chat-history-header-actions-new { + margin-left: 2px; + cursor: pointer; + } + + .kt-popover-title { + margin-bottom: 8px; + } + } + + .kt-popover { + .kt-popover-content { + width: 300px; + } + .kt-popover-content { + color: var(--editor-foreground); + background-color: var(--editor-background); + padding: 12px 12px; + border-radius: 3px; + box-shadow: 0 0 8px 2px var(--widget-shadow); + } + .kt-popover-title { + color: var(--editor-foreground); + font-size: 13px; + } + .kt-input { + color: var(--input-foreground); + background: var(--input-background); + border: 1px solid var(--input-border); + text-align: left; + height: 24px; + font-size: 13px; + border-radius: 4px; + &:focus { + border-color: var(--inputValidation-infoBorder); + } + + &::placeholder { + color: var(--input-placeholderForeground); + opacity: 0.6; + } + } + } + + .dm-chat-history-list { + overflow: auto; + max-height: 400px; + margin-top: 4px; + font-size: 13px; + } + + .dm-chat-history-time { + opacity: 0.6; + padding-left: 4px; + } + + .dm-chat-history-item { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + padding: 4px; + margin-top: 2px; + border-radius: 3px; + + .dm-chat-history-item-content { + display: flex; + align-items: center; + width: 100%; + max-width: 100%; + height: 24px; + } + + .dm-chat-history-item-title { + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + white-space: nowrap; + } + + .dm-chat-history-item-actions { + display: none; + } + + .dm-chat-history-item-selected { + background: var(--textPreformat-background); + } + + &:hover { + background: var(--textPreformat-background); + + .dm-chat-history-item-actions { + display: block; + } + .dm-chat-history-item-content { + max-width: calc(100% - 50px); + } + } + } + + svg { + path { + fill: var(--foreground); + } + } +} diff --git a/packages/ai-native/src/browser/model/msg-history-manager.ts b/packages/ai-native/src/browser/model/msg-history-manager.ts index 0c92712e0e..301061cddd 100644 --- a/packages/ai-native/src/browser/model/msg-history-manager.ts +++ b/packages/ai-native/src/browser/model/msg-history-manager.ts @@ -5,7 +5,6 @@ import { IHistoryChatMessage } from '@opensumi/ide-core-common/lib/types/ai-nati type IExcludeMessage = Omit; -@Injectable({ multiple: false }) export class MsgHistoryManager extends Disposable { private messageMap: Map = new Map(); private messageAdditionalMap: Map> = new Map(); @@ -16,6 +15,14 @@ export class MsgHistoryManager extends Disposable { private readonly _onMessageAdditionalChange = new Emitter>(); public readonly onMessageAdditionalChange: Event> = this._onMessageAdditionalChange.event; + constructor(data?: { additional: Record; messages: IHistoryChatMessage[] }) { + super(); + if (data) { + this.messageMap = new Map(data.messages.map((item) => [item.id, item])); + this.messageAdditionalMap = new Map(Object.entries(data.additional)); + } + } + override dispose(): void { this.clearMessages(); super.dispose(); @@ -110,4 +117,11 @@ export class MsgHistoryManager extends Disposable { public getMessageAdditional(id: string): Record { return this.messageAdditionalMap.get(id) || {}; } + + toJSON() { + return { + messages: this.getMessages(), + additional: Object.fromEntries(this.messageAdditionalMap.entries()), + }; + } } diff --git a/packages/core-common/src/storage.ts b/packages/core-common/src/storage.ts index 5f9d281496..b79185c823 100644 --- a/packages/core-common/src/storage.ts +++ b/packages/core-common/src/storage.ts @@ -53,6 +53,7 @@ export const STORAGE_NAMESPACE = { RECENT_DATA: new URI('recent').withScheme(STORAGE_SCHEMA.SCOPE), DEBUG: new URI('debug').withScheme(STORAGE_SCHEMA.SCOPE), OUTLINE: new URI('outline').withScheme(STORAGE_SCHEMA.SCOPE), + CHAT: new URI('chat').withScheme(STORAGE_SCHEMA.SCOPE), // global database GLOBAL_LAYOUT: new URI('layout-global').withScheme(STORAGE_SCHEMA.GLOBAL), GLOBAL_EXTENSIONS: new URI('extensions').withScheme(STORAGE_SCHEMA.GLOBAL), diff --git a/packages/extension/src/hosted/api/vscode/ext.host.language.ts b/packages/extension/src/hosted/api/vscode/ext.host.language.ts index 71a00a5496..cdd8f2c2c7 100644 --- a/packages/extension/src/hosted/api/vscode/ext.host.language.ts +++ b/packages/extension/src/hosted/api/vscode/ext.host.language.ts @@ -698,7 +698,8 @@ export class ExtHostLanguages implements IExtHostLanguages { handle, InlineEditAdapter, async (adapter) => { - adapter.disposeEdit(pid); + // 增加延迟释放,避免在 ESC 退出时,InlineEdit 的 Rejected 命令被提前销毁导致异常 + setTimeout(() => adapter.disposeEdit(pid), 100); }, undefined, undefined, diff --git a/packages/i18n/src/common/en-US.lang.ts b/packages/i18n/src/common/en-US.lang.ts index 5601494cf7..87e2063e8b 100644 --- a/packages/i18n/src/common/en-US.lang.ts +++ b/packages/i18n/src/common/en-US.lang.ts @@ -1484,6 +1484,11 @@ export const localizationBundle = { 'aiNative.operate.stop.title': 'Stop', 'aiNative.operate.close.title': 'Close', 'aiNative.operate.clear.title': 'Clear', + 'aiNative.operate.newChat.title': 'New Chat', + 'aiNative.operate.chatHistory.title': 'Chat History', + 'aiNative.operate.chatHistory.searchPlaceholder': 'Search Chats...', + 'aiNative.operate.chatHistory.edit': 'Edit', + 'aiNative.operate.chatHistory.delete': 'Delete', 'aiNative.chat.welcome.loading.text': 'Initializing...', 'aiNative.chat.ai.assistant.limit.message': '{0} earliest messages are dropped due to the input token limit', diff --git a/packages/i18n/src/common/zh-CN.lang.ts b/packages/i18n/src/common/zh-CN.lang.ts index 047b278917..55d1373793 100644 --- a/packages/i18n/src/common/zh-CN.lang.ts +++ b/packages/i18n/src/common/zh-CN.lang.ts @@ -1252,6 +1252,11 @@ export const localizationBundle = { 'aiNative.operate.stop.title': '停止', 'aiNative.operate.close.title': '关闭', 'aiNative.operate.clear.title': '清空', + 'aiNative.operate.newChat.title': '新建聊天', + 'aiNative.operate.chatHistory.title': '聊天历史', + 'aiNative.operate.chatHistory.searchPlaceholder': '请搜索...', + 'aiNative.operate.chatHistory.edit': '编辑', + 'aiNative.operate.chatHistory.delete': '删除', 'aiNative.chat.welcome.loading.text': '初始化中...', 'aiNative.chat.ai.assistant.limit.message': '{0} 条最早的消息因输入 Tokens 限制而被丢弃',