C#实现本地Deepseek模型及其他模型的对话v1.4
前言
系 统:Window11
开发工具:Visual Studio 2022
相关技术:C# 、WPF .Net 8.0
1、C#实现本地AI聊天功能
WPF+OllamaSharpe实现本地聊天功能,可以选择使用Deepseek 及其他模型。
新增根据聊天记录回复的功能。
优化了部分ViewModel,将对应Model字段、属性移到Model中,方便后续扩展。
新增读取外部数据回复问题功能,目前支持txt文件。
新增添加图片提问题功能,模型需要支持视觉(如:minicpm-v:latest)。
优化了类结构,创建对应的Model(MainWindowModel),将所有字段、属性移到Model。
新增聊天记录窗体,修改了窗体加载时,加载聊天记录的功能。将其拆分成一个视图。
移除了折叠栏功能,更新为Grid区域的显示与隐藏。 将聊天记录列表从主窗体中分离)。
更新记录文件加载功能,显示提问日期。 新增选择文件类型设置预览图标。
新增功能,新聊天后第一次提问完成后,保存的记录刷新到记录列表、记录删除功能。
新增功能,创建新窗体判断显示Ollama服务运行状态。
2、相关依赖
OllamaSharpe:启用本地Ollama服务
Markdig.wpf : Markdown格式化输出功能。
Microsoft.Xaml.Behaviors.Wpf :解决部分不能进行命令绑定的控件实现命令绑定功能。
Newtonsoft.Json:Json数据的序列化和反序列化。
3 界面预览
①界面预览
②界面预览-聊天记录
③界面预览-图像分析
④项目结构
Commands: 用于命令绑定
ExtensionTool:目前没啥大功能…
Services :一些服务操作,如聊天数据处理、图像处理、序列化…
Models : 视图对应的模型,在类中创建一些字段、属性。
Views :视图以及一些视图控件的样式资源
ViewModels:视图模型,主要处理视图和模型的交互、以及一些业务处理
代码
Commands 目录
命名空间:OfflineAI.Commands
EventsCommand
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; }
}
}
ParameterCommand
public class ParameterCommand : ICommand
{
public Action<object> execute;
public ParameterCommand(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
public class ParameterlessCommand : ICommand
{
private Action _execute;
public ParameterlessCommand(Action execute)
{
this._execute = execute;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return CanExecuteChanged != null;
}
public void Execute(object? parameter)
{
_execute.Invoke();
}
}
RelayCommand
public class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Predicate<object> _canExecute;
public RelayCommand(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; }
}
}
ExtensionTool 目录
命名空间 OfflineAI.ExtensionTool
LimitedObservableCollection
/// <summary>
/// 限定大小集合
/// </summary>
public class LimitedObservableCollection<T> : ObservableCollection<T>
{
private readonly int _maxSize;
public LimitedObservableCollection(int maxSize)
{
_maxSize = maxSize;
CollectionChanged += OnCollectionChanged;
}
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (Count > _maxSize)
{
// 移除超出部分的元素
for (int i = Count - 1; i >= _maxSize; i--)
{
RemoveAt(i);
}
}
}
}
Models 目录
命名空间 OfflineAI.Models
PropertyChangedBase
public class PropertyChangedBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
ChatDataModel
/// 聊天数据:路径、数据对象、右键菜单命令
public class ChatDataModel: PropertyChangedBase
{
public ChatDataModel()
{
JsonModel = new ChatJsonDataModel();
}
public string? Uri { get; set; }
public ChatJsonDataModel? JsonModel { get; set; }
public ICommand MenuItemMouseDownCommand { get; set; }
}
ChatJsonDataModel
/// 聊天(Json)数据:角色、内容、图像、返回内容、时间
public class ChatJsonDataModel: PropertyChangedBase
{
[JsonProperty("role")]
public string Role { get; set; }
[JsonProperty("content")]
public string Content { get; set; }
[JsonProperty("images[]")]
public string Image { get; set; }
[JsonProperty("result")]
public string Result { get; set; }
[JsonProperty("date")]
public string Date { get; set; }
}
ExternalDataPreModel
/// 外部数据预览模型:文件名、索引、图像文件
public class ExternalDataPreModel:PropertyChangedBase
{
public string FileName { get; set; }
public ExternalDataType DataType
{
get; set;
}
private object _index;
public object Index
{
get => _index;
set
{
if (_index != value)
{
_index = value;
OnPropertyChanged();
}
}
}
private string imageSource;
public string ImageSource
{
get => imageSource;
set
{
if (imageSource != value)
{
imageSource = value;
OnPropertyChanged();
}
}
}
}
/// 外部数据类型:文本、图像、其他未知
public enum ExternalDataType
{
Text,
Image,
Unknown
}
/// 文件变更
public class FileChangedModel
{
[Description("文件名")]
public string? FileName { get; set; }
[Description("操作")]
public FileChangeType Options { get; set; }
}
/// 文件变更类型,创建,删除,修改
public enum FileChangeType
{
[Description("文件被创建")]
Created,
[Description("文件被删除")]
Deleted,
[Description("文件被修改")]
Modified
}
FileOperationModel
///文件操作:1、是否生成目录 2、目录 3、日期目录 4、文件名 5、日期文件名 6、扩展名 7、文件名格式
public class FileOperationModel : PropertyChangedBase
{
public bool IsGenerateDirectory { get; set; }
public string Directory { get; set; }
public string DirectoryDT { get; set; }
public string FileName { get; set; }
public string FileNameDT { get; set; }
public string Extension { get; set; }
public string FileNameFormat { get; set; }
}
MainWindowModel
///
public class MainWindowModel: PropertyChangedBase
{
private int _expandedBarWidth = 50; //折叠栏宽度
private string _selectedModel; //选择的模型
private Visibility _expandedMenuIsHide = Visibility.Hidden;//折叠栏是否隐藏
private OllamaService _ollamaService; // Ollama服务对象
private UserControl _currentView; // 当前显示视图
private UserControl _expandedBarView; // 折叠栏视图
private ObservableCollection<string> _modelListCollection; // 模型列表集合
/// 视图集合,保存视图
public ObservableCollection<UserControl> ViewCollection { get; set; }
/// 折叠栏宽度
public int ExpandedBarWidth
{
get => _expandedBarWidth;
set
{
if (value != _expandedBarWidth)
{
_expandedBarWidth = value;
OnPropertyChanged();
}
}
}
/// 选择的模型
public string SelectedModel
{
get => _selectedModel;
set
{
if (value != _selectedModel)
{
_selectedModel = value;
_ollamaService.SelectModel = _selectedModel;
OnPropertyChanged();
}
}
}
public Visibility ExpandedMenuIsHide {
get => _expandedMenuIsHide;
set
{
if (value != _expandedMenuIsHide)
{
_expandedMenuIsHide = value;
OnPropertyChanged();
}
}
}
/// Ollama服务对象
public OllamaService Ollama
{
get => _ollamaService;
set
{
if (_ollamaService != value)
{
_ollamaService = value;
OnPropertyChanged();
}
}
}
/// 当前显示视图
public UserControl CurrentView
{
get => _currentView;
set
{
if (_currentView != value)
{
_currentView = value;
OnPropertyChanged();
}
}
}
/// 当前折叠栏视图
public UserControl ExpandedBarView
{
get => _expandedBarView;
set
{
if (_expandedBarView != value)
{
_expandedBarView = value;
OnPropertyChanged();
}
}
}
/// 模型列表集合
public ObservableCollection<string> ModelListCollection
{
get => _modelListCollection;
set
{
if (_modelListCollection != value)
{
_modelListCollection = value;
OnPropertyChanged();
}
}
}
}
UseChatModel
/// 用户聊天
public class UserChatModel : PropertyChangedBase
{
private bool _isAutoScrolling = false; //是否自动滚动
private bool _isHintVisible = true; //提示是否可见
private string _inputText = string.Empty; //输入文本
private string _directory = string.Empty; //目录
private string _fileName = string.Empty; //文件
private string _submitButtonName = "提交"; //提交按钮名称
private OllamaService _ollama; //共享Ollama对象
private bool _isShowRunState = true; //是否显示运行状态
private bool? _runState = false; //运行状态
/// 是否自动滚动
public bool IsAutoScrolling {
get => _isAutoScrolling;
set
{
if (_isAutoScrolling != value)
{
_isAutoScrolling = value;
OnPropertyChanged();
}
}
}
/// 是否显示运行状态
public bool IsShowRunState
{
get => _isShowRunState;
set
{
if (_isShowRunState != value)
{
_isShowRunState = value;
OnPropertyChanged();
}
}
}
/// 输入文本
public string InputText {
get => _inputText;
set
{
if (_inputText != value)
{
_inputText = value;
OnPropertyChanged();
}
}
}
/// 运行状态
public bool? RunState
{
get => _runState;
set
{
if (_runState != value)
{
_runState = value;
OnPropertyChanged();
}
}
}
/// 是否显示提示
public bool IsHintVisible
{
get => _isHintVisible;
set
{
if (_isHintVisible != value)
{
_isHintVisible = value;
OnPropertyChanged();
}
}
}
/// 目录
public string Directory {
get => _directory;
set
{
if (_directory != value)
{
_directory = value;
OnPropertyChanged();
}
}
}
/// 文件名
public string FileName {
get => _fileName;
set
{
if (_fileName != value)
{
_fileName = value;
OnPropertyChanged();
}
}
}
/// 提交按钮名称
public string SubmitButtonName {
get => _submitButtonName;
set
{
if (_submitButtonName != value)
{
_submitButtonName = value;
OnPropertyChanged();
}
}
}
///Ollama对象
public OllamaService Ollama
{
get => _ollama;
set
{
if (_ollama != value)
{
_ollama = value;
RunState = _ollama.Connected;
OnPropertyChanged();
}
}
}
}
Services 目录
命名空间:Offline.Services
DataService
public class DataService
{
#region 字段、属性
/// 外部数据模型集合:当前最大10个
private LimitedObservableCollection<ExternalDataService> _externalDatas
= new LimitedObservableCollection<ExternalDataService>(10);
/// 数据索引
public int DataID { get; private set; }
/// 外部数据模型集合个数
public int ExternalDataCount => ExternalDatas.Count;
/// 外部数据模型集合
public LimitedObservableCollection<ExternalDataService> ExternalDatas { get => _externalDatas; }
/// 文件操作模型
private FileOperationModel _fileModel = new FileOperationModel();
/// 文件操作模型
public FileOperationModel FileModel
{
get => _fileModel;
private set => _fileModel = value;
}
#endregion
#region 构造函数
public DataService()
{
FileModel = new FileOperationModel();
}
public DataService(string directory)
{
FileModel = new FileOperationModel()
{
IsGenerateDirectory = true, //是否生成目录
Directory = directory, //目录
FileNameFormat = "yyyyMMddHHmmss", //生成文件名格式
Extension = "json", //拓展名
};
FileModel.FileName = $"{directory}{GenFileNameDT(FileModel.FileNameFormat,FileModel.Extension)}";
GenerateDirectoryDT();
GenerateFileNameDT(FileModel.FileNameFormat, FileModel.Extension);
}
#endregion
#region 公共方法
/// 生成日期目录路径
public void GenerateDirectoryDT()
{
//创建日期目录1
string filePath = $"{FileModel.Directory}\\{DateTime.Now.ToString("yyyy")}";
Directory.CreateDirectory($"{filePath}");
//创建日期目录2
filePath = $"{filePath}\\{DateTime.Now.ToString("yyyyMMdd")}\\";
Directory.CreateDirectory($"{filePath}");
//设置日期目录
FileModel.DirectoryDT = filePath;
}
/// 生成日期文件名
public void GenerateFileNameDT(string timeFormat, string extension)
{
FileModel.FileNameDT = $"{FileModel.DirectoryDT}" +
$"{DateTime.Now.ToString(timeFormat)}.{extension}";
}
/// 更新路径
public void UpdatePath(string filePath)
{
string fileName = Path.GetFileName(filePath);
string extension = Path.GetExtension(filePath);
string directory = Path.GetDirectoryName(filePath);
FileModel.Extension = extension;
FileModel.FileName = fileName;
FileModel.Directory = directory;
GenerateDirectoryDT();
GenerateFileNameDT(FileModel.FileNameFormat, FileModel.Extension);
}
/// 生成文件名,根据时间格式和扩展名生成文件名
public string GenFileNameDT(string timeFormat, string extension)
{
return $"{DateTime.Now.ToString(FileModel.FileNameFormat)}.{FileModel.Extension}";
}
/// 添加外部数据
public void AddExternaalData(ExternalDataService dataModel)
{
ExternalDatas.Add(dataModel);
DataID++;
}
/// 清空外部数据
public void ClearExternalData()
{
ExternalDatas.Clear();
DataID = 0;
}
#region 静态方法
/// 获取指定目录下的所有文件(*.json)
public static string[] GetFiles(string directory)
{
string[] files = Directory.GetFiles(directory, $"*.Json", SearchOption.AllDirectories);
return files;
}
/// 写入数据到文件:以追加的方式将数据写入到 JSON 文件,如果文件不存在,则创建一个新文件添加数据。
public static void AppendDataToJsonFile(ChatDataModel dataModel)
{
List<ChatJsonDataModel>? datas;
/// 如果文件存在,读取现有数据
if (File.Exists(dataModel.Uri))
{
var json = File.ReadAllText(dataModel.Uri);
datas = JsonSerializer.Deserialize<List<ChatJsonDataModel>>(json);
}
/// 如果文件不存在,创建一个空的列表
else
{
datas = new List<ChatJsonDataModel>();
}
// 添加新数据
datas?.Add(dataModel.JsonModel);
// 将更新后的数据写回文件
var options = new JsonSerializerOptions { WriteIndented = true };
var updatedJson = JsonSerializer.Serialize(datas, options);
File.WriteAllText(dataModel.Uri, updatedJson);
}
/// 读取Json文件中的数据
public static List<ChatJsonDataModel>? ReadDataFormJsonFile(string fileName)
{
List<ChatJsonDataModel>? datas = null;
if (File.Exists(fileName))
{
var json = File.ReadAllText(fileName);
datas = JsonSerializer.Deserialize<List<ChatJsonDataModel>>(json);
return datas;
}
return null;
}
/// 检查文件扩展名
public static bool FileFilter(string fileName)
{
string extension = Path.GetExtension(fileName);
if (IsFileOfType(extension, ".txt")
|| IsFileOfType(extension, ".json")
|| IsFileOfType(extension, ".cs")
|| IsFileOfType(extension, ".xaml")
|| IsFileOfType(extension, ".js")
|| IsFileOfType(extension, ".css")
|| IsFileOfType(extension, ".cpp")
|| IsFileOfType(extension, ".c")
|| IsFileOfType(extension, ".py")
|| IsFileOfType(extension, ".xml")
|| IsFileOfType(extension, ".html")
|| IsFileOfType(extension, ".xml"))
{
return true;
}
return false;
}
/// 检查文件扩展名是否与指定的扩展名匹配(不区分大小写)
public static bool IsFileOfType(string extension, string type)
{
return extension.Equals(type, StringComparison.OrdinalIgnoreCase);
}
/// 获取文件是否为图像
public static ImageFormat GetFileIsImage(string filePath)
{
return ImageFormatService.GetImageFormat(filePath);
}
/// 获取文件是否为文本类型
public static bool GetFileIsText(string filePath)
{
return FileFilter(filePath);
}
/// 获取文件类型
public static ExternalDataType GetFileType(string filePath)
{
if (GetFileIsText(filePath))
{
return ExternalDataType.Text;
}
else if (GetFileIsImage(filePath) != null)
{
return ExternalDataType.Image;
}
else
{
return ExternalDataType.Unknown;
}
}
#endregion
#endregion
}
ExternalDataService
/// 外部数据对象:
public class ExternalDataService
{
public ExternalDataService(){}
public ExternalDataService(ExternalDataPreView view){View = view;}
/// 外部数据预览视图模型
public ExternalDataPreViewModel? ViewModel { get => View?.ViewModel; }
/// 外部数据预览视图
public ExternalDataPreView? View { get; set; }
/// 外部数据预览模型
public ExternalDataPreModel? Model { get => ViewModel?.Model; }
/// 生成外部数据预览对象:1、索引、2、文件路径、3、点击事件回调
public static ExternalDataService GeneratePreObject(object index, string filePath, RoutedEventHandler clickCallback)
{
//创建外部数据预览面板
FileInfo fileInfo = new FileInfo(filePath);
ExternalDataPreView preView = new ExternalDataPreView();
preView.ViewModel.Model.Index = index;
preView.ViewModel.Model.FileName = filePath;
preView.ViewModel.Model.DataType = DataService.GetFileType(filePath);
preView.RemoveButton.Tag = preView.ViewModel.Model.Index;
preView.Height = 32;
preView.ViewModel.SetImageSource(DataService.GetFileType(filePath));
preView.Tbx_Text.Clear();
preView.Tbx_Text.AppendText($"{Path.GetFileName(filePath)}{Environment.NewLine}");
preView.Tbx_Text.AppendText($"大小{FormatFileSize(fileInfo.Length)}");
preView.RemoveButton.Click += clickCallback;
return new ExternalDataService(preView);
}
/// 文件大小格式
private static string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
int order = 0;
while (bytes >= 1024 && order < sizes.Length - 1)
{
order++;
bytes = bytes / 1024;
}
return $"{bytes:0.##} {sizes[order]}";
}
}
ImageFormatService
public class ImageFormatService
{
#region 判断图像的正确格式
/// 图像格式工具:获取正确的图像格式,通过图像文件的二进制头部图像格式标识。
public static ImageFormat GetImageFormat(string filePath)
{
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
using (BinaryReader br = new BinaryReader(fs))
{
// 读取文件的前几个字节
byte[] headerBytes = br.ReadBytes(16);
// 根据文件的前几个字节判断图像的实际格式
if (IsJpeg(headerBytes)){return ImageFormat.Jpeg;}
else if (IsPng(headerBytes)){return ImageFormat.Png;}
else if (IsGif(headerBytes)){return ImageFormat.Gif;}
else if (IsBmp(headerBytes)){return ImageFormat.Bmp;}
else{return null;}
}
}
}
/// JPEG 文件的前两个字节是 0xFF, 0xD8
private static bool IsJpeg(byte[] headerBytes)
{
return headerBytes.Length >= 2 && headerBytes[0] == 0xFF && headerBytes[1] == 0xD8;
}
/// PNG 文件的前八个字节是固定的签名:137 80 78 71 13 10 26 10
private static bool IsPng(byte[] headerBytes)
{
return headerBytes.Length >= 8 && headerBytes[0] == 137
&& headerBytes[1] == 80 && headerBytes[2] == 78
&& headerBytes[3] == 71 && headerBytes[4] == 13
&& headerBytes[5] == 10 && headerBytes[6] == 26
&& headerBytes[7] == 10;
}
/// GIF 文件的前三个字节是 "GIF"
private static bool IsGif(byte[] headerBytes)
{
return headerBytes.Length >= 3 && headerBytes[0] == 71
&& headerBytes[1] == 73 && headerBytes[2] == 70;
}
/// BMP 文件的前两个字节是 "BM"
private static bool IsBmp(byte[] headerBytes)
{
return headerBytes.Length >= 2 && headerBytes[0] == 66&& headerBytes[1] == 77;
}
#endregion
}
OllamaService
public class OllamaService
{
#region 字段|属性
#region 字段
private bool _connected = false; //连接状态
private Chat chat; //构建交互式聊天模型对象。
private OllamaApiClient _ollama; //OllamaAPI对象
private string _selectModel; //选择的模型名称
#endregion
#region 属性
/// 连接状态
public bool Connected
{
get { return _connected; }
set { _connected = value; }
}
/// 选择的模型名称
public string SelectModel {
get => _selectModel;
set
{
if(value != _selectModel)
{
_selectModel = value;
_ollama.SelectedModel = value;
chat.Model = value;
}
}
}
/// 构建交互式聊天模型对象。
public Chat Chat
{
get { return chat; }
set { chat = value; }
}
/// OllamaAPI对象
public OllamaApiClient Ollama
{
get { return _ollama; }
set { _ollama = value; }
}
/// 模型列表
public ObservableCollection<string> ModelList { get; set; }
#endregion
#endregion
#region 构造函数
public OllamaService()
{
ProcessService.ExecuteCommand("ollama list");
Initialize("llama3.2:3b");
}
#endregion
#region 其他方法
/// 初始化方法
private void Initialize( string modelName)
{
try
{
// 设置默认设备为GPU
Environment.SetEnvironmentVariable("OLLAMA_DEFAULT_DEVICE", "gpu");
//连接Ollama,并设置初始模型
Ollama = new OllamaApiClient(new Uri("http://localhost:11434"));
//获取本地可用的模型列表
ModelList = (ObservableCollection<string>)GetModelList();
//遍历查找是否包含llama3.2:3b模型
var tmepModelName = ModelList.FirstOrDefault(name => name.ToLower().Contains("llama3.2:3b"));
//设置的模型不为空
if (tmepModelName != null)
{
Ollama.SelectedModel = tmepModelName;
}
//模型列表不为空
else if (ModelList.Count > 0)
{
_ollama.SelectedModel = ModelList[ModelList.Count - 1];
}
//Ollama服务启用成功
chat = new Chat(_ollama);
SelectModel = _ollama.SelectedModel;
_connected = true;
}
catch (Exception)
{
_connected = false; //Ollama服务启用失败
}
}
/// 获取模型里列表
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;
}
/// 创建新的聊天对象
public void CreateNewChat()
{
try
{
var version = _ollama.GetVersionAsync();
if (version?.Result != null)
{
chat = new Chat(_ollama);
Connected = true;
}
else
{
chat = new Chat(_ollama);
Connected = false;
}
}
catch (Exception ex)
{
Connected = false;
}
}
#endregion
}
ProcessService
public class ProcessService
{
public static bool ExecuteCommand(string command)
{
// 创建一个新的进程启动信息
ProcessStartInfo processStartInfo = new ProcessStartInfo
{
FileName = "cmd.exe", // 设置要启动的程序为cmd.exe
Arguments = $"/C {command}", // 设置要执行的命令
UseShellExecute = true, // 使用操作系统shell启动进程
CreateNoWindow = false, //不创建窗体
};
try
{
Process process = Process.Start(processStartInfo);// 启动进程
process.WaitForExit(); // 等待进程退出
process.Close(); // 返回是否成功执行
return process.ExitCode == 0;
}
catch (Exception ex)
{
Debug.WriteLine($"发生错误: {ex.Message}");// 其他异常处理
return false;
}
}
}
ViewModels目录
命名空间:Offline.ViewModels
ChatRecordListViewModel
public class ChatRecordListViewModel: PropertyChangedBase
{
#region 字段、属性
#region 字段
/// 聊天记录集合:
private ObservableCollection<ChatDataModel> _chatRecordCollection;
/// 文件侦听
private FileSystemWatcher FileWatcher = new FileSystemWatcher();
#endregion
#region 属性
/// 聊天记录集合
public ObservableCollection<ChatDataModel> ChatRecordCollection
{
get => _chatRecordCollection;
set
{
if (_chatRecordCollection != value)
{
_chatRecordCollection = value;
OnPropertyChanged();
}
}
}
/// 事件:加载聊天记录
public event Action<string> LoadChatRecordEventHandler;
/// 聊天记录鼠标点击事件
public ICommand ChatRecordMouseDownCommand { get; set; }
#endregion
#endregion
#region 构造函数、初始化方法
public ChatRecordListViewModel() {Initialize();}
~ChatRecordListViewModel() {StopFileWatcher();}
/// 初始化
private void Initialize()
{
ChatRecordMouseDownCommand = new RelayCommand(OnChatRecordMouseDown);
LoadChatRecord();
StartFileWatcher($"{AppDomain.CurrentDomain.BaseDirectory}\\Record");
}
/// 菜单项鼠标按下:删除文件
private void OnMenuItemMouseDown(object obj)
{
if(obj is string uri)
{
File.Delete(uri);
Debug.Print($"删除记录:{uri}");
}
}
#endregion
#region 命令方法
/// 事件:聊天记录鼠标按下:
private void OnChatRecordMouseDown(object args)
{
if (args is MouseButtonEventArgs mouseDown)
{
if (mouseDown.ChangedButton == MouseButton.Left)
{
if (mouseDown.Source is TextBlock textBlock)
{
Debug.Print(textBlock.Tag.ToString());
OnLoadChatRecordCallBack(textBlock.Tag.ToString());
}
}
if (mouseDown.ChangedButton == MouseButton.Right)
{
if (mouseDown.Source is TextBlock textBlock)
{
Debug.Print(textBlock.Tag.ToString());
}
}
}
}
#endregion
#region 其他方法
/// 加载聊天记录:
private void LoadChatRecord()
{
string directory = $"{AppDomain.CurrentDomain.BaseDirectory}\\Record";
ObservableCollection<ChatDataModel> records = new ObservableCollection<ChatDataModel>();
ChatRecordCollection = new ObservableCollection<ChatDataModel>();
string[] files = DataService.GetFiles(directory);
foreach (string file in files)
{
List<ChatJsonDataModel>? datas = DataService.ReadDataFormJsonFile(file);
if (datas != null && datas[0].Content.Trim().Length > 0)
{
ChatDataModel dataModel = new ChatDataModel();
dataModel.JsonModel = datas[0]; //Json数据
dataModel.Uri = file; //加载链接
dataModel.MenuItemMouseDownCommand = new RelayCommand(OnMenuItemMouseDown);
records.Add(dataModel);
Debug.WriteLine(datas[0].Content);
}
}
//排序:按时间排序
var sortDatas = records.OrderByDescending(e => DateTime.Parse(e.JsonModel.Date)).ToList();
foreach (ChatDataModel dataModel in sortDatas)
{
ChatRecordCollection.Add(dataModel);
Debug.Print($"{dataModel.JsonModel.Date}");
}
}
/// 开始文件侦听
private void StartFileWatcher(string directory)
{
FileWatcher.Path = directory;
FileWatcher.Filter = "*.json";
FileWatcher.IncludeSubdirectories = true;
FileWatcher.Created += OnCreated; // 侦听文件创建事件
FileWatcher.Deleted += OnDeleted; // 侦听文件删除事件
FileWatcher.EnableRaisingEvents = true; // 开始侦听
Debug.Print("开始侦听目录: " + directory);
}
/// <summary>
/// 停止文件侦听
/// </summary>
private void StopFileWatcher()
{
FileWatcher.EnableRaisingEvents = false; // 停止侦听
Debug.Print("停止侦听目录: " + FileWatcher.Path);
}
#endregion
#region 事件方法
/// 触发回调:加载聊天记录:
private void OnLoadChatRecordCallBack(string args)
{
LoadChatRecordEventHandler?.Invoke(args);
}
/// 注册回调:1、LoadChatRecordEventHandler事件
public void RegisterCallBack(Action<string> action)
{
LoadChatRecordEventHandler += action;
}
/// 取消注册回调 1、LoadChatRecordEventHandler事件
public void UnRegisterCallBack(Action<string> action)
{
LoadChatRecordEventHandler -= action;
}
/// 文件创建事件处理程序:
private void OnCreated(object sender, FileSystemEventArgs e)
{
Debug.Print($"文件已创建: {e.FullPath}");
OnFileChanged(e.FullPath, FileChangeType.Created);
}
/// 事件:文件删除事件
private void OnDeleted(object sender, FileSystemEventArgs e)
{
Debug.Print($"文件已删除: {e.FullPath}");
OnFileChanged(e.FullPath, FileChangeType.Deleted);
}
/// 事件:触发文件变更事件。
private void OnFileChanged(string fileName, FileChangeType options)
{
if (options == FileChangeType.Created)
{
List<ChatJsonDataModel>? datas = DataService.ReadDataFormJsonFile(fileName);
///读取Json文件中的数据
if (datas != null && datas[0].Content.Trim().Length > 0)
{
ChatDataModel dataModel = new ChatDataModel();
dataModel.JsonModel = datas[0];
dataModel.Uri = fileName;
//使用线程异步删除
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
ChatRecordCollection.Insert(0,dataModel);
}));
Debug.WriteLine(datas[0].Content);
}
}
//执行移除操作
if (options == FileChangeType.Deleted)
{
//使用线程异步删除
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
ChatRecordCollection.Remove(ChatRecordCollection.FirstOrDefault(obj => obj.Uri.Equals(fileName)));
}));
}
}
#endregion
}
External
/// 外部数据预览视图模型: 1、 根据文件类型设置预览图标。
public class ExternalDataPreViewModel:PropertyChangedBase
{
public ExternalDataPreModel _model = new ExternalDataPreModel();
public ExternalDataPreModel Model
{
get =>_model;
set
{
if (_model == value)
{
_model = value;
OnPropertyChanged();
}
}
}
public ExternalDataPreViewModel()
{
Model.ImageSource = "/Views/Resources/text-file-blue-64.png";
}
/// 根据文件类型设置图像源
public void SetImageSource(ExternalDataType fileType)
{
switch (fileType)
{
case ExternalDataType.Text:
Model.ImageSource = "/Views/Resources/text-file-blue-64.png";
break;
case ExternalDataType.Image:
Model.ImageSource = "/Views/Resources/image-file-blue-64.png";
break;
default:
Model.ImageSource = "/Views/Resources/unknown-red-64.png";
break;
}
}
}
MainViewModel
/// <summary>
/// 主窗体视图模型:
/// 作者:吾与谁归
/// 时间:2025年02月17日(首次创建时间)
/// 功能:
/// 版本version: 1.0
/// 1、2025-02-17:添加折叠栏展开|折叠功能。
/// 2、2025-02-17:添加视图切换功能 1)系统设置 2) 聊天
/// 3、2025-02-18:添加窗体关闭时提示。
/// 4、2025-02-19:添加首页功能、修改新聊天功能。点击首页显示当前聊天,点击新聊天会创建新的会话(Chat)。
/// 5、2025-02-20:添加窗体加载时传递Ollama对象功能。
/// 6、2025-02-24:添加窗体加载时,加载聊天记录的功能。
/// 7、2025-02-28:修复创建新对话后,无法查看记录的问题。
/// 版本version: 1.1~1.4
/// 1、2025-03-01:优化了类结构,创建对应的Model(MainWindowModel),将所有字段、属性移到Model。
/// 2、2025-03-01:新增聊天记录窗体,修改了窗体加载时,加载聊天记录的功能。将其移动到了ChatRecordListView,在其视图模型中实现。
/// 2、2025-03-10:移除了折叠栏功能,更新为Grid区域的显示与隐藏
/// </summary>
public class MainViewModel : PropertyChangedBase
{
#region 字段、属性、命令
#region 字段
/// 主窗体模型对象
private MainWindowModel _mainModel = new MainWindowModel();
#endregion
#region 属性
/// 获取聊天记录视图对象
private ChatRecordListView? GetChatRecordView
{
get => MainModel.ExpandedBarView as ChatRecordListView;
}
/// 主窗体模型对象
public MainWindowModel MainModel
{
get => _mainModel;
set
{
if (_mainModel != value)
{
_mainModel = value;
OnPropertyChanged();
}
}
}
#endregion
#region 命令属性
/// 折叠功能菜单命令
public ICommand ExpandedMenuCommand { get; set; }
/// 切换视图命令
public ICommand SwitchViewCommand { get; set; }
/// 窗体关闭命令
public ICommand ClosingWindowCommand { get; set; }
/// 窗体加载命令
public ICommand LoadedWindowCommand { get; set; }
#endregion
#endregion
#region 构造函数
public MainViewModel()
{
Initialize();
}
/// 初始化方法
public void Initialize()
{
//初始化Ollama
MainModel.Ollama = new OllamaService();
MainModel.ModelListCollection = MainModel.Ollama.ModelList;
MainModel.SelectedModel = MainModel.Ollama.SelectModel;
//创建命令
SwitchViewCommand = new ParameterCommand(OnSwitchView);
LoadedWindowCommand = new EventsCommand<object>(OnLoadedWindow);
ClosingWindowCommand = new EventsCommand<object>(OnClosingWindow);
ExpandedMenuCommand = new EventsCommand<object>(OnExpandedMenu);
//初始化视图集合
MainModel.ViewCollection = new ObservableCollection<UserControl>();
//添加视图到集合
MainModel.ViewCollection.Add(new SystemSettingView());
MainModel.ViewCollection.Add(new UserChatView());
//默认显示视图
MainModel.CurrentView = MainModel.ViewCollection[1];
//设置折叠栏显示视图
MainModel.ExpandedBarView = new ChatRecordListView();
//获取聊天视图对象 //注册事件回调
GetChatRecordView.ViewModel.RegisterCallBack(GetUserControl<UserChatView>().ViewModel.LoadChatRecordCallback);
//折叠栏折叠状态
MainModel.ExpandedBarWidth = 0;
}
#endregion
#region 命令方法
/// 触发主视图窗体加载方法:窗体加载时传递Ollama对象
private void OnLoadedWindow(object obj)
{
Debug.Print(obj?.ToString());
var userView = GetUserControl<UserChatView>();
userView.ViewModel.ChatModel.Ollama = MainModel.Ollama;
}
/// 触发关闭窗体方法
private void OnClosingWindow(object obj)
{
if (obj is CancelEventArgs cancelEventArgs)
{
if (MessageBox.Show("确定要关闭程序吗?", "确认关闭", MessageBoxButton.YesNo) == MessageBoxResult.No)
{cancelEventArgs.Cancel = true; // 取消关闭}
else{ClearResources();}
}
}
/// 视图切换命令触发的方法
private void OnSwitchView(object obj)
{
Debug.WriteLine(obj.ToString());
switch (obj.ToString())
{
case "SystemSettingView":
MainModel.CurrentView = MainModel.ViewCollection[0];
break;
case "UserChatView":
MainModel.CurrentView = MainModel.ViewCollection[1];
break;
case "NewUserChatView": //新建聊天窗体
NewChat();
break;
}
}
/// 折叠菜单功能
private void OnExpandedMenu(object obj)
{
if (MainModel.ExpandedMenuIsHide == Visibility.Visible)
{
MainModel.ExpandedMenuIsHide = Visibility.Hidden;
MainModel.ExpandedBarWidth = 0;
}
else
{
MainModel.ExpandedMenuIsHide = Visibility.Visible;
MainModel.ExpandedBarWidth = 250;
}
}
#endregion
#region 其他方法
/// 获取用户控件视图:获取集合中的视图对象
public T? GetUserControl<T>() where T : UserControl
{
return MainModel.ViewCollection.FirstOrDefault(obj => obj is T) as T;
}
/// 释放资源:窗体关闭时触发
private void ClearResources(){}
/// 新建聊天窗体
private void NewChat()
{
var view = GetUserControl<UserChatView>();
UserChatView newChatView = new UserChatView();
//取消注册的回调
GetChatRecordView.ViewModel.UnRegisterCallBack(view.ViewModel.LoadChatRecordCallback);
//给当前对象注册回调GetChatRecordView.ViewModel.RegisterCallBack(newChatView.ViewModel.LoadChatRecordCallback);
//创建新的Chat对象并初始化数据
MainModel.Ollama.CreateNewChat();
newChatView.ViewModel.ChatModel.Ollama = MainModel.Ollama;
MainModel.ViewCollection[1] = null;
MainModel.ViewCollection[1] = newChatView;
MainModel.CurrentView = newChatView;
}
#endregion
}
UserChatViewModel
/// <summary>
/// 描述:用户聊天视图模型:
/// 作者:吾与谁归
/// 时间: 2025年2月19日
/// 功能:
/// 版本version: 1.0
/// 1、 2025-02-19:添加了AI聊天功能,输出问题及结果到UI,并使用Markdown相关的库做简单渲染。
/// 2、 2025-02-20:优化了构造函数,使用无参构造,方便在设计器中直接绑定数据上下文(感觉)。
/// 3、 2025-02-20:添加了滑动查看内容(自动滚动,鼠标滚动)。
/// 4、 2025-02-24:添加了聊天记录保存功能。
/// 5、 2025-02-24:添加了聊天记录加载功能,通过点击记录列表显示。
/// 版本version: 1.1
/// 1、 2025-03-01:添加了根据聊天记录回复的功能。
/// 2、 2025-03-01:添加了UserChatViewModel对应Model,将字段、属性移到Model中,方便后续扩展。
/// 3、 2025-03-05:新增读取外部数据回复问题功能,目前支持txt文件。
/// 4、 2025-03-05:新增添加图片提问题功能,模型需要支持视觉(如:minicpm-v:latest)。
/// </summary>
public class UserChatViewModel:PropertyChangedBase
{
#region 字段、属性、命令
#region 字段
private MarkdownViewer _markdownViewer; //MarkdownViewer控件
private ScrollViewer scrollViewer; //ScrollViewer滑动控件
private WrapPanel wrapPanel; //水平排序容器
private CancellationTokenSource? _cts_ChatThread; //聊天异步线程:取消标记
private CancellationTokenSource? _cts_MessageQueue; //消息异步线程:取消标记
private ConcurrentQueue<string> _messageQueue; //消息异步线程:消息队列
//自定义
private DataService _userDataService; //聊天数据服务
private UserChatModel _chatModel = new UserChatModel(); //用户聊天模型
#endregion
#region 属性
/// 聊天数据服务
public DataService UserDataService
{
get => _userDataService;
private set => _userDataService = value;
}
/// 用户聊天模型
public UserChatModel ChatModel
{
get => _chatModel;
set
{
if (_chatModel != value)
{
_chatModel = value;
OnPropertyChanged();
}
}
}
#endregion
#region 命令
/// 展开功能菜单命令
public ICommand SelecteAddFileCommand { get; set; }
/// 提交命令
public ICommand SubmitQuestionCommand { get; set; }
/// 鼠标滚动
public ICommand MouseWheelCommand { get; set; }
/// 鼠标按下
public ICommand MouseDownCommand { get; set; }
/// Markdown对象命令
public ICommand MarkdownOBJCommand { get; set; }
/// 滑动条加载
public ICommand ScrollLoadedCommand { get; set; }
/// 外部数据面板加载
public ICommand ExternalDataPanelLoadedCommand { get; set; }
#endregion
#endregion
#region 构造函数
public UserChatViewModel()
{
Initialize();
}
#endregion
#region 初始化方法
/// 初始化方法
public void Initialize()
{
//初始化命令
SelecteAddFileCommand = new ParameterCommand(OnSelecteAddFile);
MouseWheelCommand = new EventsCommand<MouseWheelEventArgs>(OnMouseWheel);
MouseDownCommand = new EventsCommand<MouseButtonEventArgs>(OnMouseDown);
MarkdownOBJCommand = new EventsCommand<object>(OnMarkdownOBJ);
SubmitQuestionCommand = new ParameterlessCommand(OnSubmitQuestion);
ScrollLoadedCommand = new EventsCommand<RoutedEventArgs>(OnScrollLoaded);
ExternalDataPanelLoadedCommand = new EventsCommand<RoutedEventArgs>(OnExternalDataPanelLoaded);
//按钮名称
ChatModel.SubmitButtonName = "提交";
//聊天记录
ChatModel.Directory = $"{AppDomain.CurrentDomain.BaseDirectory}\\Record\\";
UserDataService = new DataService($"{ChatModel.Directory}");
ChatModel.FileName = UserDataService.FileModel.FileNameDT;
}
/// 水平排列容器加载
private void OnExternalDataPanelLoaded(RoutedEventArgs args)
{
if (args.Source is WrapPanel wrapP)
{
wrapPanel = wrapP;
Debug.Print("wrapPanel loaded...");
}
}
#endregion
#region 命令方法
/// 加载文件:选择添加文件,最多添加10个文件
private void OnSelecteAddFile(object obj)
{
OpenFileDialog openFile = new OpenFileDialog();
openFile.Filter = "(*.txt;*,png;*.jpg;*.jpeg;*.bmp)|*.txt;*.png;*.jpg;*.jpeg;*.bmp|(*.png)|*.png|(*.*)|*.*";
openFile.Multiselect = true;
if (openFile.ShowDialog() == true)
{
string[] files = openFile.FileNames;
//多个文件时创建外部数据
if (files.Count() > 1)
{
wrapPanel.Children.Clear();
foreach (var file in files)
{
Debug.Print(file);
if (UserDataService.ExternalDataCount < 10)
{
ExternalDataService dataObj = ExternalDataService.GeneratePreObject(UserDataService.DataID, file, BtnRemoveControl_Click);
UserDataService.AddExternaalData(dataObj);
wrapPanel.Children.Add(dataObj.View);
}
}
}
//单个文件时创建外部数据
else
{
Debug.Print(openFile.FileName);
if (UserDataService.ExternalDataCount < 10)
{
ExternalDataService dataObj = ExternalDataService.GeneratePreObject(UserDataService.DataID, openFile.FileName, BtnRemoveControl_Click);
UserDataService.AddExternaalData(dataObj);
wrapPanel.Children.Add(dataObj.View);
}
}
}
}
/// 提交: 提交问题到AI并获取返回结果
private async void OnSubmitQuestion()
{
_ = Task.Delay(1);
string input = ChatModel.InputText;
ChatDataModel chatData = new ChatDataModel();
chatData.JsonModel.Role = "User";
chatData.JsonModel.Content = input;
chatData.JsonModel.Date = DateTime.Now.ToString();
string appendText = string.Empty;
string tempText = string.Empty;
try
{
if (!SubmintChecked(input))
{
//直接回答完成:设置输入框为空,不自动滚动,按钮名称,按钮使能
ChatModel.IsAutoScrolling = false;
OnStopCurrentChat();
ChatModel.SubmitButtonName = "提交";
return;
}
ChatModel.SubmitButtonName = "停止";
ChatModel.IsAutoScrolling = true;
AppendText($"##{Environment.NewLine}");
AppendText($"### [{chatData.JsonModel.Date}]{Environment.NewLine}");
AppendText($"# 【User】{Environment.NewLine}");
AppendText($"**{chatData.JsonModel.Content}**{Environment.NewLine}");
AppendText($"---{Environment.NewLine}");
AppendText($"{Environment.NewLine}");
scrollViewer.ScrollToEnd();
IEnumerable<string> imageBase64 = null;
int index = 1;
//加载文本|图像文件数据
foreach (var data in UserDataService.ExternalDatas)
{
//加载图像数据
if (data.Model.DataType == ExternalDataType.Image)
{
OllamaSharp.Models.Chat.Message externalMessage = new OllamaSharp.Models.Chat.Message();
imageBase64 = ConvertImageToBase64IEnumerable(data.Model.FileName);
externalMessage.Role = ChatRole.User;
externalMessage.Content = $"这是第{index++}张图像,如果用户问到,那么可能是这张!";
externalMessage.Images = imageBase64.ToArray();
ChatModel.Ollama.Chat.Messages.Add(externalMessage);
string ImageUri = " + ")";
appendText += $"{ImageUri}{Environment.NewLine}{Environment.NewLine}";
AppendText($"{ImageUri}{Environment.NewLine}{Environment.NewLine}");
}
//加载文本数据
if (data.Model.DataType == ExternalDataType.Text)
{
OllamaSharp.Models.Chat.Message externalMessage = new OllamaSharp.Models.Chat.Message();
externalMessage.Content = $"$\"文件名{{Path.GetFileName(data.FileName)}},以下是这一份文件内容:如果用户有问到文件,可能是这些内容:\\n{File.ReadAllText(data.Model.FileName)}";
externalMessage.Role = ChatRole.User;
ChatModel.Ollama.Chat.Messages.Add(externalMessage);
}
}
AppendText($"## 【AI】{Environment.NewLine}");
//异步获取AI回答
_cts_ChatThread = new CancellationTokenSource();
_cts_MessageQueue = new CancellationTokenSource();
_messageQueue = new ConcurrentQueue<string>();
await foreach (var answerToken in ChatModel.Ollama.Chat.SendAsync(input, imageBase64, _cts_ChatThread.Token))
{
if (answerToken.Equals("\n") || answerToken.Equals("\r\n"))
{
Debug.Print(answerToken);
}
appendText += answerToken;
AppendText(answerToken);
await Task.Delay(20);
if (ChatModel.IsAutoScrolling) scrollViewer.ScrollToEnd();//是否自动滚动
}
AppendText($"{Environment.NewLine}{Environment.NewLine}");
chatData.JsonModel.Result = appendText;
//创建聊天数据模型,保存记录
chatData.Uri = UserDataService.FileModel.FileNameDT;
DataService.AppendDataToJsonFile(chatData);
//提交回答完后清空外部数据
wrapPanel.Children.Clear();
UserDataService.ClearExternalData();
}
catch (Exception ex)
{
AppendText($"Error: {ex.Message}");
AppendText($"{Environment.NewLine}{Environment.NewLine}");
}
//回答完成:设置输入框为空,不自动滚动,按钮名称
ChatModel.InputText = string.Empty;
ChatModel.IsAutoScrolling = false;
ChatModel.SubmitButtonName = "提交";
}
/// 停止当前聊天
private void OnStopCurrentChat()
{
_cts_ChatThread?.CancelAsync();
_cts_MessageQueue?.CancelAsync();
AppendText($"{Environment.NewLine}");
Debug.Print("-----------取消提问------------");
Thread.Sleep(1);
}
/// 鼠标滚动上下滑动
private void OnMouseWheel(MouseWheelEventArgs e)
{
try
{
if (e.Source is FrameworkElement element && element.Parent is ScrollViewer scrollViewer)
{
double currentOffset = scrollViewer.VerticalOffset;
if (e.Delta > 0)
{
scrollViewer.ScrollToVerticalOffset(currentOffset - e.Delta);
}
else
{
scrollViewer.ScrollToVerticalOffset(currentOffset - e.Delta);
}
e.Handled = true;// 标记事件已处理,防止默认滚动行为
}
}
catch (Exception ex)
{
Debug.Print(ex.Message);
}
}
/// Markdown中鼠标按下
private void OnMouseDown(MouseButtonEventArgs args)
{
if (args.LeftButton == MouseButtonState.Pressed)
{
ChatModel.IsAutoScrolling = false;
Debug.Print("Mouse Down...");
}
}
/// 滚动栏触发
private void OnScrollLoaded(RoutedEventArgs args)
{
if (args.Source is ScrollViewer scrollView)
{
scrollViewer = scrollView;
Debug.Print("Scroll loaded...");
}
}
/// Markdown控件对象更新触发
private void OnMarkdownOBJ(object obj)
{
if (_markdownViewer != null) return;
if (obj is MarkdownViewer markdownViewer)
{
_markdownViewer = markdownViewer;
_markdownViewer.Markdown = string.Empty;
}
}
#endregion
#region 其他方法
/// 输出文本
public void AppendText(string newText)
{
Debug.Print(newText);
ChatModel.IsShowRunState = false;
_markdownViewer.Markdown += newText;
}
/// 提交校验
private bool SubmintChecked(string input)
{
if (string.IsNullOrEmpty(input)) return false;
if (input.Trim().Length<2) return false;
if (ChatModel.SubmitButtonName.Equals("停止")) return false;
return true;
}
/// 获取Markdown格式文本
public void GetMarkdownFormat(string text)
{
// 解析Markdown
var pipeline = new MarkdownPipelineBuilder().Build();
var document = Markdig. Markdown.Parse(text, pipeline);
// 遍历语法树并输出结果
StringBuilder output = new StringBuilder();
TraverseMarkdown(document, output);
// 显示结果
ChatJsonDataModel chatJsonDataModel = new ChatJsonDataModel();
chatJsonDataModel.Result = output.ToString();
}
/// 遍历语法树:输出代码块字符串
private void TraverseMarkdown(MarkdownObject markdownObject, StringBuilder output)
{
//代码块
if (markdownObject is FencedCodeBlock codeBlock)
{
output.AppendLine(codeBlock.Lines.ToString());
}
else if (markdownObject is ContainerBlock containerBlock)
{
foreach (var child in containerBlock)
{
TraverseMarkdown(child, output);
}
}
else if (markdownObject is LeafBlock leafBlock)
{
output.AppendLine(leafBlock.Lines.ToString());
}
}
/// 转换输出Markdown文本
private void ConvertMarkdownOut(string text)
{
// .UseCustomRenderers() // 使用自定义渲染器
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
// 应用样式
_markdownViewer.Pipeline = pipeline;
_markdownViewer.Markdown = text;
}
/// 移除控件:移除外部数据预览控件
private void BtnRemoveControl_Click(object sender, RoutedEventArgs e)
{
// 获取触发事件的按钮
Button removeButton = sender as Button;
if (removeButton != null)
{
removeButton.Click -= BtnRemoveControl_Click;
var dataView = UserDataService.ExternalDatas.FirstOrDefault(obj => obj.Model.Index.Equals(removeButton.Tag));
wrapPanel.Children.Remove(dataView.View);
UserDataService.ExternalDatas.Remove(dataView);
}
}
public OllamaSharp.Models.Chat.Message GenerateMessage(string content, string role = "user")
{
return new OllamaSharp.Models.Chat.Message()
{
Content = content,
Role = role
};
}
#endregion
#region 图像功能
/// 转换图像为Base64
private IEnumerable<IEnumerable<byte>> ConvertImagesToBytes(string imagePaths)
{
List<IEnumerable<byte>> imagesAsBytes = new List<IEnumerable<byte>>();
try
{
byte[] imageBytes = File.ReadAllBytes(imagePaths);
imagesAsBytes.Add(imageBytes);
}
catch (Exception ex)
{
MessageBox.Show("Failed to convert image to bytes: " + ex.Message);
}
return imagesAsBytes;
}
/// 转换图像为Base64
private string ConvertImageToBase64(string imagePath)
{
try
{
byte[] imageBytes = File.ReadAllBytes(imagePath);
return Convert.ToBase64String(imageBytes);
}
catch (Exception ex)
{
MessageBox.Show("Failed to convert image to Base64: " + ex.Message);
return null;
}
}
/// 从字节中加载图像
public void LoadImagesFromBytes(IEnumerable<IEnumerable<byte>> imagesAsBytes)
{
foreach (IEnumerable<byte> imageBytes in imagesAsBytes)
{
try
{
BitmapImage bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.StreamSource = new MemoryStream(imageBytes.ToArray());
bitmapImage.EndInit();
}
catch (Exception ex)
{
MessageBox.Show("Failed to load image: " + ex.Message);
}
}
}
/// 转换图像到base64 IEnumerable
private IEnumerable<string> ConvertImageToBase64IEnumerable(string imagePath)
{
string base64String = ConvertImageToBase64(imagePath);
return new List<string> { base64String };
}
/// 从Base 64加载图像
public void LoadImagesFromBase64(IEnumerable<string> imagesAsBase64)
{
foreach (string base64String in imagesAsBase64)
{
try
{
string base64Data = base64String.Split(',').Last();
byte[] imageBytes = Convert.FromBase64String(base64Data);
BitmapImage bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.StreamSource = new MemoryStream(imageBytes);
bitmapImage.EndInit();
}
catch (Exception ex)
{
MessageBox.Show("Failed to load image: " + ex.Message);
}
}
}
#endregion
#region 回调方法
/// 加载聊天记录回调:
public void LoadChatRecordCallback(string path)
{
Debug.Print(path);
List<ChatJsonDataModel> datas = DataService.ReadDataFormJsonFile(path);
StringBuilder appendText = new StringBuilder();
//切换聊天记录时,清空Chat对象中的消息,避免重复加载。
ChatModel.Ollama.Chat.Messages.Clear();
foreach (ChatJsonDataModel data in datas)
{
//文本追加、添加到Markdown控件中
appendText.AppendLine($"### {data.Date}{Environment.NewLine}");
appendText.AppendLine($"## 【{data.Role}】{Environment.NewLine}");
appendText.AppendLine($"#### {data.Content}{Environment.NewLine}");
AppendText($"---{Environment.NewLine}");
appendText.AppendLine($"## 【AI】{Environment.NewLine}");
appendText.AppendLine($"{data.Result}{Environment.NewLine}");
appendText.AppendLine($"{Environment.NewLine}");
//创建并添加消息到Chat中
ChatModel.Ollama.Chat.Messages.Add(GenerateMessage(data.Content));
ChatModel.Ollama.Chat.Messages.Add(GenerateMessage(data.Result, "system"));
}
//设置当前路径为该文件路径,方便提问时将内容续写到该文件中
UserDataService.FileModel.FileNameDT = path;
ConvertMarkdownOut(appendText.ToString());//显示内容
scrollViewer.ScrollToTop(); //滚动到顶部
}
#endregion
}
Views
命名空间 OfflineAI.Views
资源
样式
BorderStyle.xaml
<Style x:Key="BorderStyle" TargetType="Border">
<Setter Property="Background" Value="#FFFFFF"/>
<Setter Property="Margin" Value="5"/>
<Setter Property="Padding" Value="1"/>
<Setter Property="BorderThickness" Value="1 1 1 1"/>
<Setter Property="BorderBrush" Value="#FFFFFF"/>
</Style>
ButtonStyle.xaml
<!-- 定义圆角按钮的静态样式 -->
<Style x:Key="RoundCornerButtonStyle" TargetType="Button">
<Setter Property="Width" Value="60"/>
<Setter Property="Height" Value="20"/>
<Setter Property="Margin" Value="10"/>
<Setter Property="Padding" Value="5"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="BorderBrush" Value="DarkGray"/>
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#04D3F2" Offset="0.6" />
<GradientStop Color="#FFAB0D" Offset="2.8" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
<!--设置模板样式-->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="roundedRectangle"
CornerRadius="10"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<!-- 设置顶部圆角 -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<!-- 鼠标悬停时 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="roundedRectangle" Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#FFB3B3" Offset="0.4" />
<GradientStop Color="#D68B8B" Offset="0.7" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
<!-- 按钮被按下时 -->
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="roundedRectangle" Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#D68B8B" Offset="0.4" />
<GradientStop Color="#A05252" Offset="0.7" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 定义带图标的按钮的静态样式 -->
<Style x:Key="IconButtonStyle" TargetType="Button">
<Setter Property="Padding" Value="5"/>
<!-- 调整高度以适应图标和文本 -->
<Setter Property="Height" Value="50"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="Margin" Value="5 5 5 5"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="#FFFFFF"/>
<!--设置背景颜色-->
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<!-- 淡色 -->
<GradientStop Color="#AAAAAA" Offset="0.7" />
<GradientStop Color="#666666" Offset="0.3" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="roundedRectangle"
CornerRadius="10"
Background="#FFFFFF"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<!-- 使用 StackPanel 来布局图标和文本 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<ContentPresenter Content="{TemplateBinding Content}"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<!-- 鼠标悬停时 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="roundedRectangle" Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#4477BB" Offset="0.4" />
<GradientStop Color="#5599BB" Offset="0.7" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
<!-- 按钮被按下时 -->
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="roundedRectangle" Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#6655BB" Offset="0.4" />
<GradientStop Color="#6699BB" Offset="0.7" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
ComboBoxStyle.xaml
<Style x:Key="RoundComboBoxStyle" TargetType="{x:Type ComboBox}">
<Setter Property="Margin" Value="5"/>
<Setter Property="Width" Value="200"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ComboBox}">
<!--边缘设置-->
<Border x:Name="roundedRectangle"
CornerRadius="5"
BorderThickness="1"
BorderBrush="#AAAAFF"
Background="#EEEEEE">
<Grid>
<!--下拉箭头:开关按钮:(检验下拉菜单是否打开:IsDropDownOpen)-->
<ToggleButton IsChecked="{Binding IsDropDownOpen,Mode=TwoWay,RelativeSource={RelativeSource TemplatedParent}}">
<!--开关按钮样式-->
<ToggleButton.Style>
<Style TargetType="{x:Type ToggleButton}">
<Setter Property="Margin" Value="2"/>
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="Auto"/>
<Setter Property="MinWidth" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
<Setter Property="ClickMode" Value="Press"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="BorderThickness" Value="3"/>
<!--下拉箭头颜色-->
<Setter Property="Foreground" Value="#000000"/>
<!--下拉箭头颜色边缘线宽-->
<Setter Property="BorderBrush" Value="#00000000"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ToggleButton}">
<DockPanel LastChildFill="False"
SnapsToDevicePixels="True">
<!--面板背景颜色-->
<DockPanel.Background>
<SolidColorBrush Color="{TemplateBinding Background}">
</SolidColorBrush>
</DockPanel.Background>
<Border x:Name="Border"
CornerRadius="5"
DockPanel.Dock="Right"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Path Data="M0,0L3.5,4 7,0z"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Fill="{TemplateBinding Foreground}" />
</Border>
</DockPanel>
<!--是否校验-->
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True"/>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground"
Value="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}"/>
</Trigger>
</Style.Triggers>
</Style>
</ToggleButton.Style>
</ToggleButton>
<!--项内容-->
<ContentPresenter Margin="3"
IsHitTestVisible="False"
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"/>
<!--下拉显示面板:设置下拉面板的相对位置-->
<Popup PopupAnimation="Slide"
Focusable="False"
HorizontalOffset="-1"
Height="200"
Width="{TemplateBinding ActualWidth}"
IsOpen="{TemplateBinding IsDropDownOpen}">
<Grid SnapsToDevicePixels="True" HorizontalAlignment="Stretch">
<Border CornerRadius="5"
BorderBrush="#AAAAFF"
BorderThickness="1,1,1,1"
HorizontalAlignment="Stretch">
<!--下拉面板背景颜色-->
<Border.Background>
<SolidColorBrush Color="#EEEEEE" />
</Border.Background>
</Border>
<!--滑动条-->
<ScrollViewer SnapsToDevicePixels="True" HorizontalAlignment="Stretch" >
<StackPanel IsItemsHost="True"
HorizontalAlignment="Stretch" KeyboardNavigation.DirectionalNavigation="Contained">
</StackPanel>
</ScrollViewer>
</Grid>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<!-- 触发颜色: 鼠标悬停时 -->
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="roundedRectangle" Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#AABBCC" Offset="0.4" />
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
ChatRecordListView
<UserControl x:Class="OfflineAI.Views.ChatRecordListView"
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:behavior="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:OfflineAI.Views"
xmlns:viewModel="clr-namespace:OfflineAI.ViewModels"
xmlns:commands="clr-namespace:OfflineAI.Commands"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="300">
<UserControl.DataContext>
<viewModel:ChatRecordListViewModel x:Name="ViewModel"/>
</UserControl.DataContext>
<Grid Background="#FFFFFF">
<ScrollViewer Background="Transparent" x:Name="RecordScrollViewer"
HorizontalScrollBarVisibility="Visible"
VerticalScrollBarVisibility="Visible">
<ListBox Background="Transparent" Margin="5"
HorizontalAlignment="Stretch"
ItemsSource="{Binding ChatRecordCollection}">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Background" Value="#FAFFFF"/>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Vertical" Margin="0,0,0,0">
<!--显示消息日期-->
<TextBlock FontSize="14" Margin="0,0,0,0"
Background="#EEEEEE" Foreground="#CCCCCC"
Tag="{Binding Uri}" Text="{Binding JsonModel.Date}" >
<TextBlock.ContextMenu>
<ContextMenu Name="RightKeyMenu"
DataContext="{Binding PlacementTarget.DataContext,RelativeSource={RelativeSource Self}}">
<MenuItem Name="Delete" Header="删除"
Command="{Binding MenuItemMouseDownCommand}" CommandParameter="{Binding Uri}" />
<Separator/>
</ContextMenu>
</TextBlock.ContextMenu>
</TextBlock>
<!-- 显示消息内容 -->
<TextBlock FontSize="14" Margin="10,5,0,0"
Tag="{Binding Uri}"
Text="{Binding JsonModel.Content}" >
<behavior:Interaction.Triggers>
<!--鼠标点击命令事件-->
<behavior:EventTrigger EventName="PreviewMouseDown">
<behavior:InvokeCommandAction
Command="{Binding DataContext.ChatRecordMouseDownCommand,
RelativeSource={RelativeSource AncestorType=ListBox}}"
PassEventArgsToCommand="True">
</behavior:InvokeCommandAction>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>
</UserControl>
UserChatView
<UserControl x:Class="OfflineAI.Views.UserChatView"
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:behavior="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:OfflineAI.Views"
xmlns:markdig ="clr-namespace:Markdig.Wpf;assembly=Markdig.Wpf"
xmlns:viewmodels="clr-namespace:OfflineAI.ViewModels"
mc:Ignorable="d"
d:DesignHeight="600" d:DesignWidth="800"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<!--绑定数据上下文-->
<UserControl.DataContext>
<viewmodels:UserChatViewModel x:Name="ViewModel"/>
</UserControl.DataContext>
<!--控件资源-->
<UserControl.Resources>
<ResourceDictionary>
<!--资源字典: 添加控件样式-->
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="../Views/Styles/MarkDownViewerStyle.xaml"/>
<ResourceDictionary Source="../Views/Styles/BorderStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
<!--命令绑定事件:窗体加载时传参数Markdown控件对象。在Grid中创建,否则会出现null异常-->
<behavior:Interaction.Triggers>
<behavior:EventTrigger EventName="Loaded">
<behavior:InvokeCommandAction
Command="{Binding MarkdownOBJCommand}"
CommandParameter="{Binding ElementName=MarkdownContent}"/>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
<!--定义行-->
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="250"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!--行背景色-->
<Border Grid.Row="0" Background="#FFFFFF"/>
<Border Grid.Row="1" Background="#EEEEEE"/>
<Border Grid.Row="2" Background="#EEEEEE" BorderThickness="0" BorderBrush="Transparent"/>
<Grid Grid.Row="0" Height="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<!--markdown 滑动条-->
<ScrollViewer Background="#FFFFFF"
x:Name="MarkDownScrollViewer">
<!--加载事件命令-->
<behavior:Interaction.Triggers>
<behavior:EventTrigger EventName="Loaded">
<behavior:InvokeCommandAction
Command="{Binding ScrollLoadedCommand}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
<!--markdown 控件-->
<markdig:MarkdownViewer
Name="MarkdownContent">
<markdig:MarkdownViewer.Resources>
<!-- 应用自定义样式 -->
<Style TargetType="Paragraph" BasedOn="{StaticResource Heading1Style}" />
<Style TargetType="Hyperlink" BasedOn="{StaticResource LinkStyle}" />
</markdig:MarkdownViewer.Resources>
<!--命令绑定事件:鼠标滚动显示内容-->
<behavior:Interaction.Triggers>
<!--鼠标滚动命令事件-->
<behavior:EventTrigger EventName="PreviewMouseWheel">
<behavior:InvokeCommandAction
Command="{Binding MouseWheelCommand}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
<!--鼠标点击命令事件-->
<behavior:EventTrigger EventName="PreviewMouseDown">
<behavior:InvokeCommandAction
Command="{Binding MouseDownCommand}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
</markdig:MarkdownViewer>
</ScrollViewer>
<!-- Ollama 运行状态显示 -->
<TextBlock VerticalAlignment="Center"
HorizontalAlignment="Center"
FontSize="18"
IsHitTestVisible="False">
<TextBlock.Style>
<Style TargetType="TextBlock">
<!-- 默认显示运行状态 -->
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<!-- 如果 ChatModel.显示状态为True,显示运行状态 -->
<DataTrigger Binding="{Binding ChatModel.IsShowRunState}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding ChatModel.IsShowRunState}" Value="False">
<Setter Property="Visibility" Value="Hidden" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
<WrapPanel>
<Image Width="24" Height="24" Margin="0,0,5,0"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Image.Style>
<Style TargetType="Image">
<Style.Triggers>
<!-- 如果 RunState 为 true,显示绿色图像 -->
<DataTrigger Binding="{Binding ChatModel.RunState}" Value="False">
<Setter Property="Source" Value="../Views/Resources/ollama-run-state-red-32.png" />
</DataTrigger>
<DataTrigger Binding="{Binding ChatModel.RunState}" Value="True">
<Setter Property="Source" Value="../Views/Resources/ollama-run-state-green-32.png" />
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<TextBox VerticalAlignment="Center" HorizontalAlignment="Center"
Background="Transparent" BorderThickness="0">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<!-- 如果 RunState 为 true,显示绿色图像 -->
<DataTrigger Binding="{Binding ChatModel.RunState}" Value="False">
<Setter Property="Text" Value="Ollama停止运行" />
</DataTrigger>
<DataTrigger Binding="{Binding ChatModel.RunState}" Value="True">
<Setter Property="Text" Value="Ollama正在运行" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
</WrapPanel>
</TextBlock>
</Grid>
<!--第2行内容:显示回话内容-->
<Grid Grid.Row="1" Margin="2" Background="#FFFFFF">
<!--定义三行-->
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="40"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="3*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<!--第1行Border样式:-->
<Border Grid.Row="0" Grid.Column="1" BorderThickness="0" Style="{StaticResource BorderStyle}"/>
<!--第2行Border样式:-->
<Border Grid.Row="1" Grid.Column="1" Style="{StaticResource BorderStyle}"/>
<!--第3行Border样式:-->
<Border Grid.Row="2" Grid.Column="1"
Background="#EEEEEE"
BorderThickness="1 0 1 1"
BorderBrush ="#000000" />
<!--第1行内容区域:外部数据预览面板-->
<Grid Grid.Row="0" Grid.Column="1" Background="#FFFFFF">
<WrapPanel Name="FileShowArea" Margin="0,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<behavior:Interaction.Triggers>
<behavior:EventTrigger EventName="Loaded">
<behavior:InvokeCommandAction
Command="{Binding ExternalDataPanelLoadedCommand}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
</WrapPanel>
</Grid>
<!--第2行内容区域:文本输入框-->
<Grid Grid.Row="1" Grid.Column="1">
<TextBox Padding="5"
FontSize="14"
BorderThickness="1,1,1,0"
BorderBrush ="#000000"
AcceptsReturn="True"
Background="#EEEEEE"
Foreground="#000000"
TextWrapping="WrapWithOverflow"
Text="{Binding ChatModel.InputText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
VerticalScrollBarVisibility="Auto">
<!--回车发送-->
<TextBox.InputBindings>
<KeyBinding Command="{Binding SubmitQuestionCommand}" Key="Enter"/>
</TextBox.InputBindings>
</TextBox>
<!-- 提示文本 -->
<TextBlock Text="给AI发送消息"
Foreground="#555555"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="10,5,0,0"
FontSize="14"
IsHitTestVisible="False">
<TextBlock.Style>
<Style TargetType="TextBlock">
<!-- 默认隐藏提示文本 -->
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<!-- 如果 ChatModel.InputText 为空,显示提示文本 -->
<DataTrigger Binding="{Binding ChatModel.InputText}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
<DataTrigger Binding="{Binding ChatModel.InputText}" Value="{x:Null}">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<!--第3行内容区域:文件选择,提交按钮-->
<Grid Grid.Row="2" Grid.Column="1">
<WrapPanel Margin="0,0,5,0"
HorizontalAlignment="Right"
VerticalAlignment="Center">
<!--选择添加文件按钮-->
<Button Width="50" Command="{Binding SelecteAddFileCommand}" BorderThickness="0" Background="Transparent">
<Image Width="24" Height="24" x:Name="FileIco"
Source="/Views/Resources/append-black-24.png"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
<!-- 定义ToolTip -->
<Button.ToolTip>
<ToolTip Content="添加文件" Placement="Top" HorizontalOffset="0" VerticalOffset="0">
<ToolTip.Triggers>
<!-- 设置ToolTip显示延迟1秒 -->
<EventTrigger RoutedEvent="ToolTip.Opened">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:1"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</ToolTip.Triggers>
</ToolTip>
</Button.ToolTip>
<!-- 设置ToolTip的显示和隐藏 -->
<Button.Style>
<Style TargetType="Button">
<Setter Property="ToolTipService.ShowDuration" Value="10000"/>
<!-- ToolTip显示持续时间 -->
<Setter Property="ToolTipService.InitialShowDelay" Value="1000"/>
<!-- 延迟1秒显示 -->
<Setter Property="ToolTipService.BetweenShowDelay" Value="0"/>
<!-- 防止快速显示 -->
</Style>
</Button.Style>
</Button>
<!--问题提交按钮-->
<Button Width="50" Margin="5 0 0 0" BorderThickness="0" Background="Transparent"
Command="{Binding SubmitQuestionCommand}"
Content="{Binding ChatModel.SubmitButtonName}">
</Button>
</WrapPanel>
</Grid>
</Grid>
<!--第3行内容:-->
<Grid Grid.Row="2">
</Grid>
</Grid>
</UserControl>
SystemSettingView
<UserControl x:Class="OfflineAI.Views.SystemSettingView"
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:local="clr-namespace:OfflineAI.Views"
xmlns:viewModels="clr-namespace:OfflineAI.ViewModels"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid>
<StackPanel Background="#FFFFFF" Margin="5">
<TextBox FontSize="36" IsReadOnly="True"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center">系统设置</TextBox>
</Grid>
</UserControl>
UserViews
命名空间:OfflineAI.Views.UserViews
<UserControl x:Class="OfflineAI.Views.UserViews.ExternalDataPreView"
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:local="clr-namespace:OfflineAI.Views.UserViews"
xmlns:viewmodels="clr-namespace:OfflineAI.ViewModels"
mc:Ignorable="d"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<!--绑定数据上下文-->
<UserControl.DataContext>
<viewmodels:ExternalDataPreViewModel x:Name="ViewModel"/>
</UserControl.DataContext>
<Grid Background="Transparent" >
<!--外部数据预览视图:1、显示加载的外部数据文件名2、隐藏显示文件路径3、点击按钮可以删除 -->
<Border Background="Transparent"/>
<WrapPanel HorizontalAlignment="Left" VerticalAlignment="Center" Margin="1" Background="#AAAABB">
<Image x:Name="ImgFileIco" Width="20" Height="20"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Source="{Binding Model.ImageSource}" >
</Image>
<TextBox x:Name="Tbx_Text"
Text="External Data"
IsReadOnly="True"
Background="#AAAABB"
HorizontalContentAlignment="Left"
VerticalContentAlignment="Center"
TextWrapping="Wrap"/>
<Button x:Name="RemoveButton"
Tag="{Binding Model.Index}"
Background="#AAAABB"
VerticalAlignment="Center"
HorizontalAlignment="Center"
VerticalContentAlignment="Center"
Content=" × ">
</Button>
</WrapPanel>
</Grid>
</UserControl>
MainWindow
命名空间:OfflineAI
<Window x:Class="OfflineAI.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:behavior="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:OfflineAI"
xmlns:viewmodels="clr-namespace:OfflineAI.ViewModels"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d"
Title="ChatAI" Height="800" Width="1000"
Icon="/Views/Resources/app-logo-128.ico"
MinHeight="600" MinWidth="800">
<!--绑定上下文-->
<Window.DataContext>
<viewmodels:MainViewModel/>
</Window.DataContext>
<!--样式资源-->
<Window.Resources>
<ResourceDictionary>
<!--资源字典: 添加控件样式-->
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Views/Styles/ButtonStyle.xaml"/>
<ResourceDictionary Source="Views/Styles/ComboBoxStyle.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<!--事件命令绑定-->
<behavior:Interaction.Triggers>
<!--窗体加载命令绑定-->
<behavior:EventTrigger EventName="Loaded">
<behavior:InvokeCommandAction Command="{Binding LoadedWindowCommand}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
<!--窗体关闭命令绑定-->
<behavior:EventTrigger EventName="Closing">
<behavior:InvokeCommandAction Command="{Binding ClosingWindowCommand}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
<Grid >
<!-- 定义2行 -->
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="20"/>
</Grid.RowDefinitions>
<!-- 定义3列:-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Visibility="{Binding MainModel.ExpandedMenuIsHide}">
<!--折叠栏内添加的视图-->
<ContentControl Margin="5,5,5,5" Width="{Binding MainModel.ExpandedBarWidth}"
Content="{Binding MainModel.ExpandedBarView}"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"/>
</Grid>
<!-- 右侧内容区域 -->
<Border Background="LightGray" Grid.Row="0" Grid.Column="1" Padding="10"/>
<!--主要区域-->
<Grid Grid.Row="0" Grid.Column="1" Margin="3">
<!--定义三行-->
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="*"/>
<RowDefinition Height="350"/>
</Grid.RowDefinitions>
<!--设置背景色-->
<Border Grid.Row="0" Background="#EEEEEE"/>
<Border Grid.Row="1" Background="#FFFFFF" Grid.RowSpan="2"/>
<!--第一行内容:左对齐内容-->
<WrapPanel VerticalAlignment="Center">
<Button Background="Transparent" BorderThickness="0"
Command="{Binding ExpandedMenuCommand}"
CommandParameter="expanded">
<StackPanel>
<Image Source="Views/Resources/expended-bar-black-64.png"
Margin="5" Width="32" Height="32"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!--视图切换:首页-->
<Button x:Name="Btn_HomePage" Width="40" Height="36" FontSize="16"
Style="{StaticResource IconButtonStyle}"
Command="{Binding SwitchViewCommand}"
CommandParameter="UserChatView">
<StackPanel Orientation="Horizontal">
<Image Source="Views/Resources/home-black-24.png"
Margin="5" Width="24" Height="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!--视图切换:新聊天界面-->
<Button x:Name="Btn_Chat" Width="100" Height="36" FontSize="16"
Style="{StaticResource IconButtonStyle}"
Command="{Binding SwitchViewCommand}"
CommandParameter="NewUserChatView">
<StackPanel Orientation="Horizontal">
<Image Source="Views/Resources/edit-black-24.png"
Margin="5" Width="24" Height="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Text="新聊天" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!--模型列表-->
<Label Foreground="#000000" Margin="5" FontSize="16" VerticalAlignment="Center" >
<TextBlock Foreground="#333333" Text="模型:" VerticalAlignment="Center"/>
</Label>
<ComboBox x:Name="Cbx_ModelList"
Style="{StaticResource RoundComboBoxStyle}"
ItemsSource="{Binding MainModel.ModelListCollection}"
SelectedItem="{Binding MainModel.SelectedModel}">
</ComboBox>
</WrapPanel>
<!--第一行内容:右对齐内容-->
<WrapPanel Margin="0,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" >
<Button Width="36" Height="36"
Style="{StaticResource IconButtonStyle}"
Command="{Binding SwitchViewCommand}"
CommandParameter="SystemSettingView">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Image Source="/Views/Resources/setting-64.png"
Width="24" Height="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</WrapPanel>
<!--第二行内容:显示当前视图-->
<ContentControl Grid.Row="1" Margin="5,5,5,5"
Content="{Binding MainModel.CurrentView}"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" Grid.RowSpan="2"/>
</Grid>
</Grid>
</Window>
代码完结
附上项目地址:https://github.com/timenodes/OfflineAI