diff --git a/example b/example new file mode 100644 index 00000000..e69de29b diff --git a/package-lock.json b/package-lock.json index 9a3202b0..a537062c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "twinny", - "version": "3.10.16", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twinny", - "version": "3.10.16", + "version": "3.11.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -25,7 +25,8 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "stream-http": "^3.2.0", - "string_score": "^0.1.22" + "string_score": "^0.1.22", + "uuid": "^9.0.1" }, "devDependencies": { "@types/async-lock": "^1.4.2", @@ -36,6 +37,7 @@ "@types/react-dom": "^18.2.18", "@types/react-syntax-highlighter": "^15.5.11", "@types/string_score": "^0.1.31", + "@types/uuid": "^9.0.8", "@types/vscode": "^1.70.0", "@typescript-eslint/eslint-plugin": "^5.31.0", "@typescript-eslint/parser": "^5.31.0", @@ -596,6 +598,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@types/vscode": { "version": "1.87.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.87.0.tgz", @@ -8149,6 +8157,18 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/package.json b/package.json index 7a0355a7..31f30714 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "twinny", "displayName": "twinny - AI Code Completion and Chat", "description": "Locally hosted AI code completion plugin for vscode", - "version": "3.10.16", + "version": "3.11.0", "icon": "assets/icon.png", "keywords": [ "code-inference", @@ -84,27 +84,32 @@ { "command": "twinny.openChat", "group": "navigation@0", - "when": "view == twinny.sidebar && twinnyManageTemplates" + "when": "view == twinny.sidebar && twinnyManageTemplates || view == twinny.sidebar && twinnyManageProviders" }, { - "command": "twinny.manageTemplates", + "command": "twinny.manageProviders", "group": "navigation@1", "when": "view == twinny.sidebar" }, + { + "command": "twinny.manageTemplates", + "group": "navigation@2", + "when": "view == twinny.sidebar" + }, { "command": "twinny.templates", "when": "view == twinny.sidebar", - "group": "navigation@2" + "group": "navigation@3" }, { "command": "twinny.newChat", "when": "view == twinny.sidebar", - "group": "navigation@3" + "group": "navigation@4" }, { "command": "twinny.settings", "when": "view == twinny.sidebar", - "group": "navigation@4" + "group": "navigation@5" } ] }, @@ -176,6 +181,12 @@ "title": "Manage twinny templates", "icon": "$(files)" }, + { + "command": "twinny.manageProviders", + "shortTitle": "Manage twinny providers", + "title": "Manage twinny providers", + "icon": "$(plug)" + }, { "command": "twinny.openChat", "shortTitle": "Back to chat view", @@ -233,129 +244,35 @@ "default": true, "description": "Activates or deactivates the Twinny extension." }, - "twinny.apiHostname": { - "order": 1, - "type": "string", - "default": "0.0.0.0", - "description": "Hostname for chat completion API.", - "required": true - }, - "twinny.apiFimHostname": { - "order": 2, - "type": "string", - "default": "0.0.0.0", - "description": "Hostname for FIM completion API.", - "required": true - }, - "twinny.apiProvider": { - "order": 3, - "type": "string", - "enum": [ - "ollama", - "llamacpp", - "lmstudio", - "oobabooga", - "litellm" - ], - "default": "ollama", - "description": "API Chat provider." - }, - "twinny.apiProviderFim": { - "order": 4, - "type": "string", - "enum": [ - "ollama", - "llamacpp", - "lmstudio", - "oobabooga", - "litellm" - ], - "default": "ollama", - "description": "API FIM provider." - }, - "twinny.chatApiPath": { - "order": 5, - "type": "string", - "default": "/v1/chat/completions", - "description": "Endpoint path for chat completions.", - "required": true - }, - "twinny.chatApiPort": { - "order": 6, - "type": "number", - "default": 11434, - "description": "The API port usually `11434` for Ollama and `8080` for llama.cpp (May differ depending on API configuration)", - "required": true - }, - "twinny.fimApiPort": { - "order": 7, - "type": "number", - "default": 11434, - "description": "The API port usually `11434` for Ollama and `8080` for llama.cpp (May differ depending on API configuration)", - "required": true - }, - "twinny.fimApiPath": { - "order": 8, - "type": "string", - "default": "/api/generate", - "description": "Endpoint path for FIM completions.", - "required": true - }, - "twinny.chatModelName": { - "order": 9, - "type": "string", - "default": "codellama:7b-instruct", - "description": "Model identifier for chat completions. Applicable only for Ollama and Oobabooga API." - }, - "twinny.fimModelName": { - "order": 10, - "type": "string", - "default": "codellama:7b-code", - "description": "Model identifier for FIM completions. Applicable only for Ollama and Oobabooga API." - }, - "twinny.fimTemplateFormat": { - "order": 11, - "type": "string", - "enum": [ - "automatic", - "stable-code", - "codellama", - "deepseek", - "starcoder", - "custom-template" - ], - "default": "automatic", - "description": "The prompt format to be used for FIM completions. Overrides automatic detection." - }, "twinny.disableAutoSuggest": { - "order": 12, + "order": 1, "type": "boolean", "default": false, "description": "Disables automatic suggestions, manual trigger (default shortcut Alt+\\)." }, "twinny.contextLength": { - "order": 13, + "order": 2, "type": "number", "default": 100, "description": "Defines the number of lines before and after the current line to include in FIM prompts.", "required": true }, "twinny.debounceWait": { - "order": 14, + "order": 3, "type": "number", "default": 300, "description": "Delay in milliseconds before triggering the next completion.", "required": true }, "twinny.temperature": { - "order": 15, + "order": 4, "type": "number", "default": 0.2, "description": "Sets the model's creativity level (temperature) for generating completions.", "required": true }, "twinny.useMultiLineCompletions": { - "order": 16, + "order": 5, "type": "boolean", "default": false, "description": "Use multiline completions (Can be inaccurate)." @@ -364,63 +281,51 @@ "dependencies": { "twinny.useMultiLineCompletions": true }, - "order": 17, + "order": 6, "type": "number", - "default": 20, + "default": 7, "description": "Maximum number of lines to use for multi line completions. Applicable only when useMultiLineCompletions is enabled." }, "twinny.useFileContext": { - "order": 18, + "order": 8, "type": "boolean", "default": false, "description": "Enables scanning of neighbouring documents to enhance completion prompts. (Experimental)" }, "twinny.enableCompletionCache": { - "order": 19, + "order": 9, "type": "boolean", "default": false, "description": "Caches FIM completions for identical prompts to enhance performance." }, "twinny.numPredictChat": { - "order": 20, + "order": 10, "type": "number", "default": 512, "description": "Maximum token limit for chat completions.", "required": true }, "twinny.numPredictFim": { - "order": 21, + "order": 11, "type": "number", "default": 512, "description": "Maximum token limit for FIM completions. Set to -1 for no limit. Twinny should stop at logical line breaks.", "required": true }, "twinny.enableSubsequentCompletions": { - "order": 22, + "order": 12, "type": "boolean", "default": true, "description": "Enable this setting to allow twinny to keep making subsequent completion requests to the API after the last completion request was accepted." }, "twinny.keepAlive": { - "order": 23, + "order": 13, "type": "string", "default": "5m", "description": "Keep models in memory by making requests with keep_alive=-1. Applicable only for Ollama API." }, - "twinny.useTls": { - "order": 24, - "type": "boolean", - "default": false, - "description": "Enables TLS encryption for API connections." - }, - "twinny.apiBearerToken": { - "order": 25, - "type": "string", - "default": "", - "description": "Bearer token for secure API authentication." - }, "twinny.enableLogging": { - "order": 26, + "order": 14, "type": "boolean", "default": true, "description": "Enable twinny debug mode" @@ -451,6 +356,7 @@ "@types/react-dom": "^18.2.18", "@types/react-syntax-highlighter": "^15.5.11", "@types/string_score": "^0.1.31", + "@types/uuid": "^9.0.8", "@types/vscode": "^1.70.0", "@typescript-eslint/eslint-plugin": "^5.31.0", "@typescript-eslint/parser": "^5.31.0", @@ -486,6 +392,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", "stream-http": "^3.2.0", - "string_score": "^0.1.22" + "string_score": "^0.1.22", + "uuid": "^9.0.1" } } diff --git a/src/common/constants.ts b/src/common/constants.ts index cd693e85..03958e67 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -52,7 +52,7 @@ export const MESSAGE_NAME = { twinnySetOllamaModel: 'twinny-set-ollama-model', twinnySetConfigValue: 'twinny-set-config-value', twinnyGetConfigValue: 'twinny-get-config-value', - twinnyGetGitChanges: 'twinny-get-git-changes', + twinnyGetGitChanges: 'twinny-get-git-changes' } export const MESSAGE_KEY = { @@ -61,7 +61,8 @@ export const MESSAGE_KEY = { downloadCancelled: 'downloadCancelled', lastConversation: 'lastConversation', selectedTemplates: 'selectedTemplates', - selection: 'selection' + selection: 'selection', + showProviders: 'showProviders', } export const SETTING_KEY = { @@ -73,12 +74,14 @@ export const SETTING_KEY = { export const CONTEXT_NAME = { twinnyGeneratingText: 'twinnyGeneratingText', - twinnyManageTemplates: 'twinnyManageTemplates' + twinnyManageTemplates: 'twinnyManageTemplates', + twinnyManageProviders: 'twinnyManageProviders' } export const UI_TABS = { chat: 'chat', - templates: 'templates' + templates: 'templates', + providers: 'providers' } export const FIM_TEMPLATE_FORMAT = { @@ -112,6 +115,29 @@ export const DEFAULT_ACTION_TEMPLATES = [ 'explain' ] +export const DEFAULT_PROVIDER_FORM_VALUES = { + apiHostname: '0.0.0.0', + apiKey: '', + apiPath: '', + apiPort: 11434, + apiProtocol: 'http', + id: '', + label: '', + modelName: '', + name: '', + provider: 'ollama', + type: 'chat' +} + +export const FIM_TEMPLATE_TYPE = [ + 'automatic', + 'stable-code', + 'codellama', + 'deepseek', + 'starcoder', + 'custom-template' +] + export const WASM_LANGAUAGES: { [key: string]: string } = { cpp: 'cpp', hpp: 'cpp', diff --git a/src/common/types.ts b/src/common/types.ts index a145e21b..9dfa255d 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -174,13 +174,14 @@ export interface ApiModels { models: ApiModel[] } -export type ResolvedInlineCompletion = InlineCompletionItem[] -| InlineCompletionList -| PromiseLike< - InlineCompletionItem[] | InlineCompletionList | null | undefined - > -| null -| undefined +export type ResolvedInlineCompletion = + | InlineCompletionItem[] + | InlineCompletionList + | PromiseLike< + InlineCompletionItem[] | InlineCompletionList | null | undefined + > + | null + | undefined export interface InteractionItem { keyStrokes: number | null | undefined @@ -193,3 +194,15 @@ export interface InteractionItem { character: number }[] } + +export interface InferenceProvider { + apiBaseUrl?: string + apiHostname?: string + apiKey?: string + apiPath?: string + apiPort?: number + apiProtocol?: string + modelName?: string + name: string + type: (typeof ApiProviders)[keyof typeof ApiProviders] +} diff --git a/src/extension/chat-service.ts b/src/extension/chat-service.ts index 8972b7fe..4c64648c 100644 --- a/src/extension/chat-service.ts +++ b/src/extension/chat-service.ts @@ -1,4 +1,11 @@ -import { StatusBarItem, WebviewView, commands, window, workspace } from 'vscode' +import { + StatusBarItem, + WebviewView, + commands, + window, + workspace, + ExtensionContext +} from 'vscode' import { CONTEXT_NAME, MESSAGE_NAME, UI_TABS, USER } from '../common/constants' import { @@ -17,34 +24,31 @@ import { TemplateProvider } from './template-provider' import { streamResponse } from './stream' import { createStreamRequestBody } from './provider-options' import { kebabToSentence } from '../webview/utils' +import { ACTIVE_CHAT_PROVIDER_KEY, TwinnyProvider } from './provider-manager' export class ChatService { private _config = workspace.getConfiguration('twinny') - private _apiHostname = this._config.get('apiHostname') as string - private _apiPath = this._config.get('chatApiPath') as string - private _apiProvider = this._config.get('apiProvider') as string - private _bearerToken = this._config.get('apiBearerToken') as string - private _chatModel = this._config.get('chatModelName') as string private _completion = '' private _controller?: AbortController + private _extensionContext?: ExtensionContext private _keepAlive = this._config.get('keepAlive') as string | number private _numPredictChat = this._config.get('numPredictChat') as number - private _port = this._config.get('chatApiPort') as string private _promptTemplate = '' private _statusBar: StatusBarItem private _temperature = this._config.get('temperature') as number private _templateProvider?: TemplateProvider - private _useTls = this._config.get('useTls') as boolean private _view?: WebviewView constructor( statusBar: StatusBarItem, templateDir: string, + extensionContext: ExtensionContext, view?: WebviewView ) { this._view = view this._statusBar = statusBar this._templateProvider = new TemplateProvider(templateDir) + this._extensionContext = extensionContext workspace.onDidChangeConfiguration((event) => { if (!event.affectsConfiguration('twinny')) { return @@ -53,24 +57,35 @@ export class ChatService { }) } + private getProvider = () => { + const provider = this._extensionContext?.globalState.get( + ACTIVE_CHAT_PROVIDER_KEY + ) + return provider + } + private buildStreamRequest( prompt: string, messages?: MessageType[] | MessageRoleContent[] ) { + const provider = this.getProvider() + + if (!provider) return + const requestOptions: StreamRequestOptions = { - hostname: this._apiHostname, - port: this._port, - path: this._apiPath, - protocol: this._useTls ? 'https' : 'http', + hostname: provider.apiHostname, + port: Number(provider.apiPort), + path: provider.apiPath, + protocol: provider.apiProtocol, method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this._bearerToken}` + Authorization: `Bearer ${provider.apiKey}` } } - const requestBody = createStreamRequestBody(this._apiProvider, prompt, { - model: this._chatModel, + const requestBody = createStreamRequestBody(provider.provider, prompt, { + model: provider.modelName, numPredictChat: this._numPredictChat, temperature: this._temperature, messages, @@ -84,8 +99,11 @@ export class ChatService { streamResponse: StreamResponse | undefined, onEnd?: (completion: string) => void ) => { + const provider = this.getProvider() + if (!provider) return + try { - const data = getChatDataFromProvider(this._apiProvider, streamResponse) + const data = getChatDataFromProvider(provider.provider, streamResponse) this._completion = this._completion + data if (onEnd) return this._view?.webview.postMessage({ @@ -112,7 +130,7 @@ export class ChatService { if (onEnd) { onEnd(this._completion) this._view?.webview.postMessage({ - type: MESSAGE_NAME.twinnyOnEnd, + type: MESSAGE_NAME.twinnyOnEnd } as ServerMessage) return } @@ -285,10 +303,9 @@ export class ChatService { this.sendEditorLanguage() const messageRoleContent = await this.buildMesageRoleContent(messages) const prompt = await this.buildChatPrompt(messages) - const { requestBody, requestOptions } = this.buildStreamRequest( - prompt, - messageRoleContent - ) + const request = this.buildStreamRequest(prompt, messageRoleContent) + if (!request) return + const { requestBody, requestOptions } = request return this.streamResponse({ requestBody, requestOptions }) } @@ -328,22 +345,15 @@ export class ChatService { ], language ) - const { requestBody, requestOptions } = this.buildStreamRequest( - prompt, - messageRoleContent - ) + const request = this.buildStreamRequest(prompt, messageRoleContent) + if (!request) return + const { requestBody, requestOptions } = request return this.streamResponse({ requestBody, requestOptions, onEnd }) } private updateConfig() { this._config = workspace.getConfiguration('twinny') this._temperature = this._config.get('temperature') as number - this._chatModel = this._config.get('chatModelName') as string - this._apiPath = this._config.get('chatApiPath') as string - this._port = this._config.get('chatApiPort') as string - this._apiHostname = this._config.get('apiHostname') as string - this._apiProvider = this._config.get('apiProvider') as string this._keepAlive = this._config.get('keepAlive') as string | number - this._useTls = this._config.get('useTls') as boolean } } diff --git a/src/extension/fim-templates.ts b/src/extension/fim-templates.ts index fa0dad16..bc9270bc 100644 --- a/src/extension/fim-templates.ts +++ b/src/extension/fim-templates.ts @@ -22,8 +22,7 @@ export const getFimPromptTemplateLLama = ({ languageId?.syntaxComments?.end || '' }` : '' - const heading = header ? header : '' - + const heading = useFileContext && header ? header : '' return `
${fileContext} \n${heading}${prefix}  ${suffix} `
 }
 
@@ -40,7 +39,7 @@ export const getDefaultFimPromptTemplate = ({
   const fileContext = useFileContext
     ? `${languageId?.syntaxComments?.start}${context}${languageId?.syntaxComments?.end}`
     : ''
-  const heading = header ? header : ''
+  const heading = useFileContext && header ? header : ''
   return `
 ${fileContext}\n${heading}${prefix}  ${suffix} `
 }
 
@@ -57,7 +56,7 @@ export const getFimPromptTemplateDeepseek = ({
   const fileContext = useFileContext
     ? `${languageId?.syntaxComments?.start}${context}${languageId?.syntaxComments?.end}`
     : ''
-  const heading = header ? header : ''
+  const heading = useFileContext && header ? header : ''
   return `<|fim▁begin|>${fileContext}\n${heading}${prefix}<|fim▁hole|>${suffix}<|fim▁end|>`
 }
 
@@ -159,7 +158,10 @@ export const getStopWordsChosen = (format: string) => {
 }
 
 export const getStopWords = (fimModel: string, format: string) => {
-  if (format === FIM_TEMPLATE_FORMAT.automatic || format === FIM_TEMPLATE_FORMAT.custom) {
+  if (
+    format === FIM_TEMPLATE_FORMAT.automatic ||
+    format === FIM_TEMPLATE_FORMAT.custom
+  ) {
     return getStopWordsAuto(fimModel)
   }
   return getStopWordsChosen(format)
diff --git a/src/extension/provider-manager.ts b/src/extension/provider-manager.ts
new file mode 100644
index 00000000..56282b4e
--- /dev/null
+++ b/src/extension/provider-manager.ts
@@ -0,0 +1,255 @@
+import { ExtensionContext, WebviewView } from 'vscode'
+import { ApiProviders, ClientMessage, ServerMessage } from '../common/types'
+import { FIM_TEMPLATE_FORMAT, UI_TABS } from '../common/constants'
+import { v4 as uuidv4 } from 'uuid'
+
+export const PROVIDER_MESSAGE_TYPE = {
+  addProvider: 'twinny.add-provider',
+  getActiveChatProvider: 'twinny.get-active-provider',
+  getActiveFimProvider: 'twinny.get-active-fim-provider',
+  getAllProviders: 'twinny.get-providers',
+  removeProvider: 'twinny.remove-provider',
+  setActiveChatProvider: 'twinny.set-active-chat-provider',
+  setActiveFimProvider: 'twinny.set-active-fim-provider',
+  updateProvider: 'twinny.update-provider',
+  focusProviderTab: 'twinny.focus-provider-tab',
+  copyProvider: 'twinny.copy-provider',
+  resetProvidersToDefaults: 'twinny.reset-providers-to-defaults'
+}
+
+export interface TwinnyProvider {
+  apiHostname: string
+  apiPath: string
+  apiPort: number
+  apiProtocol: string
+  id: string
+  label: string
+  modelName: string
+  provider: string
+  type: string
+  apiKey?: string
+  fimTemplate?: string
+}
+
+export const ACTIVE_CHAT_PROVIDER_KEY = 'twinny.active-chat-provider'
+export const ACTIVE_FIM_PROVIDER_KEY = 'twinny.active-fim-provider'
+export const INFERENCE_PROVIDERS_KEY = 'twinny.inference-providers'
+
+type Providers = Record | undefined
+
+export class ProviderManager {
+  _context: ExtensionContext
+  _webviewView: WebviewView
+
+  constructor(context: ExtensionContext, webviewView: WebviewView) {
+    this._context = context
+    this._webviewView = webviewView
+    this.setUpEventListeners()
+    this.addDefaultProviders()
+  }
+
+  setUpEventListeners() {
+    this._webviewView.webview.onDidReceiveMessage(
+      (message: ClientMessage) => {
+        this.handleMessage(message)
+      }
+    )
+  }
+
+  handleMessage(message: ClientMessage) {
+    const { data: provider } = message
+    switch (message.type) {
+      case PROVIDER_MESSAGE_TYPE.addProvider:
+        return this.addProvider(provider)
+      case PROVIDER_MESSAGE_TYPE.removeProvider:
+        return this.removeProvider(provider)
+      case PROVIDER_MESSAGE_TYPE.updateProvider:
+        return this.updateProvider(provider)
+      case PROVIDER_MESSAGE_TYPE.getActiveChatProvider:
+        return this.getActiveChatProvider()
+      case PROVIDER_MESSAGE_TYPE.getActiveFimProvider:
+        return this.getActiveFimProvider()
+      case PROVIDER_MESSAGE_TYPE.setActiveChatProvider:
+        return this.setActiveChatProvider(provider)
+      case PROVIDER_MESSAGE_TYPE.setActiveFimProvider:
+        return this.setActiveFimProvider(provider)
+      case PROVIDER_MESSAGE_TYPE.copyProvider:
+        return this.copyProvider(provider)
+      case PROVIDER_MESSAGE_TYPE.getAllProviders:
+        return this.getAllProviders()
+      case PROVIDER_MESSAGE_TYPE.resetProvidersToDefaults:
+        return this.resetProvidersToDefaults()
+    }
+  }
+
+  public focusProviderTab = () => {
+    this._webviewView?.webview.postMessage({
+      type: PROVIDER_MESSAGE_TYPE.focusProviderTab,
+      value: {
+        data: UI_TABS.providers
+      }
+    } as ServerMessage)
+  }
+
+  getDefaultChatProvider() {
+    return {
+      apiHostname: '0.0.0.0',
+      apiPath: '/v1/chat/completions',
+      apiPort: 11434,
+      apiProtocol: 'http',
+      id: uuidv4(),
+      label: 'Ollama 7B Chat',
+      modelName: 'codellama:7b-instruct',
+      provider: ApiProviders.Ollama,
+      type: 'chat'
+    } as TwinnyProvider
+  }
+
+  getDefaultFimProvider() {
+    return {
+      apiHostname: '0.0.0.0',
+      apiPath: '/api/generate',
+      apiPort: 11434,
+      apiProtocol: 'http',
+      fimTemplate: FIM_TEMPLATE_FORMAT.codellama,
+      label: 'Ollama 7B FIM',
+      id: uuidv4(),
+      modelName: 'codellama:7b-code',
+      provider: ApiProviders.Ollama,
+      type: 'fim'
+    } as TwinnyProvider
+  }
+
+  addDefaultProviders() {
+    this.addDefaultChatProvider()
+    this.addDefaultFimProvider()
+  }
+
+  addDefaultChatProvider(): TwinnyProvider {
+    const provider = this.getDefaultChatProvider()
+    if (!this._context.globalState.get(ACTIVE_CHAT_PROVIDER_KEY)) {
+      this.addDefaultProvider(provider)
+    }
+    return provider
+  }
+
+  addDefaultFimProvider(): TwinnyProvider {
+    const provider = this.getDefaultFimProvider()
+    if (!this._context.globalState.get(ACTIVE_FIM_PROVIDER_KEY)) {
+      this.addDefaultProvider(provider)
+    }
+    return provider
+  }
+
+  addDefaultProvider(provider: TwinnyProvider): void {
+    if (provider.type === 'chat') {
+      this._context.globalState.update(ACTIVE_CHAT_PROVIDER_KEY, provider)
+    } else {
+      this._context.globalState.update(ACTIVE_FIM_PROVIDER_KEY, provider)
+    }
+    this.addProvider(provider)
+  }
+
+  getProviders(): Providers {
+    const providers = this._context.globalState.get<
+      Record
+    >(INFERENCE_PROVIDERS_KEY)
+    return providers
+  }
+
+  getAllProviders() {
+    const providers = this.getProviders() || {}
+    this._webviewView.webview.postMessage({
+      type: PROVIDER_MESSAGE_TYPE.getAllProviders,
+      value: {
+        data: providers
+      }
+    })
+  }
+
+  getActiveChatProvider() {
+    const provider = this._context.globalState.get(
+      ACTIVE_CHAT_PROVIDER_KEY
+    )
+    this._webviewView.webview.postMessage({
+      type: PROVIDER_MESSAGE_TYPE.getActiveChatProvider,
+      value: {
+        data: provider
+      }
+    })
+    return provider
+  }
+
+  getActiveFimProvider() {
+    const provider = this._context.globalState.get(
+      ACTIVE_FIM_PROVIDER_KEY
+    )
+    this._webviewView.webview.postMessage({
+      type: PROVIDER_MESSAGE_TYPE.getActiveFimProvider,
+      value: {
+        data: provider
+      }
+    })
+    return provider
+  }
+
+  setActiveChatProvider(provider?: TwinnyProvider) {
+    if (!provider) return
+    this._context.globalState.update(ACTIVE_CHAT_PROVIDER_KEY, provider)
+    return this.getActiveChatProvider()
+  }
+
+  setActiveFimProvider(provider?: TwinnyProvider) {
+    if (!provider) return
+    this._context.globalState.update(ACTIVE_FIM_PROVIDER_KEY, provider)
+    return this.getActiveFimProvider()
+  }
+
+  addProvider(provider?: TwinnyProvider) {
+    const providers = this.getProviders() || {}
+    if (!provider) return
+    provider.id = uuidv4()
+    providers[provider.id] = provider
+    this._context.globalState.update(INFERENCE_PROVIDERS_KEY, providers)
+    this.getAllProviders()
+  }
+
+  copyProvider(provider?: TwinnyProvider) {
+    if (!provider) return
+    provider.id = uuidv4()
+    provider.label = `${provider.label}-copy`
+    this.addProvider(provider)
+  }
+
+  removeProvider(provider?: TwinnyProvider) {
+    const providers = this.getProviders() || {}
+    if (!provider) return
+    delete providers[provider.id]
+    this._context.globalState.update(INFERENCE_PROVIDERS_KEY, providers)
+    this.getAllProviders()
+  }
+
+  updateProvider(provider?: TwinnyProvider) {
+    const providers = this.getProviders() || {}
+    const activeFimProvider = this.getActiveFimProvider()
+    const activeChatProvider = this.getActiveChatProvider()
+    if (!provider) return
+    providers[provider.id] = provider
+    this._context.globalState.update(INFERENCE_PROVIDERS_KEY, providers)
+    if (provider.id === activeFimProvider?.id) this.setActiveFimProvider(provider)
+    if (provider.id === activeChatProvider?.id) this.setActiveChatProvider(provider)
+    this.getAllProviders()
+  }
+
+  resetProvidersToDefaults(): void {
+    this._context.globalState.update(INFERENCE_PROVIDERS_KEY, undefined)
+    this._context.globalState.update(ACTIVE_CHAT_PROVIDER_KEY, undefined)
+    this._context.globalState.update(ACTIVE_FIM_PROVIDER_KEY, undefined)
+    const chatProvider = this.addDefaultChatProvider()
+    const fimProvider = this.addDefaultFimProvider()
+    this.focusProviderTab()
+    this.setActiveChatProvider(chatProvider)
+    this.setActiveFimProvider(fimProvider)
+    this.getAllProviders()
+  }
+}
diff --git a/src/extension/providers/completion.ts b/src/extension/providers/completion.ts
index 087c740b..3f0767b0 100644
--- a/src/extension/providers/completion.ts
+++ b/src/extension/providers/completion.ts
@@ -10,7 +10,8 @@ import {
   window,
   Uri,
   InlineCompletionContext,
-  InlineCompletionTriggerKind
+  InlineCompletionTriggerKind,
+  ExtensionContext
 } from 'vscode'
 import AsyncLock from 'async-lock'
 import 'string_score'
@@ -43,31 +44,23 @@ import { CompletionFormatter } from '../completion-formatter'
 import { FileInteractionCache } from '../file-interaction'
 import { getLineBreakCount } from '../../webview/utils'
 import { TemplateProvider } from '../template-provider'
+import { ACTIVE_FIM_PROVIDER_KEY, TwinnyProvider } from '../provider-manager'
 
 export class CompletionProvider implements InlineCompletionItemProvider {
   private _config = workspace.getConfiguration('twinny')
   private _abortController: AbortController | null
   private _acceptedLastCompletion = false
-  private _apiDefaultHostName = this._config.get('apiHostname') as string
-  private _apiFimHostname = this._config.get('apiFimHostname') as string
-  private _apiPath = this._config.get('fimApiPath') as string
-  private _apiProviderDefault = this._config.get('apiProvider') as string
-  private _apiProviderFim = this._config.get('apiProviderFim') as string
-  private _bearerToken = this._config.get('apiBearerToken') as string
   private _cacheEnabled = this._config.get('enableCompletionCache') as boolean
   private _chunkCount = 0
   private _completion = ''
   private _debouncer: NodeJS.Timeout | undefined
   private _debounceWait = this._config.get('debounceWait') as number
-  private _disableAutoSuggest = this._config.get(
-    'disableAutoSuggest'
-  ) as boolean
+  private _disableAuto = this._config.get('disableAutoSuggest') as boolean
   private _document: TextDocument | null
   private _enabled = this._config.get('enabled')
   private _enableSubsequent = this._config.get('enableSubsequent') as boolean
+  private _extensionContext: ExtensionContext
   private _fileInteractionCache: FileInteractionCache
-  private _fimModel = this._config.get('fimModelName') as string
-  private _fimTemplateFormat = this._config.get('fimTemplateFormat') as string
   private _keepAlive = this._config.get('keepAlive') as string | number
   private _lastCompletionMultiline = false
   private _lastCompletionText = ''
@@ -77,21 +70,19 @@ export class CompletionProvider implements InlineCompletionItemProvider {
   private _nonce = 0
   private _numLineContext = this._config.get('contextLength') as number
   private _numPredictFim = this._config.get('numPredictFim') as number
-  private _port = this._config.get('fimApiPort') as number
   private _position: Position | null
   private _statusBar: StatusBarItem
-  private _stopWords = getStopWords(this._fimModel, this._fimTemplateFormat)
   private _temperature = this._config.get('temperature') as number
   private _templateProvider: TemplateProvider
   private _useFileContext = this._config.get('useFileContext') as boolean
   private _useMultiLine = this._config.get('useMultiLineCompletions') as boolean
-  private _useTls = this._config.get('useTls') as boolean
   private _usingFimTemplate = false
 
   constructor(
     statusBar: StatusBarItem,
     fileInteractionCache: FileInteractionCache,
-    templateProvider: TemplateProvider
+    templateProvider: TemplateProvider,
+    extentionContext: ExtensionContext
   ) {
     this._abortController = null
     this._document = null
@@ -101,6 +92,7 @@ export class CompletionProvider implements InlineCompletionItemProvider {
     this._statusBar = statusBar
     this._fileInteractionCache = fileInteractionCache
     this._templateProvider = templateProvider
+    this._extensionContext = extentionContext
   }
 
   public async provideInlineCompletionItems(
@@ -121,7 +113,7 @@ export class CompletionProvider implements InlineCompletionItemProvider {
 
     if (
       context.triggerKind === InlineCompletionTriggerKind.Invoke &&
-      !this._disableAutoSuggest
+      !this._disableAuto
     ) {
       this._completion = this._lastCompletionText
       return this.triggerInlineCompletion(prefixSuffix)
@@ -132,7 +124,7 @@ export class CompletionProvider implements InlineCompletionItemProvider {
       !editor ||
       isLastCompletionAccepted ||
       this._lastCompletionMultiline ||
-      getShouldSkipCompletion(context, this._disableAutoSuggest) ||
+      getShouldSkipCompletion(context, this._disableAuto) ||
       getIsMiddleWord()
     ) {
       this._statusBar.text = '🤖'
@@ -157,9 +149,10 @@ export class CompletionProvider implements InlineCompletionItemProvider {
 
     return new Promise((resolve, reject) => {
       this._debouncer = setTimeout(() => {
-        this._lock.acquire('completion', () => {
-          const { requestBody, requestOptions } =
-            this.buildStreamRequest(prompt)
+        this._lock.acquire('twinny.completion', () => {
+          const request = this.buildStreamRequest(prompt)
+          if (!request || !prompt) return
+          const { requestBody, requestOptions } = request
 
           try {
             streamResponse({
@@ -196,26 +189,25 @@ export class CompletionProvider implements InlineCompletionItemProvider {
   }
 
   private buildStreamRequest(prompt: string) {
-    const requestBody = createStreamRequestBodyFim(
-      this._apiProviderFim || this._apiProviderDefault,
-      prompt,
-      {
-        model: this._fimModel,
-        numPredictFim: this._numPredictFim,
-        temperature: this._temperature,
-        keepAlive: this._keepAlive
-      }
-    )
+    const provider = this.getFimProvider()
+    if (!provider) return
+
+    const requestBody = createStreamRequestBodyFim(provider.provider, prompt, {
+      model: provider.modelName,
+      numPredictFim: this._numPredictFim,
+      temperature: this._temperature,
+      keepAlive: this._keepAlive
+    })
 
     const requestOptions: StreamRequestOptions = {
-      hostname: this._apiFimHostname || this._apiDefaultHostName,
-      port: this._port,
-      path: this._apiPath,
-      protocol: this._useTls ? 'https' : 'http',
+      hostname: provider.apiHostname,
+      port: Number(provider.apiPort),
+      path: provider.apiPath,
+      protocol: provider.apiProtocol,
       method: 'POST',
       headers: {
         'Content-Type': 'application/json',
-        Authorization: `Bearer ${this._bearerToken}`
+        Authorization: `Bearer ${provider.apiKey}`
       }
     }
 
@@ -223,11 +215,10 @@ export class CompletionProvider implements InlineCompletionItemProvider {
   }
 
   private onData(data: StreamResponse | undefined): string {
+    const provider = this.getFimProvider()
+    if (!provider) return ''
     try {
-      const completionData = getFimDataFromProvider(
-        this._apiProviderFim || this._apiProviderDefault,
-        data
-      )
+      const completionData = getFimDataFromProvider(provider.provider, data)
       if (completionData === undefined) return ''
 
       this._completion = this._completion + completionData
@@ -357,21 +348,34 @@ export class CompletionProvider implements InlineCompletionItemProvider {
     return fileChunks.join('\n')
   }
 
+  private getFimProvider = () => {
+    const provider = this._extensionContext.globalState.get(
+      ACTIVE_FIM_PROVIDER_KEY
+    )
+    return provider
+  }
+
   private removeStopWords(completion: string) {
+    const provider = this.getFimProvider()
+    if (!provider) return completion
+    const template = provider.fimTemplate || FIM_TEMPLATE_FORMAT.automatic
     let filteredCompletion = completion
-    this._stopWords.forEach((stopWord) => {
+    const stopWords = getStopWords(provider.modelName, template)
+    stopWords.forEach((stopWord) => {
       filteredCompletion = filteredCompletion.split(stopWord).join('')
     })
     return filteredCompletion
   }
 
   private async getPrompt(prefixSuffix: PrefixSuffix) {
-    if (!this._document || !this._position) return ''
+    const provider = this.getFimProvider()
+    if (!provider) return ''
+    if (!this._document || !this._position || !provider) return ''
 
     const language = this._document.languageId
     const interactionContext = await this.getFileInteractionContext()
 
-    if (this._fimTemplateFormat === FIM_TEMPLATE_FORMAT.custom) {
+    if (provider.fimTemplate === FIM_TEMPLATE_FORMAT.custom) {
       const systemMessage =
         await this._templateProvider.readSystemMessageTemplate('fim-system.hbs')
 
@@ -390,7 +394,9 @@ export class CompletionProvider implements InlineCompletionItemProvider {
       }
     }
 
-    const prompt = getFimPrompt(this._fimModel, this._fimTemplateFormat, {
+    const template = provider.fimTemplate || FIM_TEMPLATE_FORMAT.automatic
+
+    const prompt = getFimPrompt(provider.modelName, template, {
       context: interactionContext || '',
       prefixSuffix,
       header: this.getPromptHeader(language, this._document.uri),
@@ -442,28 +448,18 @@ Using custom FIM template fim.bhs?: ${this._usingFimTemplate}
   }
 
   public updateConfig() {
-    this._apiFimHostname = this._config.get('apiFimHostname') as string
-    this._apiDefaultHostName = this._config.get('apiHostname') as string
-    this._apiPath = this._config.get('fimApiPath') as string
-    this._apiProviderDefault = this._config.get('apiProvider') as string
-    this._apiProviderFim = this._config.get('apiProviderFim') as string
-    this._bearerToken = this._config.get('apiBearerToken') as string
     this._cacheEnabled = this._config.get('enableCompletionCache') as boolean
     this._config = workspace.getConfiguration('twinny')
     this._debounceWait = this._config.get('debounceWait') as number
-    this._disableAutoSuggest = this._config.get('disableAutoSuggest') as boolean
+    this._disableAuto = this._config.get('disableAutoSuggest') as boolean
     this._enableSubsequent = this._config.get('enableSubsequent') as boolean
-    this._fimModel = this._config.get('fimModelName') as string
-    this._fimTemplateFormat = this._config.get('fimTemplateFormat') as string
     this._keepAlive = this._config.get('keepAlive') as string | number
     this._maxLines = this._config.get('maxLines') as number
     this._numLineContext = this._config.get('contextLength') as number
     this._numPredictFim = this._config.get('numPredictFim') as number
-    this._port = this._config.get('fimApiPort') as number
     this._temperature = this._config.get('temperature') as number
     this._useFileContext = this._config.get('useFileContext') as boolean
     this._useMultiLine = this._config.get('useMultiLineCompletions') as boolean
-    this._useTls = this._config.get('useTls') as boolean
     this._logger.updateConfig()
   }
 }
diff --git a/src/extension/providers/sidebar.ts b/src/extension/providers/sidebar.ts
index 8d9780cc..b5dfba49 100644
--- a/src/extension/providers/sidebar.ts
+++ b/src/extension/providers/sidebar.ts
@@ -16,6 +16,7 @@ import {
 } from '../../common/types'
 import { TemplateProvider } from '../template-provider'
 import { OllamaService } from '../ollama-service'
+import { ProviderManager } from '../provider-manager'
 
 export class SidebarProvider implements vscode.WebviewViewProvider {
   private _context: vscode.ExtensionContext
@@ -23,6 +24,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
   private _templateDir: string
   private _templateProvider: TemplateProvider
   private _ollamaService: OllamaService | undefined = undefined
+  private _providerManager: ProviderManager | undefined = undefined
   public chatService: ChatService | undefined = undefined
   public view?: vscode.WebviewView
 
@@ -42,10 +44,13 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
     this.chatService = new ChatService(
       this._statusBar,
       this._templateDir,
+      this._context,
       webviewView
     )
     this.view = webviewView
 
+    this._providerManager = new ProviderManager(this._context, this.view)
+
     webviewView.webview.options = {
       enableScripts: true,
       localResourceRoots: [this._context?.extensionUri]
diff --git a/src/index.ts b/src/index.ts
index 3cd83be6..56303def 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -40,7 +40,8 @@ export async function activate(context: ExtensionContext) {
   const completionProvider = new CompletionProvider(
     statusBar,
     fileInteractionCache,
-    templateProvider
+    templateProvider,
+    context,
   )
   const sidebarProvider = new SidebarProvider(statusBar, context, templateDir)
 
@@ -107,6 +108,19 @@ export async function activate(context: ExtensionContext) {
         true
       )
     }),
+    commands.registerCommand('twinny.manageProviders', async () => {
+      commands.executeCommand(
+        'setContext',
+        CONTEXT_NAME.twinnyManageProviders,
+        true
+      )
+      sidebarProvider.view?.webview.postMessage({
+        type: MESSAGE_NAME.twinnySetTab,
+        value: {
+          data: UI_TABS.providers
+        }
+      } as ServerMessage)
+    }),
     commands.registerCommand('twinny.manageTemplates', async () => {
       commands.executeCommand(
         'setContext',
@@ -126,6 +140,11 @@ export async function activate(context: ExtensionContext) {
         CONTEXT_NAME.twinnyManageTemplates,
         false
       )
+      commands.executeCommand(
+        'setContext',
+        CONTEXT_NAME.twinnyManageProviders,
+        false
+      )
       sidebarProvider.view?.webview.postMessage({
         type: MESSAGE_NAME.twinnySetTab,
         value: {
@@ -158,6 +177,9 @@ export async function activate(context: ExtensionContext) {
       sidebarProvider.getTwinnyWorkspaceContext({
         key: MESSAGE_KEY.lastConversation
       })
+      sidebarProvider.view?.webview.postMessage({
+        type: MESSAGE_NAME.twinnyStopGeneration,
+      } as ServerMessage)
     }),
 
     window.registerWebviewViewProvider('twinny.sidebar', sidebarProvider),
diff --git a/src/webview/chat.tsx b/src/webview/chat.tsx
index f7f4e2ec..59083d51 100644
--- a/src/webview/chat.tsx
+++ b/src/webview/chat.tsx
@@ -5,36 +5,24 @@ import {
   VSCodeTextArea,
   VSCodePanelView,
   VSCodeProgressRing,
-  VSCodeBadge
+  VSCodeBadge,
 } from '@vscode/webview-ui-toolkit/react'
 
-import {
-  ASSISTANT,
-  MESSAGE_KEY,
-  MESSAGE_NAME,
-  USER
-} from '../common/constants'
+import { ASSISTANT, MESSAGE_KEY, MESSAGE_NAME, USER } from '../common/constants'
 
 import {
   useSelection,
   useTheme,
   useWorkSpaceContext
 } from './hooks'
-import {
-  DisabledAutoScrollIcon,
-  EnabledAutoScrollIcon,
-} from './icons'
+import { DisabledAutoScrollIcon, EnabledAutoScrollIcon } from './icons'
 
 import { Suggestions } from './suggestions'
-import {
-  ClientMessage,
-  MessageType,
-  ServerMessage
-} from '../common/types'
+import { ClientMessage, MessageType, ServerMessage } from '../common/types'
 import { Message } from './message'
 import { getCompletionContent } from './utils'
-import { ModelSelect } from './model-select'
 import styles from './index.module.css'
+import { ProviderSelect } from './provider-select'
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 const global = globalThis as any
@@ -46,9 +34,14 @@ export const Chat = () => {
   const [loading, setLoading] = useState(false)
   const [messages, setMessages] = useState()
   const [completion, setCompletion] = useState()
-  const [showModelSelect, setShowModelSelect] = useState(false)
   const markdownRef = useRef(null)
   const autoScrollContext = useWorkSpaceContext(MESSAGE_KEY.autoScroll)
+  const showProvidersContext = useWorkSpaceContext(
+    MESSAGE_KEY.showProviders
+  )
+  const [showProviders, setShowProviders] = useState(
+    showProvidersContext || false
+  )
   const [isAutoScrolledEnabled, setIsAutoScrolledEnabled] = useState<
     boolean | undefined
   >(autoScrollContext)
@@ -157,6 +150,7 @@ export const Chat = () => {
       case MESSAGE_NAME.twinnyStopGeneration: {
         setCompletion(null)
         generatingRef.current = false
+        setLoading(false)
         chatRef.current?.focus()
         setTimeout(() => {
           stopRef.current = false
@@ -199,7 +193,7 @@ export const Chat = () => {
     }
   }
 
-  const togggleAutoScroll = () => {
+  const handleToggleAutoScroll = () => {
     setIsAutoScrolledEnabled((prev) => {
       global.vscode.postMessage({
         type: MESSAGE_NAME.twinnySetWorkspaceContext,
@@ -213,9 +207,20 @@ export const Chat = () => {
     })
   }
 
+  const handleToggleProviderSelection = () => {
+    setShowProviders((prev) => {
+      global.vscode.postMessage({
+        type: MESSAGE_NAME.twinnySetWorkspaceContext,
+        key: MESSAGE_KEY.showProviders,
+        data: !prev
+      } as ClientMessage)
+      return !prev
+    })
+  }
+
   const handleGetGitChanges = () => {
     global.vscode.postMessage({
-      type: MESSAGE_NAME.twinnyGetGitChanges,
+      type: MESSAGE_NAME.twinnyGetGitChanges
     } as ClientMessage)
   }
 
@@ -225,10 +230,6 @@ export const Chat = () => {
     }
   }
 
-  const handleToggleModelSelection = () => {
-    setShowModelSelect((prev) => !prev)
-  }
-
   useEffect(() => {
     window.addEventListener('message', messageEventHandler)
     chatRef.current?.focus()
@@ -242,15 +243,19 @@ export const Chat = () => {
     if (autoScrollContext !== undefined)
       setIsAutoScrolledEnabled(autoScrollContext)
 
+    if (showProvidersContext !== undefined)
+      setShowProviders(showProvidersContext)
+
     if (lastConversation?.length) {
       return setMessages(lastConversation)
     }
     setMessages([])
-  }, [lastConversation, autoScrollContext])
+  }, [lastConversation, autoScrollContext, showProvidersContext])
 
   return (
     
       
+ {showProviders && }
{messages?.map((message, index) => (
@@ -277,11 +282,10 @@ export const Chat = () => { {!!selection.length && ( )} - {showModelSelect && }
@@ -296,21 +300,21 @@ export const Chat = () => { title="Generate commit message from staged changes" appearance="icon" > - + - + {selection?.length}
🤖 diff --git a/src/webview/hooks.ts b/src/webview/hooks.ts index a6420adf..9d9533e8 100644 --- a/src/webview/hooks.ts +++ b/src/webview/hooks.ts @@ -1,13 +1,16 @@ import { useEffect, useState } from 'react' -import { MESSAGE_KEY, MESSAGE_NAME, SETTING_KEY } from '../common/constants' +import { MESSAGE_KEY, MESSAGE_NAME } from '../common/constants' import { ClientMessage, LanguageType, - ApiModel, ServerMessage, ThemeType } from '../common/types' +import { + PROVIDER_MESSAGE_TYPE, + TwinnyProvider +} from '../extension/provider-manager' // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any @@ -142,6 +145,117 @@ export const useTemplates = () => { return { templates, saveTemplates } } +export const useProviders = () => { + const [providers, setProviders] = useState>({}) + const [chatProvider, setChatProvider] = useState() + const [fimProvider, setFimProvider] = useState() + const handler = (event: MessageEvent) => { + const message: ServerMessage< + Record | TwinnyProvider + > = event.data + if (message?.type === PROVIDER_MESSAGE_TYPE.getAllProviders) { + if (message.value.data) { + const providers = message.value.data as Record + setProviders(providers) + } + } + if (message?.type === PROVIDER_MESSAGE_TYPE.getActiveChatProvider) { + if (message.value.data) { + const provider = message.value.data as TwinnyProvider + setChatProvider(provider) + } + } + if (message?.type === PROVIDER_MESSAGE_TYPE.getActiveFimProvider) { + if (message.value.data) { + const provider = message.value.data as TwinnyProvider + setFimProvider(provider) + } + } + return () => window.removeEventListener('message', handler) + } + + const saveProvider = (provider: TwinnyProvider) => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.addProvider, + data: provider + } as ClientMessage) + } + + const copyProvider = (provider: TwinnyProvider) => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.copyProvider, + data: provider + } as ClientMessage) + } + + const updateProvider = (provider: TwinnyProvider) => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.updateProvider, + data: provider + } as ClientMessage) + } + + const removeProvider = (provider: TwinnyProvider) => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.removeProvider, + data: provider + } as ClientMessage) + } + + const setActiveFimProvider = (provider: TwinnyProvider) => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.setActiveFimProvider, + data: provider + } as ClientMessage) + } + + const setActiveChatProvider = (provider: TwinnyProvider) => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.setActiveChatProvider, + data: provider + } as ClientMessage) + } + + const getFimProvidersByType = (type: string) => { + return Object.values(providers).filter( + (provider) => provider.type === type + ) as TwinnyProvider[] + } + + const resetProviders = () => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.resetProvidersToDefaults + } as ClientMessage) + } + + useEffect(() => { + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.getAllProviders + }) + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.getActiveChatProvider + }) + global.vscode.postMessage({ + type: PROVIDER_MESSAGE_TYPE.getActiveFimProvider + }) + window.addEventListener('message', handler) + }, []) + + return { + providers, + chatProvider, + fimProvider, + saveProvider, + copyProvider, + resetProviders, + updateProvider, + removeProvider, + setActiveFimProvider, + setActiveChatProvider, + getFimProvidersByType + } +} + export const useConfigurationSetting = (key: string) => { const [configurationSetting, setConfigurationSettings] = useState< string | boolean | number @@ -167,58 +281,3 @@ export const useConfigurationSetting = (key: string) => { return { configurationSetting } } - -export const useModels = () => { - const [models, setModels] = useState([]) - const [chatModelName, setChatModel] = useState() - const [fimModelName, setFimModel] = useState() - const configValueKeys = [SETTING_KEY.chatModelName, SETTING_KEY.fimModelName] - const handler = (event: MessageEvent) => { - const message: ServerMessage = event.data - if (message?.type === MESSAGE_NAME.twinnyFetchOllamaModels) { - setModels(message?.value.data) - } - if ( - message?.type === MESSAGE_NAME.twinnyGetConfigValue && - message.value.type === SETTING_KEY.chatModelName - ) { - setChatModel(message?.value.data as string | undefined) - } - if ( - message?.type === MESSAGE_NAME.twinnyGetConfigValue && - message.value.type === SETTING_KEY.fimModelName - ) { - setFimModel(message?.value.data as string | undefined) - } - return () => window.removeEventListener('message', handler) - } - - const saveModel = (model: string) => (type: string) => { - global.vscode.postMessage({ - type: MESSAGE_NAME.twinnySetConfigValue, - key: type, - data: model - } as ClientMessage) - if (type === SETTING_KEY.chatModelName) { - setChatModel(model) - } - if (type === SETTING_KEY.fimModelName) { - setFimModel(model) - } - } - - useEffect(() => { - configValueKeys.forEach((key: string) => { - global.vscode.postMessage({ - type: MESSAGE_NAME.twinnyGetConfigValue, - key - }) - }) - global.vscode.postMessage({ - type: MESSAGE_NAME.twinnyFetchOllamaModels - }) - window.addEventListener('message', handler) - }, []) - - return { models, setModels, saveModel, chatModelName, fimModelName } -} diff --git a/src/webview/index.module.css b/src/webview/index.module.css index b4919620..68f8e932 100644 --- a/src/webview/index.module.css +++ b/src/webview/index.module.css @@ -154,11 +154,11 @@ pre code { margin-top: 20px; } -.modelSelect { +.twinnyForm { width: 100%; } -.modelSelect div { +.twinnyForm div { margin-bottom: 10px; display: flex; flex-direction: column; diff --git a/src/webview/main.tsx b/src/webview/main.tsx index a85c6c5d..8677ec10 100644 --- a/src/webview/main.tsx +++ b/src/webview/main.tsx @@ -3,14 +3,16 @@ import { Chat } from './chat' import { TemplateSettings } from './template-settings' import { ServerMessage } from '../common/types' import { MESSAGE_NAME, UI_TABS } from '../common/constants' +import { Providers } from './providers' const tabs: Record = { [UI_TABS.chat]: , - [UI_TABS.templates]: + [UI_TABS.templates]: , + [UI_TABS.providers]: } export const Main = () => { - const [tab, setTab] = useState('chat') + const [tab, setTab] = useState(UI_TABS.chat) const handler = (event: MessageEvent) => { const message: ServerMessage = event.data diff --git a/src/webview/model-select.tsx b/src/webview/model-select.tsx deleted file mode 100644 index 70a04a12..00000000 --- a/src/webview/model-select.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - VSCodeDivider, - VSCodeDropdown, - VSCodeTextField -} from '@vscode/webview-ui-toolkit/react' - -import { useConfigurationSetting, useModels } from './hooks' -import { getModelShortName } from './utils' -import { SETTING_KEY } from '../common/constants' - -import styles from './index.module.css' -import { ApiProviders } from '../common/types' - -export const ModelSelect = () => { - const { models, saveModel, fimModelName, chatModelName } = useModels() - const { configurationSetting: apiProvider } = useConfigurationSetting( - SETTING_KEY.apiProvider - ) - const { configurationSetting: apiProviderFim } = useConfigurationSetting( - SETTING_KEY.apiProviderFim - ) - - const handleOnChangeChat = (e: unknown): void => { - const event = e as React.ChangeEvent - const selectedValue = event?.target.value || '' - saveModel(selectedValue)(SETTING_KEY.chatModelName) - } - - const handleOnChangeFim = (e: unknown): void => { - const event = e as React.ChangeEvent - const selectedValue = event?.target.value || '' - saveModel(selectedValue)(SETTING_KEY.fimModelName) - } - - const isOllamaChat = apiProvider === ApiProviders.Ollama - const isOllamaFim = apiProviderFim === ApiProviders.Ollama - - return ( -
- {!isOllamaChat ? ( -
-
- - -
-
- ) : ( -
- - - {models?.map((model, index) => { - return ( - - ) - })} - -
- )} - {!isOllamaFim ? ( -
-
- - -
-
- ) : ( -
- - - {models?.map((model, index) => { - return ( - - ) - })} - - -
- )} -
- ) -} diff --git a/src/webview/provider-select.tsx b/src/webview/provider-select.tsx new file mode 100644 index 00000000..a470428c --- /dev/null +++ b/src/webview/provider-select.tsx @@ -0,0 +1,73 @@ +import { + VSCodeDropdown, + VSCodeOption, + VSCodeDivider +} from '@vscode/webview-ui-toolkit/react' + +import styles from './providers.module.css' +import { useProviders } from './hooks' + +export const ProviderSelect = () => { + const { + getFimProvidersByType, + setActiveChatProvider, + setActiveFimProvider, + providers, + chatProvider, + fimProvider + } = useProviders() + + const handleChangeChatProvider = (e: unknown): void => { + const event = e as React.ChangeEvent + const value = event.target.value + const provider = providers[value] + setActiveChatProvider(provider) + } + + const handleChangeFimProvider = (e: unknown): void => { + const event = e as React.ChangeEvent + const value = event.target.value + const provider = providers[value] + setActiveFimProvider(provider) + } + + return ( + <> +
+
+
Chat
+ + {Object.values(getFimProvidersByType('chat')) + .sort((a, b) => a.modelName.localeCompare(b.modelName)) + .map((provider, index) => ( + + {`${provider.label} (${provider.provider})`} + + ))} + +
+
+
Fill-in-middle
+ + {Object.values(getFimProvidersByType('fim')) + .sort((a, b) => a.modelName.localeCompare(b.modelName)) + .map((provider, index) => ( + + {`${provider.label} (${provider.provider})`} + + ))} + +
+
+ + + ) +} diff --git a/src/webview/providers.module.css b/src/webview/providers.module.css new file mode 100644 index 00000000..0f85ec49 --- /dev/null +++ b/src/webview/providers.module.css @@ -0,0 +1,71 @@ +.providersHeader { + display: flex; + gap: 3px; +} + +.provider { + padding: 5px; + border-radius: 5px; + background: var(--vscode-inputOption-hoverBackground); + border: 1px solid var(--vscode-editorWidget-border); + margin: 5px 0; +} + +.providerHeader { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} + +.providerHeader h4 { + margin: 0; +} + +.providerDetails { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, auto); + gap: 5px; + overflow: hidden; +} + +.providerDetails > div { + text-wrap: nowrap; + text-overflow: ellipsis; +} + +.providerForm { + width: 100%; +} + +.providerForm div { + margin-bottom: 10px; + display: flex; + flex-direction: column; +} + +.providerFormButtons { + display: flex; + flex-direction: row !important; + justify-content: flex-end; + align-self: flex-end; + gap: 5px; +} + +.providerHeader svg { + width: 20px; +} + +.providerSelector { + width: 100%; + gap: 3px; +} + +.providerSelector vscode-dropdown { + width: 100%; +} + +.providerSelector > div { + width: 100%; +} diff --git a/src/webview/providers.tsx b/src/webview/providers.tsx new file mode 100644 index 00000000..c22c0653 --- /dev/null +++ b/src/webview/providers.tsx @@ -0,0 +1,344 @@ +import React from 'react' +import { useProviders } from './hooks' +import { + VSCodeButton, + VSCodeDivider, + VSCodeDropdown, + VSCodeOption, + VSCodePanelView, + VSCodeTextField +} from '@vscode/webview-ui-toolkit/react' +import { ApiProviders } from '../common/types' + +import styles from './providers.module.css' +import { TwinnyProvider } from '../extension/provider-manager' +import { + DEFAULT_PROVIDER_FORM_VALUES, + FIM_TEMPLATE_TYPE +} from '../common/constants' + +export const Providers = () => { + const [showForm, setShowForm] = React.useState(false) + const [provider, setProvider] = React.useState() + const { providers, removeProvider, copyProvider, resetProviders } = + useProviders() + + const handleClose = () => { + setShowForm(false) + } + + const handleAdd = () => { + setProvider(undefined) + setShowForm(true) + } + + const handleEdit = (provider: TwinnyProvider) => { + setProvider(provider) + setShowForm(true) + } + + const handleDelete = (provider: TwinnyProvider) => { + removeProvider(provider) + } + + const handleCopy = (provider: TwinnyProvider) => { + copyProvider(provider) + } + + const handleReset = () => { + resetProviders() + } + + return ( +
+

Providers

+ + {showForm ? ( + + ) : ( + <> +
+
+ Add Provider + + + Reset Providers + +
+ {Object.values(providers).map((provider, index) => ( +
+
+

{provider.label}

+
+ handleEdit(provider)} + > + + + handleCopy(provider)} + > + + + handleDelete(provider)} + > + + +
+
+ +
+
+ Provider: {provider.provider} +
+
+ Type: {provider.type} +
+ {provider.type === 'fim' && ( +
+ Fim Template: {provider.fimTemplate} +
+ )} +
+ Model: {provider.modelName} +
+
+ Hostname: {provider.apiHostname} +
+
+ Path: {provider.apiPath} +
+
+ Protocol: {provider.apiProtocol} +
+
+ Port: {provider.apiPort} +
+ {provider.apiKey && ( +
+ ApiKey: {provider.apiKey.substring(0, 12)}... +
+ )} +
+
+ ))} +
+ + )} +
+
+ ) +} + +interface ProviderFormProps { + onClose: () => void + provider?: TwinnyProvider +} + +function ProviderForm({ onClose, provider }: ProviderFormProps) { + const isEditing = provider !== undefined + const { saveProvider, updateProvider } = useProviders() + const [formState, setFormState] = React.useState( + provider || DEFAULT_PROVIDER_FORM_VALUES + ) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (isEditing) { + updateProvider(formState) + return onClose() + } + saveProvider(formState) + onClose() + } + + const handleChange = (e: unknown) => { + const event = e as unknown as React.ChangeEvent + const { name, value } = event.target + setFormState({ ...formState, [name]: value.trim() }) + } + + const handleChangeDropdown = (e: unknown): void => { + const event = e as React.ChangeEvent + const value = event?.target.value || '' + const name = event?.target.name || '' + setFormState({ ...formState, [name]: value.trim() }) + } + + const handleCancel = () => { + onClose() + } + + return ( + <> + +
+
+
+ +
+ +
+ +
+
+ +
+ + {['chat', 'fim'].map((type, index) => ( + + {type === 'chat' ? 'Chat' : 'FIM'} + + ))} + +
+ + {formState.type === 'fim' && ( +
+
+ +
+ + {FIM_TEMPLATE_TYPE.map((type, index) => ( + + {type} + + ))} + +
+ )} + +
+
+ +
+ + {Object.values(ApiProviders).map((type, index) => ( + + {type} + + ))} + +
+ +
+
+ +
+ + {['http', 'https'].map((type, index) => ( + + {type} + + ))} + +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + Save + + + Cancel + +
+
+ + ) +} diff --git a/src/webview/template-settings.tsx b/src/webview/template-settings.tsx index ac41c99b..90ad7ec4 100644 --- a/src/webview/template-settings.tsx +++ b/src/webview/template-settings.tsx @@ -54,7 +54,7 @@ export const TemplateSettings = () => { return ( <> -

Template settings

+

Template settings

Select the templates you want to use in the chat interface.

diff --git a/src/webview/utils.ts b/src/webview/utils.ts index b042aab1..efbf04d2 100644 --- a/src/webview/utils.ts +++ b/src/webview/utils.ts @@ -54,11 +54,4 @@ export const kebabToSentence = (kebabStr: string) => { return words.join(' ') } -export const getModelShortName = (name: string) => { - if (name.length > 32) { - return `${name.substring(0, 15)}...${name.substring(name.length - 16)}` - } - return name -} - export const getLineBreakCount = (str: string) => str.split('\n').length