前言
1、C#实现本地AI聊天功能
WPF+OllamaSharpe实现本地聊天功能,可以选择使用Deepseek 及其他模型。
2、此程序默认你已经安装好了Ollama。
在运行前需要线安装好Ollama,如何安装请自行搜索
Ollama下载地址: https://ollama.org.cn
Ollama模型下载地址: https://ollama.org.cn/library
基本运行环境: 根据自己使用的AI搜索对应模型基本配置,有需要使用GPU运行的模型。
此程序除了安装Ollama外,无需安装其他配置。
.
3、相关依赖
OllamaSharpe:启用本地Ollama服务
Markdig.wpf : Markdown格式化输出功能。
Microsoft.Xaml.Behaviors.Wpf :解决部分不能进行命令绑定的控件实现命令绑定功能。
运行
项目
项目结构
项目结构包含如下目录:
.
Commands: 用于命令绑定
Models : 视图对应的模型
Services :一些操作服务
ViewModels:视图模型,主要的业务处理
Views :视图以及一些视图控件的样式资源
具体如下图:
项目代码
Commands
EventsCommand
using System.Windows.Input;
/// <summary>
/// 事件命令:
/// 有些控件的无法绑定命令,但是想要实现命令绑定功能,可通过创建该命令实现。
/// 需要引用Microsoft.Xaml.Behaviors.Wpf组合实现。
/// </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; }
}
}
ParameterCommand
using System.Windows.Input;
namespace OfflineAI.Commands
{
/// <summary>
/// 参数命令:
/// 可以带参数的命令:
/// </summary>
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
using System.Windows.Input;
namespace OfflineAI.Commands
{
/// <summary>
/// 无参数命令:
/// 无参数的命令:
/// </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();
}
}
}
Models
ChatRecordModel
namespace OfflineAI.Models
{
/// <summary>
/// 聊天记录模型
/// </summary>
public class ChatRecordModel
{
public ChatRecordModel(int id, string dateTime, string name,string fullName, string data)
{
Id = id;
DateTime = dateTime;
Name = name;
FullName = fullName;
Data = data;
}
/// <summary>
/// ID
/// </summary>
public int Id { get; set; }
/// <summary>
/// 日期
/// </summary>
public string DateTime { get; set; }
/// <summary>
/// 名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 完整名称
/// </summary>
public string FullName { get; set; }
/// <summary>
/// 数据
/// </summary>
public string Data { get; set; }
}
}
FileOperationModel
namespace OfflineAI.Models
{
public class FileOperationModel
{
/// <summary>
/// 是否生成目录
/// </summary>
public bool IsGenerateDirectory { get; set; }
/// <summary>
/// 文件目录
/// </summary>
public string Directory { get; set; }
/// <summary>
/// 日期目录(生成的目录)
/// </summary>
public string DirectoryDateTime { get; set; }
/// <summary>
/// 文件名称(全路径)
/// </summary>
public string FileName { get; set; }
/// <summary>
/// 文件名称(生成文件全路径)
/// </summary>
public string FileNameDateTime { get; set; }
}
}
Services
FileOperation
using OfflineAI.Models;
using System.IO;
namespace OfflineAI.Services
{
/// <summary>
/// 文件操作类:
/// 1、2025-02-24:添加创建日期目录方法。输入文件名,添加时间目录。
/// 2、2025-02-24:添加写入数据到文件方法(.txt格式)
/// </summary>
public class FileOperation
{
private FileOperationModel _fileOperation;
#region 构造函数
public FileOperation(string fileName)
{
_fileOperation = new FileOperationModel();
_fileOperation.IsGenerateDirectory = true;
UpdataFileName(fileName);
}
#endregion
#region 公共方法
/// <summary>
/// 更新文件名
/// </summary>
public void UpdataFileName(string fileName)
{
if (Path.GetExtension(fileName).ToLower().Equals("txt"))
_fileOperation.FileName = fileName;
else
_fileOperation.FileName = fileName + ".txt";
_fileOperation.Directory = Path.GetDirectoryName(fileName);
CreateDateTime();
_fileOperation.FileNameDateTime = $"{_fileOperation.DirectoryDateTime}\\{Path.GetFileName(_fileOperation.FileName)}";
}
/// <summary>
/// 写入文本
/// </summary>
public void WriteTxt(string data)
{
SaveDataAsTxt(data);
}
/// <summary>
/// 写入文本,指定文件名
/// </summary>
public void WriteTxt(string fileName, string data)
{
UpdataFileName(fileName);
SaveDataAsTxt(data);
}
public string ReadTxt(string fileName)
{
// 使用 using 语句确保资源被正确释放
using (FileStream fs = new FileStream(fileName, FileMode.Open, FileAccess.Read))
using (StreamReader sr = new StreamReader(fs))
{
return sr.ReadToEnd();
}
}
/// <summary>
/// 获取指定目录下的所有文件(*.txt)
/// </summary>
public string[] GetFiles()
{
string[] files = Directory.GetFiles(_fileOperation.Directory, "*.txt", SearchOption.AllDirectories);
return files;
}
/// <summary>
/// 获取指定目录下的所有文件(*.txt)
/// </summary>
public static string[] GetFiles(string directory)
{
string[] files = Directory.GetFiles(directory, "*.txt", SearchOption.AllDirectories);
return files;
}
#endregion
#region 私有方法
/// <summary>
/// 保存数据为Txt类型的文本
/// </summary>
private void SaveDataAsTxt(string data)
{
if (_fileOperation.IsGenerateDirectory)
{
try
{
string fileName = _fileOperation.FileName;
if (_fileOperation.IsGenerateDirectory)
{
fileName = _fileOperation.FileNameDateTime;
}
using (FileStream fileStream = new FileStream(fileName, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
{
using (StreamWriter writer = new StreamWriter(fileStream))
{
writer.Write(data);
}
}
Console.WriteLine("数据已成功写入文件。");
}
catch (Exception ex)
{
Console.WriteLine("写入文件时发生错误: " + ex.Message);
}
}
}
/// <summary>
/// 创建日期目录
/// </summary>
private void CreateDateTime()
{
if (_fileOperation.IsGenerateDirectory)
{
string path = $"{_fileOperation.Directory}\\{DateTime.Now.ToString("yyyy")}";
Directory.CreateDirectory($"{path}");
path = $"{path}\\{DateTime.Now.ToString("yyyyMMdd")}\\";
Directory.CreateDirectory($"{path}");
_fileOperation.DirectoryDateTime = path;
}
}
#endregion
}
}
ProcessService
using System.ComponentModel;
using System.Diagnostics;
namespace OfflineAI.Services
{
public class ProcessService
{
/// <summary>
/// 执行CMD指令
/// </summary>
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;
}
}
}
}
ShareOllamaObject
using OfflineAI.Services;
using OllamaSharp;
using System.Collections.ObjectModel;
namespace OfflineAI.Sevices
{
/// <summary>
/// 共享Ollama对象类:保持Ollama对象一致才能使用当前对象实现对话
/// 作 者:吾与谁归
/// 时 间:2025年02月18日
/// 功 能:
/// 1) 2025-02-18:使用cmd命令启动Ollama服务,目前使用ollama list();
/// 2) 2025-02-18:初始化模型参数,在初始化时启用GPU、连接ollama、初始化模型。
/// </summary>
public class ShareOllamaObject
{
#region 字段|属性|集合
#region 字段
private bool _connected = false; //连接状态
private Chat chat; //构建交互式聊天模型对象。
private OllamaApiClient _ollama; //OllamaAPI对象
private string _selectModel; //选择的模型名称
#endregion
#region 属性
/// <summary>
/// 连接状态
/// </summary>
public bool Connected
{
get { return _connected; }
set { _connected = value; }
}
public string SelectModel { get => _selectModel; set => _selectModel = value; }
/// <summary>
/// 构建交互式聊天模型对象。
/// </summary>
public Chat Chat
{
get { return chat; }
set { chat = value; }
}
/// <summary>
/// OllamaAPI对象
/// </summary>
public OllamaApiClient Ollama
{
get { return _ollama; }
set { _ollama = value; }
}
#endregion
#region 集合
/// <summary>
/// 模型列表
/// </summary>
public ObservableCollection<string> ModelList { get; set; }
#endregion
#endregion
#region 构造函数
public ShareOllamaObject()
{
ProcessService.ExecuteCommand("ollama list");
Initialize("llama3.2:3b");
ProcessService.GetProcessId("ollama");
}
#endregion
#region 其他方法
/// <summary>
/// 初始化方法
/// </summary>
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服务启用成功
SelectModel = _ollama.SelectedModel;
_connected = true;
chat = new Chat(_ollama);
}
catch (Exception)
{
_connected = false; //Ollama服务启用失败
}
}
/// <summary>
/// 获取模型里列表
/// </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;
}
public void ReCreateChat()
{
chat = new Chat(_ollama);
}
#endregion
}
}
ViewModels
MainViewModel
using OfflineAI.Sevices;
using OfflineAI.Commands;
using OfflineAI.Views;
using System.Windows;
using System.Diagnostics;
using System.Windows.Input;
using System.ComponentModel;
using System.Windows.Controls;
using System.Collections.ObjectModel;
using System.IO;
using OfflineAI.Services;
using OfflineAI.Models;
namespace OfflineAI.ViewModels
{
/// <summary>
/// 主窗体视图模型:
/// 作者:吾与谁归
/// 时间:2025年02月17日(首次创建时间)
/// 更新:
/// 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:添加了窗体加载时,加载聊天记录的功能。
/// </summary>
public class MainViewModel : PropertyChangedBase
{
#region 字段、属性、集合、命令
#region 字段
private UserControl _currentView; //当前视图
private ShareOllamaObject _ollamaService; //共享Ollama服务对象
private string _selectedModel; //选择的模型
private ObservableCollection<string> _modelListCollection; //模型列表
private int _expandedBarWidth = 50; //折叠栏宽度
private string _directory; //目录
private string _fileName; //文件
private ObservableCollection<ChatRecordModel> _chatRecordCollection;
public event Action<string> LoadChatRecordEventHandler;
#endregion
#region 属性
/// <summary>
/// 当前显示视图
/// </summary>
public UserControl CurrentView {
get => _currentView;
set
{
if (_currentView != value)
{
_currentView = value;
OnPropertyChanged();
}
}
}
public ShareOllamaObject OllamaService
{
get => _ollamaService;
set
{
if (_ollamaService != value)
{
_ollamaService = value;
OnPropertyChanged();
}
}
}
public string SelectedModel
{
get => _selectedModel;
set
{
if (_selectedModel != value)
{
_selectedModel = value;
OllamaService.Ollama.SelectedModel = value;
OllamaService.Chat.Model = value;
OnPropertyChanged();
}
}
}
public int ExpandedBarWidth
{
get => _expandedBarWidth;
set
{
if (_expandedBarWidth != value)
{
_expandedBarWidth = value;
OnPropertyChanged();
}
}
}
#endregion
#region 集合
/// <summary>
/// 视图集合,保存视图
/// </summary>
public ObservableCollection<UserControl> ViewCollection { get; set; }
public ObservableCollection<string> ModelListCollection
{
get => _modelListCollection;
set
{
if (_modelListCollection != value)
{
_modelListCollection = value;
OnPropertyChanged();
}
}
}
public ObservableCollection<ChatRecordModel> ChatRecordCollection
{
get => _chatRecordCollection;
set
{
if (_chatRecordCollection != value)
{
_chatRecordCollection = value;
OnPropertyChanged();
}
}
}
#endregion
#region 命令
/// <summary>
/// 展开功能菜单命令
/// </summary>
public ICommand ExpandedMenuCommand { get; set; }
/// <summary>
/// 折叠功能菜单命令
/// </summary>
public ICommand CollapsedMenuCommand { get; set; }
/// <summary>
/// 切换视图命令
/// </summary>
public ICommand SwitchViewCommand { get; set; }
/// <summary>
/// 窗体关闭命令
/// </summary>
public ICommand ClosingWindowCommand { get; set; }
/// <summary>
/// 窗体加载命令
/// </summary>
public ICommand LoadedWindowCommand { get; set; }
/// <summary>
/// 聊天记录鼠标按下命令
/// </summary>
public ICommand ChatRecordMouseDownCommand { get; set; }
#endregion
#endregion
#region 构造函数
public MainViewModel()
{
Initialize();
}
/// <summary>
/// 初始化方法
/// </summary>
public void Initialize()
{
//初始化Ollama
_ollamaService = new ShareOllamaObject();
ModelListCollection = _ollamaService.ModelList;
SelectedModel = _ollamaService.SelectModel;
//创建命令
SwitchViewCommand = new ParameterCommand(SwitchViewTrigger);
LoadedWindowCommand = new EventsCommand<object>(LoadedWindowTrigger);
CollapsedMenuCommand = new EventsCommand<object>(CollapsedMenuTrigger);
ExpandedMenuCommand = new EventsCommand<object>(ExpandedMenuTrigger);
ClosingWindowCommand = new EventsCommand<object>(ClosingWindowTrigger);
ChatRecordMouseDownCommand = new EventsCommand<ChatRecordModel>(ChatRecordMouseDownTrigger);
ViewCollection = new ObservableCollection<UserControl>();
//添加视图到集合
ViewCollection.Add(new SystemSettingView());
ViewCollection.Add(new UserChatView());
//默认显示窗体
CurrentView = ViewCollection[1];
//折叠栏折叠状态
ExpandedBarWidth = 25;
//加载聊天记录
LoadChatRecord();
}
#endregion
#region 命令方法
/// <summary>
/// 聊天记录鼠标按下
/// </summary>
private void ChatRecordMouseDownTrigger(ChatRecordModel obj)
{
Debug.Print(obj.ToString());
OnLoadChatRecordCallBack(obj.FullName.ToString());
}
/// <summary>
/// 触发主视图窗体加载方法
/// </summary>
private void LoadedWindowTrigger(object sender)
{
Debug.Print(sender?.ToString());
var userView = ViewCollection.FirstOrDefault(obj => obj is UserChatView) as UserChatView;
userView.UserWindow.Ollama = _ollamaService;
LoadChatRecordEventHandler += userView.UserWindow.LoadChatRecordCallback;
}
/// <summary>
/// 触发关闭窗体方法
/// </summary>
private void ClosingWindowTrigger(object obj)
{
if (obj is CancelEventArgs cancelEventArgs)
{
if (MessageBox.Show("确定要关闭程序吗?", "确认关闭", MessageBoxButton.YesNo) == MessageBoxResult.No)
{
cancelEventArgs.Cancel = true; // 取消关闭
}
else
{
ClearingResources();
}
}
}
/// <summary>
/// 视图切换命令触发的方法
/// </summary>
private void SwitchViewTrigger(object obj)
{
Debug.WriteLine(obj.ToString());
switch (obj.ToString())
{
case "SystemSettingView":
CurrentView = ViewCollection[0];
break;
case "UserChatView":
CurrentView = ViewCollection[1];
break;
case "NewUserChatView":
UserChatView newChatView = new UserChatView();
OllamaService.ReCreateChat();
newChatView.UserWindow.Ollama = OllamaService;
ViewCollection[1] = newChatView;
CurrentView = newChatView;
break;
}
}
/// <summary>
/// 折叠菜单触发方法
/// </summary>
private void CollapsedMenuTrigger(object e)
{
ExpandedBarWidth = 25;
Debug.WriteLine("折叠");
}
/// <summary>
/// 展开菜单触发方法
/// </summary>
private void ExpandedMenuTrigger(object e)
{
ExpandedBarWidth = 250;
Debug.WriteLine("展开");
}
#endregion
#region 其他方法
/// <summary>
/// 加载聊天记录
/// </summary>
private void LoadChatRecord()
{
_directory = $"{Environment.CurrentDirectory}\\Record";
string[] files = FileOperation.GetFiles(_directory);
ObservableCollection<ChatRecordModel> records = new ObservableCollection<ChatRecordModel>();
string name = string.Empty;
string data = string.Empty;
foreach (var item in files)
{
name = Path.GetFileNameWithoutExtension(item);
data = File.ReadAllLines(item)[3];
if (data.Trim().Length > 1 )
{
records.Add(new ChatRecordModel(records.Count, name, name, item, data.Substring(1)));
}
}
ChatRecordCollection = records;
}
/// <summary>
/// 触发事件:加载聊天记录回调
/// </summary>
private void OnLoadChatRecordCallBack(object sender)
{
LoadChatRecordEventHandler.Invoke(sender.ToString());
}
/// <summary>
/// 释放资源:窗体关闭时触发
/// </summary>
private void ClearingResources()
{
//ProcessService.GetPIDAndCloseByPort(11434);
}
#endregion
}
}
PropertyChangedBase
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace OfflineAI.ViewModels
{
/// <summary>
/// 属性变更基类
/// </summary>
public class PropertyChangedBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
UserChatViewModel
using Markdig.Wpf;
using OfflineAI.Commands;
using OfflineAI.Services;
using OfflineAI.Sevices;
using System.Diagnostics;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Forms;
using System.Windows.Input;
namespace OfflineAI.ViewModels
{
/// <summary>
/// 描述:用户聊天视图模型:
/// 作者:吾与谁归
/// 时间: 2025年2月19日
/// 更新:
/// 1、 2025-02-19:添加AI聊天功能,输出问题及结果到UI,并使用Markdown相关的库做简单渲染。
/// 2、 2025-02-20:优化了构造函数,使用无参构造,方便在设计器中直接绑定数据上下文(感觉)。
/// 3、 2025-02-20:滚轮滑动显示内容,提交问题后滚动显示内容,鼠标右键点击内容停止继续滚动,回答结束停止滚动。
/// 4、 2025-02-24:添加聊天记录保存功能。
/// 5、 2025-02-24:添加聊天记录加载功能,通过点击记录列表显示。
/// </summary>
public class UserChatViewModel:PropertyChangedBase
{
#region 字段、属性、集合、命令
#region 字段
private bool _isAutoScrolling = false; //是否自动滚动
private string _currentInputText; //当前输入文本
private string _messageContent; //消息内容
private string _directory; //目录
private string _fileName; //文件名
private MarkdownViewer _markdownViewer; //MarkdownViewer控件
private ScrollViewer _scrollViewer; //ScrollViewer滑动控件
private StringBuilder _message = new StringBuilder(); //消息字符串拼接
private CancellationToken cancellationToken; //异步线程取消标记
private FileOperation _fileIO; //文件IO
private ShareOllamaObject _ollama; //Ollama 对象实例
private string _submitButtonName;
#endregion
#region 属性
/// <summary>
/// 提交按钮名称
/// </summary>
public string SubmitButtonName
{
get => _submitButtonName;
set
{
if (_submitButtonName != value)
{
_submitButtonName = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 消息内容
/// </summary>
public string? MessageContent
{
get => _messageContent;
set
{
_messageContent = value;
OnPropertyChanged();
}
}
/// <summary>
/// 当前输入文本
/// </summary>
public string CurrentInputText
{
get => _currentInputText;
set
{
if (_currentInputText != value)
{
_currentInputText = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 共享Ollama对象
/// </summary>
public ShareOllamaObject Ollama
{
get => _ollama;
set
{
if (_ollama != value)
{
_ollama = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 自动滚动消息
/// </summary>
public bool IsAutoScrolling
{
get => _isAutoScrolling;
set
{
if (_isAutoScrolling != value)
{
_isAutoScrolling = value;
OnPropertyChanged();
}
}
}
#endregion
#region 集合
#endregion
#region 命令
/// <summary>
/// 展开功能菜单命令
/// </summary>
public ICommand LoadFileCommand { get; set; }
/// <summary>
/// 提交命令
/// </summary>
public ICommand SubmiQuestionCommand { get; set; }
/// <summary>
/// 鼠标滚动
/// </summary>
public ICommand MouseWheelCommand { get; set; }
/// <summary>
/// 鼠标按下
/// </summary>
public ICommand MouseDownCommand { get; set; }
/// <summary>
/// Markdown对象命令
/// </summary>
public ICommand MarkdownOBJCommand { get; set; }
/// <summary>
/// 滑动条加载
/// </summary>
public ICommand ScrollLoadedCommand { get; set; }
#endregion
#endregion
#region 构造函数
public UserChatViewModel()
{
Initialize();
}
#endregion
#region 初始化方法
/// <summary>
/// 初始化方法
/// </summary>
public void Initialize()
{
//文件加载
LoadFileCommand = new ParameterCommand(LoadFileTrigger);
MouseWheelCommand = new EventsCommand<MouseWheelEventArgs>(MouseWheelTrigger);
MouseDownCommand = new EventsCommand<MouseButtonEventArgs>(MouseDownTrigger);
MarkdownOBJCommand = new EventsCommand<object>(MarkdownOBJTrigger);
SubmiQuestionCommand = new ParameterlessCommand(SubmitQuestionTrigger);
ScrollLoadedCommand = new EventsCommand<RoutedEventArgs>(ScrollLoadedTrigger);
//
SubmitButtonName = "提交";
//日志记录
_directory = $"{Environment.CurrentDirectory}\\Record\\";
_fileName = $"{_directory}\\{DateTime.Now.ToString("yyyyMMddHHmmss")}";
_fileIO = new FileOperation($"{_fileName}");
//
}
#endregion
#region 命令方法
/// <summary>
/// 加载文件
/// </summary>
private void LoadFileTrigger(object obj)
{
OpenFileDialog openFile = new OpenFileDialog();
openFile.Multiselect = true;
if (openFile.ShowDialog() == DialogResult.OK)
{
string[] files = openFile.FileNames;
if (files.Count() > 1)
{
foreach (var item in files)
{
Debug.WriteLine(item);
}
}
else
{
Debug.WriteLine(openFile.FileName);
}
}
}
/// <summary>
/// 提交: 提交问题到AI并获取返回结果
/// </summary>
private async void SubmitQuestionTrigger()
{
_ = Task.Delay(1);
string input = CurrentInputText;
try
{
if (!SubmintChecked(input)) return;
SubmitButtonName = "停止";
_message.Clear();
_isAutoScrolling = true;
AppendText($"##{Environment.NewLine}");
AppendText($"[{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss:fff")}]{Environment.NewLine}");
AppendText($"## 【User】{Environment.NewLine}");
AppendText($">{input}{Environment.NewLine}");
AppendText($"{Environment.NewLine}");
AppendText($"## 【AI】{Environment.NewLine}");
await foreach (var answerToken in Ollama.Chat.SendAsync(input))
{
AppendText(answerToken);
await Task.Delay(20);
if (_isAutoScrolling) _scrollViewer.ScrollToEnd();//是否自动滚动
}
AppendText($"{Environment.NewLine}{Environment.NewLine}");
}
catch (Exception ex)
{
AppendText($"Error: {ex.Message}");
AppendText($"{Environment.NewLine}{Environment.NewLine}");
}
//回答完成
_fileIO.WriteTxt($"{_fileName}", _message.ToString());
CurrentInputText = string.Empty;
_isAutoScrolling = false;
SubmitButtonName = "提交";
}
/// <summary>
/// 鼠标滚动上下滑动
/// </summary>
private void MouseWheelTrigger(MouseWheelEventArgs e)
{
try
{
// 获取 ScrollViewer 对象
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);
}
}
/// <summary>
/// Markdown中鼠标按下
/// </summary>
private void MouseDownTrigger(MouseButtonEventArgs args)
{
if (args.LeftButton == MouseButtonState.Pressed)
{
IsAutoScrolling = false;
Debug.Print("Mouse Down...");
}
}
/// <summary>
/// 滚动栏触发
/// </summary>
private void ScrollLoadedTrigger(RoutedEventArgs args)
{
if (args.Source is ScrollViewer scrollView )
{
_scrollViewer = scrollView;
Debug.Print("Scroll loaded...");
}
}
/// <summary>
/// Markdown控件对象更新触发
/// </summary>
private void MarkdownOBJTrigger(object obj)
{
if (_markdownViewer != null) return;
if (obj is MarkdownViewer markdownViewer)
{
_markdownViewer = markdownViewer;
_markdownViewer.Markdown = "";
}
}
#endregion
#region 其他方法
/// <summary>
/// 输出文本
/// </summary>
public void AppendText(string newText)
{
Debug.Print(newText);
_markdownViewer.Markdown += newText;
_message.Append(newText);
}
/// <summary>
/// 提交校验
/// </summary>
private bool SubmintChecked(string input)
{
if (string.IsNullOrEmpty(input)) return false;
if (input.Length<2) return false;
if (input.Equals("停止")) return false;
return true;
}
#endregion
#region 回调方法
/// <summary>
/// 加载聊天记录回调
/// </summary>
public void LoadChatRecordCallback(string path)
{
Debug.Print(path);
_scrollViewer.ScrollToTop();
_markdownViewer.Markdown = _fileIO. ReadTxt(path);
}
#endregion
}
}
Views
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="450" d:DesignWidth="800"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<!--绑定数据上下文-->
<UserControl.DataContext>
<viewmodels:UserChatViewModel x:Name="UserWindow"/>
</UserControl.DataContext>
<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="300"/>
</Grid.RowDefinitions>
<!--行背景色-->
<Border Grid.Row="0" Background="#FFFFFF"/>
<Border Grid.Row="1" Background="#5E5E5E"/>
<Grid>
<!--markdown 滑动条-->
<ScrollViewer Background="#AEAEAE"
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">
<!--命令绑定事件:鼠标滚动显示内容-->
<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>
</Grid>
<!--第三行内容:显示回话内容-->
<Grid Grid.Row="1" Margin="2">
<!--定义三行-->
<Grid.RowDefinitions>
<RowDefinition Height="25"/>
<RowDefinition Height="*"/>
<RowDefinition Height="30"/>
</Grid.RowDefinitions>
<!--设置Border样式-->
<Border Grid.Row="0" Margin="150,0,150,0" Background="#5E5E5E">
<Border.BorderThickness>2,2,2,0</Border.BorderThickness>
<Border.BorderBrush>
<SolidColorBrush Color="#000000"/>
</Border.BorderBrush>
</Border>
<Border Grid.Row="1" Margin="150,0,150,0" Background="#5E5E5E">
<Border.BorderThickness>2,0,2,0</Border.BorderThickness>
<Border.BorderBrush>
<SolidColorBrush Color="#000000"/>
</Border.BorderBrush>
</Border>
<Border Grid.Row="2" Margin="150,0,150,0" Background="#5E5E5E">
<Border.BorderThickness>2,0,2,2</Border.BorderThickness>
<Border.BorderBrush>
<SolidColorBrush Color="#000000"/>
</Border.BorderBrush>
</Border>
<!--第2行内容区域-->
<Grid Grid.Row="1" Margin="150,0,150,0">
<TextBox x:Name="InputBox" Background="#5E5E5E"
Text="{Binding CurrentInputText , Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Grid.Row="1" Margin="5" AcceptsReturn="True"
VerticalScrollBarVisibility="Auto">
<!--回车发送-->
<TextBox.InputBindings>
<KeyBinding Command="{Binding SubmiQuestionCommand}" Key="Enter"/>
</TextBox.InputBindings>
</TextBox>
</Grid>
<!--第3行内容区域-->
<Grid Grid.Row="2" Margin="150,0,150,0">
<WrapPanel HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,5,0">
<Button Width="50" Command="{Binding LoadFileCommand}">
<Image Width="24" Height="24"
Source="/Views/Resources/append24-black.png"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Button>
<Button Width="50" Command="{Binding SubmiQuestionCommand}" Content="{Binding SubmitButtonName}"></Button>
</WrapPanel>
</Grid>
</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>
<CheckBox Width="200" Margin="5" HorizontalAlignment="Left" IsChecked="True">是否滚动显示</CheckBox>
<ComboBox Width="200" Margin="5" HorizontalAlignment="Left">
</ComboBox>
</StackPanel>
</Grid>
</UserControl>
Styles \ ButtonStyle.xaml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 定义圆角按钮的静态样式 -->
<Style x:Key="RoundCornerButtonStyle" TargetType="Button">
<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="BorderBrush" Value="DarkGray"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="5"/>
<Setter Property="Margin" Value="10"/>
<Setter Property="Width" Value="60"/>
<Setter Property="Height" Value="20"/>
<!--设置模板样式-->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<!--使用 Border 元素作为按钮的主要容器。
roundedRectangle:名称,方便在触发器中引用。
Background:绑定背景色到按钮的 Background 属性。
BorderBrush:绑定边框颜色到按钮的 BorderBrush 属性。
BorderThickness:绑定边框宽度到按钮的 BorderThickness 属性。
CornerRadius:设置边框的圆角半径为10,使按钮具有圆角效果。
ContentPresenter:用于显示按钮的内容(如文本或图标)。
-->
<Border x:Name="roundedRectangle" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
<!-- 设置顶部圆角 -->
<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="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#AED3D2" Offset="0.3" />
<!-- 淡色 -->
<GradientStop Color="#F0FBFF" Offset="0.7" />
<!-- 深色 -->
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="BorderBrush" Value="DarkGray"></Setter>
<Setter Property="BorderThickness" Value="0"></Setter>
<Setter Property="Padding" Value="5"></Setter>
<Setter Property="Margin" Value="5 5 5 5"></Setter>
<Setter Property="FontSize" Value="20"></Setter>
<!-- 调整宽度以适应图标和文本 -->
<Setter Property="Height" Value="50"></Setter>
<!-- 调整高度以适应图标和文本 -->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="roundedRectangle" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="10">
<!-- 使用 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="#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>
</ResourceDictionary>
MainWindow
<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-logo128.ico"
MinHeight="600" MinWidth="800">
<!--绑定上下文-->
<Window.DataContext>
<viewmodels:MainViewModel>
</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>
<!-- 定义3列:-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
</Grid.ColumnDefinitions>
<!-- 定义2行 -->
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="20"/>
</Grid.RowDefinitions>
<!-- 折叠栏 Expander -->
<Expander x:Name="expanderBox" Grid.Row="0" Grid.Column="0" Header=""
Background="#AABBBB" ExpandDirection="Left"
IsExpanded="False"
FlowDirection="LeftToRight" Width="{Binding ExpandedBarWidth}">
<!--命令绑定事件-->
<behavior:Interaction.Triggers>
<!--折叠栏展开命令绑定-->
<behavior:EventTrigger EventName="Expanded">
<behavior:InvokeCommandAction Command="{Binding ExpandedMenuCommand}" />
</behavior:EventTrigger>
<!--折叠栏折叠命令绑定-->
<behavior:EventTrigger EventName="Collapsed">
<behavior:InvokeCommandAction Command="{Binding CollapsedMenuCommand}" />
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
<ScrollViewer Background="#AEAEAE" x:Name="RecordScrollViewer">
<ListBox ItemsSource="{Binding ChatRecordCollection}" Margin="5">
<ListBox.ItemTemplate>
<DataTemplate>
<!-- 显示消息内容 -->
<TextBlock Text="{Binding Data}" Margin="10,0,0,0">
<behavior:Interaction.Triggers>
<!--鼠标点击命令事件-->
<behavior:EventTrigger EventName="PreviewMouseDown">
<behavior:InvokeCommandAction
Command="{Binding DataContext.ChatRecordMouseDownCommand,
RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}"
PassEventArgsToCommand="True"/>
</behavior:EventTrigger>
</behavior:Interaction.Triggers>
</TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Expander>
<!-- 右侧内容区域 -->
<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="#99BBCC"/>
<Border Grid.Row="1" Background="#FFFFFF" Grid.RowSpan="2"/>
<!--第一行内容:左对齐内容-->
<WrapPanel VerticalAlignment="Center">
<!--视图切换:首页-->
<Button x:Name="Btn_HomePage" Width="50" Height="36" FontSize="16"
Style="{StaticResource IconButtonStyle}"
Command="{Binding SwitchViewCommand}"
CommandParameter="UserChatView">
<StackPanel Orientation="Horizontal">
<Image Source="Views/Resources/home24-black.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/edit24-black.png"
Margin="5" Width="24" Height="24"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Text="新聊天" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!--模型列表-->
<Label Content="模型:" Margin="5" FontSize="18" VerticalAlignment="Center"/>
<ComboBox x:Name="Cbx_ModelList"
Style="{StaticResource RoundComboBoxStyle}"
ItemsSource="{Binding ModelListCollection}"
SelectedItem="{Binding SelectedModel}">
</ComboBox>
</WrapPanel>
<!--第一行内容:右对齐内容-->
<WrapPanel Margin="0,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" >
<Button Background="#99BBCC"
Command="{Binding SwitchViewCommand}"
CommandParameter="SystemSettingView">
<Image Source="/Views/Resources/setting64.png"
Margin="5" Width="24" Height="24"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Button>
</WrapPanel>
<!--第二行内容:显示当前视图-->
<ContentControl Grid.Row="1" Margin="5,5,5,5"
Content="{Binding CurrentView}"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" Grid.RowSpan="2"/>
</Grid>
</Grid>
</Window>
总结
以上为项目的全部代码。
实现功能:
1、添加折叠栏展开|折叠功能。
2、视图切换功能 1)系统设置 2) 聊天
3、关闭窗体时提示是否关闭,释放相关资源。
4、添加首页功能,和修改新聊天功能。点击新聊天会创建新的会话(Chat)。
5、窗体加载时传递Ollama对象。
6、添加了窗体加载时,加载聊天记录的功能。
7、添加AI聊天功能,输出问题及结果到UI,并使用Markdown相关的库做简单渲染。
8、优化了构造函数,使用无参构造,方便在设计器中直接绑定数据上下文(感觉)。
9、 滚轮滑动显示内容,提交问题后滚动显示内容,鼠标右键点击内容停止继续滚动,回答结束停止滚动。
10、添加聊天记录保存功能。
11、添加聊天记录加载功能,通过点击记录列表显示。
待完善:
1、使用deepseek r*模型时,控件刷新会把 的前面的一部分吞掉,使用Debug打印的是完整的问题,初步怀疑是异步刷新UI更不上的问题。
2、想使用Markdown的高级渲染功能使用起来,目前仅是简单的渲染(有空要做出来)。
3、聊天记录仅仅是显示功能,没有实现承接聊天记录回答问题。
4、参考网页端的功能开发更多功能。
项目下载地址:https://github.com/timenodes/OfflineAI