refactor: simplify AI assistant architecture with tool-based integration

- Replace complex OpenAIService with AIProvider model
- Implement tool calling support for AI-generated commit messages
- Remove advanced AI configuration UI (prompts, streaming toggle)
- Add dedicated AI namespace with ChatTools, Service, and ToolCallsBuilder
- Update all view models and views to use new AI architecture
- Improve code organization and maintainability

Signed-off-by: leo <longshuang@msn.cn>
This commit is contained in:
leo
2026-03-24 18:24:01 +08:00
parent 516eb50494
commit 91b411ea14
17 changed files with 404 additions and 409 deletions

92
src/AI/ChatTools.cs Normal file
View File

@@ -0,0 +1,92 @@
using System;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using OpenAI.Chat;
namespace SourceGit.AI
{
public static class ChatTools
{
public static readonly ChatTool Tool_GetDetailChangesInFile = ChatTool.CreateFunctionTool(
nameof(GetDetailChangesInFile),
"Get the detailed changes in the specified file in the specified repository.",
BinaryData.FromBytes(Encoding.UTF8.GetBytes("""
{
"type": "object",
"properties": {
"repo": {
"type": "string",
"description": "The path to the repository."
},
"file": {
"type": "string",
"description": "The path to the file."
},
"originalFile": {
"type": "string",
"description": "The path to the original file when it has been renamed."
}
},
"required": ["repo", "file"]
}
""")), false);
public static async Task<ToolChatMessage> Process(ChatToolCall call, Action<string> output)
{
using var doc = JsonDocument.Parse(call.FunctionArguments);
switch (call.FunctionName)
{
case nameof(GetDetailChangesInFile):
{
var hasRepo = doc.RootElement.TryGetProperty("repo", out var repoPath);
var hasFile = doc.RootElement.TryGetProperty("file", out var filePath);
var hasOriginalFile = doc.RootElement.TryGetProperty("originalFile", out var originalFilePath);
if (!hasRepo)
throw new ArgumentException("repo", "The repo argument is required");
if (!hasFile)
throw new ArgumentException("file", "The file argument is required");
output?.Invoke($"Read changes in file: {filePath.GetString()}");
var toolResult = await ChatTools.GetDetailChangesInFile(
repoPath.GetString(),
filePath.GetString(),
hasOriginalFile ? originalFilePath.GetString() : string.Empty);
return new ToolChatMessage(call.Id, toolResult);
}
default:
throw new NotSupportedException($"The tool {call.FunctionName} is not supported");
}
}
private static async Task<string> GetDetailChangesInFile(string repo, string file, string originalFile)
{
var rs = await new GetDiffContentCommand(repo, file, originalFile).ReadAsync();
return rs.IsSuccess ? rs.StdOut : string.Empty;
}
private class GetDiffContentCommand : Commands.Command
{
public GetDiffContentCommand(string repo, string file, string originalFile)
{
WorkingDirectory = repo;
Context = repo;
var builder = new StringBuilder();
builder.Append("diff --no-color --no-ext-diff --diff-algorithm=minimal --cached -- ");
if (!string.IsNullOrEmpty(originalFile) && !file.Equals(originalFile, StringComparison.Ordinal))
builder.Append(originalFile.Quoted()).Append(' ');
builder.Append(file.Quoted());
Args = builder.ToString();
}
public async Task<Result> ReadAsync()
{
return await ReadToEndAsync().ConfigureAwait(false);
}
}
}
}

98
src/AI/Service.cs Normal file
View File

