前言
AI 强势来袭,开发了一个 AI+ 项目代码分析的工具,实现 上传整个项目、上传单个文件、解释当前代码、评估代码质量、优化当前代码 能力
技术栈
Electron + React + TypeScript + Express+ Openai + EventSource(流式渲染) + HttpProxyAgent + Zustand + MonacoEditor
核心技术点
1. 上传项目、文件并解析成树结构(Electron 主进程 -> 渲染进程通信)
渲染进程的事件
const handleOpenFolder / handleOpenFile = () => {
window.electron.ipcRenderer.sendMessage('open-folder-dialog');
window.electron.ipcRenderer.sendMessage('open-file-dialog');
};
打开文件夹 和 打开文件 在主进程中的操作,区别在于 openDirectory 还是 openFile
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory'],
properties: ['openFile'],
});
打开文件夹后,找到下面所有的文件,并梳理成树结构返回渲染进程
import { ipcMain, dialog, BrowserWindow } from 'electron';
import { readdir } from 'fs/promises';
ipcMain.on('open-folder-dialog', async () => {
try {
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory'],
});
if (!result.canceled && result.filePaths.length > 0) {
const rootStructure = [
{
isRoot: true,
key: result.filePaths[0],
title: path.basename(selectedDirectory);,
children: await readDirectoryAsync(selectedDirectory),
currentOpenType: 'folder',
},
];
mainWindow?.webContents.send('init-tree-structure', rootStructure);
}
} catch (error) {
console.error(error);
}
});
// 找到下面所有的文件
export async function readDirectoryAsync(
dirPath: string,
): Promise<Directory[]> {
try {
const items = await readdir(dirPath, { withFileTypes: true });
const results: Directory[] = await Promise.all(
items.map(async (item) => {
const itemPath = path.join(dirPath, item.name);
const isDirectory = item.isDirectory();
return isDirectory
? {
key: itemPath,
title: item.name,
children: await readDirectoryAsync(itemPath),
}
: { key: itemPath, title: item.name, isLeaf: true };
}),
);
return results;
} catch (error) {
console.error(error);
}
}
2. EventSource 流式渲染,实现数据实时传输,干货
2.1 renderer 渲染进程里抽象出一层(被调用)
import { EventStreamContentType, fetchEventSource } from '@fortaine/fetch-event-source';
// 检查是否需要流式传输
if (shouldStream) {
// 用于存储完整的响应文本
let responseText = '';
// 用于存储未处理的剩余文本
let remainText = '';
// 标志流式传输是否完成
let finished = false;
// 动画函数,用于更新响应文本
function animateResponseText() {
// 如果传输完成或已中止,追加剩余文本并返回
if (finished || controller.signal.aborted) {
responseText += remainText;
return;
}
// 如果还有剩余文本,按批次处理并更新响应文本
if (remainText.length > 0) {
const fetchCount = Math.max(1, Math.round(remainText.length / 60));
const fetchText = remainText.slice(0, fetchCount);
responseText += fetchText;
remainText = remainText.slice(fetchCount);
options.onUpdate?.(responseText, fetchText);
}
// 使用 requestAnimationFrame 调度下一次动画帧
requestAnimationFrame(animateResponseText);
}
// 启动动画函数
animateResponseText();
// 完成函数,用于标记传输完成并调用完成回调
const finish = () => {
if (!finished) {
finished = true;
options.onFinish(responseText + remainText);
}
};
// 设置中止信号的处理函数
controller.signal.onabort = finish;
// 使用 fetchEventSource 开始流式传输
fetchEventSource(chatPath, {
// 传递 payload 和配置
...chatPayload,
// 打开连接时的处理函数
async onopen(res) {
// 清除请求超时定时器
clearTimeout(requestTimeoutId);
// 获取响应内容类型
const contentType = res.headers.get('content-type');
// 如果内容类型是纯文本,读取响应文本并完成传输
if (contentType?.startsWith('text/plain')) {
responseText = await res.clone().text();
return finish();
}
// 检查响应状态和内容类型是否符合预期
if (
!res.ok ||
!res.headers
.get('content-type')
?.startsWith(EventStreamContentType) ||
res.status !== 200
) {
// 处理错误响应,获取额外信息
const responseTexts = [responseText];
let extraInfo = await res.clone().text();
try {
const resJson = await res.clone().json();
extraInfo = prettyObject(resJson);
} catch (e) {
console.log(e);
}
if (res.status === 401) {
responseTexts.push('401');
}
if (extraInfo) {
responseTexts.push(extraInfo);
}
responseText = responseTexts.join('\n\n');
return finish();
}
},
// 收到消息时的处理函数
onmessage(msg) {
// 如果消息是 '[DONE]' 或传输已经完成,结束传输
if (msg.data === '[DONE]' || finished) {
return finish();
}
const text = msg.data;
try {
// 解析消息内容,提取 delta 并追加到剩余文本
const json = JSON.parse(text) as {
choices: Array<{
delta: {
content: string;
};
}>;
};
const delta = json.choices[0]?.delta?.content;
if (delta) {
remainText += delta;
}
} catch (e) {
console.error('[Request] parse error', text);
}
},
// 连接关闭时的处理函数
onclose() {
finish();
},
// 发生错误时的处理函数
onerror(e) {
options.onError?.(e);
throw e;
},
// 允许在窗口隐藏时继续保持连接
openWhenHidden: true,
});
}
2.2 main 主进程中启动一个服务
import express, { Express } from 'express';
import { HttpProxyAgent } from 'http-proxy-agent';
import fetch from 'electron-fetch';
const app = express();
app.use(cors());
app.use(express.json());
// 端口为你本机的网络代理端口
const agent = new HttpProxyAgent('http://127.0.0.1:7890');
app.post('/api/openai/v1/chat/completions', async (req, res) => {
// 这是主进程要请求openai的地址
const fetchUrl = `${baseUrl}/${path}`;
const fetchOptions = {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
[authHeaderName]: authValue,
},
method: req.method,
body: JSON.stringify(req.body),
redirect: 'manual',
duplex: 'half',
signal: controller.signal,
agent,
};
try {
const response = await fetch(fetchUrl, fetchOptions);
// 这里的流式响应设置
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
for await (const chunk of response.body) {
res.write(chunk.toString());
}
} finally {
clearTimeout(timeoutId);
}
});
3. 全局数据流 zustand + monacoEditor
全局数据流 zustand 和 monacoEditor 编辑器在前端的使用就不过多赘述了,想看源码的同学 AI项目代码解释分析纠错器-GitHub