Expert Chat —— 一个自带钥匙的跨平台 LLM 客户端

前言

DeepSeek 官网那套体验我很喜欢——深度思考能看推理过程、能联网、能传文件,界面也干净。但网页端总归是网页端:要登录、有限制、数据在别人服务器上。我想要的是一个纯本地、自带 API Key、跑在我自己设备上的客户端,把官网的核心体验搬下来,同时所有数据只留在本机。

于是有了 Expert Chat:一个用 Flutter 写的跨平台 LLM 客户端,支持任意 OpenAI 兼容的服务(DeepSeek、OpenAI、Kimi、智谱……填进去就能用)。没有后端、没有账号、不做云同步——你的 Key 和聊天记录都只待在你自己的设备里。

功能特性

  • 多模型对话:可以配多个 LLM 供应商,随时切换。
  • 流式输出:AI 的回复实时一段段吐出来,不用干等。
  • 思考过程展示:接 reasoner 类模型时,可以展开看模型的完整推理链(thinking)。
  • 文件解析:支持上传 PDF、DOCX、Excel、TXT 等文件,解析成文本作为上下文喂给模型。
  • 联网搜索:集成搜索 API,模型可以自主决定检索、注入结果、并带上引用来源。
  • 对话管理:新建、删除、导出对话历史。
  • Markdown 渲染:代码高亮、表格、链接等完整支持。
  • 安全存储:API Key 走 flutter_secure_storage 加密存储,进系统钥匙串(Windows DPAPI / macOS Keychain / Android Keystore)。
  • 跨平台:Windows、macOS、Linux、iOS、Android、Web 一套代码全覆盖。
  • 主题:浅色 / 深色 / 跟随系统。

为什么用 Flutter

一开始我也纠结过技术选型。Electron、Tauri 桌面端是强,但移动端缺位;React Native 加 Tauri 又得维护两套技术栈。算来算去,Flutter 是唯一能真正六端通吃的方案——一套 Dart 代码同时编译到桌面和移动,桌面性能还比 Electron 好。加上我本来就有 Flutter 环境,就定了它。

技术栈大致是这样:

层级 技术
框架 / 语言 Flutter 3 / Dart 3
状态管理 Riverpod 3.x
数据库 Drift(SQLite)
网络 / SSE Dio(ResponseType.stream 逐 chunk 解析)
安全存储 flutter_secure_storage
文件解析 syncfusion_flutter_pdf / docx_to_text / excel
UI 字体 Noto Sans SC

架构

项目是纯客户端分层结构,lib/ 下大致这么组织:

1
2
3
4
5
6
7
8
9
10
11
12
lib/
├── core/ # 全局配置(主题、Provider)
├── data/ # 数据层(Drift 数据库、Repository)
│ └── db/ # 数据库定义
├── domain/ # 业务逻辑
│ ├── export/ # 对话导出
│ ├── llm/ # LLM 调用封装(OpenAI 兼容抽象)
│ └── tools/ # 文件解析、搜索等工具
├── features/ # UI 页面
│ ├── chat/ # 聊天界面
│ └── settings/ # 设置界面
└── state/ # 状态管理(Controller)

多个供应商背后是一层统一的 llm_provider 抽象,DeepSeek / OpenAI / Kimi / 智谱这些只要是 OpenAI 兼容的,都通过同一套接口接进来。联网搜索和文件解析这两块,因为官网那套服务端能力不对外开放,所以是自己实现的——搜索源可以自选,文件在本地解析成文本再注入。

跑起来

环境要求:Flutter SDK ≥ 3.12,Dart SDK ≥ 3.12。

1
2
3
4
5
6
7
8
9
10
11
12
# 克隆仓库
git clone https://github.com/wweiyi2004/expert_chat.git
cd expert_chat

# 安装依赖
flutter pub get

# 生成 Drift 数据库代码
dart run build_runner build

# 运行应用
flutter run

跑起来之后进 设置 页面,填上 LLM API 的 Base URL、API Key 和模型名;想用联网搜索的话,再配一个搜索 API Key 即可。

一些踩坑记录

Riverpod 从 2.x 升到 3.x 有几个地方不一样,我在这上面卡过:

  • 取值要用 AsyncValue.value不是 valueOrNull
  • AsyncNotifier 自带 update 方法,自定义方法别和它重名(我后来统一用 apply)。

另外历史存储我是先用 JSON 文件跑通、再切到 Drift/SQLite 的——接口提前抽象好,避免一上来就被 codegen 卡住首次运行。这种"先能跑、再升级"的节奏,对个人项目挺友好。

开源地址

项目已经开源,欢迎 Star / Issue / PR:

👉 https://github.com/wweiyi2004/expert_chat

如果你也想要一个数据只留在自己设备上的 AI 客户端,欢迎试用、提建议。