笔记:记一次使用bun+vite+tauri+react+tailwindcss+fluent design ui实现一个基于ollama的本地模型管理工具

1,676 阅读5分钟

前言

如标题所示,bun在前段时间发布了正式版,突发奇想用就用它配合tarui实现一个简单的模型管理工具。

搭建环境

第一步,安装bun

进入官网bun,根据提示复制命令即可安装bun,由于作者使用的windows系统,接下来都会以window终端作为演示

powershell -c "irm bun.sh/install.ps1 | iex"

or linux/macos

curl -fsSL https://bun.sh/install | bash

当然,安装过程中你可能需要一点小魔法,会有意想不到的速度哦

安装完成后使用

bun --version

检查版本

第二步,使用create-tauri-app创建项目

根据tauri给出的文档,使用以下命令开始创建项目(当然,你需要根据tauri的文档完成运行前的依赖安装,rust和vs依赖,这里就不做赘述了)

bun create tauri-app --beta

然后根据vite的提示选择需要的环境,这里依次选择

image.png 这时候项目基础就搭建的差不多了,接下来使用以下命令安装tailwindcssfluent ui

bun add -D tailwindcss
bunx tailwindcss init

bun add @fluentui/react-components

接下来打开项目,使用bun install安装项目依赖,这时候项目就已经可以跑通了,你将会看到一个简单的窗口

第三步,对依赖完成文件修改

  1. 修改tailwind.config.js文件
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
  1. 修改 main.ts 将fluent的配置组件引入进来
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
<FluentProvider theme={webLightTheme}>
    <App />
</FluentProvider>

修改代码,完成基本功能

第一步,修改rust代码,让我们可以调用ollama的功能

  1. 首先,在src-tauri/src目录下创建一个新的rust文件,model.rs
  2. 然后在里面添加如下代码
use serde::Serialize;
use std::process::{Command, Output};

fn execute_command(cmd: &str) -> Output {
    let mut command  = Command::new("cmd");
    command .arg("/c").arg(cmd);
    command .output().expect("failed to execute command")
}

#[derive(Serialize, Debug)]
pub struct Model {
    name: String,
    version: String,
    id: String,
    size: String,
    modified: String,
}

#[tauri::command]
pub fn get_models() -> Vec<Model> {
    let output = execute_command("ollama list");

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut models: Vec<Model> = vec![];

    for line in stdout.lines().skip(1) {
        // 模型信息分隔
        let parts: Vec<&str> = line.split('\t').map(|s| s.trim()).collect();
        if parts.len() == 5 {
            // 拆分模型名称和版本
            let model_name_parts = parts[0].split(':').collect::<Vec<&str>>();
            // 构造模型对象
            let model = Model {
                name: model_name_parts[0].to_string(),
                version: model_name_parts[1].to_string(),
                id: parts[1].to_string(),
                size: parts[2].to_string(),
                modified: parts[3].to_string(),
            };
            models.push(model);
        }
    }
    models
}

#[tauri::command]
pub fn run_model(model_name: &str) {
    let output = execute_command(&format!("ollama run {}", model_name));
    format!("{:#?}", output);
}

pub fn pull_model(model_name: &str) {
    let output = execute_command(&format!("ollama pull {}", model_name));
    format!("{:#?}", output);
}

// 更新模型
#[tauri::command]
pub fn update_model(model_name: &str) {
    pull_model(model_name);
}

// 获取模型许可
#[tauri::command]
pub fn get_license(model_name: &str) -> String {
    let output = execute_command(&format!("ollama show {} --license", model_name));
    String::from_utf8_lossy(&output.stdout).to_string()
}

#[tauri::command]
pub fn delete_model(model_name: &str) {
    let output = execute_command(&format!("ollama rm {}", model_name));
    format!("{:#?}", output);
}

// 检查 Ollama
#[tauri::command]
pub fn check_ollama() {
    let output = execute_command("ollama --version");
    format!("{:#?}", output);
}
  1. 然后在main.rs中修改代码,将我们编写的函数注入进去
use crate::models::{check_ollama, delete_model, get_license, get_models, run_model, update_model};
mod models;

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_models,
            run_model,
            update_model,
            get_license,
            check_ollama,
            delete_model
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  1. 接下来修改前端代码,在src中新建文件夹components,接着在components目录下新建文件ModelList.tsx,最后在列表中加入如下代码,就可以获得一个简易的模型列表了
import * as React from "react";
import { invoke } from "@tauri-apps/api/tauri";
import {
  Button,
  Card,
  CardHeader,
  Subtitle1,
  Caption1,
  Tooltip,
  Menu,
  MenuTrigger,
  MenuPopover,
  MenuList,
  MenuItem,
  CardFooter,
} from "@fluentui/react-components";
import { MoreHorizontal20Regular } from "@fluentui/react-icons";

