indexCopy.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. <template>
  2. <div>
  3. <v-avatar
  4. ref="box"
  5. class="box point elevation-5"
  6. color="indigo"
  7. @click="handleClick"
  8. @mousedown="handleMousedown"
  9. >
  10. <v-icon dark large>
  11. mdi-face-agent
  12. </v-icon>
  13. </v-avatar>
  14. <v-navigation-drawer
  15. v-model="show"
  16. fixed
  17. temporary
  18. hide-overlay
  19. right
  20. width="35%"
  21. style="z-index: var(--zIndex-nav);"
  22. >
  23. <div v-if="show" class="assistant-chat-wrapper d-flex flex-column">
  24. <v-banner class="flex-shrink-0">
  25. <div class="my-2">
  26. <div class="assistant-title">{{ displayTitle }}</div>
  27. <div v-if="displaySubtitle" class="assistant-subtitle mt-1">{{ displaySubtitle }}</div>
  28. </div>
  29. </v-banner>
  30. <div ref="chatBox" class="chat-list pa-3 flex-grow-1 overflow-auto">
  31. <v-list three-line>
  32. <!-- 欢迎语 -->
  33. <v-list-item>
  34. <v-list-item-content>
  35. <v-list-item-title class="d-flex align-center indigo--text">
  36. <v-icon class="mr-3" color="indigo">mdi-face-agent</v-icon>
  37. {{ system.name }}
  38. </v-list-item-title>
  39. <v-list-item-subtitle class="d-flex">
  40. <div class="indigo lighten-5 pa-3 round welcome-text">
  41. {{ system.welcome }}
  42. </div>
  43. </v-list-item-subtitle>
  44. </v-list-item-content>
  45. </v-list-item>
  46. <v-list-item v-for="talk in talks" :key="talk.id">
  47. <v-list-item-content>
  48. <v-list-item-title
  49. class="d-flex align-center"
  50. :class="talk.type === 'question' ? 'justify-end blue--text' : 'indigo--text'"
  51. >
  52. {{ talk.type === 'question' ? $store.getters.userInfo?.username || '我' : '' }}
  53. <v-icon
  54. :class="talk.type === 'question' ? 'ml-3' : 'mr-3'"
  55. :color="talk.type === 'question' ? 'blue' : 'indigo'"
  56. >
  57. {{ talk.type === 'question' ? 'mdi-account-circle' : 'mdi-face-agent' }}
  58. </v-icon>
  59. {{ talk.type === 'question' ? '' : system.name }}
  60. </v-list-item-title>
  61. <v-list-item-subtitle class="d-flex" :class="{ 'justify-end': talk.type === 'question' }">
  62. <div class="lighten-5 pa-3 round" :class="talk.type === 'question' ? 'blue' : 'indigo'">
  63. <div
  64. v-for="(line, index) in talk.content"
  65. :key="index"
  66. class="talk-line"
  67. v-html="formatLine(line, talk.type)"
  68. ></div>
  69. </div>
  70. </v-list-item-subtitle>
  71. </v-list-item-content>
  72. </v-list-item>
  73. <!-- loading -->
  74. <v-list-item v-show="loading" style="min-height: 10px">
  75. <v-list-item-content>
  76. <v-list-item-title class="d-flex align-center indigo--text">
  77. <v-icon class="mr-3" color="indigo">mdi-face-agent</v-icon>
  78. <div class="mr-3">{{ system.name }} 思考中</div>
  79. <v-progress-circular size="18" width="2" indeterminate color="indigo" />
  80. </v-list-item-title>
  81. </v-list-item-content>
  82. </v-list-item>
  83. </v-list>
  84. </div>
  85. <div class="send d-flex align-center justify-center flex-shrink-0">
  86. <div class="send-box">
  87. <v-textarea
  88. v-model="question"
  89. dense
  90. class="send-box-area"
  91. auto-grow
  92. :placeholder="displayInputPlaceholder"
  93. solo
  94. hide-details
  95. no-resize
  96. rows="1"
  97. @keydown.enter="handleKeyCode"
  98. />
  99. <v-btn icon color="primary" class="btn" :disabled="!question.trim()" @click="handleSendMsg">
  100. <v-icon>mdi-send</v-icon>
  101. </v-btn>
  102. </div>
  103. </div>
  104. </div>
  105. </v-navigation-drawer>
  106. </div>
  107. </template>
  108. <script>
  109. import { generateUUID } from '@/utils/index'
  110. // 元数据
  111. const DEFAULT_WEBHOOK_URL = 'https://n8n.citupro.com/webhook/datameta-agent/chat'
  112. export default {
  113. name: 'AssistantIframe',
  114. props: {
  115. /** n8n Chat Trigger 的 webhook 地址,用于发消息拿回复 */
  116. iframeUrl: {
  117. type: String,
  118. default: DEFAULT_WEBHOOK_URL
  119. },
  120. /** 抽屉标题 */
  121. title: {
  122. type: String,
  123. default: '智能助手'
  124. },
  125. /** 欢迎语 */
  126. welcome: {
  127. type: String,
  128. default: '您好,我是您的智能助手,有什么问题可以问我哦!'
  129. },
  130. inputPlaceholder: {
  131. type: String,
  132. default: '请输入您想问的内容,按 Ctrl+Enter 换行'
  133. },
  134. subTitle: {
  135. type: String,
  136. default: ''
  137. }
  138. },
  139. data () {
  140. return {
  141. show: false,
  142. lock: false,
  143. isDragging: false,
  144. info: {},
  145. sessionId: '',
  146. displayTitle: this.title,
  147. displaySubtitle: this.subTitle,
  148. displayInputPlaceholder: this.inputPlaceholder,
  149. system: {
  150. name: '智能助手',
  151. welcome: this.welcome
  152. },
  153. talks: [],
  154. question: '',
  155. loading: false,
  156. talkId: 0,
  157. configFetched: false
  158. }
  159. },
  160. watch: {
  161. show (val) {
  162. if (val && !this.sessionId) {
  163. this.sessionId = generateUUID()
  164. }
  165. if (val && this.iframeUrl && !this.configFetched) {
  166. this.fetchConfig()
  167. }
  168. },
  169. title (val) {
  170. if (!this.configFetched) this.displayTitle = val
  171. },
  172. welcome (val) {
  173. if (!this.configFetched) this.system.welcome = val
  174. },
  175. talks: {
  176. handler () {
  177. this.$nextTick(() => {
  178. const box = this.$refs.chatBox
  179. if (box) {
  180. box.scrollTo({ top: box.scrollHeight, behavior: 'smooth' })
  181. }
  182. })
  183. },
  184. deep: true,
  185. immediate: true
  186. }
  187. },
  188. mounted () {
  189. this.sessionId = generateUUID()
  190. this.$nextTick(() => {
  191. document.addEventListener('mouseup', this.handleMouseup)
  192. document.addEventListener('mousemove', this.handleMousemove)
  193. this.$once('hook:beforeDestroy', () => {
  194. document.removeEventListener('mouseup', this.handleMouseup)
  195. document.removeEventListener('mousemove', this.handleMousemove)
  196. })
  197. })
  198. },
  199. methods: {
  200. handleClick () {
  201. if (this.isDragging) return
  202. this.show = !this.show
  203. },
  204. handleMousedown (e) {
  205. this.lock = true
  206. const { left, top, height, width } = this.$refs.box.$el.getBoundingClientRect()
  207. this.info = {
  208. click: { x: e.clientX, y: e.clientY },
  209. left,
  210. top,
  211. height,
  212. width,
  213. clientX: e.clientX,
  214. clientY: e.clientY
  215. }
  216. },
  217. handleMousemove (e) {
  218. if (!this.lock) return
  219. const xLen = Math.abs(this.info.click?.x - e.clientX)
  220. const yLen = Math.abs(this.info.click?.y - e.clientY)
  221. if (xLen < 20 && yLen < 20) return
  222. const clientX = e.clientX - this.info.clientX
  223. const clientY = e.clientY - this.info.clientY
  224. this.isDragging = true
  225. this.info.left += clientX
  226. this.info.top += clientY
  227. this.$refs.box.$el.style.left = this.info.left + 'px'
  228. this.$refs.box.$el.style.top = this.info.top + 'px'
  229. this.info.clientX = e.clientX
  230. this.info.clientY = e.clientY
  231. },
  232. handleMouseup () {
  233. this.lock = false
  234. setTimeout(() => { this.isDragging = false })
  235. },
  236. /** 从 n8n chat 页面的 HTML 中解析 createChat 的 i18n.en / initialMessages */
  237. parseConfigFromHtml (raw) {
  238. const config = {}
  239. const str = raw.replace(/\s+/g, ' ')
  240. const titleMatch = str.match(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/)
  241. if (titleMatch) config.title = titleMatch[1].replace(/\\"/g, '"')
  242. const subtitleMatch = str.match(/"subtitle"\s*:\s*"((?:[^"\\]|\\.)*)"/)
  243. if (subtitleMatch) config.subtitle = subtitleMatch[1].replace(/\\"/g, '"')
  244. const placeholderMatch = str.match(/"inputPlaceholder"\s*:\s*"((?:[^"\\]|\\.)*)"/)
  245. if (placeholderMatch) config.inputPlaceholder = placeholderMatch[1].replace(/\\"/g, '"')
  246. const initialMessages = this.parseInitialMessagesArray(raw)
  247. if (initialMessages.length) {
  248. config.welcome = initialMessages.join('\n')
  249. }
  250. return config
  251. },
  252. /** 解析 initialMessages 数组中的全部字符串(支持任意条数) */
  253. parseInitialMessagesArray (raw) {
  254. const idx = raw.indexOf('initialMessages')
  255. if (idx === -1) return []
  256. const arrStart = raw.indexOf('[', idx)
  257. if (arrStart === -1) return []
  258. let depth = 1
  259. let pos = arrStart + 1
  260. while (pos < raw.length && depth > 0) {
  261. const ch = raw[pos]
  262. if (ch === '[') depth++
  263. else if (ch === ']') depth--
  264. pos++
  265. }
  266. const arrContent = raw.slice(arrStart + 1, pos - 1)
  267. const list = []
  268. const re = /"((?:[^"\\]|\\.)*)"/g
  269. let m
  270. while ((m = re.exec(arrContent)) !== null) {
  271. list.push(m[1].replace(/\\"/g, '"'))
  272. }
  273. return list
  274. },
  275. async fetchConfig () {
  276. if (!this.iframeUrl) return
  277. try {
  278. const res = await fetch(this.iframeUrl, { method: 'GET' })
  279. if (!res.ok) return
  280. const text = await res.text()
  281. const raw = text && text.trim()
  282. if (!raw) return
  283. let config = null
  284. if (raw.startsWith('{')) {
  285. const data = JSON.parse(raw)
  286. config = data && typeof data.data !== 'undefined' ? data.data : data
  287. } else if (raw.toLowerCase().includes('createchat') && (raw.includes('i18n') || raw.includes('title') || raw.includes('initialMessages'))) {
  288. config = this.parseConfigFromHtml(raw)
  289. }
  290. if (!config || typeof config !== 'object') return
  291. this.configFetched = true
  292. if (config.title != null) this.displayTitle = String(config.title)
  293. if (config.subtitle != null) this.displaySubtitle = String(config.subtitle)
  294. const placeholder = config.inputPlaceholder ?? config.placeholder
  295. if (placeholder != null) this.displayInputPlaceholder = String(placeholder)
  296. if (config.welcome != null) this.system.welcome = String(config.welcome)
  297. } catch (_) {
  298. // GET 返回 HTML 或非 JSON 时已在上方按 createChat 解析,失败则保留 props 默认值
  299. }
  300. },
  301. escapeHtml (text) {
  302. if (text === null || text === undefined) return ''
  303. return String(text)
  304. .replace(/&/g, '&amp;')
  305. .replace(/</g, '&lt;')
  306. .replace(/>/g, '&gt;')
  307. },
  308. formatLine (line, type) {
  309. if (type !== 'response') {
  310. return this.escapeHtml(line)
  311. }
  312. return this.formatResponseLine(line)
  313. },
  314. formatResponseLine (line) {
  315. const raw = typeof line === 'string' ? line : String(line || '')
  316. const trimmed = raw.replace(/\r?\n$/, '')
  317. if (!trimmed) {
  318. return '&nbsp;'
  319. }
  320. // Markdown 标题:# / ## / ### ... 开头,# 数量代表 h 级别
  321. const headingMatch = trimmed.match(/^\s*(#{1,6})\s+(.+)$/)
  322. if (headingMatch) {
  323. const level = headingMatch[1].length
  324. const text = headingMatch[2]
  325. const safe = this.escapeHtml(text)
  326. return `<strong class="talk-heading-${level}">${safe}</strong>`
  327. }
  328. let isBullet = false
  329. let work = trimmed
  330. // 列表项:- 开头
  331. if (/^\s*-\s+/.test(work)) {
  332. isBullet = true
  333. work = work.replace(/^\s*-\s+/, '')
  334. }
  335. let html = this.escapeHtml(work)
  336. // 加粗:**text**
  337. html = html.replace(/\*\*((?:[^*]|\*)+?)\*\*/g, '<strong>$1</strong>')
  338. if (isBullet) {
  339. return `<span class="talk-bullet-dot">• </span>${html}`
  340. }
  341. return html
  342. },
  343. handleKeyCode (event) {
  344. if (event.keyCode === 13) {
  345. if (!event.ctrlKey) {
  346. event.preventDefault()
  347. this.handleSendMsg()
  348. } else {
  349. this.question += '\n'
  350. }
  351. }
  352. },
  353. async handleSendMsg () {
  354. const text = this.question.trim()
  355. if (!text) return
  356. this.talks.push({
  357. id: ++this.talkId,
  358. type: 'question',
  359. content: text.split('\n')
  360. })
  361. this.question = ''
  362. this.loading = true
  363. try {
  364. const response = await fetch(this.iframeUrl, {
  365. method: 'POST',
  366. headers: { 'Content-Type': 'application/json' },
  367. body: JSON.stringify({
  368. chatInput: text,
  369. sessionId: this.sessionId
  370. })
  371. })
  372. if (!response.ok) {
  373. throw new Error(response.statusText || '请求失败')
  374. }
  375. const data = await response.json()
  376. const output = data.output ?? data.text ?? (typeof data === 'string' ? data : '')
  377. const content = (output && String(output).trim()) ? String(output).split('\n') : ['暂无回复']
  378. this.talks.push({
  379. id: ++this.talkId,
  380. type: 'response',
  381. content
  382. })
  383. } catch (err) {
  384. const msg = err?.message || err || '网络或服务异常'
  385. this.$snackbar?.error ? this.$snackbar.error(msg) : this.$message?.({ type: 'error', message: msg })
  386. this.talks.push({
  387. id: ++this.talkId,
  388. type: 'response',
  389. content: [`请求失败:${msg}`]
  390. })
  391. } finally {
  392. this.loading = false
  393. }
  394. }
  395. }
  396. }
  397. </script>
  398. <style lang="scss" scoped>
  399. .point {
  400. cursor: pointer;
  401. }
  402. .box {
  403. z-index: 9;
  404. position: fixed;
  405. right: 50px;
  406. bottom: 100px;
  407. }
  408. .assistant-chat-wrapper {
  409. height: 100%;
  410. }
  411. .assistant-title {
  412. font-size: 1.25rem;
  413. font-weight: 600;
  414. }
  415. .assistant-subtitle {
  416. font-size: 0.875rem;
  417. opacity: 0.9;
  418. }
  419. .chat-list {
  420. min-height: 0;
  421. }
  422. .round {
  423. border-radius: 5px;
  424. max-width: 80%;
  425. }
  426. .welcome-text {
  427. white-space: pre-line;
  428. }
  429. .talk-line {
  430. white-space: pre-wrap;
  431. word-break: break-word;
  432. }
  433. .talk-heading-1,
  434. .talk-heading-2,
  435. .talk-heading-3,
  436. .talk-heading-4,
  437. .talk-heading-5,
  438. .talk-heading-6 {
  439. font-weight: 600;
  440. }
  441. .talk-heading-1 { font-size: 1.2rem; }
  442. .talk-heading-2 { font-size: 1.1rem; }
  443. .talk-heading-3 { font-size: 1.05rem; }
  444. .talk-heading-4 { font-size: 1rem; }
  445. .talk-heading-5 { font-size: 0.95rem; }
  446. .talk-heading-6 { font-size: 0.9rem; }
  447. .talk-bullet-dot {
  448. margin-right: 4px;
  449. }
  450. .send {
  451. padding: 20px;
  452. .send-box {
  453. width: 100%;
  454. position: relative;
  455. .btn {
  456. position: absolute;
  457. right: 20px;
  458. bottom: 12px;
  459. }
  460. .send-box-area {
  461. ::v-deep textarea {
  462. padding: 15px 70px 15px 0 !important;
  463. max-height: 300px;
  464. min-height: 60px;
  465. overflow: auto;
  466. margin: 0 !important;
  467. }
  468. }
  469. }
  470. }
  471. </style>