3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第1张图片

 

TAURI 是什么

TAURI 是一个使用 Rust 编写的程序框架,它允许我们使用 Web 技术和 Rust 语言构建跨端应用。它提供了大量特性,例如系统通知、网络请求、全局快捷键、本地文件处理等,它们都可以在前端通过 JavaScript 便捷的调用。

TAURI 应用的后端基于 Rust,这是一种内存安全、性能出色、跨平台的系统级程序设计语言,它保证了 TAURI 应用的高效和安全性。TAURI 应用由系统的 WebView 进行用户界面的渲染,因此开发者可以使用流行的 Web 技术快速构建用户界面,并且可以有效的控制打包产物体积。

TAURI 当前已支持 macOS、Windows、Linux 平台,在即将到来的 2.0 版本中将会支持 iOS/iPadOS 和 Android。

TAURI 对比 Electron

TAURI 和 Election 都是基于 Web 技术构建跨平台应用的程序框架,但是 Electron 比 TAURI 诞生早了将近 6 年。

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第2张图片

Github Star 对比:107k 63k

Electron 基本可以归属于上个时代的产物,和 React 同年 2013 年面世,彼时还处于前端高速发展的初期,Angular 和 React 刚从 jQuery 中抢过来一小部分用户,Vue 还在胎中,webpack 刚发布还不足两年……

Electron 的诞生大大降低了桌面应用开发成本、维护难度,并且有 GitHub 和 Microsoft 巨头公司背书,多年来一直拥有活跃的技术社区,再加上 VS Code、Slack、Discord 这些知名 App 的流行,让更多的人加入了蓬勃发展的社区。

庞大的社区带来了丰富的生态系统,这也是 TAURI 不及 Electron 最明显的方面。

下面是其他方面二者的对比:

  • 渲染引擎:Electron 应用统一使用 Chromium,具有很好的兼容性和性能表现,但是也增加了打包产物体积,App 运行时所占内存也一直被诟病;TAURI 使用系统 WebView 作为渲染引擎,打包产物体积更小、运行所占内存更少,但是由于 WebView 的差异,TAURI App 兼容性相对薄弱。
  • 后端技术:TAURI 后端基于 Rust,TAURI App 会使用更少的内存和 CPU 资源,性能更优,TAURI 提供了更好的集成方式,可以很方便的将 Rust 和其他后端语言结合使用;Electron 后端基于 Node.js 平台,可以享受丰富的 Node.js 生态,更容易上手开发后端服务。
  • 支持的平台:因为渲染引擎的选择不同,Electron 只能支持 Windows/macOS/Linux,而 TAURI 不仅支持这些平台,还能支持 iOS/iPadOS/Android。

心动不如行动!现在就用 TAURI 开发一款跨平台的 ChatGPT 客户端!

它有如下功能:

  • 持久化本地保存对话记录
  • 多页面支持
  • 使用个人 API Key
  • 配置 API Host 代理、Chat Model、对话风格
  • 让 AI 理解上下文,并且可配置上下文消息数
  • 指定 AI 人格,让 TA 成为编程大师、郭德纲、猫娘然后与你交流
  • ……

当前项目已开源!文末给出该项目的 Github 代码仓库地址!

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第3张图片

 

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第4张图片

 

我从开始阅读 TAURI 官方文档,到开发完成这款 App,只用了 3 天时间。有了我的踩坑,你甚至可以 1 天内开发完成这款应用!

开始!

创建项目

创建项目前,需要确保本地已安装 Node.js、Rust,然后使用你的 Node.js 包管理工具(如 pnpm )执行:

pnpm create tauri-app

在终端中,可以命名项目名称,选择包管理工具、JavaScript/TypeScript、前端框架。我这里选择的是 pnpm + TS + Vite + React。

项目目录结构:

root
├── public
├── src
├── src-tauri
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── ...

基本的目录结构和一个标准的 Web 项目目录结构几乎一致,但是这里多了一个 src-tauri 目录,这是一个 Rust 项目的目录:

src-tauri
├── Cargo.toml
├── tauri.conf.json
├── src
├── icons
└── ...