type Models = {
  name: String;
  id: String;
  size: String;
  modified: String;
  version: String;
};

type ModelList = {
  updateTime: number;
  models: Models[];
};

const REFRESH_INTERVAL = 1000 * 60 * 60 * 24;

const isOld = (date: number) => {
  if (date == null) {
    return true;
  }
  const nowTime = new Date().getTime();
  return nowTime - date > REFRESH_INTERVAL;
}

export default function ModelList() {
  const [models, setModels] = React.useState<Models[]>([]);
  async function getModels() {
    const localModel: ModelList = JSON.parse(localStorage.getItem("modelList") || "{}");
    // 对模型列表做出本地缓存
    if (!localModel || isOld(localModel.updateTime)) {
      const data: Models[] = await invoke("get_models");
      setModels(data);
      localStorage.setItem("modellist", JSON.stringify({ updateTime: new Date().getTime(), models: data }));
    } else {
      setModels(localModel.models);
    }
  }

  return (
    <section className="select-none px-[4%] py-8 w-full h-full">
      <section className="flex gap-4 items-center">
        <Tooltip
          content="click to get models"
          relationship="label"
          positioning={"after"}
          showDelay={50}
          hideDelay={50}>
          <Button
            appearance="outline"
            onClick={getModels}>
            get Models
          </Button>
        </Tooltip>
        {/* <Subtitle2>click to get models</Subtitle2> */}
      </section>
      <section className="grid gap-4 mt-4">
        {models?.map(model => (
          <Card
            key={model.id + ""}>
            <CardHeader
              header={<Subtitle1>{model.name}</Subtitle1>}
              description={<Caption1>Size: {model.size}</Caption1>}
              action={
                <Menu>
                  <MenuTrigger disableButtonEnhancement>
                    <Button
                      appearance="transparent"
                      aria-label="More options"
                      icon={<MoreHorizontal20Regular />}
                    />
                  </MenuTrigger>
                  <MenuPopover>
                    <MenuList>
                      <MenuItem onClick={() => console.log("update")}>update</MenuItem>
                      <MenuItem>delete</MenuItem>
                      <MenuItem>license</MenuItem>
                    </MenuList>
                  </MenuPopover>
                </Menu>
              }
            />
            <section className="flex gap-4">
              <span>version: {model.version}</span>
              <span>update: {model.modified}</span>
            </section>
            <CardFooter>
              <Button appearance="subtle">Run Model</Button>
            </CardFooter>
          </Card>
        ))}
      </section>
    </section>
  );
}

image.png

调试

虽然webview提供的的devtools已经可以满足大部分调试了,但是我们怎么可以放过react-devtools这个强大的react项目调试工具呢

首先,我们需要先安装react-devtools

bun add -D react-devtools

接下来使用bunx react-devtools,然后将react-devtools窗口的端口代码复制到index.html中便可以运行react-devtools,查看组件的详细信息了

但是 这样每次运行都需要打开两个命令窗口,太过于麻烦了。我们可以尝试换个方法使用它

  1. 首先,在项目的根目录下创建一个名为start-script.js的脚本文件
  2. 然后在start-script.js中添加如下代码
const { exec } = require('child_process');

// 执行 react-devtools
const devtoolsProcess = exec('bun react-devtools');

// 执行 tauri dev
const tauriProcess = exec('tauri dev');

// 捕获错误
devtoolsProcess.on('error', (error) => {
  console.error('Error running react-devtools:', error);
});

tauriProcess.on('error', (error) => {
  console.error('Error running tauri dev:', error);
});
  1. 接下来在package.json文件的scripts下添加一行代码"tauri:rd": "bun start-script.js"
  2. 最后使用bun run tauri:rd便可以启动两个窗口啦

预览

结语

这时候我们的代码就基本上差不多了,关于其它功能暂时就不一一列出了,大家可以发挥自己的想象实现它。

这个文章的主要目的是实现一个简单的小工具,顺带学习一下标题列出的这些技术栈。

到这里就结束了,github链接就不放了,本来想做成chat的,最后实在太懒了,就实现了一部分。