前言
在现代软件工程中,图形用户界面(GUI)虽占据主导地位,但终端用户界面(TUI, Text User Interface)凭借其低资源占用、高响应速度及对键盘操作的极致优化,在开发者工具与服务器运维领域依然保持着不可替代的地位。本文将深度剖析如何利用 Go 语言及其生态中的 Charmbracelet 库,构建一个功能完备、界面现代化的 AI 对话终端应用。
第一部分:基础设施环境构建
构建高性能应用的基础在于稳固的系统环境。本次部署环境选择 Ubuntu LTS 系列(20.04/22.04/24.04),这些版本提供了长期支持与稳定的内核环境,适合开发与部署容器化或原生应用。
硬件层面的最低要求为 1GB 内存与 5GB 磁盘空间。对于单纯运行 TUI 客户端而言,此配置绰绰有余,但考虑到编译过程中的中间文件生成及操作系统自身的开销,预留足够的缓冲空间是必要的。
1.1 系统软件包更新与维护
在进行任何开发工作之前,确保操作系统软件包处于最新状态是保障安全与兼容性的首要步骤。通过 APT 包管理器更新索引并升级已安装的软件包。
执行系统更新操作:
bash
sudo apt update && sudo apt upgrade -y
终端将回显更新过程的详细日志,包括读取软件包列表、构建依赖关系树以及下载具体的更新包。
下图展示了执行更新命令后的终端反馈,可以看到软件包列表已被成功读取,系统处于待绪状态。

1.2 核心开发工具链部署
Go 语言环境的搭建及后续的项目拉取、编译均依赖于一系列基础工具。
wget与curl:用于从网络下载文件及进行 API 调试。git:版本控制工具,用于管理项目代码。build-essential:包含 GCC 编译器、Make 工具及标准 C 库头文件。尽管 Go 是自举编译,但在处理某些涉及 CGO(C语言调用)的底层依赖时,GCC 依然是必需的。
安装命令如下:
bash
sudo apt install -y wget curl git build-essential
安装过程中,APT 会自动解析依赖并完成安装。下图呈现了工具链安装完成后的终端输出,表明所需的基础设施已就绪。

1.3 Go 语言运行时环境配置
Go 语言(Golang)以其高效的并发模型和静态编译特性著称。为了获得最新的语言特性(如泛型优化、标准库改进),建议直接从官方源下载最新二进制发行版,而非使用 APT 源中较旧的版本。
设定版本变量并执行下载:
bash
# 设置要安装的版本号
GO_VERSION="1.23.6"
# 下载安装包
wget https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz
wget 命令将从 Google 官方的分发服务器下载适用于 Linux AMD64 架构的压缩包。
下图显示了下载过程的进度条,确认文件 go1.23.6.linux-amd64.tar.gz 已成功保存至本地。

接下来进行解压与安装。标准惯例是将第三方软件安装至 /usr/local 目录。
bash
sudo tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
此命令将压缩包解压,并在 /usr/local/go 创建完整的 Go 目录结构。随后清理下载的原始文件以释放空间:
bash
rm go${GO_VERSION}.linux-amd64.tar.gz
1.4 环境变量持久化配置
仅将文件解压并不足以让系统识别 go 命令。必须将 Go 的二进制目录添加到系统的 PATH 环境变量中。同时,配置 GOPATH 用于指定工作区目录。
编辑 Shell 配置文件(以 Bash 为例):
bash
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
echo 'export GOPATH=$HOME/go' >> ~/.bashrc
echo 'export PATH=$PATH:$GOPATH/bin' >> ~/.bashrc
上述命令将必要的环境变量追加至用户配置文件的末尾。
/usr/local/go/bin:指向 Go 编译器的核心命令。$HOME/go/bin:指向通过go install安装的第三方二进制工具的存放位置。
下图展示了环境变量配置脚本执行后的状态,文件修改操作在后台完成。

最后,加载新的配置并验证安装版本:
bash
source ~/.bashrc
go version
终端应输出 Go 的版本信息,确认为 go1.23.6 linux/amd64,证明运行时环境配置无误。

第二部分:大模型推理服务接入
本应用的核心逻辑是与远程大语言模型(LLM)进行交互。此处选择蓝耘(Lanyun)平台提供的 DeepSeek 模型服务。
2.1 凭证获取
bash
https://console.lanyun.net/#/register?promoterCode=5663b8b127
访问服务控制台链接进行注册。注册完成后,需在后台创建 API Key。API Key 是识别调用者身份及计费的唯一凭证,其权限必须严格保密。
下图展示了在控制台界面生成 API Key 的操作位置,生成的密钥通常以 sk- 开头。

