Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The various features of Amica mainly use and support the following technologies:
- [KoboldCpp](https://github.com/LostRuins/koboldcpp)
- [Oobabooga](https://github.com/oobabooga/text-generation-webui/wiki)
- [OpenRouter](https://openrouter.ai/) (access to multiple AI models)
- [Gemini API](https://ai.google.dev/) (Google's multimodal AI with reasoning)
- Text-to-Speech
- [Eleven Labs API](https://elevenlabs.io/)
- [Speech T5](https://huggingface.co/microsoft/speecht5_tts)
Expand Down
18 changes: 17 additions & 1 deletion src/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { LlamaCppSettingsPage } from './settings/LlamaCppSettingsPage';
import { OllamaSettingsPage } from './settings/OllamaSettingsPage';
import { KoboldAiSettingsPage } from './settings/KoboldAiSettingsPage';
import { MoshiSettingsPage } from './settings/MoshiSettingsPage';
import { GeminiSettingsPage } from './settings/GeminiSettingsPage';

import { TTSBackendPage } from './settings/TTSBackendPage';
import { ElevenLabsSettingsPage } from './settings/ElevenLabsSettingsPage';
Expand Down Expand Up @@ -105,6 +106,9 @@ export const Settings = ({
const [openRouterApiKey, setOpenRouterApiKey] = useState(config("openrouter_apikey"));
const [openRouterUrl, setOpenRouterUrl] = useState(config("openrouter_url"));
const [openRouterModel, setOpenRouterModel] = useState(config("openrouter_model"));
const [geminiApiKey, setGeminiApiKey] = useState(config("gemini_apikey"));
const [geminiModel, setGeminiModel] = useState(config("gemini_model"));
const [geminiThinkingLevel, setGeminiThinkingLevel] = useState(config("gemini_thinking_level"));

const [ttsBackend, setTTSBackend] = useState(config("tts_backend"));
const [elevenlabsApiKey, setElevenlabsApiKey] = useState(config("elevenlabs_apikey"));
Expand Down Expand Up @@ -280,6 +284,7 @@ export const Settings = ({
koboldAiUrl, koboldAiUseExtra, koboldAiStopSequence,
moshiUrl,
openRouterApiKey, openRouterUrl, openRouterModel,
geminiApiKey, geminiModel, geminiThinkingLevel,
ttsBackend,
elevenlabsApiKey, elevenlabsVoiceId,
speechT5SpeakerEmbeddingsUrl,
Expand Down Expand Up @@ -363,7 +368,7 @@ export const Settings = ({

case 'chatbot':
return <MenuPage
keys={["chatbot_backend", "name", "system_prompt", "arbius_llm_settings", "chatgpt_settings", "llamacpp_settings", "ollama_settings", "koboldai_settings", "moshi_settings", "openrouter_settings"]}
keys={["chatbot_backend", "name", "system_prompt", "arbius_llm_settings", "chatgpt_settings", "llamacpp_settings", "ollama_settings", "koboldai_settings", "moshi_settings", "openrouter_settings", "gemini_settings"]}
menuClick={handleMenuClick} />;

case 'language':
Expand Down Expand Up @@ -526,6 +531,17 @@ export const Settings = ({
setSettingsUpdated={setSettingsUpdated}
/>

case 'gemini_settings':
return <GeminiSettingsPage
geminiApiKey={geminiApiKey}
setGeminiApiKey={setGeminiApiKey}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
geminiThinkingLevel={geminiThinkingLevel}
setGeminiThinkingLevel={setGeminiThinkingLevel}
setSettingsUpdated={setSettingsUpdated}
/>

case 'tts_backend':
return <TTSBackendPage
ttsBackend={ttsBackend}
Expand Down
3 changes: 2 additions & 1 deletion src/components/settings/ChatbotBackendPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const chatbotBackends = [
{key: "koboldai", label: "KoboldAI"},
{key: "moshi", label: "Moshi"},
{key: "openrouter", label: "OpenRouter"},
{key: "gemini", label: "Gemini"},
];

function idToTitle(id: string): string {
Expand Down Expand Up @@ -75,7 +76,7 @@ export function ChatbotBackendPage({
</select>
</FormRow>
</li>
{ ["arbius_llm", "chatgpt", "llamacpp", "ollama", "koboldai", "moshi"].includes(chatbotBackend) && (
{ ["arbius_llm", "chatgpt", "llamacpp", "ollama", "koboldai", "moshi", "gemini"].includes(chatbotBackend) && (
<li className="py-4">
<FormRow label={`${t("Configure")} ${t(idToTitle(chatbotBackend))}`}>
<button
Expand Down
113 changes: 113 additions & 0 deletions src/components/settings/GeminiSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { BasicPage, FormRow, NotUsingAlert } from './common';
import { SecretTextInput } from '@/components/secretTextInput';
import { config, updateConfig } from "@/utils/config";

const GEMINI_MODELS = [
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash (recommended)' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro (preview)' },
];

const THINKING_LEVELS = [
{ value: 'off', label: 'Disabled' },
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' },
];

export function GeminiSettingsPage({
geminiApiKey,
setGeminiApiKey,
geminiModel,
setGeminiModel,
geminiThinkingLevel,
setGeminiThinkingLevel,
setSettingsUpdated,
}: {
geminiApiKey: string;
setGeminiApiKey: (key: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
geminiThinkingLevel: string;
setGeminiThinkingLevel: (level: string) => void;
setSettingsUpdated: (updated: boolean) => void;
}) {
const { t } = useTranslation();

useEffect(() => {
const storedModel = localStorage.getItem('chatvrm_gemini_model');
const storedThinkingLevel = localStorage.getItem('chatvrm_gemini_thinking_level');

if (!storedModel) {
updateConfig('gemini_model', geminiModel);
}
if (!storedThinkingLevel) {
updateConfig('gemini_thinking_level', geminiThinkingLevel);
}
}, []); // Intentionally using initial prop values only, not reactive to prop changes

const description = <>{t('gemini_desc')} <a href="https://ai.google.dev" target="_blank" rel="noopener noreferrer">Google AI Studio</a>.</>;

return (
<BasicPage
title={t('Gemini Settings')}
description={description}
>
{ config("chatbot_backend") !== "gemini" && (
<NotUsingAlert>
{t("not_using_alert", "You are not currently using {{name}} as your {{what}} backend. These settings will not be used.", {name: t("Gemini"), what: t("ChatBot")})}
</NotUsingAlert>
) }
<ul role="list" className="divide-y divide-gray-100 max-w-xs">
<li className="py-4">
<FormRow label={t('Gemini API Key')}>
<SecretTextInput
value={geminiApiKey}
onChange={(event: React.ChangeEvent<any>) => {
setGeminiApiKey(event.target.value);
updateConfig("gemini_apikey", event.target.value);
setSettingsUpdated(true);
}}
/>
</FormRow>
</li>
<li className="py-4">
<FormRow label={t('Model')}>
<select
className="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={geminiModel}
onChange={(event: React.ChangeEvent<any>) => {
setGeminiModel(event.target.value);
updateConfig("gemini_model", event.target.value);
setSettingsUpdated(true);
}}
>
{GEMINI_MODELS.map((model) => (
<option key={model.value} value={model.value}>{t(model.label)}</option>
))}
</select>
</FormRow>
</li>
<li className="py-4">
<FormRow label={t('Reasoning Level')}>
<select
className="mt-2 block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6"
value={geminiThinkingLevel}
onChange={(event: React.ChangeEvent<any>) => {
setGeminiThinkingLevel(event.target.value);
updateConfig("gemini_thinking_level", event.target.value);
setSettingsUpdated(true);
}}
>
{THINKING_LEVELS.map((level) => (
<option key={level.value} value={level.value}>{t(level.label)}</option>
))}
</select>
</FormRow>
</li>
</ul>
</BasicPage>
);
}
2 changes: 2 additions & 0 deletions src/components/settings/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export function getIconFromPage(page: string): JSX.Element {
case 'koboldai_settings': return <AdjustmentsHorizontalIcon className="h-5 w-5 flex-none text-gray-800" aria-hidden="true" />;
case 'moshi_settings': return <AdjustmentsHorizontalIcon className="h-5 w-5 flex-none text-gray-800" aria-hidden="true" />;
case 'openrouter_settings': return <AdjustmentsHorizontalIcon className="h-5 w-5 flex-none text-gray-800" aria-hidden="true" />;
case 'gemini_settings': return <AdjustmentsHorizontalIcon className="h-5 w-5 flex-none text-gray-800" aria-hidden="true" />;
case 'name': return <IdentificationIcon className="h-5 w-5 flex-none text-gray-800" aria-hidden="true" />;
case 'system_prompt': return <DocumentTextIcon className="h-5 w-5 flex-none text-gray-800" aria-hidden="true" />;

Expand Down Expand Up @@ -221,6 +222,7 @@ function getLabelFromPage(page: string): string {
case 'koboldai_settings': return t('KoboldAI');
case 'moshi_settings': return t('Moshi');
case 'openrouter_settings': return t('OpenRouter');
case 'gemini_settings': return t('Gemini');
case 'name' : return t('Name');
case 'system_prompt': return t('System Prompt');

Expand Down
3 changes: 3 additions & 0 deletions src/features/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "./ollamaChat";
import { getKoboldAiChatResponseStream } from "./koboldAiChat";
import { getReasoingEngineChatResponseStream } from "./reasoiningEngineChat";
import { getGeminiChatResponseStream } from "./geminiChat";

import { rvc } from "@/features/rvc/rvc";
import { coquiLocal } from "@/features/coquiLocal/coquiLocal";
Expand Down Expand Up @@ -733,6 +734,8 @@ export class Chat {
return getKoboldAiChatResponseStream(messages);
case 'openrouter':
return getOpenRouterChatResponseStream(messages);
case 'gemini':
return getGeminiChatResponseStream(messages);
}

return getEchoChatResponseStream(messages);
Expand Down
164 changes: 164 additions & 0 deletions src/features/chat/geminiChat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Message } from "./messages";
import { config } from '@/utils/config';

function getApiKey(configKey: string) {
const apiKey = config(configKey);
if (!apiKey) {
throw new Error(`Invalid ${configKey} API Key`);
}
return apiKey;
}

function buildRequestBody(messages: Message[], model: string) {
const systemMessage = messages.find((msg) => msg.role === "system");
const conversationMessages = messages.filter((msg) => msg.role !== "system");

const generationConfig: any = {
maxOutputTokens: 400,
};

// Model version detection: check if model name contains "gemini-3"
const isGemini3 = model.includes("gemini-3");
const thinkingLevel = config("gemini_thinking_level");

console.log("Gemini thinkingLevel config:", thinkingLevel, "isGemini3:", isGemini3);

if (isGemini3) {
// Gemini 3.0 only supports "low" or "high", cannot disable thinking
const effectiveLevel = thinkingLevel === "off" ? "low" : thinkingLevel

generationConfig.thinkingConfig = {
thinkingLevel: effectiveLevel, // "low" or "high"
};
} else {
// Gemini 2.5 uses thinkingBudget nested in thinkingConfig
const isPro = model.includes("pro");

let thinkingBudget: number;
if (thinkingLevel === "off") {
// Pro requires thinking (min 128), others can be 0
thinkingBudget = isPro ? 128 : 0;
} else if (thinkingLevel === "high") {
thinkingBudget = -1; // Dynamic
} else {
// Low: consistent reasoning budget across all models
thinkingBudget = 1024;
}

generationConfig.thinkingConfig = {
thinkingBudget,
};
}

const body: any = {
contents: conversationMessages.map((msg) => ({
role: msg.role === "assistant" ? "model" : "user",
parts: [{ text: msg.content }],
})),
generationConfig,
};

if (systemMessage) {
body.systemInstruction = {
parts: [{ text: systemMessage.content }],
};
}

return body;
}

async function getResponseStream(messages: Message[]) {
const apiKey = getApiKey("gemini_apikey");
const model = config("gemini_model");

const headers: Record<string, string> = {
"x-goog-api-key": apiKey,
"Content-Type": "application/json"
};

const requestBody = buildRequestBody(messages, model);
console.log("Gemini request body:", JSON.stringify(requestBody, null, 2));

// @todo: v1beta endpoint is subject to change, but required to support both 2.5 and 3.0 model at this time (30.11.2025)
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
headers,
method: "POST",
body: JSON.stringify(requestBody),
}
);

const reader = res.body?.getReader();
if (res.status !== 200 || !reader) {
if (res.status === 401) {
throw new Error("Invalid Gemini API key");
}
if (res.status === 400) {
const errorBody = await res.text();
throw new Error(`Invalid request to Gemini API: ${errorBody}`);
}
if (res.status === 403) {
throw new Error("Gemini API access forbidden - check API key permissions");
}
if (res.status === 429) {
throw new Error("Gemini API rate limit exceeded");
}
if (res.status >= 500) {
throw new Error("Gemini API server error - please try again later");
}

throw new Error(`Gemini chat error (${res.status})`);
}

const stream = new ReadableStream({
async start(controller: ReadableStreamDefaultController) {
const decoder = new TextDecoder("utf-8");
try {
let combined = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const data = decoder.decode(value);
const chunks = data
.split("data:")
.filter((val) => !!val && val.trim() !== "[DONE]");

for (const chunk of chunks) {
if (chunk.length > 0 && chunk[0] === ":") {
continue;
}
combined += chunk;

try {
const json = JSON.parse(combined);
const messagePiece = json.candidates?.[0]?.content?.parts?.[0]?.text;
combined = "";
if (!!messagePiece) {
controller.enqueue(messagePiece);
}
} catch (error) {
// JSON not yet complete, continue buffering
}
}
}
} catch (error) {
console.error(error);
controller.error(error);
} finally {
reader.releaseLock();
controller.close();
}
},
async cancel() {
await reader?.cancel();
reader.releaseLock();
},
});

return stream;
}

export async function getGeminiChatResponseStream(messages: Message[]) {
return getResponseStream(messages);
}
Loading