mirror of
https://fastgit.cc/github.com/sourcegit-scm/sourcegit
synced 2026-04-21 05:10:25 +08:00
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:
92
src/AI/ChatTools.cs
Normal file
92
src/AI/ChatTools.cs
Normal 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
98
src/AI/Service.cs
Normal 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
119
src/AI/ToolCallsBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
11
src/Models/AIProvider.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -480,7 +480,7 @@ namespace SourceGit.ViewModels
|
||||
set;
|
||||
} = [];
|
||||
|
||||
public AvaloniaList<Models.OpenAIService> OpenAIServices
|
||||
public AvaloniaList<Models.AIProvider> OpenAIServices
|
||||
{
|
||||
get;
|
||||
set;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user