mirror of
https://github.com/wanglin2/mind-map.git
synced 2026-02-21 10:27:44 +08:00
648 lines
17 KiB
Vue
648 lines
17 KiB
Vue
<template>
|
||
<div>
|
||
<!-- 客户端连接失败提示弹窗 -->
|
||
<el-dialog
|
||
class="clientTipDialog"
|
||
:title="$t('ai.connectFailedTitle')"
|
||
:visible.sync="clientTipDialogVisible"
|
||
width="400px"
|
||
append-to-body
|
||
>
|
||
<div class="tipBox">
|
||
<p>{{ $t('ai.connectFailedTip') }}</p>
|
||
<p>
|
||
{{ $t('ai.connectFailedCheckTip1')
|
||
}}<a
|
||
href="https://pan.baidu.com/s/1huasEbKsGNH2Af68dvWiOg?pwd=3bp3"
|
||
>{{ $t('ai.baiduNetdisk') }}</a
|
||
>、<a href="https://github.com/wanglin2/mind-map/releases">Github</a>
|
||
</p>
|
||
<p>{{ $t('ai.connectFailedCheckTip2') }}</p>
|
||
<P>{{ $t('ai.connectFailedCheckTip3') }}</P>
|
||
<p>
|
||
{{ $t('ai.connectFailedCheckTip4')
|
||
}}<el-button size="small" @click="testConnect">{{
|
||
$t('ai.connectionDetection')
|
||
}}</el-button>
|
||
</p>
|
||
</div>
|
||
<div slot="footer" class="dialog-footer">
|
||
<el-button type="primary" @click="clientTipDialogVisible = false">{{
|
||
$t('ai.close')
|
||
}}</el-button>
|
||
</div>
|
||
</el-dialog>
|
||
<!-- ai内容输入弹窗 -->
|
||
<el-dialog
|
||
class="createDialog"
|
||
:title="$t('ai.createMindMapTitle')"
|
||
:visible.sync="createDialogVisible"
|
||
width="450px"
|
||
append-to-body
|
||
>
|
||
<div class="inputBox">
|
||
<el-input
|
||
type="textarea"
|
||
:rows="5"
|
||
:placeholder="$t('ai.createTip')"
|
||
v-model="aiInput"
|
||
>
|
||
</el-input>
|
||
<div class="tip warning">
|
||
{{ $t('ai.importantTip') }}
|
||
</div>
|
||
<div class="tip">
|
||
{{ $t('ai.wantModifyAiConfigTip')
|
||
}}<el-button size="small" @click="showAiConfigDialog">{{
|
||
$t('ai.modifyAIConfiguration')
|
||
}}</el-button>
|
||
</div>
|
||
</div>
|
||
<div slot="footer" class="dialog-footer">
|
||
<el-button @click="closeAiCreateDialog">{{
|
||
$t('ai.cancel')
|
||
}}</el-button>
|
||
<el-button type="primary" @click="doAiCreate">{{
|
||
$t('ai.confirm')
|
||
}}</el-button>
|
||
</div>
|
||
</el-dialog>
|
||
<!-- ai生成中添加一个透明层,防止期间用户进行操作 -->
|
||
<div
|
||
class="aiCreatingMask"
|
||
ref="aiCreatingMaskRef"
|
||
v-show="aiCreatingMaskVisible"
|
||
>
|
||
<el-button type="warning" class="btn" @click="stopCreate">{{
|
||
$t('ai.stopGenerating')
|
||
}}</el-button>
|
||
</div>
|
||
<AiConfigDialog v-model="aiConfigDialogVisible"></AiConfigDialog>
|
||
<!-- AI续写 -->
|
||
<el-dialog
|
||
class="createDialog"
|
||
:title="$t('ai.aiCreatePart')"
|
||
:visible.sync="createPartDialogVisible"
|
||
width="450px"
|
||
append-to-body
|
||
>
|
||
<div class="inputBox">
|
||
<el-input type="textarea" :rows="5" v-model="aiPartInput"> </el-input>
|
||
</div>
|
||
<div slot="footer" class="dialog-footer">
|
||
<el-button @click="closeAiCreatePartDialog">{{
|
||
$t('ai.cancel')
|
||
}}</el-button>
|
||
<el-button type="primary" @click="confirmAiCreatePart">{{
|
||
$t('ai.confirm')
|
||
}}</el-button>
|
||
</div>
|
||
</el-dialog>
|
||
</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: '',
|
||
|
||
createPartDialogVisible: false,
|
||
aiPartInput: '',
|
||
beingCreatePartNode: null
|
||
}
|
||
},
|
||
computed: {
|
||
...mapState(['aiConfig'])
|
||
},
|
||
created() {
|
||
this.$bus.$on('ai_create_all', this.aiCrateAll)
|
||
this.$bus.$on('ai_create_part', this.showAiCreatePartDialog)
|
||
this.$bus.$on('ai_chat', this.aiChat)
|
||
this.$bus.$on('ai_chat_stop', this.aiChatStop)
|
||
this.$bus.$on('showAiConfigDialog', this.showAiConfigDialog)
|
||
},
|
||
mounted() {
|
||
document.body.appendChild(this.$refs.aiCreatingMaskRef)
|
||
},
|
||
beforeDestroy() {
|
||
this.$bus.$off('ai_create_all', this.aiCrateAll)
|
||
this.$bus.$off('ai_create_part', this.showAiCreatePartDialog)
|
||
this.$bus.$off('ai_chat', this.aiChat)
|
||
this.$bus.$off('ai_chat_stop', this.aiChatStop)
|
||
this.$bus.$off('showAiConfigDialog', this.showAiConfigDialog)
|
||
},
|
||
methods: {
|
||
// 显示AI配置修改弹窗
|
||
showAiConfigDialog() {
|
||
this.aiConfigDialogVisible = true
|
||
},
|
||
|
||
// 客户端连接检测
|
||
async testConnect() {
|
||
try {
|
||
await fetch(`http://localhost:${this.aiConfig.port}/ai/test`, {
|
||
method: 'GET'
|
||
})
|
||
this.$message.success(this.$t('ai.connectSuccessful'))
|
||
this.clientTipDialogVisible = false
|
||
this.createDialogVisible = true
|
||
} catch (error) {
|
||
console.log(error)
|
||
this.$message.error(this.$t('ai.connectFailed'))
|
||
}
|
||
},
|
||
|
||
// 检测ai是否可用
|
||
async aiTest() {
|
||
// 检查配置
|
||
if (
|
||
!(
|
||
this.aiConfig.api &&
|
||
this.aiConfig.key &&
|
||
this.aiConfig.model &&
|
||
this.aiConfig.port
|
||
)
|
||
) {
|
||
this.showAiConfigDialog()
|
||
throw new Error(this.$t('ai.configurationMissing'))
|
||
}
|
||
// 检查连接
|
||
let isConnect = false
|
||
try {
|
||
await fetch(`http://localhost:${this.aiConfig.port}/ai/test`, {
|
||
method: 'GET'
|
||
})
|
||
isConnect = true
|
||
} catch (error) {
|
||
console.log(error)
|
||
this.clientTipDialogVisible = true
|
||
}
|
||
if (!isConnect) {
|
||
throw new Error(this.$t('ai.connectFailed'))
|
||
}
|
||
},
|
||
|
||
// AI生成整体
|
||
async aiCrateAll() {
|
||
try {
|
||
await this.aiTest()
|
||
this.createDialogVisible = true
|
||
} catch (error) {
|
||
console.log(error)
|
||
}
|
||
},
|
||
|
||
// 关闭ai内容输入弹窗
|
||
closeAiCreateDialog() {
|
||
this.createDialogVisible = false
|
||
this.aiInput = ''
|
||
},
|
||
|
||
// 确认生成
|
||
doAiCreate() {
|
||
const aiInputText = this.aiInput.trim()
|
||
if (!aiInputText) {
|
||
this.$message.warning(this.$t('ai.noInputTip'))
|
||
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: `${this.$t(
|
||
'ai.aiCreateMsgPrefix'
|
||
)}${aiInputText}${this.$t('ai.aiCreateMsgPostfix')}`
|
||
}
|
||
]
|
||
},
|
||
content => {
|
||
if (content) {
|
||
const arr = content.split(/\n+/)
|
||
this.aiCreatingContent = arr.splice(0, arr.length - 1).join('\n')
|
||
}
|
||
this.loopRenderOnAiCreating()
|
||
},
|
||
content => {
|
||
this.aiCreatingContent = content
|
||
this.resetOnAiCreatingStop()
|
||
},
|
||
() => {
|
||
this.resetOnAiCreatingStop()
|
||
this.resetOnRenderEnd()
|
||
this.$message.error(this.$t('ai.generationFailed'))
|
||
}
|
||
)
|
||
},
|
||
|
||
// 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(this.$t('ai.stoppedGenerating'))
|
||
},
|
||
|
||
// 轮询进行渲染
|
||
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.$message.success(this.$t('ai.aiGenerationSuccess'))
|
||
}
|
||
}
|
||
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续写弹窗
|
||
showAiCreatePartDialog(node) {
|
||
this.beingCreatePartNode = node
|
||
const currentMindMapData = this.mindMap.getData()
|
||
// 填充默认内容
|
||
this.aiPartInput = `${this.$t(
|
||
'ai.aiCreatePartMsgPrefix'
|
||
)}${getStrWithBrFromHtml(currentMindMapData.data.text)}${this.$t(
|
||
'ai.aiCreatePartMsgCenter'
|
||
)}${getStrWithBrFromHtml(node.getData('text'))}${this.$t(
|
||
'ai.aiCreatePartMsgPostfix'
|
||
)}`
|
||
this.createPartDialogVisible = true
|
||
},
|
||
|
||
// 关闭AI续写弹窗
|
||
closeAiCreatePartDialog() {
|
||
this.createPartDialogVisible = false
|
||
},
|
||
|
||
// 复位AI续写弹窗数据
|
||
resetAiCreatePartDialog() {
|
||
this.beingCreatePartNode = null
|
||
this.aiPartInput = ''
|
||
},
|
||
|
||
// 确认AI续写
|
||
confirmAiCreatePart() {
|
||
if (!this.aiPartInput.trim()) return
|
||
this.closeAiCreatePartDialog()
|
||
this.aiCreatePart()
|
||
},
|
||
|
||
// AI生成部分
|
||
async aiCreatePart() {
|
||
try {
|
||
if (!this.beingCreatePartNode) {
|
||
return
|
||
}
|
||
await this.aiTest()
|
||
this.beingAiCreateNodeUid = this.beingCreatePartNode.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:
|
||
this.aiPartInput.trim() + this.$t('ai.aiCreatePartMsgHelp')
|
||
}
|
||
]
|
||
},
|
||
content => {
|
||
if (content) {
|
||
const arr = content.split(/\n+/)
|
||
this.aiCreatingContent = arr.splice(0, arr.length - 1).join('\n')
|
||
}
|
||
|
||
this.loopRenderOnAiCreatingPart()
|
||
},
|
||
content => {
|
||
this.aiCreatingContent = content
|
||
this.resetOnAiCreatingStop()
|
||
this.resetAiCreatePartDialog()
|
||
},
|
||
() => {
|
||
this.resetOnAiCreatingStop()
|
||
this.resetAiCreatePartDialog()
|
||
this.resetOnRenderEnd()
|
||
this.$message.error(this.$t('ai.generationFailed'))
|
||
}
|
||
)
|
||
} 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.$message.success(this.$t('ai.aiGenerationSuccess'))
|
||
}
|
||
}
|
||
this.mindMap.on('node_tree_render_end', onRenderEnd)
|
||
// 因为是续写,所以首次也直接使用updateData方法渲染
|
||
this.mindMap.updateData(treeData)
|
||
},
|
||
|
||
// AI对话
|
||
async aiChat(
|
||
messageList = [],
|
||
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: messageList.map(msg => {
|
||
return {
|
||
role: 'user',
|
||
content: msg
|
||
}
|
||
})
|
||
},
|
||
content => {
|
||
progress(content)
|
||
},
|
||
content => {
|
||
end(content)
|
||
},
|
||
error => {
|
||
err(error)
|
||
}
|
||
)
|
||
} catch (error) {
|
||
console.log(error)
|
||
}
|
||
},
|
||
|
||
// AI对话停止
|
||
aiChatStop() {
|
||
if (this.aiInstance) {
|
||
this.aiInstance.stop()
|
||
this.isAiCreating = false
|
||
this.aiInstance = null
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</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>
|