介绍
大家好,我是刘索隆,一个不务正业
喜欢前端的后端程序员(java)。
最近后端业务一直在做和GPT相关的产品,同时自己想学习一些前端的东西,所以计划整一个ChatGPT客户端软件。
前端东西都是现学的,代码有点烂,欢迎各位大佬指正。
技术选型
这里采用Tauri
+Vue
,利用webview
来开发桌面应用,学习曲线相较于原生的简单了不少。
开发
项目搭建
Tauri环境搭建请参考官网 Build smaller, faster, and more secure desktop applications with a web frontend | Tauri Apps
按照官网步骤建立工程
改造标题栏
主要是觉得自带的标题栏太丑了,看着不顺眼
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后边框及阴影也没有了
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:前端要学的东西也好多啊
程序截图
后续规划
ChatGPT的接入其实不算问题,主要是想借助这个应用的开发学习一些前端的知识。
目前该应用就主要实现了换肤,聊天功能。
后端的业务中已经实现了GPT接入本地知识库进行智能应答,所以打算后面也为这个项目添加本地知识库的导入(菜单都已经罗列上了)。 计算时接入向量,采用向量匹配的模式,实现采用本地知识库内容的应答,同时支持发送图片的功能。
- 本地知识库导入
- 图片发送
- Tauri 相关功能(快捷键,多窗口,进程间通信,自动更新等)