@@ -0,0 +1,98 @@
using System;
using System.ClientModel;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.OpenAI;
using OpenAI;
using OpenAI.Chat;
namespace SourceGit.AI
{
public class Service
{
public Service(Models.AIProvider ai)
{
_ai = ai;
}
public async Task GenerateCommitMessage(string repo, string changeList, Action<string> onUpdate, CancellationToken cancellation)
{
var key = _ai.ReadApiKeyFromEnv ? Environment.GetEnvironmentVariable(_ai.ApiKey) : _ai.ApiKey;
var endPoint = new Uri(_ai.Server);
var credential = new ApiKeyCredential(key);
var client = _ai.Server.Contains("openai.azure.com/", StringComparison.Ordinal)
? new AzureOpenAIClient(endPoint, credential)
: new OpenAIClient(credential, new() { Endpoint = endPoint });
var chatClient = client.GetChatClient(_ai.Model);
var options = new ChatCompletionOptions() { Tools = { ChatTools.Tool_GetDetailChangesInFile } };
var userMessageBuilder = new StringBuilder();
userMessageBuilder
.AppendLine("Generate a commit message (follow the rule of conventional commit message) for given git repository.")
.AppendLine("- Read all given changed files before generating. Do not skip any one file.")
.Append("Reposiory path: ").AppendLine(repo.Quoted())
.AppendLine("Changed files: ")
.Append(changeList);
var messages = new List<ChatMessage>() { new UserChatMessage(userMessageBuilder.ToString()) };
do
{
var inProgress = false;
var updates = chatClient.CompleteChatStreamingAsync(messages, options).WithCancellation(cancellation);
var toolCalls = new ToolCallsBuilder();
var contentBuilder = new StringBuilder();
await foreach (var update in updates)
{
foreach (var contentPart in update.ContentUpdate)
contentBuilder.Append(contentPart.Text);
foreach (var toolCall in update.ToolCallUpdates)
toolCalls.Append(toolCall);
switch (update.FinishReason)
{
case ChatFinishReason.Stop:
onUpdate?.Invoke(string.Empty);
onUpdate?.Invoke("[Assistant]:");
onUpdate?.Invoke(contentBuilder.ToString());
break;
case ChatFinishReason.Length:
throw new Exception("The response was cut off because it reached the maximum length. Consider increasing the max tokens limit.");
case ChatFinishReason.ToolCalls:
{
var calls = toolCalls.Build();
var assistantMessage = new AssistantChatMessage(calls);
if (contentBuilder.Length > 0)
assistantMessage.Content.Add(ChatMessageContentPart.CreateTextPart(contentBuilder.ToString()));
messages.Add(assistantMessage);
foreach (var call in calls)
{
var result = await ChatTools.Process(call, onUpdate);
messages.Add(result);
}
inProgress = true;
break;
}
case ChatFinishReason.ContentFilter:
throw new Exception("Ommitted content due to a content filter flag");
default:
break;
}
}
if (!inProgress)
break;
} while (true);
}
private readonly Models.AIProvider _ai;
}
}

119
src/AI/ToolCallsBuilder.cs Normal file
View File

