diff --git a/README.md b/README.md index 41b39e4..7f3dd3b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ To use Internet search you need a [Tavily API key](https://app.tavily.com/home). ## TODO +- [ ] Google Gemini API - [ ] i18n - [ ] File upload for retrieval (??) - [ ] Proper database (SQLite3) storage (??) diff --git a/assets/google.svg b/assets/google.svg new file mode 100644 index 0000000..088288f --- /dev/null +++ b/assets/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/build/build_number.txt b/build/build_number.txt index a14f8d5..3b4a6e8 100644 --- a/build/build_number.txt +++ b/build/build_number.txt @@ -1 +1 @@ -179 +181 diff --git a/defaults/settings.json b/defaults/settings.json index 3a4d578..ad3f44a 100644 --- a/defaults/settings.json +++ b/defaults/settings.json @@ -66,6 +66,15 @@ "chat": "" } }, + "google": { + "models": { + "chat": [], + "image": [] + }, + "model": { + "chat": "" + } + }, "groq": { "models": { "chat": [], diff --git a/package-lock.json b/package-lock.json index 9786bb3..749e4e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.20.4", "@el3um4s/run-vbs": "^1.1.2", + "@google/generative-ai": "^0.11.1", "@iktakahiro/markdown-it-katex": "^4.0.1", "@mistralai/mistralai": "^0.1.3", "applescript": "^1.0.0", @@ -2019,6 +2020,14 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "node_modules/@google/generative-ai": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.11.1.tgz", + "integrity": "sha512-ZiUiJJbl55TXcvu73+Kf/bUhzcRTH/bsGBeYZ9ULqU0imXg3POcd+NVYM9j+TGq4MA73UYwHPmJHwmy+QZEzyQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index 03c98c6..f26d17c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "witsy", "productName": "Witsy", - "version": "1.6.0", + "version": "1.7.0", "description": "Witsy: desktop AI assistant", "repository": { "type": "git", @@ -65,6 +65,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.20.4", "@el3um4s/run-vbs": "^1.1.2", + "@google/generative-ai": "^0.11.1", "@iktakahiro/markdown-it-katex": "^4.0.1", "@mistralai/mistralai": "^0.1.3", "applescript": "^1.0.0", diff --git a/src/components/EngineLogo.vue b/src/components/EngineLogo.vue index 9fcd85d..b0051b6 100644 --- a/src/components/EngineLogo.vue +++ b/src/components/EngineLogo.vue @@ -10,6 +10,7 @@ import logoOpenAI from '../../assets/openai.svg' import logoOllama from '../../assets/ollama.svg' import logoAnthropic from '../../assets/anthropic.svg' import logoMistralAI from '../../assets/mistralai.svg' +import logoGoogle from '../../assets/google.svg' import logoGroq from '../../assets/groq.svg' const logos = { @@ -17,6 +18,7 @@ const logos = { ollama: logoOllama, anthropic: logoAnthropic, mistralai: logoMistralAI, + google: logoGoogle, groq: logoGroq } @@ -73,4 +75,8 @@ const logo = computed(() => logos[props.engine]) filter: none; } +.logo.grayscale.google { + filter: grayscale() +} + diff --git a/src/services/google.ts b/src/services/google.ts new file mode 100644 index 0000000..89b01eb --- /dev/null +++ b/src/services/google.ts @@ -0,0 +1,203 @@ + +import { Message } from '../types/index.d' +import { LLmCompletionPayload, LlmChunk, LlmCompletionOpts, LlmResponse, LlmStream, LlmToolCall, LlmEventCallback } from '../types/llm.d' +import { EngineConfig, Configuration } from '../types/config.d' +import LlmEngine from './engine' +import { ChatSession, Content, EnhancedGenerateContentResponse, GenerativeModel, GoogleGenerativeAI } from '@google/generative-ai' + +export const isGoogleReady = (engineConfig: EngineConfig): boolean => { + return engineConfig.apiKey?.length > 0 +} + +export default class extends LlmEngine { + + client: GoogleGenerativeAI + currentChat: ChatSession + toolCalls: LlmToolCall[] + + constructor(config: Configuration) { + super(config) + this.client = new GoogleGenerativeAI( + config.engines.google.apiKey, + ) + } + + getName(): string { + return 'google' + } + + getVisionModels(): string[] { + return []//['gemini-pro-vision', '*vision*'] + } + + isVisionModel(model: string): boolean { + return this.getVisionModels().includes(model) || model.includes('vision') + } + + getRountingModel(): string | null { + return null + } + + async getModels(): Promise { + + // need an api key + if (!this.client.apiKey) { + return null + } + + // do it + return [ + { id: 'models/gemini-1.5-pro-latest', name: 'Gemini 1.5 Pro' }, + //{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash' }, + { id: 'models/gemini-pro', name: 'Gemini 1.0 Pro' }, + { id: 'models/gemini-pro-vision', name: 'Gemini Pro Vision' }, + ] + } + + + async complete(thread: Message[], opts: LlmCompletionOpts): Promise { + + // call + const modelName = opts?.model || this.config.engines.openai.model.chat + console.log(`[openai] prompting model ${modelName}`) + const model = this.getModel(modelName, thread[0].content) + const chat = model.startChat({ + history: thread.slice(1, -1).map((message) => this.messageToContent(message)) + }) + + // done + const result = await chat.sendMessage(thread[thread.length-1].content) + return { + type: 'text', + content: result.response.text() + } + } + + async stream(thread: Message[], opts: LlmCompletionOpts): Promise { + + // model: switch to vision if needed + const modelName = this.selectModel(thread, opts?.model || this.getChatModel()) + + // reset + this.toolCalls = [] + + // save the message thread + const payload = this.buildPayload(thread, modelName) + + // call + console.log(`[openai] prompting model ${modelName}`) + const model = this.getModel(modelName, payload[0].content) + this.currentChat = model.startChat({ + history: payload.slice(1, -1).map((message) => this.messageToContent(message)) + }) + + // done + const result = await this.currentChat.sendMessageStream(payload[payload.length-1].content) + return result.stream + + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getModel(model: string, instructions: string): GenerativeModel { + return this.client.getGenerativeModel({ + model: model, + //systemInstruction: instructions + // tools: [{ + // functionDeclarations: this.getAvailableTools().map((tool) => { + // return tool.function + // }) + // }], + }, { + apiVersion: 'v1beta' + }) + } + + messageToContent(message: any): Content { + return { + role: message.role == 'assistant' ? 'model' : message.role, + parts: [ { text: message.content } ] + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async stop(stream: AsyncGenerator) { + //await stream?.controller?.abort() + } + + async streamChunkToLlmChunk(chunk: EnhancedGenerateContentResponse, eventCallback: LlmEventCallback): Promise { + + //console.log('[google] chunk:', chunk) + + // tool calls + const toolCalls = chunk.functionCalls() + if (toolCalls?.length) { + + // save + this.toolCalls = toolCalls.map((tc) => { + return { + id: tc.name, + message: '', + function: tc.name, + args: JSON.stringify(tc.args), + } + }) + + // call + for (const toolCall of this.toolCalls) { + + // first notify + eventCallback?.call(this, { + type: 'tool', + content: this.getToolPreparationDescription(toolCall.function) + }) + + // first notify + eventCallback?.call(this, { + type: 'tool', + content: this.getToolRunningDescription(toolCall.function) + }) + + // now execute + const args = JSON.parse(toolCall.args) + const content = await this.callTool(toolCall.function, args) + console.log(`[openai] tool call ${toolCall.function} with ${JSON.stringify(args)} => ${JSON.stringify(content).substring(0, 128)}`) + + // send + this.currentChat.sendMessageStream([ + { functionResponse: { + name: toolCall.function, + response: content + }} + ]) + + // clear + eventCallback?.call(this, { + type: 'tool', + content: null, + }) + + } + + // done + return null + + } + + // text chunk + return { + text: chunk.text(), + done: chunk.candidates[0].finishReason === 'STOP' + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addImageToPayload(message: Message, payload: LLmCompletionPayload) { + //TODO + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async image(prompt: string, opts: LlmCompletionOpts): Promise { + return null + } + +} diff --git a/src/services/llm.ts b/src/services/llm.ts index 5b9203d..e5cad8b 100644 --- a/src/services/llm.ts +++ b/src/services/llm.ts @@ -5,10 +5,11 @@ import OpenAI, { isOpenAIReady } from './openai' import Ollama, { isOllamaReady } from './ollama' import MistralAI, { isMistrailAIReady } from './mistralai' import Anthropic, { isAnthropicReady } from './anthropic' +import Google, { isGoogleReady } from './google' import Groq, { isGroqReady } from './groq' import LlmEngine from './engine' -export const availableEngines = ['openai', 'ollama', 'anthropic', 'mistralai', 'groq'] +export const availableEngines = ['openai', 'ollama', 'anthropic', 'mistralai', 'google', 'groq'] export const textFormats = ['pdf', 'txt', 'docx', 'pptx', 'xlsx'] export const imageFormats = ['jpeg', 'jpg', 'png', 'webp'] @@ -17,6 +18,7 @@ export const isEngineReady = (engine: string) => { if (engine === 'ollama') return isOllamaReady(store.config.engines.ollama) if (engine === 'mistralai') return isMistrailAIReady(store.config.engines.mistralai) if (engine === 'anthropic') return isAnthropicReady(store.config.engines.anthropic) + if (engine === 'google') return isGoogleReady(store.config.engines.google) if (engine === 'groq') return isGroqReady(store.config.engines.groq) return false } @@ -26,6 +28,7 @@ export const igniteEngine = (engine: string, config: Configuration, fallback = ' if (engine === 'ollama') return new Ollama(config) if (engine === 'mistralai') return new MistralAI(config) if (engine === 'anthropic') return new Anthropic(config) + if (engine === 'google') return new Google(config) if (engine === 'groq') return new Groq(config) if (isEngineReady(fallback)) return igniteEngine(fallback, config) return null @@ -223,6 +226,39 @@ export const loadAnthropicModels = async () => { return true } +export const loadGoogleModels = async () => { + + let models = [] + + try { + const google = new Google(store.config) + models = await google.getModels() + } catch (error) { + console.error('Error listing Google models:', error); + } + if (!models) { + store.config.engines.google.models = { chat: [], image: [], } + return false + } + + // store + store.config.engines.google.models = { + chat: models + .map(model => { return { + id: model.id, + name: model.name, + meta: model + }}) + //.sort((a, b) => a.name.localeCompare(b.name)) + } + + // select valid model + store.config.engines.google.model.chat = getValidModelId('google', 'chat', store.config.engines.google.model.chat) + + // done + return true +} + export const loadGroqModels = async () => { let models = [] diff --git a/src/settings/SettingsGoogle.vue b/src/settings/SettingsGoogle.vue new file mode 100644 index 0000000..44ee2d0 --- /dev/null +++ b/src/settings/SettingsGoogle.vue @@ -0,0 +1,104 @@ + + + + + + diff --git a/src/settings/SettingsLLM.vue b/src/settings/SettingsLLM.vue index 19e0e0b..eb5eeed 100644 --- a/src/settings/SettingsLLM.vue +++ b/src/settings/SettingsLLM.vue @@ -22,6 +22,7 @@ import SettingsOpenAI from './SettingsOpenAI.vue' import SettingsOllama from './SettingsOllama.vue' import SettingsMistralAI from './SettingsMistralAI.vue' import SettingsAnthropic from './SettingsAnthropic.vue' +import SettingsGoogle from './SettingsGoogle.vue' import SettingsGroq from './SettingsGroq.vue' const currentEngine = ref(availableEngines[0]) @@ -36,6 +37,7 @@ const engines = computed(() => { ollama: 'Ollama', anthropic: 'Anthropic', mistralai: 'Mistral AI', + google: 'Google', groq: 'Groq' }[engine], } @@ -47,6 +49,7 @@ const currentView = computed(() => { if (currentEngine.value == 'ollama') return SettingsOllama if (currentEngine.value == 'anthropic') return SettingsAnthropic if (currentEngine.value == 'mistralai') return SettingsMistralAI + if (currentEngine.value == 'google') return SettingsGoogle if (currentEngine.value == 'groq') return SettingsGroq }) diff --git a/tests/unit/engine_ready.test.ts b/tests/unit/engine_ready.test.ts index 3518804..b799cec 100644 --- a/tests/unit/engine_ready.test.ts +++ b/tests/unit/engine_ready.test.ts @@ -19,6 +19,7 @@ test('Default Configuration', () => { }) test('OpenAI Configuration', () => { + expect(isEngineReady('openai')).toBe(true) store.config.engines.openai.apiKey = '123' expect(isEngineReady('openai')).toBe(true) store.config.engines.openai.models.chat = [model] @@ -42,22 +43,19 @@ test('MistralAI Configuration', () => { }) test('Anthropic Configuration', () => { - store.config.engines.anthropic.models.image = [{ id: 'llava:latest', name: 'llava:latest', meta: {} }] + store.config.engines.anthropic.models.image = [model] expect(isEngineReady('anthropic')).toBe(false) - store.config.engines.anthropic.models.chat = [{ id: 'llava:latest', name: 'llava:latest', meta: {} }] + store.config.engines.anthropic.models.chat = [model] expect(isEngineReady('anthropic')).toBe(false) store.config.engines.anthropic.apiKey = '123' expect(isEngineReady('anthropic')).toBe(true) }) test('Google Configuration', () => { + store.config.engines.google.models.image = [model] expect(isEngineReady('google')).toBe(false) - store.config.engines.google = { - apiKey: '123', - models: { - chat: [model], - image: [model] - } - } + store.config.engines.google.models.chat = [model] expect(isEngineReady('google')).toBe(false) + store.config.engines.google.apiKey = '123' + expect(isEngineReady('google')).toBe(true) })