guide.html 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
  6. <title>智能导诊</title>
  7. <link rel="stylesheet" href="./css/bootstrap.min.css">
  8. <link rel="stylesheet" href="./css/all.min.css">
  9. <link rel="stylesheet" href="./css/github.min.css">
  10. <link rel="stylesheet" href="./css/styles.css">
  11. <link rel="stylesheet" href="./css/chat.css">
  12. </head>
  13. <body>
  14. <div class="container-fluid vh-100 d-flex flex-column">
  15. <!-- 顶部标题栏 -->
  16. <header class="p-2" style="min-height: 44px; text-align: center; font-size: 18px; font-weight: 700;">
  17. <!--智能导诊-->
  18. </header>
  19. <!-- 聊天主区域 -->
  20. <main class="flex-grow-1 d-flex flex-column overflow-hidden" id="chat-container">
  21. <div class="flex-grow-1 overflow-auto px-2 py-3" id="chat-messages">
  22. <!-- 初始系统消息 -->
  23. <div class="message system-message" style="margin-top: 24px;" >
  24. <div class="avatar">
  25. <img src="./image/robot.png" alt="">
  26. </div>
  27. <div class="content">
  28. <div class="card">
  29. <div class="card-header bg-light">
  30. 医疗导诊助手
  31. </div>
  32. <div class="card-body">
  33. <p>您好!我是医院智能导诊助手,可以帮助您:</p>
  34. <ul>
  35. <li>根据症状推荐合适的科室</li>
  36. <li>查找相关领域的专家医生</li>
  37. <li>提供初步的医疗建议</li>
  38. </ul>
  39. <p>请描述您的症状,我会为您提供专业的导诊服务。</p>
  40. </div>
  41. </div>
  42. </div>
  43. </div>
  44. </div>
  45. <!-- 输入区域 -->
  46. <div class="px-3 py-2 border-top">
  47. <div class="input-group input-group-sm">
  48. <textarea
  49. id="message-input"
  50. class="form-control"
  51. placeholder="请描述您的症状..."
  52. rows="1"
  53. enterkeyhint="send"
  54. ></textarea>
  55. <button id="send-btn" class="btn btn-primary btn-sm">
  56. <i class="fas fa-paper-plane"></i>
  57. </button>
  58. </div>
  59. </div>
  60. </main>
  61. </div>
  62. <!-- 隐藏字段 -->
  63. <input type="hidden" id="session-id" value="{{ session_id }}">
  64. <!-- JavaScript 依赖 -->
  65. <script src="./js/bootstrap.bundle.min.js"></script>
  66. <script src="./js/marked.min.js"></script>
  67. <script src="./js/highlight.min.js"></script>
  68. <script>document.addEventListener('gesturestart', function (event) {
  69. event.preventDefault();
  70. }, false);</script>
  71. <!-- 主应用脚本 -->
  72. <script>
  73. const searchParams = new URLSearchParams(window.location.search);
  74. const host = `https://dev.hzliuzhi.com:8040`;
  75. document.addEventListener('DOMContentLoaded', function () {
  76. if (searchParams.has('hide_title')) document.querySelector('.container-fluid header').style.display = 'none';
  77. else document.querySelector('.container-fluid header').innerHTML = document.title;
  78. // 获取DOM元素
  79. const chatMessages = document.getElementById('chat-messages');
  80. const messageInput = document.getElementById('message-input');
  81. const sendBtn = document.getElementById('send-btn');
  82. let sessionId = document.getElementById('session-id').value;
  83. if (sessionId.replace(/\s/g, '') === `{{session_id}}`) document.querySelector(`#session-id`).value = sessionId = searchParams.get('session_id');
  84. let eventSource = null;
  85. // 初始化Marked和Highlight.js
  86. marked.setOptions({
  87. breaks: true,
  88. highlight: function (code, language) {
  89. const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
  90. return hljs.highlight(validLanguage, code).value;
  91. }
  92. });
  93. // 发送消息
  94. function sendMessage() {
  95. const message = messageInput.value.trim();
  96. if (!message) return;
  97. // 添加用户消息
  98. addMessage('user', message);
  99. messageInput.value = '';
  100. // 添加AI思考状态
  101. const aiMessageElement = addMessage('assistant', '', true);
  102. // 关闭之前的连接
  103. if (eventSource) {
  104. eventSource.close();
  105. eventSource = null;
  106. }
  107. let aiResponse = '';
  108. let responseElement = null;
  109. // 使用fetch进行流式请求
  110. fetch(`${host}/hospital_guide/chat/stream`, {
  111. method: 'POST',
  112. headers: {
  113. 'Content-Type': 'application/json',
  114. },
  115. body: JSON.stringify({
  116. session_id: sessionId,
  117. message: message
  118. })
  119. })
  120. .then(response => {
  121. if (!response.ok) {
  122. throw new Error(`HTTP error! status: ${response.status}`);
  123. }
  124. const reader = response.body.getReader();
  125. const decoder = new TextDecoder();
  126. function readStream() {
  127. return reader.read().then(({done, value}) => {
  128. if (done) {
  129. return;
  130. }
  131. const chunk = decoder.decode(value);
  132. const lines = chunk.split('\n');
  133. for (const line of lines) {
  134. if (line.startsWith('data: ')) {
  135. try {
  136. const data = JSON.parse(line.slice(6));
  137. if (data.error) {
  138. // 处理错误
  139. if (!responseElement) {
  140. aiMessageElement.innerHTML = `<div class="alert alert-danger">${data.error}</div>`;
  141. }
  142. return;
  143. }
  144. if (data.end) {
  145. console.log('结束信号')
  146. // 显示结果卡片
  147. showResultCard(aiResponse).then(() => {
  148. aiMessageElement.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
  149. });
  150. return;
  151. }
  152. if (data.content) {
  153. console.log('流信号', data.content)
  154. aiResponse += data.content;
  155. if (!responseElement) {
  156. // 移除思考状态
  157. aiMessageElement.innerHTML = '';
  158. responseElement = aiMessageElement;
  159. }
  160. updateMessageContent(responseElement, aiResponse);
  161. aiMessageElement.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
  162. }
  163. } catch (e) {
  164. console.error('解析响应数据失败:', e);
  165. }
  166. }
  167. }
  168. // 继续读取流
  169. return readStream();
  170. });
  171. }
  172. return readStream();
  173. })
  174. .catch(error => {
  175. console.error('请求失败:', error);
  176. if (!responseElement) {
  177. aiMessageElement.innerHTML = '<div class="alert alert-danger">连接错误,请重试</div>';
  178. }
  179. });
  180. }
  181. // 添加消息到聊天界面
  182. function addMessage(role, content, isThinking = false) {
  183. const messageDiv = document.createElement('div');
  184. messageDiv.className = `message ${role}-message`;
  185. const avatar = document.createElement('div');
  186. avatar.className = 'avatar';
  187. avatar.innerHTML = role === 'user' ?
  188. '<i class="fas fa-user"></i>' :
  189. '<img src="./image/robot.png" alt="">';
  190. const contentDiv = document.createElement('div');
  191. contentDiv.className = 'content';
  192. if (isThinking) {
  193. contentDiv.innerHTML = `
  194. <div class="thinking-container">
  195. <div class="thinking-indicator">
  196. <span></span><span></span><span></span>
  197. </div>
  198. <div class="thinking-text">思考中...</div>
  199. </div>
  200. `;
  201. } else {
  202. contentDiv.innerHTML = marked.parse(content);
  203. hljs.highlightAll();
  204. }
  205. messageDiv.appendChild(avatar);
  206. messageDiv.appendChild(contentDiv);
  207. chatMessages.appendChild(messageDiv);
  208. // 滚动到底部
  209. // chatMessages.scrollTop = chatMessages.scrollHeight;
  210. messageDiv.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'start' });
  211. return contentDiv;
  212. }
  213. // 更新消息内容
  214. function updateMessageContent(element, content) {
  215. element.innerHTML = marked.parse(content);
  216. hljs.highlightAll();
  217. }
  218. // 显示结果卡片
  219. function showResultCard(symptoms) {
  220. // 创建新的结果卡片
  221. const resultCardId = 'result-card-' + Date.now();
  222. const resultCardHtml = `
  223. <div class="result-card mt-3" id="${resultCardId}">
  224. <div class="card shadow-sm">
  225. <div class="card-body p-0">
  226. <ul class="nav" role="tablist">
  227. <li class="nav-item" role="presentation">
  228. <button class="nav-link active" data-bs-toggle="tab"
  229. data-bs-target="#dept-${resultCardId}" type="button" role="tab" aria-controls="dept-${resultCardId}" aria-selected="true">
  230. <i class="fas fa-clinic-medical me-1"></i> 推荐科室
  231. </button>
  232. </li>
  233. </ul>
  234. <div class="tab-content">
  235. <div class="tab-pane fade show active" id="dept-${resultCardId}" role="tabpanel">
  236. <div class="department-results p-2" data-loaded="false">
  237. <div class="text-center py-3">
  238. <div class="spinner-border text-primary" role="status">
  239. <span class="visually-hidden">加载中...</span>
  240. </div>
  241. <p class="mt-2 text-muted">正在查询相关科室...</p>
  242. </div>
  243. </div>
  244. </div>
  245. </div>
  246. </div>
  247. </div>
  248. <div class="card shadow-sm">
  249. <div class="card-body p-0">
  250. <ul class="nav" role="tablist">
  251. <li class="nav-item" role="presentation">
  252. <button class="nav-link active" data-bs-toggle="tab"
  253. data-bs-target="#doctor-${resultCardId}" type="button" role="tab" aria-controls="doctor-${resultCardId}" aria-selected="false">
  254. <i class="fas fa-user-md me-1"></i> 推荐医生
  255. </button>
  256. </li>
  257. </ul>
  258. <div class="tab-content">
  259. <div class="tab-pane fade show active" id="doctor-${resultCardId}" role="tabpanel">
  260. <div class="doctor-results p-2" data-loaded="false">
  261. <div class="text-center py-3">
  262. <div class="spinner-border text-primary" role="status">
  263. <span class="visually-hidden">加载中...</span>
  264. </div>
  265. <p class="mt-2 text-muted">正在查询相关医生...</p>
  266. </div>
  267. </div>
  268. </div>
  269. </div>
  270. </div>
  271. </div>
  272. </div>
  273. `;
  274. // 将结果卡片添加到当前AI消息中
  275. const currentAiMessage = document.querySelector('.assistant-message:last-child .content');
  276. if (currentAiMessage) {
  277. currentAiMessage.insertAdjacentHTML('beforeend', resultCardHtml);
  278. }
  279. // 加载科室数据
  280. return Promise.all([
  281. loadDepartments(symptoms, resultCardId),
  282. loadDoctors(symptoms, resultCardId),
  283. ]);
  284. // 设置医生标签页点击事件 - 只在第一次点击时加载数据
  285. const doctorTab = document.querySelector(`[data-bs-target="#doctor-${resultCardId}"]`);
  286. if (doctorTab) {
  287. doctorTab.addEventListener('click', function () {
  288. // 检查是否已经加载过医生数据
  289. const doctorContainer = document.querySelector(`#doctor-${resultCardId} .doctor-results`);
  290. if (doctorContainer && doctorContainer.dataset.loaded !== 'true') {
  291. // 显示加载状态
  292. doctorContainer.innerHTML = `
  293. <div class="text-center py-3">
  294. <div class="spinner-border text-primary" role="status">
  295. <span class="visually-hidden">加载中...</span>
  296. </div>
  297. <p class="mt-2 text-muted">正在查询相关医生...</p>
  298. </div>
  299. `;
  300. loadDoctors(symptoms, resultCardId);
  301. // 标记为已加载
  302. doctorContainer.dataset.loaded = 'true';
  303. }
  304. });
  305. }
  306. // 滚动到底部显示结果
  307. setTimeout(() => {
  308. chatMessages.scrollTop = chatMessages.scrollHeight;
  309. }, 100);
  310. }
  311. // 加载科室数据
  312. function loadDepartments(symptoms, resultCardId) {
  313. const container = document.querySelector(`#dept-${resultCardId} .department-results`);
  314. if (!container) {
  315. console.error('找不到科室结果容器');
  316. return;
  317. }
  318. if (container.dataset.loaded === 'true') return;
  319. // 显示加载状态
  320. container.innerHTML = `
  321. <div class="text-center py-3">
  322. <div class="spinner-border text-primary" role="status">
  323. <span class="visually-hidden">加载中...</span>
  324. </div>
  325. <p class="mt-2 text-muted">正在查询相关科室...</p>
  326. </div>`;
  327. return fetch(`${host}/hospital_guide/api/search/departments`, {
  328. method: 'POST',
  329. headers: {
  330. 'Content-Type': 'application/json'
  331. },
  332. body: JSON.stringify({
  333. query: symptoms,
  334. session_id: sessionId
  335. })
  336. })
  337. .then(response => response.json())
  338. .then(data => {
  339. if (data.results && data.results.length > 0) {
  340. let html = '';
  341. data.results.forEach(dept => {
  342. // 截断描述文本,超过50字显示省略号
  343. const description = dept.description.length > 50
  344. ? dept.description.substring(0, 50) + '...'
  345. : dept.description;
  346. html += `
  347. <div class="card mb-2 dept-card">
  348. <div class="card-body p-2">
  349. <div class="d-flex align-items-center mb-2">
  350. <div class="flex-shrink-0">
  351. <div class="avatar-sm bg-primary rounded-circle d-flex align-items-center justify-content-center" >
  352. <i class="fas fa-clinic-medical fa-sm text-white"></i>
  353. </div>
  354. </div>
  355. <div class="flex-grow-1 ms-2">
  356. <h6 class="card-title mb-0">${escapeHtml(dept.name)}</h6>
  357. </div>
  358. <div class="d-flex align-items-center">
  359. <button class="btn btn-sm btn-outline-primary">
  360. <i class="fas fa-info-circle me-1"></i> 详情
  361. </button>
  362. </div>
  363. </div>
  364. <p class="card-text small text-muted mb-0" title="${escapeHtml(dept.description)}">${escapeHtml(description)}</p>
  365. </div>
  366. </div>`;
  367. });
  368. container.innerHTML = html;
  369. // 标记为已加载
  370. container.dataset.loaded = 'true';
  371. } else {
  372. container.innerHTML = '<div class="alert alert-warning">未找到相关科室</div>';
  373. container.dataset.loaded = 'true';
  374. }
  375. })
  376. .catch(error => {
  377. console.error('加载科室失败:', error);
  378. container.innerHTML = '<div class="alert alert-danger">加载科室失败</div>';
  379. container.dataset.loaded = 'true';
  380. });
  381. }
  382. // 加载医生数据
  383. function loadDoctors(symptoms, resultCardId) {
  384. const container = document.querySelector(`#doctor-${resultCardId} .doctor-results`);
  385. if (!container) {
  386. console.error('找不到医生结果容器');
  387. return;
  388. }
  389. if (container.dataset.loaded === 'true') return;
  390. // 显示加载状态
  391. container.innerHTML = `
  392. <div class="text-center py-3">
  393. <div class="spinner-border text-primary" role="status">
  394. <span class="visually-hidden">加载中...</span>
  395. </div>
  396. <p class="mt-2 text-muted">正在查询相关医生...</p>
  397. </div>`;
  398. return fetch(`${host}/hospital_guide/api/search/doctors`, {
  399. method: 'POST',
  400. headers: {
  401. 'Content-Type': 'application/json'
  402. },
  403. body: JSON.stringify({
  404. query: symptoms,
  405. session_id: sessionId
  406. })
  407. })
  408. .then(response => response.json())
  409. .then(data => {
  410. if (data.results && data.results.length > 0) {
  411. let html = '';
  412. data.results.forEach(doctor => {
  413. // 截断描述文本,超过40字显示省略号
  414. const description = doctor.description && doctor.description.length > 40
  415. ? doctor.description.substring(0, 40) + '...'
  416. : (doctor.description || '暂无描述');
  417. html += `
  418. <div class="card mb-2 doctor-card" onclick="showDoctorDetail('${escapeHtml(doctor.name || '未知医生')}', '${escapeHtml(doctor.description || '')}', ${Math.round((doctor.score || 0) * 100)})">
  419. <div class="card-body p-2">
  420. <div class="d-flex align-items-center mb-2">
  421. <div class="flex-shrink-0">
  422. <div class="avatar-md bg-light rounded-circle d-flex align-items-center justify-content-center" >
  423. <i class="fas fa-user-md fa-lg text-primary"></i>
  424. </div>
  425. </div>
  426. <div class="flex-grow-1 ms-2">
  427. <h6 class="card-title mb-0" >${escapeHtml(doctor.name || '未知医生')}</h6>
  428. </div>
  429. <div class="d-flex align-items-center">
  430. <span class="badge bg-light text-dark me-2" >${escapeHtml(doctor.department || '未知科室')}</span>
  431. <button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); showDoctorDetail('${escapeHtml(doctor.name || '未知医生')}', '${escapeHtml(doctor.description || '')}', ${Math.round((doctor.score || 0) * 100)})">
  432. <i class="fas fa-calendar-check me-1"></i> 预约
  433. </button>
  434. </div>
  435. </div>
  436. <p class="card-text small text-muted mb-0" title="${escapeHtml(doctor.description || '')}" >${escapeHtml(description)}</p>
  437. </div>
  438. </div>`;
  439. });
  440. container.innerHTML = html;
  441. // 标记为已加载
  442. container.dataset.loaded = 'true';
  443. } else {
  444. container.innerHTML = '<div class="alert alert-warning">未找到相关医生</div>';
  445. container.dataset.loaded = 'true';
  446. }
  447. })
  448. .catch(error => {
  449. console.error('加载医生失败:', error);
  450. container.innerHTML = '<div class="alert alert-danger">加载医生失败</div>';
  451. container.dataset.loaded = 'true';
  452. });
  453. }
  454. // HTML转义函数
  455. function escapeHtml(unsafe) {
  456. if (!unsafe) return '';
  457. return unsafe
  458. .replace(/&/g, "&amp;")
  459. .replace(/</g, "&lt;")
  460. .replace(/>/g, "&gt;")
  461. .replace(/"/g, "&quot;")
  462. .replace(/'/g, "&#039;");
  463. }
  464. // 显示医生详情
  465. function showDoctorDetail(doctorName, description, matchScore) {
  466. const modalHtml = `
  467. <div class="modal fade" id="doctorDetailModal" tabindex="-1" aria-labelledby="doctorDetailModalLabel" aria-hidden="true">
  468. <div class="modal-dialog modal-lg">
  469. <div class="modal-content">
  470. <div class="modal-header">
  471. <h5 class="modal-title" id="doctorDetailModalLabel">
  472. <i class="fas fa-user-md me-2"></i>${doctorName}
  473. </h5>
  474. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  475. </div>
  476. <div class="modal-body">
  477. <div class="row">
  478. <div class="col-md-4 text-center">
  479. <div class="avatar-lg bg-light rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3" >
  480. <i class="fas fa-user-md fa-3x text-primary"></i>
  481. </div>
  482. <h6 class="text-primary">${doctorName}</h6>
  483. <span class="badge bg-primary">匹配度: ${matchScore}%</span>
  484. </div>
  485. <div class="col-md-8">
  486. <h6>专业描述</h6>
  487. <p class="text-muted">${description}</p>
  488. <hr>
  489. <h6>联系方式</h6>
  490. <p class="text-muted">请通过医院前台或预约系统联系</p>
  491. <hr>
  492. <h6>出诊时间</h6>
  493. <p class="text-muted">具体出诊时间请咨询医院</p>
  494. </div>
  495. </div>
  496. </div>
  497. <div class="modal-footer">
  498. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
  499. <button type="button" class="btn btn-primary">
  500. <i class="fas fa-calendar-check me-1"></i> 预约挂号
  501. </button>
  502. </div>
  503. </div>
  504. </div>
  505. </div>
  506. `;
  507. // 移除已存在的模态框
  508. const existingModal = document.getElementById('doctorDetailModal');
  509. if (existingModal) {
  510. existingModal.remove();
  511. }
  512. // 添加新的模态框
  513. document.body.insertAdjacentHTML('beforeend', modalHtml);
  514. // 显示模态框
  515. const modal = new bootstrap.Modal(document.getElementById('doctorDetailModal'));
  516. modal.show();
  517. }
  518. // 事件监听
  519. sendBtn.addEventListener('click', sendMessage);
  520. messageInput.addEventListener('keypress', function (e) {
  521. if (e.key === 'Enter' && !e.shiftKey) {
  522. e.preventDefault();
  523. sendMessage();
  524. }
  525. });
  526. // 初始聚焦输入框
  527. messageInput.focus();
  528. });
  529. </script>
  530. </body>
  531. </html>