@@ -0,0 +1,119 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using OpenAI.Chat;
namespace SourceGit.AI
{
public class ToolCallsBuilder
{
private readonly Dictionary<int, string> _indexToToolCallId = [];
private readonly Dictionary<int, string> _indexToFunctionName = [];
private readonly Dictionary<int, SequenceBuilder<byte>> _indexToFunctionArguments = [];
public void Append(StreamingChatToolCallUpdate toolCallUpdate)
{
if (toolCallUpdate.ToolCallId != null)
{
_indexToToolCallId[toolCallUpdate.Index] = toolCallUpdate.ToolCallId;
}
if (toolCallUpdate.FunctionName != null)
{
_indexToFunctionName[toolCallUpdate.Index] = toolCallUpdate.FunctionName;
}
if (toolCallUpdate.FunctionArgumentsUpdate != null && !toolCallUpdate.FunctionArgumentsUpdate.ToMemory().IsEmpty)
{
if (!_indexToFunctionArguments.TryGetValue(toolCallUpdate.Index, out SequenceBuilder<byte> argumentsBuilder))
{
argumentsBuilder = new SequenceBuilder<byte>();
_indexToFunctionArguments[toolCallUpdate.Index] = argumentsBuilder;
}
argumentsBuilder.Append(toolCallUpdate.FunctionArgumentsUpdate);
}
}
public IReadOnlyList<ChatToolCall> Build()
{
List<ChatToolCall> toolCalls = [];
foreach ((int index, string toolCallId) in _indexToToolCallId)
{
ReadOnlySequence<byte> sequence = _indexToFunctionArguments[index].Build();
ChatToolCall toolCall = ChatToolCall.CreateFunctionToolCall(
id: toolCallId,
functionName: _indexToFunctionName[index],
functionArguments: BinaryData.FromBytes(sequence.ToArray()));
toolCalls.Add(toolCall);
}
return toolCalls;
}
}
public class SequenceBuilder<T>
{
Segment _first;
Segment _last;
public void Append(ReadOnlyMemory<T> data)
{
if (_first == null)
{
Debug.Assert(_last == null);
_first = new Segment(data);
_last = _first;
}
else
{
_last = _last!.Append(data);
}
}
public ReadOnlySequence<T> Build()
{
if (_first == null)
{
Debug.Assert(_last == null);
return ReadOnlySequence<T>.Empty;
}
if (_first == _last)
{
Debug.Assert(_first.Next == null);
return new ReadOnlySequence<T>(_first.Memory);
}
return new ReadOnlySequence<T>(_first, 0, _last!, _last!.Memory.Length);
}
private sealed class Segment : ReadOnlySequenceSegment<T>
{
public Segment(ReadOnlyMemory<T> items) : this(items, 0)
{
}
private Segment(ReadOnlyMemory<T> items, long runningIndex)
{
Debug.Assert(runningIndex >= 0);
Memory = items;
RunningIndex = runningIndex;
}
public Segment Append(ReadOnlyMemory<T> items)
{
long runningIndex;
checked
{ runningIndex = RunningIndex + Memory.Length; }
Segment segment = new(items, runningIndex);
Next = segment;
return segment;
}
}
}
}

View File

@@ -1,101 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace SourceGit.Commands
{
/// <summary>
/// A C# version of https://github.com/anjerodev/commitollama
/// </summary>
public class GenerateCommitMessage
{
public class GetDiffContent : Command
{
public GetDiffContent(string repo, Models.DiffOption opt)
{
WorkingDirectory = repo;
Context = repo;
Args = $"diff --no-color --no-ext-diff --diff-algorithm=minimal {opt}";
}
public async Task<Result> ReadAsync()
{
return await ReadToEndAsync().ConfigureAwait(false);
}
}
public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> onResponse)
{
_service = service;
_repo = repo;
_changes = changes;
_cancelToken = cancelToken;
_onResponse = onResponse;
}
public async Task ExecAsync()
{
try
{
_onResponse?.Invoke("Waiting for pre-file analyzing to completed...\n\n");
var responseBuilder = new StringBuilder();
var summaryBuilder = new StringBuilder();
foreach (var change in _changes)
{
if (_cancelToken.IsCancellationRequested)
return;
responseBuilder.Append("- ");
summaryBuilder.Append("- ");
var rs = await new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadAsync();
if (rs.IsSuccess)
{
await _service.ChatAsync(
_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 completed...\n\n{responseBuilder}");
});
}
responseBuilder.AppendLine();
summaryBuilder.Append("(file: ").Append(change.Path).AppendLine(")");
}
if (_cancelToken.IsCancellationRequested)
return;
var responseBody = responseBuilder.ToString();
var subjectBuilder = new StringBuilder();
await _service.ChatAsync(
_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}");
}
}
private Models.OpenAIService _service;
private string _repo;
private List<Models.Change> _changes;
private CancellationToken _cancelToken;
private Action<string> _onResponse;
}
}

11
src/Models/AIProvider.cs Normal file
View File

