From 3c5b9a353ea7776ba2621b097244d269336ffb27 Mon Sep 17 00:00:00 2001 From: leo Date: Tue, 24 Dec 2024 15:51:27 +0800 Subject: [PATCH] refactor: rewrite OpenAI integration - use `OpenAI` and `Azure.AI.OpenAI` - use streaming response - re-design `AIAssistant` --- README.md | 4 +- src/App.JsonCodeGen.cs | 2 - src/Commands/GenerateCommitMessage.cs | 89 +++++------ src/Models/OpenAI.cs | 124 +++------------ src/Resources/Locales/en_US.axaml | 2 + src/Resources/Locales/zh_CN.axaml | 2 + src/Resources/Locales/zh_TW.axaml | 2 + src/SourceGit.csproj | 3 + src/ViewModels/Repository.cs | 19 +++ src/ViewModels/WorkingCopy.cs | 31 +--- src/Views/AIAssistant.axaml | 41 +++-- src/Views/AIAssistant.axaml.cs | 158 ++++++++++++++++--- src/Views/RevisionFileContentViewer.axaml.cs | 110 +++++++++++++ src/Views/RevisionFiles.axaml.cs | 110 ------------- 14 files changed, 375 insertions(+), 322 deletions(-) diff --git a/README.md b/README.md index 11c53ddd..ee6e530b 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,11 @@ This software supports using OpenAI or other AI service that has an OpenAI comap For `OpenAI`: -* `Server` must be `https://api.openai.com/v1/chat/completions` +* `Server` must be `https://api.openai.com/v1` For other AI service: -* The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1/chat/completions`. For example, when using `Ollama`, it should be `http://localhost:11434/v1/chat/completions` instead of `http://localhost:11434/api/generate` +* The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1`. For example, when using `Ollama`, it should be `http://localhost:11434/v1` instead of `http://localhost:11434/api/generate` * The `API Key` is optional that depends on the service ## External Tools diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs index 70567af5..8daea4f9 100644 --- a/src/App.JsonCodeGen.cs +++ b/src/App.JsonCodeGen.cs @@ -46,8 +46,6 @@ namespace SourceGit [JsonSerializable(typeof(Models.ExternalToolPaths))] [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] [JsonSerializable(typeof(Models.JetBrainsState))] - [JsonSerializable(typeof(Models.OpenAIChatRequest))] - [JsonSerializable(typeof(Models.OpenAIChatResponse))] [JsonSerializable(typeof(Models.ThemeOverrides))] [JsonSerializable(typeof(Models.Version))] [JsonSerializable(typeof(Models.RepositorySettings))] diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs index e4f25f38..f3596588 100644 --- a/src/Commands/GenerateCommitMessage.cs +++ b/src/Commands/GenerateCommitMessage.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Text; using System.Threading; +using Avalonia.Threading; + namespace SourceGit.Commands { /// @@ -20,82 +22,75 @@ namespace SourceGit.Commands } } - public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onProgress) + public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onResponse) { _service = service; _repo = repo; _changes = changes; _cancelToken = cancelToken; - _onProgress = onProgress; + _onResponse = onResponse; } - public string Result() + public void Exec() { try { - var summarybuilder = new StringBuilder(); - var bodyBuilder = new StringBuilder(); + var responseBuilder = new StringBuilder(); + var summaryBuilder = new StringBuilder(); foreach (var change in _changes) { if (_cancelToken.IsCancellationRequested) - return ""; + return; - _onProgress?.Invoke($"Analyzing {change.Path}..."); + responseBuilder.Append("- "); + summaryBuilder.Append("- "); - var summary = GenerateChangeSummary(change); - summarybuilder.Append("- "); - summarybuilder.Append(summary); - summarybuilder.Append("(file: "); - summarybuilder.Append(change.Path); - summarybuilder.Append(")"); - summarybuilder.AppendLine(); + var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); + if (rs.IsSuccess) + { + _service.Chat( + _service.AnalyzeDiffPrompt, + $"Here is the `git diff` output: {rs.StdOut}", + _cancelToken, + update => + { + responseBuilder.Append(update); + summaryBuilder.Append(update); + _onResponse?.Invoke("Waiting for pre-file analyzing to complated...\n\n" + responseBuilder.ToString()); + }); + } - bodyBuilder.Append("- "); - bodyBuilder.Append(summary); - bodyBuilder.AppendLine(); + responseBuilder.Append("\n"); + summaryBuilder.Append("(file: "); + summaryBuilder.Append(change.Path); + summaryBuilder.Append(")\n"); } if (_cancelToken.IsCancellationRequested) - return ""; + return; - _onProgress?.Invoke($"Generating commit message..."); - - var body = bodyBuilder.ToString(); - var subject = GenerateSubject(summarybuilder.ToString()); - return string.Format("{0}\n\n{1}", subject, body); + var responseBody = responseBuilder.ToString(); + var subjectBuilder = new StringBuilder(); + _service.Chat( + _service.GenerateSubjectPrompt, + $"Here are the summaries changes:\n{summaryBuilder}", + _cancelToken, + update => + { + subjectBuilder.Append(update); + _onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}"); + }); } catch (Exception e) { - App.RaiseException(_repo, $"Failed to generate commit message: {e}"); - return ""; + Dispatcher.UIThread.Post(() => App.RaiseException(_repo, $"Failed to generate commit message: {e}")); } } - private string GenerateChangeSummary(Models.Change change) - { - var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); - var diff = rs.IsSuccess ? rs.StdOut : "unknown change"; - - var rsp = _service.Chat(_service.AnalyzeDiffPrompt, $"Here is the `git diff` output: {diff}", _cancelToken); - if (rsp != null && rsp.Choices.Count > 0) - return rsp.Choices[0].Message.Content; - - return string.Empty; - } - - private string GenerateSubject(string summary) - { - var rsp = _service.Chat(_service.GenerateSubjectPrompt, $"Here are the summaries changes:\n{summary}", _cancelToken); - if (rsp != null && rsp.Choices.Count > 0) - return rsp.Choices[0].Message.Content; - - return string.Empty; - } - private Models.OpenAIService _service; private string _repo; private List _changes; private CancellationToken _cancelToken; - private Action _onProgress; + private Action _onResponse; } } diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs index df67ff66..317ba322 100644 --- a/src/Models/OpenAI.cs +++ b/src/Models/OpenAI.cs @@ -1,81 +1,13 @@ using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; +using System.ClientModel; using System.Threading; - +using Azure.AI.OpenAI; using CommunityToolkit.Mvvm.ComponentModel; +using OpenAI; +using OpenAI.Chat; namespace SourceGit.Models { - public class OpenAIChatMessage - { - [JsonPropertyName("role")] - public string Role - { - get; - set; - } - - [JsonPropertyName("content")] - public string Content - { - get; - set; - } - } - - public class OpenAIChatChoice - { - [JsonPropertyName("index")] - public int Index - { - get; - set; - } - - [JsonPropertyName("message")] - public OpenAIChatMessage Message - { - get; - set; - } - } - - public class OpenAIChatResponse - { - [JsonPropertyName("choices")] - public List Choices - { - get; - set; - } = []; - } - - public class OpenAIChatRequest - { - [JsonPropertyName("model")] - public string Model - { - get; - set; - } - - [JsonPropertyName("messages")] - public List Messages - { - get; - set; - } = []; - - public void AddMessage(string role, string content) - { - Messages.Add(new OpenAIChatMessage { Role = role, Content = content }); - } - } - public class OpenAIService : ObservableObject { public string Name @@ -147,45 +79,39 @@ namespace SourceGit.Models """; } - public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation) + public void Chat(string prompt, string question, CancellationToken cancellation, Action onUpdate) { - var chat = new OpenAIChatRequest() { Model = Model }; - chat.AddMessage("user", prompt); - chat.AddMessage("user", question); - - var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) }; - if (!string.IsNullOrEmpty(ApiKey)) + Uri server = new(Server); + ApiKeyCredential key = new(ApiKey); + ChatClient client = null; + if (Server.Contains("openai.azure.com/", StringComparison.Ordinal)) { - if (Server.Contains("openai.azure.com/", StringComparison.Ordinal)) - client.DefaultRequestHeaders.Add("api-key", ApiKey); - else - client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); + var azure = new AzureOpenAIClient(server, key); + client = azure.GetChatClient(Model); + } + else + { + var openai = new OpenAIClient(key, new() { Endpoint = server }); + client = openai.GetChatClient(Model); } - var req = new StringContent(JsonSerializer.Serialize(chat, JsonCodeGen.Default.OpenAIChatRequest), Encoding.UTF8, "application/json"); try { - var task = client.PostAsync(Server, req, cancellation); - task.Wait(cancellation); + var updates = client.CompleteChatStreaming([ + new UserChatMessage(prompt), + new UserChatMessage(question), + ], null, cancellation); - var rsp = task.Result; - var reader = rsp.Content.ReadAsStringAsync(cancellation); - reader.Wait(cancellation); - - var body = reader.Result; - if (!rsp.IsSuccessStatusCode) + foreach (var update in updates) { - throw new Exception($"AI service returns error code {rsp.StatusCode}. Body: {body ?? string.Empty}"); + if (update.ContentUpdate.Count > 0) + onUpdate.Invoke(update.ContentUpdate[0].Text); } - - return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse); } catch { - if (cancellation.IsCancellationRequested) - return null; - - throw; + if (!cancellation.IsCancellationRequested) + throw; } } diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 0a81617a..38e10eea 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -19,7 +19,9 @@ Track Branch: Tracking remote branch AI Assistant + RE-GENERATE Use AI to generate commit message + APPLY AS COMMIT MESSAGE Patch Error Raise errors and refuses to apply the patch diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index cc765738..aee49b80 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -22,7 +22,9 @@ 跟踪分支 设置上游跟踪分支 AI助手 + 重新生成 使用AI助手生成提交信息 + 应用本次生成 应用补丁(apply) 错误 输出错误,并终止应用补丁 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 758ec3be..2b5c99f3 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -22,7 +22,9 @@ 追蹤分支 設定遠端追蹤分支 AI 助理 + 重新產生 使用 AI 產生提交訊息 + 套用為提交訊息 套用修補檔 (apply patch) 錯誤 輸出錯誤,並中止套用修補檔 diff --git a/src/SourceGit.csproj b/src/SourceGit.csproj index 64168b2e..8efed855 100644 --- a/src/SourceGit.csproj +++ b/src/SourceGit.csproj @@ -24,6 +24,7 @@ true true link + true @@ -48,8 +49,10 @@ + + diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 83d23c31..a4d6ba7d 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -1162,6 +1162,25 @@ namespace SourceGit.ViewModels App.GetLauncer()?.OpenRepositoryInTab(node, null); } + public AvaloniaList GetPreferedOpenAIServices() + { + var services = Preference.Instance.OpenAIServices; + if (services == null || services.Count == 0) + return []; + + if (services.Count == 1) + return services; + + var prefered = _settings.PreferedOpenAIService; + foreach (var service in services) + { + if (service.Name.Equals(prefered, StringComparison.Ordinal)) + return [service]; + } + + return services; + } + public ContextMenu CreateContextMenuForGitFlow() { var menu = new ContextMenu(); diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs index bbefd77f..0fe64d0d 100644 --- a/src/ViewModels/WorkingCopy.cs +++ b/src/ViewModels/WorkingCopy.cs @@ -1056,7 +1056,7 @@ namespace SourceGit.ViewModels var menu = new ContextMenu(); var ai = null as MenuItem; - var services = GetPreferedOpenAIServices(); + var services = _repo.GetPreferedOpenAIServices(); if (services.Count > 0) { ai = new MenuItem(); @@ -1067,7 +1067,7 @@ namespace SourceGit.ViewModels { ai.Click += (_, e) => { - var dialog = new Views.AIAssistant(services[0], _repo.FullPath, _selectedStaged, generated => CommitMessage = generated); + var dialog = new Views.AIAssistant(services[0], _repo.FullPath, this, _selectedStaged); App.OpenDialog(dialog); e.Handled = true; }; @@ -1082,7 +1082,7 @@ namespace SourceGit.ViewModels item.Header = service.Name; item.Click += (_, e) => { - var dialog = new Views.AIAssistant(dup, _repo.FullPath, _selectedStaged, generated => CommitMessage = generated); + var dialog = new Views.AIAssistant(dup, _repo.FullPath, this, _selectedStaged); App.OpenDialog(dialog); e.Handled = true; }; @@ -1447,7 +1447,7 @@ namespace SourceGit.ViewModels return null; } - var services = GetPreferedOpenAIServices(); + var services = _repo.GetPreferedOpenAIServices(); if (services.Count == 0) { App.RaiseException(_repo.FullPath, "Bad configuration for OpenAI"); @@ -1456,7 +1456,7 @@ namespace SourceGit.ViewModels if (services.Count == 1) { - var dialog = new Views.AIAssistant(services[0], _repo.FullPath, _staged, generated => CommitMessage = generated); + var dialog = new Views.AIAssistant(services[0], _repo.FullPath, this, _staged); App.OpenDialog(dialog); return null; } @@ -1472,7 +1472,7 @@ namespace SourceGit.ViewModels item.Header = service.Name; item.Click += (_, e) => { - var dialog = new Views.AIAssistant(dup, _repo.FullPath, _staged, generated => CommitMessage = generated); + var dialog = new Views.AIAssistant(dup, _repo.FullPath, this, _staged); App.OpenDialog(dialog); e.Handled = true; }; @@ -1588,25 +1588,6 @@ namespace SourceGit.ViewModels return false; } - private IList GetPreferedOpenAIServices() - { - var services = Preference.Instance.OpenAIServices; - if (services == null || services.Count == 0) - return []; - - if (services.Count == 1) - return services; - - var prefered = _repo.Settings.PreferedOpenAIService; - foreach (var service in services) - { - if (service.Name.Equals(prefered, StringComparison.Ordinal)) - return [service]; - } - - return services; - } - private Repository _repo = null; private bool _isLoadingData = false; private bool _isStaging = false; diff --git a/src/Views/AIAssistant.axaml b/src/Views/AIAssistant.axaml index 528d0e5b..e07c3a3e 100644 --- a/src/Views/AIAssistant.axaml +++ b/src/Views/AIAssistant.axaml @@ -10,7 +10,7 @@ x:Name="ThisControl" Icon="/App.ico" Title="{DynamicResource Text.AIAssistant}" - Width="400" SizeToContent="Height" + Width="520" SizeToContent="Height" CanResize="False" WindowStartupLocation="CenterOwner"> @@ -36,18 +36,33 @@ IsVisible="{OnPlatform True, macOS=False}"/> - - + + - - + + + + +