Skip to content

Commit 91b411e

Browse files
committed
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>
1 parent 516eb50 commit 91b411e

17 files changed

+403
-408
lines changed

src/AI/ChatTools.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System;
2+
using System.Text;
3+
using System.Text.Json;
4+
using System.Threading.Tasks;
5+
using OpenAI.Chat;
6+
7+
namespace SourceGit.AI
8+
{
9+
public static class ChatTools
10+
{
11+
public static readonly ChatTool Tool_GetDetailChangesInFile = ChatTool.CreateFunctionTool(
12+
nameof(GetDetailChangesInFile),
13+
"Get the detailed changes in the specified file in the specified repository.",
14+
BinaryData.FromBytes(Encoding.UTF8.GetBytes("""
15+
{
16+
"type": "object",
17+
"properties": {
18+
"repo": {
19+
"type": "string",
20+
"description": "The path to the repository."
21+
},
22+
"file": {
23+
"type": "string",
24+
"description": "The path to the file."
25+
},
26+
"originalFile": {
27+
"type": "string",
28+
"description": "The path to the original file when it has been renamed."
29+
}
30+
},
31+
"required": ["repo", "file"]
32+
}
33+
""")), false);
34+
35+
public static async Task<ToolChatMessage> Process(ChatToolCall call, Action<string> output)
36+
{
37+
using var doc = JsonDocument.Parse(call.FunctionArguments);
38+
39+
switch (call.FunctionName)
40+
{
41+
case nameof(GetDetailChangesInFile):
42+
{
43+
var hasRepo = doc.RootElement.TryGetProperty("repo", out var repoPath);
44+
var hasFile = doc.RootElement.TryGetProperty("file", out var filePath);
45+
var hasOriginalFile = doc.RootElement.TryGetProperty("originalFile", out var originalFilePath);
46+
if (!hasRepo)
47+
throw new ArgumentException("repo", "The repo argument is required");
48+
if (!hasFile)
49+
throw new ArgumentException("file", "The file argument is required");
50+
51+
output?.Invoke($"Read changes in file: {filePath.GetString()}");
52+
53+
var toolResult = await ChatTools.GetDetailChangesInFile(
54+
repoPath.GetString(),
55+
filePath.GetString(),
56+
hasOriginalFile ? originalFilePath.GetString() : string.Empty);
57+
return new ToolChatMessage(call.Id, toolResult);
58+
}
59+
default:
60+
throw new NotSupportedException($"The tool {call.FunctionName} is not supported");
61+
}
62+
}
63+
64+
private static async Task<string> GetDetailChangesInFile(string repo, string file, string originalFile)
65+
{
66+
var rs = await new GetDiffContentCommand(repo, file, originalFile).ReadAsync();
67+
return rs.IsSuccess ? rs.StdOut : string.Empty;
68+
}
69+
70+
private class GetDiffContentCommand : Commands.Command
71+
{
72+
public GetDiffContentCommand(string repo, string file, string originalFile)
73+
{
74+
WorkingDirectory = repo;
75+
Context = repo;
76+
77+
var builder = new StringBuilder();
78+
builder.Append("diff --no-color --no-ext-diff --diff-algorithm=minimal --cached -- ");
79+
if (!string.IsNullOrEmpty(originalFile) && !file.Equals(originalFile, StringComparison.Ordinal))
80+
builder.Append(originalFile.Quoted()).Append(' ');
81+
builder.Append(file.Quoted());
82+
83+
Args = builder.ToString();
84+
}
85+
86+
public async Task<Result> ReadAsync()
87+
{
88+
return await ReadToEndAsync().ConfigureAwait(false);
89+
}
90+
}
91+
}
92+
}

