文章说明
在体验了cursor这个工具后,我对它的功能体验感觉很不错,它的GPT很智能,而且结合了VSCode编辑器,很方便进行开发和一些简单示例代码的编写。我想仿照着它的功能书写一个简易版demo,但是我发现还是有不少困难的,其中主要的困难点在于GPT对话回答的训练,即如何很好地将它与项目信息联系起来;另一个难点是对返回信息中的代码合并到当前代码的一个处理。
我在体验了monaco编辑器后,感觉可以结合这个编辑器,以及阿里的千问对话模型,来模拟一个在线的简单对话效果,来方便进行GPT对话和实际的代码运行测试。目前1.0版本还不包含运行代码的功能,主要是对话生成文件,然后不同版本文件的内容比对效果。后来我发现这种功能体验上不是很好;后续2.0版本中,我改为了自己创建文件,然后GPT对话生成代码,采用手动复制代码的方式,并添加了运行代码的效果;由于目前 monaco 编辑器,我发现它只对JavaScript语言有输入提示,我就只做了后台的JavaScript代码运行,后续我将再研究研究monaco编辑器,看看是否有支持别的语言的插件。
核心代码
1.0版本
采用GPT自动对话的方式来得到代码文件,并添加代码比对效果
App.vue
<script setup>
import {reactive} from "vue";
import {CodeDiff} from "v-code-diff";
import {ElLoading, ElMessage} from "element-plus";
const data = reactive({
questionText: [],
resultList: [],
fileList: [],
fileListVisible: false,
code: {
visible: false,
fileName: "",
oldValue: "",
newValue: "",
},
});
const defaultQuestionTip = "然后你的返回信息格式要求为json格式,其中 有三个属性,第一个是属性文件名称fileName,第二个属性是文件内容,也就是代码内容 code,第三个是本次代码相较于上次代码的变更描述,其中变更描述属性名称为change,然后采用中文描述,最后返回只需要一个json对象,不需要别的内容";
function sendQuestion() {
const body = {
model: "qwen-plus",
messages: [],
};
for (let i = 0; i < data.resultList.length; i++) {
body.messages.push({
role: "user",
content: data.resultList[i].question,
});
}
let questionText;
if (body.messages.length === 0) {
body.messages.push({
role: "user",
content: data.questionText + "," + defaultQuestionTip,
});
questionText = data.questionText + "," + defaultQuestionTip;
} else {
body.messages.push({
role: "user",
content: data.questionText,
});
questionText = data.questionText;
}
const result = {
id: Date.now(),
question: questionText,
change: ""
};
data.resultList.push(result);
const loading = ElLoading.service({
text: '正在处理中…………',
background: 'rgba(0, 0, 0, 0.7)',
});
fetch("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", {
method: "post",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer 填充ApiKey"
},
body: JSON.stringify(body),
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
function read() {
reader.read().then(({done, value}) => {
if (done) {
loading.close();
data.fileListVisible = true;
return;
}
const chunk = decoder.decode(value, {stream: true});
const resData = JSON.parse(chunk);
let content = resData.choices[0].message.content;
if (content.startsWith("```json")) {
content = content.replace("```json", "");
}
if (content.endsWith("```")) {
content = content.substring(0, content.length - 3);
}
content = JSON.parse(content);
result.change = content.change;
updateFileList(content.fileName, content.code);
read();
}).catch(error => {
ElMessage({
message: error,
type: 'error',
});
});
}
read();
}).catch(error => {
ElMessage({
message: '请求失败' + error,
type: 'error',
});
});
}
function updateFileList(fileName, code) {
let exist = false;
let fileItem;
for (let i = 0; i < data.fileList.length; i++) {
if (data.fileList[i].fileName === fileName) {
exist = true;
fileItem = data.fileList[i];
break;
}
}
if (exist) {
fileItem.currentVersion = fileItem.versionList.length + 1;
fileItem.versionList.push({
version: fileItem.versionList.length + 1,
code: code,
});
} else {
data.fileList.push({
fileName: fileName,
versionList: [
{
version: 1,
code: code,
},
],
currentVersion: 1,
diffVersion: [],
});
}
}
function diff(row) {
const diffVersion = row.diffVersion;
const versionList = row.versionList;
if (versionList.length === 1 && diffVersion.length === 1) {
data.code.fileName = row.fileName;
data.code.oldValue = "";
data.code.newValue = versionList[0].code;
data.code.visible = true;
return;
}
if (diffVersion.length !== 2) {
ElMessage({
message: '请选择两个版本比较',
type: 'warning',
});
return;
}
data.code.fileName = row.fileName;
for (let i = 0; i < versionList.length; i++) {
if (versionList[i].version === diffVersion[0]) {
data.code.oldValue = versionList[i].code;
}
if (versionList[i].version === diffVersion[1]) {
data.code.newValue = versionList[i].code;
}
}
data.code.visible = true;
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
ElMessage({
message: '已成功复制到剪贴板',
type: 'success',
});
}).catch(err => {
ElMessage({
message: '复制到剪贴板时出错:' + err,
type: 'error',
});
});
}
</script>
<template>
<div class="container">
<div class="question-container">
<p class="file-list-btn" @click="data.fileListVisible = true">fileList</p>
<template v-for="item in data.resultList" :key="item.id">
<div class="single-question-reply">
<p class="question-content">{{ item.question }}</p>
<p class="reply-content-change">{{ item.change }}</p>
</div>
</template>
</div>
<textarea v-model="data.questionText" :placeholder="'按下 Alt + Enter 提问'" class="question-input"
@keydown.alt.enter="sendQuestion"/>
</div>
<el-dialog v-model="data.fileListVisible" title="文件列表" width="50%">
<el-table :data="data.fileList" border width="100%">
<el-table-column align="center" label="文件名称" prop="fileName"/>
<el-table-column align="center" label="版本">
<template #default="scope">
<el-select v-model="scope.row.currentVersion">
<el-option v-for="item in scope.row.versionList" :key="item.version" :label="item.version"
:value="item.version"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column align="center" label="比对">
<template #default="scope">
<div style="display: flex">
<el-select v-model="scope.row.diffVersion" :multiple-limit="2" multiple placeholder="比对版本选择">
<el-option v-for="item in scope.row.versionList" :key="item.version" :label="item.version"
:value="item.version"></el-option>
</el-select>
<el-button style="margin-left: 10px" type="danger" @click="diff(scope.row)">确认</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model="data.code.visible" title="代码比对" width="80%">
<el-row style="margin-bottom: 20px">
<el-button type="primary" @click="copyToClipboard(data.code.oldValue)">复制左侧</el-button>
<el-button type="danger" @click="copyToClipboard(data.code.newValue)">复制右侧</el-button>
</el-row>
<CodeDiff :file-name="data.code.fileName" :new-string="data.code.newValue" :old-string="data.code.oldValue"
output-format="side"/>
</el-dialog>
</template>
<style lang="scss">
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
.container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.7;
background-color: #f6f7fb;
.question-container {
width: 60%;
flex: 1;
min-width: 600px;
padding: 30px 10px;
box-shadow: #e2e2e2 0 0 3px 3px;
overflow: auto;
position: relative;
&::-webkit-scrollbar {
height: 6px;
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #e2e2e2;
border-radius: 0;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
.file-list-btn {
position: absolute;
left: 10px;
top: 10px;
background-color: #5453e2aa;
color: #ffffff;
padding: 2px 10px;
border-radius: 4px;
font-size: 14px;
user-select: none;
&:hover {
cursor: pointer;
background-color: #5453e2;
}
}
.single-question-reply {
margin: 20px 0;
font-size: 14px;
.question-content {
background-color: #e0dfff;
border-radius: 10px;
min-height: 40px;
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 10px;
justify-content: right;
word-break: break-all;
}
.reply-content-change {
background-color: #ffffff;
border-radius: 10px;
min-height: 40px;
display: flex;
align-items: center;
padding: 10px;
word-break: break-all;
}
}
}
.question-input {
width: 60%;
height: 120px;
min-width: 600px;
margin: 20px 0;
border: none;
outline: none;
padding: 10px;
resize: none;
box-shadow: #e2e2e2 0 0 3px 3px;
font-size: 18px;
&::-webkit-scrollbar {
height: 6px;
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #e2e2e2;
border-radius: 0;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
}
.code-diff-view {
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
</style>
2.0版本
添加代码复制功能、代码运行功能;结合了monaco编辑器和md-editor-v3的markdown预览组件
QuestionPanel.vue
<script setup>
import {onMounted, reactive, watch} from "vue";
import {MdPreview} from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
import {message} from "@/util";
const data = reactive({
questionText: "",
resultList: [],
});
function sendQuestion() {
if (isReplying) {
message("正在对话中", "warning");
return;
}
isReplying = true;
controlScroll = false;
data.resultList.push({
question: data.questionText,
reply: "",
});
getMaxHeight();
const messages = [];
for (let i = 0; i < data.resultList.length - 1; i++) {
messages.push({
role: "user",
content: data.resultList[i].question,
});
messages.push({
role: "assistant",
content: data.resultList[i].reply,
});
}
messages.push({
role: "user",
content: data.resultList[data.resultList.length - 1].question,
});
data.questionText = "";
fetch(new Request('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', {
method: 'post',
mode: "cors",
headers: {
Authorization: "Bearer sk-efb4867ada2d4a058d6bca481dbf51c4",
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "qwen-plus",
messages: messages,
stream: true,
}),
})).then(response => {
const reader = response.body.getReader();
read();
function read() {
reader.read().then(({done, value}) => {
if (done) {
message("对话完成", "success");
isReplying = false;
return;
}
const readContent = new Uint8Array(value);
const resText = Uint8ArrayToString(readContent);
const lastIndexOf = resText.lastIndexOf("data: {\"choices\":[{");
if (lastIndexOf > -1) {
const trimText = resText.substring(lastIndexOf);
const content = JSON.parse(trimText.replace("data: ", "").replace("data: [DONE]", "")).choices[0].delta.content;
data.resultList[data.resultList.length - 1].reply += content;
}
if (!controlScroll) {
getMaxHeight();
}
read();
}).catch(error => {
message(error, "error");
});
}
}).catch(error => {
message(error, "error");
});
}
function Uint8ArrayToString(fileData) {
const decoder = new TextDecoder('utf-8');
return decoder.decode(fileData);
}
let isReplying = false;
let controlScroll = false;
let logContainer;
function getMaxHeight() {
logContainer = document.getElementsByClassName("log-container")[0];
logContainer.scrollTo({
top: logContainer.scrollHeight,
behavior: "smooth"
});
}
onMounted(() => {
window.addEventListener("wheel", function () {
if (!controlScroll) {
controlScroll = true;
}
});
});
watch(() => data.questionText, () => {
if (data.questionText.length > 500) {
data.questionText = data.questionText.slice(0, 500);
}
});
</script>
<template>
<div class="panel-container">
<div class="log-container">
<template v-for="item in data.resultList" :key="item.id">
<div class="single-question-reply">
<p class="question-content">
<MdPreview v-model="item.question"/>
</p>
<p class="reply-content">
<MdPreview v-model="item.reply"/>
</p>
</div>
</template>
</div>
<textarea v-model="data.questionText" :placeholder="'按下 Alt + Enter 提问(最多输入500字)'" class="question-input"
@keydown.alt.enter="sendQuestion"/>
</div>
</template>
<style lang="scss" scoped>
.panel-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.7;
border-left: 1px solid #d1d1d1;
.log-container {
width: 100%;
flex: 1;
overflow: auto;
position: relative;
&::-webkit-scrollbar {
height: 6px;
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #e2e2e2;
border-radius: 0;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
.single-question-reply {
margin: 20px;
font-size: 14px;
.question-content {
background-color: #e0dfff;
border-radius: 10px;
min-height: 40px;
display: flex;
align-items: center;
margin-bottom: 10px;
justify-content: right;
word-break: break-all;
}
.reply-content {
background-color: #ffffff;
border-radius: 10px;
min-height: 40px;
display: flex;
align-items: center;
word-break: break-all;
}
}
}
.question-input {
width: 100%;
height: 120px;
margin-top: 5px;
border: none;
outline: none;
padding: 10px;
resize: none;
font-size: 18px;
&::-webkit-scrollbar {
height: 6px;
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: #e2e2e2;
border-radius: 0;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
}
</style>
后台代码运行功能
package com.boot.util;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import static com.boot.util.DefaultConfig.*;
import static com.boot.util.LanguageType.JAVASCRIPT;
/**
* @author bbyh
* @date 2023/2/27 0027 15:10
*/
@Slf4j
public class ExecUtil {
public static String exec(String language, String codeText) throws Exception {
File codeTextInputFile = new File(LANGUAGE_INPUT_FILE_MAP.get(language));
if (codeTextInputFile.exists()) {
try (FileOutputStream outputStream = new FileOutputStream(codeTextInputFile)) {
outputStream.write(codeText.getBytes(StandardCharsets.UTF_8));
}
}
if (judgeLinux()) {
int exeCode = execLinux(language);
logCodeTextRunLog(language, codeText, exeCode);
} else if (judgeWindows()) {
int exeCode = execWindows(language);
logCodeTextRunLog(language, codeText, exeCode);
}
String[] outputFileNames = {ERROR_OUTPUT_FILE, OUTPUT_FILE};
for (String outputFileName : outputFileNames) {
File outputFile = new File(outputFileName);
if (outputFile.exists()) {
try (FileInputStream inputStream = new FileInputStream(outputFile)) {
byte[] buf = new byte[1024 * 1024];
int read = inputStream.read(buf);
if (read >= 0) {
String result = new String(buf, 0, read);
String encoding = getEncoding(result);
if (!"".equals(encoding)) {
return new String(buf, 0, read, encoding);
}
}
}
}
}
return "";
}
private static void logCodeTextRunLog(String language, String codeText, int exeCode) {
log.info("运行时间:{}", new Date());
log.info("编程语言:{}", language);
log.info("运行代码:{}", codeText);
log.info("运行结果:{}", exeCode);
}
private static int execLinux(String language) throws Exception {
if (language.equals(JAVASCRIPT)) {
return execLinuxCommand("node " + JAVASCRIPT_INPUT_FILE + " > " + OUTPUT_FILE + " 2> " + ERROR_OUTPUT_FILE);
}
return 0;
}
private static int execLinuxCommand(String command) throws Exception {
ArrayList<String> commandList = new ArrayList<>();
commandList.add("/bin/sh");
commandList.add("-c");
commandList.add(command);
Process exec = Runtime.getRuntime().exec(commandList.toArray(new String[0]));
return exec.waitFor();
}
private static int execWindows(String language) throws Exception {
if (language.equals(JAVASCRIPT)) {
return execWindowsCommand("node " + JAVASCRIPT_INPUT_FILE + " > " + OUTPUT_FILE + " 2> " + ERROR_OUTPUT_FILE);
}
return 0;
}
private static int execWindowsCommand(String command) throws Exception {
Process exec = Runtime.getRuntime().exec("cmd /c " + command);
return exec.waitFor();
}
public static Boolean judgeWindows() {
return System.getProperty("os.name").toLowerCase().contains("windows");
}
public static Boolean judgeLinux() {
return System.getProperty("os.name").toLowerCase().contains("linux");
}
public static String getEncoding(String str) throws Exception {
String[] encodes = {"UTF-8", "GBK"};
for (String encode : encodes) {
if (str.equals(new String(str.getBytes(encode), encode))) {
return encode;
}
}
return "";
}
}
运行截图
1.0版本
1.0版本对话截图
1.0版本文件列表
1.0版本代码比对
2.0版本
2.0版本运行代码
2.0版本提问效果展示
源码下载
其中代码运行可参考该项目:在线代码编辑器