模拟cursor小工具(1.0版本和2.0版本)

文章说明

在体验了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版本提问效果展示
在这里插入图片描述

源码下载

模拟cursor小工具

其中代码运行可参考该项目:在线代码编辑器

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值