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:
366
settings_widget.py
Normal file
366
settings_widget.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user