@@ -0,0 +1,11 @@
namespace SourceGit.Models
{
public class AIProvider
{
public string Name { get; set; }
public string Server { get; set; }
public string Model { get; set; }
public string ApiKey { get; set; }
public bool ReadApiKeyFromEnv { get; set; }
}
}

View File

@@ -1,239 +0,0 @@
using System;
using System.ClientModel;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.OpenAI;
using CommunityToolkit.Mvvm.ComponentModel;
using OpenAI;
using OpenAI.Chat;
namespace SourceGit.Models
{
public partial class OpenAIResponse
{
public OpenAIResponse(Action<string> onUpdate)
{
_onUpdate = onUpdate;
}
public void Append(string text)
{
var buffer = text;
if (_thinkTail.Length > 0)
{
_thinkTail.Append(buffer);
buffer = _thinkTail.ToString();
_thinkTail.Clear();
}
buffer = REG_COT().Replace(buffer, "");
var startIdx = buffer.IndexOf('<');
if (startIdx >= 0)
{
if (startIdx > 0)
OnReceive(buffer.Substring(0, startIdx));
var endIdx = buffer.IndexOf('>', startIdx + 1);
if (endIdx <= startIdx)
{
if (buffer.Length - startIdx <= 15)
_thinkTail.Append(buffer.AsSpan(startIdx));
else
OnReceive(buffer.Substring(startIdx));
}
else if (endIdx < startIdx + 15)
{
var tag = buffer.Substring(startIdx + 1, endIdx - startIdx - 1);
if (_thinkTags.Contains(tag))
_thinkTail.Append(buffer.AsSpan(startIdx));
else
OnReceive(buffer.Substring(startIdx));
}
else
{
OnReceive(buffer.Substring(startIdx));
}
}
else
{
OnReceive(buffer);
}
}
public void End()
{
if (_thinkTail.Length > 0)
{
OnReceive(_thinkTail.ToString());
_thinkTail.Clear();
}
}
private void OnReceive(string text)
{
if (!_hasTrimmedStart)
{
text = text.TrimStart();
if (string.IsNullOrEmpty(text))
return;
_hasTrimmedStart = true;
}
_onUpdate?.Invoke(text);
}
[GeneratedRegex(@"<(think|thought|thinking|thought_chain)>.*?</\1>", RegexOptions.Singleline)]
private static partial Regex REG_COT();
private Action<string> _onUpdate = null;
private StringBuilder _thinkTail = new StringBuilder();
private HashSet<string> _thinkTags = ["think", "thought", "thinking", "thought_chain"];
private bool _hasTrimmedStart = false;
}
public class OpenAIService : ObservableObject
{
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
public string Server
{
get => _server;
set => SetProperty(ref _server, value);
}
public string ApiKey
{
get => _apiKey;
set => SetProperty(ref _apiKey, value);
}
public bool ReadApiKeyFromEnv
{
get => _readApiKeyFromEnv;
set => SetProperty(ref _readApiKeyFromEnv, value);
}
public string Model
{
get => _model;
set => SetProperty(ref _model, value);
}
public bool Streaming
{
get => _streaming;
set => SetProperty(ref _streaming, value);
}
public string AnalyzeDiffPrompt
{
get => _analyzeDiffPrompt;
set => SetProperty(ref _analyzeDiffPrompt, value);
}
public string GenerateSubjectPrompt
{
get => _generateSubjectPrompt;
set => SetProperty(ref _generateSubjectPrompt, value);
}
public OpenAIService()
{
AnalyzeDiffPrompt = """
You are an expert developer specialist in creating commits.
Provide a super concise one sentence overall changes summary of the user `git diff` output following strictly the next rules:
- Do not use any code snippets, imports, file routes or bullets points.
- Do not mention the route of file that has been change.
- Write clear, concise, and descriptive messages that explain the MAIN GOAL made of the changes.
- Use the present tense and active voice in the message, for example, "Fix bug" instead of "Fixed bug.".
- Use the imperative mood, which gives the message a sense of command, e.g. "Add feature" instead of "Added feature".
- Avoid using general terms like "update" or "change", be specific about what was updated or changed.
- Avoid using terms like "The main goal of", just output directly the summary in plain text
""";
GenerateSubjectPrompt = """
You are an expert developer specialist in creating commits messages.
Your only goal is to retrieve a single commit message.
Based on the provided user changes, combine them in ONE SINGLE commit message retrieving the global idea, following strictly the next rules:
- Assign the commit {type} according to the next conditions:
feat: Only when adding a new feature.
fix: When fixing a bug.
docs: When updating documentation.
style: When changing elements styles or design and/or making changes to the code style (formatting, missing semicolons, etc.) without changing the code logic.
test: When adding or updating tests.
chore: When making changes to the build process or auxiliary tools and libraries.
revert: When undoing a previous commit.
refactor: When restructuring code without changing its external behavior, or is any of the other refactor types.
- Do not add any issues numeration, explain your output nor introduce your answer.
- Output directly only one commit message in plain text with the next format: {type}: {commit_message}.
- Be as concise as possible, keep the message under 50 characters.
""";
}
public async Task ChatAsync(string prompt, string question, CancellationToken cancellation, Action<string> onUpdate)
{
var key = _readApiKeyFromEnv ? Environment.GetEnvironmentVariable(_apiKey) : _apiKey;
var endPoint = new Uri(_server);
var credential = new ApiKeyCredential(key);
var client = _server.Contains("openai.azure.com/", StringComparison.Ordinal)
? new AzureOpenAIClient(endPoint, credential)
: new OpenAIClient(credential, new() { Endpoint = endPoint });
var chatClient = client.GetChatClient(_model);
var messages = new List<ChatMessage>()
{
_model.Equals("o1-mini", StringComparison.Ordinal) ? new UserChatMessage(prompt) : new SystemChatMessage(prompt),
new UserChatMessage(question),
};
try
{
var rsp = new OpenAIResponse(onUpdate);
if (_streaming)
{
var updates = chatClient.CompleteChatStreamingAsync(messages, null, cancellation);
await foreach (var update in updates)
{
if (update.ContentUpdate.Count > 0)
rsp.Append(update.ContentUpdate[0].Text);
}
}
else
{
var completion = await chatClient.CompleteChatAsync(messages, null, cancellation);
if (completion.Value.Content.Count > 0)
rsp.Append(completion.Value.Content[0].Text);
}
rsp.End();
}
catch
{
if (!cancellation.IsCancellationRequested)
throw;
}
}
private string _name;
private string _server;
private string _apiKey;
private bool _readApiKeyFromEnv = false;
private string _model;
private bool _streaming = true;
private string _analyzeDiffPrompt;
private string _generateSubjectPrompt;
}
}