其中 tauri.conf.json 需要特别关注,因为它是整个 TAURI App 的配置文件; src-tauri/src 中可以写一些 Rust 代码, src-tauri/icons 是 App 的图标文件夹,存放了不同操作系统会用到的不同分辨率/格式的 App 图标资源,可以用 CLI tauri icon base-icon.png 自动生成 。

启动

安装依赖、启动项目:

pnpm i
pnpm tauri dev

执行后,会根据配置校验代码、编译前端代码、编译 Rust 代码,启动 App:

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第5张图片

这是一个使用系统 WebView 渲染的用户界面,如果希望可以像开发传统 Web 项目一样,使用 Chrome 浏览器开发调试,只需要执行 pnpm vite 即可(假如选择的前端工具是 vite)。

注意:用浏览器开发时,系统原生能力是无法使用的,只有通过 tauri dev 启动打开的 App 才能调用系统原生能力。

多页面支持

让 TAURI App 支持多页面并非难事,常见的前端路由库都可以用在 TAURI 应用中实现多页面应用,这里我们选用 React Router 实现多页面。

pnpm add react-router-dom

当前安装的是 v6 版本(新特性巨多)。

入口文件 main.tsx 没什么改动:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')).render(
  
    
  
)

在 App.tsx 中配置两个页面:

import Chat from '@/pages/Chat'
import More from '@/pages/More'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import Layout from './Layout'

// 页面多的话可以抽离出去组织一下
const router = createBrowserRouter([
  {
    path: '/',
    element: ,
    children: [
      { path: '/', element:  },
      { path: '/more', element:  }
    ],
  },
])

export default function App() {
  return (
    
  )
}

在 Layout.tsx 中使用  指定页面组件渲染的位置:

import { Outlet } from 'react-router-dom'
import Header from '../Header'

export default function Layout() {
  return (
    
) }

通过页面顶部的 

 导航组件看一下 React Router 其他一些用法:

import { Link, useLocation, useNavigate } from 'react-router-dom'

export default function Header() {
  // 调用 navigate() 去你想去的地方 ⛱️
  const navigate = useNavigate()
  // 我在哪?
  const location = useLocation()
  const showBack = location.pathname !== '/'

  return (
    
navigate('/')}>
{/* 相当于 HTML 中的 ,点击后跳转页面 */}
) }
  • 懒加载页面组件在 TAURI 应用里不是很刚需,因为打包后代码文件都在本地,加载速度足够快
  • 大部分情况下,可以把只有一个 Window 的 TAURI App 视作 Web 中的单页面应用(SPA)

用户设置页面

页面功能说明

一个表单页面,点击 保存 后将用户的配置保存到本地文件中。

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第6张图片

 页面代码

import { getUserConfig, setUserConfig } from '@/utils/user-config'
import { dialog } from '@tauri-apps/api'
import { useMount, useSetState } from 'ahooks'
import { useNavigate } from 'react-router-dom'
import style from './index.module.css'

export default function More() {
  const navigate = useNavigate()
  const [state, setState] = useSetState({
    key: '',
    // ...
  })

  // 页面加载时,读取本地配置文件,并设置 state
  useMount(async () => {
    const config = await getUserConfig()
    setState({
      key: config.openAi.key,
      // ...
    })
  })

  async function save() {
    await setUserConfig(state)
    // 调用系统原生的 dialog
    await dialog.message('✅ 配置已保存')
    navigate('/')
  }

  return (
    
setState({ key: e.target.value })} />
{/* 其他表单输入项... */}
) }

读写用户配置工具函数

getUserConfig() 和 setUserConfig() 的具体实现:

import { UserConfig } from '@/types'
import { readTextFile, writeTextFile } from './file'

const CONFIG_FILE_NAME = 'config.json'

// 保存一个 JS 变量,以便前端获取配置时,不用每次都读文件
let userConfig: UserConfig | null = null

export async function getUserConfig() {
  if (userConfig) return userConfig

  const config = await readTextFile(CONFIG_FILE_NAME)
  try {
    userConfig = JSON.parse(config)
  } catch (error) {
    userConfig = DEFAULT_USER_CONFIG
  }

  return userConfig!
}

export async function setUserConfig(config: UserConfig) {
  await writeTextFile({
    path: CONFIG_FILE_NAME,
    contents: JSON.stringify(config),
  })
  userConfig = config
}

