Demo:初步接入AI生成思维导图和AI对话能力

This commit is contained in:
街角小林
2025-02-18 17:29:58 +08:00
parent 09e393b174
commit ad63b4c72c
27 changed files with 1317 additions and 174 deletions

View File

@@ -8,7 +8,8 @@
"lint": "vue-cli-service lint",
"buildLibrary": "node ./scripts/updateVersion.js && vue-cli-service build --mode library --target lib --name simpleMindMap ../simple-mind-map/full.js --dest ../simple-mind-map/dist && esbuild ../simple-mind-map/full.js --bundle --external:buffer --format=esm --outfile=../simple-mind-map/dist/simpleMindMap.esm.js && esbuild ../simple-mind-map/full.js --bundle --minify --external:buffer --format=esm --outfile=../simple-mind-map/dist/simpleMindMap.esm.min.js",
"format": "prettier --write src/* src/*/* src/*/*/* src/*/*/*/*",
"createNodeImageList": "node ./scripts/createNodeImageList.js"
"createNodeImageList": "node ./scripts/createNodeImageList.js",
"ai:serve": "node ./scripts/ai.js"
},
"dependencies": {
"@toast-ui/editor": "^3.1.5",

View File

@@ -1,8 +1,8 @@
const express = require('express')
const http = require('http')
const { pipeline } = require('stream')
const port = 3000
const baseUrl = 'http://ark.cn-beijing.volces.com'
const port = 3456
// 起个服务
const app = express()
@@ -19,63 +19,90 @@ app.use((req, res, next) => {
// 监听对话请求
app.get('/ai/test', (req, res) => {
res.send('/ai/test').end()
res
.json({
code: 0,
data: null,
msg: '连接成功'
})
.end()
})
app.post('/ai/chat', (req, res) => {
try {
const { a: apiKey, b: model, messages } = req.body
// 设置SSE响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
// res.append('Content-Type', 'text/event-stream')
// res.append('Cache-Control', 'no-cache')
// res.append('Connection', 'keep-alive')
res.send(1).end(200)
} catch (error) {
console.log(error)
const { api, method, headers, data } = req.body
// 创建代理请求
const proxyReq = http.request(
api,
{
method: method || 'POST',
headers: {
...headers
}
},
proxyRes => {
// 检查目标服务响应状态
if (proxyRes.statusCode !== 200) {
proxyRes.resume()
return res.status(proxyRes.statusCode).end()
}
// 使用双向流管道
const pipelinePromise = new Promise(resolve => {
pipeline(proxyRes, res, err => {
// 过滤客户端主动断开的情况
if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
console.error('Pipeline error:', err)
}
resolve()
})
})
// 处理流结束
proxyRes.on('end', () => {
if (!res.writableEnded) {
res.end()
}
})
return pipelinePromise
}
)
// 错误处理增强
const handleError = err => {
if (!res.headersSent) {
res.status(502).end('Bad Gateway')
}
cleanupStreams()
}
// 模拟发送数据
// const intervalId = setInterval(() => {
// res.send(`data: ${new Date().toISOString()}\n\n`)
// }, 1000)
// 流清理函数
const cleanupStreams = () => {
proxyReq.destroy()
res.destroy()
}
// // 监听客户端断开连接
// req.on('close', () => {
// console.log('Client disconnected.')
// clearInterval(intervalId)
// res.end()
// })
// 事件监听器
proxyReq.on('error', handleError)
res.on('error', handleError)
// const aiReq = http.request(
// baseUrl + '/api/v3/chat/completions',
// {
// method: 'POST',
// headers: {
// Authorization: 'Bearer ' + apiKey
// }
// },
// aiRes => {
// aiRes.on('data', chunk => {
// console.log(`BODY: ${chunk}`)
// res.send(chunk)
// })
// aiRes.on('end', () => {
// console.log('No more data in response.')
// res.end()
// })
// }
// )
// const postData = {
// model,
// messages,
// stream: true
// }
// aiReq.write(JSON.stringify(postData))
// aiReq.end()
// 处理客户端提前断开
req.on('close', () => {
if (!res.writableFinished) {
console.log('Client disconnected prematurely')
cleanupStreams()
}
})
proxyReq.write(JSON.stringify(data))
proxyReq.end()
})
// res.writeHead(404)
// res.end()
app.listen(port, () => {
console.log(`app listening on port ${port}`)
})

View File

@@ -7,99 +7,7 @@
<script>
export default {
name: 'App',
components: {},
data() {
return {
currentChunk: '',
controller: new AbortController(),
content: ''
}
},
created() {
this.getData()
},
methods: {
async getData() {
const res = await this.postMsg()
return
const decoder = new TextDecoder()
while (1) {
const { done, value } = await res.read()
if (done) {
return
}
// 拿到当前切片的数据
const text = decoder.decode(value)
// 处理切片数据
let chunk = this.handleChunkData(text)
// 判断是否有不完整切片,如果有,合并下一次处理,没有则获取数据
if (this.currentChunk) continue
const list = chunk
.split('\n')
.filter(item => {
return !!item
})
.map(item => {
return JSON.parse(item.replace(/^data:/, ''))
})
list.forEach(item => {
this.content += item.choices
.map(item2 => {
return item2.delta.content
})
.join('')
console.log(this.content)
})
}
},
async postMsg() {
const res = await fetch('http://localhost:3000/ai/chat', {
signal: this.controller.signal,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
a: '227d32b1-08aa-413d-9c12-6832c34bc59b',
b: 'ep-20250214110611-hhf9z',
messages: [
{
role: 'user',
content:
'帮我写一个【2025年前端学习计划】需要以Markdown格式返回因为我要导入思维导图软件进行使用只需返回内容即可。'
}
]
})
})
if (res.status && res.status !== 200) {
return false
}
return res.body.getReader()
},
handleChunkData(chunk) {
chunk = chunk.trim()
// 如果存在上一个切片
if (this.currentChunk) {
chunk = this.currentChunk + chunk
this.currentChunk = ''
}
// 如果存在done,认为是完整切片且是最后一个切片
if (chunk.includes('[DONE]')) {
return chunk
}
// 最后一个字符串不为},则默认切片不完整,保存与下次拼接使用(这种方法不严谨,但已经能解决大部分场景的问题)
if (chunk[chunk.length - 1] !== '}') {
this.currentChunk = chunk
}
return chunk
},
stop() {
this.controller.abort()
this.controller = new AbortController()
}
}
components: {}
}
</script>

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 2479351 */
src: url('iconfont.woff2?t=1739152990179') format('woff2'),
url('iconfont.woff?t=1739152990179') format('woff'),
url('iconfont.ttf?t=1739152990179') format('truetype');
src: url('iconfont.woff2?t=1739843331607') format('woff2'),
url('iconfont.woff?t=1739843331607') format('woff'),
url('iconfont.ttf?t=1739843331607') format('truetype');
}
.iconfont {
@@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.iconAIshengcheng:before {
content: "\e6b5";
}
.iconprinting:before {
content: "\ea28";
}

View File

@@ -444,6 +444,11 @@ export const sidebarTriggerList = [
value: 'setting',
icon: 'iconshezhi'
},
{
name: 'AI',
value: 'ai',
icon: 'iconAIshengcheng'
},
{
name: 'ShortcutKey',
value: 'shortcutKey',

View File

@@ -534,6 +534,11 @@ export const sidebarTriggerList = [
value: 'outline',
icon: 'iconfuhao-dagangshu'
},
{
name: 'AI',
value: 'ai',
icon: 'iconAIshengcheng'
},
{
name: '设置',
value: 'setting',

View File

@@ -439,6 +439,11 @@ export const sidebarTriggerList = [
value: 'outline',
icon: 'iconfuhao-dagangshu'
},
{
name: 'AI',
value: 'ai',
icon: 'iconAIshengcheng'
},
{
name: '設置',
value: 'setting',

View File

@@ -134,7 +134,8 @@ export default {
expandNodeChild: 'Expand all sub nodes',
unExpandNodeChild: 'Un expand all sub nodes',
addToDo: 'Add toDo',
removeToDo: 'Remove toDo'
removeToDo: 'Remove toDo',
aiCreate: 'AI Continuation'
},
count: {
words: 'Words',
@@ -329,7 +330,8 @@ export default {
newFileTip:
'Please export the currently edited file before creating a new one, Beware of content loss',
openFileTip:
'Please export the currently edited file before opening it, Beware of content loss'
'Please export the currently edited file before opening it, Beware of content loss',
ai: 'AI'
},
edit: {
newFeatureNoticeTitle: 'New feature reminder',

View File

@@ -133,7 +133,8 @@ export default {
expandNodeChild: '展开所有下级节点',
unExpandNodeChild: '收起所有下级节点',
addToDo: '添加待办',
removeToDo: '删除待办'
removeToDo: '删除待办',
aiCreate: 'AI续写'
},
count: {
words: '字数',
@@ -323,7 +324,8 @@ export default {
creatingTip: '正在创建文件',
directory: '目录',
newFileTip: '新建文件前请先导出当前编辑的文件,谨防内容丢失',
openFileTip: '打开文件前请先导出当前编辑的文件,谨防内容丢失'
openFileTip: '打开文件前请先导出当前编辑的文件,谨防内容丢失',
ai: 'AI'
},
edit: {
newFeatureNoticeTitle: '新特性提醒',
@@ -412,5 +414,8 @@ export default {
nodeTagStyle: {
placeholder: '请输入标签内容',
delete: '删除此标签'
},
ai: {
chatTitle: 'AI对话'
}
}

View File

@@ -134,7 +134,8 @@ export default {
expandNodeChild: '展開所有下級節點',
unExpandNodeChild: '收起所有下級節點',
addToDo: '添加待辦',
removeToDo: '刪除待辦'
removeToDo: '刪除待辦',
aiCreate: 'AI續寫'
},
count: {
words: '字數',
@@ -323,7 +324,8 @@ export default {
creatingTip: '正在建立檔案',
directory: '目錄',
newFileTip: '新增檔案前,請先匯出目前編輯的檔案,以免內容遺失',
openFileTip: '開啟檔案前,請先匯出目前編輯的檔案,以免內容遺失'
openFileTip: '開啟檔案前,請先匯出目前編輯的檔案,以免內容遺失',
ai: 'AI'
},
edit: {
newFeatureNoticeTitle: '新功能提醒',

View File

@@ -35,4 +35,3 @@ if (window.takeOverApp) {
} else {
initApp()
}

View File

@@ -0,0 +1,290 @@
<template>
<Sidebar ref="sidebar" :title="$t('ai.chatTitle')">
<div class="aiChatBox" :class="{ isDark: isDark }">
<div class="chatHeader">
<el-button size="mini" @click="clear">清空记录</el-button>
</div>
<div class="chatResBox customScrollbar" ref="chatResBoxRef">
<div
class="chatItem"
v-for="item in chatList"
:key="item.id"
:class="[item.type]"
>
<div class="chatItemInner" v-if="item.type === 'user'">
<div class="avatar">
<span class="icon el-icon-user"></span>
</div>
<div class="content">{{ item.content }}</div>
</div>
<div class="chatItemInner" v-else-if="item.type === 'ai'">
<div class="avatar">
<span class="icon iconfont iconAIshengcheng"></span>
</div>
<div class="content" v-html="item.content"></div>
</div>
</div>
</div>
<div class="chatInputBox">
<textarea
v-model="text"
class="customScrollbar"
placeholder="Enter 发送Shift + Enter 换行。"
@keydown.enter.prevent
@keyup.enter.prevent="send"
></textarea>
<el-button class="btn" size="mini" @click="send" :loading="isCreating"
>发送</el-button
>
</div>
</div>
</Sidebar>
</template>
<script>
import Sidebar from './Sidebar'
import { mapState, mapMutations } from 'vuex'
import { createUid } from 'simple-mind-map/src/utils'
import MarkdownIt from 'markdown-it'
let md = null
export default {
components: {
Sidebar
},
data() {
return {
text: '',
chatList: [],
isCreating: false
}
},
computed: {
...mapState({
isDark: state => state.localConfig.isDark,
activeSidebar: state => state.activeSidebar
})
},
watch: {
activeSidebar(val) {
if (val === 'ai') {
this.$refs.sidebar.show = true
} else {
this.$refs.sidebar.show = false
}
}
},
created() {},
beforeDestroy() {},
methods: {
send() {
if (this.isCreating) return
const text = this.text.trim()
if (!text) {
return
}
this.text = ''
this.chatList.push({
id: createUid(),
type: 'user',
content: text
})
this.chatList.push({
id: createUid(),
type: 'ai',
content: ''
})
this.isCreating = true
this.$bus.$emit(
'ai_chat',
text,
res => {
console.log(res)
if (!md) {
md = new MarkdownIt()
}
this.chatList[this.chatList.length - 1].content = md.render(res)
this.$refs.chatResBoxRef.scrollTop = this.$refs.chatResBoxRef.scrollHeight
},
() => {
this.isCreating = false
},
() => {
this.isCreating = false
}
)
},
clear() {
this.chatList = []
}
}
}
</script>
<style lang="less" scoped>
.aiChatBox {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
&.isDark {
}
.chatHeader {
height: 50px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 12px;
}
.chatResBox {
width: 100%;
height: 100%;
padding: 0 12px;
margin: 12px 0;
overflow-y: auto;
overflow-x: hidden;
.chatItem {
margin-bottom: 20px;
border: 1px solid;
position: relative;
border-radius: 10px;
&:last-of-type {
margin-bottom: 0;
}
&.ai {
border-color: #409eff;
.chatItemInner {
.avatar {
border-color: #409eff;
left: -12px;
top: -12px;
.icon {
color: #409eff;
}
}
}
}
&.user {
border-color: #f56c6c;
.chatItemInner {
.avatar {
border-color: #f56c6c;
right: -12px;
top: -12px;
.icon {
color: #f56c6c;
}
}
}
}
.chatItemInner {
width: 100%;
padding: 12px;
.avatar {
width: 30px;
height: 30px;
border: 1px solid;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: absolute;
background-color: #fff;
.icon {
font-size: 18px;
font-weight: bold;
}
}
/deep/ .content {
width: 100%;
overflow: hidden;
color: #3f4a54;
font-size: 14px;
line-height: 1.5;
p {
margin-bottom: 12px;
&:last-of-type {
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 24px;
margin-bottom: 16px;
}
code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace;
}
pre {
padding: 12px;
background-color: rgba(175, 184, 193, 0.2);
code {
background-color: transparent;
padding: 0;
overflow: hidden;
}
}
}
}
}
}
.chatInputBox {
flex-shrink: 0;
width: 100%;
height: 150px;
border-top: 1px solid #e8e8e8;
position: relative;
textarea {
width: 100%;
height: 100%;
outline: none;
padding: 12px;
border: none;
}
.btn {
position: absolute;
right: 12px;
bottom: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<el-dialog
class="aiConfigDialog"
title="AI配置"
:visible.sync="aiConfigDialogVisible"
width="550px"
append-to-body
>
<div class="aiConfigBox">
<el-form
:model="ruleForm"
:rules="rules"
ref="ruleFormRef"
label-width="100px"
>
<p class="title">火山方舟大模型配置</p>
<p class="desc">
目前仅支持火山方舟大模型需要自行去获取key详细操作步骤见<a
href=""
>教程</a
>
</p>
<el-form-item label="API Key" prop="key">
<el-input v-model="ruleForm.key"></el-input>
</el-form-item>
<el-form-item label="推理接入点" prop="model">
<el-input v-model="ruleForm.model"></el-input>
</el-form-item>
<el-form-item label="接口" prop="api">
<el-input v-model="ruleForm.api"></el-input>
</el-form-item>
<el-form-item label="请求方式" prop="method">
<el-select v-model="ruleForm.method" placeholder="请选择">
<el-option key="POST" label="POST" value="POST"></el-option>
<el-option key="GET" label="GET" value="GET"></el-option>
</el-select>
</el-form-item>
<p class="title">思绪思维导图客户端配置</p>
<el-form-item label="端口" prop="port">
<el-input v-model="ruleForm.port"></el-input>
</el-form-item>
</el-form>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="cancel">取消</el-button>
<el-button type="primary" @click="confirm">确认</el-button>
</div>
</el-dialog>
</template>
<script>
import { mapState, mapMutations } from 'vuex'
export default {
model: {
prop: 'visible',
event: 'change'
},
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
aiConfigDialogVisible: false,
ruleForm: {
api: '',
key: '',
model: '',
port: '',
method: ''
},
rules: {
api: [{ required: true, message: '请输入接口', trigger: 'blur' }],
key: [{ required: true, message: '请输入API Key', trigger: 'blur' }],
model: [
{ required: true, message: '请输入推理接入点', trigger: 'blur' }
],
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
method: [{ required: true, message: '请选择', trigger: 'blur' }]
}
}
},
computed: {
...mapState(['aiConfig'])
},
watch: {
visible(val) {
this.aiConfigDialogVisible = val
},
aiConfigDialogVisible(val, oldVal) {
if (!val && oldVal) {
this.close()
}
}
},
created() {
this.initFormData()
},
methods: {
...mapMutations(['setLocalConfig']),
close() {
this.$emit('change', false)
},
initFormData() {
Object.keys(this.aiConfig).forEach(key => {
this.ruleForm[key] = this.aiConfig[key]
})
},
cancel() {
this.close()
this.initFormData()
},
confirm() {
this.$refs.ruleFormRef.validate(valid => {
if (valid) {
this.close()
this.setLocalConfig({
...this.ruleForm
})
this.$message.success('配置保存成功')
}
})
}
}
}
</script>
<style lang="less" scoped>
.aiConfigDialog {
/deep/ .el-dialog__body {
padding: 12px 20px;
}
.aiConfigBox {
.title {
margin-bottom: 12px;
font-weight: bold;
}
.desc {
margin-bottom: 12px;
padding-left: 12px;
border-left: 5px solid #ccc;
}
}
}
</style>

View File

@@ -0,0 +1,546 @@
<template>
<div>
<!-- 客户端连接失败提示弹窗 -->
<el-dialog
class="clientTipDialog"
title="客户端连接失败提示"
:visible.sync="clientTipDialogVisible"
width="400px"
append-to-body
>
<div class="tipBox">
<p>客户端连接失败请检查</p>
<p>
1.是否安装了思绪思维导图客户端如果没有请点此安装<a
href="https://pan.baidu.com/s/1huasEbKsGNH2Af68dvWiOg?pwd=3bp3"
>百度网盘</a
><a href="https://github.com/wanglin2/mind-map/releases">Github</a>
</p>
<p>2.如果安装了客户端请确认是否打开了客户端</p>
<P>3.如果已经安装并启动了那么可以尝试关闭然后重新启动</P>
<p>
完成以上步骤后可点击<el-button size="small" @click="testConnect"
>连接检测</el-button
>
</p>
</div>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="clientTipDialogVisible = false"
>关闭</el-button
>
</div>
</el-dialog>
<!-- ai内容输入弹窗 -->
<el-dialog
class="createDialog"
title="一键生成思维导图"
:visible.sync="createDialogVisible"
width="450px"
append-to-body
>
<div class="inputBox">
<el-input
type="textarea"
:rows="5"
placeholder="请输入一个主题AI会根据你的主题生成思维导图杭州周末出游计划。"
v-model="aiInput"
>
</el-input>
<div class="tip warning">
重要提示一键生成会覆盖现有数据建议先导出当前数据
</div>
<div class="tip">
想要修改AI配置请点击<el-button
size="small"
@click="aiConfigDialogVisible = true"
>修改配置</el-button
>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="closeAiCreateDialog">取消</el-button>
<el-button type="primary" @click="doAiCreate">确认</el-button>
</div>
</el-dialog>
<!-- ai生成中添加一个透明层防止期间用户进行操作 -->
<div
class="aiCreatingMask"
ref="aiCreatingMaskRef"
v-show="aiCreatingMaskVisible"
>
<el-button type="warning" class="btn" @click="stopCreate"
>停止生成</el-button
>
</div>
<AiConfigDialog v-model="aiConfigDialogVisible"></AiConfigDialog>
</div>
</template>
<script>
import Ai from '@/utils/ai'
import { transformMarkdownTo } from 'simple-mind-map/src/parse/markdownTo'
import {
createUid,
isUndef,
checkNodeOuter,
getStrWithBrFromHtml
} from 'simple-mind-map/src/utils'
import { mapState } from 'vuex'
import AiConfigDialog from './AiConfigDialog.vue'
export default {
components: {
AiConfigDialog
},
props: {
mindMap: {
type: Object
}
},
data() {
return {
aiInstance: null,
isAiCreating: false,
aiCreatingContent: '',
isLoopRendering: false,
uidMap: {},
latestUid: '',
clientTipDialogVisible: false,
createDialogVisible: false,
aiInput: '',
aiCreatingMaskVisible: false,
aiConfigDialogVisible: false,
mindMapDataCache: '',
beingAiCreateNodeUid: ''
}
},
computed: {
...mapState(['aiConfig'])
},
created() {
this.$bus.$on('ai_create_all', this.aiCrateAll)
this.$bus.$on('ai_create_part', this.aiCreatePart)
this.$bus.$on('ai_chat', this.aiChat)
},
mounted() {
document.body.appendChild(this.$refs.aiCreatingMaskRef)
},
beforeDestroy() {
this.$bus.$off('ai_create_all', this.aiCrateAll)
this.$bus.$off('ai_create_part', this.aiCreatePart)
this.$bus.$off('ai_chat', this.aiChat)
},
methods: {
// 客户端连接检测
async testConnect() {
try {
await fetch(`http://localhost:${this.aiConfig.port}/ai/test`, {
method: 'GET'
})
this.$message.success('连接成功')
this.clientTipDialogVisible = false
this.createDialogVisible = true
} catch (error) {
console.log(error)
this.$message.error('连接失败')
}
},
// 检测ai是否可用
async aiTest() {
// 检查配置
if (
!(
this.aiConfig.api &&
this.aiConfig.key &&
this.aiConfig.model &&
this.aiConfig.port
)
) {
this.aiConfigDialogVisible = true
return
}
// 检查连接
await fetch(`http://localhost:${this.aiConfig.port}/ai/test`, {
method: 'GET'
})
},
// AI生成整体
async aiCrateAll() {
try {
await this.aiTest()
this.createDialogVisible = true
} catch (error) {
console.log(error)
this.clientTipDialogVisible = true
}
},
// 关闭ai内容输入弹窗
closeAiCreateDialog() {
this.createDialogVisible = false
this.aiInput = ''
},
// 确认生成
doAiCreate() {
const aiInputText = this.aiInput.trim()
if (!aiInputText) {
this.$message.warning('请输入内容')
return
}
this.closeAiCreateDialog()
this.aiCreatingMaskVisible = true
// 发起请求
this.isAiCreating = true
this.aiInstance = new Ai({
port: this.aiConfig.port
})
this.aiInstance.init('huoshan', this.aiConfig)
this.mindMap.renderer.setRootNodeCenter()
this.mindMap.setData(null)
this.aiInstance.request(
{
messages: [
{
role: 'user',
content: `帮我写一个【${aiInputText}需要以Markdown格式返回并且只能使用Markdown的标题和无序列表两种语法可以支持多层嵌套。只需返回内容即可。`
}
]
},
content => {
if (content && /\n$/.test(content)) {
this.aiCreatingContent = content
}
this.loopRenderOnAiCreating()
},
content => {
this.aiCreatingContent = content
this.resetOnAiCreatingStop()
this.$message.success('AI生成完成')
},
() => {
this.resetOnAiCreatingStop()
this.resetOnRenderEnd()
this.$message.error('生成失败')
}
)
},
// AI请求完成或出错后需要复位的数据
resetOnAiCreatingStop() {
this.aiCreatingMaskVisible = false
this.isAiCreating = false
this.aiInstance = null
},
// 渲染结束后需要复位的数据
resetOnRenderEnd() {
this.isLoopRendering = false
this.uidMap = {}
this.aiCreatingContent = ''
this.mindMapDataCache = ''
this.beingAiCreateNodeUid = ''
},
// 停止生成
stopCreate() {
this.aiInstance.stop()
this.isAiCreating = false
this.aiCreatingMaskVisible = false
this.$message.success('已停止生成')
},
// 轮询进行渲染
loopRenderOnAiCreating() {
if (!this.aiCreatingContent.trim() || this.isLoopRendering) return
this.isLoopRendering = true
const treeData = transformMarkdownTo(this.aiCreatingContent)
this.addUid(treeData)
let lastTreeData = JSON.stringify(treeData)
// 在当前渲染完成时再进行下一次渲染
const onRenderEnd = () => {
// 处理超出画布的节点
this.checkNodeOuter()
// 如果生成结束数据渲染完毕,那么解绑事件
if (!this.isAiCreating && !this.aiCreatingContent) {
this.mindMap.off('node_tree_render_end', onRenderEnd)
this.latestUid = ''
return
}
const treeData = transformMarkdownTo(this.aiCreatingContent)
this.addUid(treeData)
// 正在生成中
if (this.isAiCreating) {
// 如果和上次数据一样则不触发重新渲染
const curTreeData = JSON.stringify(treeData)
if (curTreeData === lastTreeData) {
setTimeout(() => {
onRenderEnd()
}, 500)
return
}
lastTreeData = curTreeData
this.mindMap.updateData(treeData)
} else {
// 已经生成结束
// 还要触发一遍渲染,否则会丢失数据
this.mindMap.updateData(treeData)
this.resetOnRenderEnd()
}
}
this.mindMap.on('node_tree_render_end', onRenderEnd)
this.mindMap.setData(treeData)
},
// 处理超出画布的节点
checkNodeOuter() {
if (this.latestUid) {
const latestNode = this.mindMap.renderer.findNodeByUid(this.latestUid)
if (latestNode) {
const { isOuter, offsetLeft, offsetTop } = checkNodeOuter(
this.mindMap,
latestNode,
100,
100
)
if (isOuter) {
this.mindMap.view.translateXY(offsetLeft, offsetTop)
}
}
}
},
// 给AI生成的数据添加uid
addUid(data) {
const checkRepeatUidMap = {}
const walk = (node, pUid = '') => {
if (!node.data) {
node.data = {}
}
if (isUndef(node.data.uid)) {
// 根据pUid+文本内容来复用上一次生成数据的uid
const key = pUid + '-' + node.data.text
node.data.uid = this.uidMap[key] || createUid()
// 当前uid和之前的重复那么重新生成一个。这种情况很少但是以防万一
if (checkRepeatUidMap[node.data.uid]) {
node.data.uid = createUid()
}
this.latestUid = this.uidMap[key] = node.data.uid
checkRepeatUidMap[node.data.uid] = true
}
if (node.children && node.children.length > 0) {
node.children.forEach(child => {
walk(child, node.data.uid)
})
}
}
walk(data)
},
// AI生成部分
async aiCreatePart(node) {
try {
await this.aiTest()
this.beingAiCreateNodeUid = node.getData('uid')
const currentMindMapData = this.mindMap.getData()
this.mindMapDataCache = JSON.stringify(currentMindMapData)
this.aiCreatingMaskVisible = true
// 发起请求
this.isAiCreating = true
this.aiInstance = new Ai({
port: this.aiConfig.port
})
this.aiInstance.init('huoshan', this.aiConfig)
this.aiInstance.request(
{
messages: [
{
role: 'user',
content: `我有一个主题为【${getStrWithBrFromHtml(
currentMindMapData.data.text
)}】的思维导图,帮我续写其中一个内容为【${getStrWithBrFromHtml(
node.getData('text')
)}】的节点的下级内容需要以Markdown格式返回并且只能使用Markdown的标题和无序列表两种语法可以支持多层嵌套。只需返回内容即可。`
}
]
},
content => {
if (content && /\n$/.test(content)) {
this.aiCreatingContent = content
}
this.loopRenderOnAiCreatingPart()
},
content => {
this.aiCreatingContent = content
this.resetOnAiCreatingStop()
this.$message.success('AI生成完成')
},
() => {
this.resetOnAiCreatingStop()
this.resetOnRenderEnd()
this.$message.error('生成失败')
}
)
} catch (error) {
console.log(error)
}
},
// 将生成的数据添加到指定节点上
addToTargetNode(newChildren = []) {
const initData = JSON.parse(this.mindMapDataCache)
const walk = node => {
if (node.data.uid === this.beingAiCreateNodeUid) {
if (!node.children) {
node.children = []
}
node.children.push(...newChildren)
return
}
if (node.children && node.children.length > 0) {
node.children.forEach(child => {
walk(child)
})
}
}
walk(initData)
return initData
},
// 轮询进行部分渲染
loopRenderOnAiCreatingPart() {
if (!this.aiCreatingContent.trim() || this.isLoopRendering) return
this.isLoopRendering = true
const partData = transformMarkdownTo(this.aiCreatingContent)
this.addUid(partData)
let lastPartData = JSON.stringify(partData)
const treeData = this.addToTargetNode(partData.children || [])
// 在当前渲染完成时再进行下一次渲染
const onRenderEnd = () => {
// 处理超出画布的节点
this.checkNodeOuter()
// 如果生成结束数据渲染完毕,那么解绑事件
if (!this.isAiCreating && !this.aiCreatingContent) {
this.mindMap.off('node_tree_render_end', onRenderEnd)
this.latestUid = ''
return
}
const partData = transformMarkdownTo(this.aiCreatingContent)
this.addUid(partData)
const treeData = this.addToTargetNode(partData.children || [])
if (this.isAiCreating) {
// 如果和上次数据一样则不触发重新渲染
const curPartData = JSON.stringify(partData)
if (curPartData === lastPartData) {
setTimeout(() => {
onRenderEnd()
}, 500)
return
}
lastPartData = curPartData
this.mindMap.updateData(treeData)
} else {
this.mindMap.updateData(treeData)
this.resetOnRenderEnd()
}
}
this.mindMap.on('node_tree_render_end', onRenderEnd)
// 因为是续写所以首次也直接使用updateData方法渲染
this.mindMap.updateData(treeData)
},
// AI对话
async aiChat(text, progress = () => {}, end = () => {}, err = () => {}) {
try {
await this.aiTest()
// 发起请求
this.isAiCreating = true
this.aiInstance = new Ai({
port: this.aiConfig.port
})
this.aiInstance.init('huoshan', this.aiConfig)
this.aiInstance.request(
{
messages: [
{
role: 'user',
content: text
}
]
},
content => {
progress(content)
},
content => {
end(content)
},
error => {
err(error)
}
)
} catch (error) {
console.log(error)
}
}
}
}
</script>
<style lang="less" scoped>
.clientTipDialog,
.createDialog {
/deep/ .el-dialog__body {
padding: 12px 20px;
}
}
.tipBox {
p {
margin-bottom: 12px;
a {
color: #409eff;
}
}
}
.inputBox {
.tip {
margin-top: 12px;
&.warning {
color: #f56c6c;
}
}
}
.aiCreatingMask {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 99999;
background-color: transparent;
.btn {
position: absolute;
left: 50%;
top: 100px;
transform: translateX(-50%);
}
}
</style>

View File

@@ -995,8 +995,6 @@ export default {
this.$bus.$off('setData', this.onSetData)
},
methods: {
...mapMutations(['setLocalConfig']),
onSetData() {
if (this.activeSidebar !== 'baseStyle') return
setTimeout(() => {

View File

@@ -140,6 +140,10 @@
<div class="item" @click="exec('EXPORT_CUR_NODE_TO_PNG')">
<span class="name">{{ $t('contextmenu.exportNodeToPng') }}</span>
</div>
<div class="splitLine"></div>
<div class="item" @click="aiCreate">
<span class="name">{{ $t('contextmenu.aiCreate') }}</span>
</div>
</template>
<template v-if="type === 'svg'">
<div class="item" @click="exec('RETURN_CENTER')">
@@ -569,6 +573,12 @@ export default {
console.log(error)
this.$message.error(this.$t('contextmenu.copyFail'))
}
},
// AI续写
aiCreate() {
this.$bus.$emit('ai_create_part', this.node)
this.hide()
}
}
}

View File

@@ -119,7 +119,7 @@ export default {
}
}
@media screen and (max-width: 740px) {
@media screen and (max-width: 900px) {
.countContainer {
display: none;
}

View File

@@ -47,6 +47,8 @@
v-if="mindMap"
:mindMap="mindMap"
></NodeImgPlacementToolbar>
<AiCreate v-if="mindMap" :mindMap="mindMap"></AiCreate>
<AiChat></AiChat>
<div
class="dragMask"
v-if="showDragMask"
@@ -133,6 +135,8 @@ import NodeTagStyle from './NodeTagStyle.vue'
import Setting from './Setting.vue'
import AssociativeLineStyle from './AssociativeLineStyle.vue'
import NodeImgPlacementToolbar from './NodeImgPlacementToolbar.vue'
import AiCreate from './AiCreate.vue'
import AiChat from './AiChat.vue'
// 注册插件
MindMap.usePlugin(MiniMap)
@@ -187,7 +191,9 @@ export default {
NodeTagStyle,
Setting,
AssociativeLineStyle,
NodeImgPlacementToolbar
NodeImgPlacementToolbar,
AiCreate,
AiChat
},
data() {
return {

View File

@@ -1,5 +1,5 @@
<template>
<div class="navigatorContainer" :class="{ isDark: isDark }">
<div class="navigatorContainer customScrollbar" :class="{ isDark: isDark }">
<div class="item">
<el-select
v-model="lang"
@@ -193,7 +193,8 @@ export default {
url = 'https://wanglin2.github.io/mind-map-docs/help/help1.html'
break
case 'devDoc':
url = 'https://wanglin2.github.io/mind-map-docs/start/introduction.html'
url =
'https://wanglin2.github.io/mind-map-docs/start/introduction.html'
break
case 'site':
url = 'https://wanglin2.github.io/mind-map-docs/'
@@ -268,7 +269,7 @@ export default {
}
}
@media screen and (max-width: 590px) {
@media screen and (max-width: 700px) {
.navigatorContainer {
left: 20px;
overflow-x: auto;

View File

@@ -1,13 +1,14 @@
<template>
<div
class="sidebarTriggerContainer"
class="sidebarTriggerContainer "
@click.stop
:class="{ hasActive: show && activeSidebar, show: show, isDark: isDark }"
:style="{ maxHeight: maxHeight + 'px' }"
>
<div class="toggleShowBtn" :class="{ hide: !show }" @click="show = !show">
<span class="iconfont iconjiantouyou"></span>
</div>
<div class="trigger">
<div class="trigger customScrollbar">
<div
class="triggerItem"
v-for="item in triggerList"
@@ -35,7 +36,8 @@ export default {
name: 'SidebarTrigger',
data() {
return {
show: true
show: true,
maxHeight: 0
}
},
computed: {
@@ -62,11 +64,28 @@ export default {
}
}
},
created() {
window.addEventListener('resize', this.onResize)
this.updateSize()
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize)
},
methods: {
...mapMutations(['setActiveSidebar']),
trigger(item) {
this.setActiveSidebar(item.value)
},
onResize() {
this.updateSize()
},
updateSize() {
const topMargin = 110
const bottomMargin = 80
this.maxHeight = window.innerHeight - topMargin - bottomMargin
}
}
}
@@ -75,11 +94,13 @@ export default {
<style lang="less" scoped>
.sidebarTriggerContainer {
position: fixed;
top: 110px;
bottom: 80px;
right: -60px;
margin-top: 110px;
transition: all 0.3s;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
justify-content: center;
&.isDark {
.trigger {
@@ -145,7 +166,9 @@ export default {
background-color: #fff;
box-shadow: 0 2px 16px 0 rgba(0, 0, 0, 0.06);
border-radius: 6px;
overflow: hidden;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
.triggerItem {
height: 60px;

View File

@@ -195,7 +195,8 @@ export default {
'formula',
// 'attachment',
'outerFrame',
'annotation'
'annotation',
'ai'
],
horizontalList: [],
verticalList: [],

View File

@@ -184,6 +184,17 @@
:dir="dir"
@setAnnotation="onSetAnnotation"
></NodeAnnotationBtn>
<div
v-if="item === 'ai'"
class="toolbarBtn"
:class="{
disabled: hasGeneralization
}"
@click="aiCrate"
>
<span class="icon iconfont iconAIshengcheng"></span>
<span class="text">{{ $t('toolbar.ai') }}</span>
</div>
</template>
</div>
</template>
@@ -299,6 +310,11 @@ export default {
// 设置标记
onSetAnnotation(...args) {
this.$bus.$emit('execCommand', 'SET_NOTATION', this.activeNodes, ...args)
},
// AI生成整体
aiCrate() {
this.$bus.$emit('ai_create_all')
}
}
}
@@ -376,6 +392,7 @@ export default {
.text {
margin-top: 3px;
text-align: center;
}
}

View File

@@ -38,7 +38,14 @@ const store = new Vuex.Store({
supportCheckbox: false, // 是否支持Checkbox插件
supportLineFlow: false, // 是否支持LineFlow插件
supportMomentum: false, // 是否支持Momentum插件
isDragOutlineTreeNode: false // 当前是否正在拖拽大纲树的节点
isDragOutlineTreeNode: false, // 当前是否正在拖拽大纲树的节点
aiConfig: {
api: 'http://ark.cn-beijing.volces.com/api/v3/chat/completions',
key: '',
model: '',
port: 3456,
method: 'POST'
}
},
mutations: {
// 设置思维导图数据
@@ -53,11 +60,18 @@ const store = new Vuex.Store({
// 设置本地配置
setLocalConfig(state, data) {
state.localConfig = {
const aiConfigKeys = Object.keys(state.aiConfig)
Object.keys(data).forEach(key => {
if (aiConfigKeys.includes(key)) {
state.aiConfig[key] = data[key]
} else {
state.localConfig[key] = data[key]
}
})
storeLocalConfig({
...state.localConfig,
...data
}
storeLocalConfig(state.localConfig)
...state.aiConfig
})
},
// 设置当前显示的侧边栏

120
web/src/utils/ai.js Normal file
View File

@@ -0,0 +1,120 @@
class Ai {
constructor(options = {}) {
this.options = options
this.baseData = {}
this.controller = null
this.currentChunk = ''
this.content = ''
}
init(type = 'huoshan', options = {}) {
// 火山引擎接口
if (type === 'huoshan') {
this.baseData = {
api: options.api,
method: options.method,
headers: {
Authorization: 'Bearer ' + options.key
},
data: {
model: options.model,
stream: true
}
}
}
}
async request(data, progress = () => {}, end = () => {}, err = () => {}) {
try {
const res = await this.postMsg(data)
const decoder = new TextDecoder()
while (1) {
const { done, value } = await res.read()
if (done) {
return
}
// 拿到当前切片的数据
const text = decoder.decode(value)
// 处理切片数据
let chunk = this.handleChunkData(text)
// 判断是否有不完整切片,如果有,合并下一次处理,没有则获取数据
if (this.currentChunk) continue
let isEnd = false
const list = chunk
.split('\n')
.filter(item => {
isEnd = item.includes('[DONE]')
return !!item && !isEnd
})
.map(item => {
return JSON.parse(item.replace(/^data:/, ''))
})
list.forEach(item => {
this.content += item.choices
.map(item2 => {
return item2.delta.content
})
.join('')
})
progress(this.content)
if (isEnd) {
end(this.content)
}
}
} catch (error) {
console.log(error)
// 手动停止请求不需要触发错误回调
if (!(error && error.name === 'AbortError')) {
err(error)
}
}
}
async postMsg(data) {
this.controller = new AbortController()
const res = await fetch(`http://localhost:${this.options.port}/ai/chat`, {
signal: this.controller.signal,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...this.baseData,
data: {
...this.baseData.data,
...data
}
})
})
if (res.status && res.status !== 200) {
return false
}
return res.body.getReader()
}
handleChunkData(chunk) {
chunk = chunk.trim()
// 如果存在上一个切片
if (this.currentChunk) {
chunk = this.currentChunk + chunk
this.currentChunk = ''
}
// 如果存在done,认为是完整切片且是最后一个切片
if (chunk.includes('[DONE]')) {
return chunk
}
// 最后一个字符串不为},则默认切片不完整,保存与下次拼接使用(这种方法不严谨,但已经能解决大部分场景的问题)
if (chunk[chunk.length - 1] !== '}') {
this.currentChunk = chunk
}
return chunk
}
stop() {
this.controller.abort()
this.controller = new AbortController()
}
}
export default Ai