View File

@@ -610,14 +610,11 @@
<x:String x:Key="Text.Period.Yesterday" xml:space="preserve">Yesterday</x:String>
<x:String x:Key="Text.Preferences" xml:space="preserve">Preferences</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.AnalyzeDiffPrompt" xml:space="preserve">Analyze Diff Prompt</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API Key</x:String>
<x:String x:Key="Text.Preferences.AI.GenerateSubjectPrompt" xml:space="preserve">Generate Subject Prompt</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Model</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Name</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">Entered value is the name to load API key from ENV</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Server</x:String>
<x:String x:Key="Text.Preferences.AI.Streaming" xml:space="preserve">Enable Streaming</x:String>
<x:String x:Key="Text.Preferences.Appearance" xml:space="preserve">APPEARANCE</x:String>
<x:String x:Key="Text.Preferences.Appearance.DefaultFont" xml:space="preserve">Default Font</x:String>
<x:String x:Key="Text.Preferences.Appearance.EditorTabWidth" xml:space="preserve">Editor Tab Width</x:String>

View File

@@ -33,7 +33,7 @@
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="BuildDate" Value="$([System.DateTime]::Now.ToString('o'))"/>
<AssemblyMetadata Include="BuildDate" Value="$([System.DateTime]::Now.ToString('o'))" />
</ItemGroup>
<ItemGroup>
@@ -60,7 +60,7 @@
<PackageReference Include="OpenAI" Version="2.8.0" />
<PackageReference Include="Pfim" Version="0.11.4" />
<ProjectReference Include="../depends/AvaloniaEdit/src/AvaloniaEdit.TextMate/AvaloniaEdit.TextMate.csproj"/>
<ProjectReference Include="../depends/AvaloniaEdit/src/AvaloniaEdit.TextMate/AvaloniaEdit.TextMate.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -22,13 +24,17 @@ namespace SourceGit.ViewModels
private set => SetProperty(ref _text, value);
}
public AIAssistant(Repository repo, Models.OpenAIService service, List<Models.Change> changes)
public AIAssistant(string repo, Models.AIProvider provider, List<Models.Change> changes)
{
_repo = repo;
_service = service;
_changes = changes;
_provider = provider;
_cancel = new CancellationTokenSource();
var builder = new StringBuilder();
foreach (var c in changes)
SerializeChange(c, builder);
_changeList = builder.ToString();
Gen();
}
@@ -40,16 +46,32 @@ namespace SourceGit.ViewModels
Gen();
}
public void Apply()
{
_repo.SetCommitMessage(Text);
}
public void Cancel()
{
_cancel?.Cancel();
}
private void SerializeChange(Models.Change c, StringBuilder builder)
{
var status = c.Index switch
{
Models.ChangeState.Added => "A",
Models.ChangeState.Modified => "M",
Models.ChangeState.Deleted => "D",
Models.ChangeState.TypeChanged => "T",
Models.ChangeState.Renamed => "R",
Models.ChangeState.Copied => "C",
_ => " ",
};
builder.Append(status).Append('\t');
if (c.Index == Models.ChangeState.Renamed || c.Index == Models.ChangeState.Copied)
builder.Append(c.OriginalPath).Append(" -> ").Append(c.Path).AppendLine();
else
builder.Append(c.Path).AppendLine();
}
private void Gen()
{
Text = string.Empty;
@@ -58,18 +80,31 @@ namespace SourceGit.ViewModels
_cancel = new CancellationTokenSource();
Task.Run(async () =>
{
await new Commands.GenerateCommitMessage(_service, _repo.FullPath, _changes, _cancel.Token, message =>
var server = new AI.Service(_provider);
var builder = new StringBuilder();
builder.AppendLine("Asking AI to generate commit message...").AppendLine();
Dispatcher.UIThread.Post(() => Text = builder.ToString());
try
{
Dispatcher.UIThread.Post(() => Text = message);
}).ExecAsync().ConfigureAwait(false);
await server.GenerateCommitMessage(_repo, _changeList, message =>
{
builder.AppendLine(message);
Dispatcher.UIThread.Post(() => Text = builder.ToString());
}, _cancel.Token).ConfigureAwait(false);
}
catch (Exception e)
{
App.RaiseException(_repo, e.Message);
}
Dispatcher.UIThread.Post(() => IsGenerating = false);
}, _cancel.Token);
}
private readonly Repository _repo = null;
private Models.OpenAIService _service = null;
private List<Models.Change> _changes = null;
private readonly string _repo = null;
private readonly Models.AIProvider _provider = null;
private readonly string _changeList = null;
private CancellationTokenSource _cancel = null;
private bool _isGenerating = false;
private string _text = string.Empty;