src/AI/Service.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using System;
2+
using System.ClientModel;
3+
using System.Collections.Generic;
4+
using System.Text;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Azure.AI.OpenAI;
8+
using OpenAI;
9+
using OpenAI.Chat;
10+
11+
namespace SourceGit.AI
12+
{
13+
public class Service
14+
{
15+
public Service(Models.AIProvider ai)
16+
{
17+
_ai = ai;
18+
}
19+
20+
public async Task GenerateCommitMessage(string repo, string changeList, Action<string> onUpdate, CancellationToken cancellation)
21+
{
22+
var key = _ai.ReadApiKeyFromEnv ? Environment.GetEnvironmentVariable(_ai.ApiKey) : _ai.ApiKey;
23+
var endPoint = new Uri(_ai.Server);
24+
var credential = new ApiKeyCredential(key);
25+
var client = _ai.Server.Contains("openai.azure.com/", StringComparison.Ordinal)
26+
? new AzureOpenAIClient(endPoint, credential)
27+
: new OpenAIClient(credential, new() { Endpoint = endPoint });
28+
29+
var chatClient = client.GetChatClient(_ai.Model);
30+
var options = new ChatCompletionOptions() { Tools = { ChatTools.Tool_GetDetailChangesInFile } };
31+
32+
var userMessageBuilder = new StringBuilder();
33+
userMessageBuilder
34+
.AppendLine("Generate a commit message (follow the rule of conventional commit message) for given git repository.")
35+
.AppendLine("- Read all given changed files before generating. Do not skip any one file.")
36+
.Append("Reposiory path: ").AppendLine(repo.Quoted())
37+
.AppendLine("Changed files: ")
38+
.Append(changeList);
39+
40+
var messages = new List<ChatMessage>() { new UserChatMessage(userMessageBuilder.ToString()) };
41+
42+
do
43+
{
44+
var inProgress = false;
45+
var updates = chatClient.CompleteChatStreamingAsync(messages, options).WithCancellation(cancellation);
46+
var toolCalls = new ToolCallsBuilder();
47+
var contentBuilder = new StringBuilder();
48+
49+
await foreach (var update in updates)
50+
{
51+
foreach (var contentPart in update.ContentUpdate)
52+
contentBuilder.Append(contentPart.Text);
53+
54+
foreach (var toolCall in update.ToolCallUpdates)
55+
toolCalls.Append(toolCall);
56+
57+
switch (update.FinishReason)
58+
{
59+
case ChatFinishReason.Stop:
60+
onUpdate?.Invoke(string.Empty);
61+
onUpdate?.Invoke("[Assistant]:");
62+
onUpdate?.Invoke(contentBuilder.ToString());
63+
break;
64+
case ChatFinishReason.Length:
65+
throw new Exception("The response was cut off because it reached the maximum length. Consider increasing the max tokens limit.");
66+
case ChatFinishReason.ToolCalls:
67+
{
68+
var calls = toolCalls.Build();
69+
var assistantMessage = new AssistantChatMessage(calls);
70+
if (contentBuilder.Length > 0)
71+
assistantMessage.Content.Add(ChatMessageContentPart.CreateTextPart(contentBuilder.ToString()));
72+
messages.Add(assistantMessage);
73+
74+
foreach (var call in calls)
75+
{
76+
var result = await ChatTools.Process(call, onUpdate);
77+
messages.Add(result);
78+
}
79+
80+
inProgress = true;
81+
break;
82+
}
83+
case ChatFinishReason.ContentFilter:
84+
throw new Exception("Ommitted content due to a content filter flag");
85+
default:
86+
break;
87+
}
88+
89+
}
90+
91+
if (!inProgress)
92+
break;
93+
} while (true);
94+
}
95+
96+
private readonly Models.AIProvider _ai;
97+
}
98+
}

