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)
})