View File

@@ -480,7 +480,7 @@ namespace SourceGit.ViewModels
set;
} = [];
public AvaloniaList<Models.OpenAIService> OpenAIServices
public AvaloniaList<Models.AIProvider> OpenAIServices
{
get;
set;

View File

@@ -1599,7 +1599,7 @@ namespace SourceGit.ViewModels
log.Complete();
}
public List<Models.OpenAIService> GetPreferredOpenAIServices()
public List<Models.AIProvider> GetPreferredOpenAIServices()
{
var services = Preferences.Instance.OpenAIServices;
if (services == null || services.Count == 0)
@@ -1609,7 +1609,7 @@ namespace SourceGit.ViewModels
return [services[0]];
var preferred = _settings.PreferredOpenAIService;
var all = new List<Models.OpenAIService>();
var all = new List<Models.AIProvider>();
foreach (var service in services)
{
if (service.Name.Equals(preferred, StringComparison.Ordinal))

View File

@@ -51,13 +51,6 @@
<v:LoadingIcon Width="14" Height="14"
Margin="0,0,8,0"
IsVisible="{Binding IsGenerating}"/>
<Button Classes="flat"
Height="28"
Margin="0,0,8,0"
Padding="12,0"
Content="{DynamicResource Text.AIAssistant.Use}"
IsEnabled="{Binding !IsGenerating}"
Click="OnApply"/>
<Button Classes="flat"
Height="28"
Padding="12,0"

View File

@@ -9,12 +9,29 @@ using Avalonia.Media;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
namespace SourceGit.Views
{
public class AIResponseView : TextEditor
{
public class LineStyleTransformer : DocumentColorizingTransformer
{
protected override void ColorizeLine(DocumentLine line)
{
var content = CurrentContext.Document.GetText(line);
if (content.StartsWith("Read changes in file: ", StringComparison.Ordinal))
{
ChangeLinePart(line.Offset + 22, line.EndOffset, v =>
{
v.TextRunProperties.SetForegroundBrush(Brushes.DeepSkyBlue);
v.TextRunProperties.SetTextDecorations(TextDecorations.Underline);
});
}
}
}
public static readonly StyledProperty<string> ContentProperty =
AvaloniaProperty.Register<AIResponseView, string>(nameof(Content), string.Empty);
@@ -49,6 +66,7 @@ namespace SourceGit.Views
{
_textMate = Models.TextMateHelper.CreateForEditor(this);
Models.TextMateHelper.SetGrammarByFileName(_textMate, "README.md");
TextArea.TextView.LineTransformers.Add(new LineStyleTransformer());
}
}
@@ -122,11 +140,5 @@ namespace SourceGit.Views
base.OnClosing(e);
(DataContext as ViewModels.AIAssistant)?.Cancel();
}
private void OnApply(object sender, RoutedEventArgs e)
{
(DataContext as ViewModels.AIAssistant)?.Apply();
Close();
}
}
}

