一、前言
C#之WPF+OllamaSharpe实现离线AI对话,调用Markdig格式化显示交互结果.
此程序默认你已经安装好了Ollama。
在运行前需要线安装好Ollama,如何安装请自行搜索
Ollama下载地址: https://ollama.org.cn
Ollama模型下载地址: https://ollama.org.cn/library
1.1 运行环境
基本运行环境: 根据自己使用的AI搜索对应模型基本配置,有需要使用GPU运行的模型。
运行效果
二、程序
2.1 项目结构
项目结构如下图,目前对WPF的MVVM模型只是初步初探,所有只是做了简单的模块区分。
-
Models
在此创建一些实现 ICommand 的类(这个目录应该只生命一些对象模型的,模型中只创建基本属性,但是我目前没有做区分。先简单实现功能,后面可能会优化。) -
Resources
在此存放图像资源… -
Resources
ViewModels:在此创建视图模型,视图对应的模型(前后端分离思想?),如SettingViewModel用于跟SetingView进行数据绑定(属性、命令)。 -
Views
在此创建视图->UI交互界面。
2.2 项目代码
MainWindow.xaml
<Window x:Class="MAIModel.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:MAIModel"
xmlns:viewmodels="clr-namespace:MAIModel.ViewModels"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:behaviors="clr-namespace:MAIModel.Commands"
mc:Ignorable="d"
WindowStartupLocation="CenterScreen"
Title="ChatAI" Height="600" Width="800"
Icon="/Resources/app-logo128.ico"
MinHeight="600" MinWidth="800">
<!--Bind context-->
<Window.DataContext>
<viewmodels:MainViewModel/>
</Window.DataContext>
<!--Reference style resource-->
<Window.Resources>
<ResourceDictionary>
<!--resource dictionary : add control style-->
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Views/Style/ButtonStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<!-- Add close behevior event-->
<i:Interaction.Behaviors>
<behaviors:ClosingWindowBehavior Command="{Binding ClosingWindowCommand}" />
</i:Interaction.Behaviors>
<!--Front-end display content-->
<Grid>
<!--defined column-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<!--defined row-->
<Grid.RowDefinitions>
<RowDefinition Height="10"/>
<RowDefinition Height="*"/>
<RowDefinition Height="25"/>
</Grid.RowDefinitions>
<!-- Row 1 , Column 1: set the background color-->
<Grid Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3">
<!-- Row 1 -->
<Rectangle >
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#916CE5" Offset="0.5" />
<GradientStop Color="#FFFFFF" Offset="1.5" />
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle>
</Grid>
<!-- Row 2,Column 1:
1、Set the background color of function bar(Rectangular area).
2、Set the function bar buttons : icon + text + other style
-->
<Grid Grid.Row="1" Grid.Column="0" >
<!--Row 2,Column 1: Backgroud-->
<Rectangle Grid.Row="1" Grid.Column="0">
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#9ABAFF" Offset="0.8" />
<GradientStop Color="#9ABFAF" Offset="0.3" />
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle>
<!--Row 2,Column 1: Function button setting-->
<StackPanel Margin="0 0 0 0" Grid.Row="1" Grid.Column="0">
<Button Command="{Binding SwitchToViewCommand}" CommandParameter="SettingView"
Style="{StaticResource IconButtonStyle}">
<StackPanel Orientation="Horizontal">
<Image Source="/Resources/setting64.png" Margin="5" />
<TextBlock Text="设置" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button
Command="{Binding SwitchToViewCommand}" CommandParameter="ChatMdView"
Style="{StaticResource IconButtonStyle}">
<StackPanel Orientation="Horizontal">
<Image Source="/Resources/chat64.png" Margin="5"/>
<TextBlock Text="会话" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
<Grid Grid.Row="1" Grid.Column="1" Margin="5">
<!-- Row 2,Column 2:Subview display area ,used to display switched subview.-->
<ContentControl
Content="{Binding CurrentView}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"/>
</Grid>
<!-- Row 2,Column 2:
1、Background color.
2、 Use the Lable to display current model and time.
-->
<Grid Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3">
<Rectangle>
<Rectangle.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#FAAFA9" Offset="0.1" />
<GradientStop Color="#A4D3A2" Offset="0.9" />
</LinearGradientBrush>
</Rectangle.Fill>
</Rectangle >
<WrapPanel Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3"
VerticalAlignment="Center" HorizontalAlignment="Right">
<Label Content="{Binding CurrentModel}" Width="auto" FontSize="12" Margin="5 0 5 0"/>
<Label Content="{Binding CurrentTime}" Background="#00F0BD"
Width="auto" FontSize="12" Margin="5 0 5 0"/>
</WrapPanel>
</Grid>
</Grid>
</Window>
SettingView.xaml
<UserControl x:Class="MAIModel.Views.SettingView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local ="clr-namespace:MAIModel.ViewModels"
mc:Ignorable="d" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<UserControl.Resources>
<ResourceDictionary>
<!--Resource dictionary : add the control style-->
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Style/ButtonStyle.xaml"/>
<ResourceDictionary Source="Style/TextBoxStyle.xaml"/>
<ResourceDictionary Source="Style/LabelStyle.xaml"/>
<ResourceDictionary Source="Style/ComboBoxStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid Background="#FFFFFF" HorizontalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="50"/>
<RowDefinition Height="50"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- The first line -->
<WrapPanel Grid.Row="0" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Left">
<Label Content="Ollama路径:" Margin="5" HorizontalAlignment="Left" VerticalAlignment="Center" />
<TextBox x:Name="Tbx_OllamaAppPath" FontSize="12"
Text="{Binding OllamaAppPath}"
Style="{StaticResource SearchBoxStyle}" Margin="5" />
</WrapPanel>
<!--The second line-->
<WrapPanel Grid.Row="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Left">
<Label Content="Ollama:" VerticalAlignment="Center" Margin="5" />
<Label Name="Label_State" Style="{StaticResource RoundLabelStyle}" />
<Button Content="打开" Style="{StaticResource RoundCornerButtonStyle}"
Command="{Binding StartOllamaServerCommand}"/>
</WrapPanel>
<!--The third line-->
<WrapPanel Grid.Row="2" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Left">
<Label Content="模型:" VerticalAlignment="Center" Margin="5" />
<ComboBox x:Name="Cbx_ModelList" Style="{StaticResource RoundComboBoxStyle}"
ItemsSource="{Binding ModelList}"
SelectedItem="{Binding SelectedModel}">
</ComboBox>
<Button Content="刷新" Margin="5" Grid.Row="1"
Style="{StaticResource RoundCornerButtonStyle}"
Command="{Binding ModelListUpdateCommand}"/>
</WrapPanel>
<TextBox x:Name="ModelDesciption" Grid.Row="3" IsReadOnly="True"
TextWrapping="WrapWithOverflow" Text="{Binding ModelInformation,Mode=OneWay}"/>
</Grid>
</UserControl>
------------------------------------------- SettingView -------------------------------------------
using MAIModel.ViewModels;
using System.Windows.Controls;
namespace MAIModel.Views
{
public partial class SettingView : UserControl
{
SettingViewModel _viewModel;
public SettingView(ShareOllamaObject ollama)
{
InitializeComponent();
_viewModel = new SettingViewModel(ollama);
this.DataContext = _viewModel;
}
}
}
ChatMdView.xaml
<UserControl x:Class="MAIModel.Views.ChatMdView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
xmlns:markdig="clr-namespace:Markdig.Wpf;assembly=Markdig.Wpf"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources >
<ResourceDictionary>
<!--Resource dictionary:Add control style.-->
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Style/ButtonStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid Background="#0F000F">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="200" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<!--First line: Display output text to "Markdown" container-->
<Grid Grid.Row="0">
<ScrollViewer Background="#FFFFFF" x:Name="MarkDownScrollViewer">
<!--Bind event command to the ScrollViewer-->
<i:Interaction.Triggers>
<i:EventTrigger EventName="ScrollChanged">
<i:InvokeCommandAction Command = "{Binding ScrollToEndCommand}"
CommandParameter="{Binding ElementName=MarkDownScrollViewer}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<!--scrollviewer internal container-->
<markdig:MarkdownViewer x:Name="MarkdownOutputBox" Markdown="{Binding MarkdownContent}" />
</ScrollViewer>
</Grid>
<!-- the second line -->
<Grid Grid.Row="1">
<TextBox x:Name="InputBox"
Text="{Binding InputText , Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="1" Margin="5" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto">
<!--key binding of "Enter"-->
<TextBox.InputBindings>
<KeyBinding Command="{Binding SubmitQuestionCommand}" Key="Enter"/>
</TextBox.InputBindings>
</TextBox>
</Grid>
<!-- The third line: submit button -->
<Grid Grid.Row="2">
<WrapPanel Grid.Row="2" HorizontalAlignment="Right">
<Button x:Name="BtnNewChat" Content="新建会话"
HorizontalAlignment="Right"
Style="{StaticResource RoundCornerButtonStyle}"
Command="{Binding NewSessionCommand}"
Width="100"
Height="30"/>
<Button x:Name="BtnSubmit" Content="提交"
HorizontalAlignment="Right"
Style="{StaticResource RoundCornerButtonStyle}"
Command="{Binding SubmitQuestionCommand}"
Width="100"
Height="30"/>
</WrapPanel>
</Grid>
</Grid>
</UserControl>
----------------------------------------- ChatMdView -----------------------------------------
using MAIModel.ViewModels;
using System.Windows.Controls;
namespace MAIModel.Views
{
public partial class ChatMdView : UserControl
{
ChatMdViewModel viewModel;
public ChatMdView(ShareOllamaObject shareOllama)
{
InitializeComponent();
viewModel = new ChatMdViewModel();
viewModel.SetOllama(shareOllama);
this.DataContext = viewModel;
}
}
}
ShareOllamaObject
using OllamaSharp;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text;
using System.Windows;
namespace MAIModel.ViewModels
{
/// <summary>
/// 0、Current class:
/// </summary>
public class ShareOllamaObject
{
#region Field | Property | Collection | Command
#region Field
private bool _ollamaEnabled = false; //ollama connected state
private string _ollamaAppPath; //ollama app path.
private int recordIndex = 0; //current record index.
private string _currentPath; //current record;
private Chat chat; //build interactive chat model object.
private OllamaApiClient _ollama; //OllamaAPI object.
#endregion
#region Property
public string OllamaAppPath
{
get { return _ollamaAppPath; }
set { _ollamaAppPath = value; }
}
public bool OllamaEnabled
{
get { return _ollamaEnabled; }
set { _ollamaEnabled = value; }
}
public OllamaApiClient Ollama
{
get { return _ollama; }
set { _ollama = value; }
}
public Chat Chat
{
get { return chat; }
set { chat = value; }
}
public string CurrentPath
{
get => _currentPath;
}
public int RecordIndex
{
get => recordIndex;
set
{
recordIndex = value;
_currentPath = $"{Environment.CurrentDirectory}//Data//{DateTime.Today.ToString("yyyyMMdd")}" +
$"//{DateTime.Today.ToString("yyyyMMdd")}_{recordIndex}.txt";
}
}
#endregion
#region Collection
public ObservableCollection<string> ModelList { get; set; }
#endregion
#endregion
#region Constructor
public ShareOllamaObject()
{
RecordIndex = 0;
WriteDataToFileAsync("");
Init(OllamaAppPath, "llama3.2:9b");
}
#endregion
#region other method
/// <summary>
/// initialite method
/// </summary>
private void Init(string appPath,string modelName)
{
OllamaAppPath =appPath;
try
{
// 设置默认设备为GPU
Environment.SetEnvironmentVariable("OLLAMA_DEFAULT_DEVICE", "gpu");
//判断路径是否存在
if (OllamaAppPath == string.Empty || OllamaAppPath == null) OllamaAppPath = @"ollama app.exe";
//路径存在获取应用名
if (File.Exists(OllamaAppPath)) OllamaAppPath = Path.GetFileName(OllamaAppPath);
//获取环境Ollama环境变量:用于找到 :ollama app.exe
var filePath = FindExeInPath(OllamaAppPath);
//如果路径存在,启动Ollama
if (File.Exists(filePath)) CheckStartProcess(OllamaAppPath);
//连接Ollama,并设置初始模型
_ollama = new OllamaApiClient(new Uri("http://localhost:11434"));
//获取本地可用的模型列表
ModelList = (ObservableCollection<string>)GetModelList();
var tmepModelName = ModelList.FirstOrDefault(name => name.ToLower().Contains("llama3.2"));
if (tmepModelName!=null) _ollama.SelectedModel = tmepModelName;
else if (ModelList.Count>0) _ollama.SelectedModel = ModelList[ModelList.Count-1];
if (ModelList.FirstOrDefault(name => name.Equals(modelName))!=null) _ollama.SelectedModel = modelName;
//Ollama服务启用成功
OllamaEnabled = true;
}
catch (Exception)
{
OllamaEnabled = false;
}
}
/// <summary>
/// update the model selected by Ollama
/// </summary>
public void UpdataSelectedModel(string model)
{
Ollama.SelectedModel = model;
OllamaEnabled = true;
}
/// <summary>
/// Start Ollama app and relevant server.
/// </summary>
public async void StartOllama(string appPath,string modelName)
{
Init(appPath,modelName); await Task.Delay(1);
}
/// <summary>
/// get model list
/// </summary>
public Collection<string> GetModelList()
{
var models = _ollama.ListLocalModelsAsync();
var modelList = new ObservableCollection<string>();
foreach (var model in models.Result)
{
modelList.Add(model.Name);
}
return modelList;
}
#endregion
#region starting or closeing method of Ollama(server).
/// <summary>
/// Finds whether the specified application name is configured in the system environment.
/// If it exists, return the full path, otherwise return null
/// </summary>
public static string FindExeInPath(string exeName)
{
// get environment variable "Path" value
var pathVariable = Environment.GetEnvironmentVariable("PATH");
// Split string
string[] paths = pathVariable.Split(Path.PathSeparator);
foreach (string path in paths)
{
string fullPath = Path.Combine(path, exeName);
if (File.Exists(fullPath))
{
return fullPath;
}
}
return null;
}
/// <summary>
///Startup program Specifies a program, enters a program name, and determines whether the program is running.
/// If it is running, exit directly, otherwise run the program according to the input path.
/// </summary>
public static void CheckStartProcess(string processPath)
{
string processName = Path.GetFileName(processPath);
CheckStartProcess(processName, processPath);
}
/// <summary>
/// Startup program Specifies a program, enters a program name, and determines whether the program is running.
/// If it is running, exit directly, otherwise run the program according to the input path.
/// </summary>
public static void CheckStartProcess(string processName, string processPath)
{
// Check whather the program is running
if (!IsProcessRunning(processName))
{
Console.WriteLine($"{processName} is not running. Starting the process...");
StartProcess(processPath);
}
else Console.WriteLine($"{processName} is already running.");
}
/// <summary>
/// Enter the program path to start the program
/// </summary>
public static void StartProcess(string processPath)
{
try
{
Process.Start(processPath);
Console.WriteLine("Process started successfully.");
}
catch (Exception ex)
{
Console.WriteLine($"Error starting process: {ex.Message}");
}
}
/// <summary>
/// Check whather the process is running
/// </summary>
public static bool IsProcessRunning(string processName)
{
Process[] processes = Process.GetProcessesByName(processName);
return processes.Length > 0;
}
/// <summary>
/// close the process with the specify name.
/// </summary>
/// <param name="processName"></param>
public static void CloseProcess(string processName)
{
try
{
foreach (var process in Process.GetProcessesByName(processName))
{
process.Kill();
process.WaitForExit();
Application.Current.Shutdown();
}
}
catch (Exception ex)
{
MessageBox.Show($"无法关闭【{processName}】进程: {ex.Message}");
}
}
/// <summary>
/// get current process name
/// </summary>
public static string GetProgramName()
{
Assembly assembly = Assembly.GetExecutingAssembly();
return assembly.GetName().Name;
}
#endregion
#region File save
/// <summary>
/// Save record
/// </summary>
public void WriteDataToFileAsync(string data, int retryCount = 5, int delayMilliseconds = 500)
{
//Get the directory where the file located.
string directoryPath = Path.GetDirectoryName(CurrentPath);
// if directory exists't ,create directory(include all must directory).
if (!string.IsNullOrEmpty(directoryPath) && !Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
for (int i = 0; i < retryCount; i++)
{
try
{
using (FileStream fs = new FileStream(CurrentPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
using (StreamWriter writer = new StreamWriter(fs, Encoding.UTF8))
{
writer.WriteAsync(data);
}
return; // successful writed exit the loop.
}
catch (IOException ex)
{
if (i == retryCount - 1)
{
throw; //If the maximum number of retries is reached , a exception is thrown
}
Task.Delay(delayMilliseconds); // Wait a while and try again
}
catch (Exception ex)
{
throw; //other exception is thrown
}
}
}
#endregion
}
}
MainViewModel
using MAIModel.Commands;
using MAIModel.Views;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
namespace MAIModel.ViewModels
{
/// <summary>
/// </summary>
public class MainViewModel : INotifyPropertyChanged
{
#region Field | Property | Collection | Command
#region Field
private object _currentView; //The current view object.
private string _currentTime; //The current time.
private string _currentModel; //The current model name.
private DispatcherTimer _timer; //Time label timer.
private ShareOllamaObject _ollamaObject; //OllamaAPI object.
#endregion
#region Property
public object CurrentView
{
get => _currentView;
set
{
_currentView = value;
OnPropertyChanged();
}
}
public string CurrentTime
{
get => _currentTime;
set
{
_currentTime = value;
OnPropertyChanged();
}
}
public string CurrentModel
{
get => _currentModel;
set
{
_currentModel = value;
OnPropertyChanged();
}
}
#endregion
#region Collection
private ObservableCollection<UserControl> _viewList;
private ObservableCollection<UserControl> ViewList
{
get => _viewList;
set
{
_viewList = value;
OnPropertyChanged();
}
}
#endregion
#region Command
public ICommand SwitchToViewCommand { get; }
public ICommand ClosingWindowCommand { get; }
#endregion
#endregion
#region Constructor
public MainViewModel()
{
//Initialize Ollama object.
_ollamaObject = new ShareOllamaObject();
//bind command method
SwitchToViewCommand = new ObjectPassingCommand(OnSwitchToView);
ClosingWindowCommand = new EventsCommand<CancelEventArgs>(OnClosingWindow);
//create view
_viewList = new ObservableCollection<UserControl>();
ViewList.Add(new SettingView(_ollamaObject));
ViewList.Add(new ChatMdView(_ollamaObject));
//Set the default display of subview 1.
CurrentModel = _ollamaObject.Ollama.SelectedModel;
InitializeTimer();
CurrentView = ViewList[0];
}
#region The window close event
/// <summary>
///trigger close event
/// </summary>
private void OnClosingWindow(CancelEventArgs e)
{
if (MessageBox.Show("确定要关闭程序吗?", "确认关闭", MessageBoxButton.YesNo) == MessageBoxResult.No)
e.Cancel = true;
else ClearingResources();
}
/// <summary>
/// Clear the resource.
/// </summary>
private void ClearingResources()
{
ShareOllamaObject.CloseProcess("ollama_llama_server");
Debug.Print($"{ShareOllamaObject.GetProgramName()}:关闭成功...");
}
#endregion
#endregion
#region Other mothod
//Initialize time label timer //Each one second update once
private void InitializeTimer()
{
_timer = new DispatcherTimer();
_timer.Interval = TimeSpan.FromSeconds(1);
_timer.Tick += Timer_Tick;
_timer.Start();
}
//update current time
private void Timer_Tick(object sender, EventArgs e)
{
CurrentTime = DateTime.Now.ToString("HH:mm:ss");
CurrentModel = _ollamaObject.Ollama.SelectedModel;
}
#endregion
#region Command method
#region View switch
//set the view
public void OnSwitchToView(object operationItem)
{
var viewObj = ViewList.FirstOrDefault(viewObj => viewObj.GetType().Name.Equals(operationItem));
if (viewObj == null)
{
var newViewObj =new UserControl();
switch (operationItem)
{
case "ChatMdView":
newViewObj = new ChatMdView(_ollamaObject);
break;
case "SettingView":
newViewObj = new SettingView(_ollamaObject);
break;
default:
break;
}
ViewList.Add(newViewObj);
CurrentView = newViewObj;
}
else
{
CurrentView = viewObj;
}
}
#endregion
#endregion
#region Property changed event
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
SettingViewModel
using MAIModel.Commands;
using MAIModel.Models;
using Microsoft.Win32;
using OllamaSharp;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace MAIModel.ViewModels
{
/// <summary>
/// 0、Current class:
/// </summary>
public class SettingViewModel:INotifyPropertyChanged
{
#region Field | Property | Collection | Command
#region Field
private string _selectedModel; //select model
private string _modelInfo; //model info
private SolidColorBrush _labelBackgroundColor; //color style
private readonly ShareOllamaObject _ollama; //OllamaAPI object.
#endregion
#region Property
public string OllamaAppPath
{
get { return _ollama.OllamaAppPath; }
set { _ollama.OllamaAppPath = value; OnPropertyChanged(); }
}
public string SelectedModel
{
get => _selectedModel;
set
{
if (_selectedModel != value)
{
_selectedModel = value;
ResetModelName();
}
OnPropertyChanged();
}
}
public string ModelInformation
{
get => _modelInfo;
set
{
_modelInfo = value;
OnPropertyChanged();
}
}
public SolidColorBrush LabelBackgroundColor
{
get => _labelBackgroundColor;
set
{
if (_labelBackgroundColor != value)
{
_labelBackgroundColor = value;
OnPropertyChanged();
}
}
}
#endregion
#region Collection
public ObservableCollection<string> ModelList
{
get { return _ollama.ModelList; }
set { _ollama.ModelList = value; OnPropertyChanged(); }
}
#endregion
#region Command
public ICommand OpenFileDialogCommand { get; } //select Ollama application file path command.
public ICommand GetModelListCommand { get; } //get model list command.
public ICommand ModelListUpdateCommand { get; } //model list update command.
public ICommand StartOllamaServerCommand { get; } //start ollam server command.
#endregion
#endregion
#region Constructor
public SettingViewModel(ShareOllamaObject ollama)
{
_ollama = ollama;
Task task = OnGetModelList();
OpenFileDialogCommand = new ParameterlessCommand(() => OnSelectOllamaAppPathDialog());
GetModelListCommand = new ParameterlessCommand(async () => await OnGetModelList());
ModelListUpdateCommand = new ParameterlessCommand(async () => await OnModelListUpdate());
StartOllamaServerCommand = new ParameterlessCommand(async () => OnStartOllamaServer());
SetConnected();
}
#endregion
#region other method
///set ollama model server application object.
public void SetOllamaApiClient(OllamaApiClient ollama)
{
_ollama.Ollama = ollama;
}
// set the connection states color
public void SetConnected()
{
if (_ollama.OllamaEnabled)
{
LabelBackgroundColor = Brushes.Green;
}
else
{
LabelBackgroundColor = Brushes.Red;
}
}
/// <summary>
/// reset the model
/// </summary>
private void ResetModelName()
{
_ollama.OllamaEnabled = false;
_ollama.Ollama.SelectedModel = SelectedModel;
ModelInformationChanged();
_ollama.OllamaEnabled = true;
}
/// <summary>
/// model info changed
/// </summary>
public void ModelInformationChanged()
{
string modelName = SelectedModel.Split(':')[0].ToLower();
string modelInfoPath = $"{Environment.CurrentDirectory}\\model introduction\\{modelName}.txt";
string info = string.Empty;
if (File.Exists(modelInfoPath))
{
info = File.ReadAllText(modelInfoPath);
}
//MessageBox.Show(modelInfoPath);
switch (modelName)
{
case ModelDescription.Llama32:
ModelInformation = info;
break;
case ModelDescription.CodeGemma:
ModelInformation = info;
break;
default:
ModelInformation = "";
break;
}
}
#endregion
#region command trigger method
private void OnStartOllamaServer()
{
if (!_ollama.OllamaEnabled)
{
_ollama.StartOllama(OllamaAppPath, SelectedModel);
}
}
private void OnSelectOllamaAppPathDialog()
{
OpenFileDialog openFileDialog = new OpenFileDialog();
if (openFileDialog.ShowDialog() == true)
{
OllamaAppPath = openFileDialog.FileName;
}
}
/// <summary>
/// get the model list
/// </summary>
private async Task OnGetModelList()
{
try
{
ModelList.Clear();
ModelList = (ObservableCollection<string>)_ollama.GetModelList();
Debug.Print($"ModelList count: {ModelList.Count}");
SelectedModel = _ollama.Ollama.SelectedModel;
var modelName = ModelList.FirstOrDefault(name=>name.Equals(SelectedModel));
if (ModelList.Count>0 && modelName != null)
{
SelectedModel = ModelList[ModelList.Count-1];
}
}
catch (Exception ex)
{
MessageBox.Show($"Error: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// update the model list
/// </summary>
private async Task OnModelListUpdate()
{
MessageBox.Show($"List Update");
}
#endregion
#region property changed event
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
ChatMdViewModel
using MAIModel.Commands;
using Markdig.Wpf;
using OllamaSharp;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows.Controls;
using System.Windows.Input;
namespace MAIModel.ViewModels
{
/// <summary>
/// 0、Current class:
/// </summary>
public class ChatMdViewModel : INotifyPropertyChanged
{
#region Field | Property | Collection | Command
#region Field
private string? _inputText; //User input text.
private Chat? chat; //Build interactive chat.
private ShareOllamaObject _ollama; //share Ollama object.
private CancellationTokenSource _cancellationTokenSource; //Termination chat Token
private bool _useExtensions = true; //whether enable Markdown extensions function.
private string _markdownContent; //Markdown context.
private MarkdownViewer markdownViewer; //Markdwon viewer.
private bool _isAutoScrolling = false; //whather enable scroll
private double _textWidth; // MarkdownViewer width
#endregion
#region Property : Support property changed notify.
//InputText:
public string? InputText
{
get => _inputText;
set
{
_inputText = value;
OnPropertyChanged();
}
}
public string MarkdownContent
{
get => _markdownContent;
set
{
_markdownContent = value;
// Notify property changed if needed
OnPropertyChanged();
}
}
public double TextWidth
{
get => _textWidth;
set
{
_textWidth = value;
OnPropertyChanged();
}
}
#endregion
#region Collection:
#endregion
#region Command: Builde Command: generate response command
public ICommand? SubmitQuestionCommand { get; }
//stop current chat
public ICommand? StopCurrentChatCommand { get; }
//new chat
public ICommand? NewSessionCommand { get; }
//scroll to MarkdownViewer end command
public ICommand ScrollToEndCommand { get; }
#endregion
#endregion
#region Constructor : Initialize
public ChatMdViewModel()
{
// initialize object
markdownViewer = new MarkdownViewer();
_cancellationTokenSource = new CancellationTokenSource();
//generate command
SubmitQuestionCommand = new ParameterlessCommand(async()=>OnSubmitQuestion());
StopCurrentChatCommand = new ParameterlessCommand( OnStopCurrentChat);
NewSessionCommand = new ParameterlessCommand(OnNewSessionCommand);
//markdown reletive command
ScrollToEndCommand = new ScrollViewerCommand(OnScrollToEnd);
OnLoadRecord();
}
#endregion
#region other method
#region other
//setting Ollama
public void SetOllama(ShareOllamaObject ollama)
{
_ollama = ollama;
}
//check chat state
private bool CheckChatState()
{
if (_ollama.Ollama == null || _ollama.OllamaEnabled == false)
{
MarkdownContent += "server not open...";
return false;
}
if (_ollama.Ollama.SelectedModel == null)
{
MarkdownContent += "model not select...";
return false;
}
if (string.IsNullOrWhiteSpace(InputText))
{
MarkdownContent += "text is null ...";
return false;
}
return true;
}
//trigger sroll to end
private void OnScrollToEnd(object parameter)
{
var scrollViewer = parameter as ScrollViewer;
if (scrollViewer != null && _isAutoScrolling)
{
scrollViewer.ScrollToEnd();
TextWidth = scrollViewer.Width;
}
}
#endregion
#region Mardown command binding method
//loaded history record
public void OnLoadRecord()
{
OutText(File.ReadAllText($"{Environment.CurrentDirectory}//Data//" +
$"{DateTime.Today.ToString("yyyyMMdd")}//{DateTime.Today.ToString("yyyyMMdd")}_0.txt"));
}
#endregion
#endregion
#region command method
/// <summary>
/// Submit question: Submit problem to the AI and get the output result
/// </summary>
private async void OnSubmitQuestion()
{
try
{
// Checks whether the string is empty, empty, or contains only whitespace characters
if (CheckChatState())
{
_isAutoScrolling = true; //enable auto scroll
//ToggleExtensions();
string input = InputText;
InputText =string.Empty;
string output = string.Empty;
OutText($"{Environment.NewLine}");
OutText($"## 【User】{Environment.NewLine}");
OutText($">{input}{Environment.NewLine}");
OutText($"## 【AI】{Environment.NewLine}");
//
output+=($"{Environment.NewLine}");
output += ($"## 【User】{Environment.NewLine}");
output += ($">{input}{Environment.NewLine}");
output += ($"## 【AI】{Environment.NewLine}");
if (input.Equals("/clearContext"))
{
chat = new Chat(_ollama.Ollama);
_ollama.RecordIndex++;
return;
}
#region Start answer :Mode two => chat mode
if (chat == null)
{
chat = new Chat(_ollama.Ollama);
_ollama.RecordIndex++;
}
_cancellationTokenSource = new CancellationTokenSource();
await foreach (var answerToken in chat.SendAsync(input, _cancellationTokenSource.Token))
{
OutText(answerToken);
output += (answerToken);
await Task.Delay(20);
Debug.Print(answerToken);
}
OutText($"{Environment.NewLine}");
_ollama.WriteDataToFileAsync(output);
#endregion
}
}
catch (Exception ex)
{
OutText($"Error: {ex.Message}{Environment.NewLine}");
}
_isAutoScrolling = false;
}
/// <summary>
/// New build chat.
/// </summary>
private void OnNewSessionCommand()
{
OnStopCurrentChat();
if (chat != null)
{
chat.SendAsync("/clearContext");
if (_ollama != null)
chat = new Chat(_ollama.Ollama);
}
OutText( $"{string.Empty}{Environment.NewLine}");
}
/// <summary>
/// stop chat.
/// </summary>
private void OnStopCurrentChat()
{
_cancellationTokenSource?.Cancel();
Task.Delay(100);
OutText($"{string.Empty}{Environment.NewLine}");
MarkdownContent = string.Empty;
}
/// <summary>
/// output Text to Markdown.
/// </summary>
/// <param name="text"></param>
public void OutText(string text)
{
MarkdownContent += text;
}
#endregion
#region Method that trigger a property changed event.
/// <summary>
/// OnPropertyChanged:Trigger a property changed event.
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
ModelDescription
/// <summary>
/// select switch display model description.
/// </summary>
public class ModelDescription
{
public const string CodeGemma = "codegemma";
public const string Llama32 = "llama3.2";
//model list(description)
public const string Codellama34b = "codellama:34b";
public const string Llava13b = "llava:13b";
public const string CommandRLatest = "command-r:latest";
public const string Wizardlm2Latest = "wizardlm2:latest";
public const string Qwen25CoderLatest = "qwen2.5-coder:latest";
public const string Qwen25_14b = "qwen2.5:14b";
public const string SamanthaMistralLatest = "samantha-mistral:latest";
public const string MistralSmallLatest = "mistral-small:latest";
public const string Gemma29b = "gemma2:9b";
public const string NemotronMiniLatest = "nemotron-mini:latest";
public const string Phi35Latest = "phi3.5:latest";
public const string Llama32VisionLatest = "llama3.2-vision:latest";
public const string Llama31_8b = "llama3.1:8b";
public const string Gemma22b = "gemma2:2b";
public const string Qwen27b = "qwen2:7b";
public const string Qwen20_5b = "qwen2:0.5b";
public const string Llama31_70b = "llama3.1:70b";
public const string Llama31Latest = "llama3.1:latest";
public const string Llama32Latest = "llama3.2:latest";
public const string Llama32_3b = "llama3.2:3b";
}
ClosingWindowBehavior
using System.ComponentModel;
using System.Windows.Input;
using System.Windows.Interactivity;
using System.Windows;
namespace MAIModel.Commands
{
/// <summary>
/// Close Window Behavior
/// </summary>
public class ClosingWindowBehavior : Behavior<Window>
{
public static readonly DependencyProperty CommandProperty =
DependencyProperty.Register("Command", typeof(ICommand), typeof(ClosingWindowBehavior), new PropertyMetadata(null));
public static readonly DependencyProperty CommandParameterProperty =
DependencyProperty.Register("CommandParameter", typeof(object), typeof(ClosingWindowBehavior), new PropertyMetadata(null));
public ICommand Command
{
get { return (ICommand)GetValue(CommandProperty); }
set { SetValue(CommandProperty, value); }
}
public object CommandParameter
{
get { return GetValue(CommandParameterProperty); }
set { SetValue(CommandParameterProperty, value); }
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.Closing += OnClosing;
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.Closing -= OnClosing;
}
private void OnClosing(object sender, CancelEventArgs e)
{
if (Command != null && Command.CanExecute(CommandParameter))
{
Command.Execute(e);
}
}
}
}
EventsCommand
using System;
using System.Windows.Input;
/// <summary>
/// close window events
/// </summary>
public class EventsCommand<T> : ICommand
{
private readonly Action<T> _execute;
private readonly Func<T, bool> _canExecute;
public EventsCommand(Action<T> execute, Func<T, bool> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
return _canExecute?.Invoke((T)parameter) ?? true;
}
public void Execute(object parameter)
{
_execute((T)parameter);
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}
ObjectPassingCommand
using System.Windows.Input;
namespace MAIModel.Commands
{
/// <summary>
/// object parameter passing.
/// </summary>
public class ObjectPassingCommand : ICommand
{
public Action<object> execute;
public ObjectPassingCommand(Action<object> execute)
{
this.execute = execute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return CanExecuteChanged != null;
}
public void Execute(object? parameter)
{
execute?.Invoke(parameter);
}
}
}
ParameterlessCommand
using System.Windows.Input;
namespace MAIModel.Commands
{
/// <summary>
/// relay command
/// </summary>
public class ParameterlessCommand : ICommand
{
private Action _execute;
public ParameterlessCommand(Action execute)
{
_execute = execute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return CanExecuteChanged != null;
}
public void Execute(object? parameter)
{
_execute.Invoke();
}
}
}
ScrollViewerCommand
using System.Windows.Input;
namespace MAIModel.Commands
{
/// <summary>
/// Scroll command : The argument object passed by this constructor of this class is ScrollViewer
/// </summary>
class ScrollViewerCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Predicate<object> _canExecute;
public ScrollViewerCommand(Action<object> execute, Predicate<object> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute(parameter);
}
public void Execute(object parameter)
{
_execute(parameter);
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}
}
三、总结
1、通过此项目学习了WPF。
2、通过此项目了解了基本的MVVM模式,WPF的数据绑定,属性变更,以及如何通过实现ICommand接口进行命令触发。
3、简单实现了使用C#和OllamaAPI实现AI交互界面。
4、简单调用了Markdig库 将交互数据以md格式显示。