cc12458 9 месяцев назад
Родитель
Сommit
8769275db9

+ 4 - 0
public/chat/css/chat.css

@@ -23,6 +23,7 @@ body {
 
 .message {
     display: flex;
+    width: calc(100% - 4px);
     max-width: 90%;
     align-self: flex-start;
     padding: 0.5rem 0;
@@ -56,6 +57,9 @@ body {
     color: white;
     margin-left: 12px;
 }
+.message.robot-message .avatar {
+    margin-right: 12px;
+}
 
 .message .content {
     padding: 12px 16px;

+ 357 - 0
public/chat/e.html

@@ -0,0 +1,357 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
+    <title>萧e生大模型</title>
+    <link rel="stylesheet" href="./css/bootstrap.min.css">
+    <link rel="stylesheet" href="./css/all.min.css">
+    <link rel="stylesheet" href="./css/github.min.css">
+    <link rel="stylesheet" href="./css/styles.css">
+    <link rel="stylesheet" href="./css/chat.css">
+</head>
+<body>
+<div class="container-fluid not-bg vh-100 d-flex flex-column">
+    <!-- 顶部标题栏 -->
+    <header class="p-2" style="min-height: 44px; text-align: center; font-size: 18px; font-weight: 700;">
+        <!--中医AI助手-->
+    </header>
+
+    <!-- 聊天主区域 -->
+    <main class="flex-grow-1 d-flex flex-column overflow-hidden" id="chat-container">
+        <div class="flex-grow-1 overflow-auto px-2 py-3" id="chat-messages"></div>
+
+        <!-- 输入区域 -->
+        <div class="px-3 py-2 border-top">
+            <div class="input-group input-group-sm">
+                <textarea
+                        id="message-input"
+                        class="form-control"
+                        placeholder="请输入..."
+                        rows="1"
+                        enterkeyhint="send"
+                ></textarea>
+                <button id="send-btn" class="btn btn-primary btn-sm">
+                    <i class="fas fa-paper-plane"></i>
+                </button>
+            </div>
+        </div>
+    </main>
+</div>
+
+<!-- 隐藏字段 -->
+<input type="hidden" id="session-id" value="{{ session_id }}">
+
+<!-- JavaScript 依赖 -->
+<script src="./js/bootstrap.bundle.min.js"></script>
+<script src="./js/marked.min.js"></script>
+<script src="./js/highlight.min.js"></script>
+<script>document.addEventListener('gesturestart', function (event) {
+    event.preventDefault();
+}, false);</script>
+
+<!-- 主应用脚本 -->
+<script>
+const u1 = `
+**杨红,男,38岁**
+<br>
+1. **主诉**:脘腹痞胀1年余,去年10月以来多次出血(黑便)
+1. **现病史**:脘腹痞胀1年余,去年10月以来多次出血(黑便),平素饮酒量多,形体丰腴。
+1. **既往史**:有嗜酸粒细胞增多症病史,常服用强的松。
+1. **中医四诊**:脘腹痞胀,右腹隐痛,大便溏薄,易出汗,舌质紫暗,苔薄白,脉沉细。
+1. **辅助检查**:胃镜示“慢性浅表性胃炎伴糜烂”。
+`
+const r1 = `
+根据您描述的情况,患者目前最可能的原因是与消化系统相关,肝脾不调,气血瘀滞导致便血。以下是具体分析:
+
+<br>
+
+### 辨证分析:
+1. 患者形体肥胖属湿盛体质,且平素酒,更易蕴生湿热,湿热灼伤络脉,血溢脉外则为瘀血。
+1. 湿邪壅滞,脾失健运,故大便溏薄。证属肝脾不调,气血瘀滞。
+
+<br>
+
+### 推荐方案:
+治以理气活血,健脾和胃,化瘀通络。常用药:当归、赤芍药、五灵脂、延胡索,另吞服三七粉1~2g
+
+<br>
+
+#### 中药方剂:
+1. **痛泻要方加减** ([国医徐景藩](#)) <a href="javascript:void(0):">转方</a>
+   - 组成:焦白术10g,白芍药15g,炒防风10g,煨木香6g,鸡内金10g,佛手10g,三棱10g,葛根花68,泽泻15g,牡丹皮10g,黄连2g,补骨脂6g,石斛10g,炙甘草3g,焦山楂、神曲各15g,青皮、陈皮各6g。
+   - 煎服方法:每日1剂,水煎服。
+   - **另**:三七粉1g,研末冲服,每日2次。
+   <span></span>
+
+2. **柴胡疏肝散(疏肝理气)** <a href="">转方</a>
+   - 组成:柴胡10g,陈皮12g,川芎10g,香附12g,枳壳10g,芍药12g,甘草6g
+   - 作用:疏肝解郁,行气止痛,缓解右腹隐痛及气滞症状。
+   <span></span>
+
+3. **丹参饮(活血化瘀)** <a href="">转方</a>
+   - 组成:丹参15g,檀香10g(后下),砂仁6g(后下),木香10g
+   - 作用:活血化瘀,改善舌质紫暗及血瘀状态。
+   <span></span>
+
+<br>
+
+#### 适宜技术:
+1. **针刺疗法**:<a href="javascript:void(0):">转方</a>
+   - 【取穴】足三里(健脾和胃)、中脘(调理脾胃)、脾俞(健脾益气)、三阴交(调和肝脾)。
+   - 【操作方法】直刺进针,得气后,每5min行针(平补平泻法)一次,留针 30 min 后出针。
+   - 【疗程说明】针刺、刮痧,可任选一种或两种均选,针刺每周干预2次,连续治疗8次为一疗程;刮痧每周干预1次,连续治疗4次为1疗程。
+   <span></span>
+
+2. **艾灸**:<a href="javascript:void(0):">转方</a>
+   选用神阙、关元、气海,温阳散寒,增强脾阳。距皮肤2~3cm处进行熏烤,根据患者的热感情况调整合适的距离,当患者感觉温热舒适时固定不动,每穴灸10~15分钟,以局部皮肤出现潮红为度。
+
+3. **推拿**:<a href="javascript:void(0):">转方</a>
+   腹部顺时针按摩,配合按压足三里、合谷穴,每日10分钟,促进气血运行。
+
+4. **足浴**:
+   - 银杏叶100g,槐花40g,菊花30g,丹参20g。将以上药物同入药罐中,清水浸泡30分钟,加水2000mL煎汤,煮沸20分钟后去渣取汁,将汁倒入足浴器中,先熏蒸再足浴,每晚1次。20天为一疗程。
+   - 当归50g,牛膝20g,干姜20g,桂枝10g,桑枝10g。将以上药物同入药罐中,清水浸泡30分钟,加水2000mL煎汤,煮沸20分钟后去渣取汁,将汁倒入足浴器中,先熏蒸再足浴,每晚1次。20天为一疗程。
+   <span></span>
+
+5. **茶饮**:
+   - **芎归茶**
+      <span></span>
+      ![芎归茶](./image/c1.png)
+      <span></span>
+      - 【成分】:川芎5克,当归2克。
+      - 【用法】:将川芎、当归放入砂锅中,加适量水煎煮30分钟,去渣取汁。每日1剂,代茶饮用。
+      - 【功效】:活血祛瘀。
+      - 【禁忌】:阴虚火旺的人不宜饮用此茶。
+      <span></span>
+
+   - **通脉花果茶**
+      <span></span>
+      ![通脉花果茶](./image/c2.png)
+      <span></span>
+      - 【原料】:山楂15g,玫瑰花10g,月季花10g,红花5g。
+      - 【制作】:将山楂、玫瑰花、月季花和红花用水冲净,放入保温杯中,倒入滚开的热水,盖上盖子拧紧。然后将保温杯上下颠倒几次,使水充分地浸泡药材。静置20分钟后可以饮用。此药茶可以反复冲泡至味淡。
+      - 【效用】:活血化瘀,理气消食。适合血瘀质兼见面部黄褐斑、情志不遂者饮用。
+      <span></span>
+
+<br>
+
+### 生活调护建议:
+- 饮食清淡,忌辛辣油腻,避免饮酒,可适量食用山药、莲子等健脾食物。
+- 保持规律作息,避免熬夜,适当运动以助气血流通。
+- 建议进一步检查肠镜及血液指标,排除其他潜在疾病。
+
+<br>
+
+### 注意事项:
+- 中医治疗需结合个体情况调整,以上方案需在专业医师指导下使用。
+- 长期服用激素需警惕副作用(如骨质疏松、免疫力下降),建议定期复查。
+- 若出现头晕、乏力、心悸等症状,需警惕气血两虚或出血加重,及时就医。
+
+<br>
+
+**以上建议旨在辅助调理,具体治疗需结合临床实际调整。**
+`
+</script>
+<script>
+    window.addEventListener('message', e => {
+        const messageInput = document.getElementById('message-input');
+        if (e.data && e.data.type === 'input') messageInput.value = e.data.value
+    }, false);
+    function getValue() {
+        const messageInput = document.getElementById('message-input');
+        return messageInput.value;
+    }
+</script>
+<script>
+    const searchParams = new URLSearchParams(window.location.search);
+    const host = `https://dev.hzliuzhi.com:62006`;
+    document.addEventListener('DOMContentLoaded', function () {
+        if (searchParams.has('hide_title')) document.querySelector('.container-fluid header').style.display = 'none';
+        else document.querySelector('.container-fluid header').innerHTML = document.title;
+        // 获取DOM元素
+        const chatMessages = document.getElementById('chat-messages');
+        const messageInput = document.getElementById('message-input');
+        const sendBtn = document.getElementById('send-btn');
+        let sessionId = document.getElementById('session-id').value;
+        if (sessionId.replace(/\s/g, '') === `{{session_id}}`) document.querySelector(`#session-id`).value = sessionId = searchParams.get('session_id');
+
+        let eventSource = null;
+
+        // 初始化Marked和Highlight.js
+        marked.setOptions({
+            breaks: true,
+            highlight: function (code, language) {
+                const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
+                return hljs.highlight(validLanguage, code).value;
+            }
+        });
+
+        // 发送消息
+        function sendMessage() {
+            const message = messageInput.value.trim();
+            if (!message) return;
+
+            // 添加用户消息
+            addMessage('user', message);
+            messageInput.value = '';
+
+            // 添加AI思考状态
+            const aiMessageElement = addMessage('assistant', '', true);
+
+            // 关闭之前的连接
+            if (eventSource) {
+                eventSource.close();
+                eventSource = null;
+            }
+
+            let aiResponse = '';
+            let responseElement = null;
+
+            // 使用fetch进行流式请求
+            fetch(`${host}/tcm_chat/chat/tcm`, {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify({
+                    session_id: sessionId,
+                    message: message
+                })
+            })
+                .then(response => {
+                    if (!response.ok) {
+                        throw new Error(`HTTP error! status: ${response.status}`);
+                    }
+
+                    const reader = response.body.getReader();
+                    const decoder = new TextDecoder();
+
+                    function readStream() {
+                        return reader.read().then(({done, value}) => {
+                            if (done) {
+                                return;
+                            }
+                            const chunk = decoder.decode(value);
+                            const lines = chunk.split('\n');
+
+                            for (const line of lines) {
+                                if (line.startsWith('think: ')) { // 思考内容
+                                    try {
+                                        const data = JSON.parse(line.slice(7));
+                                        if (data.content) {
+                                            console.log('流信号 思考内容', data.content)
+                                            // 这里没实现呢,需要你参考openai的实现@xiong
+                                            // 进入思考状态
+                                        }
+                                    } catch (e) {
+                                        console.error('解析响应数据失败:', e);
+                                    }
+                                } else if (line.startsWith('data: ')) { // 返回内容
+                                    try {
+                                        const data = JSON.parse(line.slice(6));
+                                        if (data.error) {
+                                            // 处理错误
+                                            if (!responseElement) {
+                                                aiMessageElement.innerHTML = `<div class="alert alert-danger">${data.error}</div>`;
+                                            }
+                                            return;
+                                        }
+
+                                        if (data.content) {
+                                            console.log('流信号', data.content)
+                                            aiResponse += data.content;
+                                            if (!responseElement) {
+                                                // 移除思考状态
+                                                aiMessageElement.innerHTML = '';
+                                                responseElement = aiMessageElement;
+                                            }
+                                            updateMessageContent(responseElement, aiResponse);
+                                        }
+                                    } catch (e) {
+                                        console.error('解析响应数据失败:', e);
+                                    }
+                                }
+                            }
+                            // 继续读取流
+                            return readStream();
+                        });
+                    }
+
+                    return readStream();
+                })
+                .catch(error => {
+                    console.error('请求失败:', error);
+                    if (!responseElement) {
+                        aiMessageElement.innerHTML = '<div class="alert alert-danger">连接错误,请重试</div>';
+                    }
+                });
+        }
+
+        // 添加消息到聊天界面
+        function addMessage(role, content, isThinking = false) {
+            const messageDiv = document.createElement('div');
+            messageDiv.className = `message ${role}-message mb-3`;
+
+            const avatar = document.createElement('div');
+            avatar.className = 'avatar';
+            avatar.innerHTML = role === 'user' ?
+                '<i class="fas fa-user"></i>' :
+                '<img src="./image/robot.png" alt="">';
+
+            const contentDiv = document.createElement('div');
+            contentDiv.className = 'content';
+
+            if (isThinking) {
+                contentDiv.innerHTML = `
+                        <div class="thinking-container">
+                            <div class="thinking-indicator">
+                                <span></span><span></span><span></span>
+                            </div>
+                            <div class="thinking-text">思考中...</div>
+                        </div>
+                    `;
+            } else {
+                contentDiv.innerHTML = marked.parse(content);
+                hljs.highlightAll();
+            }
+
+            messageDiv.appendChild(avatar);
+            messageDiv.appendChild(contentDiv);
+            chatMessages.appendChild(messageDiv);
+
+            // 滚动到底部
+            // chatMessages.scrollTop = chatMessages.scrollHeight;
+            messageDiv.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'start' });
+
+            return contentDiv;
+        }
+
+        // 更新消息内容
+        function updateMessageContent(element, content) {
+            element.innerHTML = marked.parse(content);
+            hljs.highlightAll();
+        }
+
+        // 事件监听
+        sendBtn.addEventListener('click', sendMessage);
+
+        messageInput.addEventListener('keypress', function (e) {
+            if (e.key === 'Enter' && !e.shiftKey) {
+                e.preventDefault();
+                sendMessage();
+            }
+        });
+
+        // 初始聚焦输入框
+        messageInput.focus();
+
+        setTimeout(() => {
+            addMessage('user', u1);
+            const el = addMessage('robot', '', true);
+            setTimeout(() => { updateMessageContent(el, r1) }, 3000);
+        }, 1000)
+    });
+</script>
+</body>
+</html>

BIN
public/chat/image/c1.png


BIN
public/chat/image/c2.png


+ 139 - 0
src/components/AiEModal.vue

@@ -0,0 +1,139 @@
+<script>
+import SoundRecorder from '@/components/SoundRecorder.vue';
+
+export default {
+  name: 'AiEModal',
+  components: {SoundRecorder},
+  props: {
+    url: {
+      type: String,
+      default: '',
+    },
+    immediate: {
+      type: Boolean,
+      default: false,
+    },
+    id: String,
+    title: String,
+  },
+  computed: {
+    realUrl() {
+      if (this.url.startsWith(`http`)) return this.url;
+      const url = this.url.startsWith('/') ? this.url.slice(1) : this.url;
+      return location.href.includes(`index.html`)
+          ? location.href.replace(location.search, '').replace(location.hash, '').split('index.html')[0] + url
+          : location.origin + '/' + url;
+    },
+    realSrc() {
+      if (!this.sessionId) this.sessionId = Date.now() + Array.from({length: 16}, () => Math.floor(Math.random() * 16).toString(16)).join('');
+      const [url, search] = this.realUrl.split('?');
+      const params = new URLSearchParams(search);
+      if (!params.has('session_id')) params.append('session_id', this.sessionId);
+      if (!this.hideTitle) params.delete('hide_title');
+      else if (!params.has('hide_title')) params.append('hide_title', true);
+      return `${url}?${params.toString()}`;
+    },
+  },
+  data() {
+    return {
+      modalLoaded: false,
+      modalProps: {
+        show: false,
+        type: 'modal',
+        remember: true,
+        position: {
+          top: 0,
+          left: 0,
+        },
+        padding: false,
+        showMinimize: true,
+        resize: true,
+        mask: false, lockView: false, lockScroll: false,
+        showClose: true, escClosable: true,
+
+        loading: true,
+        title: ' ',
+        width: Math.max(430, window.innerWidth * 0.6),
+        height: Math.max(932, window.innerHeight * 0.8),
+      },
+
+      sessionId: '',
+      hideTitle: true,
+
+      aiText: '',
+      aiTextBackups: '',
+    };
+  },
+  methods: {
+    handle() {
+      if (this.modalProps.show && this.$refs.modal.isMinimized) this.$refs.modal.revert();
+      else {
+        if (!this.sessionId) this.modalProps.loading = true;
+        this.modalProps.show = true;
+        setTimeout(() => { try { this.$refs.kiosk.contentDocument.querySelector('#message-input').focus(); } catch (e) {} }, 1000);
+      }
+    },
+    load() {
+      const iframe = this.$refs.kiosk;
+      this.modalProps.loading = false;
+      this.modalProps.title = this.title || iframe.contentDocument.title;
+      try { this.$refs.kiosk.contentDocument.querySelector('#message-input').focus(); } catch (e) {}
+    },
+    onClose({type, $event}) {
+      if (!$event) $event = {};
+      if (type === 'close' && !$event.shiftKey) this.sessionId = '';
+    },
+
+    begin() {
+      try {
+        this.aiText = '';
+        this.aiTextBackups = this.$refs.kiosk.contentWindow.getValue();
+      } catch (e) { this.aiTextBackups = this.aiText ; }
+    },
+    change(value) {
+      this.aiText = this.aiTextBackups + value;
+      this.$refs.kiosk.contentWindow.postMessage({type: 'input', value: this.aiText });
+    },
+  },
+  mounted() {
+    this.modalProps.showClose = !this.immediate;
+    if (this.immediate) {
+      this.handle();
+      setTimeout(() => {
+        this.$refs.modal.minimize();
+        this.modalLoaded = true;
+      }, 2000);
+    } else this.modalLoaded = true;
+  },
+};
+</script>
+
+<template>
+  <div>
+    <slot :handle="handle" :title="modalProps.title" :open="modalProps.show">
+      <div v-if="!modalProps.show" @click="handle()">{{ title || modalProps.title }}</div>
+    </slot>
+    <vxe-modal :class="{initialize: immediate && !modalLoaded}" ref="modal" v-bind="modalProps"
+               v-model="modalProps.show" :id="id" :esc-closable="modalProps.escClosable" @close="onClose">
+      <iframe ref="kiosk" :src="realSrc" style="display: block;width: 100%;height: 100%;border: 0;"
+              @load="load"></iframe>
+      <div class="float-wrapper" v-if="!modalProps.loading && modalProps.show">
+        <SoundRecorder @begin="begin()" @change="change($event)" @finish="change($event)"/>
+      </div>
+    </vxe-modal>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.vxe-modal--wrapper.initialize {
+  display: none;
+}
+
+.float-wrapper {
+  $size: 36px;
+  position: absolute;
+  bottom: (16 + 8px) + (46 - $size) / 2 + 5px;
+  right: (16px) + (55px + 8px) + 12px;
+  z-index: 11;
+}
+</style>

+ 7 - 0
src/views/diagnosis/Prescribing.vue

@@ -312,6 +312,11 @@
         </div>
         <!-- 右侧按钮 -->
         <div class="flex-vertical-center-r flex-wrap">
+          <AiEModal url="/chat/e.html" title="萧e生大模型">
+            <template #default="{ handle }">
+              <el-button size="mini" style="margin-right: 10px;" @click="handle" type="primary">萧e生大模型</el-button>
+            </template>
+          </AiEModal>
           <el-button v-if="showPrec" size="mini" @click="openUnifyPrescPrec" type="primary">萧然医派协定方</el-button>
           <el-button size="mini" key="tongue" :loading="tongueAndFaceLoading" :disabled="!tongueAndFaceAnalysis" @click="openTongueAndFaceAnalysis" type="primary">舌面诊影像</el-button>
           <el-button
@@ -1184,10 +1189,12 @@ import prescribing from "../../utils/minix/prescribing";
 
 import {formatPicture} from "@/utils/picture";
 import {CC_Dosage2Basis} from '@/utils/medicine';
+import AiEModal from '@/components/AiEModal.vue';
 
 export default {
   mixins: [prescribing],
   components: {
+    AiEModal,
     Popup,
     chineseMedicine,
     medicineChinese,