Path: blob/main/src/vs/workbench/contrib/editTelemetry/browser/editStats/aiStatsFeature.ts
5263 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { sumBy } from '../../../../../base/common/arrays.js';6import { TaskQueue, timeout } from '../../../../../base/common/async.js';7import { Lazy } from '../../../../../base/common/lazy.js';8import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';9import { autorun, derived, mapObservableArrayCached, observableValue, runOnChange } from '../../../../../base/common/observable.js';10import { AnnotatedStringEdit } from '../../../../../editor/common/core/edits/stringEdit.js';11import { isAiEdit, isUserEdit } from '../../../../../editor/common/textModelEditSource.js';12import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';13import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';14import { AnnotatedDocuments } from '../helpers/annotatedDocuments.js';15import { AiStatsStatusBar } from './aiStatsStatusBar.js';1617export class AiStatsFeature extends Disposable {18private readonly _data: IValue<IData>;19private readonly _dataVersion = observableValue(this, 0);2021constructor(22annotatedDocuments: AnnotatedDocuments,23@IStorageService private readonly _storageService: IStorageService,24@IInstantiationService private readonly _instantiationService: IInstantiationService,25) {26super();2728const storedValue = getStoredValue<IData>(this._storageService, 'aiStats', StorageScope.WORKSPACE, StorageTarget.USER);29this._data = rateLimitWrite<IData>(storedValue, 1 / 60, this._store);3031this.aiRate.recomputeInitiallyAndOnChange(this._store);3233this._register(autorun(reader => {34reader.store.add(this._instantiationService.createInstance(AiStatsStatusBar.hot.read(reader), this));35}));363738const lastRequestIds: string[] = [];3940const obs = mapObservableArrayCached(this, annotatedDocuments.documents, (doc, store) => {41store.add(runOnChange(doc.documentWithAnnotations.value, (_val, _prev, edit) => {42const e = AnnotatedStringEdit.compose(edit.map(e => e.edit));4344const curSession = new Lazy(() => this._getDataAndSession());4546for (const r of e.replacements) {47if (isAiEdit(r.data.editSource)) {48curSession.value.currentSession.aiCharacters += r.newText.length;49} else if (isUserEdit(r.data.editSource)) {50curSession.value.currentSession.typedCharacters += r.newText.length;51}52}5354if (e.replacements.length > 0) {55const sessionToUpdate = curSession.value.currentSession;56const s = e.replacements[0].data.editSource;57if (s.metadata.source === 'inlineCompletionAccept') {58if (sessionToUpdate.acceptedInlineSuggestions === undefined) {59sessionToUpdate.acceptedInlineSuggestions = 0;60}61sessionToUpdate.acceptedInlineSuggestions += 1;62}6364if (s.metadata.source === 'Chat.applyEdits' && s.metadata.$$requestId !== undefined) {65const didSeeRequestId = lastRequestIds.includes(s.metadata.$$requestId);66if (!didSeeRequestId) {67lastRequestIds.push(s.metadata.$$requestId);68if (lastRequestIds.length > 10) {69lastRequestIds.shift();70}71if (sessionToUpdate.chatEditCount === undefined) {72sessionToUpdate.chatEditCount = 0;73}74sessionToUpdate.chatEditCount += 1;75}76}77}7879if (curSession.hasValue) {80this._data.writeValue(curSession.value.data);81this._dataVersion.set(this._dataVersion.get() + 1, undefined);82}83}));84});8586obs.recomputeInitiallyAndOnChange(this._store);87}8889public readonly aiRate = this._dataVersion.map(() => {90const val = this._data.getValue();91if (!val) {92return 0;93}9495const r = average(val.sessions, session => {96const sum = session.typedCharacters + session.aiCharacters;97if (sum === 0) {98return 0;99}100return session.aiCharacters / sum;101});102103return r;104});105106public readonly sessionCount = derived(this, r => {107this._dataVersion.read(r);108const val = this._data.getValue();109if (!val) {110return 0;111}112return val.sessions.length;113});114115public readonly sessions = derived(this, r => {116this._dataVersion.read(r);117const val = this._data.getValue();118if (!val) {119return [];120}121return val.sessions;122});123124public readonly acceptedInlineSuggestionsToday = derived(this, r => {125this._dataVersion.read(r);126const val = this._data.getValue();127if (!val) {128return 0;129}130const startOfToday = new Date();131startOfToday.setHours(0, 0, 0, 0);132133const sessionsToday = val.sessions.filter(s => s.startTime > startOfToday.getTime());134return sumBy(sessionsToday, s => s.acceptedInlineSuggestions ?? 0);135});136137private _getDataAndSession(): { data: IData; currentSession: ISession } {138const state = this._data.getValue() ?? { sessions: [] };139140const sessionLengthMs = 5 * 60 * 1000; // 5 minutes141142let lastSession = state.sessions.at(-1);143const nowTime = Date.now();144if (!lastSession || nowTime - lastSession.startTime > sessionLengthMs) {145state.sessions.push({146startTime: nowTime,147typedCharacters: 0,148aiCharacters: 0,149acceptedInlineSuggestions: 0,150chatEditCount: 0,151});152lastSession = state.sessions.at(-1)!;153154const dayMs = 24 * 60 * 60 * 1000; // 24h155// Clean up old sessions, keep only the last 24h worth of sessions156while (state.sessions.length > dayMs / sessionLengthMs) {157state.sessions.shift();158}159}160return { data: state, currentSession: lastSession };161}162}163164interface IData {165sessions: ISession[];166}167168// 5 min window169interface ISession {170startTime: number;171typedCharacters: number;172aiCharacters: number;173acceptedInlineSuggestions: number | undefined;174chatEditCount: number | undefined;175}176177178function average<T>(arr: T[], selector: (item: T) => number): number {179if (arr.length === 0) {180return 0;181}182const s = sumBy(arr, selector);183return s / arr.length;184}185186187interface IValue<T> {188writeValue(value: T | undefined): void;189getValue(): T | undefined;190}191192function rateLimitWrite<T>(targetValue: IValue<T>, maxWritesPerSecond: number, store: DisposableStore): IValue<T> {193const queue = new TaskQueue();194let _value: T | undefined = undefined;195let valueVersion = 0;196let savedVersion = 0;197store.add(toDisposable(() => {198if (valueVersion !== savedVersion) {199targetValue.writeValue(_value);200savedVersion = valueVersion;201}202}));203204return {205writeValue(value: T | undefined): void {206valueVersion++;207const v = valueVersion;208_value = value;209210queue.clearPending();211queue.schedule(async () => {212targetValue.writeValue(value);213savedVersion = v;214await timeout(5000);215});216},217getValue(): T | undefined {218if (valueVersion > 0) {219return _value;220}221return targetValue.getValue();222}223};224}225226function getStoredValue<T>(service: IStorageService, key: string, scope: StorageScope, target: StorageTarget): IValue<T> {227let lastValue: T | undefined = undefined;228let hasLastValue = false;229return {230writeValue(value: T | undefined): void {231if (value === undefined) {232service.remove(key, scope);233} else {234service.store(key, JSON.stringify(value), scope, target);235}236lastValue = value;237},238getValue(): T | undefined {239if (hasLastValue) {240return lastValue;241}242const strVal = service.get(key, scope);243lastValue = strVal === undefined ? undefined : JSON.parse(strVal) as T | undefined;244hasLastValue = true;245return lastValue;246}247};248}249250251