feat: add multi-provider support to chat widget

- Added support for multiple AI providers (Ollama, Gemini, OpenRouter, Copilot) with provider abstraction layer
- Created settings view with provider configuration and API key management
- Updated UI to show current provider status and handle provider-specific availability
- Modified reasoning mode to work exclusively with Ollama provider
- Added provider switching functionality with persistent settings
- Updated error messages and placeholders to be
This commit is contained in:
Melvin Ragusa
2025-10-31 00:08:04 +01:00
parent 55aad289bc
commit 6cc11fc9e4
4 changed files with 1396 additions and 57 deletions

366
settings_widget.py Normal file
View File

@@ -0,0 +1,366 @@
"""Settings widget for AI provider configuration."""
from __future__ import annotations
import webbrowser
from gi.repository import GLib, Gtk
from ignis import widgets
from .provider_client import (
OllamaProvider,
GeminiProvider,
OpenRouterProvider,
CopilotProvider,
)
from .reasoning_controller import ReasoningController
class SettingsWidget(widgets.Box):
"""Settings view for configuring AI providers."""
def __init__(self, preferences: ReasoningController, on_provider_changed=None, on_back=None):
"""Initialize settings widget.
Args:
preferences: ReasoningController instance for managing preferences
on_provider_changed: Callback function called when provider changes
on_back: Callback function called when back button is clicked
"""
self._preferences = preferences
self._on_provider_changed = on_provider_changed
self._on_back_callback = on_back
# Provider instances (will be created as needed)
self._providers = {}
# Header
header_title = widgets.Label(
label="Settings",
halign="start",
css_classes=["title-2"],
)
back_button = widgets.Button(
label="← Back",
on_click=lambda x: self._on_back(),
halign="start",
hexpand=False,
)
header_box = widgets.Box(
spacing=8,
hexpand=True,
child=[back_button, header_title],
)
# Provider selection
provider_label = widgets.Label(
label="AI Provider",
halign="start",
css_classes=["title-3"],
)
# Radio buttons for provider selection
self._provider_buttons = {}
provider_names = {
"ollama": "Ollama (Local)",
"gemini": "Google Gemini",
"openrouter": "OpenRouter",
"copilot": "GitHub Copilot",
}
current_provider = self._preferences.get_provider()
provider_box = widgets.Box(vertical=True, spacing=4)
for provider_id, provider_label_text in provider_names.items():
button = Gtk.CheckButton(label=provider_label_text)
button.set_active(provider_id == current_provider)
button.connect("toggled", lambda btn, pid=provider_id: self._on_provider_selected(pid) if btn.get_active() else None)
self._provider_buttons[provider_id] = button
provider_box.append(button)
# API key inputs
self._api_key_entries = {}
# Gemini API key
gemini_label = widgets.Label(
label="Gemini API Key",
halign="start",
)
gemini_entry = Gtk.Entry()
gemini_entry.set_placeholder_text("Enter your Gemini API key")
gemini_entry.set_visibility(False) # Password mode
gemini_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
api_key = self._preferences.get_api_key("gemini")
if api_key:
gemini_entry.set_text(api_key)
gemini_entry.connect("changed", lambda e: self._on_api_key_changed("gemini", e.get_text()))
self._api_key_entries["gemini"] = gemini_entry
# Toggle visibility button for Gemini
gemini_visibility_button = Gtk.Button(icon_name="view-reveal-symbolic")
gemini_visibility_button.connect("clicked", lambda b: self._toggle_password_visibility(gemini_entry, b))
gemini_box = widgets.Box(
spacing=8,
hexpand=True,
child=[gemini_entry, gemini_visibility_button],
)
# OpenRouter API key
openrouter_label = widgets.Label(
label="OpenRouter API Key",
halign="start",
)
openrouter_entry = Gtk.Entry()
openrouter_entry.set_placeholder_text("Enter your OpenRouter API key")
openrouter_entry.set_visibility(False) # Password mode
openrouter_entry.set_input_purpose(Gtk.InputPurpose.PASSWORD)
api_key = self._preferences.get_api_key("openrouter")
if api_key:
openrouter_entry.set_text(api_key)
openrouter_entry.connect("changed", lambda e: self._on_api_key_changed("openrouter", e.get_text()))
self._api_key_entries["openrouter"] = openrouter_entry
# Toggle visibility button for OpenRouter
openrouter_visibility_button = Gtk.Button(icon_name="view-reveal-symbolic")
openrouter_visibility_button.connect("clicked", lambda b: self._toggle_password_visibility(openrouter_entry, b))
openrouter_box = widgets.Box(
spacing=8,
hexpand=True,
child=[openrouter_entry, openrouter_visibility_button],
)
# GitHub Copilot OAuth
copilot_label = widgets.Label(
label="GitHub Copilot",
halign="start",
)
copilot_status_label = widgets.Label(
label="Not authenticated" if not self._preferences.get_copilot_token() else "Authenticated",
halign="start",
css_classes=["dim-label"],
)
copilot_auth_button = widgets.Button(
label="Authenticate with GitHub" if not self._preferences.get_copilot_token() else "Re-authenticate",
on_click=lambda x: self._on_copilot_auth(),
)
# Test connection buttons
test_ollama_button = widgets.Button(
label="Test Connection",
on_click=lambda x: self._test_connection("ollama"),
)
test_gemini_button = widgets.Button(
label="Test Connection",
on_click=lambda x: self._test_connection("gemini"),
)
test_openrouter_button = widgets.Button(
label="Test Connection",
on_click=lambda x: self._test_connection("openrouter"),
)
test_copilot_button = widgets.Button(
label="Test Connection",
on_click=lambda x: self._test_connection("copilot"),
)
# Status labels for test results
self._test_status_labels = {
"ollama": widgets.Label(label="", halign="start", css_classes=["dim-label"]),
"gemini": widgets.Label(label="", halign="start", css_classes=["dim-label"]),
"openrouter": widgets.Label(label="", halign="start", css_classes=["dim-label"]),
"copilot": widgets.Label(label="", halign="start", css_classes=["dim-label"]),
}
# Layout
settings_content = widgets.Box(
vertical=True,
spacing=16,
hexpand=True,
vexpand=True,
)
# Provider section
settings_content.append(provider_label)
settings_content.append(provider_box)
spacer1 = widgets.Box(hexpand=True)
spacer1.set_size_request(-1, 12)
settings_content.append(spacer1)
# Gemini section
settings_content.append(gemini_label)
settings_content.append(gemini_box)
test_gemini_box = widgets.Box(spacing=8, hexpand=True, child=[test_gemini_button, self._test_status_labels["gemini"]])
settings_content.append(test_gemini_box)
spacer2 = widgets.Box(hexpand=True)
spacer2.set_size_request(-1, 8)
settings_content.append(spacer2)
# OpenRouter section
settings_content.append(openrouter_label)
settings_content.append(openrouter_box)
test_openrouter_box = widgets.Box(spacing=8, hexpand=True, child=[test_openrouter_button, self._test_status_labels["openrouter"]])
settings_content.append(test_openrouter_box)
spacer3 = widgets.Box(hexpand=True)
spacer3.set_size_request(-1, 8)
settings_content.append(spacer3)
# Ollama section
ollama_label = widgets.Label(
label="Ollama",
halign="start",
)
ollama_info = widgets.Label(
label="Local Ollama server. Start with: ollama serve",
halign="start",
css_classes=["dim-label"],
)
settings_content.append(ollama_label)
settings_content.append(ollama_info)
test_ollama_box = widgets.Box(spacing=8, hexpand=True, child=[test_ollama_button, self._test_status_labels["ollama"]])
settings_content.append(test_ollama_box)
spacer4 = widgets.Box(hexpand=True)
spacer4.set_size_request(-1, 8)
settings_content.append(spacer4)
# Copilot section
settings_content.append(copilot_label)
settings_content.append(copilot_status_label)
settings_content.append(copilot_auth_button)
test_copilot_box = widgets.Box(spacing=8, hexpand=True, child=[test_copilot_button, self._test_status_labels["copilot"]])
settings_content.append(test_copilot_box)
# Scrolled window for settings
scroller = widgets.Scroll(
hexpand=True,
vexpand=True,
child=settings_content,
)
# Main container
super().__init__(
vertical=True,
spacing=12,
hexpand=True,
vexpand=True,
child=[header_box, scroller],
css_classes=["ai-sidebar-content"],
)
# Set margins
self.set_margin_top(16)
self.set_margin_bottom(16)
self.set_margin_start(16)
self.set_margin_end(16)
def _toggle_password_visibility(self, entry: Gtk.Entry, button: Gtk.Button):
"""Toggle password visibility in entry."""
visible = entry.get_visibility()
entry.set_visibility(not visible)
button.set_icon_name("view-reveal-symbolic" if visible else "view-conceal-symbolic")
def _on_provider_selected(self, provider_id: str):
"""Handle provider selection."""
self._preferences.set_provider(provider_id)
if self._on_provider_changed:
self._on_provider_changed(provider_id)
def _on_api_key_changed(self, provider: str, api_key: str):
"""Handle API key changes."""
self._preferences.set_api_key(provider, api_key)
# Clear test status
self._test_status_labels[provider].label = ""
def _on_copilot_auth(self):
"""Handle GitHub Copilot OAuth authentication."""
# Note: For a full OAuth implementation, you would need:
# 1. A GitHub OAuth app registered
# 2. A local HTTP server to receive the callback
# 3. Exchange authorization code for token
# For now, we'll show a message that OAuth setup is required
status_label = self._test_status_labels["copilot"]
status_label.label = "OAuth setup required. See documentation for GitHub OAuth app configuration."
status_label.css_classes = ["dim-label"]
# TODO: Implement full OAuth flow
# This would involve:
# 1. Generate OAuth URL
# 2. Open browser
# 3. Start local server to receive callback
# 4. Exchange code for token
# 5. Save token
def _test_connection(self, provider_id: str):
"""Test connection to a provider."""
status_label = self._test_status_labels[provider_id]
status_label.label = "Testing..."
status_label.css_classes = ["dim-label"]
def _test():
try:
provider = self._get_provider(provider_id)
if provider:
success, message = provider.test_connection()
GLib.idle_add(
lambda: self._update_test_status(provider_id, success, message),
priority=GLib.PRIORITY_DEFAULT
)
else:
GLib.idle_add(
lambda: self._update_test_status(provider_id, False, "Provider not configured"),
priority=GLib.PRIORITY_DEFAULT
)
except Exception as e:
GLib.idle_add(
lambda: self._update_test_status(provider_id, False, f"Error: {str(e)}"),
priority=GLib.PRIORITY_DEFAULT
)
import threading
thread = threading.Thread(target=_test, daemon=True)
thread.start()
def _update_test_status(self, provider_id: str, success: bool, message: str):
"""Update test status label."""
status_label = self._test_status_labels[provider_id]
status_label.label = message
if success:
status_label.css_classes = ["dim-label"]
else:
status_label.css_classes = ["dim-label"]
def _get_provider(self, provider_id: str):
"""Get or create provider instance."""
if provider_id in self._providers:
return self._providers[provider_id]
if provider_id == "ollama":
provider = OllamaProvider()
elif provider_id == "gemini":
api_key = self._preferences.get_api_key("gemini")
provider = GeminiProvider(api_key=api_key) if api_key else None
elif provider_id == "openrouter":
api_key = self._preferences.get_api_key("openrouter")
provider = OpenRouterProvider(api_key=api_key) if api_key else None
elif provider_id == "copilot":
token = self._preferences.get_copilot_token()
provider = CopilotProvider(oauth_token=token) if token else None
else:
return None
if provider:
self._providers[provider_id] = provider
return provider
def _on_back(self):
"""Handle back button click."""
# Signal to parent to switch back to chat view
if hasattr(self, '_on_back_callback'):
self._on_back_callback()