diff --git a/packages/ai-core/src/common/communication-recording-service.ts b/packages/ai-core/src/common/communication-recording-service.ts index 491d8065173e5..4ae26c47e0e2e 100644 --- a/packages/ai-core/src/common/communication-recording-service.ts +++ b/packages/ai-core/src/common/communication-recording-service.ts @@ -41,4 +41,7 @@ export interface CommunicationRecordingService { readonly onDidRecordResponse: Event; getHistory(agentId: string): CommunicationHistory; + + clearHistory(): void; + readonly onStructuralChange: Event; } diff --git a/packages/ai-history/src/browser/ai-history-contribution.ts b/packages/ai-history/src/browser/ai-history-contribution.ts index f33d71cb6793d..892d112f17208 100644 --- a/packages/ai-history/src/browser/ai-history-contribution.ts +++ b/packages/ai-history/src/browser/ai-history-contribution.ts @@ -13,11 +13,14 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { FrontendApplication } from '@theia/core/lib/browser'; +import { FrontendApplication, Widget } from '@theia/core/lib/browser'; import { AIViewContribution } from '@theia/ai-core/lib/browser'; -import { injectable } from '@theia/core/shared/inversify'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { AIHistoryView } from './ai-history-widget'; -import { Command, CommandRegistry } from '@theia/core'; +import { Command, CommandRegistry, Emitter } from '@theia/core'; +import { codicon } from '@theia/core/lib/browser'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { CommunicationRecordingService } from '@theia/ai-core'; export const AI_HISTORY_TOGGLE_COMMAND_ID = 'aiHistory:toggle'; export const OPEN_AI_HISTORY_VIEW = Command.toLocalizedCommand({ @@ -25,9 +28,28 @@ export const OPEN_AI_HISTORY_VIEW = Command.toLocalizedCommand({ label: 'Open AI History view', }); +export const AI_HISTORY_VIEW_SORT_CHRONOLOGICALLY = Command.toLocalizedCommand({ + id: 'aiHistory:sortChronologically', + label: 'AI History: Sort chronologically', + iconClass: codicon('arrow-down') +}); + +export const AI_HISTORY_VIEW_SORT_REVERSE_CHRONOLOGICALLY = Command.toLocalizedCommand({ + id: 'aiHistory:sortReverseChronologically', + label: 'AI History: Sort reverse chronologically', + iconClass: codicon('arrow-up') +}); + +export const AI_HISTORY_VIEW_CLEAR = Command.toLocalizedCommand({ + id: 'aiHistory:clear', + label: 'AI History: Clear History', + iconClass: codicon('clear-all') +}); + @injectable() -export class AIHistoryViewContribution extends AIViewContribution { - constructor() { +export class AIHistoryViewContribution extends AIViewContribution implements TabBarToolbarContribution { + recordingService: CommunicationRecordingService; + constructor(@inject(CommunicationRecordingService) recordingService: CommunicationRecordingService) { super({ widgetId: AIHistoryView.ID, widgetName: AIHistoryView.LABEL, @@ -37,16 +59,86 @@ export class AIHistoryViewContribution extends AIViewContribution }, toggleCommandId: AI_HISTORY_TOGGLE_COMMAND_ID, }); + this.recordingService = recordingService; } async initializeLayout(_app: FrontendApplication): Promise { await this.openView(); } - override registerCommands(commands: CommandRegistry): void { - super.registerCommands(commands); - commands.registerCommand(OPEN_AI_HISTORY_VIEW, { + override registerCommands(registry: CommandRegistry): void { + super.registerCommands(registry); + registry.registerCommand(OPEN_AI_HISTORY_VIEW, { execute: () => this.openView({ activate: true }), }); + registry.registerCommand(AI_HISTORY_VIEW_SORT_CHRONOLOGICALLY, { + isEnabled: widget => this.withWidget(widget, () => !widget.isChronologial), + isVisible: widget => this.withWidget(widget, () => !widget.isChronologial), + execute: widget => this.withWidget(widget, chatWidget => { + widget.sortHistory(true); + return true; + }) + }); + registry.registerCommand(AI_HISTORY_VIEW_SORT_REVERSE_CHRONOLOGICALLY, { + isEnabled: widget => this.withWidget(widget, () => widget.isChronologial), + isVisible: widget => this.withWidget(widget, () => widget.isChronologial), + execute: widget => this.withWidget(widget, chatWidget => { + widget.sortHistory(false); + return true; + }) + }); + registry.registerCommand(AI_HISTORY_VIEW_CLEAR, { + isEnabled: widget => this.withWidget(widget, () => true), + isVisible: widget => this.withWidget(widget, () => true), + execute: widget => this.withWidget(widget, chatWidget => { + this.clearHistory() + return true; + }) + }); + } + clearHistory() { + this.recordingService.clearHistory(); + } + + protected withWidget( + widget: Widget | undefined = this.tryGetWidget(), + predicate: (output: AIHistoryView) => boolean = () => true + ): boolean | false { + return widget instanceof AIHistoryView ? predicate(widget) : false; + } + + protected readonly onAIHistoryWidgetStateChangedEmitter = new Emitter(); + protected readonly onAIHistoryWidgettStateChanged = this.onAIHistoryWidgetStateChangedEmitter.event; + + @postConstruct() + protected override init(): void { + super.init(); + this.widget.then(widget => { widget.onStateChanged(() => this.onAIHistoryWidgetStateChangedEmitter.fire()) }); + } + + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: AI_HISTORY_VIEW_SORT_CHRONOLOGICALLY.id, + command: AI_HISTORY_VIEW_SORT_CHRONOLOGICALLY.id, + tooltip: 'Sort chronologically', + isVisible: widget => this.isHistoryViewWidget(widget), + onDidChange: this.onAIHistoryWidgettStateChanged + }); + registry.registerItem({ + id: AI_HISTORY_VIEW_SORT_REVERSE_CHRONOLOGICALLY.id, + command: AI_HISTORY_VIEW_SORT_REVERSE_CHRONOLOGICALLY.id, + tooltip: 'Sort reverse chronologically', + isVisible: widget => this.isHistoryViewWidget(widget), + onDidChange: this.onAIHistoryWidgettStateChanged + }); + registry.registerItem({ + id: AI_HISTORY_VIEW_CLEAR.id, + command: AI_HISTORY_VIEW_CLEAR.id, + tooltip: 'Clear History of all agents', + isVisible: widget => this.isHistoryViewWidget(widget) + }); + } + protected isHistoryViewWidget(widget?: Widget): boolean { + return !!widget && AIHistoryView.ID === widget.id; } } diff --git a/packages/ai-history/src/browser/ai-history-frontend-module.ts b/packages/ai-history/src/browser/ai-history-frontend-module.ts index 021fc013cabdd..460dc9a6a06a6 100644 --- a/packages/ai-history/src/browser/ai-history-frontend-module.ts +++ b/packages/ai-history/src/browser/ai-history-frontend-module.ts @@ -21,6 +21,7 @@ import { ILogger } from '@theia/core'; import { AIHistoryViewContribution } from './ai-history-contribution'; import { AIHistoryView } from './ai-history-widget'; import '../../src/browser/style/ai-history.css'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; export default new ContainerModule(bind => { bind(DefaultCommunicationRecordingService).toSelf().inSingletonScope(); @@ -38,4 +39,6 @@ export default new ContainerModule(bind => { id: AIHistoryView.ID, createWidget: () => context.container.get(AIHistoryView) })).inSingletonScope(); + bind(TabBarToolbarContribution).toService(AIHistoryViewContribution); + }); diff --git a/packages/ai-history/src/browser/ai-history-widget.tsx b/packages/ai-history/src/browser/ai-history-widget.tsx index 28277426f31a1..4f0bf24a68465 100644 --- a/packages/ai-history/src/browser/ai-history-widget.tsx +++ b/packages/ai-history/src/browser/ai-history-widget.tsx @@ -14,14 +14,22 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { Agent, AgentService, CommunicationRecordingService, CommunicationRequestEntry, CommunicationResponseEntry } from '@theia/ai-core'; -import { codicon, ReactWidget } from '@theia/core/lib/browser'; +import { codicon, ReactWidget, StatefulWidget } from '@theia/core/lib/browser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; import { CommunicationCard } from './ai-history-communication-card'; import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component'; +import { deepClone, Emitter, Event } from '@theia/core'; + +export namespace AIHistoryView { + export interface State { + chronological: boolean; + } +} + @injectable() -export class AIHistoryView extends ReactWidget { +export class AIHistoryView extends ReactWidget implements StatefulWidget { @inject(CommunicationRecordingService) protected recordingService: CommunicationRecordingService; @inject(AgentService) @@ -32,6 +40,11 @@ export class AIHistoryView extends ReactWidget { protected selectedAgent?: Agent; + + protected _state: AIHistoryView.State = { chronological: false } + protected readonly onStateChangedEmitter = new Emitter(); + + constructor() { super(); this.id = AIHistoryView.ID; @@ -41,11 +54,39 @@ export class AIHistoryView extends ReactWidget { this.title.iconClass = codicon('history'); } + + protected get state(): AIHistoryView.State { + return this._state; + } + + protected set state(state: AIHistoryView.State) { + this._state = state; + this.onStateChangedEmitter.fire(this._state); + } + + get onStateChanged(): Event { + return this.onStateChangedEmitter.event; + } + + storeState(): object { + return this.state; + } + + restoreState(oldState: object & Partial): void { + const copy = deepClone(this.state); + if (oldState.chronological) { + copy.chronological = oldState.chronological; + } + this.state = copy; + } + @postConstruct() protected init(): void { this.update(); this.toDispose.push(this.recordingService.onDidRecordRequest(entry => this.historyContentUpdated(entry))); this.toDispose.push(this.recordingService.onDidRecordResponse(entry => this.historyContentUpdated(entry))); + this.toDispose.push(this.recordingService.onStructuralChange(() => this.update())); + this.toDispose.push(this.onStateChanged(newState => this.update())); this.selectAgent(this.agentService.getAllAgents()[0]); } @@ -82,10 +123,13 @@ export class AIHistoryView extends ReactWidget { if (!this.selectedAgent) { return
No agent selected.
; } - const history = this.recordingService.getHistory(this.selectedAgent.id); + const history = [...this.recordingService.getHistory(this.selectedAgent.id)]; if (history.length === 0) { return
No history available for the selected agent '{this.selectedAgent.name}'.
; } + if (!this.state.chronological) { + history.reverse(); + } return history.map(entry => ); } @@ -93,4 +137,12 @@ export class AIHistoryView extends ReactWidget { e.stopPropagation(); this.selectAgent(agent); } + + public sortHistory(chronological: boolean) { + this.state = { ...deepClone(this.state), chronological: chronological }; + } + + get isChronologial(): boolean { + return !!this.state.chronological; + } } diff --git a/packages/ai-history/src/common/communication-recording-service.ts b/packages/ai-history/src/common/communication-recording-service.ts index 9d23a6766064e..fe1e1ccc2620b 100644 --- a/packages/ai-history/src/common/communication-recording-service.ts +++ b/packages/ai-history/src/common/communication-recording-service.ts @@ -20,6 +20,7 @@ import { inject, injectable, named } from '@theia/core/shared/inversify'; @injectable() export class DefaultCommunicationRecordingService implements CommunicationRecordingService { + @inject(ILogger) @named('llm-communication-recorder') protected logger: ILogger; @@ -29,6 +30,9 @@ export class DefaultCommunicationRecordingService implements CommunicationRecord protected onDidRecordResponseEmitter = new Emitter(); readonly onDidRecordResponse: Event = this.onDidRecordResponseEmitter.event; + protected onStructuralChangeEmitter = new Emitter(); + readonly onStructuralChange: Event = this.onStructuralChangeEmitter.event; + protected history: Map = new Map(); getHistory(agentId: string): CommunicationHistory { @@ -60,4 +64,9 @@ export class DefaultCommunicationRecordingService implements CommunicationRecord } } } + + clearHistory(): void { + this.history.clear(); + this.onStructuralChangeEmitter.fire(undefined); + } }