mirror of
https://github.com/sourcegit-scm/sourcegit.git
synced 2025-01-11 23:57:21 -08:00
refactor: rewrite OpenAI integration
- use `OpenAI` and `Azure.AI.OpenAI` - use streaming response - re-design `AIAssistant`
This commit is contained in:
parent
f06b1d5d51
commit
3c5b9a353e
14 changed files with 375 additions and 322 deletions
|
@ -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
|
||||
|
|
|
@ -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))]
|
||||
|
|
|
@ -3,6 +3,8 @@ using System.Collections.Generic;
|
|||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace SourceGit.Commands
|
||||
{
|
||||
/// <summary>
|
||||
|
@ -20,82 +22,75 @@ namespace SourceGit.Commands
|
|||
}
|
||||
}
|
||||
|
||||
public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> onProgress)
|
||||
public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> 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<Models.Change> _changes;
|
||||
private CancellationToken _cancelToken;
|
||||
private Action<string> _onProgress;
|
||||
private Action<string> _onResponse;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<OpenAIChatChoice> Choices
|
||||
{
|
||||
get;
|
||||
set;
|
||||
} = [];
|
||||
}
|
||||
|
||||
public class OpenAIChatRequest
|
||||
{
|
||||
[JsonPropertyName("model")]
|
||||
public string Model
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
[JsonPropertyName("messages")]
|
||||
public List<OpenAIChatMessage> 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,44 +79,38 @@ namespace SourceGit.Models
|
|||
""";
|
||||
}
|
||||
|
||||
public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation)
|
||||
{
|
||||
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))
|
||||
public void Chat(string prompt, string question, CancellationToken cancellation, Action<string> onUpdate)
|
||||
{
|
||||
Uri server = new(Server);
|
||||
ApiKeyCredential key = new(ApiKey);
|
||||
ChatClient client = null;
|
||||
if (Server.Contains("openai.azure.com/", StringComparison.Ordinal))
|
||||
client.DefaultRequestHeaders.Add("api-key", ApiKey);
|
||||
{
|
||||
var azure = new AzureOpenAIClient(server, key);
|
||||
client = azure.GetChatClient(Model);
|
||||
}
|
||||
else
|
||||
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
|
||||
{
|
||||
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;
|
||||
|
||||
if (!cancellation.IsCancellationRequested)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,9 @@
|
|||
<x:String x:Key="Text.AddWorktree.Tracking" xml:space="preserve">Track Branch:</x:String>
|
||||
<x:String x:Key="Text.AddWorktree.Tracking.Toggle" xml:space="preserve">Tracking remote branch</x:String>
|
||||
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI Assistant</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Regen" xml:space="preserve">RE-GENERATE</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Tip" xml:space="preserve">Use AI to generate commit message</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Use" xml:space="preserve">APPLY AS COMMIT MESSAGE</x:String>
|
||||
<x:String x:Key="Text.Apply" xml:space="preserve">Patch</x:String>
|
||||
<x:String x:Key="Text.Apply.Error" xml:space="preserve">Error</x:String>
|
||||
<x:String x:Key="Text.Apply.Error.Desc" xml:space="preserve">Raise errors and refuses to apply the patch</x:String>
|
||||
|
|
|
@ -22,7 +22,9 @@
|
|||
<x:String x:Key="Text.AddWorktree.Tracking" xml:space="preserve">跟踪分支</x:String>
|
||||
<x:String x:Key="Text.AddWorktree.Tracking.Toggle" xml:space="preserve">设置上游跟踪分支</x:String>
|
||||
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI助手</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Regen" xml:space="preserve">重新生成</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Tip" xml:space="preserve">使用AI助手生成提交信息</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Use" xml:space="preserve">应用本次生成</x:String>
|
||||
<x:String x:Key="Text.Apply" xml:space="preserve">应用补丁(apply)</x:String>
|
||||
<x:String x:Key="Text.Apply.Error" xml:space="preserve">错误</x:String>
|
||||
<x:String x:Key="Text.Apply.Error.Desc" xml:space="preserve">输出错误,并终止应用补丁</x:String>
|
||||
|
|
|
@ -22,7 +22,9 @@
|
|||
<x:String x:Key="Text.AddWorktree.Tracking" xml:space="preserve">追蹤分支</x:String>
|
||||
<x:String x:Key="Text.AddWorktree.Tracking.Toggle" xml:space="preserve">設定遠端追蹤分支</x:String>
|
||||
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI 助理</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Regen" xml:space="preserve">重新產生</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Tip" xml:space="preserve">使用 AI 產生提交訊息</x:String>
|
||||
<x:String x:Key="Text.AIAssistant.Use" xml:space="preserve">套用為提交訊息</x:String>
|
||||
<x:String x:Key="Text.Apply" xml:space="preserve">套用修補檔 (apply patch)</x:String>
|
||||
<x:String x:Key="Text.Apply.Error" xml:space="preserve">錯誤</x:String>
|
||||
<x:String x:Key="Text.Apply.Error.Desc" xml:space="preserve">輸出錯誤,並中止套用修補檔</x:String>
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
<PublishAot>true</PublishAot>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<TrimMode>link</TrimMode>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(DisableUpdateDetection)' == 'true'">
|
||||
|
@ -48,8 +49,10 @@
|
|||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.3" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.1.0" />
|
||||
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.1.0" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||
<PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-rc4.5" />
|
||||
<PackageReference Include="OpenAI" Version="2.1.0" />
|
||||
<PackageReference Include="TextMateSharp" Version="1.0.65" />
|
||||
<PackageReference Include="TextMateSharp.Grammars" Version="1.0.65" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -1162,6 +1162,25 @@ namespace SourceGit.ViewModels
|
|||
App.GetLauncer()?.OpenRepositoryInTab(node, null);
|
||||
}
|
||||
|
||||
public AvaloniaList<Models.OpenAIService> 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();
|
||||
|
|
|
@ -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<Models.OpenAIService> 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;
|
||||
|
|
|
@ -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">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto">
|
||||
|
@ -36,18 +36,33 @@
|
|||
IsVisible="{OnPlatform True, macOS=False}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Animated Icon -->
|
||||
<v:LoadingIcon Grid.Row="1"
|
||||
Width="24" Height="24"
|
||||
Margin="0,16,0,0"/>
|
||||
<!-- AI response -->
|
||||
<v:AIResponseView Grid.Row="1"
|
||||
x:Name="TxtResponse"
|
||||
Margin="8"
|
||||
Height="320"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource Brush.Border2}"
|
||||
Background="{DynamicResource Brush.Contents}"/>
|
||||
|
||||
<!-- Message -->
|
||||
<TextBlock Grid.Row="2"
|
||||
x:Name="ProgressMessage"
|
||||
Margin="16"
|
||||
FontSize="{Binding Source={x:Static vm:Preference.Instance}, Path=DefaultFontSize, Converter={x:Static c:DoubleConverters.Decrease}}"
|
||||
HorizontalAlignment="Center"
|
||||
Text="Generating commit message... Please wait!"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<!-- Options -->
|
||||
<Border Grid.Row="2" Margin="0,0,0,8">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<v:LoadingIcon x:Name="IconInProgress" Width="14" Height="14" Margin="0,0,8,0"/>
|
||||
<Button Classes="flat"
|
||||
x:Name="BtnGenerateCommitMessage"
|
||||
Height="28"
|
||||
Margin="0,0,8,0"
|
||||
Padding="12,0"
|
||||
Content="{DynamicResource Text.AIAssistant.Use}"
|
||||
Click="OnGenerateCommitMessage"/>
|
||||
<Button Classes="flat"
|
||||
x:Name="BtnRegenerate"
|
||||
Height="28"
|
||||
Padding="12,0"
|
||||
Content="{DynamicResource Text.AIAssistant.Regen}"
|
||||
Click="OnRegen"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</v:ChromelessWindow>
|
||||
|
|
|
@ -3,26 +3,112 @@ using System.Collections.Generic;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
|
||||
using AvaloniaEdit.Document;
|
||||
using AvaloniaEdit.Editing;
|
||||
using AvaloniaEdit.TextMate;
|
||||
using AvaloniaEdit;
|
||||
|
||||
namespace SourceGit.Views
|
||||
{
|
||||
public class AIResponseView : TextEditor
|
||||
{
|
||||
protected override Type StyleKeyOverride => typeof(TextEditor);
|
||||
|
||||
public AIResponseView() : base(new TextArea(), new TextDocument())
|
||||
{
|
||||
IsReadOnly = true;
|
||||
ShowLineNumbers = false;
|
||||
WordWrap = true;
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
|
||||
TextArea.TextView.Margin = new Thickness(4, 0);
|
||||
TextArea.TextView.Options.EnableHyperlinks = false;
|
||||
TextArea.TextView.Options.EnableEmailHyperlinks = false;
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
|
||||
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
|
||||
|
||||
if (_textMate == null)
|
||||
{
|
||||
_textMate = Models.TextMateHelper.CreateForEditor(this);
|
||||
Models.TextMateHelper.SetGrammarByFileName(_textMate, "README.md");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnUnloaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnUnloaded(e);
|
||||
|
||||
TextArea.TextView.ContextRequested -= OnTextViewContextRequested;
|
||||
|
||||
if (_textMate != null)
|
||||
{
|
||||
_textMate.Dispose();
|
||||
_textMate = null;
|
||||
}
|
||||
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
var selected = SelectedText;
|
||||
if (string.IsNullOrEmpty(selected))
|
||||
return;
|
||||
|
||||
var copy = new MenuItem() { Header = App.Text("Copy") };
|
||||
copy.Click += (_, ev) =>
|
||||
{
|
||||
App.CopyText(selected);
|
||||
ev.Handled = true;
|
||||
};
|
||||
|
||||
if (this.FindResource("Icons.Copy") is Geometry geo)
|
||||
{
|
||||
copy.Icon = new Avalonia.Controls.Shapes.Path()
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
Stretch = Stretch.Uniform,
|
||||
Data = geo,
|
||||
};
|
||||
}
|
||||
|
||||
var menu = new ContextMenu();
|
||||
menu.Items.Add(copy);
|
||||
menu.Open(TextArea.TextView);
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private TextMate.Installation _textMate = null;
|
||||
}
|
||||
|
||||
public partial class AIAssistant : ChromelessWindow
|
||||
{
|
||||
public AIAssistant()
|
||||
{
|
||||
_cancel = new CancellationTokenSource();
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public AIAssistant(Models.OpenAIService service, string repo, List<Models.Change> changes, Action<string> onDone)
|
||||
public AIAssistant(Models.OpenAIService service, string repo, ViewModels.WorkingCopy wc, List<Models.Change> changes)
|
||||
{
|
||||
_service = service;
|
||||
_repo = repo;
|
||||
_wc = wc;
|
||||
_changes = changes;
|
||||
_onDone = onDone;
|
||||
_cancel = new CancellationTokenSource();
|
||||
|
||||
InitializeComponent();
|
||||
}
|
||||
|
@ -30,39 +116,63 @@ namespace SourceGit.Views
|
|||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
if (string.IsNullOrEmpty(_repo))
|
||||
return;
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
var message = new Commands.GenerateCommitMessage(_service, _repo, _changes, _cancel.Token, SetDescription).Result();
|
||||
if (_cancel.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
{
|
||||
_onDone?.Invoke(message);
|
||||
Close();
|
||||
});
|
||||
}, _cancel.Token);
|
||||
Generate();
|
||||
}
|
||||
|
||||
protected override void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
base.OnClosing(e);
|
||||
_cancel.Cancel();
|
||||
_cancel?.Cancel();
|
||||
}
|
||||
|
||||
private void SetDescription(string message)
|
||||
private void OnGenerateCommitMessage(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Dispatcher.UIThread.Invoke(() => ProgressMessage.Text = message);
|
||||
if (_wc != null)
|
||||
_wc.CommitMessage = TxtResponse.Text;
|
||||
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnRegen(object sender, RoutedEventArgs e)
|
||||
{
|
||||
TxtResponse.Text = string.Empty;
|
||||
Generate();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void Generate()
|
||||
{
|
||||
if (_repo == null)
|
||||
return;
|
||||
|
||||
IconInProgress.IsVisible = true;
|
||||
BtnGenerateCommitMessage.IsEnabled = false;
|
||||
BtnRegenerate.IsEnabled = false;
|
||||
|
||||
_cancel = new CancellationTokenSource();
|
||||
Task.Run(() =>
|
||||
{
|
||||
new Commands.GenerateCommitMessage(_service, _repo, _changes, _cancel.Token, message =>
|
||||
{
|
||||
Dispatcher.UIThread.Invoke(() => TxtResponse.Text = message);
|
||||
}).Exec();
|
||||
|
||||
if (!_cancel.IsCancellationRequested)
|
||||
{
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
{
|
||||
IconInProgress.IsVisible = false;
|
||||
BtnGenerateCommitMessage.IsEnabled = true;
|
||||
BtnRegenerate.IsEnabled = true;
|
||||
});
|
||||
}
|
||||
}, _cancel.Token);
|
||||
}
|
||||
|
||||
private Models.OpenAIService _service;
|
||||
private string _repo;
|
||||
private ViewModels.WorkingCopy _wc;
|
||||
private List<Models.Change> _changes;
|
||||
private Action<string> _onDone;
|
||||
private CancellationTokenSource _cancel;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,117 @@
|
|||
using System;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
|
||||
using AvaloniaEdit;
|
||||
using AvaloniaEdit.Document;
|
||||
using AvaloniaEdit.Editing;
|
||||
using AvaloniaEdit.TextMate;
|
||||
|
||||
namespace SourceGit.Views
|
||||
{
|
||||
public class RevisionTextFileView : TextEditor
|
||||
{
|
||||
protected override Type StyleKeyOverride => typeof(TextEditor);
|
||||
|
||||
public RevisionTextFileView() : base(new TextArea(), new TextDocument())
|
||||
{
|
||||
IsReadOnly = true;
|
||||
ShowLineNumbers = true;
|
||||
WordWrap = false;
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
|
||||
TextArea.LeftMargins[0].Margin = new Thickness(8, 0);
|
||||
TextArea.TextView.Margin = new Thickness(4, 0);
|
||||
TextArea.TextView.Options.EnableHyperlinks = false;
|
||||
TextArea.TextView.Options.EnableEmailHyperlinks = false;
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
|
||||
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
|
||||
UpdateTextMate();
|
||||
}
|
||||
|
||||
protected override void OnUnloaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnUnloaded(e);
|
||||
|
||||
TextArea.TextView.ContextRequested -= OnTextViewContextRequested;
|
||||
|
||||
if (_textMate != null)
|
||||
{
|
||||
_textMate.Dispose();
|
||||
_textMate = null;
|
||||
}
|
||||
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
|
||||
if (DataContext is Models.RevisionTextFile source)
|
||||
{
|
||||
UpdateTextMate();
|
||||
Text = source.Content;
|
||||
}
|
||||
else
|
||||
{
|
||||
Text = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
var selected = SelectedText;
|
||||
if (string.IsNullOrEmpty(selected))
|
||||
return;
|
||||
|
||||
var copy = new MenuItem() { Header = App.Text("Copy") };
|
||||
copy.Click += (_, ev) =>
|
||||
{
|
||||
App.CopyText(selected);
|
||||
ev.Handled = true;
|
||||
};
|
||||
|
||||
if (this.FindResource("Icons.Copy") is Geometry geo)
|
||||
{
|
||||
copy.Icon = new Avalonia.Controls.Shapes.Path()
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
Stretch = Stretch.Uniform,
|
||||
Data = geo,
|
||||
};
|
||||
}
|
||||
|
||||
var menu = new ContextMenu();
|
||||
menu.Items.Add(copy);
|
||||
menu.Open(TextArea.TextView);
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateTextMate()
|
||||
{
|
||||
if (_textMate == null)
|
||||
_textMate = Models.TextMateHelper.CreateForEditor(this);
|
||||
|
||||
if (DataContext is Models.RevisionTextFile file)
|
||||
Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName);
|
||||
}
|
||||
|
||||
private TextMate.Installation _textMate = null;
|
||||
}
|
||||
|
||||
public partial class RevisionFileContentViewer : UserControl
|
||||
{
|
||||
public RevisionFileContentViewer()
|
||||
|
|
|
@ -1,118 +1,8 @@
|
|||
using System;
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
|
||||
using AvaloniaEdit;
|
||||
using AvaloniaEdit.Document;
|
||||
using AvaloniaEdit.Editing;
|
||||
using AvaloniaEdit.TextMate;
|
||||
|
||||
namespace SourceGit.Views
|
||||
{
|
||||
public class RevisionTextFileView : TextEditor
|
||||
{
|
||||
protected override Type StyleKeyOverride => typeof(TextEditor);
|
||||
|
||||
public RevisionTextFileView() : base(new TextArea(), new TextDocument())
|
||||
{
|
||||
IsReadOnly = true;
|
||||
ShowLineNumbers = true;
|
||||
WordWrap = false;
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
|
||||
|
||||
TextArea.LeftMargins[0].Margin = new Thickness(8, 0);
|
||||
TextArea.TextView.Margin = new Thickness(4, 0);
|
||||
TextArea.TextView.Options.EnableHyperlinks = false;
|
||||
TextArea.TextView.Options.EnableEmailHyperlinks = false;
|
||||
}
|
||||
|
||||
protected override void OnLoaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnLoaded(e);
|
||||
|
||||
TextArea.TextView.ContextRequested += OnTextViewContextRequested;
|
||||
UpdateTextMate();
|
||||
}
|
||||
|
||||
protected override void OnUnloaded(RoutedEventArgs e)
|
||||
{
|
||||
base.OnUnloaded(e);
|
||||
|
||||
TextArea.TextView.ContextRequested -= OnTextViewContextRequested;
|
||||
|
||||
if (_textMate != null)
|
||||
{
|
||||
_textMate.Dispose();
|
||||
_textMate = null;
|
||||
}
|
||||
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
|
||||
if (DataContext is Models.RevisionTextFile source)
|
||||
{
|
||||
UpdateTextMate();
|
||||
Text = source.Content;
|
||||
}
|
||||
else
|
||||
{
|
||||
Text = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
var selected = SelectedText;
|
||||
if (string.IsNullOrEmpty(selected))
|
||||
return;
|
||||
|
||||
var copy = new MenuItem() { Header = App.Text("Copy") };
|
||||
copy.Click += (_, ev) =>
|
||||
{
|
||||
App.CopyText(selected);
|
||||
ev.Handled = true;
|
||||
};
|
||||
|
||||
if (this.FindResource("Icons.Copy") is Geometry geo)
|
||||
{
|
||||
copy.Icon = new Avalonia.Controls.Shapes.Path()
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
Stretch = Stretch.Uniform,
|
||||
Data = geo,
|
||||
};
|
||||
}
|
||||
|
||||
var menu = new ContextMenu();
|
||||
menu.Items.Add(copy);
|
||||
menu.Open(TextArea.TextView);
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateTextMate()
|
||||
{
|
||||
if (_textMate == null)
|
||||
_textMate = Models.TextMateHelper.CreateForEditor(this);
|
||||
|
||||
if (DataContext is Models.RevisionTextFile file)
|
||||
Models.TextMateHelper.SetGrammarByFileName(_textMate, file.FileName);
|
||||
}
|
||||
|
||||
private TextMate.Installation _textMate = null;
|
||||
}
|
||||
|
||||
public partial class RevisionFiles : UserControl
|
||||
{
|
||||
public RevisionFiles()
|
||||
|
|
Loading…
Reference in a new issue