2.2 模型参数确认
为了正确发起 HTTP 请求,需确认以下关键参数:
- Model ID :
/maas/deepseek-ai/DeepSeek-V3.2。此 ID 指定了后端调用的具体模型版本。 - Base URL :
https://maas-api.lanyun.net/v1/chat/completions。这是符合 OpenAI 接口规范的 API 端点。
下图展示了平台提供的模型接入信息详情,这些信息将直接硬编码或通过配置注入到 Go 程序中。

第三部分:依赖管理与模块化设计
Go 语言自 1.11 版本引入 Modules 机制,极大简化了依赖管理。本项目 go_tui 依赖于 Charmbracelet 生态系统,这是一个专门用于构建现代化 CLI/TUI 应用的工具集。
3.1 依赖分析 (go.sum 与 go.mod)
go.mod 文件定义了项目的模块路径及直接依赖。 核心依赖库解析:
- bubbletea (
github.com/charmbracelet/bubbletea): 基于 ELM 架构(Model-Update-View)的 TUI 框架,是整个应用的主循环引擎。它处理输入事件、窗口大小调整及渲染循环。 - bubbles (
github.com/charmbracelet/bubbles): 提供现成的 UI 组件,如文本输入框(Textarea)和视口(Viewport)。 - lipgloss (
github.com/charmbracelet/lipgloss): 用于定义样式的库,支持前景色、背景色、边框、对齐及 padding 设置,类似于 CSS。
go.sum 文件记录了所有依赖项(包括间接依赖)的哈希校验值,确保构建的一致性与安全性。文件中列出了如 mattn/go-runewidth(处理东亚字符宽度)、muesli/termenv(终端环境检测)等底层库的版本锁定信息。
go.sum
go
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
go.mod
go
module go_tui
go 1.24.2
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
第四部分:核心代码实现深度剖析
main.go 承载了业务逻辑与 UI 渲染的全部实现。代码结构遵循 Bubble Tea 的 TEA(The Elm Architecture)模式,分为 Model(状态)、Update(逻辑更新)、View(视图渲染)三个核心部分。
4.1 导入与常量定义
go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
apiURL = "https://maas-api.lanyun.net/v1/chat/completions"
apiKey = "sk-xxxxxxxxxxxxxx" // 实际部署时应从环境变量读取
model = "/maas/deepseek-ai/DeepSeek-V3.2"
)
代码首先引入了标准库用于 HTTP 通信与 JSON 处理,以及 Charmbracelet 的组件库。常量部分定义了与 AI 服务端的连接参数。
4.2 UI 样式定义
利用 lipgloss 定义了多组样式对象:
go
var (
userStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
aiStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62"))
// ... 其他样式
)
这里使用了 ANSI 256 色码。
userStyle使用青色(Color 86)并加粗,标识用户输入。aiStyle使用粉色(Color 212),标识 AI 回复。borderStyle定义了圆角边框(RoundedBorder),赋予界面现代感。
4.3 数据结构设计
程序定义了用于 API 交互的数据结构:
go
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
以及用于 TUI 状态管理的 appModel:
go
type appModel struct {
viewport viewport.Model // 用于显示聊天历史的可滚动区域
textarea textarea.Model // 用户输入区域
history []Message // 保存上下文对话历史
chatLog []string // 用于渲染的格式化字符串切片
width int // 终端当前宽度
height int // 终端当前高度
loading bool // 是否正在等待 API 响应
}
viewport 和 textarea 是 bubbles 库提供的组件,它们自身也是 TEA 模型,拥有独立的 Update 和 View 方法,实现了组件的嵌套复用。
4.4 初始化逻辑
newModel 函数负责初始化组件状态:
textarea被配置为自动获取焦点,高度设为 3 行,并禁用了回车换行(回车用于发送)。viewport初始化时展示欢迎信息。
Init 方法返回 textarea.Blink 命令,这使得输入框的光标开始闪烁,提供视觉反馈。
4.5 事件处理循环 (Update)
Update 是程序的心脏,处理所有消息(Msg):
go
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ...
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// 处理终端窗口大小改变,动态调整布局
m.width = msg.Width
m.height = msg.Height
m.relayout()
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit // 退出程序
case tea.KeyEnter:
// 发送消息逻辑
input := strings.TrimSpace(m.textarea.Value())
// ... 状态更新:清空输入框,追加历史,设置 loading 状态
return m, callAPI(m.history) // 发起异步 API 调用
}
case aiReplyMsg:
// 处理 AI 成功响应
m.loading = false
// ... 追加回复至历史记录与界面
m.refreshViewport()
case aiErrMsg:
// 处理错误响应
// ... 显示错误信息
}
// 将消息传递给子组件
m.textarea, taCmd = m.textarea.Update(msg)
m.viewport, vpCmd = m.viewport.Update(msg)
return m, tea.Batch(taCmd, vpCmd)
}
这里体现了 Go 强大的接口特性。tea.Msg 是空接口,可以承载任何类型的数据。callAPI 返回的 tea.Cmd 是一个函数,该函数在后台执行并返回 tea.Msg,从而触发下一次 Update 循环,实现了非阻塞的异步 I/O。
4.6 异步 API 调用
callAPI 函数封装了 HTTP 请求逻辑:
go
func callAPI(history []Message) tea.Cmd {
return func() tea.Msg {
// 序列化请求体
body, err := json.Marshal(chatRequest{Model: model, Messages: history})
// 创建 HTTP 请求
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+apiKey)
// 发送请求并读取响应
resp, err := http.DefaultClient.Do(req)
// ... 反序列化响应
return aiReplyMsg{cr.Choices[0].Message.Content}
}
}
此设计确保了网络延迟不会冻结 UI 界面。当网络请求进行时,UI 线程仍在运行(例如光标仍在闪烁),直到 aiReplyMsg 被发送回主循环。
4.7 视图渲染 (View)
View 方法将状态转换为字符串进行输出:
go
func (m appModel) View() string {
// 渲染标题与帮助信息
header := lipgloss.JoinHorizontal(...)
// 渲染带边框的视口与输入框
vpBorder := borderStyle.Width(m.width - 2).Render(m.viewport.View())
taBorder := borderStyle.Width(m.width - 2).Render(m.textarea.View())
// 垂直拼接所有元素
return lipgloss.JoinVertical(lipgloss.Left, header, vpBorder, taBorder)
}
lipgloss.JoinVertical 和 JoinHorizontal 实现了类似 Flexbox 的布局能力,确保界面元素在不同尺寸的终端中都能正确排列。
main.go
go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
apiURL = "https://maas-api.lanyun.net/v1/chat/completions"
apiKey = "sk-xxxxxxxxxxxxxx"
model = "/maas/deepseek-ai/DeepSeek-V3.2"
)
// ── styles ────────────────────────────────────────────────────────────────────
var (
userStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true)
aiStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("212"))
errStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("99")).
Bold(true).
Padding(0, 1)
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
borderStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62"))
)
// ── API types ─────────────────────────────────────────────────────────────────
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
type chatResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// ── tea messages ──────────────────────────────────────────────────────────────
type aiReplyMsg struct{ content string }
type aiErrMsg struct{ err error }
// ── model ─────────────────────────────────────────────────────────────────────
type appModel struct {
viewport viewport.Model
textarea textarea.Model
history []Message
chatLog []string
width int
height int
loading bool
}
func newModel() appModel {
ta := textarea.New()
ta.Placeholder = "Type a message... (Enter to send, Ctrl+C to quit)"
ta.Focus()
ta.SetWidth(80)
ta.SetHeight(3)
ta.ShowLineNumbers = false
ta.KeyMap.InsertNewline.SetEnabled(false) // Enter = send
vp := viewport.New(80, 20)
vp.SetContent("Welcome! Start chatting below.\n")
return appModel{
viewport: vp,
textarea: ta,
}
}
func (m appModel) Init() tea.Cmd {
return textarea.Blink
}
// ── update ────────────────────────────────────────────────────────────────────
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
taCmd tea.Cmd
vpCmd tea.Cmd
)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.relayout()
case tea.KeyMsg:
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEnter:
if m.loading {
return m, nil
}
input := strings.TrimSpace(m.textarea.Value())
if input == "" {
return m, nil
}
m.textarea.Reset()
m.addLine(userStyle.Render("You: ") + input)
m.addLine("")
m.history = append(m.history, Message{Role: "user", Content: input})
m.loading = true
m.addLine(helpStyle.Render("AI is thinking..."))
m.refreshViewport()
return m, callAPI(m.history)
}
case aiReplyMsg:
// remove "thinking" line
if len(m.chatLog) > 0 {
m.chatLog = m.chatLog[:len(m.chatLog)-1]
}
m.loading = false
m.history = append(m.history, Message{Role: "assistant", Content: msg.content})
m.addLine(aiStyle.Render("AI: ") + msg.content)
m.addLine("")
m.refreshViewport()
case aiErrMsg:
if len(m.chatLog) > 0 {
m.chatLog = m.chatLog[:len(m.chatLog)-1]
}
m.loading = false
if len(m.history) > 0 {
m.history = m.history[:len(m.history)-1]
}
m.addLine(errStyle.Render("Error: " + msg.err.Error()))
m.addLine("")
m.refreshViewport()
}
m.textarea, taCmd = m.textarea.Update(msg)
m.viewport, vpCmd = m.viewport.Update(msg)
return m, tea.Batch(taCmd, vpCmd)
}
// ── view ──────────────────────────────────────────────────────────────────────
func (m appModel) View() string {
title := titleStyle.Render("DeepSeek Chat")
help := helpStyle.Render("Ctrl+C quit • PgUp/PgDn scroll")
header := lipgloss.JoinHorizontal(lipgloss.Center,
title,
strings.Repeat(" ", max(0, m.width-lipgloss.Width(title)-lipgloss.Width(help)-4)),
help,
)
vpBorder := borderStyle.
Width(m.width - 2).
Render(m.viewport.View())
taBorder := borderStyle.
Width(m.width - 2).
Render(m.textarea.View())
return lipgloss.JoinVertical(lipgloss.Left,
header,
vpBorder,
taBorder,
)
}
// ── helpers ───────────────────────────────────────────────────────────────────
func (m *appModel) addLine(s string) {
m.chatLog = append(m.chatLog, s)
}
func (m *appModel) refreshViewport() {
m.viewport.SetContent(strings.Join(m.chatLog, "\n"))
m.viewport.GotoBottom()
}
func (m *appModel) relayout() {
// header ~1 line, borders add 2 each, textarea 3 lines + 2 border = 5
vpHeight := m.height - 1 - 5 - 2
if vpHeight < 5 {
vpHeight = 5
}
m.viewport.Width = m.width - 4
m.viewport.Height = vpHeight
m.textarea.SetWidth(m.width - 4)
m.refreshViewport()
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// ── API call ──────────────────────────────────────────────────────────────────
func callAPI(history []Message) tea.Cmd {
return func() tea.Msg {
body, err := json.Marshal(chatRequest{Model: model, Messages: history})
if err != nil {
return aiErrMsg{err}
}
req, err := http.NewRequest("POST", apiURL, bytes.NewReader(body))
if err != nil {
return aiErrMsg{err}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return aiErrMsg{err}
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return aiErrMsg{err}
}
var cr chatResponse
if err := json.Unmarshal(raw, &cr); err != nil {
return aiErrMsg{fmt.Errorf("parse error: %w\nraw: %s", err, raw)}
}
if cr.Error != nil {
return aiErrMsg{fmt.Errorf("API error: %s", cr.Error.Message)}
}
if len(cr.Choices) == 0 {
return aiErrMsg{fmt.Errorf("no choices returned")}
}
return aiReplyMsg{cr.Choices[0].Message.Content}
}
}
// ── main ──────────────────────────────────────────────────────────────────────
func main() {
p := tea.NewProgram(newModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Println("Error:", err)
}
}
第五部分:编译与运行
代码编写完成后,进入编译阶段。
5.1 依赖下载与构建
首先进入项目目录,下载 go.mod 中定义的所有依赖包:
bash
cd go_tui
go mod download
随后执行构建命令,生成二进制可执行文件:
bash
go build -o go_tui .
-o go_tui 参数指定了输出文件的名称。Go 编译器会将所有依赖库静态链接至该文件中,使其可以独立运行,无需外部运行时支持。
5.2 程序执行与交互
运行编译后的程序:
bash
./go_tui
终端将清屏并进入 TUI 交互模式。下图展示了程序启动后的初始界面,顶部有标题栏,中间为聊天记录视口(Viewport),底部为输入框(Textarea)。

5.3 交互演示
在输入框输入问题并按下回车,程序将进入加载状态,随后展示 AI 的回复。由于支持 Markdown 渲染(由 Lipgloss 和 Viewport 处理文本格式),回复内容清晰易读。
下图展示了与 AI 模型进行多轮对话的效果。可以看到用户提问与 AI 回复使用了不同的颜色区分,且视口区域正确处理了长文本的折行与滚动。

5.4 项目目录结构
最终的项目文件结构清晰简洁:
main.go: 源码入口。go.mod/go.sum: 依赖描述。go_tui: 编译产物。
下图展示了最终的文件目录结构,体现了 Go 项目简洁的工程化特征。

总结
本文详细阐述了从零开始搭建基于 Go 语言的 TUI 大模型聊天客户端的全过程。通过结合 Go 语言的高并发网络处理能力与 Charmbracelet 生态的现代化 UI 组件,开发者能够快速构建出既具备底层系统级性能,又拥有良好用户体验的终端应用。这种架构不仅适用于简单的聊天机器人,更为开发复杂的运维监控、数据库管理及 DevOps 工具提供了坚实的技术范本。