零依赖打造彩色终端 AI 聊天工具——基于 .NET 10 的 ChatAgentCLI
目录一、起因二、效果预览2.1 自定义 ASCII Logo三、技术架构3.1 技术栈四、核心实现拆解4.1 流式 SSE 解析4.2 逐字符 Markdown 着色4.3 加载动画4.4 终端输入处理五、API 对接细节5.1 获取 API Key5.2 API 文档与 Base URL5.3 请求体构建5.4 在代码中使用六、为什么零依赖七、不足与改进方向八、总结不用任何 NuGet 包仅靠 .NET BCL 手写 SSE 流式解析 Markdown 语法高亮渲染不到 250 行代码实现一个带加载动画的彩色终端 AI 聊天客户端。一、起因在日常开发中与 AI 模型对话几乎是刚需。Web 界面固然方便但频繁切换窗口、复制粘贴代码片段的操作并不高效。如果能在终端里直接和 AI 对话并且回复还能实时流式输出、带代码高亮那开发体验会有很大提升。市面上已有不少类似的 CLI 工具但它们大多依赖大量第三方库。于是我想能不能只用 .NET 内置库从零手写一个这就是 ChatAgentCLI 的由来。二、效果预览启动时显示 ASCII Logo输入问题后出现中文加载动画思考中 | → 整理思路 / → 组织语言 - → 生成回复 随后 AI 回复以流式方式逐字符输出并根据 Markdown 语法实时着色代码块绿色文字 黑色背景加粗text黄色文字斜体text青色文字行内代码text白色文字 灰色背景标题#品红色文字2.1 自定义 ASCII Logo启动时的 ASCII 文字 Logo 可以通过 TAAG 在线生成。该工具提供了丰富的字体选项Graffiti、Big、Slant 等在文本框中输入你想要的文字选择喜欢的字体后直接复制生成的 ASCII 字符画替换到代码中对应的 Logo 字符串即可。三、技术架构整个项目极其精简只有三个文件零 NuGet 依赖ChatAgentCLI/ ├── ChatAgentCLI.csproj # .NET 10.0 项目文件 ├── Program.cs # 入口 REPL 交互循环 └── DeepSeekClient.cs # HTTP 客户端 SSE 解析 Markdown 着色渲染3.1 技术栈项目选择语言C# 13.NET 10.0依赖零 NuGet 包仅用 BCL通信协议SSEServer-Sent EventsAPI 格式OpenAI 兼容格式DeepSeek目标模型DeepSeek Chat目标平台Windows x64四、核心实现拆解4.1 流式 SSE 解析DeepSeek API 返回的是 OpenAI 兼容的 SSE 格式流式响应每行以data:开头。我们不使用任何 SSE 客户端库而是直接逐行读取using var stream await response.Content.ReadAsStreamAsync(); usingvar reader new StreamReader(stream, Encoding.UTF8); while ((line await reader.ReadLineAsync()) ! null) { if (string.IsNullOrEmpty(line)) continue; if (!line.StartsWith(data:)) continue; var data line.Substring(5).Trim(); if (data [DONE]) break; usingvar doc JsonDocument.Parse(data); var root doc.RootElement; if (root.TryGetProperty(choices, outvar choices) choices.GetArrayLength() 0) { var choice choices[0]; if (choice.TryGetProperty(delta, outvar delta) delta.TryGetProperty(content, outvar text)) { var chunk text.GetString(); // 渲染 chunk 到终端... } } }当收到新的content字段时立即渲染到终端用户看到的就是逐字跳出的流式效果。4.2 逐字符 Markdown 着色这是项目最有意思的部分。收到的文本是 Markdown 格式但在终端中我们需要把它渲染成彩色文字。实现方式是一个简单的状态机逐字符扫描bool inCodeBlock false; bool bold false; bool italic false; for (int i 0; i chunk.Length; i) { // 代码块 if (chunk.Substring(i, 3) ) { inCodeBlock !inCodeBlock; Console.ResetColor(); if (inCodeBlock) { i 2; Console.ForegroundColor ConsoleColor.Green; Console.BackgroundColor ConsoleColor.Black; } continue; } if (inCodeBlock) { Console.ForegroundColor ConsoleColor.Green; Console.Write(chunk[i]); continue; } // 加粗 **text** if (chunk[i] * chunk[i 1] *) { bold !bold; i; Console.ForegroundColor bold ? ConsoleColor.Yellow : Console.ForegroundColor; if (!bold) Console.ResetColor(); continue; } // 斜体 *text*、行内代码 text、标题 # 等... if (chunk[i] \n) { Console.ResetColor(); bold false; italic false; } Console.Write(chunk[i]); } Console.Out.Flush();每个 chunk 到来时即时渲染并Flush用户在终端看到的就是逐字跳出的效果和 Web 端的流式输出体验一致。4.3 加载动画在请求发出后、第一个 chunk 到达前终端需要给用户一个正在处理的反馈。这里实现了一个简单的加载动画private void LoadingLoop(CancellationToken ct) { var spinner new[] { |, /, -, \\ }; var phrases new[] { 思考中, 整理思路, 组织语言, 生成回复 }; var sw Stopwatch.StartNew(); int i 0; while (!ct.IsCancellationRequested) { int pi ((int)(sw.ElapsedMilliseconds / 3000)) % phrases.Length; Console.SetCursorPosition(0, _loadingRow); Console.Write($ {phrases[pi]} {spinner[i % spinner.Length]} ); i; Thread.Sleep(150); } }动画在后台线程运行每 150ms 刷新一次。当收到第一个内容 chunk 时通过CancellationTokenSource.Cancel()停止动画并清除该行。4.4 终端输入处理终端输入没有直接用Console.ReadLine()而是用Console.ReadKey(intercept: true)手动处理每个按键这样可以自定义退格行为在输入区域下方绘制分隔线─字符动态调整Console.BufferHeight防止光标越界正确处理中文字符的显示宽度CJK 字符占 2 列while (true) { var key Console.ReadKey(intercept: true); if (key.Key ConsoleKey.Enter) break; if (key.Key ConsoleKey.Backspace) { if (inputSb.Length 0) { inputSb.Remove(inputSb.Length - 1, 1); // 清除整行并重绘按 Unicode 类别计算显示宽度 Console.SetCursorPosition(0, inputRow); Console.Write(newstring( , 60)); Console.SetCursorPosition(1, inputRow); Console.Write(inputSb.ToString()); int displayWidth 1; foreach (char c in inputSb.ToString()) displayWidth char.GetUnicodeCategory(c) System.Globalization.UnicodeCategory.OtherLetter ? 2 : 1; Console.SetCursorPosition(displayWidth, inputRow); } continue; } if (!char.IsControl(key.KeyChar)) { inputSb.Append(key.KeyChar); Console.Write(key.KeyChar); } }五、API 对接细节5.1 获取 API Key前往 DeepSeek API Keys 页面登录后点击 Create New Key 创建新的 API Key复制生成的 Key格式为sk-开头妥善保管5.2 API 文档与 Base URLDeepSeek API 文档详见 DeepSeek API Docs核心信息项目值Base URLhttps://api.deepseek.com接口路径/chat/completions认证方式Bearer Token流式参数stream: true5.3 请求体构建使用 DeepSeek 的 OpenAI 兼容格式构建请求_http.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, apiKey); var requestBody new { model deepseek-chat, max_tokens 4096, messages new[] { new { role user, content question } }, stream true, stream_options new { include_usage false } }; var json JsonSerializer.Serialize(requestBody); usingvar content new StringContent(json, Encoding.UTF8, application/json); var request new HttpRequestMessage(HttpMethod.Post, ${_baseUrl}/chat/completions); request.Content content;使用 C# 匿名类型 JsonSerializer.Serialize构建 JSON无需引入Newtonsoft.Json等第三方库。.NET 内置的System.Text.Json完全够用。5.4 在代码中使用// Program.cs var chatClient new DeepSeekClient( apiKey: sk-your-key-here, baseUrl: https://api.deepseek.com); await chatClient.AskStreamingWithLoadingAsync(userInput);六、为什么零依赖你可能会问有现成的 SSE 库、Markdown 渲染库、终端 UI 框架为什么不直接用这个项目本质上是一个技术验证——用最基础的工具能做出什么效果。零依赖意味着编译产物极小没有一堆 DLL 跟着跑没有依赖冲突不需要担心包版本兼容性可控性高每一行代码都在自己手里出 bug 能快速定位学习价值手写 SSE 解析和 Markdown 状态机的过程比调用一个await client.ChatStreamAsync()学到的东西多得多当然如果要投入生产可能需要引入更成熟的终端 UI 库如Spectre.Console来处理跨平台、表格、进度条等更复杂的需求。但作为一个轻量级工具当前方案已经足够。七、不足与改进方向当前版本虽然能用但还有一些可以优化的地方方向说明多轮对话目前每次请求只发送当前问题没有维护对话历史API Key 管理Key 硬编码在代码中应改为环境变量或配置文件读取跨平台支持当前针对 Windows x64可适配 Linux/macOS 终端Markdown 解析健壮性跨 chunk 的语法标记如**被拆到两个 chunk可能无法正确识别错误处理网络异常、API 限流等情况需要更完善的降级策略多模型切换可支持配置不同模型DeepSeek、Claude、GPT 等八、总结ChatAgentCLI 用不到 250 行代码、零外部依赖实现了一个功能完整的终端 AI 聊天工具SSE 流式解析逐行读取、实时响应Markdown 着色状态机逐字符渲染彩色输出加载动画多线程 spinner 中文提示短语轮播终端交互手动按键处理 缓冲区管理 CJK 字符宽度适配它证明了很多时候我们不需要引入一堆 NuGet 包标准库就能把事情办好。开源地址https://github.com/yanjinhuagood/ChatAgentCLI引入地址