Procházet zdrojové kódy

[新增]AI: 思维导图

hhhero před 9 měsíci
rodič
revize
fa59b3c16a

+ 6 - 1
package.json

@@ -52,7 +52,12 @@
     "highlight.js": "^11.9.0",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
     "lodash-es": "^4.17.21",
+    "markdown-it": "^14.1.0",
     "marked": "^12.0.2",
     "marked": "^12.0.2",
+    "markmap-common": "^0.16.0",
+    "markmap-lib": "^0.16.1",
+    "markmap-toolbar": "^0.17.0",
+    "markmap-view": "^0.16.0",
     "min-dash": "^4.1.1",
     "min-dash": "^4.1.1",
     "mitt": "^3.0.1",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",
     "nprogress": "^0.2.0",
@@ -85,8 +90,8 @@
     "@types/qs": "^6.9.12",
     "@types/qs": "^6.9.12",
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
-    "@unocss/transformer-variant-group": "^0.58.5",
     "@unocss/eslint-config": "^0.57.4",
     "@unocss/eslint-config": "^0.57.4",
+    "@unocss/transformer-variant-group": "^0.58.5",
     "@vitejs/plugin-legacy": "^5.3.1",
     "@vitejs/plugin-legacy": "^5.3.1",
     "@vitejs/plugin-vue": "^5.0.4",
     "@vitejs/plugin-vue": "^5.0.4",
     "@vitejs/plugin-vue-jsx": "^3.1.0",
     "@vitejs/plugin-vue-jsx": "^3.1.0",

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 5622 - 332
pnpm-lock.yaml


+ 39 - 0
src/api/ai/mindmap/index.ts

@@ -0,0 +1,39 @@
+import { getAccessToken } from '@/utils/auth'
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+import { config } from '@/config/axios/config'
+
+export interface AiMindMapGenerateReqVO {
+  prompt: string
+}
+//
+
+export const AiMindMapApi = {
+  generateMindMap: ({
+    data,
+    onClose,
+    onMessage,
+    onError,
+    ctrl
+  }: {
+    data: AiMindMapGenerateReqVO
+    onMessage?: (res: any) => void
+    onError?: (...args: any[]) => void
+    onClose?: (...args: any[]) => void
+    ctrl: AbortController
+  }) => {
+    const token = getAccessToken()
+    return fetchEventSource(`${config.base_url}/ai/mind-map/generate-stream`, {
+      method: 'post',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      openWhenHidden: true,
+      body: JSON.stringify(data),
+      onmessage: onMessage,
+      onerror: onError,
+      onclose: onClose,
+      signal: ctrl.signal
+    })
+  }
+}