View File

@@ -588,7 +588,7 @@ namespace SourceGit.Views
if (services.Count == 1)
{
await App.ShowDialog(new ViewModels.AIAssistant(repo, services[0], vm.Staged));
await App.ShowDialog(new ViewModels.AIAssistant(repo.FullPath, services[0], vm.Staged));
e.Handled = true;
return;
}
@@ -601,7 +601,7 @@ namespace SourceGit.Views
item.Header = service.Name;
item.Click += async (_, ev) =>
{
await App.ShowDialog(new ViewModels.AIAssistant(repo, dup, vm.Staged));
await App.ShowDialog(new ViewModels.AIAssistant(repo.FullPath, dup, vm.Staged));
ev.Handled = true;
};

View File

@@ -792,7 +792,7 @@
<TextBlock Classes="tab_header" Text="{DynamicResource Text.Preferences.AI}"/>
</TabItem.Header>
<Grid Margin="0,8,0,16" MinHeight="400">
<Grid Margin="0,8,0,16" MinHeight="360">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*" MaxWidth="400"/>
@@ -822,7 +822,7 @@
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="m:OpenAIService">
<DataTemplate DataType="m:AIProvider">
<Grid ColumnDefinitions="Auto,*">
<Path Grid.Column="0" Width="14" Height="14" Data="{StaticResource Icons.AIAssist}"/>
<TextBlock Grid.Column="1" Text="{Binding Name}" Margin="8,0" TextTrimming="CharacterEllipsis"/>
@@ -859,7 +859,7 @@
</ContentControl.Content>
<ContentControl.DataTemplates>
<DataTemplate DataType="m:OpenAIService">
<DataTemplate DataType="m:AIProvider">
<StackPanel Orientation="Vertical" MaxWidth="680">
<TextBlock Text="{DynamicResource Text.Preferences.AI.Name}"/>
<TextBox Margin="0,4,0,0" CornerRadius="3" Height="28" Text="{Binding Name, Mode=TwoWay}"/>
@@ -875,28 +875,6 @@
<CheckBox Margin="0,4,0,0"
Content="{DynamicResource Text.Preferences.AI.ReadApiKeyFromEnv}"
IsChecked="{Binding ReadApiKeyFromEnv, Mode=TwoWay}"/>
<TextBlock Margin="0,12,0,0" Text="{DynamicResource Text.Preferences.AI.AnalyzeDiffPrompt}"/>
<TextBox Height="120"
Margin="0,4,0,0"
CornerRadius="3"
VerticalContentAlignment="Top"
Text="{Binding AnalyzeDiffPrompt, Mode=TwoWay}"
AcceptsReturn="true"
TextWrapping="Wrap"/>
<TextBlock Margin="0,12,0,0" Text="{DynamicResource Text.Preferences.AI.GenerateSubjectPrompt}"/>
<TextBox Height="120"
Margin="0,4,0,0"
CornerRadius="3"
VerticalContentAlignment="Top"
Text="{Binding GenerateSubjectPrompt, Mode=TwoWay}"
AcceptsReturn="true"
TextWrapping="Wrap"/>
<CheckBox Margin="0,12,0,0"
Content="{DynamicResource Text.Preferences.AI.Streaming}"
IsChecked="{Binding Streaming, Mode=TwoWay}"/>
</StackPanel>
</DataTemplate>
</ContentControl.DataTemplates>

