自用GPT客户端

719 阅读3分钟

介绍

大家好,我是刘索隆,一个不务正业 喜欢前端的后端程序员(java)。

最近后端业务一直在做和GPT相关的产品,同时自己想学习一些前端的东西,所以计划整一个ChatGPT客户端软件。

前端东西都是现学的,代码有点烂,欢迎各位大佬指正。

技术选型

这里采用Tauri+Vue,利用webview 来开发桌面应用,学习曲线相较于原生的简单了不少。

开发

项目搭建

Tauri环境搭建请参考官网 Build smaller, faster, and more secure desktop applications with a web frontend | Tauri Apps

按照官网步骤建立工程

image.png

改造标题栏

主要是觉得自带的标题栏太丑了,看着不顺眼

src-tauri/tauri.config.json 文件修改

"windows": [
  {
    "fullscreen": false,
    "resizable": true,
    "title": "Tauri GPT",
    "width": 800,
    "height": 600,
    "minWidth": 700,
    "minHeight": 500,
    // 主要是这个,隐藏自带的标题栏
    "decorations": false,
    "center": true,
    "theme":"Dark"
  }

decorations 属性不光设置的是标题栏的显隐,设置false后边框及阴影也没有了

image.png

windows-shadows包可以解决这个问题,Cargo.toml增加该包的依赖

[dependencies]
tauri = { version = "1.3", features = ["clipboard-all", "fs-all", "global-shortcut-all", "http-all", "path-all", "shell-open", "system-tray", "window-all"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
window-shadows = "0.2.1"

src-tauri/src/main.rs 进行设置

use tauri::{Manager};
use window_shadows::set_shadow;


fn main() {
    tauri::Builder::default()
        .setup(|app| {
            let window = app.get_window("main").unwrap();
            set_shadow(&window, true).expect("不支持的平台!");
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("应用程序运行时出错");
}

GPT流式接口调用

gpt-3.5-turbo 模型参数中有个stream,设置为true时,即为流式调用,不用等回复一次性返回,java后端的时候采用了sse的当时推送给前端的,但是这个项目没有上后端,所以使用http fetch 来进行api的调用

ps: 以下代码大家将就看(自己觉得写的就挺烂的,主要对这块不是很熟)

// 接收提问事件名称
export const OPENAI_STREAM_QUESTION_EVENT = 'openai:stream:question:event';
// 终止流式问答事件名称
export const OPENAI_STREAM_STOP_EVENT = 'openai:stream:stop:event';
// stream问答开始/结束事件
export const OPENAI_STREAM_ANSWER_EVENT = 'openai:stream:answer:event';
// 身份设置事件
export const OPENAI_SET_IDENTITY_EVENT = 'openai:set:identity:event';
const userAppStore = useAppStoreWithOut();
const { apiHost, apiKey, temperature, top_p, max_tokens, n } = userAppStore.getOpenaiConfig;
export const openaiEmitter = mitt();
const decoder = new TextDecoder('UTF-8');
const headers = new Headers();
headers.append('Content-Type', 'application/json');
headers.append('Authorization', `Bearer ${apiKey}`);

openaiEmitter.on(OPENAI_STREAM_QUESTION_EVENT, async (messages: ChatMessages) => {
  await streamChatCompletions(messages);
});

enum Api {
  ChatCompletions = '/v1/chat/completions',
}
enum OpenAiModel {
  GPT_35_TURBO = 'gpt-3.5-turbo',
}
/**
 * stream 式问答
 * @param messages
 */
const streamChatCompletions = async (messages?: ChatMessages) => {
  const abortController = new AbortController();
  // 消息裁剪
  const newMessage = messageHandler(messages);
  const body = {
    model: OpenAiModel.GPT_35_TURBO,
    messages: newMessage,
    stream: true,
    temperature: temperature,
    top_p: top_p,
    max_tokens: max_tokens,
    n: n,
  };
  const id = createUUID();
  let time = currentDateTime();
  const currentMessageIndex = messages?.push({
    id: id,
    role: MessageRoleTypeEnum.ASSISTANT,
    content: '',
    time: time,
    loading: true,
  });
  // 监听终止事件 终止问答
  openaiEmitter.on(OPENAI_STREAM_STOP_EVENT, () => abortController.abort());
  try {
    const res = await fetch(apiHost + Api.ChatCompletions, {
      headers: headers,
      method: 'POST',
      body: JSON.stringify(body),
      signal: abortController.signal,
    });
    let answer = '';
    if (res.status !== 200) {
      throw new OpenaiError(200, 'Open AI 接口请求失败');
    }

    openaiEmitter.emit(OPENAI_STREAM_ANSWER_EVENT, true);
    const reader = (res as any).body.getReader();
    time = currentDateTime();
    while (true) {
      const { done, value } = await reader.read();
      if (done || abortController.signal.aborted) {
        openaiEmitter.emit(OPENAI_STREAM_ANSWER_EVENT, false);
        break;
      }
      const content = decoder.decode(value).replaceAll('data: ', '').split('\n').filter(Boolean);
      for (const string of content) {
        if (string != '[DONE]') {
          const jsonObject = JSON.parse(string);
          const obj = jsonObject.choices[0].delta;
          answer += obj.content ? obj.content : '';
          const message = {
            id: id,
            role: MessageRoleTypeEnum.ASSISTANT,
            content: answer,
            time: time,
          };
          (messages as ChatMessages).splice((currentMessageIndex as number) - 1, 1, message);
        } else {
          openaiEmitter.emit(OPENAI_STREAM_ANSWER_EVENT, false);
        }
      }
    }
  } catch (error) {
    if ((error as Error).name != 'AbortError') {
      const message = {
        id: createUUID(),
        role: MessageRoleTypeEnum.ASSISTANT,
        content: (error as OpenaiError).message,
        time: currentDateTime(),
      };
      messages?.push(message);
    }
    openaiEmitter.emit(OPENAI_STREAM_ANSWER_EVENT, false);
  }
};

代码中借助mitt事件通知来操作是否中止stream response。最主要的代码就是这个接口了,其余的,例如Openai的参数配置是设置路由守卫,在路由跳转前,将配置塞进pinia,然后从pinia中直接获取的。同时设置了一些其余事件协助在问答过程中的一些功能的处理。

其余的配置

Openai的接口要访问,首先要有ApiKey,然后还有能够访问api.openai.com/v1/chat/com…接口,要么能够科学上网,要么设置代理。 所以程序中设置了一个维护key和proxy的地方,通过json文件进行存储。

所以需要程序可操作本地文件

tauri.config.json 修改

"allowlist": {
  "all": false,
  "clipboard": {
    "all": true,
    "writeText": true,
    "readText": true
  },
  "shell": {
    "all": false,
    "open": true
  },
  "http": {
    "all": true,
    "request": true,
    "scope": ["https://**","http://**"]
  },
  "window": {
    "all": true
  },
  "path": {
    "all": true
  },
   "fs": {
     "all": true,
     "scope": ["$DATA/**","$APPDATA/**","$APPCONFIG/**","$APPLOG/**"]
   }
},

allowlist中主要设置的是允许Tauri操作的一些权限及范围问题,这个可以根据功能的实际情况酌情开放,比如,需要文件操作,fs设置为true,同时配置了操作的文件范围,具体配置可以参考配置 |金牛座应用 (tauri.app)

样式

UI方面使用的是Naive UI: 一个 Vue 3 组件库,有框架,省不少事儿,同时集成了Home | Windi CSS css框架

ps:前端要学的东西也好多啊

程序截图

image.png

一键发送.gif

后续规划

ChatGPT的接入其实不算问题,主要是想借助这个应用的开发学习一些前端的知识。

目前该应用就主要实现了换肤,聊天功能。

后端的业务中已经实现了GPT接入本地知识库进行智能应答,所以打算后面也为这个项目添加本地知识库的导入(菜单都已经罗列上了)。 计算时接入向量,采用向量匹配的模式,实现采用本地知识库内容的应答,同时支持发送图片的功能。

  1. 本地知识库导入
  2. 图片发送
  3. Tauri 相关功能(快捷键,多窗口,进程间通信,自动更新等)