diff --git a/package-lock.json b/package-lock.json index 02a632659..f71737de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "katex": "^0.16.21", "marked": "^14.1.3", "minimist": "^1.2.8", - "openai": "^4.28.0", + "openai": "^4.80.1", "prosemirror-history": "^1.4.1", "prosemirror-model": "^1.18.1", "prosemirror-state": "^1.4.2", diff --git a/package.json b/package.json index 95a2539af..a8ccd321b 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "katex": "^0.16.21", "marked": "^14.1.3", "minimist": "^1.2.8", - "openai": "^4.28.0", + "openai": "^4.80.1", "prosemirror-history": "^1.4.1", "prosemirror-model": "^1.18.1", "prosemirror-state": "^1.4.2", diff --git a/src/base/common/error.ts b/src/base/common/error.ts index e9391130b..b063c63a5 100644 --- a/src/base/common/error.ts +++ b/src/base/common/error.ts @@ -2,7 +2,7 @@ import { IDisposable, toDisposable } from "src/base/common/dispose"; import { Result, err, ok } from "src/base/common/result"; import { mixin } from "src/base/common/utilities/object"; import { errorToMessage, panic } from "src/base/common/utilities/panic"; -import { isPromise } from "src/base/common/utilities/type"; +import { isObject, isPromise, isString } from "src/base/common/utilities/type"; type IErrorCallback = (error: any) => void; type IErrorListener = IErrorCallback; @@ -319,3 +319,172 @@ export class Stacktrace { return this; } } + +/** + * A standardized Error for LLM operations, encapsulating both + * provider-specific errors (e.g. OpenAI) and internal errors. + */ +export class AIError extends Error { + + // [fields] + + /** Indicates the name of the LLM model caused the error */ + public readonly modelName: string | null; + + /** Error category for quick classification */ + public readonly errorCategory: + | 'API' + | 'Network' + | 'Auth' + | 'Client' + | 'Internal' + | 'Unknown'; + + /** HTTP status code (if applicable) */ + public readonly status?: number; + + /** HTTP headers from the response (if applicable) */ + public readonly headers?: Record; + + /** Original error object (if available) */ + public readonly internal?: unknown; + + /** Provider-specific error code (e.g. 'invalid_api_key') */ + public readonly code?: string; + + /** Request ID from response headers (if available) */ + public readonly requestId?: string; + + // [constructor] + + constructor(modelName: string | null, rawError: unknown) { + const { message, metadata } = AIError.__parseRawError(rawError); + super(message); + + this.modelName = modelName; + this.errorCategory = metadata.category; + this.internal = metadata.internal; + + // API Error metadata + this.status = metadata.status; + this.headers = metadata.headers; + this.code = metadata.code; + this.requestId = metadata.headers?.['x-request-id']; + } + + // [static methods] + + private static __parseRawError(raw: unknown): { + message: string; + metadata: __ErrorParseMetadata; + } { + + // raw string, simple return + if (isString(raw)) { + return { + message: raw, + metadata: { category: 'Unknown' } + }; + } + + // non object, no can do here. + if (!isObject(raw)) { + return { + message: 'Unknown non-object error', + metadata: { + category: 'Unknown', + internal: raw + } + }; + } + + // default metadata + const metadata: __ErrorParseMetadata = { + category: 'Unknown', + status: undefined, + headers: undefined, + code: undefined, + internal: raw + }; + + // obtain metadata + const status = Number(raw['status']) ?? undefined; + const headers = raw['headers']; + const code = this.__safeGetCode(raw); + const message = this.__getHumanMessage(raw, status, code); + + // clean up + metadata.status = status; + metadata.headers = headers; + metadata.code = code; + metadata.category = this.__determineCategory(raw, status, code); + + return { message, metadata }; + } + + private static __determineCategory( + raw: object, + status?: number, + code?: string + ): AIError['errorCategory'] { + + // Network errors (timeout, connection issues) + if (raw instanceof Error && [ + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENOTFOUND' + ].some(code => code === raw['code']) + ) { + return 'Network'; + } + + // API errors classification + if (status) { + if (status === 401) return 'Auth'; + if (status === 429) return 'API'; + if (status >= 400 && status < 500) return 'Client'; + if (status >= 500) return 'Internal'; + } + + // OpenAI-style error codes + if (code) { + if (code.includes('invalid_api')) return 'Auth'; + if (code.includes('rate_limit')) return 'API'; + } + + return 'Unknown'; + } + + private static __getHumanMessage( + raw: object, + status?: number, + code?: string + ): string { + const baseMessage = raw['message'] || 'Unknown error'; + const parts: string[] = []; + + if (status) parts.push(`Status: ${status}`); + if (code) parts.push(`Code: ${code}`); + + return parts.length > 0 + ? `${baseMessage} (${parts.join(', ')})` + : baseMessage; + } + + private static __safeGetCode(raw: object): string | undefined { + const codeSources = [ + (raw).code, + (raw).error?.code, + (raw).body?.error?.code + ]; + return codeSources.find(v => isString(v)); + } +} + +type __ErrorParseMetadata = { + category: AIError['errorCategory']; + status?: number; + headers?: Record; + code?: string; + internal?: unknown; +}; \ No newline at end of file diff --git a/src/base/common/json.ts b/src/base/common/json.ts index f8a800a50..b6c8970fa 100644 --- a/src/base/common/json.ts +++ b/src/base/common/json.ts @@ -145,7 +145,7 @@ interface IJsonSchemaForString extends IJsonSchemaBase<'string'> { /** The maximum length of the string. */ maxLength?: number; - /** The predefined format of the string. Example: 'email', 'phone number', 'post address' etc. */ + /** The predefined format of the string (written Regular Expression). Example: 'email', 'phone number', 'post address' etc. */ format?: string; /** Regular expression to match the valid string. */ diff --git a/src/base/common/utilities/panic.ts b/src/base/common/utilities/panic.ts index 3ce657a7c..754ad45e6 100644 --- a/src/base/common/utilities/panic.ts +++ b/src/base/common/utilities/panic.ts @@ -1,20 +1,12 @@ +import { IpcErrorTag } from "src/base/common/error"; import type { ArrayToUnion } from "src/base/common/utilities/type"; /** * To prevent potential circular dependency issues due to the wide use of `panic` - * throughout the program, this function has been relocated to a separate file + * throughout the program, these functions has been relocated to a separate file * that does not import any other files. */ -function __stringifySafe(obj: unknown): string { - try { - // eslint-disable-next-line local/code-no-json-stringify - return JSON.stringify(obj); - } catch (err) { - return ''; - } -} - /** * @description Panics the program by throwing an error with the provided message. * @@ -31,7 +23,7 @@ export function panic(error: unknown): never { throw new Error('unknown panic error'); } - if (error instanceof Error) { + if (isError(error)) { // eslint-disable-next-line local/code-no-throw throw error; } @@ -40,6 +32,29 @@ export function panic(error: unknown): never { throw new Error(errorToMessage(error)); } +/** + * @description An ipc-safe function to check the given object is {@link Error} + * or not. + */ +export function isError(obj: any): obj is Error { + if (obj instanceof Error) { + return true; + } + + // not even object + if (typeof obj !== "object" || obj === null) { + return false; + } + + // check for standard errors + if (typeof obj['name'] === 'string' && typeof obj['message'] === 'string') { + return true; + } + + // check for IPC-converted errors + return obj?.[IpcErrorTag] === null; +} + /** * @description Asserts that the provided object is neither `undefined` nor * `null`. @@ -223,3 +238,12 @@ function __stackToMessage(stack: any): string { return stack; } } + +function __stringifySafe(obj: unknown): string { + try { + // eslint-disable-next-line local/code-no-json-stringify + return JSON.stringify(obj); + } catch (err) { + return ''; + } +} \ No newline at end of file diff --git a/src/base/common/utilities/type.ts b/src/base/common/utilities/type.ts index b0f634ff0..d221f27e0 100644 --- a/src/base/common/utilities/type.ts +++ b/src/base/common/utilities/type.ts @@ -1,3 +1,4 @@ +import type { Register } from "src/base/common/event"; /** * Represents all the falsy value in JavaScript. @@ -445,6 +446,21 @@ export type Promisify = { : T[K] }; +/** + * Ensure all functions in an object all return {@link Promise} (event register + * are ignored). + */ +export type AsyncOnly = { + [K in keyof T]: + T[K] extends Register + ? T[K] // keep event-register + : T[K] extends (...args: any[]) => infer R + ? R extends Promise + ? T[K] // only allow async function + : never // no sync function + : T[K]; // non-functional remain the same + }; + /** * Split string into a tuple by a deliminator. */ diff --git a/src/code/browser/browser.ts b/src/code/browser/browser.ts index 3d87ff8ce..66efb2fc0 100644 --- a/src/code/browser/browser.ts +++ b/src/code/browser/browser.ts @@ -70,7 +70,7 @@ export class BrowserInstance extends Disposable implements IBrowserService { // alert error from main process onMainProcess(IpcChannel.rendererAlertError, error => { - ErrorHandler.onUnexpectedError(error); + this.commandService.executeCommand(AllCommands.alertError, 'MainProcess', error); }); // execute command request from main process diff --git a/src/code/browser/inspector/inspectorItemRenderer.ts b/src/code/browser/inspector/inspectorItemRenderer.ts index 1ec7d1d1c..8f0ad29af 100644 --- a/src/code/browser/inspector/inspectorItemRenderer.ts +++ b/src/code/browser/inspector/inspectorItemRenderer.ts @@ -9,6 +9,10 @@ import { FuzzyScore } from "src/base/common/fuzzy"; import { isBoolean, isNullable, isNumber, isString } from "src/base/common/utilities/type"; import { InspectorItem } from "src/code/browser/inspector/inspectorTree"; import { IConfigurationService, ConfigurationModuleType } from "src/platform/configuration/common/configuration"; +import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; +import { IHostService } from "src/platform/host/common/hostService"; +import { InspectorDataType } from "src/platform/inspector/common/inspector"; +import { StatusKey } from "src/platform/status/common/status"; interface IInspectorItemMetadata extends IListViewMetadata { readonly keyElement: HTMLElement; @@ -21,7 +25,10 @@ export class InspectorItemRenderer implements ITreeListRenderer InspectorDataType | undefined, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IHostService private readonly hostService: IHostService, + @IEncryptionService private readonly encryptionService: IEncryptionService, ) {} public render(element: HTMLElement): IInspectorItemMetadata { @@ -63,7 +70,7 @@ export class InspectorItemRenderer implements ITreeListRenderer { + valuePart.addEventListener('change', async e => { const raw = valuePart.value; const rawLower = raw.toLowerCase(); let value: any; @@ -91,7 +98,18 @@ export class InspectorItemRenderer implements ITreeListRenderer { @@ -12,14 +12,15 @@ export class InspectorTree extends MultiTree { constructor( container: HTMLElement, data: InspectorData[], - configurationService: IConfigurationService, + getCurrentView: () => InspectorDataType | undefined, + @IInstantiationService instantiationservice: IInstantiationService, ) { const rootItem = new InspectorItem('$_root_', undefined, 'object'); const initData = transformDataToTree(data); super( container, rootItem, - [new InspectorItemRenderer(configurationService)], + [instantiationservice.createInstance(InspectorItemRenderer, getCurrentView)], new InspectorItemProvider(), { collapsedByDefault: false, diff --git a/src/code/browser/inspector/renderer.inspector.ts b/src/code/browser/inspector/renderer.inspector.ts index 104955114..4528727d5 100644 --- a/src/code/browser/inspector/renderer.inspector.ts +++ b/src/code/browser/inspector/renderer.inspector.ts @@ -13,6 +13,7 @@ import { BrowserConfigurationService } from "src/platform/configuration/browser/ import { APP_CONFIG_NAME, IConfigurationService } from "src/platform/configuration/common/configuration"; import { ConfigurationRegistrant } from "src/platform/configuration/common/configurationRegistrant"; import { initExposedElectronAPIs, ipcRenderer, safeIpcRendererOn, WIN_CONFIGURATION } from "src/platform/electron/browser/global"; +import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; import { BrowserEnvironmentService } from "src/platform/environment/browser/browserEnvironmentService"; import { IBrowserEnvironmentService, ApplicationMode } from "src/platform/environment/common/environment"; import { BrowserFileChannel } from "src/platform/files/browser/fileChannel"; @@ -137,6 +138,10 @@ new class InspectorRenderer { }); instantiationService.store(IConfigurationService, configurationService); + // encryption-service + const encryptionService = ProxyChannel.unwrapChannel(ipcService.getChannel(IpcChannel.Encryption)); + instantiationService.store(IEncryptionService, encryptionService); + return instantiationService; } @@ -208,11 +213,13 @@ class InspectorWindow { private readonly _inspectorViewContainer: HTMLElement; private _tree?: InspectorTree; + private _currView?: InspectorDataType; + // [constructor] constructor( parent: HTMLElement, - @IConfigurationService private readonly configurationService: IConfigurationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { this._parent = parent; @@ -232,7 +239,11 @@ class InspectorWindow { this._tree.dispose(); this._tree = undefined; } - this._tree = new InspectorTree(this._inspectorViewContainer, data, this.configurationService); + this._tree = this.instantiationService.createInstance(InspectorTree, + this._inspectorViewContainer, + data, + () => this._currView, + ); } public layout(): void { @@ -270,6 +281,7 @@ class InspectorWindow { currButton = button; button.element.classList.toggle('focused'); this.__beginListening(type); + this._currView = type; }); } diff --git a/src/code/browser/renderer.desktop.ts b/src/code/browser/renderer.desktop.ts index 66c370fb0..998c72aed 100644 --- a/src/code/browser/renderer.desktop.ts +++ b/src/code/browser/renderer.desktop.ts @@ -68,6 +68,9 @@ import { I18nService, II18nService } from "src/platform/i18n/browser/i18nService import { IRecentOpenService, RecentOpenService } from "src/platform/app/browser/recentOpenService"; import { EditorPaneRegistrant } from "src/workbench/services/editorPane/editorPaneRegistrant"; import { INotificationService } from "src/workbench/services/notification/notification"; +import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; +import { BrowserAITextChannel } from "src/platform/ai/common/aiTextChannel"; +import { IAITextService } from "src/platform/ai/common/aiText"; /** * @class This is the main entry of the renderer process. @@ -209,6 +212,14 @@ const renderer = new class extends class RendererInstance extends Disposable { ); instantiationService.store(II18nService, i18nService); + // encryption-service + const encryptionService = ProxyChannel.unwrapChannel(ipcService.getChannel(IpcChannel.Encryption)); + instantiationService.store(IEncryptionService, encryptionService); + + // ai-text-service + const aiTextService = new BrowserAITextChannel(ipcService); + instantiationService.store(IAITextService, aiTextService); + // singleton initializations logService.debug('renderer', 'Registering singleton services descriptors...'); for (const [serviceIdentifier, serviceDescriptor] of getSingletonServiceDescriptors()) { diff --git a/src/code/electron/app.ts b/src/code/electron/app.ts index b9be593f8..6917058d1 100644 --- a/src/code/electron/app.ts +++ b/src/code/electron/app.ts @@ -37,6 +37,11 @@ import { MainInspectorService } from "src/platform/inspector/electron/mainInspec import { IMainInspectorService } from "src/platform/inspector/common/inspector"; import { IS_MAC } from "src/base/common/platform"; import { RecentOpenUtility } from "src/platform/app/common/recentOpen"; +import { IAITextService } from "src/platform/ai/common/aiText"; +import { MainAITextService } from "src/platform/ai/electron/mainAITextService"; +import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; +import { MainEncryptionService } from "src/platform/encryption/electron/mainEncryptionService"; +import { MainAITextChannel } from "src/platform/ai/common/aiTextChannel"; /** * An interface only for {@link ApplicationInstance} @@ -141,6 +146,12 @@ export class ApplicationInstance extends Disposable implements IApplicationInsta // main-inspector-service this.mainInstantiationService.store(IMainInspectorService, new ServiceDescriptor(MainInspectorService, [])); + // main-encryption-service + this.mainInstantiationService.store(IEncryptionService, new ServiceDescriptor(MainEncryptionService, [])); + + // ai-text-service + this.mainInstantiationService.store(IAITextService, new ServiceDescriptor(MainAITextService, [])); + this.logService.debug('App', 'Application services constructed.'); return this.mainInstantiationService; } @@ -167,8 +178,15 @@ export class ApplicationInstance extends Disposable implements IApplicationInsta const dialogChannel = ProxyChannel.wrapService(dialogService); server.registerChannel(IpcChannel.Dialog, dialogChannel); - // ai-service-channel + // encryption-service-channel + const encryptionService = provider.getOrCreateService(IEncryptionService); + const encryptionChannel = ProxyChannel.wrapService(encryptionService); + server.registerChannel(IpcChannel.Encryption, encryptionChannel); + // ai-service-channel + const aiTextService = provider.getOrCreateService(IAITextService); + const aiTextChannel = new MainAITextChannel(aiTextService); + server.registerChannel(IpcChannel.AIText, aiTextChannel); this.logService.debug('App', 'IPC channels registered successfully.'); } diff --git a/src/code/electron/main.ts b/src/code/electron/main.ts index a4c9d800e..91cc8b9a3 100644 --- a/src/code/electron/main.ts +++ b/src/code/electron/main.ts @@ -34,6 +34,7 @@ import { DiagnosticsService } from 'src/platform/diagnostics/electron/diagnostic import { IDiagnosticsService } from 'src/platform/diagnostics/common/diagnostics'; import { toBoolean } from 'src/base/common/utilities/type'; import { Disposable, monitorDisposableLeak } from 'src/base/common/dispose'; +import { AIModelRegistrant } from 'src/platform/ai/electron/aiModelRegistrant'; interface IMainProcess { start(argv: ICLIArguments): Promise; @@ -254,7 +255,7 @@ const main = new class extends class MainProcess extends Disposable implements I */ registrant.registerRegistrant(service.createInstance(ConfigurationRegistrant)); registrant.registerRegistrant(service.createInstance(ReviverRegistrant)); - + registrant.registerRegistrant(service.createInstance(AIModelRegistrant)); registrant.init(service); } diff --git a/src/platform/ai/common/ai.ts b/src/platform/ai/common/ai.ts new file mode 100644 index 000000000..a47b4bf9d --- /dev/null +++ b/src/platform/ai/common/ai.ts @@ -0,0 +1,33 @@ +import { Disposable } from "src/base/common/dispose"; +import * as AIText from "src/platform/ai/common/aiText"; + +export namespace AI { + + /** + * A name list of popular LLM models. + */ + export const enum ModelName { + ChatGPT = 'ChatGPT', + DeepSeek = 'DeepSeek', + } + + /** + * A name list of different modalities. + */ + export const enum Modality { + Text = 'text', + Voice = 'voice', + Image = 'image', + Video = 'video', + } + + export interface ILLMModel extends Disposable { + readonly modality: AI.Modality; + readonly name: AI.ModelName; + + readonly apiKey: string; + setAPIKey(newKey: string): void; + } + + export import Text = AIText.AIText; +} diff --git a/src/platform/ai/common/aiModel.register.ts b/src/platform/ai/common/aiModel.register.ts new file mode 100644 index 000000000..41473c3aa --- /dev/null +++ b/src/platform/ai/common/aiModel.register.ts @@ -0,0 +1,23 @@ +import { AI } from "src/platform/ai/common/ai"; +import { TextDeepSeekModel } from "src/platform/ai/electron/deepSeekModel"; +import { TextGPTModel } from "src/platform/ai/electron/GPTModel"; +import { createRegister, RegistrantType } from "src/platform/registrant/common/registrant"; + +export const textModelRegister = createRegister( + RegistrantType.AIModel, + 'textModelRegister', + registrant => { + // ChatGPT + registrant.registerModel( + AI.Modality.Text, + AI.ModelName.ChatGPT, + TextGPTModel, + ); + // DeepSeek + registrant.registerModel( + AI.Modality.Text, + AI.ModelName.DeepSeek, + TextDeepSeekModel, + ); + } +); \ No newline at end of file diff --git a/src/platform/ai/common/aiText.ts b/src/platform/ai/common/aiText.ts new file mode 100644 index 000000000..dffc7f00c --- /dev/null +++ b/src/platform/ai/common/aiText.ts @@ -0,0 +1,102 @@ +/* eslint-disable local/code-interface-check */ +import type * as OpenAI from "openai"; +import type { AI } from "src/platform/ai/common/ai"; +import { Disposable } from "src/base/common/dispose"; +import { createService, IService } from "src/platform/instantiation/common/decorator"; +import { nullable } from "src/base/common/utilities/type"; +import { AsyncResult } from "src/base/common/result"; +import { AIError } from "src/base/common/error"; + +/** + * // TODO: doc + */ +export namespace AIText { + + /** + * // TODO + */ + export type ModelIDs = + | OpenAI.OpenAI.ChatModel + | 'deepseek-chat' + | 'deepseek-reasoner'; + + /** + * An option for constructing a {@link AI.Text.Model}. + */ + export interface IModelOptions extends OpenAI.ClientOptions { + /** + * Indicates the name of constructing text model. + */ + readonly name: AI.ModelName; + + /** + * The private key to connect to the server. + */ + readonly apiKey: string; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * @default 2 + */ + maxRetries?: number; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * Note that request timeouts are retried by default, so in a worst-case scenario you may wait + * much longer than this timeout before the promise succeeds or fails. + */ + timeout?: number; + } + + /** + * The actual data model for handling text communication with LLM. + */ + export interface Model extends AI.ILLMModel { + readonly modality: AI.Modality.Text; + sendRequest(options: OpenAI.OpenAI.ChatCompletionCreateParamsNonStreaming): AsyncResult; + sendRequestStream(options: OpenAI.OpenAI.ChatCompletionCreateParamsStreaming, onChunkReceived: (chunk: AI.Text.Response) => void): AsyncResult; + } + + /** + * A standardized object of text response from LLM. + */ + export interface Response { + /** + * The primary or dominant message from the first choice. + */ + readonly primaryMessage: AI.Text.SingleMessage; + + /** + * Messages from the other choices provided by the API. + */ + readonly alternativeMessages?: AI.Text.SingleMessage[]; + readonly id: string; + readonly model: string; + readonly usage?: OpenAI.OpenAI.CompletionUsage; + } + + export type SingleMessageRole = 'system' | 'user' | 'assistant' | 'tool'; + export type SingleMessageFinishReason = 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call' | null; + export interface SingleMessage { + readonly role?: AI.Text.SingleMessageRole; + readonly finishReason: AI.Text.SingleMessageFinishReason; + readonly content: string | nullable; + + /** + * Supported in DeepSeek-reasoner. + */ + readonly reasoning_content: string | nullable; + } +} + +export const IAITextService = createService('ai-text-service'); +export interface IAITextService extends Disposable, IService { + init(): Promise; + switchModel(options: AI.Text.IModelOptions): Promise; + updateAPIKey(newKey: string, name: AI.ModelName | null, persisted?: boolean): Promise; + sendRequest(options: OpenAI.OpenAI.ChatCompletionCreateParamsNonStreaming): AsyncResult; + sendRequestStream(options: OpenAI.OpenAI.ChatCompletionCreateParamsStreaming, onChunkReceived: (chunk: AI.Text.Response) => void): AsyncResult; +} diff --git a/src/platform/ai/common/aiTextChannel.ts b/src/platform/ai/common/aiTextChannel.ts new file mode 100644 index 000000000..8b9069394 --- /dev/null +++ b/src/platform/ai/common/aiTextChannel.ts @@ -0,0 +1,136 @@ +import type * as OpenAI from "openai"; +import type { IAITextService } from "src/platform/ai/common/aiText"; +import { IpcChannel, type IChannel, type IServerChannel } from "src/platform/ipc/common/channel"; +import { panic } from "src/base/common/utilities/panic"; +import { AI } from "src/platform/ai/common/ai"; +import { Emitter, Register } from "src/base/common/event"; +import { Disposable } from "src/base/common/dispose"; +import { IIpcService } from "src/platform/ipc/browser/ipcService"; +import { Blocker } from "src/base/common/utilities/async"; +import { AIError } from "src/base/common/error"; +import { AsyncResult, Result } from "src/base/common/result"; + +const enum AITextCommand { + switchModel = 'switchModel', + updateAPIKey = 'updateAPIKey', + sendRequest = 'sendRequest', + sendRequestStream = 'sendRequestStream', +} + +export class MainAITextChannel extends Disposable implements IServerChannel { + + // [constructor] + + constructor( + private readonly mainAITextService: IAITextService, + ) { + super(); + } + + // [public methods] + + public async callCommand(_id: string, command: AITextCommand, arg: any[]): Promise { + switch (command) { + case AITextCommand.switchModel: return this.__switchModel(arg[0]); + case AITextCommand.updateAPIKey: return this.__updateAPIKey(arg[0], arg[1], arg[2]); + case AITextCommand.sendRequest: return this.__sendRequest(arg[0]); + default: panic(`main-ai-text channel - unknown file command ${command}`); + } + } + + public registerListener(_id: string, event: never, arg: any[]): Register { + switch (event) { + case AITextCommand.sendRequestStream: return this.__sendRequestStream(arg[0]); + } + panic(`Event not found: ${event}`); + } + + // [private methods] + + private async __switchModel(options: AI.Text.IModelOptions): Promise { + return this.mainAITextService.switchModel(options); + } + + private async __updateAPIKey(newKey: string, modelType: AI.ModelName | null, persisted?: boolean): Promise { + return this.mainAITextService.updateAPIKey(newKey, modelType, persisted); + } + + private async __sendRequest(options: OpenAI.OpenAI.ChatCompletionCreateParamsNonStreaming): Promise { + return this.mainAITextService.sendRequest(options).unwrap(); + } + + private __sendRequestStream(options: OpenAI.OpenAI.ChatCompletionCreateParamsStreaming): Register { + const emitter = this.__register(new Emitter({})); + + this.mainAITextService.sendRequestStream(options, response => { + emitter.fire(response); + + // finished for some reason, clean up. + if (response.primaryMessage.finishReason !== null) { + this.release(emitter); + } + }) + .mapErr(error => { + this.release(emitter); + return error; + }) + .unwrap(); + + return emitter.registerListener; + } +} + +export class BrowserAITextChannel extends Disposable implements IAITextService { + + declare _serviceMarker: undefined; + + // [fields] + + private readonly _channel: IChannel; + + // [constructor] + + constructor( + @IIpcService ipcService: IIpcService, + ) { + super(); + this._channel = ipcService.getChannel(IpcChannel.AIText); + } + + // [public methods] + + public async init(): Promise { + panic('Method not supported in browser.'); + } + + public async switchModel(options: AI.Text.IModelOptions): Promise { + await this._channel.callCommand(AITextCommand.switchModel, [options]); + } + + public async updateAPIKey(newKey: string, modelType: AI.ModelName | null, persisted?: boolean): Promise { + await this._channel.callCommand(AITextCommand.updateAPIKey, [newKey, modelType, persisted]); + } + + public sendRequest(options: OpenAI.OpenAI.ChatCompletionCreateParamsNonStreaming): AsyncResult { + return Result.fromPromise(() => { + return this._channel.callCommand(AITextCommand.sendRequest, [options]); + }); + } + + public sendRequestStream(options: OpenAI.OpenAI.ChatCompletionCreateParamsStreaming, onChunkReceived: (chunk: AI.Text.Response) => void): AsyncResult { + const blocker = new Blocker(); + + const listener = this._channel.registerListener(AITextCommand.sendRequestStream, [options]); + const disconnect = this.__register(listener(response => { + onChunkReceived(response); + + // finished for some reason, clean up. + if (response.primaryMessage.finishReason !== null) { + blocker.resolve(); + this.release(disconnect); + } + })); + + return Result.fromPromise(() => blocker.waiting()); + } +} \ No newline at end of file diff --git a/src/platform/ai/electron/GPTModel.ts b/src/platform/ai/electron/GPTModel.ts new file mode 100644 index 000000000..fa214916f --- /dev/null +++ b/src/platform/ai/electron/GPTModel.ts @@ -0,0 +1,36 @@ +import OpenAI from "openai"; +import { AI } from "src/platform/ai/common/ai"; +import { TextSharedOpenAIModel } from "src/platform/ai/electron/openAIModel"; + +export class TextGPTModel extends TextSharedOpenAIModel implements AI.Text.Model { + + // [field] + + public readonly name = AI.ModelName.ChatGPT; + + // [constructor] + + constructor(options: AI.Text.IModelOptions) { + options.baseURL = undefined; // use default one + super(new OpenAI({ ...options })); + } + + // [override methods] + + protected override __createNonStreamingSingleMessage(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): AI.Text.SingleMessage { + return { + content: choice.message.content, + reasoning_content: undefined, + finishReason: choice.finish_reason, + role: choice.message.role, + }; + } + protected override __createStreamingSingleMessage(choice: OpenAI.Chat.Completions.ChatCompletionChunk.Choice): AI.Text.SingleMessage { + return { + content: choice.delta.content, + reasoning_content: undefined, + finishReason: choice.finish_reason, + role: choice.delta.role, + }; + } +} \ No newline at end of file diff --git a/src/platform/ai/electron/aiModelRegistrant.ts b/src/platform/ai/electron/aiModelRegistrant.ts new file mode 100644 index 000000000..a6e4b740a --- /dev/null +++ b/src/platform/ai/electron/aiModelRegistrant.ts @@ -0,0 +1,72 @@ +import { Constructor } from "src/base/common/utilities/type"; +import { AI } from "src/platform/ai/common/ai"; +import { textModelRegister } from "src/platform/ai/common/aiModel.register"; +import { IService } from "src/platform/instantiation/common/decorator"; +import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; +import { IRegistrant, RegistrantType } from "src/platform/registrant/common/registrant"; + +type ModelConstructor = Constructor< + AI.Text.Model, // instance + [options: any, ...services: IService[]] // parameters +>; + +/** + * A main-process registrant that responsible for managing supported LLM models + * in our application. + * + * For example: + * - text: DeepSeek, ChatGPT, Llama, etc... + * - image: DeepSeek, ChatGPT, etc... + * - voice: ... + * - video: ... + */ +export interface IAIModelRegistrant extends IRegistrant { + registerModel(modality: AI.Modality, name: string, constructor: ModelConstructor): void; + getRegisteredModel(modality: AI.Modality, name: string): ModelConstructor | undefined; +} + +export class AIModelRegistrant implements IAIModelRegistrant { + + // [fields] + + public readonly type = RegistrantType.AIModel; + private readonly _models: Map>; + + // [constructor] + + constructor() { + this._models = new Map(); + } + + // [public methods] + + public initRegistrations(serviceProvider: IServiceProvider): void { + textModelRegister(serviceProvider); + } + + public registerModel(modality: AI.Modality, name: string, constructor: ModelConstructor): void { + let models = this._models.get(modality); + if (!models) { + models = []; + this._models.set(modality, models); + } + + const matched = models.find(each => each.name === name); + if (matched) { + console.warn(`[AIModelRegistrant] Duplicate registration. Modality: ${modality}, name: ${name}.`); + return; + } + + models.push({ name: name, ctor: constructor }); + } + + public getRegisteredModel(modality: AI.Modality, name: string): ModelConstructor | undefined { + const models = this._models.get(modality); + if (!models) { + return undefined; + } + + const matched = models.find(each => each.name === name); + return matched?.ctor; + } +} \ No newline at end of file diff --git a/src/platform/ai/electron/deepSeekModel.ts b/src/platform/ai/electron/deepSeekModel.ts new file mode 100644 index 000000000..00ff70b7a --- /dev/null +++ b/src/platform/ai/electron/deepSeekModel.ts @@ -0,0 +1,36 @@ +import OpenAI from "openai"; +import { AI } from "src/platform/ai/common/ai"; +import { TextSharedOpenAIModel } from "src/platform/ai/electron/openAIModel"; + +export class TextDeepSeekModel extends TextSharedOpenAIModel implements AI.Text.Model { + + // [field] + + public readonly name = AI.ModelName.DeepSeek; + + // [constructor] + + constructor(options: AI.Text.IModelOptions) { + options.baseURL = 'https://api.deepseek.com'; + super(new OpenAI({ ...options })); + } + + // [override methods] + + protected override __createNonStreamingSingleMessage(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): AI.Text.SingleMessage { + return { + content: choice.message.content, + reasoning_content: choice.message['reasoning_content'], + finishReason: choice.finish_reason, + role: choice.message.role, + }; + } + protected override __createStreamingSingleMessage(choice: OpenAI.Chat.Completions.ChatCompletionChunk.Choice): AI.Text.SingleMessage { + return { + content: choice.delta.content, + reasoning_content: choice.delta['reasoning_content'], + finishReason: choice.finish_reason, + role: choice.delta.role, + }; + } +} \ No newline at end of file diff --git a/src/platform/ai/electron/gptModel.ts b/src/platform/ai/electron/gptModel.ts deleted file mode 100644 index dc98f1f80..000000000 --- a/src/platform/ai/electron/gptModel.ts +++ /dev/null @@ -1,145 +0,0 @@ -import OpenAI from "openai"; -import { Disposable } from "src/base/common/dispose"; -import { InitProtector } from "src/base/common/error"; -import { AsyncResult, Result } from "src/base/common/result"; -import { panic } from "src/base/common/utilities/panic"; -import { ArrayToUnion } from "src/base/common/utilities/type"; -import { IAITextModel, IAITextModelOpts, IAIRequestTextMessage, IAIResponseTextMessage, IAITextResponse, IAiTextRequestOpts, MessageResponseRole } from "src/platform/ai/electron/textAI"; - -export class GPTModel extends Disposable implements IAITextModel { - - // [field] - - private _model?: OpenAI; - private _initProtector: InitProtector; - private readonly _textValidFinishReasons = ['stop', 'length', 'content_filter'] as const; - - // [constructor] - - constructor() { - super(); - this._initProtector = new InitProtector(); - } - - // [public methods] - - public init(opts: IAITextModelOpts): void { - this._initProtector.init('GPTModel cannot initialize twice').unwrap(); - this._model = new OpenAI({ ...opts }); - } - - public sendTextRequest(messages: IAIRequestTextMessage[], opts: IAiTextRequestOpts): AsyncResult { - const client = this.__assertModel(); - return Result.fromPromise(async () => { - const completion = await client.chat.completions.create({ - messages: messages, - stream: false, - ...opts - }); - - const textResponse = this.__constructTextResponse( - completion.id, - completion.model, - () => completion.choices, - choice => this.__createTextMessage( - choice, - choice => choice.message.content, - choice => choice.message.role, - choice => choice.finish_reason, - ) - ); - - return textResponse; - }); - } - - public sendTextRequestStream( - messages: IAIRequestTextMessage[], - opts: IAiTextRequestOpts, - onChunkReceived: (chunk: IAITextResponse) => void, - ): AsyncResult - { - const client = this.__assertModel(); - - return Result.fromPromise(async() => { - const stream = await client.chat.completions.create({ - messages: messages, - stream: true, - ...opts - }); - - for await (const chunk of stream) { - const textResponse = this.__constructTextResponse( - chunk.id, - chunk.model, - () => chunk.choices, - choice => this.__createTextMessage( - choice, - choice => choice.delta.content, - choice => choice.delta.role, - choice => choice.finish_reason, - ), - ); - - onChunkReceived(textResponse); - } - }); - } - - // [private helper methods] - - private __assertFinishReasonValidity(finishReason: string | null, validReasons: TValidReasons): ArrayToUnion{ - if (finishReason === null || !validReasons.includes(finishReason)) { - panic(new Error(`Text request finished with invalid reason: ${finishReason}`)); - } - return finishReason; - } - - private __assertModel(): OpenAI { - if (!this._model) { - panic(new Error('Try to send request without ai model')); - } - return this._model; - } - - private __createTextMessage( - choice: TChoice, - getContent: (choice: TChoice) => string | null | undefined, - getRole: (choice: TChoice) => MessageResponseRole | undefined, - getFinishReason: (choice: TChoice) => string | null, - ): IAIResponseTextMessage { - return { - content: getContent(choice), - role: getRole(choice), - finishReason: this.__assertFinishReasonValidity(getFinishReason(choice), this._textValidFinishReasons) - }; - } - - private __constructTextResponse( - id: string, - model: string, - getChoices: () => TChoice[], - createTextMessage: (choice: TChoice) => IAIResponseTextMessage, - ): IAITextResponse { - const choices = getChoices(); - - const firstChoice = choices[0]; - if (firstChoice === undefined) { - panic(new Error("No choices returned in the chunk.")); - } - - const firstMessage = createTextMessage(firstChoice); - - const alternativeMessages: IAIResponseTextMessage[] = []; - for (const choice of choices) { - alternativeMessages.push(createTextMessage(choice)); - } - - return { - primaryMessage: firstMessage, - alternativeMessages: alternativeMessages, - id: id, - model: model, - }; - } -} \ No newline at end of file diff --git a/src/platform/ai/electron/llmModel.ts b/src/platform/ai/electron/llmModel.ts new file mode 100644 index 000000000..b73f5a22e --- /dev/null +++ b/src/platform/ai/electron/llmModel.ts @@ -0,0 +1,24 @@ +import { Disposable } from "src/base/common/dispose"; +import { AI } from "src/platform/ai/common/ai"; + +/** + * A base class for all LLM-related models. + */ +export abstract class LLMModel extends Disposable implements AI.ILLMModel { + + // [fields] + + public readonly abstract modality: AI.Modality; + public readonly abstract name: AI.ModelName; + + // [constructor] + + constructor() { + super(); + } + + // [abstract] + + abstract get apiKey(): string; + public abstract setAPIKey(newKey: string): void; +} \ No newline at end of file diff --git a/src/platform/ai/electron/mainAITextService.ts b/src/platform/ai/electron/mainAITextService.ts index 4386bb7a8..cfd992ee7 100644 --- a/src/platform/ai/electron/mainAITextService.ts +++ b/src/platform/ai/electron/mainAITextService.ts @@ -1,11 +1,26 @@ +import OpenAI from "openai"; import { Disposable } from "src/base/common/dispose"; -import { AsyncResult } from "src/base/common/result"; -import { IAITextModel, IAIRequestTextMessage, IAiTextRequestOpts, IAITextResponse, IAITextServiceOpts, IAITextModelOpts, IAITextService, TextModelType } from "src/platform/ai/electron/textAI"; -import { GPTModel } from "src/platform/ai/electron/gptModel"; -import { createService } from "src/platform/instantiation/common/decorator"; +import { AIError } from "src/base/common/error"; +import { Emitter, Event } from "src/base/common/event"; +import { ILogService } from "src/base/common/logger"; +import { AsyncResult, err, ok, Result } from "src/base/common/result"; +import { panic } from "src/base/common/utilities/panic"; +import { isNullable } from "src/base/common/utilities/type"; +import { AI } from "src/platform/ai/common/ai"; +import { IAITextService } from "src/platform/ai/common/aiText"; +import { IAIModelRegistrant } from "src/platform/ai/electron/aiModelRegistrant"; +import { IConfigurationService } from "src/platform/configuration/common/configuration"; +import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; import { IInstantiationService } from "src/platform/instantiation/common/instantiation"; - -export const IMainAITextService = createService('main-ai-text-service'); +import { IpcChannel } from "src/platform/ipc/common/channel"; +import { IMainLifecycleService } from "src/platform/lifecycle/electron/mainLifecycleService"; +import { RegistrantType } from "src/platform/registrant/common/registrant"; +import { IRegistrantService } from "src/platform/registrant/common/registrantService"; +import { StatusKey } from "src/platform/status/common/status"; +import { IMainStatusService } from "src/platform/status/electron/mainStatusService"; +import { IMainWindowService } from "src/platform/window/electron/mainWindowService"; +import { IWindowInstance } from "src/platform/window/electron/windowInstance"; +import { WorkbenchConfiguration } from "src/workbench/services/workbench/configuration.register"; export class MainAITextService extends Disposable implements IAITextService { @@ -13,46 +28,176 @@ export class MainAITextService extends Disposable implements IAITextService { // [fields] - private _model: IAITextModel; - private _modelType: TextModelType; + private readonly _onDidError = this.__register(new Emitter()); + public readonly onDidError = this._onDidError.registerListener; + + private readonly _registrant: IAIModelRegistrant; + private _model?: AI.Text.Model; // [constructor] constructor( - opts: IAITextServiceOpts, + @ILogService private readonly logService: ILogService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IMainStatusService private readonly statusService: IMainStatusService, + @IMainWindowService private readonly mainWindowService: IMainWindowService, + @IMainLifecycleService private readonly lifecycleService: IMainLifecycleService, + @IEncryptionService private readonly encryptionService: IEncryptionService, + @IRegistrantService registrantService: IRegistrantService, ) { super(); - this._modelType = opts.type; - this._model = this.__constructModel(); + this._registrant = registrantService.getRegistrant(RegistrantType.AIModel); + + // Initialize when the browser window is ready. + Event.onceSafe(this.mainWindowService.onDidOpenWindow)(window => { + this.__registerListeners(window); + }); } // [public methods] - public init(opts: IAITextModelOpts): void { - this._model.init(opts); + public async init(): Promise { + if (this._model) { + return; + } + this.logService.debug('MainAITextService', `Initializing...`); + const options = await this.__constructOptions(); + this._model = this.__constructModel(options); + this.logService.debug('MainAITextService', `Initialized successfully.`); } - public switchModel(opts: IAITextServiceOpts): void { - if (this._modelType === opts.type) { + public async updateAPIKey(newKey: string, modelType: AI.ModelName | null, persisted: boolean = true): Promise { + if (newKey === '') { return; } - this._modelType = opts.type; - this._model.dispose(); + this.logService.debug('MainAITextService', `Updating API key (model: ${modelType})...`); + + const encrypted = await this.encryptionService.encrypt(newKey); + const resolvedType = modelType || this._model?.name; + + // if persisted and desired to change specific model APIKey + if (persisted && resolvedType) { + const key = this.__getStatusAPIKey(resolvedType); + await this.statusService.set(key, encrypted).unwrap(); + } + + // if any current model, we update its API Key in memory. + if (this._model) { + this._model.setAPIKey(newKey); + } - this._model = this.__constructModel(); + this.logService.debug('MainAITextService', `Updated API key (model: ${resolvedType}).`); + } + + public getModel(): Result { + if (!this._model) { + return err(new AIError(null, 'service not initialized')); + } + return ok(this._model); + } + + public async switchModel(opts: AI.Text.IModelOptions): Promise { + if (!this._model) { + return; + } + + if (this._model.name === opts.name) { + return; + } + + this.__destructCurrentModel(); + this._model = this.__constructModel(opts); } - public sendRequest(message: IAIRequestTextMessage[], opts: IAiTextRequestOpts): AsyncResult { - return this._model.sendTextRequest(message, opts); + public sendRequest(options: OpenAI.ChatCompletionCreateParamsNonStreaming): AsyncResult { + return this + .getModel() + .toAsync() + .andThen(model => model.sendRequest(options)); + } + + public sendRequestStream(options: OpenAI.ChatCompletionCreateParamsStreaming, onChunkReceived: (chunk: AI.Text.Response) => void): AsyncResult { + return this + .getModel() + .toAsync() + .andThen(model => model.sendRequestStream(options, onChunkReceived)); } // [private helper methods] - private __constructModel(): IAITextModel { - switch (this._modelType) { - case TextModelType.GPT: - return new GPTModel(); + private async __registerListeners(window: IWindowInstance): Promise { + + // alert errors to the user + this.__register(this.onDidError(err => { + window.sendIPCMessage(IpcChannel.rendererAlertError, err); + })); + + /** + * save data: + * 1. API Key + */ + this.__register(this.lifecycleService.onWillQuit(e => { + const saveAPIKey = (async () => { + if (!this._model) { + return; + } + const encrypted = await this.encryptionService.encrypt(this._model.apiKey); + const key = this.__getStatusAPIKey(this._model.name); + return this.statusService.set(key, encrypted).unwrap(); + })(); + e.join(saveAPIKey); + })); + + // initialize when the window is ready + window.whenRendererReady().then(async () => { + this.init(); + }); + } + + private async __constructOptions(): Promise { + const modelName = this.configurationService.get(WorkbenchConfiguration.AiTextModel); + const encrypted = this.statusService.get(this.__getStatusAPIKey(modelName)); + + const apiKey = encrypted + ? await this.encryptionService.decrypt(encrypted) + : ''; // still provide mock string + + if (isNullable(apiKey) || apiKey === '') { + this._onDidError.fire(new Error('No API Key provided.')); + } + + return { + name: modelName, + apiKey: apiKey, + }; + } + + private __constructModel(options: AI.Text.IModelOptions): AI.Text.Model { + + // log options, make sure to exclude API keys. + const logOptions: any = Object.assign({}, options); + delete logOptions.apiKey; + this.logService.debug('[MainAITextService]', `Constructing (text) model (${options.name}) with options:`, logOptions); + + // model construction + const modelName = options.name; + const modelCtor = this._registrant.getRegisteredModel(AI.Modality.Text, modelName); + if (!modelCtor) { + panic(new Error(`[MainAITextService] Cannot find proper (text) model with name (${modelName}) to construct.`)); } + const model = this.instantiationService.createInstance(modelCtor, options); + return this.__register(model); + } + + private __destructCurrentModel(): void { + if (!this._model) { + return; + } + this.release(this._model); + } + + private __getStatusAPIKey(modelType: AI.ModelName): StatusKey { + return `${StatusKey.textAPIKey}-${modelType}` as StatusKey; } } diff --git a/src/platform/ai/electron/openAIModel.ts b/src/platform/ai/electron/openAIModel.ts new file mode 100644 index 000000000..dde2cd4c7 --- /dev/null +++ b/src/platform/ai/electron/openAIModel.ts @@ -0,0 +1,109 @@ +import OpenAI from "openai"; +import { AI } from "src/platform/ai/common/ai"; +import { Disposable } from "src/base/common/dispose"; +import { panic } from "src/base/common/utilities/panic"; +import { nullable } from "src/base/common/utilities/type"; +import { ChatCompletionCreateParamsNonStreaming } from "openai/resources"; +import { AsyncResult, Result } from "src/base/common/result"; +import { AIError } from "src/base/common/error"; +import { LLMModel } from "src/platform/ai/electron/llmModel"; + +/** + * @description Abstract base class for OpenAI-compatible text generation models. + */ +export abstract class TextSharedOpenAIModel extends LLMModel implements AI.Text.Model { + + // [field] + + public readonly modality = AI.Modality.Text; + public abstract override readonly name: AI.ModelName; + + // [constructor] + + constructor( + protected readonly client: OpenAI, + ) { + super(); + } + + // [getter] + + get apiKey() { return this.client.apiKey; } + public setAPIKey(newKey: string): void { this.client.apiKey = newKey; } + + // [abstract methods] + + protected abstract __createNonStreamingSingleMessage(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): AI.Text.SingleMessage; + protected abstract __createStreamingSingleMessage(choice: OpenAI.Chat.Completions.ChatCompletionChunk.Choice): AI.Text.SingleMessage; + + // [public methods] + + public sendRequest(options: ChatCompletionCreateParamsNonStreaming): AsyncResult { + return Result.fromPromise(async () => { + const completion = await this.client.chat.completions.create(options); + const textResponse = this.__createResponse( + completion.id, + completion.model, + () => completion.choices, + choice => this.__createNonStreamingSingleMessage(choice), + () => completion.usage, + ); + return textResponse; + }) + .mapErr(error => new AIError(this.name, error)); + } + + public sendRequestStream(options: OpenAI.ChatCompletionCreateParamsStreaming, onChunkReceived: (chunk: AI.Text.Response) => void): AsyncResult { + return Result.fromPromise(async () => { + const stream = await this.client.chat.completions.create(options); + for await (const chunk of stream) { + const textResponse = this.__createResponse( + chunk.id, + chunk.model, + () => chunk.choices, + choice => this.__createStreamingSingleMessage(choice), + () => chunk.usage, + ); + + onChunkReceived(textResponse); + } + }) + .mapErr(error => new AIError(this.name, error)); + } + + public override dispose(): void { + super.dispose(); + } + + // [private helper methods] + + private __createResponse( + id: string, + model: string, + getChoices: () => TChoice[], + createTextMessage: (choice: TChoice) => AI.Text.SingleMessage, + getUsage: () => OpenAI.CompletionUsage | nullable, + ): AI.Text.Response { + const choices = getChoices(); + + const firstChoice = choices[0]; + if (firstChoice === undefined) { + panic(new Error("No choices returned in the chunk.")); + } + + const firstMessage = createTextMessage(firstChoice); + + const alternativeMessages: AI.Text.SingleMessage[] = []; + for (const choice of choices) { + alternativeMessages.push(createTextMessage(choice)); + } + + return { + primaryMessage: firstMessage, + alternativeMessages: alternativeMessages, + id: id, + model: model, + usage: getUsage() ?? undefined, + }; + } +} \ No newline at end of file diff --git a/src/platform/ai/electron/textAI.ts b/src/platform/ai/electron/textAI.ts deleted file mode 100644 index c4394806c..000000000 --- a/src/platform/ai/electron/textAI.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Agent } from "openai/_shims"; -import { Disposable, IDisposable } from "src/base/common/dispose"; -import { AsyncResult } from "src/base/common/result"; -import { IService } from "src/platform/instantiation/common/decorator"; - -export const enum TextModelType { - GPT = 'GPT', -} - -export type MessageRequestRole = 'system' | 'user' | 'assistant'; -export type MessageResponseRole = 'system' | 'user' | 'assistant' | 'tool'; - -export interface IAIRequestTextMessage { - - role: MessageRequestRole; - - content: string; -} - -export interface IAIResponseTextMessage { - - role?: MessageResponseRole; - - content?: string | null; - - readonly finishReason: 'stop' | 'length' | 'content_filter'; -} - -export interface IAITextModel extends Disposable { - init(opts: IAITextModelOpts): void; - sendTextRequest(message: IAIRequestTextMessage[], opts: IAiTextRequestOpts): AsyncResult; -} - -export interface IAIRequestTokenUsage { - /** - * Number of tokens in the generated completion. - */ - completionTokens: number; - - /** - * Number of tokens in the prompt. - */ - promptTokens: number; - - /** - * Total number of tokens used in the request (prompt + completion). - */ - totalTokens: number; -} - -export interface IAITextResponse { - - // The primary or dominant message from the first choice - readonly primaryMessage: IAIResponseTextMessage; - - // Messages from the other choices provided by the API - readonly alternativeMessages?: IAIResponseTextMessage[]; - - readonly id: string; - - readonly model: string; - - readonly usage?: IAIRequestTokenUsage; -} - -export interface IAITextService extends Disposable, IService { - init(IAITextModelOpts: IAITextModelOpts): void; - - switchModel(opts: IAITextServiceOpts): void; - - sendRequest(message: IAIRequestTextMessage[], opts: IAiTextRequestOpts): AsyncResult; -} - -export interface IAITextServiceOpts { - readonly type: TextModelType; -} - -export interface IAiTextRequestOpts { - - /** - * ID of the model to use. See the - * [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility) - * table for details on which models work with the Chat API. - */ - readonly model: - | 'gpt-4-turbo-preview' - | 'gpt-4' - | 'gpt-4-32k' - | 'gpt-3.5-turbo' - | 'gpt-3.5-turbo-16k'; - - /** - * The maximum number of [tokens](/tokenizer) that can be generated in the chat - * completion. - * - * The total length of input tokens and generated tokens is limited by the model's - * context length. - * [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken) - * for counting tokens. - */ - max_tokens?: number | null; - - - /** - * What sampling temperature to use, between 0 and 2. Higher values like 0.8 will - * make the output more random, while lower values like 0.2 will make it more - * focused and deterministic. - * - * We generally recommend altering this or `top_p` but not both. - */ - temperature?: number | null; -} - -export interface IAITextModelOpts { - - apiKey: string; - - /** - * The maximum number of times that the client will retry a request in case of a - * temporary failure, like a network error or a 5XX error from the server. - * - * @default 2 - */ - maxRetries?: number; - - /** - * The maximum amount of time (in milliseconds) that the client should wait for a response - * from the server before timing out a single request. - * - * Note that request timeouts are retried by default, so in a worst-case scenario you may wait - * much longer than this timeout before the promise succeeds or fails. - */ - timeout?: number; - - /** - * An HTTP agent used to manage HTTP(S) connections. - * - * If not provided, an agent will be constructed by default in the Node.js environment, - * otherwise no agent is used. - */ - httpAgent?: Agent; -} \ No newline at end of file diff --git a/src/platform/configuration/common/configurationRegistrant.ts b/src/platform/configuration/common/configurationRegistrant.ts index c6ab56829..f20db8cb0 100644 --- a/src/platform/configuration/common/configurationRegistrant.ts +++ b/src/platform/configuration/common/configurationRegistrant.ts @@ -7,7 +7,7 @@ import { Dictionary, isObject } from "src/base/common/utilities/type"; import { Section } from "src/platform/configuration/common/configuration"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; import { IRegistrant, RegistrantType } from "src/platform/registrant/common/registrant"; -import { sharedEditorConfigurationRegister, sharedNavigationViewConfigurationRegister, sharedWorkbenchConfigurationRegister, sharedWorkspaceConfigurationRegister } from "src/workbench/services/workbench/configuration.register"; +import { sharedApplicationConfigurationRegister, sharedEditorConfigurationRegister, sharedNavigationViewConfigurationRegister, sharedWorkbenchConfigurationRegister, sharedWorkspaceConfigurationRegister } from "src/workbench/services/workbench/configuration.register"; export type IConfigurationSchema = IJsonSchema & { @@ -187,6 +187,7 @@ export class ConfigurationRegistrant extends Disposable implements IConfiguratio * both processes. */ [ + sharedApplicationConfigurationRegister, sharedWorkbenchConfigurationRegister, sharedNavigationViewConfigurationRegister, sharedWorkspaceConfigurationRegister, diff --git a/src/platform/encryption/common/encryptionService.ts b/src/platform/encryption/common/encryptionService.ts new file mode 100644 index 000000000..182dd25fd --- /dev/null +++ b/src/platform/encryption/common/encryptionService.ts @@ -0,0 +1,48 @@ +import { createService, IService } from "src/platform/instantiation/common/decorator"; + +export const IEncryptionService = createService('encryption-service'); + +/** + * Encryption service provides methods for encrypting and decrypting data. + * + * @note Encryption only supported in native environment (main process). Browser + * may only access to encryption through IPC channel. + */ +export interface IEncryptionService extends IService { + /** + * @description Encrypts a given string and returns the encrypted one. + * @param value The plaintext string to encrypt. + * @returns the encrypted string. + * @panic If the encryption fails. + */ + encrypt(value: string): Promise; + + /** + * @description Decrypts an encrypted string and returns the original + * plaintext. + * + * @param value The encrypted string to decrypt. + * @returns The decrypted plaintext. + * @panic If the decryption fails. + */ + decrypt(value: string): Promise; + + /** + * @description Whether encryption is available. + * - On MacOS, returns true if Keychain is available. + * - On Windows, returns true once the app has emitted the `ready` event. + * - On Linux, returns true if the app has emitted the `ready` event and the + * secret key is available. + */ + isEncryptionAvailable(): Promise; + + /** + * @description This function on Linux will force the module to use an in + * memory password for creating symmetric key that is used for encrypt/decrypt + * functions when a valid OS password manager cannot be determined for the + * current active desktop environment. + * @note This function is a no-op on Windows and MacOS. + * @panic If executed this function on Windows/MacOS. + */ + setUsePlainTextEncryption(): Promise; +} \ No newline at end of file diff --git a/src/platform/encryption/electron/mainEncryptionService.ts b/src/platform/encryption/electron/mainEncryptionService.ts new file mode 100644 index 000000000..b4ba51930 --- /dev/null +++ b/src/platform/encryption/electron/mainEncryptionService.ts @@ -0,0 +1,94 @@ +import { app, safeStorage } from "electron"; +import { ILogService } from "src/base/common/logger"; +import { IS_MAC, IS_WINDOWS } from "src/base/common/platform"; +import { panic } from "src/base/common/utilities/panic"; +import { Strings } from "src/base/common/utilities/string"; +import { IEncryptionService } from "src/platform/encryption/common/encryptionService"; + +export class MainEncryptionService implements IEncryptionService { + + declare _serviceMarker: undefined; + + // [constructors] + + constructor( + @ILogService private readonly logService: ILogService, + ) { + /** + * Downgrade the encryption level if required. + * This only works in Linux. It is no-op on Windows or Mac. + * @see https://www.electronjs.org/docs/latest/api/safe-storage#safestoragesetuseplaintextencryptionuseplaintext + */ + if (app.commandLine.getSwitchValue('password-store') === 'basic') { + this.__doSetUsePlainTextEncryption(true); + } + } + + // [public methods] + + public async isEncryptionAvailable(): Promise { + return safeStorage.isEncryptionAvailable(); + } + + public async encrypt(value: string): Promise { + + this.logService.trace('[MainEncryptionService]', 'Encrypting value...'); + const encryptedBuffer = safeStorage.encryptString(value); + /** + * Using {@link Buffer.toString()} directly may result in encoding issues, + * as not all bytes can be safely represented in UTF-8. + * + * Instead, we use {@link JSON.stringify()} to serialize the buffer into + * a structured format (`{"type":"Buffer","data":[…]}`), ensuring + * consistent encoding. + * + * @example + * const buffer = Buffer.from([0x00, 0x80, 0xFF]); + * const str = buffer.toString(); + * console.log(str); // '��' + * + * // the following is corrupted + * console.log(JSON.stringify(str)); // {"type":"Buffer","data":[0,239,191,189,239,191,189]} + */ + const encrypted = Strings.stringifySafe2(encryptedBuffer).unwrap(); + this.logService.trace('[MainEncryptionService]', 'Encrypted value.'); + + return encrypted; + } + + public async decrypt(value: string): Promise { + const parsedValue = JSON.parse(value); + if (!parsedValue.data) { + panic(`[MainEncryptionService]', 'Invalid encrypted value: ${value}`); + } + const bufferToDecrypt = Buffer.from(parsedValue.data); + + this.logService.trace('[MainEncryptionService]', 'Decrypting value...'); + const result = safeStorage.decryptString(bufferToDecrypt); + this.logService.trace('[MainEncryptionService]', 'Decrypted value.'); + + return result; + } + + public async setUsePlainTextEncryption(): Promise { + if (IS_WINDOWS) { + panic('Setting plain text encryption is not supported on Windows.'); + } + + if (IS_MAC) { + panic('Setting plain text encryption is not supported on macOS.'); + } + + if (!safeStorage.setUsePlainTextEncryption) { + panic('Setting plain text encryption is not supported.'); + } + + this.__doSetUsePlainTextEncryption(true); + } + + private __doSetUsePlainTextEncryption(value: boolean): void { + this.logService.trace('[MainEncryptionService]', 'Setting "usePlainTextEncryption" to true...'); + safeStorage.setUsePlainTextEncryption?.(true); + this.logService.trace('[MainEncryptionService]', 'Set "usePlainTextEncryption" to true'); + } +} \ No newline at end of file diff --git a/src/platform/ipc/common/channel.ts b/src/platform/ipc/common/channel.ts index 6c4bfaa8f..76ec5569c 100644 --- a/src/platform/ipc/common/channel.ts +++ b/src/platform/ipc/common/channel.ts @@ -16,6 +16,8 @@ export const enum IpcChannel { Host = 'nota:host', Dialog = 'nota:dialog', Menu = 'nota:menu', + Encryption = 'nota:encryption', + AIText = 'nota:aiText', // main process direct communication to renderer process rendererAlertError = 'nota:rendererAlertError', @@ -64,6 +66,9 @@ export type ChannelType = IpcChannel | string; export interface IServerChannel { callCommand(id: string, command: string, arg?: any[]): Promise; registerListener(id: string, event: string, arg?: any[]): Register; + + // server channel might also has memory needs to be released + dispose?(): void; } /** diff --git a/src/platform/ipc/common/net.ts b/src/platform/ipc/common/net.ts index c68f5cd25..67bc3624c 100644 --- a/src/platform/ipc/common/net.ts +++ b/src/platform/ipc/common/net.ts @@ -1,4 +1,4 @@ -import { Disposable, IDisposable, untrackDisposable } from "src/base/common/dispose"; +import { Disposable, IDisposable, isDisposable, untrackDisposable } from "src/base/common/dispose"; import { toIPCTransferableError } from "src/base/common/error"; import { Emitter, Event, Register } from "src/base/common/event"; import { BufferReader, BufferWriter, DataBuffer } from "src/base/common/files/buffer"; @@ -668,6 +668,10 @@ export class ServerBase extends Disposable implements IChannelServer { public registerChannel(name: string, channel: IServerChannel): void { this._channels.set(name, channel); + if (isDisposable(channel)) { + this.__register(channel); + } + for (const connection of this._connections) { connection.channelServer.registerChannel(name, channel); } diff --git a/src/platform/ipc/common/proxy.ts b/src/platform/ipc/common/proxy.ts index 4d33a68c6..95c75a197 100644 --- a/src/platform/ipc/common/proxy.ts +++ b/src/platform/ipc/common/proxy.ts @@ -1,7 +1,7 @@ import type { IService } from "src/platform/instantiation/common/decorator"; import type { ServerBase } from "src/platform/ipc/common/net"; import type { IChannel, IServerChannel } from "src/platform/ipc/common/channel"; -import type { Dictionary } from "src/base/common/utilities/type"; +import type { AsyncOnly, Dictionary } from "src/base/common/utilities/type"; import type { Register } from "src/base/common/event"; import type { IReviverRegistrant } from "src/platform/ipc/common/revive"; import { CharCode } from "src/base/common/utilities/char"; @@ -21,14 +21,14 @@ export namespace ProxyChannel { * function transforms a provided service into an {@link IServerChannel} by * extracting its commands and listeners. * - * @param service - The service to be wrapped. - * @param opts - Optional parameters to configure the wrapping behavior. + * @param service The service to be wrapped. + * @param opts Optional parameters to configure the wrapping behavior. * @returns A {@link IServerChannel} that represents the wrapped service. * - * @throws {Error} If a command is not found during invocation. - * @throws {Error} If an event is not found during listener registration. + * @throws If a command is not found during invocation. + * @throws If an event is not found during listener registration. */ - export function wrapService(service: unknown, opts?: IWrapServiceOpt): IServerChannel { + export function wrapService>(service: T, opts?: IWrapServiceOpt): IServerChannel { const object = >service; const eventRegisters = new Map>(); @@ -74,14 +74,14 @@ export namespace ProxyChannel { * Additionally, argument and result revival can be configured using the * provided options. * - * @template T - The type of the service being unwrapped. - * @param channel - The channel to be unwrapped. - * @param opt - Optional parameters to configure the unwrapping behavior and revival logic. + * @template T The type of the service being unwrapped. + * @param channel The channel to be unwrapped. + * @param opt Optional parameters to configure the unwrapping behavior and revival logic. * @returns A {@link Proxy} that represents the unwrapped microservice. * - * @throws {Error} If a property is not found during access. + * @throws If a property is not found during access. */ - export function unwrapChannel(channel: IChannel, opt?: IUnwrapChannelOpt): T { + export function unwrapChannel>(channel: IChannel, opt?: IUnwrapChannelOpt): T { return (new Proxy( {}, { get: (_target: T, propName: string | symbol): unknown => { diff --git a/src/platform/logger/common/prettyLog.ts b/src/platform/logger/common/prettyLog.ts index 37b07eab8..7983fb2f1 100644 --- a/src/platform/logger/common/prettyLog.ts +++ b/src/platform/logger/common/prettyLog.ts @@ -1,11 +1,12 @@ /* eslint-disable local/code-no-json-stringify */ import { TextColors } from "src/base/common/color"; import { getCurrTimeStamp } from "src/base/common/date"; -import { IpcErrorTag, tryOrDefault } from "src/base/common/error"; +import { tryOrDefault } from "src/base/common/error"; import { Schemas, URI } from "src/base/common/files/uri"; import { Additional, ILogService, LogLevel, PrettyTypes, parseLogLevel } from "src/base/common/logger"; import { Iterable } from "src/base/common/utilities/iterable"; import { iterPropEnumerable } from "src/base/common/utilities/object"; +import { isError } from "src/base/common/utilities/panic"; import { Coordinate, Dimension, Position } from "src/base/common/utilities/size"; import { isFunction, isObject } from "src/base/common/utilities/type"; @@ -135,15 +136,17 @@ function getErrorString(color: boolean, error: any): string { return ''; } - if (!(error instanceof Error) && !(error[IpcErrorTag] === null)) { + if (!isError(error)) { return ` ${tryPaintValue(1, color, 'error', error)}`; } const stackLines: string[] = error.stack ? error.stack.split - ? error.stack.split('\n') // array of string - : error.stack // already a string - : []; // no stacks + ? error.stack.split('\n') // stack is a string, make it array of string + : Array.isArray(error.stack) + ? error.stack // already array of string + : [] // weird data, we consider no stacks. + : []; // no stacks // Find the maximum length of the lines (Adding space for formatting and borders) const maxLength = (Iterable.maxBy(stackLines, line => line.trim().length)?.length ?? 0) + 6; diff --git a/src/platform/registrant/common/registrant.ts b/src/platform/registrant/common/registrant.ts index 0969a40d2..09c202cad 100644 --- a/src/platform/registrant/common/registrant.ts +++ b/src/platform/registrant/common/registrant.ts @@ -4,11 +4,11 @@ import type { CommandRegistrant } from "src/platform/command/common/commandRegis import type { ReviverRegistrant } from "src/platform/ipc/common/revive"; import type { MenuRegistrant } from "src/platform/menu/browser/menuRegistrant"; import { ILogService } from "src/base/common/logger"; -import { executeOnce } from "src/base/common/utilities/function"; import { ConfigurationRegistrant } from "src/platform/configuration/common/configurationRegistrant"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; import { IRegistrantService } from "src/platform/registrant/common/registrantService"; import { EditorPaneRegistrant } from "src/workbench/services/editorPane/editorPaneRegistrant"; +import { AIModelRegistrant } from "src/platform/ai/electron/aiModelRegistrant"; /** * An enumeration representing the different types of registrants. @@ -26,6 +26,7 @@ export const enum RegistrantType { Color = 'Color', Menu = 'Menu', EditorPane = 'EditorPane', + AIModel = 'AIModel', } /** @@ -64,6 +65,7 @@ type RegistrantTypeMapping = { [RegistrantType.Color] : ColorRegistrant; [RegistrantType.Menu] : MenuRegistrant; [RegistrantType.EditorPane] : EditorPaneRegistrant; + [RegistrantType.AIModel] : AIModelRegistrant; }; /** diff --git a/src/platform/status/common/status.ts b/src/platform/status/common/status.ts index da65cf145..f377da660 100644 --- a/src/platform/status/common/status.ts +++ b/src/platform/status/common/status.ts @@ -7,4 +7,5 @@ export const enum StatusKey { MachineIdKey = 'machineID', WindowZoomLevel = 'windowZoomLevel', OpenRecent = 'openRecent', + textAPIKey = 'textAPIKey', } \ No newline at end of file diff --git a/src/workbench/contrib/richTextEditor/editorPane.register.ts b/src/workbench/contrib/richTextEditor/editorPane.register.ts index cd019f9e5..02f3c3c59 100644 --- a/src/workbench/contrib/richTextEditor/editorPane.register.ts +++ b/src/workbench/contrib/richTextEditor/editorPane.register.ts @@ -1,7 +1,7 @@ import { createRegister, RegistrantType } from "src/platform/registrant/common/registrant"; import { RichTextEditor } from "src/workbench/contrib/richTextEditor/richTextEditor"; import { TextEditorPaneModel } from "src/workbench/services/editorPane/editorPaneModel"; -import { EditorPaneDescriptor } from "src/workbench/services/editorPane/editorPaneRegistrant"; +import { EditorPaneDescriptor } from "src/workbench/services/editorPane/editorPaneDescriptor"; export const registerRichTextEditor = createRegister( RegistrantType.EditorPane, diff --git a/src/workbench/services/editorPane/editorPaneDescriptor.ts b/src/workbench/services/editorPane/editorPaneDescriptor.ts new file mode 100644 index 000000000..25956bbdc --- /dev/null +++ b/src/workbench/services/editorPane/editorPaneDescriptor.ts @@ -0,0 +1,14 @@ +/** + * This file is used to solve circular dependency. + */ + +import type { Constructor } from "src/base/common/utilities/type"; +import { IService } from "src/platform/instantiation/common/decorator"; +import { EditorPaneView } from "src/workbench/services/editorPane/editorPaneView"; + +export class EditorPaneDescriptor { + + constructor( + public readonly ctor: Constructor + ) { } +} diff --git a/src/workbench/services/editorPane/editorPaneModel.ts b/src/workbench/services/editorPane/editorPaneModel.ts index 64404d9e5..ea8a3c12f 100644 --- a/src/workbench/services/editorPane/editorPaneModel.ts +++ b/src/workbench/services/editorPane/editorPaneModel.ts @@ -1,6 +1,7 @@ -import type { EditorPaneDescriptor, IEditorPaneRegistrant } from "src/workbench/services/editorPane/editorPaneRegistrant"; +import type { IEditorPaneRegistrant } from "src/workbench/services/editorPane/editorPaneRegistrant"; import type { IEditorPaneView } from "src/workbench/services/editorPane/editorPaneView"; import type { AtLeastOneArray } from "src/base/common/utilities/type"; +import { EditorPaneDescriptor } from "src/workbench/services/editorPane/editorPaneDescriptor"; import { URI } from "src/base/common/files/uri"; /** diff --git a/src/workbench/services/editorPane/editorPaneRegistrant.ts b/src/workbench/services/editorPane/editorPaneRegistrant.ts index 598e014cd..359193819 100644 --- a/src/workbench/services/editorPane/editorPaneRegistrant.ts +++ b/src/workbench/services/editorPane/editorPaneRegistrant.ts @@ -1,10 +1,9 @@ import type { Constructor, Pair } from "src/base/common/utilities/type"; -import { EditorPaneView } from "src/workbench/services/editorPane/editorPaneView"; import { IServiceProvider } from "src/platform/instantiation/common/instantiation"; import { EditorPaneModel } from "src/workbench/services/editorPane/editorPaneModel"; import { IRegistrant, RegistrantType } from "src/platform/registrant/common/registrant"; import { registerRichTextEditor } from "src/workbench/contrib/richTextEditor/editorPane.register"; -import { IService } from "src/platform/instantiation/common/decorator"; +import { EditorPaneDescriptor } from "src/workbench/services/editorPane/editorPaneDescriptor"; /** * {@link IEditorPaneRegistrant} is a central part of the editor pane system, @@ -50,13 +49,6 @@ export interface IEditorPaneRegistrant extends IRegistrant[]>[]; } -export class EditorPaneDescriptor { - - constructor( - public readonly ctor: Constructor, - ) {} -} - export class EditorPaneRegistrant implements IEditorPaneRegistrant { // [fields] diff --git a/src/workbench/services/workbench/configuration.register.ts b/src/workbench/services/workbench/configuration.register.ts index d55af7601..11479ffe9 100644 --- a/src/workbench/services/workbench/configuration.register.ts +++ b/src/workbench/services/workbench/configuration.register.ts @@ -1,4 +1,5 @@ import { CollapseState } from "src/base/browser/basic/dom"; +import { AI } from "src/platform/ai/common/ai"; import { LanguageType } from "src/platform/i18n/common/i18n"; import { RegistrantType, createRegister } from "src/platform/registrant/common/registrant"; import { EditorGroupOpenPositioning } from "src/workbench/parts/workspace/editorGroupModel"; @@ -8,6 +9,10 @@ import { PresetColorTheme } from "src/workbench/services/theme/theme"; export const enum WorkbenchConfiguration { + // [application] + + AiTextModel = 'application.ai.textModel', + // [workbench] DisplayLanguage = 'workbench.language', @@ -40,12 +45,45 @@ export const enum WorkbenchConfiguration { } /** + * {@link sharedApplicationConfigurationRegister} * {@link sharedWorkbenchConfigurationRegister} * {@link sharedNavigationViewConfigurationRegister} * {@link sharedWorkspaceConfigurationRegister} * {@link sharedEditorConfigurationRegister} */ +export const sharedApplicationConfigurationRegister = createRegister( + RegistrantType.Configuration, + 'application', + (registrant) => { + registrant.registerConfigurations({ + id: 'application', + properties: { + ['application']: { + type: "object", + properties: { + + // AI configurations + ['ai']: { + type: 'object', + properties: { + ['textModel']: { + type: 'string', + default: AI.ModelName.DeepSeek, + enum: [ + AI.ModelName.DeepSeek, + AI.ModelName.ChatGPT, + ] + } + } + } + } + } + } + }); + } +); + export const sharedWorkbenchConfigurationRegister = createRegister( RegistrantType.Configuration, 'rendererWorkbench', diff --git a/test/base/common/utilities/type.test.ts b/test/base/common/utilities/type.test.ts index a9c70b3ec..9f681b157 100644 --- a/test/base/common/utilities/type.test.ts +++ b/test/base/common/utilities/type.test.ts @@ -3,8 +3,10 @@ /* eslint-disable @typescript-eslint/ban-types */ import * as assert from 'assert'; +import { Register } from 'src/base/common/event'; import { LinkedList } from 'src/base/common/structures/linkedList'; -import { AlphabetInString, AlphabetInStringCap, AlphabetInStringLow, AnyOf, AreEqual, Comparator, ConcatArray, Constructor, DeepMutable, DeepReadonly, Dictionary, DightInString, IsArray, IsBoolean, IsNull, IsNumber, IsObject, IsString, IsTruthy, MapTypes, Mutable, Negate, NestedArray, NonUndefined, nullToUndefined, NumberDictionary, Pair, Pop, Promisify, Push, Single, SplitString, StringDictionary, Triple, ifOrDefault, isBoolean, isEmptyObject, isIterable, isNonNullable, isNullable, isNumber, isObject, isPrimitive, isPromise, checkTrue, checkFalse, IsAny, IsNever, Or, NonEmptyArray, AtMostNArray, Falsy, NonFalsy, ArrayType, Flatten, AtLeastNArray, isTruthy, isFalsy, TupleOf, ExactConstructor, toBoolean, ReplaceType, DeepPartial } from 'src/base/common/utilities/type'; +import { AlphabetInString, AlphabetInStringCap, AlphabetInStringLow, AnyOf, AreEqual, Comparator, ConcatArray, Constructor, DeepMutable, DeepReadonly, Dictionary, DightInString, IsArray, IsBoolean, IsNull, IsNumber, IsObject, IsString, IsTruthy, MapTypes, Mutable, Negate, NestedArray, NonUndefined, nullToUndefined, NumberDictionary, Pair, Pop, Promisify, Push, Single, SplitString, StringDictionary, Triple, ifOrDefault, isBoolean, isEmptyObject, isIterable, isNonNullable, isNullable, isNumber, isObject, isPrimitive, isPromise, checkTrue, checkFalse, IsAny, IsNever, Or, NonEmptyArray, AtMostNArray, Falsy, NonFalsy, ArrayType, Flatten, AtLeastNArray, isTruthy, isFalsy, TupleOf, ExactConstructor, toBoolean, ReplaceType, DeepPartial, AsyncOnly } from 'src/base/common/utilities/type'; +import { IService } from 'src/platform/instantiation/common/decorator'; suite('type-test', () => { @@ -676,6 +678,21 @@ suite('typescript-types-test', () => { // no counter example as assigning another value would be a compile error }); + test('AsyncOnly type', () => { + interface IOkService extends IService { + onData: Register; + fn(): Promise; + } + + interface IErrService extends IService { + onData: Register; + fn(): string; + } + checkTrue>>(); + checkTrue['fn'] extends () => Promise ? true : false>>(); + checkTrue['fn']>>(); + }); + suite('ReplaceType utility type', () => { test('should replace ContextKeyExpr with boolean in a nested object', () => { interface ContextKeyExpr { num?: number; } diff --git a/test/code/platform/net.test.ts b/test/code/platform/net.test.ts index 8f251e851..1d72a102d 100644 --- a/test/code/platform/net.test.ts +++ b/test/code/platform/net.test.ts @@ -14,6 +14,7 @@ import { TestIPC } from 'test/utils/testService'; const TestChannelId = 'testchannel'; interface ITestService extends IService { + ping(message: string): Promise; marco(): Promise; error(message: string): Promise; neverComplete(): Promise; @@ -48,7 +49,7 @@ class TestService implements ITestService { return Promise.resolve(buffers.reduce((r, b) => r + b.buffer.length, 0)); } - ping(msg: string): void { + async ping(msg: string): Promise { this._onPong.fire(msg); } @@ -116,6 +117,10 @@ class TestClientChannel implements ITestService { context(): Promise { return this.channel.callCommand('context'); } + + ping(message: string): Promise { + return this.channel.callCommand('ping', [message]); + } } suite('IPC-test', function () { @@ -177,11 +182,11 @@ suite('IPC-test', function () { await delayFor(INSTANT_TIME); assert.deepStrictEqual(messages, []); - service.ping('hello'); + await service.ping('hello'); await delayFor(INSTANT_TIME); assert.deepStrictEqual(messages, ['hello']); - service.ping('world'); + await service.ping('world'); await delayFor(INSTANT_TIME); assert.deepStrictEqual(messages, ['hello', 'world']); @@ -196,7 +201,7 @@ suite('IPC-test', function () { suite('one to one (proxy)', function () { let server: ServerBase; let client: ClientBase; - let service: TestService; + let service: ITestService; let ipcService: ITestService; before(function () { @@ -236,11 +241,11 @@ suite('IPC-test', function () { await delayFor(INSTANT_TIME); assert.deepStrictEqual(messages, []); - service.ping('hello'); + await service.ping('hello'); await delayFor(INSTANT_TIME); assert.deepStrictEqual(messages, ['hello']); - service.ping('world'); + await service.ping('world'); await delayFor(INSTANT_TIME); assert.deepStrictEqual(messages, ['hello', 'world']); @@ -270,7 +275,7 @@ suite('IPC-test', function () { ipcService2.onPong(() => client2GotPinged = true); await delayFor(new Time(TimeUnit.Milliseconds, 1)); - service.ping('hello'); + await service.ping('hello'); await delayFor(new Time(TimeUnit.Milliseconds, 1)); assert.ok(client1GotPinged); @@ -280,52 +285,5 @@ suite('IPC-test', function () { client2.dispose(); server.dispose(); }); - - // // TODO - // // test('server gets pings from all clients (broadcast channel)', async function () { - // // const server = new TestIPCServer(); - - // // const client1 = server.createConnection('client1'); - // // const clientService1 = new TestService(); - // // const clientChannel1 = new TestServerChannel(clientService1); - // // client1.registerChannel('channel', clientChannel1); - - // // const pings: string[] = []; - // // const channel = server.getChannel('channel', () => true); - // // const service = new TestClientChannel(channel); - // // service.onPong(msg => pings.push(msg)); - - // // await delayFor(1); - // // clientService1.ping('hello 1'); - - // // await delayFor(1); - // // assert.deepStrictEqual(pings, ['hello 1']); - - // // const client2 = server.createConnection('client2'); - // // const clientService2 = new TestService(); - // // const clientChannel2 = new TestServerChannel(clientService2); - // // client2.registerChannel('channel', clientChannel2); - - // // await delayFor(1); - // // clientService2.ping('hello 2'); - - // // await delayFor(1); - // // assert.deepStrictEqual(pings, ['hello 1', 'hello 2']); - - // // client1.dispose(); - // // clientService1.ping('hello 1'); - - // // await delayFor(1); - // // assert.deepStrictEqual(pings, ['hello 1', 'hello 2']); - - // // await delayFor(1); - // // clientService2.ping('hello again 2'); - - // // await delayFor(1); - // // assert.deepStrictEqual(pings, ['hello 1', 'hello 2', 'hello again 2']); - - // // client2.dispose(); - // // server.dispose(); - // // }); }); });