
简介这是一份面向C#桌面开发初学者与WPF进阶实践者的本地AI聊天应用完整工程基于WPF框架集成Ollama本地大模型服务解决在无网络或隐私敏感场景下实现轻量级AI对话交互的实际需求。资源共2000个文件以662个C#源码文件为核心涵盖UI视图XAML、业务逻辑CS、构建配置.editorconfig/.targets/.props、缓存与编译中间产物.cache/.baml/.buildwithskipanalyzers等结构完整便于理解WPF项目分层设计与OllamaSharpe调用链路压缩包仅3.83MB轻量易部署。已有256人学习下载资源包含可直接运行的EXE、系统设置与多会话聊天双视图切换、Markdown富文本渲染输出、窗体生命周期资源释放、历史记录自动加载、无参构造函数优化等实用功能模块代码规范清晰适合作为WPFAI本地化集成的参考范例与二次开发起点。1. 为什么在WPF里硬塞一个本地AI聊天框比调API还烧脑你有没有试过点开一个桌面程序不联网、不配密钥、不等云端响应输入“帮我写个冒泡排序”三秒后代码就带着语法高亮和注释蹦出来——全程离线CPU风扇转得认真但网络图标纹丝不动。这不是Demo是某高校实验室给嵌入式教学平台做的辅助工具用的就是标题里的组合C# WPF界面 OllamaSharpe封装 Markdig.WPF渲染。它解决的不是“能不能跑AI”的问题而是“怎么让AI在Windows桌面端不掉链子、不卡死、不崩UI线程、还能把Markdown当富文本使”的一整套落地细节。适合两类人一是正在做内部工具、教育软件、离线数据处理系统的.NET开发者二是被LLM API调用延迟和费用卡住脖子、想把小模型真正“装进exe”里跑的务实派。它不追求SOTA性能但要求每次点击发送按钮UI不假死、流式输出不断行、数学公式能渲染、代码块带语言标识——这些恰恰是纯HttpClientTextBox方案最容易翻车的地方。2. 从零搭起本地AI聊天窗口WPF界面骨架与OllamaSharpe通信层2.1 创建最小可运行WPF项目并引入核心NuGet包新建一个 .NET 6.0 或更高版本的 WPF App (.NET) 项目注意必须是.NET 6OllamaSharpe 不支持 .NET Framework。打开包管理器控制台依次执行Install-Package OllamaSharp -Version 4.3.0 Install-Package Markdig.Wpf -Version 0.5.0 Install-Package CommunityToolkit.Mvvm -Version 8.2.2提示OllamaSharp 是目前最稳定的 C# 封装库它把 Ollama 的 REST API/api/chat封装成强类型方法自动处理 SSE 流式响应解析Markdig.Wpf 则是 Markdig 渲染器的 WPF 绑定版能把**加粗**、csharp\nint x0;\n、$$Emc^2$$ 这类原始 Markdown 字符串直接喂给MarkdownTextBlock控件渲染成带样式的 UI 元素。CommunityToolkit.Mvvm 用于实现 MVVM 模式避免后台代码写满事件处理器。项目结构建议按 MVVM 分层Models/存放ChatMessage含 Role、Content、Timestamp、ModelInfo等数据模型ViewModels/MainViewModel.cs负责管理消息列表、发送逻辑、模型切换、流式接收状态Views/MainWindow.xaml只放绑定表达式不写任何sender_Click2.2 构建流式聊天核心逻辑OllamaSharpe 的正确初始化与异步调用Ollama 必须提前在本地运行ollama serve默认监听http://localhost:11434。OllamaSharpe 初始化时需显式指定 BaseUrl 和超时// 在 ViewModel 构造函数中 private readonly IOllamaApiClient _ollamaClient; public MainViewModel() { _ollamaClient new OllamaApiClient( new HttpClient { BaseAddress new Uri(http://localhost:11434/) }, new OllamaApiClientOptions { Timeout TimeSpan.FromMinutes(10), // 流式响应可能持续较久不能设太短 EnableLogging false // 生产环境关闭调试时可设 true 查看 HTTP 日志 }); }发送消息的关键不是await _client.ChatAsync(...)而是用IAsyncEnumerableChatResponse接收流式片段public async IAsyncEnumerableChatResponse StreamChatAsync( string model, ListChatMessage history, [EnumeratorCancellation] CancellationToken ct default) { var request new ChatRequest { Model model, Messages history.Select(m new Message { Role m.Role, Content m.Content }).ToList(), Stream true, // 必须为 true 才能流式返回 Options new Dictionarystring, object { [temperature] 0.7, [num_predict] 2048 // 控制最大生成 token 数防无限循环 } }; await foreach (var response in _ollamaClient.ChatAsync(request, ct)) { yield return response; } }注意StreamChatAsync返回IAsyncEnumerable这是 .NET 5 对流式数据的标准抽象。它允许你在foreach await中逐帧处理响应而不是等整个回答结束才拿到字符串——这正是实现“打字机效果”的底层支撑。num_predict参数必须显式设置否则某些模型如phi3可能因上下文过长而卡死或返回空响应。2.3 WPF界面绑定用ObservableCollectionINotifyPropertyChanged实现消息实时刷新MainWindow.xaml中定义ItemsControl绑定到Messages集合并为不同角色设计不同 DataTemplateItemsControl ItemsSource{Binding Messages} ItemsControl.ItemTemplate DataTemplate Border Margin8,4 Padding12 StackPanel TextBlock Text{Binding Role, StringFormat【{0}】} FontWeightBold ForegroundGray/ local:MarkdownTextBlock Text{Binding Content} Margin0,4,0,0 MaxWidth600 HorizontalAlignmentLeft/ /StackPanel /Border /DataTemplate /ItemsControl.ItemTemplate /ItemsControl对应 ViewModel 中的属性声明必须是线程安全的private readonly ObservableCollectionChatMessage _messages new(); public ObservableCollectionChatMessage Messages _messages; // 发送时添加用户消息UI线程 _messages.Add(new ChatMessage { Role user, Content userInput }); // 流式接收时添加AI消息需切回UI线程 await Application.Current.Dispatcher.InvokeAsync(() { if (_messages.LastOrDefault()?.Role assistant) { // 追加到上一条assistant消息的Content末尾实现流式追加 _messages.Last().Content response.Message.Content; } else { _messages.Add(new ChatMessage { Role assistant, Content response.Message.Content }); } });关键点OllamaSharpe 的流式回调发生在后台线程所有 UI 更新必须通过Dispatcher.InvokeAsync封装。漏掉这一步WPF 会抛出InvalidOperationException: The calling thread cannot access this object because a different thread owns it.—— 这是 WPF 开发者最常踩的“玄学”坑之一。3. 把纯文本变富文本Markdig.WPF 渲染 Markdown 的完整配置链3.1 注册 Markdig 扩展并注入自定义渲染器Markdig.Wpf 默认只支持基础语法加粗、列表、链接要支持代码块高亮、数学公式、表格必须手动注册扩展。在App.xaml.cs的OnStartup方法中添加protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 全局配置 Markdig 渲染器 var pipeline new MarkdownPipelineBuilder() .UseAdvancedExtensions() // 启用脚注、任务列表等 .UseMathematics() // 启用 $$...$$ 和 $...$ 数学公式 .UseYamlFrontMatter() // 可选支持 YAML 元数据 .UseCodeBlockHighlighting(new HighlightSettings { Style HighlightStyle.VisualStudio2019, // 高亮主题 Languages new[] { csharp, python, json, sql } // 显式声明支持的语言 }) .Build(); // 将 pipeline 注入 Markdig.Wpf 的全局静态配置 MarkdownTextBlock.DefaultPipeline pipeline; }注意UseCodeBlockHighlighting必须传入HighlightSettings且Languages数组要明确列出你实际会遇到的语言名。如果只写cs而不是csharp高亮将失效若未调用UseMathematics()$$x^2$$会被原样显示为文本而非渲染为 LaTeX 公式。3.2 处理流式内容中的 Markdown 片段分段渲染与增量更新AI 的流式输出是碎片化的“csharp\npubl” → “ic class” → “ Program {” → “\n…”。如果每收到一段就调用MarkdownTextBlock.Text fragment会导致公式被截断、代码块无法闭合、样式反复重绘。正确做法是缓存完整响应仅在收到完整消息后触发一次渲染。在 ViewModel 中维护一个StringBuilder用于拼接private readonly StringBuilder _assistantBuffer new(); private void OnStreamReceived(ChatResponse response) { var content response.Message.Content; _assistantBuffer.Append(content); // 检查是否为最终响应Ollama 返回 donetrue 时 if (response.Done) { var fullContent _assistantBuffer.ToString(); _assistantBuffer.Clear(); // 切回UI线程更新最后一条消息的Content Application.Current.Dispatcher.InvokeAsync(() { if (_messages.LastOrDefault()?.Role assistant) { _messages.Last().Content fullContent; // 触发 MarkdownTextBlock 重新渲染 } }); } }这样做既保证了渲染完整性又避免了高频 UI 更新带来的闪烁和性能损耗。实测表明对 500 字符以内的响应MarkdownTextBlock渲染耗时稳定在 8~15ms完全满足桌面端交互流畅性要求。3.3 自定义样式与主题适配覆盖默认 CSS 并注入深色模式支持Markdig.Wpf 使用内联style标签注入 CSS但默认样式偏浅色且字体较小。在MainWindow.xaml的Window.Resources中添加Style TargetTypelocal:MarkdownTextBlock Setter PropertyFontSize Value14/ Setter PropertyFontFamily ValueSegoe UI/ Style.Triggers Trigger PropertyIsEnabled ValueFalse Setter PropertyOpacity Value0.8/ /Trigger /Style.Triggers /Style !-- 自定义 CSS 注入 -- local:MarkdownTextBlock x:NamePreviewBlock local:MarkdownTextBlock.CustomCss ![CDATA[ code { background-color: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-family: Consolas, monospace; } pre code { background-color: #2d2d2d; color: #f8f8f2; padding: 12px; overflow-x: auto; } .math { font-family: Cambria Math, serif; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } ]] /local:MarkdownTextBlock.CustomCss /local:MarkdownTextBlock提示CustomCss属性接受原始 CSS 字符串支持pre code这类嵌套选择器。深色模式下pre code的背景色设为#2d2d2dVS2019 深色主题主色文字色#f8f8f2类比 VS 的浅灰文字确保代码块在暗色背景下依然可读。math类选择器专为数学公式优化字体避免默认 sans-serif 字体导致公式符号错位。4. 常见问题排查五个真实翻车现场与血泪修复方案4.1 现象点击发送后 UI 完全卡死鼠标变成沙漏10 秒后弹出OperationCanceledException原因StreamChatAsync方法被错误地用Task.Wait()或.Result同步阻塞调用导致 UI 线程被挂起同时 OllamaSharp 的HttpClient默认使用同步 DNS 解析在网络异常时会额外阻塞数秒。解决彻底删除所有.Wait()/.Result改用async/await链路在OllamaApiClientOptions中启用异步 DNSnew OllamaApiClientOptions { HttpMessageHandler new SocketsHttpHandler { ConnectTimeout TimeSpan.FromSeconds(5), PooledConnectionLifetime TimeSpan.FromMinutes(5) } }4.2 现象Markdown 渲染正常但代码块无高亮全部显示为灰色等宽字体原因UseCodeBlockHighlighting()未正确注册或Languages数组中语言标识符与 AI 输出的代码块语言名不匹配如 AI 输出 py但数组只写了python。解决在App.xaml.cs中确认UseCodeBlockHighlighting()已调用在ChatResponse流式接收时打印response.Message.Content原始字符串观察代码块开头是python、py 还是 csharp将Languages数组扩展为new[] { csharp, python, py, js, javascript, json, sql }覆盖常见别名。4.3 现象数学公式$$Emc^2$$渲染失败显示为原始$符号原因UseMathematics()扩展未启用或MarkdownTextBlock的CustomCss中缺少.math类定义导致 LaTeX 渲染器无法注入样式。解决检查App.xaml.cs中UseMathematics()是否在UseAdvancedExtensions()之后调用顺序敏感在CustomCss中显式添加.math { font-family: Cambria Math, STIXGeneral, serif; }若仍无效临时在CustomCss中加入body { font-family: Cambria Math !important; }强制全局字体验证是否为字体缺失问题。4.4 现象连续快速发送多条消息AI 响应错乱出现“上一条消息的回答混入下一条”原因StreamChatAsync是无状态调用但 ViewModel 中_assistantBuffer和_messages是共享状态。若用户在第一条流未结束时点击第二条发送两个流会并发修改同一StringBuilder和ObservableCollection。解决为每次聊天请求生成唯一CancellationTokenSource并在发送前取消上一次未完成的请求将_assistantBuffer改为局部变量绑定到当前请求的CancellationTokenprivate async Task StartChatAsync(string input, CancellationToken ct) { var buffer new StringBuilder(); await foreach (var r in StreamChatAsync(model, history, ct)) { buffer.Append(r.Message.Content); if (r.Done !ct.IsCancellationRequested) { // 更新 UI... } } }4.5 现象Ollama 模型加载成功但首次调用ChatAsync报HttpRequestException: Connection refused原因Ollama 服务虽已启动但模型尚未加载进内存ollama run llama3仅触发下载首次chat才真正加载。此时/api/chat接口返回 503OllamaSharp 默认不重试。解决在调用ChatAsync前先执行一次轻量级健康检查try { await _ollamaClient.ListModelsAsync(ct); // 列出模型列表触发加载 } catch (HttpRequestException ex) when (ex.StatusCode HttpStatusCode.ServiceUnavailable) { // 等待 2 秒后重试或弹窗提示“模型正在加载请稍候” await Task.Delay(2000, ct); }5. 性能压测与离线鲁棒性加固让本地AI真正扛住生产环境5.1 模型加载预热与内存驻留策略Ollama 默认采用懒加载首次请求时才将模型权重从磁盘映射到内存耗时可能达 10~30 秒取决于模型大小和 SSD 速度。这对桌面端用户体验是灾难性的。解决方案是在程序启动时主动预热常用模型。在MainWindow构造函数中添加public MainWindow() { InitializeComponent(); // 启动后立即预热默认模型如 llama3:8b Task.Run(async () { try { // 发送一个空消息触发加载 var dummyReq new ChatRequest { Model llama3:8b, Messages new ListMessage { new() { Role user, Content hi } }, Stream false, // 非流式快速返回 Options new Dictionarystring, object { [num_predict] 1 } }; await _ollamaClient.ChatAsync(dummyReq); } catch (Exception ex) { // 预热失败不中断主流程记录日志即可 Debug.WriteLine($Preload failed: {ex.Message}); } }); }实测数据llama3:8b4.7GB在 NVMe SSD 上预热耗时约 12 秒后续请求首 token 延迟稳定在 350ms 内未预热时首次请求平均延迟 18.2 秒。预热后内存占用增加约 3.8GBRSS但这是可接受的代价——毕竟用户不会容忍 18 秒的“无响应”。5.2 流式响应节流与 UI 渲染降频高频流式片段如每 50ms 一个 token会导致MarkdownTextBlock频繁重绘CPU 占用飙升。我们不需要每收到一个字符就刷新 UI每 100ms 合并一次更新更合理private readonly DispatcherTimer _renderTimer new() { Interval TimeSpan.FromMilliseconds(100) }; private string _pendingRenderContent ; public MainViewModel() { _renderTimer.Tick (_, __) { if (!string.IsNullOrEmpty(_pendingRenderContent)) { Application.Current.Dispatcher.InvokeAsync(() { if (_messages.LastOrDefault()?.Role assistant) { _messages.Last().Content _pendingRenderContent; _pendingRenderContent ; } }); } }; _renderTimer.Start(); } private void OnStreamChunkReceived(string chunk) { _pendingRenderContent chunk; // 仅追加不触发渲染 }这种“节流渲染”策略将 UI 更新频率从理论上的 20Hz50ms/次降至 10Hz100ms/次实测使MarkdownTextBlock的 CPU 占用率下降 63%滚动流畅度提升明显且用户几乎感知不到延迟——因为人类阅读速度远低于 token 生成速度。5.3 离线容错与降级方案当 Ollama 不可用时优雅兜底不能假设 Ollama 服务永远在线。必须设计降级路径当HttpRequestException持续发生时自动切换至本地规则引擎或缓存应答。在 ViewModel 中维护一个故障计数器private int _ollamaFailureCount; private readonly TimeSpan _failureResetInterval TimeSpan.FromMinutes(5); private async Taskstring SafeChatAsync(string input) { try { var response await _ollamaClient.ChatAsync(new ChatRequest { Model CurrentModel, Messages BuildHistory(input), Options new Dictionarystring, object { [num_predict] 512 } }); _ollamaFailureCount 0; // 成功则清零 return response.Message.Content; } catch (HttpRequestException ex) when (ex.StatusCode HttpStatusCode.ServiceUnavailable || ex.StatusCode HttpStatusCode.InternalServerError) { _ollamaFailureCount; if (_ollamaFailureCount 3) { // 连续失败3次触发降级 return FallbackResponse(input); } throw; // 重抛给上层显示错误 } } private string FallbackResponse(string input) { // 简单关键词匹配规则引擎可替换为轻量级 ONNX 模型 if (input.Contains(排序) || input.Contains(bubble)) return csharp\n// 冒泡排序示例\nfor (int i 0; i arr.Length - 1; i)\n{\n for (int j 0; j arr.Length - 1 - i; j)\n {\n if (arr[j] arr[j 1])\n {\n int temp arr[j];\n arr[j] arr[j 1];\n arr[j 1] temp;\n }\n }\n}\n; return ⚠️ 本地AI服务暂时不可用。请检查Ollama是否运行或稍后重试。; }这个降级机制不是“摆设”。在某跨平台系统部署中它成功拦截了因 Docker Desktop 休眠导致的 Ollama 连接中断避免了用户看到红色异常弹窗转而获得一段可用的示例代码——这种“有回应的失败”比“无响应的白屏”更能维持信任感。我坚持在每个新项目里加这三板斧预热模型、节流渲染、降级响应。它们不炫技但每次交付时客户说“怎么这么顺”我就知道这些细节没白抠。希望帮到你。本文还有配套的精品资源点击获取