| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- <template>
- <div>
- <v-avatar
- ref="box"
- class="box point elevation-5"
- color="indigo"
- @click="handleClick"
- @mousedown="handleMousedown"
- >
- <v-icon dark large>
- mdi-face-agent
- </v-icon>
- </v-avatar>
- <v-navigation-drawer
- v-model="show"
- fixed
- temporary
- hide-overlay
- right
- width="35%"
- style="z-index: var(--zIndex-nav);"
- >
- <div v-if="show" class="assistant-chat-wrapper d-flex flex-column">
- <v-banner class="flex-shrink-0">
- <div class="my-2">
- <div class="assistant-title">{{ displayTitle }}</div>
- <div v-if="displaySubtitle" class="assistant-subtitle mt-1">{{ displaySubtitle }}</div>
- </div>
- </v-banner>
- <div ref="chatBox" class="chat-list pa-3 flex-grow-1 overflow-auto">
- <v-list three-line>
- <!-- 欢迎语 -->
- <v-list-item>
- <v-list-item-content>
- <v-list-item-title class="d-flex align-center indigo--text">
- <v-icon class="mr-3" color="indigo">mdi-face-agent</v-icon>
- {{ system.name }}
- </v-list-item-title>
- <v-list-item-subtitle class="d-flex">
- <div class="indigo lighten-5 pa-3 round welcome-text">
- {{ system.welcome }}
- </div>
- </v-list-item-subtitle>
- </v-list-item-content>
- </v-list-item>
- <v-list-item v-for="talk in talks" :key="talk.id">
- <v-list-item-content>
- <v-list-item-title
- class="d-flex align-center"
- :class="talk.type === 'question' ? 'justify-end blue--text' : 'indigo--text'"
- >
- {{ talk.type === 'question' ? $store.getters.userInfo?.username || '我' : '' }}
- <v-icon
- :class="talk.type === 'question' ? 'ml-3' : 'mr-3'"
- :color="talk.type === 'question' ? 'blue' : 'indigo'"
- >
- {{ talk.type === 'question' ? 'mdi-account-circle' : 'mdi-face-agent' }}
- </v-icon>
- {{ talk.type === 'question' ? '' : system.name }}
- </v-list-item-title>
- <v-list-item-subtitle class="d-flex" :class="{ 'justify-end': talk.type === 'question' }">
- <div class="lighten-5 pa-3 round" :class="talk.type === 'question' ? 'blue' : 'indigo'">
- <div
- v-for="(line, index) in talk.content"
- :key="index"
- class="talk-line"
- v-html="formatLine(line, talk.type)"
- ></div>
- </div>
- </v-list-item-subtitle>
- </v-list-item-content>
- </v-list-item>
- <!-- loading -->
- <v-list-item v-show="loading" style="min-height: 10px">
- <v-list-item-content>
- <v-list-item-title class="d-flex align-center indigo--text">
- <v-icon class="mr-3" color="indigo">mdi-face-agent</v-icon>
- <div class="mr-3">{{ system.name }} 思考中</div>
- <v-progress-circular size="18" width="2" indeterminate color="indigo" />
- </v-list-item-title>
- </v-list-item-content>
- </v-list-item>
- </v-list>
- </div>
- <div class="send d-flex align-center justify-center flex-shrink-0">
- <div class="send-box">
- <v-textarea
- v-model="question"
- dense
- class="send-box-area"
- auto-grow
- :placeholder="displayInputPlaceholder"
- solo
- hide-details
- no-resize
- rows="1"
- @keydown.enter="handleKeyCode"
- />
- <v-btn icon color="primary" class="btn" :disabled="!question.trim()" @click="handleSendMsg">
- <v-icon>mdi-send</v-icon>
- </v-btn>
- </div>
- </div>
- </div>
- </v-navigation-drawer>
- </div>
- </template>
- <script>
- import { generateUUID } from '@/utils/index'
- // 元数据
- const DEFAULT_WEBHOOK_URL = 'https://n8n.citupro.com/webhook/datameta-agent/chat'
- export default {
- name: 'AssistantIframe',
- props: {
- /** n8n Chat Trigger 的 webhook 地址,用于发消息拿回复 */
- iframeUrl: {
- type: String,
- default: DEFAULT_WEBHOOK_URL
- },
- /** 抽屉标题 */
- title: {
- type: String,
- default: '智能助手'
- },
- /** 欢迎语 */
- welcome: {
- type: String,
- default: '您好,我是您的智能助手,有什么问题可以问我哦!'
- },
- inputPlaceholder: {
- type: String,
- default: '请输入您想问的内容,按 Ctrl+Enter 换行'
- },
- subTitle: {
- type: String,
- default: ''
- }
- },
- data () {
- return {
- show: false,
- lock: false,
- isDragging: false,
- info: {},
- sessionId: '',
- displayTitle: this.title,
- displaySubtitle: this.subTitle,
- displayInputPlaceholder: this.inputPlaceholder,
- system: {
- name: '智能助手',
- welcome: this.welcome
- },
- talks: [],
- question: '',
- loading: false,
- talkId: 0,
- configFetched: false
- }
- },
- watch: {
- show (val) {
- if (val && !this.sessionId) {
- this.sessionId = generateUUID()
- }
- if (val && this.iframeUrl && !this.configFetched) {
- this.fetchConfig()
- }
- },
- title (val) {
- if (!this.configFetched) this.displayTitle = val
- },
- welcome (val) {
- if (!this.configFetched) this.system.welcome = val
- },
- talks: {
- handler () {
- this.$nextTick(() => {
- const box = this.$refs.chatBox
- if (box) {
- box.scrollTo({ top: box.scrollHeight, behavior: 'smooth' })
- }
- })
- },
- deep: true,
- immediate: true
- }
- },
- mounted () {
- this.sessionId = generateUUID()
- this.$nextTick(() => {
- document.addEventListener('mouseup', this.handleMouseup)
- document.addEventListener('mousemove', this.handleMousemove)
- this.$once('hook:beforeDestroy', () => {
- document.removeEventListener('mouseup', this.handleMouseup)
- document.removeEventListener('mousemove', this.handleMousemove)
- })
- })
- },
- methods: {
- handleClick () {
- if (this.isDragging) return
- this.show = !this.show
- },
- handleMousedown (e) {
- this.lock = true
- const { left, top, height, width } = this.$refs.box.$el.getBoundingClientRect()
- this.info = {
- click: { x: e.clientX, y: e.clientY },
- left,
- top,
- height,
- width,
- clientX: e.clientX,
- clientY: e.clientY
- }
- },
- handleMousemove (e) {
- if (!this.lock) return
- const xLen = Math.abs(this.info.click?.x - e.clientX)
- const yLen = Math.abs(this.info.click?.y - e.clientY)
- if (xLen < 20 && yLen < 20) return
- const clientX = e.clientX - this.info.clientX
- const clientY = e.clientY - this.info.clientY
- this.isDragging = true
- this.info.left += clientX
- this.info.top += clientY
- this.$refs.box.$el.style.left = this.info.left + 'px'
- this.$refs.box.$el.style.top = this.info.top + 'px'
- this.info.clientX = e.clientX
- this.info.clientY = e.clientY
- },
- handleMouseup () {
- this.lock = false
- setTimeout(() => { this.isDragging = false })
- },
- /** 从 n8n chat 页面的 HTML 中解析 createChat 的 i18n.en / initialMessages */
- parseConfigFromHtml (raw) {
- const config = {}
- const str = raw.replace(/\s+/g, ' ')
- const titleMatch = str.match(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/)
- if (titleMatch) config.title = titleMatch[1].replace(/\\"/g, '"')
- const subtitleMatch = str.match(/"subtitle"\s*:\s*"((?:[^"\\]|\\.)*)"/)
- if (subtitleMatch) config.subtitle = subtitleMatch[1].replace(/\\"/g, '"')
- const placeholderMatch = str.match(/"inputPlaceholder"\s*:\s*"((?:[^"\\]|\\.)*)"/)
- if (placeholderMatch) config.inputPlaceholder = placeholderMatch[1].replace(/\\"/g, '"')
- const initialMessages = this.parseInitialMessagesArray(raw)
- if (initialMessages.length) {
- config.welcome = initialMessages.join('\n')
- }
- return config
- },
- /** 解析 initialMessages 数组中的全部字符串(支持任意条数) */
- parseInitialMessagesArray (raw) {
- const idx = raw.indexOf('initialMessages')
- if (idx === -1) return []
- const arrStart = raw.indexOf('[', idx)
- if (arrStart === -1) return []
- let depth = 1
- let pos = arrStart + 1
- while (pos < raw.length && depth > 0) {
- const ch = raw[pos]
- if (ch === '[') depth++
- else if (ch === ']') depth--
- pos++
- }
- const arrContent = raw.slice(arrStart + 1, pos - 1)
- const list = []
- const re = /"((?:[^"\\]|\\.)*)"/g
- let m
- while ((m = re.exec(arrContent)) !== null) {
- list.push(m[1].replace(/\\"/g, '"'))
- }
- return list
- },
- async fetchConfig () {
- if (!this.iframeUrl) return
- try {
- const res = await fetch(this.iframeUrl, { method: 'GET' })
- if (!res.ok) return
- const text = await res.text()
- const raw = text && text.trim()
- if (!raw) return
- let config = null
- if (raw.startsWith('{')) {
- const data = JSON.parse(raw)
- config = data && typeof data.data !== 'undefined' ? data.data : data
- } else if (raw.toLowerCase().includes('createchat') && (raw.includes('i18n') || raw.includes('title') || raw.includes('initialMessages'))) {
- config = this.parseConfigFromHtml(raw)
- }
- if (!config || typeof config !== 'object') return
- this.configFetched = true
- if (config.title != null) this.displayTitle = String(config.title)
- if (config.subtitle != null) this.displaySubtitle = String(config.subtitle)
- const placeholder = config.inputPlaceholder ?? config.placeholder
- if (placeholder != null) this.displayInputPlaceholder = String(placeholder)
- if (config.welcome != null) this.system.welcome = String(config.welcome)
- } catch (_) {
- // GET 返回 HTML 或非 JSON 时已在上方按 createChat 解析,失败则保留 props 默认值
- }
- },
- escapeHtml (text) {
- if (text === null || text === undefined) return ''
- return String(text)
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- },
- formatLine (line, type) {
- if (type !== 'response') {
- return this.escapeHtml(line)
- }
- return this.formatResponseLine(line)
- },
- formatResponseLine (line) {
- const raw = typeof line === 'string' ? line : String(line || '')
- const trimmed = raw.replace(/\r?\n$/, '')
- if (!trimmed) {
- return ' '
- }
- // Markdown 标题:# / ## / ### ... 开头,# 数量代表 h 级别
- const headingMatch = trimmed.match(/^\s*(#{1,6})\s+(.+)$/)
- if (headingMatch) {
- const level = headingMatch[1].length
- const text = headingMatch[2]
- const safe = this.escapeHtml(text)
- return `<strong class="talk-heading-${level}">${safe}</strong>`
- }
- let isBullet = false
- let work = trimmed
- // 列表项:- 开头
- if (/^\s*-\s+/.test(work)) {
- isBullet = true
- work = work.replace(/^\s*-\s+/, '')
- }
- let html = this.escapeHtml(work)
- // 加粗:**text**
- html = html.replace(/\*\*((?:[^*]|\*)+?)\*\*/g, '<strong>$1</strong>')
- if (isBullet) {
- return `<span class="talk-bullet-dot">• </span>${html}`
- }
- return html
- },
- handleKeyCode (event) {
- if (event.keyCode === 13) {
- if (!event.ctrlKey) {
- event.preventDefault()
- this.handleSendMsg()
- } else {
- this.question += '\n'
- }
- }
- },
- async handleSendMsg () {
- const text = this.question.trim()
- if (!text) return
- this.talks.push({
- id: ++this.talkId,
- type: 'question',
- content: text.split('\n')
- })
- this.question = ''
- this.loading = true
- try {
- const response = await fetch(this.iframeUrl, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- chatInput: text,
- sessionId: this.sessionId
- })
- })
- if (!response.ok) {
- throw new Error(response.statusText || '请求失败')
- }
- const data = await response.json()
- const output = data.output ?? data.text ?? (typeof data === 'string' ? data : '')
- const content = (output && String(output).trim()) ? String(output).split('\n') : ['暂无回复']
- this.talks.push({
- id: ++this.talkId,
- type: 'response',
- content
- })
- } catch (err) {
- const msg = err?.message || err || '网络或服务异常'
- this.$snackbar?.error ? this.$snackbar.error(msg) : this.$message?.({ type: 'error', message: msg })
- this.talks.push({
- id: ++this.talkId,
- type: 'response',
- content: [`请求失败:${msg}`]
- })
- } finally {
- this.loading = false
- }
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- .point {
- cursor: pointer;
- }
- .box {
- z-index: 9;
- position: fixed;
- right: 50px;
- bottom: 100px;
- }
- .assistant-chat-wrapper {
- height: 100%;
- }
- .assistant-title {
- font-size: 1.25rem;
- font-weight: 600;
- }
- .assistant-subtitle {
- font-size: 0.875rem;
- opacity: 0.9;
- }
- .chat-list {
- min-height: 0;
- }
- .round {
- border-radius: 5px;
- max-width: 80%;
- }
- .welcome-text {
- white-space: pre-line;
- }
- .talk-line {
- white-space: pre-wrap;
- word-break: break-word;
- }
- .talk-heading-1,
- .talk-heading-2,
- .talk-heading-3,
- .talk-heading-4,
- .talk-heading-5,
- .talk-heading-6 {
- font-weight: 600;
- }
- .talk-heading-1 { font-size: 1.2rem; }
- .talk-heading-2 { font-size: 1.1rem; }
- .talk-heading-3 { font-size: 1.05rem; }
- .talk-heading-4 { font-size: 1rem; }
- .talk-heading-5 { font-size: 0.95rem; }
- .talk-heading-6 { font-size: 0.9rem; }
- .talk-bullet-dot {
- margin-right: 4px;
- }
- .send {
- padding: 20px;
- .send-box {
- width: 100%;
- position: relative;
- .btn {
- position: absolute;
- right: 20px;
- bottom: 12px;
- }
- .send-box-area {
- ::v-deep textarea {
- padding: 15px 70px 15px 0 !important;
- max-height: 300px;
- min-height: 60px;
- overflow: auto;
- margin: 0 !important;
- }
- }
- }
- }
- </style>
|