refactor: dynamic loading and choosing AI model in assistant dialog (#2228)

Signed-off-by: leo <longshuang@msn.cn>
This commit is contained in:
leo
2026-04-01 11:28:10 +08:00
parent 7ca1c5539a
commit e684d71302
23 changed files with 163 additions and 39 deletions

View File

@@ -17,6 +17,9 @@ namespace SourceGit.AI
public async Task GenerateCommitMessageAsync(string repo, string changeList, Action<string> onUpdate, CancellationToken cancellation)
{
var chatClient = _service.GetChatClient();
if (chatClient == null)
throw new Exception("Failed to fetch available models from this service. Please check your configuration and try again.");
var options = new ChatCompletionOptions() { Tools = { ChatTools.GetDetailChangesInFile } };
var userMessageBuilder = new StringBuilder();

View File

@@ -1,22 +1,90 @@
using System;
using System.ClientModel;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Azure.AI.OpenAI;
using CommunityToolkit.Mvvm.ComponentModel;
using OpenAI;
using OpenAI.Chat;
namespace SourceGit.AI
{
public class Service
public class Service : ObservableObject
{
public string Name { get; set; } = string.Empty;
public string Server { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public bool ReadApiKeyFromEnv { get; set; } = false;
public string AdditionalPrompt { get; set; } = string.Empty;
public string Name
{
get => _name;
set => SetProperty(ref _name, value);
}
public string Server
{
get;
set;
} = string.Empty;
public string ApiKey
{
get;
set;
} = string.Empty;
public bool ReadApiKeyFromEnv
{
get;
set;
} = false;
public string AdditionalPrompt
{
get;
set;
} = string.Empty;
[JsonIgnore]
public List<string> AvailableModels
{
get;
private set;
} = [];
public string Model
{
get;
set;
} = string.Empty;
public async Task<List<string>> FetchAvailableModelsAsync()
{
var credential = new ApiKeyCredential(ReadApiKeyFromEnv ? Environment.GetEnvironmentVariable(ApiKey) : ApiKey);
var client = Server.Contains("openai.azure.com/", StringComparison.Ordinal)
? new AzureOpenAIClient(new Uri(Server), credential)
: new OpenAIClient(credential, new() { Endpoint = new Uri(Server) });
var allModels = client.GetOpenAIModelClient().GetModels();
AvailableModels = new List<string>();
foreach (var model in allModels.Value)
AvailableModels.Add(model.Id);
if (AvailableModels.Count > 0)
{
if (string.IsNullOrEmpty(Model) || !AvailableModels.Contains(Model))
Model = AvailableModels[0];
}
else
{
Model = null;
}
return AvailableModels;
}
public ChatClient GetChatClient()
{
if (string.IsNullOrEmpty(Model))
return null;
var credential = new ApiKeyCredential(ReadApiKeyFromEnv ? Environment.GetEnvironmentVariable(ApiKey) : ApiKey);
var client = Server.Contains("openai.azure.com/", StringComparison.Ordinal)
? new AzureOpenAIClient(new Uri(Server), credential)
@@ -24,5 +92,7 @@ namespace SourceGit.AI
return client.GetChatClient(Model);
}
private string _name = string.Empty;
}
}

View File

@@ -602,7 +602,7 @@ $1, $2, … Werte der Eingabe-Steuerelemente</x:String>
<x:String x:Key="Text.Preferences" xml:space="preserve">Einstellungen</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API-Schlüssel</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Modell</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Modell</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">Der eingegebene Wert ist der Name der Umgebungsvariable, aus der der API-Schlüssel gelesen wird</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Server</x:String>

View File

@@ -18,6 +18,7 @@
<x:String x:Key="Text.AddWorktree.WhatToCheckout.CreateNew" xml:space="preserve">Create New Branch</x:String>
<x:String x:Key="Text.AddWorktree.WhatToCheckout.Existing" xml:space="preserve">Existing Branch</x:String>
<x:String x:Key="Text.AIAssistant" xml:space="preserve">AI Assistant</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">MODEL</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">Use</x:String>
@@ -616,7 +617,6 @@
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.AdditionalPrompt" xml:space="preserve">Additional Prompt (Use `-` to list your requirements)</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API Key</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>

View File

@@ -616,7 +616,7 @@
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">OPEN AI</x:String>
<x:String x:Key="Text.Preferences.AI.AdditionalPrompt" xml:space="preserve">Prompt adicional (Usa `-` para listar tus requerimientos)</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">Clave API</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Modelo</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Modelo</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Nombre</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">El valor ingresado es el nombre de la clave API a cargar desde ENV</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Servidor</x:String>

View File

@@ -561,7 +561,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">Préférences</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">IA</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">Clé d'API</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Modèle</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Modèle</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Nom</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">La valeur saisie est le nom pour charger la clé API depuis l'ENV</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Serveur</x:String>

View File

@@ -535,7 +535,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">Preferensi</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API Key</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Model</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Model</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Nama</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">Nilai yang dimasukkan adalah nama untuk memuat API key dari ENV</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Server</x:String>

View File

@@ -598,7 +598,7 @@ ${pure_files:N} Come ${files:N}, ma senza cartelle</x:String>
<x:String x:Key="Text.Preferences" xml:space="preserve">Preferenze</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">Chiave API</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Modello</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Modello</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Nome</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">Il valore inserito è il nome per caricare la chiave API da ENV</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Server</x:String>

View File

@@ -604,7 +604,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">設定</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API キー</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">モデル</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">モデル</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">名前</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">この値を環境変数の名前とし、そこから API キーを読み込む</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">サーバー</x:String>

View File

@@ -537,7 +537,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">환경설정</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API 키</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">모델</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">모델</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">이름</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">입력된 값은 환경변수(ENV)에서 API 키를 불러올 이름입니다</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">서버</x:String>

View File

@@ -418,7 +418,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">Preferências</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">INTELIGÊNCIA ARTIFICIAL</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">Chave da API</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Modelo</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Modelo</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Nome</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Servidor</x:String>
<x:String x:Key="Text.Preferences.Appearance" xml:space="preserve">APARÊNCIA</x:String>

View File

@@ -616,7 +616,7 @@
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">ОТКРЫТЬ ИИ</x:String>
<x:String x:Key="Text.Preferences.AI.AdditionalPrompt" xml:space="preserve">Дополнительная подсказка (Для перечисления ваших требований используйте `-`)</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">Ключ API</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Модель</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Модель</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Имя:</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">Введённое значение — это имя для загрузки API-ключа из ENV</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Сервер</x:String>

View File

@@ -415,7 +415,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">விருப்பத்தேர்வுகள்</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">செநு</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">பநிஇ திறவுகோல்</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">மாதிரி</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">மாதிரி</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">பெயர்</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">சேவையகம்</x:String>
<x:String x:Key="Text.Preferences.Appearance" xml:space="preserve">தோற்றம்</x:String>

View File

@@ -419,7 +419,7 @@
<x:String x:Key="Text.Preferences" xml:space="preserve">Налаштування</x:String>
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">Ключ API</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">Модель</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">Модель</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">Назва</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">Сервер</x:String>
<x:String x:Key="Text.Preferences.Appearance" xml:space="preserve">ВИГЛЯД</x:String>

View File

@@ -620,7 +620,7 @@
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.AdditionalPrompt" xml:space="preserve">附加提示词 (请使用 `-` 列出您的要求)</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API密钥</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">模型</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">模型</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">配置名称</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">从环境变量填写环境变量名中读取API密钥</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">服务地址</x:String>

View File

@@ -620,7 +620,7 @@
<x:String x:Key="Text.Preferences.AI" xml:space="preserve">AI</x:String>
<x:String x:Key="Text.Preferences.AI.AdditionalPrompt" xml:space="preserve">附加提示詞 (請使用 '-' 列出您的要求)</x:String>
<x:String x:Key="Text.Preferences.AI.ApiKey" xml:space="preserve">API 金鑰</x:String>
<x:String x:Key="Text.Preferences.AI.Model" xml:space="preserve">模型</x:String>
<x:String x:Key="Text.AIAssistant.Model" xml:space="preserve">模型</x:String>
<x:String x:Key="Text.Preferences.AI.Name" xml:space="preserve">名稱</x:String>
<x:String x:Key="Text.Preferences.AI.ReadApiKeyFromEnv" xml:space="preserve">從環境變數中 (輸入環境變數名稱) 讀取 API 金鑰</x:String>
<x:String x:Key="Text.Preferences.AI.Server" xml:space="preserve">伺服器</x:String>

View File

@@ -10,6 +10,17 @@ namespace SourceGit.ViewModels
{
public class AIAssistant : ObservableObject
{
public List<string> AvailableModels
{
get => _service.AvailableModels;
}
public string CurrentModel
{
get => _service.Model;
set => _service.Model = value;
}
public bool IsGenerating
{
get => _isGenerating;

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Avalonia.Collections;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -616,6 +617,21 @@ namespace SourceGit.ViewModels
RemoveInvalidRepositoriesRecursive(RepositoryNodes);
}
public async Task UpdateAvailableAIModelsAsync()
{
foreach (var service in OpenAIServices)
{
try
{
await service.FetchAvailableModelsAsync();
}
catch
{
// Ignore errors.
}
}
}
public void Save()
{
if (_isLoading || _isReadonly)

View File

@@ -46,18 +46,33 @@
Content="{Binding Text}"/>
<!-- Options -->
<Border Grid.Row="2" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<v:LoadingIcon Width="14" Height="14"
Margin="0,0,8,0"
IsVisible="{Binding IsGenerating}"/>
<Button Classes="flat"
<Grid Grid.Row="2" Margin="8,0,8,8" ColumnDefinitions="Auto,*,18,Auto">
<TextBlock Grid.Column="0"
Classes="group_header_label"
Text="{DynamicResource Text.AIAssistant.Model}"/>
<ComboBox Grid.Column="1"
Height="28"
Padding="12,0"
Content="{DynamicResource Text.AIAssistant.Regen}"
IsEnabled="{Binding !IsGenerating}"
Click="OnRegenClicked"/>
</StackPanel>
</Border>
Margin="6,0" Padding="4,0"
BorderThickness="0"
Background="Transparent"
VerticalAlignment="Center"
ItemsSource="{Binding AvailableModels, Mode=OneWay}"
SelectedItem="{Binding CurrentModel, Mode=TwoWay}"
SelectionChanged="OnModelChanged"/>
<v:LoadingIcon Grid.Column="2"
Width="14" Height="14"
Margin="0,0,8,0"
IsVisible="{Binding IsGenerating}"/>
<Button Grid.Column="3"
Classes="flat"
Height="28"
Padding="12,0"
Content="{DynamicResource Text.AIAssistant.Regen}"
IsEnabled="{Binding !IsGenerating}"
Click="OnRegenClicked"/>
</Grid>
</Grid>
</v:ChromelessWindow>

View File

@@ -149,6 +149,13 @@ namespace SourceGit.Views
(DataContext as ViewModels.AIAssistant)?.Cancel();
}
private async void OnModelChanged(object sender, SelectionChangedEventArgs e)
{
if (DataContext is ViewModels.AIAssistant vm && IsLoaded)
await vm.GenAsync();
e.Handled = true;
}
private async void OnRegenClicked(object sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.AIAssistant vm)

View File

@@ -106,13 +106,16 @@ namespace SourceGit.Views
Activate();
}
protected override void OnOpened(EventArgs e)
protected override async void OnOpened(EventArgs e)
{
base.OnOpened(e);
var state = ViewModels.Preferences.Instance.Layout.LauncherWindowState;
var preferences = ViewModels.Preferences.Instance;
var state = preferences.Layout.LauncherWindowState;
if (state == WindowState.Maximized || state == WindowState.FullScreen)
WindowState = WindowState.Maximized;
await preferences.UpdateAvailableAIModelsAsync();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)

View File

@@ -868,9 +868,6 @@
<TextBlock Margin="0,12,0,0" Text="{DynamicResource Text.Preferences.AI.Server}"/>
<TextBox Margin="0,4,0,0" CornerRadius="3" Height="28" Text="{Binding Server, Mode=TwoWay}"/>
<TextBlock Margin="0,12,0,0" Text="{DynamicResource Text.Preferences.AI.Model}"/>
<TextBox Margin="0,4,0,0" CornerRadius="3" Height="28" Text="{Binding Model, Mode=TwoWay}"/>
<TextBlock Margin="0,12,0,0" Text="{DynamicResource Text.Preferences.AI.ApiKey}"/>
<TextBox Margin="0,4,0,0" CornerRadius="3" Height="28" Text="{Binding ApiKey, Mode=TwoWay}" PasswordChar="*" RevealPassword="{Binding ReadApiKeyFromEnv, Mode=OneWay}"/>
<CheckBox Margin="0,4,0,0"

View File

@@ -205,7 +205,9 @@ namespace SourceGit.Views
await new Commands.Config(null).SetAsync($"gpg.{GPGFormat.Value}.program", GPGExecutableFile);
}
ViewModels.Preferences.Instance.Save();
var preferences = ViewModels.Preferences.Instance;
await preferences.UpdateAvailableAIModelsAsync();
preferences.Save();
}
private async void SelectThemeOverrideFile(object _, RoutedEventArgs e)