src/AI/ToolCallsBuilder.cs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using System;
2+
using System.Buffers;
3+
using System.Collections.Generic;
4+
using System.Diagnostics;
5+
using OpenAI.Chat;
6+
7+
namespace SourceGit.AI
8+
{
9+
public class ToolCallsBuilder
10+
{
11+
private readonly Dictionary<int, string> _indexToToolCallId = [];
12+
private readonly Dictionary<int, string> _indexToFunctionName = [];
13+
private readonly Dictionary<int, SequenceBuilder<byte>> _indexToFunctionArguments = [];
14+
15+
public void Append(StreamingChatToolCallUpdate toolCallUpdate)
16+
{
17+
if (toolCallUpdate.ToolCallId != null)
18+
{
19+
_indexToToolCallId[toolCallUpdate.Index] = toolCallUpdate.ToolCallId;
20+
}
21+
22+
if (toolCallUpdate.FunctionName != null)
23+
{
24+
_indexToFunctionName[toolCallUpdate.Index] = toolCallUpdate.FunctionName;
25+
}
26+
27+
if (toolCallUpdate.FunctionArgumentsUpdate != null && !toolCallUpdate.FunctionArgumentsUpdate.ToMemory().IsEmpty)
28+
{
29+
if (!_indexToFunctionArguments.TryGetValue(toolCallUpdate.Index, out SequenceBuilder<byte> argumentsBuilder))
30+
{
31+
argumentsBuilder = new SequenceBuilder<byte>();
32+
_indexToFunctionArguments[toolCallUpdate.Index] = argumentsBuilder;
33+
}
34+
35+
argumentsBuilder.Append(toolCallUpdate.FunctionArgumentsUpdate);
36+
}
37+
}
38+
39+
public IReadOnlyList<ChatToolCall> Build()
40+
{
41+
List<ChatToolCall> toolCalls = [];
42+
43+
foreach ((int index, string toolCallId) in _indexToToolCallId)
44+
{
45+
ReadOnlySequence<byte> sequence = _indexToFunctionArguments[index].Build();
46+
47+
ChatToolCall toolCall = ChatToolCall.CreateFunctionToolCall(
48+
id: toolCallId,
49+
functionName: _indexToFunctionName[index],
50+
functionArguments: BinaryData.FromBytes(sequence.ToArray()));
51+
52+
toolCalls.Add(toolCall);
53+
}
54+
55+
return toolCalls;
56+
}
57+
}
58+
59+
public class SequenceBuilder<T>
60+
{
61+
Segment _first;
62+
Segment _last;
63+
64+
public void Append(ReadOnlyMemory<T> data)
65+
{
66+
if (_first == null)
67+
{
68+
Debug.Assert(_last == null);
69+
_first = new Segment(data);
70+
_last = _first;
71+
}
72+
else
73+
{
74+
_last = _last!.Append(data);
75+
}
76+
}
77+
78+
public ReadOnlySequence<T> Build()
79+
{
80+
if (_first == null)
81+
{
82+
Debug.Assert(_last == null);
83+
return ReadOnlySequence<T>.Empty;
84+
}
85+
86+
if (_first == _last)
87+
{
88+
Debug.Assert(_first.Next == null);
89+
return new ReadOnlySequence<T>(_first.Memory);
90+
}
91+
92+
return new ReadOnlySequence<T>(_first, 0, _last!, _last!.Memory.Length);
93+
}
94+
95+
private sealed class Segment : ReadOnlySequenceSegment<T>
96+
{
97+
public Segment(ReadOnlyMemory<T> items) : this(items, 0)
98+
{
99+
}
100+
101+
private Segment(ReadOnlyMemory<T> items, long runningIndex)
102+
{
103+
Debug.Assert(runningIndex >= 0);
104+
Memory = items;
105+
RunningIndex = runningIndex;
106+
}
107+
108+
public Segment Append(ReadOnlyMemory<T> items)
109+
{
110+
long runningIndex;
111+
checked
112+
{ runningIndex = RunningIndex + Memory.Length; }
113+
Segment segment = new(items, runningIndex);
114+
Next = segment;
115+
return segment;
116+
}
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)