View File

@@ -95,10 +95,10 @@ namespace SourceGit.Views
set;
} = false;
public static readonly StyledProperty<Models.OpenAIService> SelectedOpenAIServiceProperty =
AvaloniaProperty.Register<Preferences, Models.OpenAIService>(nameof(SelectedOpenAIService));
public static readonly StyledProperty<Models.AIProvider> SelectedOpenAIServiceProperty =
AvaloniaProperty.Register<Preferences, Models.AIProvider>(nameof(SelectedOpenAIService));
public Models.OpenAIService SelectedOpenAIService
public Models.AIProvider SelectedOpenAIService
{
get => GetValue(SelectedOpenAIServiceProperty);
set => SetValue(SelectedOpenAIServiceProperty, value);
@@ -397,7 +397,7 @@ namespace SourceGit.Views
private void OnAddOpenAIService(object sender, RoutedEventArgs e)
{
var service = new Models.OpenAIService() { Name = "Unnamed Service" };
var service = new Models.AIProvider() { Name = "Unnamed Service" };
ViewModels.Preferences.Instance.OpenAIServices.Add(service);
SelectedOpenAIService = service;

View File

@@ -929,7 +929,7 @@ namespace SourceGit.Views
{
ai.Click += async (_, e) =>
{
await App.ShowDialog(new ViewModels.AIAssistant(repo, services[0], selectedStaged));
await App.ShowDialog(new ViewModels.AIAssistant(repo.FullPath, services[0], selectedStaged));
e.Handled = true;
};
}
@@ -943,7 +943,7 @@ namespace SourceGit.Views
item.Header = service.Name;
item.Click += async (_, e) =>
{
await App.ShowDialog(new ViewModels.AIAssistant(repo, dup, selectedStaged));
await App.ShowDialog(new ViewModels.AIAssistant(repo.FullPath, dup, selectedStaged));
e.Handled = true;
};