+ 75 - 0
src/views/ai/mindmap/index/components/Left.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="w-[350px] p-5 flex flex-col bg-[#f5f7f9]">
+    <h3 class="w-full h-full h-7 text-5 text-center leading-[28px] title">思维导图创作中心</h3>
+    <!--下面表单部分-->
+    <div class="flex-grow overflow-y-auto">
+      <div class="mt-[30ppx]">
+        <el-text tag="b">您的需求?</el-text>
+        <el-input
+          v-model="formData.prompt"
+          maxlength="1024"
+          rows="5"
+          class="w-100% mt-15px"
+          input-style="border-radius: 7px;"
+          placeholder="请输入提示词,让AI帮你完善"
+          show-word-limit
+          type="textarea"
+        />
+        <el-button
+          class="!w-full mt-[15px]"
+          type="primary"
+          :loading="isGenerating"
+          @click="emits('submit', formData)"
+          >智能生成思维导图</el-button
+        >
+      </div>
+      <div class="mt-[30px]">
+        <el-text tag="b">使用已有内容生成?</el-text>
+        <el-input
+          v-model="existPrompt"
+          maxlength="1024"
+          rows="5"
+          class="w-100% mt-15px"
+          input-style="border-radius: 7px;"
+          placeholder="例如:童话里的小屋应该是什么样子?"
+          show-word-limit
+          type="textarea"
+        />
+        <el-button
+          class="!w-full mt-[15px]"
+          type="primary"
+          @click="emits('directGenerate', existPrompt)"
+          :disabled="isGenerating"
+          >直接生成</el-button
+        >
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MindmapExitExample } from '@/views/ai/utils/constants'
+
+const emits = defineEmits(['submit', 'directGenerate'])
+defineProps<{
+  isGenerating: boolean
+}>()
+// 提交的提示词字段
+const formData = reactive({
+  prompt: ''
+})
+
+const existPrompt = ref(MindmapExitExample) // 已有的内容
+
+defineExpose({
+  setExistPrompt(e: string){ // 设置已有的内容,在生成结束的时候将结果赋值给该值
+    existPrompt.value = e
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.title {
+  color: var(--el-color-primary);
+}
+</style>

+ 169 - 0
src/views/ai/mindmap/index/components/Right.vue

@@ -0,0 +1,169 @@
+<template>
+  <el-card class="my-card h-full flex-grow">
+    <template #header
+      ><h3 class="m-0 px-7 shrink-0 flex items-center justify-between">
+        <span>思维导图预览</span>
+        <!-- 展示在右上角 -->
+        <el-button type="primary" v-show="isEnd" @click="downloadImage" size="small">
+          <template #icon>
+            <Icon icon="ph:copy-bold" />
+          </template>
+          下载图片
+        </el-button>
+      </h3></template
+    >
+
+    <div ref="contentRef" class="hide-scroll-bar h-full box-border">
+      <!--展示markdown的容器,最终生成的是html字符串,直接用v-html嵌入-->
+      <div v-if="isGenerating" ref="mdContainerRef" class="wh-full overflow-y-auto">
+        <div  class="flex flex-col items-center justify-center" v-html="html"></div>
+      </div>
+
+      <div ref="mindmapRef" class="wh-full">
+        <svg ref="svgRef" class="w-full" :style="{ height: `${contentAreaHeight}px` }" />
+        <div ref="toolBarRef" class="absolute bottom-[10px] right-5"></div>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { Markmap } from 'markmap-view'
+import { Transformer } from 'markmap-lib'
+import { Toolbar } from 'markmap-toolbar'
+import markdownit from 'markdown-it'
+
+const md = markdownit()
+const props = defineProps<{
+  mindmapResult: string // 生成结果
+  isEnd: boolean // 是否结束
+  isGenerating: boolean // 是否正在生成
+  isStart: boolean // 开始状态,开始时需要清除html
+}>()
+const contentRef = ref<HTMLDivElement>() // 右侧出来header以下的区域
+const mdContainerRef = ref<HTMLDivElement>() // markdown的容器,用来滚动到底下的
+const mindmapRef = ref<HTMLDivElement>() // 思维导图的容器
+const svgRef = ref<SVGElement>() // 思维导图的渲染svg
+const toolBarRef = ref<HTMLDivElement>() // 思维导图右下角的工具栏,缩放等
+const html = ref('') // 生成过程中的文本
+const contentAreaHeight = ref(0) // 生成区域的高度,出去header部分
+let markMap: Markmap | null = null
+const transformer = new Transformer()
+
+const message = useMessage()
+
+onMounted(() => {
+  contentAreaHeight.value = contentRef.value?.clientHeight || 0 // 获取区域高度
+  /** 初始化思维导图 **/
+  try {
+    markMap = Markmap.create(svgRef.value!)
+    const { el } = Toolbar.create(markMap)
+    toolBarRef.value?.append(el)
+    nextTick(update)
+  } catch (e) {
+    message.error('思维导图初始化失败')
+  }
+})
+
+watch(props, ({ mindmapResult, isGenerating, isEnd, isStart }) => {
+  // 开始生成的时候清空一下markdown的内容
+  if (isStart) {
+    html.value = ''
+  }
+  // 生成内容的时候使用markdown来渲染
+  if (isGenerating) {
+    html.value = md.render(mindmapResult)
+  }
+  if (isEnd) {
+    update()
+  }
+})
+
+const update = () => {
+  try {
+    const { root } = transformer.transform(processContent(props.mindmapResult))
+    markMap?.setData(root)
+    markMap?.fit()
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+const processContent = (text) => {
+  const arr: string[] = []
+  const lines = text.split('\n')
+  for (let line of lines) {
+    if (line.indexOf('```') !== -1) {
+      continue
+    }
+    line = line.replace(/([*_~`>])|(\d+\.)\s/g, '')
+    arr.push(line)
+  }
+  return arr.join('\n')
+}
+// download SVG to png file
+const downloadImage = () => {
+  const svgElement = mindmapRef.value
+  // 将 SVG 渲染到图片对象
+  const serializer = new XMLSerializer()
+  const source =
+    '<?xml version="1.0" standalone="no"?>\r\n' + serializer.serializeToString(svgRef.value!)
+  const image = new Image()
+  image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source)
+
+  // 将图片对象渲染
+  const canvas = document.createElement('canvas')
+  canvas.width = svgElement?.offsetWidth || 0
+  canvas.height = svgElement?.offsetHeight || 0
+  let context = canvas.getContext('2d')
+  context?.clearRect(0, 0, canvas.width, canvas.height)
+
+  image.onload = function () {
+    context?.drawImage(image, 0, 0)
+    const a = document.createElement('a')
+    a.download = 'ruoyi-mindmap.png'
+    a.href = canvas.toDataURL(`image/png`)
+    a.click()
+  }
+}
+
+defineExpose({
+  scrollBottom() {
+    mdContainerRef.value?.scrollTo(0, mdContainerRef.value?.scrollHeight)
+  }
+})
+</script>
+<style lang="scss" scoped>
+.hide-scroll-bar {
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    height: 0;
+  }
+}
+.my-card {
+  display: flex;
+  flex-direction: column;
+
+  :deep(.el-card__body) {
+    box-sizing: border-box;
+    flex-grow: 1;
+    overflow-y: auto;
+    padding: 0;
+    @extend .hide-scroll-bar;
+  }
+}
+// markmap的tool样式覆盖
+:deep(.markmap) {
+  width: 100%;
+}
+:deep(.mm-toolbar-brand) {
+  display: none;
+}
+:deep(.mm-toolbar) {
+  display: flex;
+  flex-direction: row;
+}
+</style>

+ 82 - 0
src/views/ai/mindmap/index/index.vue

@@ -0,0 +1,82 @@
+<template>
+  <div class="absolute top-0 left-0 right-0 bottom-0 flex">
+    <!--表单区域-->
+    <Left ref="leftRef" @submit="submit" @direct-generate="directGenerate" :is-generating="isGenerating" />
+    <!--右边生成思维导图区域-->
+    <Right
+      ref="rightRef"
+      :mindmapResult="mindmapResult"
+      :isEnd="isEnd"
+      :isGenerating="isGenerating"
+      :isStart="isStart"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import Left from './components/Left.vue'
+import Right from './components/Right.vue'
+import { AiMindMapApi, AiMindMapGenerateReqVO } from '@/api/ai/mindmap'
+import { MindmapExitExample } from '@/views/ai/utils/constants'
+
+defineOptions({
+  name: 'AIMindMap'
+})
+const ctrl = ref<AbortController>() // 请求控制
+const isGenerating = ref(false) // 是否正在生成思维导图
+const isStart = ref(false) // 开始生成,用来清空思维导图
+const isEnd = ref(true) // 用来判断结束的时候渲染思维导图
+const message = useMessage() // 消息提示
+
+const mindmapResult = ref('') // 生成思维导图结果
+
+const leftRef = ref<InstanceType<typeof Left>>() // 左边组件
+const rightRef = ref<InstanceType<typeof Right>>() // 右边组件
+
+onMounted(() => {
+  mindmapResult.value = MindmapExitExample
+})
+/** 使用已有内容直接生成 **/
+const directGenerate = (existPrompt: string) => {
+  isEnd.value = false // 先设置为false再设置为true,让子组建的watch能够监听到
+  mindmapResult.value = existPrompt
+  isEnd.value = true
+}
+/** 停止 stream 生成 */
+const stopStream = () => {
+  isGenerating.value = false
+  isStart.value = false
+  ctrl.value?.abort()
+}
+const submit = (data: AiMindMapGenerateReqVO) => {
+  isGenerating.value = true
+  isStart.value = true
+  isEnd.value = false
+  ctrl.value = new AbortController() // 请求控制赋值
+  mindmapResult.value = '' // 清空生成数据
+  AiMindMapApi.generateMindMap({
+    data,
+    onMessage:async (res) => {
+      const { code, data, msg } = JSON.parse(res.data)
+      if (code !== 0) {
+        message.alert(`生成思维导图异常! ${msg}`)
+        stopStream()
+        return
+      }
+      mindmapResult.value = mindmapResult.value + data
+      await nextTick()
+      rightRef.value?.scrollBottom()
+    },
+    onClose() {
+      isEnd.value = true
+      leftRef.value?.setExistPrompt(mindmapResult.value)
+      stopStream()
+    },
+    onError(err) {
+      console.error('生成思维导图失败', err)
+      stopStream()
+    },
+    ctrl: ctrl.value
+  })
+}
+</script>

+ 61 - 0
src/views/ai/utils/constants.ts

@@ -414,3 +414,64 @@ export const WriteExample = {
     data: '您的请假申请已收悉,经核实和考虑,暂时无法批准您的请假申请。\n\n如有特殊情况或紧急事务,请及时与我联系。\n\n祝工作顺利。\n\n谢谢。'
     data: '您的请假申请已收悉,经核实和考虑,暂时无法批准您的请假申请。\n\n如有特殊情况或紧急事务,请及时与我联系。\n\n祝工作顺利。\n\n谢谢。'
   }
   }
 }
 }
+/** 思维导图已有内容生成示例 **/
+export const MindmapExitExample = `# Java 技术栈
+
+## 核心技术
+### Java SE
+### Java EE
+
+## 框架
+### Spring
+#### Spring Boot
+#### Spring MVC
+#### Spring Data
+### Hibernate
+### MyBatis
+
+## 构建工具
+### Maven
+### Gradle
+
+## 版本控制
+### Git
+### SVN
+
+## 测试工具
+### JUnit
+### Mockito
+### Selenium
+
+## 应用服务器
+### Tomcat
+### Jetty
+### WildFly
+
+## 数据库
+### MySQL
+### PostgreSQL
+### Oracle
+### MongoDB
+
+## 消息队列
+### Kafka
+### RabbitMQ
+### ActiveMQ
+
+## 微服务
+### Spring Cloud
+### Dubbo
+
+## 容器化
+### Docker
+### Kubernetes
+
+## 云服务
+### AWS
+### Azure
+### Google Cloud
+
+## 开发工具
+### IntelliJ IDEA
+### Eclipse
+### Visual Studio Code`

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů