diff --git a/Clippy.Core/Services/IKeyService.cs b/Clippy.Core/Services/IKeyService.cs deleted file mode 100644 index 2a47fbf..0000000 --- a/Clippy.Core/Services/IKeyService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace Clippy.Core.Services -{ - public interface IKeyService - { - public string GetKey(); - public void SetKey(string key); - } -} diff --git a/Clippy.Core/Services/ISettingsService.cs b/Clippy.Core/Services/ISettingsService.cs index 27fb7e2..d7baa81 100644 --- a/Clippy.Core/Services/ISettingsService.cs +++ b/Clippy.Core/Services/ISettingsService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -11,5 +11,7 @@ public interface ISettingsService public bool TranslucentBackground { get; set; } public bool KeyboardEnabled { get; set; } public int Tokens { get; set; } + public string CurrentModel { get; set; } + public string OllamaUrl { get; set; } } } diff --git a/Clippy/App.xaml.cs b/Clippy/App.xaml.cs index 6891017..dae20aa 100644 --- a/Clippy/App.xaml.cs +++ b/Clippy/App.xaml.cs @@ -75,8 +75,8 @@ private static IServiceProvider ConfigureServices() { var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); diff --git a/Clippy/Clippy.csproj b/Clippy/Clippy.csproj index 496c3c2..f74ff72 100644 --- a/Clippy/Clippy.csproj +++ b/Clippy/Clippy.csproj @@ -34,7 +34,6 @@ - @@ -91,9 +90,6 @@ MSBuild:Compile - - MSBuild:Compile - MSBuild:Compile diff --git a/Clippy/Controls/APIBox.xaml b/Clippy/Controls/APIBox.xaml deleted file mode 100644 index 6d43127..0000000 --- a/Clippy/Controls/APIBox.xaml +++ /dev/null @@ -1,283 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Clippy/Controls/APIBox.xaml.cs b/Clippy/Controls/APIBox.xaml.cs deleted file mode 100644 index d822526..0000000 --- a/Clippy/Controls/APIBox.xaml.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Clippy.Core.Services; -using Clippy.Services; -using CubeKit.UI.Icons; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Windows.Foundation; -using Windows.Foundation.Collections; -using Windows.System; - -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. - -namespace Clippy.Controls -{ - public sealed partial class APIBox : UserControl - { - private KeyService Keys = (KeyService)App.Current.Services.GetService(); - - public APIBox() - { - this.InitializeComponent(); - KeyBox.Password = Keys.GetKey(); - } - - private void AddApi() - { - if (string.IsNullOrEmpty(KeyBox.Password)) - { - Reject(); - return; - } - try - { - /* OpenAIService AI = new OpenAIService(new OpenAiOptions() - { - ApiKey = KeyBox.Password - });*/ - Keys.SetKey(KeyBox.Password); - } - catch - { - Reject(); - } - } - - private FluentSymbol PrivacyToIcon(bool? boolean) => (boolean ?? false) ? FluentSymbol.EyeShow20 : FluentSymbol.EyeHide20; - - private PasswordRevealMode PrivacyToPassword(bool? boolean) => (boolean ?? false) ? PasswordRevealMode.Visible : PasswordRevealMode.Hidden; - - private void ApiBox_KeyDown(object sender, KeyRoutedEventArgs e) - { - if (e.Key == VirtualKey.Enter) - AddApi(); - } - - private void Submit_Click(object sender, RoutedEventArgs e) => AddApi(); - - private void Accept() => KeyBox.Foreground = GreenLinearGradientBrush; - - private void Reject() - { - KeyBox.Foreground = RedLinearGradientBrush; - KeyBox.Focus(FocusState.Programmatic); - PasswordLoadAnimation.Start(); - } - } -} diff --git a/Clippy/Services/KeyService.cs b/Clippy/Services/KeyService.cs deleted file mode 100644 index 4854a54..0000000 --- a/Clippy/Services/KeyService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Clippy.Core.Services; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Xml.Linq; -using Windows.Security.Credentials; - -namespace Clippy.Services -{ - public class KeyService : IKeyService - { - private const string Name = "Key"; - private const string Resource = "R"; - private PasswordVault Vault = new PasswordVault(); - - public string GetKey() - { - try - { - return Vault.Retrieve(Resource, Name).Password; - } - catch - { - return ""; - } - } - - public void SetKey(string key) => Vault.Add(new PasswordCredential(Resource, Name, key)); - } -} diff --git a/Clippy/Services/OllamaService.cs b/Clippy/Services/OllamaService.cs new file mode 100644 index 0000000..8d2bc84 --- /dev/null +++ b/Clippy/Services/OllamaService.cs @@ -0,0 +1,270 @@ +using Clippy.Core.Enums; +using Clippy.Core.Interfaces; +using Clippy.Core.Services; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Windows.Storage; + +namespace Clippy.Services +{ + public class OllamaService : IChatService + { + private readonly HttpClient _httpClient; + private readonly ISettingsService _settingsService; + + public OllamaService(ISettingsService settingsService) + { + _settingsService = settingsService; + _httpClient = new HttpClient(); + _httpClient.Timeout = TimeSpan.FromMinutes(10); // Increase timeout for long pulls/chats + } + + private string BaseUrl => _settingsService.OllamaUrl.TrimEnd('/'); + + public async Task SendChatAsync(IEnumerable Messages) + { + var request = CreateChatRequest(Messages, false); + var content = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + + try + { + var response = await _httpClient.PostAsync($"{BaseUrl}/api/chat", content); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Assume model missing, try pull + await PullModel(_settingsService.CurrentModel); + // Retry + content = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + response = await _httpClient.PostAsync($"{BaseUrl}/api/chat", content); + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json); + return result?.Message?.Content ?? ""; + } + catch (Exception ex) + { + Debug.WriteLine($"Error sending chat: {ex.Message}"); + return $"Error: {ex.Message}"; + } + } + + public async IAsyncEnumerable StreamChatAsync(IEnumerable Messages, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var request = CreateChatRequest(Messages, true); + var content = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/chat") + { + Content = content + }; + + var response = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + response.Dispose(); + // Try pull + await PullModel(_settingsService.CurrentModel); + + // Retry + var contentRetry = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + var requestRetry = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/chat") + { + Content = contentRetry + }; + response = await _httpClient.SendAsync(requestRetry, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(); + if (string.IsNullOrWhiteSpace(line)) continue; + + OllamaChatResponse? chunk = null; + try + { + chunk = JsonSerializer.Deserialize(line); + } + catch { } + + if (chunk != null) + { + if (chunk.Done) + break; + if (chunk.Message != null) + yield return chunk.Message.Content; + } + } + } + + private object CreateChatRequest(IEnumerable messages, bool stream) + { + var ollamaMessages = new List(); + foreach (var msg in messages) + { + ollamaMessages.Add(new + { + role = msg.Role.ToString().ToLower(), + content = msg.MessageText + }); + } + + return new + { + model = _settingsService.CurrentModel, + messages = ollamaMessages, + stream = stream + }; + } + + public async Task> GetInstalledModels() + { + try + { + var response = await _httpClient.GetAsync($"{BaseUrl}/api/tags"); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(); + var tags = JsonSerializer.Deserialize(json); + + var list = new List(); + if (tags?.Models != null) + { + foreach(var m in tags.Models) + { + list.Add(m.Name); + } + } + return list; + } + catch (Exception ex) + { + Debug.WriteLine($"Error getting models: {ex.Message}"); + return new List(); + } + } + + public async Task PullModel(string modelName) + { + try + { + var request = new { name = modelName }; + var content = new StringContent(JsonSerializer.Serialize(request), Encoding.UTF8, "application/json"); + + // Use a separate request for pull to ensure we don't mess up state + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/api/pull") + { + Content = content + }; + + using var response = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + using var stream = await response.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(); + if (line != null && line.Contains("\"status\":\"success\"")) + return true; + } + return true; // If we reach here, assume success? Or check last status? + } + catch (Exception ex) + { + Debug.WriteLine($"Error pulling model: {ex.Message}"); + return false; + } + } + + public async Task IsOllamaRunning() + { + try + { + var response = await _httpClient.GetAsync($"{BaseUrl}/"); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + public async Task InstallOllama() + { + // Download and run installer + string installerUrl = "https://ollama.com/download/OllamaSetup.exe"; + string tempPath = Path.Combine(ApplicationData.Current.TemporaryFolder.Path, "OllamaSetup.exe"); + + try + { + using var response = await _httpClient.GetAsync(installerUrl); + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadAsByteArrayAsync(); + await File.WriteAllBytesAsync(tempPath, data); + + var p = new Process(); + p.StartInfo = new ProcessStartInfo(tempPath) + { + UseShellExecute = true + }; + p.Start(); + } + catch (Exception ex) + { + Debug.WriteLine("Failed to install ollama: " + ex.Message); + } + } + } + + public class OllamaChatResponse + { + [JsonPropertyName("model")] + public string Model { get; set; } + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } + + [JsonPropertyName("message")] + public OllamaMessage Message { get; set; } + + [JsonPropertyName("done")] + public bool Done { get; set; } + } + + public class OllamaMessage + { + [JsonPropertyName("role")] + public string Role { get; set; } + + [JsonPropertyName("content")] + public string Content { get; set; } + } + + public class OllamaTagsResponse + { + [JsonPropertyName("models")] + public List Models { get; set; } + } + + public class OllamaModelInfo + { + [JsonPropertyName("name")] + public string Name { get; set; } + } +} diff --git a/Clippy/Services/SettingsService.cs b/Clippy/Services/SettingsService.cs index 680f497..5a0d6cf 100644 --- a/Clippy/Services/SettingsService.cs +++ b/Clippy/Services/SettingsService.cs @@ -1,4 +1,4 @@ -using Clippy.Core.Services; +using Clippy.Core.Services; using Clippy.Helpers; using CommunityToolkit.Mvvm.ComponentModel; using System; @@ -77,5 +77,27 @@ public bool KeyboardEnabled SetProperty(ref keyboardEnabled, value); } } + + private string currentModel = (string)(Settings.Values["CurrentModel"] ?? "gemma3:1b"); + public string CurrentModel + { + get => currentModel; + set + { + Settings.Values["CurrentModel"] = value; + SetProperty(ref currentModel, value); + } + } + + private string ollamaUrl = (string)(Settings.Values["OllamaUrl"] ?? "http://localhost:11434"); + public string OllamaUrl + { + get => ollamaUrl; + set + { + Settings.Values["OllamaUrl"] = value; + SetProperty(ref ollamaUrl, value); + } + } } } diff --git a/Clippy/SettingsWindow.xaml b/Clippy/SettingsWindow.xaml index 8568b0c..593d7fa 100644 --- a/Clippy/SettingsWindow.xaml +++ b/Clippy/SettingsWindow.xaml @@ -1,4 +1,4 @@ -Clippy by FireCube - 1.0 - - Clippy Options - + + +