export const DEFAULT_USER_CONFIG: UserConfig = {
  openAi: {
    key: '',
    apiHost: '',
    chatModel: 'gpt-3.5-turbo',
  },
  temperature: 1,
  maxContextMessageCount: 5,
  systemPersonality: '',
}

封装读写本地文件函数

TAURI 提供的 fs 对象已经很简洁易用,这里还封装一下主要有两个原因:

  1. 保证读写文件都在一个基础目录下进行,例如 $APP_DATA 目录。TAURI 出于安全考虑,要求对可读写文件的基础目录先行配置,具体为配置文件中的 tauri.allowList.fs.scope 配置项,只在一个基础目录下操作文件,减少了配置,也方便调试维护这些本地文件。
  2. 出于安全、不同平台兼容性考虑,使用 TAURI 操作文件,是无法使用 /etc/... 这种绝对路径的,相对路径 ../ 也无法使用,只能使用 fs.BaseDirectory 提供的一些枚举值代表的路径(足够丰富),如 fs.BaseDirectory.AppData 代表的是本机 $APP_DATA 目录,对 macOS 平台而言,具体为 /Users//Library/Application Support/ 目录,在执行写文件之前,要准备好这个文件夹!否则会写入文件失败!
import { fs } from '@tauri-apps/api'

const DEFAULT_DIR = fs.BaseDirectory.AppData

/**
 * 写文件时,应确保文件夹的存在,文件夹不存在,则无法写入
 * 使用 fs.createDir() 创建文件夹,如果文件夹已经存在,不会重复创建
 */
async function prepareWrite() {
  await fs.createDir('dir', { dir: DEFAULT_DIR, recursive: true })
}

export async function writeTextFile(file: Record<'path' | 'contents', string>) {
  await prepareWrite()
  await fs.writeTextFile(file, { dir: DEFAULT_DIR })
}

export async function readTextFile(filePath: string) {
  return await fs.readTextFile(filePath, { dir: DEFAULT_DIR })
}

对话页面

页面功能说明

用户输入问题后,请求接口,聊天记录区域以打字机的效果实时渲染 AI 的回答。

3 天,入门 TAURI 并开发一个跨平台 ChatGPT 客户端_第7张图片

页面代码

 固定在页面底部,上方  展示对话记录,

 

import MessageList from '@/components/MessageList'
import UserInput from '@/components/UserInput'
import style from './index.module.css'

export default function Chat() {
  return (
    
) }

UserInput

用户输入框,支持恢复待发送文本、按 ⬆️ 键恢复上次已发送文本。因为处理用户输入时的接口请求、文本渲染这些工作大部分都与当前组件无关,所以采用通过事件传递用户输入的文本,由 SEND_QUESTION 事件的订阅者来处理这些复杂的任务;后面将会增加新的聊天机器人,如 Bing AI,也通过订阅该事件进行 AI 回复。

import { eventBus } from '@/utils/event-bus'
import { useKeyPress, useMount, useSetState } from 'ahooks'
import { useRef } from 'react'
import style from './index.module.css'

const storage = {
  lastUserInput: '',
  curUserInput: '',
}

export default function UserInput() {
  const [state, setState] = useSetState({ input: '' })
  const inputRef = useRef(null)

  function handleUserInput(input = '') {
    setState({ input })
    storage.curUserInput = input
  }

  function send() {
    const content = state.input.replace(/(^\s*)|(\s*$)/g, '')
    if (!content) {
      return
    }
    storage.lastUserInput = content
    handleUserInput()
    eventBus.emit(eventBus.name.SEND_QUESTION, content)
  }

  useKeyPress(
    'enter',
    (e) => {
      e.preventDefault()
      send()
    }
  )
  // 实现用户键盘轻点 ⬆️,输入框内容为上次输入的问题
  useKeyPress(
    'uparrow',
    (e) => {
      const input = storage.lastUserInput
      if (!input) return
      setState((state) => {
        if (state.input) return state
        // 组件渲染完成后,将光标移至输入框末尾
        setTimeout(() => {
          inputRef.current!.selectionStart = input.length
        }, 0)
        return { input }
      })
    }
  )

  useMount(() => setState({ input: storage.curUserInput }))

  return (