更新
This commit is contained in:
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
||||
@@ -34,17 +35,179 @@ from doctor_workstation.services import (
|
||||
build_repository,
|
||||
)
|
||||
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||
from doctor_workstation.ui.widgets import (
|
||||
friendly_error,
|
||||
run_async,
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||
from doctor_workstation.ui.widgets import (
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
run_async,
|
||||
set_authentication_expired_handler,
|
||||
show_toast,
|
||||
)
|
||||
from doctor_workstation.video import BackendMode, launch_video_call
|
||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return ""
|
||||
if isinstance(value, Mapping):
|
||||
parts = [
|
||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||
for key, item in value.items()
|
||||
if item not in (None, "", [], {})
|
||||
]
|
||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(
|
||||
part
|
||||
for item in value
|
||||
if (part := _video_case_text(item, limit=limit))
|
||||
)[:limit]
|
||||
return str(value).strip()[:limit]
|
||||
|
||||
|
||||
def _video_identity(value: Any) -> str:
|
||||
if value in (None, "") or isinstance(value, bool):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
with suppress(ValueError, TypeError):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||
normalized_actual = _video_identity(actual)
|
||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||
|
||||
|
||||
def _build_video_patient_case(
|
||||
detail: Any,
|
||||
fallback_record: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
patient_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||
|
||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||
nested = get_value(detail, "data", None)
|
||||
if isinstance(nested, Mapping):
|
||||
detail = nested
|
||||
diagnosis = get_value(detail, "diagnosis", None)
|
||||
if not diagnosis and isinstance(detail, Mapping):
|
||||
diagnosis = detail
|
||||
diagnosis = diagnosis or {}
|
||||
patient = get_value(detail, "patient", None) or {}
|
||||
appointment = get_value(detail, "appointment", None) or {}
|
||||
|
||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||
detail_patient_id = first_value(
|
||||
diagnosis,
|
||||
"source_patient_id",
|
||||
default=first_value(
|
||||
patient,
|
||||
"source_patient_id",
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
detail_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||
detail = diagnosis = patient = appointment = {}
|
||||
|
||||
fallback_diagnosis_id = first_value(
|
||||
fallback_record,
|
||||
"diagnosis_id",
|
||||
"id",
|
||||
default=None,
|
||||
)
|
||||
fallback_patient_id = first_value(
|
||||
fallback_record,
|
||||
"source_patient_id",
|
||||
"patient_id",
|
||||
default=None,
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
fallback_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||
fallback_record = {}
|
||||
sources = (diagnosis, patient, appointment, fallback_record)
|
||||
|
||||
def pick(*keys: str, limit: int = 2000) -> str:
|
||||
for source in sources:
|
||||
value = first_value(source, *keys, default=None)
|
||||
text = _video_case_text(value, limit=limit)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
raw_gender = next(
|
||||
(
|
||||
first_value(source, "gender_desc", "gender", default=None)
|
||||
for source in sources
|
||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||
"name": pick("patient_name", "name", limit=120)
|
||||
or _video_case_text(patient_name, limit=120)
|
||||
or "患者",
|
||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||
"age": pick("age", limit=20),
|
||||
"height": pick("height", limit=20),
|
||||
"weight": pick("weight", limit=20),
|
||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||
"appointmentDate": pick(
|
||||
"appointment_date",
|
||||
"latest_appointment_date",
|
||||
limit=80,
|
||||
),
|
||||
"clinicalDiagnosis": pick(
|
||||
"clinical_diagnosis",
|
||||
"diagnosis_name",
|
||||
"disease_name",
|
||||
),
|
||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||
"allergyHistory": pick(
|
||||
"allergy_history_text",
|
||||
"allergy_history_desc",
|
||||
"allergy_history",
|
||||
),
|
||||
"personalHistory": pick(
|
||||
"personal_history_text",
|
||||
"personal_history_desc",
|
||||
"personal_history",
|
||||
),
|
||||
"familyHistory": pick(
|
||||
"family_history_text",
|
||||
"family_history_desc",
|
||||
"family_history",
|
||||
),
|
||||
"currentMedication": pick(
|
||||
"current_medications",
|
||||
"current_medicine",
|
||||
"current_medication",
|
||||
),
|
||||
"tongue": pick("tongue", "tongue_coating"),
|
||||
"pulse": pick("pulse", "pulse_condition"),
|
||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||
"remark": pick("remark"),
|
||||
}
|
||||
|
||||
|
||||
class _ChineseQtTranslator(QTranslator):
|
||||
@@ -247,9 +410,11 @@ class ApplicationController(QObject):
|
||||
self.shell_window: ShellWindow | None = None
|
||||
self.current_repository: Any = None
|
||||
self.current_demo_mode = config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
self._restore_generation = 0
|
||||
self._restore_in_progress = False
|
||||
self._restore_worker: Any = None
|
||||
@@ -547,16 +712,17 @@ class ApplicationController(QObject):
|
||||
self._logout(message="登录状态已失效,请重新登录。")
|
||||
return True
|
||||
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
calls = tuple(self.video_calls.values())
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
for dialog in self.demo_video_dialogs.values():
|
||||
dialog.close()
|
||||
self.demo_video_dialogs.clear()
|
||||
@@ -579,7 +745,8 @@ class ApplicationController(QObject):
|
||||
patient_id = payload.get("patient_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
@@ -633,27 +800,49 @@ class ApplicationController(QObject):
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
|
||||
def get_ticket() -> Any:
|
||||
return repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
try:
|
||||
detail = detail_loader(int(diagnosis_id))
|
||||
except Exception as error:
|
||||
LOGGER.warning(
|
||||
"patient detail could not be loaded for video call",
|
||||
extra={
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
)
|
||||
patient_case = _build_video_patient_case(
|
||||
detail,
|
||||
fallback_record,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
return ticket, patient_case
|
||||
|
||||
def request_ticket() -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
run_async(
|
||||
get_ticket,
|
||||
on_success=lambda ticket: self._launch_video(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
run_async(
|
||||
get_video_context,
|
||||
on_success=lambda context: self._launch_video(
|
||||
context[0],
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
call_key=call_key,
|
||||
marker=marker,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=context[1],
|
||||
),
|
||||
on_error=lambda error: self._video_ticket_error(
|
||||
call_key,
|
||||
marker,
|
||||
@@ -696,8 +885,9 @@ class ApplicationController(QObject):
|
||||
repository: Any,
|
||||
call_key: str,
|
||||
marker: object,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
@@ -723,9 +913,13 @@ class ApplicationController(QObject):
|
||||
local_dist=video_dist_path(),
|
||||
remote_url=self.config.video_web_url or None,
|
||||
logger=logging.getLogger("doctor_workstation.video"),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
LOGGER.exception("video call could not be launched")
|
||||
show_toast(
|
||||
@@ -734,20 +928,123 @@ class ApplicationController(QObject):
|
||||
"danger",
|
||||
5600,
|
||||
)
|
||||
return
|
||||
self.video_calls[call_key] = call
|
||||
return
|
||||
self.video_calls[call_key] = call
|
||||
qt_window = getattr(call, "qt_window", None)
|
||||
if qt_window is not None:
|
||||
qt_window.destroyed.connect(
|
||||
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
||||
key,
|
||||
expected,
|
||||
)
|
||||
)
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
if shell is None or self.current_repository is None:
|
||||
return
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||
|
||||
current = self._video_preview_state
|
||||
if current is not None and current.get("window") is not video_window:
|
||||
self._restore_video_preview(activate=False)
|
||||
|
||||
self._video_preview_generation += 1
|
||||
generation = self._video_preview_generation
|
||||
if current is None or current.get("window") is not video_window:
|
||||
try:
|
||||
state = {
|
||||
"window": video_window,
|
||||
"geometry": video_window.geometry(),
|
||||
"minimum_size": video_window.minimumSize(),
|
||||
"maximized": video_window.isMaximized(),
|
||||
"full_screen": video_window.isFullScreen(),
|
||||
"stays_on_top": bool(
|
||||
video_window.windowFlags()
|
||||
& Qt.WindowType.WindowStaysOnTopHint
|
||||
),
|
||||
}
|
||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||
available = screen.availableGeometry()
|
||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
video_window.resize(preview_width, preview_height)
|
||||
video_window.move(
|
||||
available.x() + available.width() - preview_width - margin,
|
||||
available.y() + margin,
|
||||
)
|
||||
video_window.show()
|
||||
self._video_preview_state = state
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
return
|
||||
|
||||
dialog.finished.connect(
|
||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||
)
|
||||
try:
|
||||
video_window.raise_()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
|
||||
def _restore_video_preview(
|
||||
self,
|
||||
generation: int | None = None,
|
||||
*,
|
||||
activate: bool = True,
|
||||
) -> None:
|
||||
if generation is not None and generation != self._video_preview_generation:
|
||||
return
|
||||
state = self._video_preview_state
|
||||
if state is None:
|
||||
return
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
window = state.get("window")
|
||||
try:
|
||||
window.setWindowFlag(
|
||||
Qt.WindowType.WindowStaysOnTopHint,
|
||||
bool(state.get("stays_on_top")),
|
||||
)
|
||||
window.setMinimumSize(state["minimum_size"])
|
||||
window.setGeometry(state["geometry"])
|
||||
if state.get("full_screen"):
|
||||
window.showFullScreen()
|
||||
elif state.get("maximized"):
|
||||
window.showMaximized()
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
preview = self._video_preview_state
|
||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
|
||||
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||
@@ -778,15 +1075,16 @@ class ApplicationController(QObject):
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
def shutdown(self) -> None:
|
||||
"""Invalidate asynchronous restoration and release owned resources."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
set_authentication_expired_handler(None)
|
||||
calls = tuple(self.video_calls.values())
|
||||
self._cancel_session_restore()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
|
||||
@@ -15,6 +15,7 @@ from urllib.parse import urlsplit
|
||||
from PySide6.QtCore import QObject, QPointF, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QIcon,
|
||||
QKeyEvent,
|
||||
QPainter,
|
||||
@@ -22,6 +23,9 @@ from PySide6.QtGui import (
|
||||
QPen,
|
||||
QPixmap,
|
||||
QPolygonF,
|
||||
QTextBlockFormat,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
@@ -44,7 +48,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ..diagnosis_drawer import CaseGrid, _RemoteImageButton
|
||||
from ..diagnosis_media import open_safe_http_url, safe_http_url
|
||||
from ..theme import crisp_pixmap, mark_business_dialog
|
||||
from ..theme import apply_reading_rhythm, crisp_pixmap, mark_business_dialog
|
||||
from ..widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
@@ -790,14 +794,24 @@ _DOCTOR_DOCUMENT_CSS = (
|
||||
"li { margin:3px 0; line-height:1.58; } "
|
||||
"a { color:#DDE1FF; } code,pre { color:#FFFFFF; }"
|
||||
)
|
||||
# 模型回复几乎都是“分节标题 + 两层要点”的长文。早期样式里标题只比正文大 1px、
|
||||
# 段落与列表项的间距也几乎相同,整段读起来就是一堵字墙。这里把层级、行距和分节
|
||||
# 留白拉开:标题明显更大更重,顶层小节之间留出整行空白,嵌套要点单独收紧缩进。
|
||||
_AI_DOCUMENT_CSS = (
|
||||
"body { color:#34436B; font-size:14px; line-height:1.68; } "
|
||||
"h1,h2,h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||
"p { margin:6px 0; line-height:1.68; } ul,ol { margin:7px 0 7px 21px; } "
|
||||
"li { margin:4px 0; line-height:1.62; } "
|
||||
"strong { color:#15224A; } a { color:#5B67F1; } "
|
||||
"body { color:#3A4870; font-size:14px; line-height:200%; } "
|
||||
"h1,h2 { color:#111B3F; font-size:17px; margin:22px 0 10px; line-height:160%; } "
|
||||
"h3,h4 { color:#15224A; font-size:15px; margin:18px 0 8px; line-height:160%; } "
|
||||
"p { margin:10px 0; line-height:200%; } "
|
||||
"ol { margin:12px 0 12px 22px; } "
|
||||
"ul { margin:8px 0 8px 20px; } "
|
||||
"li { margin:6px 0; line-height:190%; } "
|
||||
# 顶层小节之间留出整行空白,让四个小节一眼可数。
|
||||
"ol > li { margin:16px 0; } "
|
||||
"ol > li > strong { color:#111B3F; font-size:15px; } "
|
||||
"strong { color:#1B2A55; } a { color:#5B67F1; } "
|
||||
"hr { margin:20px 0; } "
|
||||
"code { color:#33406B; background:#EEF1FF; } "
|
||||
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:8px; }"
|
||||
"pre { color:#33406B; background:#F0F3FA; margin:12px 0; padding:10px; }"
|
||||
)
|
||||
_STRUCTURED_FIELD_LABELS = {
|
||||
"diagnosis": "诊断建议",
|
||||
@@ -1257,6 +1271,7 @@ def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") ->
|
||||
return
|
||||
if isinstance(raw, (Mapping, list, tuple)):
|
||||
browser.setMarkdown(_structured_markdown(raw))
|
||||
apply_reading_rhythm(browser, role=role)
|
||||
return
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
@@ -1269,12 +1284,15 @@ def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") ->
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
browser.setMarkdown(_structured_markdown(parsed))
|
||||
apply_reading_rhythm(browser, role=role)
|
||||
return
|
||||
lowered = text[:240].lower()
|
||||
if text.startswith("<") and any(marker in lowered for marker in _HTML_MARKERS):
|
||||
browser.setHtml(text)
|
||||
apply_reading_rhythm(browser, role=role)
|
||||
return
|
||||
browser.setMarkdown(text)
|
||||
apply_reading_rhythm(browser, role=role)
|
||||
|
||||
|
||||
def _unwrap_analysis(payload: Any) -> dict[str, Any]:
|
||||
@@ -1894,6 +1912,75 @@ def _parse_clinical_analysis(raw: Any) -> _ClinicalAnalysisModel | None:
|
||||
)
|
||||
|
||||
|
||||
#: One section of a generic structured reply: a title plus its bullet points.
|
||||
_ReportSection = namedtuple("_ReportSection", "title items")
|
||||
|
||||
_REPORT_NUMBERED_RE = re.compile(r"^\s*(\d{1,2})\s*[.、))]\s*(.+?)\s*$")
|
||||
_REPORT_BULLET_RE = re.compile(r"^\s*[-*+•○]\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def parse_structured_report(raw: Any) -> tuple[str, tuple[_ReportSection, ...], str] | None:
|
||||
"""Split a sectioned reply into ``(intro, sections, disclaimer)``.
|
||||
|
||||
The clinical panel only recognises four fixed section names (证候分析,
|
||||
信息缺口, 建议下一步, 风险提示), so the sectioned answers models actually
|
||||
return — "症状演变与疗效评估", "血糖控制与监测细节", … — fell through to plain
|
||||
text. This accepts any reply that is genuinely sectioned: two or more
|
||||
numbered or ``#`` headings, each carrying at least one bullet.
|
||||
"""
|
||||
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
text = raw.strip()
|
||||
if len(text) < 120 or text[0] in "[{<" or "```" in text:
|
||||
return None
|
||||
|
||||
intro: list[str] = []
|
||||
disclaimer: list[str] = []
|
||||
sections: list[_ReportSection] = []
|
||||
collecting_disclaimer = False
|
||||
for line in text.replace("\r\n", "\n").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or set(stripped) <= {"-", "*", "_"}:
|
||||
continue
|
||||
plain = _plain_markdown(stripped)
|
||||
if collecting_disclaimer or plain.startswith(("重要提示", "免责声明", "提示:", "安全提示")):
|
||||
collecting_disclaimer = True
|
||||
disclaimer.append(plain)
|
||||
continue
|
||||
|
||||
bullet = _REPORT_BULLET_RE.match(line)
|
||||
if bullet is not None and sections:
|
||||
sections[-1].items.append(_plain_markdown(bullet.group(1)))
|
||||
continue
|
||||
|
||||
heading = ""
|
||||
if stripped.startswith("#"):
|
||||
heading = _plain_markdown(stripped.lstrip("#").strip())
|
||||
else:
|
||||
numbered = _REPORT_NUMBERED_RE.match(_plain_markdown(stripped))
|
||||
# 顶层小节标题很短;带句号的长句是正文,不是标题。
|
||||
if numbered is not None and len(numbered.group(2)) <= 28:
|
||||
heading = numbered.group(2)
|
||||
if heading:
|
||||
sections.append(_ReportSection(heading, []))
|
||||
continue
|
||||
|
||||
if sections:
|
||||
sections[-1].items.append(plain)
|
||||
elif bullet is None:
|
||||
intro.append(plain)
|
||||
|
||||
usable = [section for section in sections if section.items]
|
||||
if len(usable) < 2:
|
||||
return None
|
||||
return (
|
||||
" ".join(intro).strip(),
|
||||
tuple(_ReportSection(s.title, tuple(s.items)) for s in usable),
|
||||
" ".join(disclaimer).strip(),
|
||||
)
|
||||
|
||||
|
||||
_InsightItem = namedtuple("_InsightItem", "kind marker label text")
|
||||
|
||||
_INSIGHT_LIST_RE = re.compile(r"^\s*(\d{1,2})\s*([\.、))]\s*)")
|
||||
@@ -2088,6 +2175,110 @@ class _RichMessage(QTextBrowser):
|
||||
self._fitting = False
|
||||
|
||||
|
||||
class _StructuredReportPanel(QWidget):
|
||||
"""Scan-first report for any sectioned reply.
|
||||
|
||||
Renders each section as its own card with a numbered chip, so a doctor can
|
||||
count and jump between sections instead of reading a continuous column of
|
||||
bullets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
intro: str,
|
||||
sections: Sequence[_ReportSection],
|
||||
disclaimer: str,
|
||||
*,
|
||||
time_text: str = "",
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("AiConsultClinicalPanel")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
self.sections = tuple(sections)
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(12)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.setSpacing(9)
|
||||
title = QLabel("AI 结构化报告")
|
||||
title.setObjectName("AiConsultClinicalTitle")
|
||||
header.addWidget(title)
|
||||
self.meta_label = QLabel(time_text)
|
||||
self.meta_label.setObjectName("AiConsultClinicalMeta")
|
||||
header.addWidget(self.meta_label)
|
||||
header.addStretch(1)
|
||||
badge = QLabel(f"{len(self.sections)} 个要点")
|
||||
badge.setObjectName("AiConsultClinicalBadge")
|
||||
badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
header.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
root.addLayout(header)
|
||||
|
||||
if intro:
|
||||
summary = QFrame(self)
|
||||
summary.setObjectName("AiConsultClinicalSummary")
|
||||
summary.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
_style_surface(summary)
|
||||
summary_layout = QVBoxLayout(summary)
|
||||
summary_layout.setContentsMargins(13, 11, 13, 11)
|
||||
summary_layout.setSpacing(4)
|
||||
body = QLabel(intro, summary)
|
||||
body.setObjectName("AiConsultClinicalSummaryBody")
|
||||
body.setWordWrap(True)
|
||||
summary_layout.addWidget(body)
|
||||
root.addWidget(summary)
|
||||
|
||||
for index, section in enumerate(self.sections, start=1):
|
||||
root.addWidget(self._section_card(index, section))
|
||||
|
||||
if disclaimer:
|
||||
note = QLabel(disclaimer, self)
|
||||
note.setObjectName("AiConsultClinicalDisclaimer")
|
||||
note.setWordWrap(True)
|
||||
note.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
root.addWidget(note)
|
||||
# 富余高度归到底部,否则会被平摊进每张卡片,把报告拉得空空荡荡。
|
||||
root.addStretch(1)
|
||||
|
||||
def _section_card(self, index: int, section: _ReportSection) -> QFrame:
|
||||
card = QFrame(self)
|
||||
card.setObjectName("AiConsultClinicalSection")
|
||||
card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
_style_surface(card)
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
head = QHBoxLayout()
|
||||
head.setContentsMargins(0, 0, 0, 0)
|
||||
head.setSpacing(8)
|
||||
chip = QLabel(str(index), card)
|
||||
chip.setObjectName("AiConsultHypothesisTag")
|
||||
chip.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
head.addWidget(chip)
|
||||
title = QLabel(section.title, card)
|
||||
title.setObjectName("AiConsultClinicalSectionTitle")
|
||||
title.setWordWrap(True)
|
||||
head.addWidget(title, 1)
|
||||
layout.addLayout(head)
|
||||
|
||||
for item in section.items:
|
||||
row = QFrame(card)
|
||||
row.setObjectName("AiConsultGapRow")
|
||||
_style_surface(row)
|
||||
row_layout = QHBoxLayout(row)
|
||||
row_layout.setContentsMargins(11, 9, 11, 9)
|
||||
row_layout.setSpacing(8)
|
||||
body = QLabel(item, row)
|
||||
body.setObjectName("AiConsultClinicalBody")
|
||||
body.setWordWrap(True)
|
||||
row_layout.addWidget(body, 1)
|
||||
layout.addWidget(row)
|
||||
return card
|
||||
|
||||
|
||||
class _ClinicalAnalysisPanel(QWidget):
|
||||
"""High-contrast, scan-first presentation for completed clinical replies."""
|
||||
|
||||
@@ -2499,14 +2690,27 @@ class _ChatBubble(QWidget):
|
||||
if self._role != "ai" or self.body is None:
|
||||
return False
|
||||
model = _parse_clinical_analysis(self._raw_payload)
|
||||
if model is None:
|
||||
panel: QWidget | None = None
|
||||
if model is not None:
|
||||
panel = _ClinicalAnalysisPanel(
|
||||
model,
|
||||
time_text=self.stamp.text(),
|
||||
parent=self._bubble_frame,
|
||||
)
|
||||
else:
|
||||
# 临床面板只认四个固定小节名;其余同样分好节的长回复走通用结构化报告,
|
||||
# 而不是退回成一整段纯文本。
|
||||
report = parse_structured_report(self._raw_payload)
|
||||
if report is not None:
|
||||
panel = _StructuredReportPanel(
|
||||
*report,
|
||||
time_text=self.stamp.text(),
|
||||
parent=self._bubble_frame,
|
||||
)
|
||||
if panel is None:
|
||||
return False
|
||||
self._reset_clinical_panel()
|
||||
panel = _ClinicalAnalysisPanel(
|
||||
model,
|
||||
time_text=self.stamp.text(),
|
||||
parent=self._bubble_frame,
|
||||
)
|
||||
panel.setParent(self._bubble_frame)
|
||||
self._clinical_panel = panel
|
||||
self._role_name.hide()
|
||||
self.stamp.hide()
|
||||
@@ -2691,6 +2895,14 @@ class _ClickCard(QFrame):
|
||||
# the doctor can see exactly what material is going to the model.
|
||||
AI_CONTEXT_MAX_CHARS = 320
|
||||
AI_PROMPT_LIMIT = 500
|
||||
|
||||
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
|
||||
AI_STREAM_PENDING_TEXT = "正在生成…"
|
||||
|
||||
#: 连接结束但一个字都没收到时的兜底文案。
|
||||
AI_STREAM_SILENT_TEXT = (
|
||||
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
|
||||
)
|
||||
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
|
||||
|
||||
|
||||
@@ -2998,6 +3210,10 @@ class AiConsultDialog(QDialog):
|
||||
self._patient_ai_context: str = ""
|
||||
self._patient_ai_context_labels: list[str] = []
|
||||
self._patient_ai_context_bubble: _ChatBubble | None = None
|
||||
# 全量上下文说明每轮问答都一样,只在本次会话第一次提问时提示一次。
|
||||
self._context_notice_shown = False
|
||||
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
|
||||
self._stream_completed = False
|
||||
self._follow_chat = True
|
||||
self._flush_timer = QTimer(self)
|
||||
self._flush_timer.setSingleShot(True)
|
||||
@@ -3005,6 +3221,14 @@ class AiConsultDialog(QDialog):
|
||||
self._flush_timer.timeout.connect(self._flush_stream_chunks)
|
||||
self.setObjectName("AiConsultDialog")
|
||||
self.setWindowTitle("问诊详情")
|
||||
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
||||
# 显式补上最大化/最小化按钮,并允许拖拽边角调整大小。
|
||||
self.setWindowFlags(
|
||||
self.windowFlags()
|
||||
| Qt.WindowType.WindowMinimizeButtonHint
|
||||
| Qt.WindowType.WindowMaximizeButtonHint
|
||||
)
|
||||
self.setSizeGripEnabled(True)
|
||||
self.resize(1280, 820)
|
||||
self.setMinimumSize(1080, 680)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
@@ -3047,6 +3271,8 @@ class AiConsultDialog(QDialog):
|
||||
self._patient_ai_context = ""
|
||||
self._patient_ai_context_labels = []
|
||||
self._patient_ai_context_bubble = None
|
||||
# 换患者等于开启新会话,全量上下文说明需要再提示一次。
|
||||
self._context_notice_shown = False
|
||||
# Appointment seeds are presentation hints only. In the admin list,
|
||||
# ``patient_id`` may actually be the diagnosis id, so it must never
|
||||
# replace the separately supplied patient owner.
|
||||
@@ -5281,12 +5507,18 @@ class AiConsultDialog(QDialog):
|
||||
self._append_bubble(
|
||||
"doctor", text, time_text=datetime.now().strftime("%H:%M")
|
||||
)
|
||||
self._append_bubble(
|
||||
"ai",
|
||||
"服务端将按当前诊单实时附带患者全部纵向资料(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
|
||||
time_text="全量上下文",
|
||||
)
|
||||
self._stream_bubble = self._append_bubble("ai", "")
|
||||
if not self._context_notice_shown:
|
||||
self._append_bubble(
|
||||
"ai",
|
||||
"服务端将按当前诊单实时附带患者全部纵向资料"
|
||||
"(病历、备注/舌苔、报告、日常记录、视频转写及历史处方)。",
|
||||
time_text="全量上下文",
|
||||
)
|
||||
self._context_notice_shown = True
|
||||
# 首帧到达前先占位。空气泡在后端无响应时会一直是一块空白卡片,
|
||||
# 医生无法判断是还在生成还是已经失败。
|
||||
self._stream_bubble = self._append_bubble("ai", AI_STREAM_PENDING_TEXT)
|
||||
self._stream_completed = False
|
||||
self._stream_text = ""
|
||||
self._pending_chunks.clear()
|
||||
self._stream_meta.clear()
|
||||
@@ -5341,6 +5573,7 @@ class AiConsultDialog(QDialog):
|
||||
if kind != "done":
|
||||
return
|
||||
self._stream_meta.update(payload)
|
||||
self._stream_completed = True
|
||||
if not self._stream_text and not self._pending_chunks:
|
||||
answer = first_value(payload, "answer", "content", default="")
|
||||
if answer not in (None, ""):
|
||||
@@ -5396,6 +5629,16 @@ class AiConsultDialog(QDialog):
|
||||
return
|
||||
if self._stream_worker is worker:
|
||||
self._stream_worker = None
|
||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
|
||||
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
|
||||
if (
|
||||
not self._stream_completed
|
||||
and not self._stream_text
|
||||
and self._stream_bubble is not None
|
||||
):
|
||||
self._stream_text = AI_STREAM_SILENT_TEXT
|
||||
self._stream_bubble.set_payload(self._stream_text)
|
||||
self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
@@ -5408,6 +5651,7 @@ class AiConsultDialog(QDialog):
|
||||
self._stream_bubble = None
|
||||
self._pending_chunks.clear()
|
||||
self._stream_text = ""
|
||||
self._stream_completed = False
|
||||
self._stream_meta.clear()
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
@@ -1911,8 +1911,20 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
|
||||
|
||||
def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None:
|
||||
self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True)
|
||||
def open_view_only(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
seed: Any = None,
|
||||
modeless: bool = False,
|
||||
) -> None:
|
||||
self.open_for(
|
||||
diagnosis_id,
|
||||
editable=False,
|
||||
seed=seed,
|
||||
view_only=True,
|
||||
modeless=modeless,
|
||||
)
|
||||
|
||||
def open_for(
|
||||
self,
|
||||
@@ -1921,6 +1933,7 @@ class DiagnosisDialog(QDialog):
|
||||
editable: bool = False,
|
||||
seed: Any = None,
|
||||
view_only: bool = False,
|
||||
modeless: bool = False,
|
||||
) -> None:
|
||||
"""Open immediately, then replace the seed with authoritative server data."""
|
||||
|
||||
@@ -1973,7 +1986,7 @@ class DiagnosisDialog(QDialog):
|
||||
self.view_stack.setCurrentWidget(
|
||||
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
||||
)
|
||||
self.setModal(not self._standalone_readonly)
|
||||
self.setModal(not modeless and not self._standalone_readonly)
|
||||
self.setWindowTitle(
|
||||
"患者信息详情"
|
||||
if self._standalone_readonly
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ from doctor_workstation.resources import app_icon_path
|
||||
|
||||
from .dialogs.ai_consult import can_open_ai_consult
|
||||
from .dialogs.ai_consult_picker import select_and_present_ai_consult
|
||||
from .dialogs.diagnosis import DiagnosisDialog
|
||||
from .dialogs.local_audio_queue import LocalAudioQueueDialog
|
||||
from .pages import (
|
||||
AppointmentsPage,
|
||||
@@ -59,6 +60,7 @@ from .widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
get_value,
|
||||
has_permission,
|
||||
show_toast,
|
||||
)
|
||||
|
||||
@@ -895,6 +897,7 @@ class ShellWindow(QMainWindow):
|
||||
self._activation_generation = 0
|
||||
self._activation_refreshed = False
|
||||
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
|
||||
self._global_diagnosis_dialog: DiagnosisDialog | None = None
|
||||
|
||||
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
|
||||
screen = self.screen() or QApplication.primaryScreen()
|
||||
@@ -1845,6 +1848,54 @@ class ShellWindow(QMainWindow):
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
|
||||
def _ensure_global_diagnosis_dialog(self) -> DiagnosisDialog:
|
||||
dialog = self._global_diagnosis_dialog
|
||||
if dialog is None:
|
||||
dialog = DiagnosisDialog(
|
||||
self.repository,
|
||||
self,
|
||||
permissions=self.permissions,
|
||||
)
|
||||
dialog.saved.connect(self.refresh_current_page)
|
||||
self._global_diagnosis_dialog = dialog
|
||||
else:
|
||||
dialog.refresh_permissions(self.permissions)
|
||||
return dialog
|
||||
|
||||
def open_diagnosis_by_id(
|
||||
self,
|
||||
diagnosis_id: Any,
|
||||
*,
|
||||
modeless: bool = False,
|
||||
) -> DiagnosisDialog | None:
|
||||
"""Open one authoritative diagnosis in the strongest permitted mode."""
|
||||
|
||||
try:
|
||||
normalized_id = int(diagnosis_id)
|
||||
except (TypeError, ValueError):
|
||||
normalized_id = 0
|
||||
if normalized_id <= 0:
|
||||
show_toast(self, "当前视频缺少有效诊单编号。", "warning", 3600)
|
||||
return None
|
||||
|
||||
if has_permission(self.permissions, "tcm.diagnosis/edit", default=False):
|
||||
dialog = self._ensure_global_diagnosis_dialog()
|
||||
dialog.open_for(normalized_id, editable=True, modeless=modeless)
|
||||
elif has_permission(
|
||||
self.permissions,
|
||||
"tcm.diagnosis/readonlyDetail",
|
||||
default=False,
|
||||
):
|
||||
dialog = self._ensure_global_diagnosis_dialog()
|
||||
dialog.open_view_only(normalized_id, modeless=modeless)
|
||||
else:
|
||||
show_toast(self, "当前账号没有查看该诊单的权限。", "danger", 4200)
|
||||
return None
|
||||
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
return dialog
|
||||
|
||||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().showEvent(event)
|
||||
page = self.stack.currentWidget()
|
||||
|
||||
@@ -15,9 +15,19 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPixmap
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QPalette,
|
||||
QPixmap,
|
||||
QTextBlockFormat,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -25,6 +35,7 @@ from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
QTextBrowser,
|
||||
)
|
||||
|
||||
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
|
||||
@@ -997,6 +1008,111 @@ QToolTip {
|
||||
).substitute(_QSS_TOKENS)
|
||||
|
||||
|
||||
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
|
||||
#: QTextDocument's markdown importer builds block formats directly and ignores
|
||||
#: ``setDefaultStyleSheet``, so spacing has to be applied to the parsed
|
||||
#: document instead of being declared in CSS.
|
||||
# 行内收紧、行间放开:一条要点自己的折行必须比两条要点之间更紧,否则整段会散成
|
||||
# 一行一行的碎片,反而更难读。
|
||||
_BLOCK_RHYTHM = {
|
||||
"heading": (22, 10, 148),
|
||||
"subheading": (18, 8, 148),
|
||||
# 顶层列表项通常就是小节标题,之间留出整行空白让各小节一眼可数。
|
||||
"section": (20, 8, 150),
|
||||
"item": (13, 5, 158), # nested bullet under a section
|
||||
"paragraph": (10, 10, 168),
|
||||
}
|
||||
|
||||
|
||||
def _block_is_all_bold(block: Any) -> bool:
|
||||
"""True when every visible run in the block is bold.
|
||||
|
||||
Models title their sections as ``1. **症状演变与疗效评估**`` rather than as
|
||||
real markdown headings, so a fully bold top-level list item is the only
|
||||
reliable signal that a block is a section title and not a content bullet.
|
||||
"""
|
||||
|
||||
iterator = block.begin()
|
||||
seen = False
|
||||
while not iterator.atEnd():
|
||||
fragment = iterator.fragment()
|
||||
iterator += 1
|
||||
if not fragment.isValid() or not fragment.text().strip():
|
||||
continue
|
||||
seen = True
|
||||
if fragment.charFormat().fontWeight() < QFont.Weight.DemiBold.value:
|
||||
return False
|
||||
return seen
|
||||
|
||||
|
||||
def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
|
||||
"""Give a parsed reply real hierarchy and breathing room.
|
||||
|
||||
Model answers are long "section heading + two levels of bullets" documents.
|
||||
Qt renders every one of those blocks at the same size with 6px margins, so
|
||||
the reply arrives as a wall of text. This walks the parsed document once
|
||||
and applies a heading scale plus per-level spacing.
|
||||
"""
|
||||
|
||||
document = browser.document()
|
||||
base_font = browser.font()
|
||||
base_px = base_font.pixelSize()
|
||||
if base_px <= 0:
|
||||
base_px = max(12, round(base_font.pointSizeF() * 96 / 72))
|
||||
|
||||
cursor = QTextCursor(document)
|
||||
cursor.beginEditBlock()
|
||||
try:
|
||||
block = document.begin()
|
||||
while block.isValid():
|
||||
heading = block.blockFormat().headingLevel()
|
||||
text_list = block.textList()
|
||||
indent = text_list.format().indent() if text_list is not None else 0
|
||||
if heading:
|
||||
kind = "heading" if heading <= 2 else "subheading"
|
||||
size = base_px + (3 if heading <= 2 else 1)
|
||||
elif indent >= 2:
|
||||
kind, size = "item", None
|
||||
elif indent == 1:
|
||||
kind = "section"
|
||||
# 只有整行加粗的顶层条目才是小节标题,普通要点保持正文字号。
|
||||
size = base_px + 2 if _block_is_all_bold(block) else None
|
||||
else:
|
||||
kind, size = "paragraph", None
|
||||
|
||||
top, bottom, line = _BLOCK_RHYTHM[kind]
|
||||
# 首块的上边距会在气泡顶部留出一段空白,视觉上像是排版错位。
|
||||
if block.position() == 0:
|
||||
top = 0
|
||||
block_format = QTextBlockFormat(block.blockFormat())
|
||||
block_format.setTopMargin(top)
|
||||
block_format.setBottomMargin(bottom)
|
||||
block_format.setLineHeight(
|
||||
line, QTextBlockFormat.LineHeightTypes.ProportionalHeight.value
|
||||
)
|
||||
cursor.setPosition(block.position())
|
||||
cursor.setBlockFormat(block_format)
|
||||
|
||||
if size is not None and block.length() > 1:
|
||||
heading_font = QFont(base_font)
|
||||
heading_font.setPixelSize(size)
|
||||
heading_font.setBold(True)
|
||||
char_format = QTextCharFormat()
|
||||
char_format.setFont(heading_font)
|
||||
if role != "doctor":
|
||||
char_format.setForeground(QColor("#111B3F"))
|
||||
cursor.setPosition(block.position())
|
||||
cursor.setPosition(
|
||||
block.position() + block.length() - 1,
|
||||
QTextCursor.MoveMode.KeepAnchor,
|
||||
)
|
||||
cursor.mergeCharFormat(char_format)
|
||||
block = block.next()
|
||||
finally:
|
||||
cursor.endEditBlock()
|
||||
|
||||
|
||||
|
||||
def _apply_group(
|
||||
palette: QPalette,
|
||||
group: QPalette.ColorGroup,
|
||||
@@ -1085,17 +1201,48 @@ def _polish_dialog_buttons(button_box: QDialogButtonBox) -> None:
|
||||
_refresh_widget_style(button)
|
||||
|
||||
|
||||
def allow_dialog_resize(dialog: QDialog) -> None:
|
||||
"""Give a business subwindow the usual minimise/maximise affordances.
|
||||
|
||||
``QDialog`` ships with a close button only, so every AI panel, report and
|
||||
editor was stuck at whatever size it was constructed with — unusable for the
|
||||
long, dense clinical replies these windows carry. Transient prompts
|
||||
(message and input boxes) are deliberately left alone, and a dialog that has
|
||||
pinned itself to a fixed size keeps that decision.
|
||||
"""
|
||||
|
||||
if isinstance(dialog, (QMessageBox, QInputDialog, QFileDialog)):
|
||||
return
|
||||
if dialog.minimumSize() == dialog.maximumSize():
|
||||
return
|
||||
# Qt hides a window whose flags change while it is visible, so a dialog that
|
||||
# is already on screen keeps the flags it was shown with.
|
||||
if dialog.isVisible():
|
||||
dialog.setSizeGripEnabled(True)
|
||||
return
|
||||
flags = dialog.windowFlags()
|
||||
if flags & Qt.WindowType.WindowMaximizeButtonHint:
|
||||
return
|
||||
dialog.setWindowFlags(
|
||||
flags
|
||||
| Qt.WindowType.WindowMinimizeButtonHint
|
||||
| Qt.WindowType.WindowMaximizeButtonHint
|
||||
)
|
||||
dialog.setSizeGripEnabled(True)
|
||||
|
||||
|
||||
def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> None:
|
||||
"""Opt a business subwindow into the shared visual contract.
|
||||
|
||||
The helper intentionally does not change modality, ownership or result
|
||||
handling. It only supplies stable styling metadata and semantic button
|
||||
roles, so existing workflows keep their original behavior.
|
||||
handling. It only supplies stable styling metadata, semantic button roles
|
||||
and window affordances, so existing workflows keep their original behavior.
|
||||
"""
|
||||
|
||||
if object_name and not dialog.objectName():
|
||||
dialog.setObjectName(object_name)
|
||||
dialog.setProperty("businessDialog", True)
|
||||
allow_dialog_resize(dialog)
|
||||
for button_box in dialog.findChildren(QDialogButtonBox):
|
||||
_polish_dialog_buttons(button_box)
|
||||
_refresh_widget_style(dialog)
|
||||
@@ -1119,6 +1266,12 @@ class _BusinessDialogStyleFilter(QObject):
|
||||
QEvent.Type.Show,
|
||||
}:
|
||||
_polish_dialog_buttons(watched)
|
||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
|
||||
# Window flags can only be changed before the dialog is on screen,
|
||||
# so dynamically-created dialogs get their affordances at polish
|
||||
# time rather than when they are already visible.
|
||||
if not self._is_native_or_overlay(watched):
|
||||
allow_dialog_resize(watched)
|
||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
||||
for button_box in watched.findChildren(QDialogButtonBox):
|
||||
_polish_dialog_buttons(button_box)
|
||||
@@ -1215,6 +1368,8 @@ def apply_theme(app: QApplication) -> None:
|
||||
|
||||
__all__ = [
|
||||
"COLORS",
|
||||
"allow_dialog_resize",
|
||||
"apply_reading_rhythm",
|
||||
"GLOBAL_QSS",
|
||||
"METRICS",
|
||||
"TYPE",
|
||||
|
||||
@@ -378,6 +378,8 @@ class VideoCallLauncher:
|
||||
patient_id: Any = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
) -> Any:
|
||||
request = self.prepare(
|
||||
ticket,
|
||||
@@ -395,6 +397,8 @@ class VideoCallLauncher:
|
||||
browser_opener=self.browser_opener,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=on_open_diagnosis,
|
||||
)
|
||||
|
||||
|
||||
@@ -411,6 +415,8 @@ def launch_video_call(
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
) -> Any:
|
||||
"""Normalize a ticket and open a call with the requested backend."""
|
||||
|
||||
@@ -427,4 +433,6 @@ def launch_video_call(
|
||||
patient_id=patient_id,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=on_open_diagnosis,
|
||||
)
|
||||
|
||||
@@ -296,6 +296,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
lifecycle_factory: Callable[[], OrderedCallLifecycle],
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.request = request
|
||||
@@ -306,6 +308,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self.logger = logger
|
||||
self.open_im = bool(open_im)
|
||||
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||
self.patient_case = dict(patient_case or {})
|
||||
self._on_open_diagnosis = on_open_diagnosis
|
||||
try:
|
||||
self._policy = TrustedDocumentPolicy.from_url(
|
||||
location.url,
|
||||
@@ -451,6 +455,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
config = {
|
||||
**self.request.to_web_config(),
|
||||
"patientName": self.patient_name,
|
||||
"patientCase": self.patient_case,
|
||||
"mode": "chat" if self.open_im else "video",
|
||||
}
|
||||
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
|
||||
@@ -499,6 +504,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
if self._closing:
|
||||
return
|
||||
event = str(message.get("event", ""))
|
||||
if event == "open-diagnosis-request":
|
||||
if self._on_open_diagnosis is not None:
|
||||
QTimer.singleShot(0, self._open_diagnosis_safely)
|
||||
return
|
||||
if event == "call-start-request":
|
||||
self._start_call_cycle()
|
||||
return
|
||||
@@ -580,6 +589,17 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
if not self.open_im or self._shutdown_requested:
|
||||
self._close_from_companion("companion-error")
|
||||
|
||||
def _open_diagnosis_safely(self) -> None:
|
||||
if self._closing or self._on_open_diagnosis is None:
|
||||
return
|
||||
try:
|
||||
self._on_open_diagnosis()
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"diagnosis drawer could not be opened from video companion",
|
||||
extra={"video_call": self.request.safe_log_context()},
|
||||
)
|
||||
|
||||
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
|
||||
try:
|
||||
succeeded = bool(future.result())
|
||||
@@ -1214,6 +1234,8 @@ class VideoCallWindow:
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
del browser_opener # Reserved for a future authenticated handoff implementation.
|
||||
try:
|
||||
@@ -1231,6 +1253,8 @@ class VideoCallWindow:
|
||||
self.repository = repository
|
||||
self.open_im = bool(open_im)
|
||||
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||
self.patient_case = dict(patient_case or {})
|
||||
self.on_open_diagnosis = on_open_diagnosis
|
||||
self.logger = logger or _LOGGER
|
||||
self.location = resolve_companion_location(
|
||||
local_dist=local_dist,
|
||||
@@ -1259,6 +1283,8 @@ class VideoCallWindow:
|
||||
lifecycle_factory=self._new_lifecycle,
|
||||
open_im=self.open_im,
|
||||
patient_name=self.patient_name,
|
||||
patient_case=self.patient_case,
|
||||
on_open_diagnosis=self.on_open_diagnosis,
|
||||
)
|
||||
except Exception:
|
||||
self.lifecycle.end("window-open-failed")
|
||||
@@ -1302,6 +1328,8 @@ def open_video_call(
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
on_open_diagnosis: Callable[[], None] | None = None,
|
||||
) -> VideoCallWindow:
|
||||
"""Create and immediately open a trusted embedded video window."""
|
||||
|
||||
@@ -1316,6 +1344,8 @@ def open_video_call(
|
||||
browser_opener=browser_opener,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=on_open_diagnosis,
|
||||
).open()
|
||||
|
||||
|
||||
|
||||
@@ -1291,3 +1291,234 @@ def test_ask_sends_only_question_and_relies_on_server_full_context(
|
||||
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
|
||||
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def _bubble_texts(dialog: AiConsultDialog) -> list[str]:
|
||||
return [
|
||||
widget.toPlainText()
|
||||
for widget in dialog.findChildren(QTextBrowser)
|
||||
if widget.objectName() == "AiConsultBubbleText"
|
||||
]
|
||||
|
||||
|
||||
def _silent_dialog(monkeypatch: pytest.MonkeyPatch) -> AiConsultDialog:
|
||||
"""A dialog whose stream workers are never actually started."""
|
||||
|
||||
monkeypatch.setattr(
|
||||
ai_consult_module,
|
||||
"QThreadPool",
|
||||
SimpleNamespace(
|
||||
globalInstance=lambda: SimpleNamespace(start=lambda worker: None)
|
||||
),
|
||||
)
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(),
|
||||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||
)
|
||||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||||
return dialog
|
||||
|
||||
|
||||
def test_full_context_notice_is_shown_once_per_conversation(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# 每轮问答都重复同一句全量上下文说明会把真正的回答挤出可视区。
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
notice = "服务端将按当前诊单实时附带患者全部纵向资料"
|
||||
counts = []
|
||||
for question in ("第一个问题", "第二个问题", "第三个问题"):
|
||||
dialog._cancel_stream()
|
||||
dialog._ask(question)
|
||||
counts.append(sum(1 for text in _bubble_texts(dialog) if notice in text))
|
||||
assert counts == [1, 1, 1]
|
||||
|
||||
# 换患者视为新会话,需要重新提示一次。
|
||||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||||
application.processEvents()
|
||||
dialog._ask("新患者的问题")
|
||||
assert sum(1 for text in _bubble_texts(dialog) if notice in text) == 1
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# 服务端既不发 done 也不报错时,占位气泡会永远停在空白状态。
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
dialog._ask("请评估当前用药是否合理")
|
||||
assert dialog._stream_bubble is not None
|
||||
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||
assert any(
|
||||
ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in _bubble_texts(dialog)
|
||||
)
|
||||
|
||||
dialog._stream_finished(
|
||||
dialog._generation, dialog._stream_generation, dialog._stream_worker
|
||||
)
|
||||
application.processEvents()
|
||||
assert dialog._stream_text == ai_consult_module.AI_STREAM_SILENT_TEXT
|
||||
assert any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in _bubble_texts(dialog))
|
||||
assert dialog.send_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_answered_stream_replaces_the_pending_placeholder(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
dialog._ask("请总结当前病情")
|
||||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
||||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
||||
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
||||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||
application.processEvents()
|
||||
|
||||
assert dialog._stream_text == "证候:脾肾两虚"
|
||||
texts = _bubble_texts(dialog)
|
||||
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
||||
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_window_can_be_maximized(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# 这个窗口信息密度很高,必须允许医生放大到整屏。
|
||||
dialog = _silent_dialog(monkeypatch)
|
||||
flags = dialog.windowFlags()
|
||||
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||||
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||||
assert dialog.isSizeGripEnabled()
|
||||
dialog.showMaximized()
|
||||
application.processEvents()
|
||||
assert dialog.isMaximized()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_long_reply_gets_section_hierarchy_and_breathing_room(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""QTextDocument's markdown importer ignores setDefaultStyleSheet.
|
||||
|
||||
Spacing therefore has to be applied to the parsed document; without it every
|
||||
block renders at one size with 6px margins and the reply reads as a wall.
|
||||
"""
|
||||
|
||||
browser = ai_consult_module._RichMessage("ai")
|
||||
browser.resize(660, 400)
|
||||
browser.set_payload(
|
||||
"概述段落。\n\n"
|
||||
"1. **症状演变与疗效评估**\n"
|
||||
" - **麻木症状:** 服药十四天后是否缓解?\n"
|
||||
" - **皮肤瘙痒:** 目前是否仍有发作?\n"
|
||||
"2. **血糖控制与监测细节**\n"
|
||||
" - **监测习惯:** 是否规律监测餐后血糖?\n"
|
||||
)
|
||||
|
||||
document = browser.document()
|
||||
base_px = browser.font().pixelSize()
|
||||
seen: dict[str, list] = {"section": [], "item": [], "paragraph": []}
|
||||
block = document.begin()
|
||||
while block.isValid():
|
||||
text_list = block.textList()
|
||||
indent = text_list.format().indent() if text_list is not None else 0
|
||||
kind = "item" if indent >= 2 else "section" if indent == 1 else "paragraph"
|
||||
seen[kind].append(block)
|
||||
block = block.next()
|
||||
|
||||
assert seen["section"] and seen["item"], "both list levels must be present"
|
||||
|
||||
# 小节标题比正文更大更重,否则四个小节无法一眼分辨。
|
||||
section = seen["section"][0]
|
||||
section_size = section.begin().fragment().charFormat().font().pixelSize()
|
||||
assert section_size > base_px
|
||||
|
||||
# 小节之间的留白必须大于同一小节内要点之间的留白。
|
||||
section_top = seen["section"][0].blockFormat().topMargin()
|
||||
item_top = seen["item"][0].blockFormat().topMargin()
|
||||
assert section_top > item_top > 0
|
||||
|
||||
# 一条要点的折行必须比两条要点之间更紧,否则整段会散成碎片。
|
||||
item_line = seen["item"][0].blockFormat().lineHeight()
|
||||
assert 0 < item_line < 170
|
||||
|
||||
# 首块不带上边距,避免气泡顶部出现一段空白。
|
||||
assert document.begin().blockFormat().topMargin() == 0
|
||||
|
||||
|
||||
def test_short_reply_is_not_over_spaced(application: QApplication) -> None:
|
||||
browser = ai_consult_module._RichMessage("ai")
|
||||
browser.resize(660, 200)
|
||||
browser.set_payload("血糖控制尚可,暂无需调整降糖方案。")
|
||||
block = browser.document().begin()
|
||||
assert block.blockFormat().topMargin() == 0
|
||||
assert block.next().isValid() is False
|
||||
|
||||
|
||||
def test_sectioned_reply_becomes_a_structured_report(application: QApplication) -> None:
|
||||
"""The clinical panel only knows four fixed section names.
|
||||
|
||||
Real answers are sectioned as 症状演变 / 血糖控制 / 用药依从性 …, which matched
|
||||
none of them and therefore fell back to a plain wall of text.
|
||||
"""
|
||||
|
||||
reply = (
|
||||
"以下是针对该患者当前情况,建议向患者确认的关键问诊问题,用于补充现有病历中的信息缺口:\n\n"
|
||||
"1. **症状演变与疗效评估**\n"
|
||||
" - **麻木症状:** 服药十四天后四肢麻木是否有所缓解?\n"
|
||||
" - **皮肤瘙痒:** 目前是否仍有发作?是否与血糖波动有关?\n"
|
||||
"2. **血糖控制与监测细节**\n"
|
||||
" - **空腹血糖波动:** 近期是否有反复的低血糖发作?\n"
|
||||
"3. **用药依从性与生活方式**\n"
|
||||
" - **西药服用情况:** 近期是否有漏服或自行调整剂量?\n\n"
|
||||
"**提示:** 以上问题基于现有脱敏病例资料梳理,需由执业医师复核后确定。\n"
|
||||
)
|
||||
parsed = ai_consult_module.parse_structured_report(reply)
|
||||
assert parsed is not None
|
||||
intro, sections, disclaimer = parsed
|
||||
assert [section.title for section in sections] == [
|
||||
"症状演变与疗效评估",
|
||||
"血糖控制与监测细节",
|
||||
"用药依从性与生活方式",
|
||||
]
|
||||
assert [len(section.items) for section in sections] == [2, 1, 1]
|
||||
assert "信息缺口" in intro
|
||||
assert "执业医师" in disclaimer
|
||||
|
||||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 11:01")
|
||||
assert bubble.finalize_clinical_analysis() is True
|
||||
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||||
assert panel is not None
|
||||
titles = [
|
||||
label.text()
|
||||
for label in panel.findChildren(QLabel)
|
||||
if label.objectName() == "AiConsultClinicalSectionTitle"
|
||||
]
|
||||
assert titles == ["症状演变与疗效评估", "血糖控制与监测细节", "用药依从性与生活方式"]
|
||||
bubble.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reply",
|
||||
[
|
||||
"血糖控制尚可,暂无需调整降糖方案。",
|
||||
"1. **只有一个小节**\n - 一条要点\n",
|
||||
'{"summary": "结构化 JSON 走既有解析路径"}',
|
||||
],
|
||||
)
|
||||
def test_unsectioned_replies_stay_plain_text(reply: str, application: QApplication) -> None:
|
||||
assert ai_consult_module.parse_structured_report(reply) is None
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.core.errors import ApiTimeoutError
|
||||
@@ -385,7 +385,10 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
||||
task: str,
|
||||
) -> dict[str, Any]:
|
||||
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
|
||||
return {"answer": "建议复核肾功能与眼底。", "model_key": "openai"}
|
||||
return {
|
||||
"answer": "### 核心建议\n\n**重点复核**\n\n- 肾功能\n- 眼底",
|
||||
"model_key": "openai",
|
||||
}
|
||||
|
||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||
dialog.open_for(501, "并发症筛查", task="complication_risk")
|
||||
@@ -393,15 +396,57 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
||||
assert calls == [
|
||||
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
|
||||
]
|
||||
assert dialog.answer_label.text() == "建议复核肾功能与眼底。"
|
||||
assert dialog.answer_label.toPlainText() == "核心建议\n重点复核\n肾功能\n眼底"
|
||||
assert "###" not in dialog.answer_label.toPlainText()
|
||||
assert "**" not in dialog.answer_label.toPlainText()
|
||||
rendered_html = dialog.answer_label.toHtml().lower()
|
||||
assert "<h3" in rendered_html
|
||||
assert "font-weight:700" in rendered_html.replace(" ", "")
|
||||
assert "openai" in dialog.model_label.text()
|
||||
assert dialog.answer_scroll.widget().findChild(QLabel, "PrescriptionAiBody") is dialog.answer_label
|
||||
assert isinstance(dialog.answer_label, QTextBrowser)
|
||||
assert dialog.answer_label.objectName() == "PrescriptionAiAnswer"
|
||||
assert dialog.answer_label.openLinks() is False
|
||||
assert dialog.answer_label.openExternalLinks() is False
|
||||
assert dialog.loading is False
|
||||
assert dialog.retry_button.isEnabled()
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_markdown_disables_model_supplied_html(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class Repository:
|
||||
def analyze_diagnosis_ai(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"answer": "### 安全内容\n\n<img src=\"https://invalid.example/pixel\">\n\n- 建议复诊",
|
||||
"model_key": "qwen",
|
||||
}
|
||||
|
||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||
dialog.open_for(501, "复诊建议", task="custom")
|
||||
|
||||
assert "安全内容" in dialog.answer_label.toPlainText()
|
||||
assert "建议复诊" in dialog.answer_label.toPlainText()
|
||||
assert "<img" in dialog.answer_label.toPlainText()
|
||||
assert 'src="https://invalid.example/pixel"' not in dialog.answer_label.toHtml()
|
||||
assert (
|
||||
dialog.answer_label.loadResource(
|
||||
ai_module.QTextDocument.ResourceType.ImageResource,
|
||||
"https://invalid.example/pixel",
|
||||
)
|
||||
is None
|
||||
)
|
||||
dialog.answer_label.selectAll()
|
||||
copied = dialog.answer_label.createMimeDataFromSelection()
|
||||
assert copied.hasText()
|
||||
assert copied.hasHtml() is False
|
||||
assert set(copied.formats()) == {"text/plain"}
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
@@ -288,6 +288,82 @@ def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
|
||||
assert shell_window.stack.currentWidget() is current
|
||||
|
||||
|
||||
def test_shell_global_diagnosis_entry_reuses_dialog_and_obeys_permissions(
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
opened: list[tuple[str, int]] = []
|
||||
refresh_callbacks: list[Any] = []
|
||||
created: list[Any] = []
|
||||
|
||||
class _SavedSignal:
|
||||
def connect(self, callback: Any) -> None:
|
||||
refresh_callbacks.append(callback)
|
||||
|
||||
class _DiagnosisDialogDouble:
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
parent: Any,
|
||||
*,
|
||||
permissions: Any,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.parent = parent
|
||||
self.permissions = permissions
|
||||
self.saved = _SavedSignal()
|
||||
self.raise_count = 0
|
||||
self.activate_count = 0
|
||||
created.append(self)
|
||||
|
||||
def refresh_permissions(self, permissions: Any) -> None:
|
||||
self.permissions = permissions
|
||||
|
||||
def open_for(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
*,
|
||||
editable: bool,
|
||||
modeless: bool,
|
||||
) -> None:
|
||||
assert editable is True
|
||||
assert modeless is True
|
||||
opened.append(("edit", diagnosis_id))
|
||||
|
||||
def open_view_only(self, diagnosis_id: int, *, modeless: bool) -> None:
|
||||
assert modeless is True
|
||||
opened.append(("view", diagnosis_id))
|
||||
|
||||
def raise_(self) -> None:
|
||||
self.raise_count += 1
|
||||
|
||||
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible test double
|
||||
self.activate_count += 1
|
||||
|
||||
monkeypatch.setattr(shell_module, "DiagnosisDialog", _DiagnosisDialogDouble)
|
||||
shell_window._global_diagnosis_dialog = None
|
||||
|
||||
shell_window.permissions = {"tcm.diagnosis/edit"}
|
||||
assert shell_window.open_diagnosis_by_id(501, modeless=True) is created[0]
|
||||
shell_window.permissions = {"tcm.diagnosis/readonlyDetail"}
|
||||
assert shell_window.open_diagnosis_by_id("502", modeless=True) is created[0]
|
||||
shell_window.permissions = {"tcm.diagnosis/*"}
|
||||
assert shell_window.open_diagnosis_by_id(503, modeless=True) is created[0]
|
||||
|
||||
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
|
||||
assert len(created) == 1
|
||||
assert created[0].parent is shell_window
|
||||
assert len(refresh_callbacks) == 1
|
||||
assert created[0].raise_count == 3
|
||||
assert created[0].activate_count == 3
|
||||
|
||||
shell_window.permissions = set()
|
||||
assert shell_window.open_diagnosis_by_id(504, modeless=True) is None
|
||||
assert shell_window.open_diagnosis_by_id(0, modeless=True) is None
|
||||
assert shell_window.open_diagnosis_by_id("invalid", modeless=True) is None
|
||||
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
|
||||
|
||||
|
||||
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
|
||||
@@ -95,7 +95,13 @@ def test_navigation_requires_each_pages_actual_list_capability() -> None:
|
||||
def test_video_release_does_not_remove_a_newer_call() -> None:
|
||||
older = object()
|
||||
newer = object()
|
||||
controller = SimpleNamespace(video_calls={"501": newer})
|
||||
# _release_video_call also clears the preview slot, so the double needs the
|
||||
# same attributes the real controller sets up in __init__.
|
||||
controller = SimpleNamespace(
|
||||
video_calls={"501": newer},
|
||||
_video_preview_state=None,
|
||||
_video_preview_generation=0,
|
||||
)
|
||||
|
||||
ApplicationController._release_video_call(controller, "501", older)
|
||||
assert controller.video_calls == {"501": newer}
|
||||
@@ -505,3 +511,37 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
||||
assert "信任自签名证书" in window.error_banner.label.text()
|
||||
window.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_business_dialogs_can_be_maximized_but_prompts_cannot() -> None:
|
||||
"""Dense AI panels and editors were stuck at their constructed size."""
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
|
||||
|
||||
from doctor_workstation.ui.theme import allow_dialog_resize
|
||||
|
||||
application = QApplication.instance() or QApplication([])
|
||||
assert application is not None
|
||||
|
||||
dialog = QDialog()
|
||||
dialog.resize(600, 400)
|
||||
allow_dialog_resize(dialog)
|
||||
flags = dialog.windowFlags()
|
||||
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||||
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||||
assert dialog.isSizeGripEnabled()
|
||||
dialog.deleteLater()
|
||||
|
||||
# Transient prompts keep their plain frame.
|
||||
prompt = QMessageBox()
|
||||
allow_dialog_resize(prompt)
|
||||
assert not (prompt.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
|
||||
prompt.deleteLater()
|
||||
|
||||
# A dialog that pinned itself to a fixed size keeps that decision.
|
||||
fixed = QDialog()
|
||||
fixed.setFixedSize(420, 300)
|
||||
allow_dialog_resize(fixed)
|
||||
assert not (fixed.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
|
||||
fixed.deleteLater()
|
||||
|
||||
@@ -5,7 +5,8 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from types import MethodType, SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -52,7 +53,7 @@ def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> No
|
||||
|
||||
assert "context.createMediaStreamDestination()" in source
|
||||
assert "cloud.getAudioTrack({ processed: true })" in source
|
||||
assert "userId: activeConfig.targetUserId" in source
|
||||
assert "attachPatientAudioTrack(cloud, activeConfig.targetUserId)" in source
|
||||
assert "new MediaRecorder(destination.stream" in source
|
||||
assert "recorder.start(1000)" in source
|
||||
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
|
||||
@@ -134,6 +135,12 @@ def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallback
|
||||
assert "stream.getAudioTracks()" in source
|
||||
assert "navigator.mediaDevices.getUserMedia" in source
|
||||
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
|
||||
assert "if (!attached)" in source
|
||||
assert "cloud.getAudioTrack(userId)" in source
|
||||
assert "event.sourceTrack" in source
|
||||
assert "cloud.on('remote-audio-available'" in source
|
||||
assert "localAudioCloud.off('remote-audio-available'" in source
|
||||
assert "!event.userId || event.userId === activeConfig?.userID" in source
|
||||
assert "localRecordingAttachedSourceCount <= 0" in source
|
||||
assert "localRecordingBytes < 1024" in source
|
||||
assert "已阻止上传空文件" in source
|
||||
@@ -201,6 +208,360 @@ def test_companion_shows_incremental_subtitles_but_only_persists_final_segments(
|
||||
assert "caption.text" in component_source
|
||||
|
||||
|
||||
def test_companion_keeps_transcript_and_patient_case_visible_in_a_side_rail() -> None:
|
||||
companion_root = PROJECT_ROOT / "video_companion" / "src"
|
||||
main_source = (companion_root / "main.ts").read_text(encoding="utf-8")
|
||||
component_source = (companion_root / "App.vue").read_text(encoding="utf-8")
|
||||
styles = (companion_root / "style.css").read_text(encoding="utf-8")
|
||||
window_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "liveCaptions.value = [...previous, caption].slice(-120)" in main_source
|
||||
assert "liveCaptionClearTimer" not in main_source
|
||||
assert 'aria-label="患者病例与实时对话"' in component_source
|
||||
assert 'id="patient-case-title"' in component_source
|
||||
assert 'id="live-transcript-title"' in component_source
|
||||
assert 'aria-label="打开完整诊单"' in component_source
|
||||
assert "runAction(onOpenDiagnosis)" in component_source
|
||||
assert "detail.clinicalDiagnosis" in component_source
|
||||
assert 'v-for="field in caseFields"' in component_source
|
||||
assert "{{ field.value }}" in component_source
|
||||
assert "{{ caption.time }}" in component_source
|
||||
assert "'caption-entry--partial': !caption.completed" in component_source
|
||||
assert ':allowed-full-screen="false"' in component_source
|
||||
assert ".video-layer--with-rail" in styles
|
||||
assert ".consultation-rail" in styles
|
||||
assert '"patientCase": self.patient_case' in window_source
|
||||
assert 'event == "open-diagnosis-request"' in window_source
|
||||
assert "QTimer.singleShot(0, self._open_diagnosis_safely)" in window_source
|
||||
stop_source = main_source.split("async function stopTranscription", 1)[1].split(
|
||||
"function transcriptionResult", 1
|
||||
)[0]
|
||||
assert "clearLiveCaptions()" not in stop_source
|
||||
|
||||
|
||||
def test_video_diagnosis_entry_uses_existing_permission_scoped_drawer() -> None:
|
||||
app_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "app.py"
|
||||
).read_text(encoding="utf-8")
|
||||
launcher_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "launcher.py"
|
||||
).read_text(encoding="utf-8")
|
||||
main_source = (
|
||||
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
shell_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
|
||||
).read_text(encoding="utf-8")
|
||||
diagnosis_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "dialogs" / "diagnosis.py"
|
||||
).read_text(encoding="utf-8")
|
||||
styles = (
|
||||
PROJECT_ROOT / "video_companion" / "src" / "style.css"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "shell.open_diagnosis_by_id(diagnosis_id, modeless=True)" in app_source
|
||||
assert "self._show_video_preview(video_window, dialog)" in app_source
|
||||
assert "WindowStaysOnTopHint" in app_source
|
||||
assert "dialog.finished.connect" in app_source
|
||||
assert "modeless=modeless" in shell_source
|
||||
assert "not modeless and not self._standalone_readonly" in diagnosis_source
|
||||
compact_styles = styles.split("@media (max-width: 700px)", 1)[1]
|
||||
assert ".consultation-rail { display: none; }" in compact_styles
|
||||
assert ".capture-button { display: none; }" in compact_styles
|
||||
assert "on_open_diagnosis=on_open_diagnosis" in launcher_source
|
||||
assert "event: 'open-diagnosis-request'" in main_source
|
||||
|
||||
|
||||
def test_video_preview_is_compact_and_restores_after_diagnosis_closes() -> None:
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from doctor_workstation.app import ApplicationController
|
||||
|
||||
class _Rect:
|
||||
def x(self) -> int:
|
||||
return 0
|
||||
|
||||
def y(self) -> int:
|
||||
return 0
|
||||
|
||||
def width(self) -> int:
|
||||
return 1920
|
||||
|
||||
def height(self) -> int:
|
||||
return 1040
|
||||
|
||||
class _Screen:
|
||||
def availableGeometry(self) -> _Rect: # noqa: N802 - Qt-compatible double
|
||||
return _Rect()
|
||||
|
||||
class _Window:
|
||||
def __init__(self) -> None:
|
||||
self.original_geometry = object()
|
||||
self.original_minimum = object()
|
||||
self.minimum = self.original_minimum
|
||||
self.geometry_value = self.original_geometry
|
||||
self.size = (900, 600)
|
||||
self.position = (30, 40)
|
||||
self.stays_on_top = False
|
||||
self.activated = 0
|
||||
|
||||
def geometry(self) -> object:
|
||||
return self.geometry_value
|
||||
|
||||
def minimumSize(self) -> object: # noqa: N802 - Qt-compatible double
|
||||
return self.minimum
|
||||
|
||||
def isMaximized(self) -> bool: # noqa: N802 - Qt-compatible double
|
||||
return False
|
||||
|
||||
def isFullScreen(self) -> bool: # noqa: N802 - Qt-compatible double
|
||||
return False
|
||||
|
||||
def windowFlags(self) -> Qt.WindowType: # noqa: N802 - Qt-compatible double
|
||||
return Qt.WindowType.Window
|
||||
|
||||
def screen(self) -> _Screen:
|
||||
return _Screen()
|
||||
|
||||
def showNormal(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
def showMaximized(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
def showFullScreen(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
def setMinimumSize(self, *value: object) -> None: # noqa: N802
|
||||
self.minimum = value[0] if len(value) == 1 else value
|
||||
|
||||
def setWindowFlag(self, _flag: Any, enabled: bool) -> None: # noqa: N802
|
||||
self.stays_on_top = enabled
|
||||
|
||||
def resize(self, width: int, height: int) -> None:
|
||||
self.size = (width, height)
|
||||
|
||||
def move(self, x: int, y: int) -> None:
|
||||
self.position = (x, y)
|
||||
|
||||
def setGeometry(self, geometry: object) -> None: # noqa: N802
|
||||
self.geometry_value = geometry
|
||||
|
||||
def show(self) -> None:
|
||||
return None
|
||||
|
||||
def raise_(self) -> None:
|
||||
return None
|
||||
|
||||
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
self.activated += 1
|
||||
|
||||
class _Signal:
|
||||
def __init__(self) -> None:
|
||||
self.callbacks: list[Any] = []
|
||||
|
||||
def connect(self, callback: Any) -> None:
|
||||
self.callbacks.append(callback)
|
||||
|
||||
class _Dialog:
|
||||
def __init__(self) -> None:
|
||||
self.finished = _Signal()
|
||||
|
||||
def raise_(self) -> None:
|
||||
return None
|
||||
|
||||
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
|
||||
return None
|
||||
|
||||
controller = SimpleNamespace(
|
||||
_video_preview_state=None,
|
||||
_video_preview_generation=0,
|
||||
)
|
||||
controller._restore_video_preview = MethodType( # type: ignore[attr-defined]
|
||||
ApplicationController._restore_video_preview,
|
||||
controller,
|
||||
)
|
||||
window = _Window()
|
||||
dialog = _Dialog()
|
||||
|
||||
ApplicationController._show_video_preview(controller, window, dialog)
|
||||
|
||||
assert window.minimum == (440, 300)
|
||||
assert window.size == (540, 356)
|
||||
assert window.position == (1362, 18)
|
||||
assert window.stays_on_top is True
|
||||
assert len(dialog.finished.callbacks) == 1
|
||||
|
||||
dialog.finished.callbacks[0](0)
|
||||
|
||||
assert window.minimum is window.original_minimum
|
||||
assert window.geometry_value is window.original_geometry
|
||||
assert window.stays_on_top is False
|
||||
assert window.activated == 1
|
||||
|
||||
|
||||
def test_video_patient_case_snapshot_is_bounded_and_clinically_useful() -> None:
|
||||
from doctor_workstation.app import _build_video_patient_case
|
||||
|
||||
summary = _build_video_patient_case(
|
||||
{
|
||||
"diagnosis": {
|
||||
"patient_name": "张三",
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"gender": 1,
|
||||
"age": 47,
|
||||
"chief_complaint": "反复口渴三个月",
|
||||
"present_illness": "近期空腹血糖偏高",
|
||||
"allergy_history": "青霉素",
|
||||
"current_medicine": ["二甲双胍", "阿卡波糖"],
|
||||
"clinical_diagnosis": "2 型糖尿病",
|
||||
},
|
||||
"appointment": {"appointment_date": "2026-08-26"},
|
||||
"internal_audit": {"token": "must-not-cross-the-bridge"},
|
||||
},
|
||||
{},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="患者",
|
||||
)
|
||||
|
||||
assert summary["diagnosisId"] == "8279"
|
||||
assert summary["name"] == "张三"
|
||||
assert summary["gender"] == "男"
|
||||
assert summary["age"] == "47"
|
||||
assert summary["chiefComplaint"] == "反复口渴三个月"
|
||||
assert summary["currentMedication"] == "二甲双胍、阿卡波糖"
|
||||
assert summary["allergyHistory"] == "青霉素"
|
||||
assert "internal_audit" not in summary
|
||||
assert set(summary) == {
|
||||
"diagnosisId",
|
||||
"name",
|
||||
"gender",
|
||||
"age",
|
||||
"height",
|
||||
"weight",
|
||||
"diagnosisDate",
|
||||
"appointmentDate",
|
||||
"clinicalDiagnosis",
|
||||
"chiefComplaint",
|
||||
"presentIllness",
|
||||
"pastHistory",
|
||||
"allergyHistory",
|
||||
"personalHistory",
|
||||
"familyHistory",
|
||||
"currentMedication",
|
||||
"tongue",
|
||||
"pulse",
|
||||
"prescriptionOpinion",
|
||||
"remark",
|
||||
}
|
||||
|
||||
bounded = _build_video_patient_case(
|
||||
{},
|
||||
{
|
||||
"diagnosis_id": 8279,
|
||||
"patient_id": 42,
|
||||
"patient_name": "患" * 180,
|
||||
"age": "4" * 40,
|
||||
"remark": "病" * 2400,
|
||||
},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="患者",
|
||||
)
|
||||
assert len(bounded["name"]) == 120
|
||||
assert len(bounded["age"]) == 20
|
||||
assert len(bounded["remark"]) == 2000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"detail",
|
||||
[
|
||||
{
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "张三",
|
||||
"chief_complaint": "口渴",
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "张三",
|
||||
"chief_complaint": "口渴",
|
||||
}
|
||||
},
|
||||
{
|
||||
"diagnosis": {
|
||||
"id": 8279,
|
||||
"source_patient_id": 42,
|
||||
"patient_name": "张三",
|
||||
"chief_complaint": "口渴",
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_video_patient_case_accepts_supported_readonly_detail_shapes(detail: object) -> None:
|
||||
from doctor_workstation.app import _build_video_patient_case
|
||||
|
||||
summary = _build_video_patient_case(
|
||||
detail,
|
||||
{},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="患者",
|
||||
)
|
||||
|
||||
assert summary["name"] == "张三"
|
||||
assert summary["chiefComplaint"] == "口渴"
|
||||
|
||||
|
||||
def test_video_patient_case_fails_closed_on_identity_mismatch() -> None:
|
||||
from doctor_workstation.app import _build_video_patient_case
|
||||
|
||||
summary = _build_video_patient_case(
|
||||
{
|
||||
"diagnosis": {
|
||||
"id": 9001,
|
||||
"source_patient_id": 7,
|
||||
"patient_name": "其他患者",
|
||||
"chief_complaint": "不得展示",
|
||||
"allergy_history": "不得展示",
|
||||
}
|
||||
},
|
||||
{
|
||||
"diagnosis_id": 9001,
|
||||
"patient_id": 7,
|
||||
"chief_complaint": "也不得展示",
|
||||
},
|
||||
diagnosis_id=8279,
|
||||
patient_id=42,
|
||||
patient_name="张三",
|
||||
)
|
||||
|
||||
assert summary["name"] == "张三"
|
||||
assert summary["chiefComplaint"] == ""
|
||||
assert summary["allergyHistory"] == ""
|
||||
|
||||
|
||||
def test_built_video_companion_contains_the_patient_case_rail() -> None:
|
||||
dist_root = PROJECT_ROOT / "video_companion" / "dist"
|
||||
styles = "\n".join(
|
||||
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.css")
|
||||
)
|
||||
scripts = "\n".join(
|
||||
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.js")
|
||||
)
|
||||
|
||||
assert ".consultation-rail" in styles
|
||||
assert ".video-layer--with-rail" in styles
|
||||
assert "patientCase" in scripts
|
||||
assert "患者病例与实时对话" in scripts
|
||||
|
||||
|
||||
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
|
||||
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
|
||||
encoding="utf-8"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+108
-108
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
|
||||
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+220
-57
@@ -18,9 +18,39 @@ interface LiveCaption {
|
||||
id: string
|
||||
speaker: string
|
||||
text: string
|
||||
time: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
interface PatientCase {
|
||||
diagnosisId: string
|
||||
name: string
|
||||
gender: string
|
||||
age: string
|
||||
height: string
|
||||
weight: string
|
||||
diagnosisDate: string
|
||||
appointmentDate: string
|
||||
clinicalDiagnosis: string
|
||||
chiefComplaint: string
|
||||
presentIllness: string
|
||||
pastHistory: string
|
||||
allergyHistory: string
|
||||
personalHistory: string
|
||||
familyHistory: string
|
||||
currentMedication: string
|
||||
tongue: string
|
||||
pulse: string
|
||||
prescriptionOpinion: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface CaseField {
|
||||
label: string
|
||||
value: string
|
||||
risk?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
phase: Readonly<Ref<string>>
|
||||
statusText: Readonly<Ref<string>>
|
||||
@@ -34,12 +64,14 @@ const props = defineProps<{
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
localRecordingState: Readonly<Ref<string>>
|
||||
liveCaptions: Readonly<Ref<LiveCaption[]>>
|
||||
patientCase: Readonly<Ref<PatientCase>>
|
||||
onSendText: (text: string) => Promise<void>
|
||||
onSendAttachment: (file: File) => Promise<void>
|
||||
onLoadMore: () => Promise<void>
|
||||
onReconnectChat: () => Promise<void>
|
||||
onStartVideo: () => Promise<void>
|
||||
onHangup: () => Promise<void>
|
||||
onOpenDiagnosis: () => Promise<void>
|
||||
onSaveScreenshot: (dataUrl: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
@@ -50,6 +82,7 @@ const messageList = ref<HTMLElement | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const screenshotPreview = ref('')
|
||||
const stickToMessageBottom = ref(true)
|
||||
const transcriptList = ref<HTMLElement | null>(null)
|
||||
|
||||
const isChat = computed(() => props.mode.value === 'chat')
|
||||
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
|
||||
@@ -84,6 +117,41 @@ const transcriptionStatusText = computed(() => {
|
||||
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
|
||||
return '录音与转写已结束'
|
||||
})
|
||||
const patientMetaText = computed(() => {
|
||||
const detail = props.patientCase.value
|
||||
return [
|
||||
detail.gender,
|
||||
detail.age ? `${detail.age}岁` : '',
|
||||
detail.height ? `${detail.height} cm` : '',
|
||||
detail.weight ? `${detail.weight} kg` : '',
|
||||
].filter(Boolean).join(' · ') || '基础资料待补充'
|
||||
})
|
||||
const patientVisitText = computed(() => {
|
||||
const detail = props.patientCase.value
|
||||
if (detail.appointmentDate) return `预约 ${detail.appointmentDate}`
|
||||
if (detail.diagnosisDate) return `诊断 ${detail.diagnosisDate}`
|
||||
return `诊单 ${detail.diagnosisId || '—'}`
|
||||
})
|
||||
const caseFields = computed<CaseField[]>(() => {
|
||||
const detail = props.patientCase.value
|
||||
const allergyRisk = Boolean(detail.allergyHistory) && !/^(无|否|未发现|无过敏史|none|no)$/i.test(
|
||||
detail.allergyHistory.trim(),
|
||||
)
|
||||
return [
|
||||
{ label: '临床诊断', value: detail.clinicalDiagnosis },
|
||||
{ label: '主诉', value: detail.chiefComplaint },
|
||||
{ label: '现病史', value: detail.presentIllness },
|
||||
{ label: '当前用药', value: detail.currentMedication },
|
||||
{ label: '过敏史', value: detail.allergyHistory, risk: allergyRisk },
|
||||
{ label: '既往史', value: detail.pastHistory },
|
||||
{ label: '个人史', value: detail.personalHistory },
|
||||
{ label: '家族史', value: detail.familyHistory },
|
||||
{ label: '舌象', value: detail.tongue },
|
||||
{ label: '脉象', value: detail.pulse },
|
||||
{ label: '处方意见', value: detail.prescriptionOpinion },
|
||||
{ label: '病例备注', value: detail.remark },
|
||||
].filter((item) => Boolean(item.value))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.messages.value.length,
|
||||
@@ -103,6 +171,17 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const last = props.liveCaptions.value.at(-1)
|
||||
return `${props.liveCaptions.value.length}:${last?.id || ''}:${last?.text || ''}`
|
||||
},
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (transcriptList.value) transcriptList.value.scrollTop = transcriptList.value.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
function handleMessageScroll(): void {
|
||||
const container = messageList.value
|
||||
if (!container) return
|
||||
@@ -335,70 +414,154 @@ watch(
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
|
||||
<TUICallKit
|
||||
class="call-kit"
|
||||
:allowed-minimized="false"
|
||||
:allowed-full-screen="true"
|
||||
/>
|
||||
<section
|
||||
v-if="videoVisible"
|
||||
class="video-layer"
|
||||
:class="{
|
||||
'video-layer--overlay': isChat,
|
||||
'video-layer--with-rail': isCalling,
|
||||
}"
|
||||
>
|
||||
<div class="video-stage">
|
||||
<TUICallKit
|
||||
class="call-kit"
|
||||
:allowed-minimized="false"
|
||||
:allowed-full-screen="false"
|
||||
/>
|
||||
|
||||
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
||||
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="eyebrow">中医视频问诊</p>
|
||||
<h2>{{ statusText.value }}</h2>
|
||||
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
|
||||
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
||||
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
||||
<div>
|
||||
<p class="eyebrow">中医视频问诊</p>
|
||||
<h2>{{ statusText.value }}</h2>
|
||||
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else class="live-status" role="status">
|
||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||
{{ statusText.value }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else class="live-status" role="status">
|
||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||
{{ statusText.value }}
|
||||
</div>
|
||||
<div v-if="isCalling" class="video-actions">
|
||||
<div
|
||||
v-if="canCapture"
|
||||
class="recording-status"
|
||||
:class="{
|
||||
'recording-status--active': transcriptionActive,
|
||||
'recording-status--error': transcriptionFailed,
|
||||
}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="recording-indicator" aria-hidden="true" />
|
||||
{{ transcriptionStatusText }}
|
||||
</div>
|
||||
<button
|
||||
class="capture-button"
|
||||
type="button"
|
||||
:disabled="!canCapture || actionBusy"
|
||||
@click="captureScreenshot"
|
||||
>
|
||||
截屏预览
|
||||
</button>
|
||||
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||
结束视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canCapture && liveCaptions.value.length"
|
||||
class="live-captions"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="实时语音字幕"
|
||||
>
|
||||
<p v-for="caption in liveCaptions.value" :key="caption.id">
|
||||
<strong>{{ caption.speaker }}</strong>
|
||||
<span>{{ caption.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isCalling" class="video-actions">
|
||||
<div
|
||||
v-if="canCapture"
|
||||
class="recording-status"
|
||||
:class="{
|
||||
'recording-status--active': transcriptionActive,
|
||||
'recording-status--error': transcriptionFailed,
|
||||
}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="recording-indicator" aria-hidden="true" />
|
||||
{{ transcriptionStatusText }}
|
||||
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
<button
|
||||
class="capture-button"
|
||||
type="button"
|
||||
:disabled="!canCapture || actionBusy"
|
||||
@click="captureScreenshot"
|
||||
>
|
||||
截屏预览
|
||||
</button>
|
||||
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||
结束视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||
{{ localError || notice.value }}
|
||||
</div>
|
||||
<aside v-if="isCalling" class="consultation-rail" aria-label="患者病例与实时对话">
|
||||
<header class="consultation-rail__header">
|
||||
<div class="consultation-rail__avatar" aria-hidden="true">
|
||||
{{ (patientCase.value.name || patientName.value).slice(0, 1) }}
|
||||
</div>
|
||||
<div class="consultation-rail__identity">
|
||||
<strong>{{ patientCase.value.name || patientName.value }}</strong>
|
||||
<span>{{ patientMetaText }}</span>
|
||||
</div>
|
||||
<div class="consultation-rail__actions">
|
||||
<span class="diagnosis-chip">诊单 {{ patientCase.value.diagnosisId || '—' }}</span>
|
||||
<button
|
||||
class="open-diagnosis-button"
|
||||
type="button"
|
||||
:disabled="actionBusy"
|
||||
aria-label="打开完整诊单"
|
||||
@click="runAction(onOpenDiagnosis)"
|
||||
>
|
||||
打开诊单
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="case-panel" aria-labelledby="patient-case-title">
|
||||
<header class="rail-section-heading">
|
||||
<div>
|
||||
<span class="rail-section-heading__index">01</span>
|
||||
<h2 id="patient-case-title">患者病例</h2>
|
||||
</div>
|
||||
<span>{{ patientVisitText }}</span>
|
||||
</header>
|
||||
<div class="case-panel__content">
|
||||
<div v-if="caseFields.length" class="case-field-list">
|
||||
<article
|
||||
v-for="field in caseFields"
|
||||
:key="field.label"
|
||||
class="case-field"
|
||||
:class="{ 'case-field--risk': field.risk }"
|
||||
>
|
||||
<span>{{ field.label }}</span>
|
||||
<p>{{ field.value }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="rail-empty rail-empty--case">
|
||||
<strong>暂无已填写的病例内容</strong>
|
||||
<span>可继续通话,已补充的病例会在下次打开时显示。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="transcript-panel" aria-labelledby="live-transcript-title">
|
||||
<header class="rail-section-heading">
|
||||
<div>
|
||||
<span class="rail-section-heading__index">02</span>
|
||||
<h2 id="live-transcript-title">实时对话</h2>
|
||||
</div>
|
||||
<span class="transcript-state" :class="{ 'transcript-state--active': transcriptionActive }">
|
||||
{{ transcriptionActive ? '转写中' : '等待语音' }}
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
ref="transcriptList"
|
||||
class="live-captions"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="实时语音字幕"
|
||||
>
|
||||
<div v-if="!liveCaptions.value.length" class="rail-empty">
|
||||
<strong>对话文字会显示在这里</strong>
|
||||
<span>接通后自动识别医生与患者语音,并保留本次通话内容。</span>
|
||||
</div>
|
||||
<article
|
||||
v-for="caption in liveCaptions.value"
|
||||
:key="caption.id"
|
||||
class="caption-entry"
|
||||
:class="{ 'caption-entry--partial': !caption.completed }"
|
||||
>
|
||||
<header>
|
||||
<strong>{{ caption.speaker }}</strong>
|
||||
<time>{{ caption.time }}</time>
|
||||
</header>
|
||||
<p>{{ caption.text }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="screenshotPreview"
|
||||
|
||||
Vendored
+24
@@ -15,9 +15,33 @@ interface DoctorCallConfig {
|
||||
patientUserId?: string
|
||||
diagnosisId: number | string
|
||||
patientName?: string
|
||||
patientCase?: DoctorPatientCase
|
||||
mode?: 'chat' | 'video'
|
||||
}
|
||||
|
||||
interface DoctorPatientCase {
|
||||
diagnosisId?: number | string
|
||||
name?: string
|
||||
gender?: string
|
||||
age?: string | number
|
||||
height?: string | number
|
||||
weight?: string | number
|
||||
diagnosisDate?: string
|
||||
appointmentDate?: string
|
||||
clinicalDiagnosis?: string
|
||||
chiefComplaint?: string
|
||||
presentIllness?: string
|
||||
pastHistory?: string
|
||||
allergyHistory?: string
|
||||
personalHistory?: string
|
||||
familyHistory?: string
|
||||
currentMedication?: string
|
||||
tongue?: string
|
||||
pulse?: string
|
||||
prescriptionOpinion?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
interface DoctorCallApi {
|
||||
start(config: DoctorCallConfig): Promise<void>
|
||||
hangup(): Promise<void>
|
||||
|
||||
+196
-44
@@ -26,6 +26,7 @@ interface NormalizedCallConfig {
|
||||
targetUserId: string
|
||||
diagnosisId: number | string
|
||||
patientName: string
|
||||
patientCase: UiPatientCase
|
||||
mode: CompanionMode
|
||||
}
|
||||
|
||||
@@ -44,9 +45,33 @@ interface UiLiveCaption {
|
||||
id: string
|
||||
speaker: string
|
||||
text: string
|
||||
time: string
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
interface UiPatientCase {
|
||||
diagnosisId: string
|
||||
name: string
|
||||
gender: string
|
||||
age: string
|
||||
height: string
|
||||
weight: string
|
||||
diagnosisDate: string
|
||||
appointmentDate: string
|
||||
clinicalDiagnosis: string
|
||||
chiefComplaint: string
|
||||
presentIllness: string
|
||||
pastHistory: string
|
||||
allergyHistory: string
|
||||
personalHistory: string
|
||||
familyHistory: string
|
||||
currentMedication: string
|
||||
tongue: string
|
||||
pulse: string
|
||||
prescriptionOpinion: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface BridgeMessage {
|
||||
source: 'doctor-call'
|
||||
event:
|
||||
@@ -56,6 +81,7 @@ interface BridgeMessage {
|
||||
| 'room'
|
||||
| 'hangup'
|
||||
| 'error'
|
||||
| 'open-diagnosis-request'
|
||||
| 'transcription-start-request'
|
||||
| 'transcription-segment'
|
||||
| 'transcription-stop'
|
||||
@@ -127,6 +153,11 @@ interface PendingLocalRecordingReply {
|
||||
interface TrtcAudioTrackEvent {
|
||||
userId?: string
|
||||
track?: MediaStreamTrack
|
||||
sourceTrack?: MediaStreamTrack
|
||||
}
|
||||
|
||||
interface TrtcRemoteAudioEvent {
|
||||
userId?: string
|
||||
}
|
||||
|
||||
interface TrtcAudioCloud {
|
||||
@@ -135,7 +166,9 @@ interface TrtcAudioCloud {
|
||||
processed?: boolean
|
||||
} | string): MediaStreamTrack | null
|
||||
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
|
||||
on?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
|
||||
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
|
||||
off?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
|
||||
}
|
||||
|
||||
const phase = ref<CallPhase>('ready')
|
||||
@@ -150,6 +183,7 @@ const hasMoreMessages = ref(false)
|
||||
const transcriptionState = ref<TranscriptionState>('idle')
|
||||
const localRecordingState = ref<LocalRecordingState>('idle')
|
||||
const liveCaptions = ref<UiLiveCaption[]>([])
|
||||
const patientCase = ref<UiPatientCase>(emptyPatientCase())
|
||||
|
||||
let activeConfig: NormalizedCallConfig | null = null
|
||||
let chat: any = null
|
||||
@@ -192,6 +226,7 @@ let localAudioContext: AudioContext | null = null
|
||||
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
|
||||
let localAudioCloud: TrtcAudioCloud | null = null
|
||||
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
|
||||
let localRemoteAudioAvailableHandler: ((event: TrtcRemoteAudioEvent) => void) | null = null
|
||||
let localAudioSources: MediaStreamAudioSourceNode[] = []
|
||||
let localAudioTrackIds = new Set<string>()
|
||||
let localAudioOwnedTracks: MediaStreamTrack[] = []
|
||||
@@ -205,8 +240,6 @@ let localRecordingChunkChain: Promise<void> = Promise.resolve()
|
||||
let localRecordingStartPromise: Promise<void> | null = null
|
||||
let localRecordingStopPromise: Promise<void> | null = null
|
||||
let localRecordingFatalError = ''
|
||||
let liveCaptionClearTimer: number | null = null
|
||||
|
||||
function initializeQtWebChannel(): void {
|
||||
const transport = window.qt?.webChannelTransport
|
||||
const QWebChannel = window.QWebChannel
|
||||
@@ -259,11 +292,80 @@ function emit(message: BridgeMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDiagnosis(): Promise<void> {
|
||||
if (!activeConfig) throw new Error('诊单上下文尚未就绪')
|
||||
emit({
|
||||
source: 'doctor-call',
|
||||
event: 'open-diagnosis-request',
|
||||
diagnosisId: activeConfig.diagnosisId,
|
||||
})
|
||||
}
|
||||
|
||||
function cleanString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maxLength = 2000): string {
|
||||
if (value === undefined || value === null) return ''
|
||||
return String(value).trim().slice(0, maxLength)
|
||||
}
|
||||
|
||||
function emptyPatientCase(): UiPatientCase {
|
||||
return {
|
||||
diagnosisId: '',
|
||||
name: '',
|
||||
gender: '',
|
||||
age: '',
|
||||
height: '',
|
||||
weight: '',
|
||||
diagnosisDate: '',
|
||||
appointmentDate: '',
|
||||
clinicalDiagnosis: '',
|
||||
chiefComplaint: '',
|
||||
presentIllness: '',
|
||||
pastHistory: '',
|
||||
allergyHistory: '',
|
||||
personalHistory: '',
|
||||
familyHistory: '',
|
||||
currentMedication: '',
|
||||
tongue: '',
|
||||
pulse: '',
|
||||
prescriptionOpinion: '',
|
||||
remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePatientCase(
|
||||
value: DoctorPatientCase | undefined,
|
||||
diagnosisId: number | string,
|
||||
fallbackName: string,
|
||||
): UiPatientCase {
|
||||
const source = value && typeof value === 'object' ? value : {}
|
||||
return {
|
||||
diagnosisId: optionalText(source.diagnosisId ?? diagnosisId, 80),
|
||||
name: optionalText(source.name || fallbackName, 120) || fallbackName,
|
||||
gender: optionalText(source.gender, 20),
|
||||
age: optionalText(source.age, 20),
|
||||
height: optionalText(source.height, 20),
|
||||
weight: optionalText(source.weight, 20),
|
||||
diagnosisDate: optionalText(source.diagnosisDate, 80),
|
||||
appointmentDate: optionalText(source.appointmentDate, 80),
|
||||
clinicalDiagnosis: optionalText(source.clinicalDiagnosis),
|
||||
chiefComplaint: optionalText(source.chiefComplaint),
|
||||
presentIllness: optionalText(source.presentIllness),
|
||||
pastHistory: optionalText(source.pastHistory),
|
||||
allergyHistory: optionalText(source.allergyHistory),
|
||||
personalHistory: optionalText(source.personalHistory),
|
||||
familyHistory: optionalText(source.familyHistory),
|
||||
currentMedication: optionalText(source.currentMedication),
|
||||
tongue: optionalText(source.tongue),
|
||||
pulse: optionalText(source.pulse),
|
||||
prescriptionOpinion: optionalText(source.prescriptionOpinion),
|
||||
remark: optionalText(source.remark),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
|
||||
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
|
||||
@@ -274,15 +376,17 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||
throw new Error('诊单ID不能为空')
|
||||
}
|
||||
|
||||
const normalizedPatientName = typeof config.patientName === 'string' && config.patientName.trim()
|
||||
? config.patientName.trim()
|
||||
: '患者'
|
||||
return {
|
||||
SDKAppID,
|
||||
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||
userSig: cleanString(config.userSig, '用户签名'),
|
||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
|
||||
patientName: typeof config.patientName === 'string' && config.patientName.trim()
|
||||
? config.patientName.trim()
|
||||
: '患者',
|
||||
patientName: normalizedPatientName,
|
||||
patientCase: normalizePatientCase(config.patientCase, diagnosisId, normalizedPatientName),
|
||||
mode: config.mode === 'chat' ? 'chat' : 'video',
|
||||
}
|
||||
}
|
||||
@@ -747,30 +851,50 @@ function attachLocalRecordingTrack(
|
||||
return true
|
||||
}
|
||||
|
||||
function attachDoctorAudioTrack(cloud: TrtcAudioCloud): boolean {
|
||||
if (typeof cloud.getAudioTrack !== 'function') return false
|
||||
let attached = false
|
||||
try {
|
||||
attached = attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
|
||||
} catch {
|
||||
// Some TRTC versions do not support processed tracks.
|
||||
}
|
||||
if (!attached) {
|
||||
try {
|
||||
attached = attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
|
||||
} catch {
|
||||
// The microphone fallback below remains available.
|
||||
}
|
||||
}
|
||||
return attached
|
||||
}
|
||||
|
||||
function attachPatientAudioTrack(cloud: TrtcAudioCloud, userId: string): boolean {
|
||||
if (typeof cloud.getAudioTrack !== 'function' || !userId) return false
|
||||
let attached = false
|
||||
try {
|
||||
attached = attachLocalRecordingTrack(cloud.getAudioTrack({
|
||||
userId,
|
||||
processed: true,
|
||||
}), 'patient')
|
||||
} catch {
|
||||
// Some TRTC versions do not expose a processed remote track.
|
||||
}
|
||||
if (!attached) {
|
||||
try {
|
||||
attached = attachLocalRecordingTrack(cloud.getAudioTrack(userId), 'patient')
|
||||
} catch {
|
||||
// Remote audio can become available a few frames after connected.
|
||||
}
|
||||
}
|
||||
return attached
|
||||
}
|
||||
|
||||
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
|
||||
if (!activeConfig) return
|
||||
if (typeof cloud?.getAudioTrack === 'function') {
|
||||
try {
|
||||
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
|
||||
} catch {
|
||||
try {
|
||||
attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
|
||||
} catch {
|
||||
// The rendered media elements below remain a supported fallback.
|
||||
}
|
||||
}
|
||||
try {
|
||||
attachLocalRecordingTrack(cloud.getAudioTrack({
|
||||
userId: activeConfig.targetUserId,
|
||||
processed: true,
|
||||
}), 'patient')
|
||||
} catch {
|
||||
try {
|
||||
attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient')
|
||||
} catch {
|
||||
// Remote audio can become available a few frames after connected.
|
||||
}
|
||||
}
|
||||
if (cloud) {
|
||||
attachDoctorAudioTrack(cloud)
|
||||
attachPatientAudioTrack(cloud, activeConfig.targetUserId)
|
||||
}
|
||||
|
||||
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
|
||||
@@ -852,8 +976,20 @@ async function cleanupLocalRecordingGraph(): Promise<void> {
|
||||
// The call engine may already have released its event dispatcher.
|
||||
}
|
||||
}
|
||||
if (
|
||||
localAudioCloud
|
||||
&& localRemoteAudioAvailableHandler
|
||||
&& typeof localAudioCloud.off === 'function'
|
||||
) {
|
||||
try {
|
||||
localAudioCloud.off('remote-audio-available', localRemoteAudioAvailableHandler)
|
||||
} catch {
|
||||
// The call engine may already have released its event dispatcher.
|
||||
}
|
||||
}
|
||||
localAudioCloud = null
|
||||
localAudioTrackHandler = null
|
||||
localRemoteAudioAvailableHandler = null
|
||||
for (const source of localAudioSources) {
|
||||
try {
|
||||
source.disconnect()
|
||||
@@ -910,15 +1046,26 @@ async function performStartLocalRecording(): Promise<void> {
|
||||
localAudioDestination = context.createMediaStreamDestination()
|
||||
const cloud = getTrtcAudioCloud()
|
||||
localAudioCloud = cloud
|
||||
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
|
||||
event.track,
|
||||
event.userId === activeConfig?.userID
|
||||
localAudioTrackHandler = (event) => {
|
||||
const sourceKind = !event.userId || event.userId === activeConfig?.userID
|
||||
? 'doctor'
|
||||
: event.userId === activeConfig?.targetUserId
|
||||
? 'patient'
|
||||
: 'unknown',
|
||||
)
|
||||
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
|
||||
: 'unknown'
|
||||
if (!attachLocalRecordingTrack(event.track, sourceKind)) {
|
||||
attachLocalRecordingTrack(event.sourceTrack, sourceKind)
|
||||
}
|
||||
}
|
||||
localRemoteAudioAvailableHandler = (event) => {
|
||||
const userId = event.userId || activeConfig?.targetUserId || ''
|
||||
if (cloud && userId === activeConfig?.targetUserId) {
|
||||
attachPatientAudioTrack(cloud, userId)
|
||||
}
|
||||
}
|
||||
if (typeof cloud?.on === 'function') {
|
||||
cloud.on('track', localAudioTrackHandler)
|
||||
cloud.on('remote-audio-available', localRemoteAudioAvailableHandler)
|
||||
}
|
||||
if (context.state === 'suspended') await context.resume()
|
||||
await waitForCallAudioTracks(cloud, sessionId)
|
||||
startCallAudioDiscovery(cloud)
|
||||
@@ -1151,10 +1298,6 @@ function getTranscriberManager(): RealtimeTranscriberManager {
|
||||
}
|
||||
|
||||
function clearLiveCaptions(): void {
|
||||
if (liveCaptionClearTimer !== null) {
|
||||
window.clearTimeout(liveCaptionClearTimer)
|
||||
liveCaptionClearTimer = null
|
||||
}
|
||||
liveCaptions.value = []
|
||||
}
|
||||
|
||||
@@ -1170,19 +1313,26 @@ function showLiveCaption(message: RealtimeTranscriberMessage): void {
|
||||
: speakerUserId === activeConfig.targetUserId
|
||||
? patientName.value || '患者'
|
||||
: '对话'
|
||||
const rawTimestamp = Number(message.timestamp)
|
||||
const captionTimestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
|
||||
? (rawTimestamp < 1_000_000_000_000 ? rawTimestamp * 1000 : rawTimestamp)
|
||||
: Date.now()
|
||||
const caption: UiLiveCaption = {
|
||||
id,
|
||||
speaker,
|
||||
text: text.slice(0, 500),
|
||||
time: new Date(captionTimestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
completed: message.isCompleted === true,
|
||||
}
|
||||
const previous = liveCaptions.value.filter((item) => item.id !== id)
|
||||
liveCaptions.value = [...previous, caption].slice(-2)
|
||||
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
|
||||
liveCaptionClearTimer = window.setTimeout(() => {
|
||||
liveCaptions.value = []
|
||||
liveCaptionClearTimer = null
|
||||
}, message.isCompleted === true ? 9000 : 5000)
|
||||
// Keep the current call's transcript visible in the side rail. Repeated
|
||||
// partial updates replace the same segment and the bounded history prevents
|
||||
// long calls from growing memory without limit.
|
||||
liveCaptions.value = [...previous, caption].slice(-120)
|
||||
}
|
||||
|
||||
function handleTranscriberMessage(
|
||||
@@ -1421,7 +1571,6 @@ async function stopTranscription(
|
||||
transcriptionSessionId = ''
|
||||
acknowledgedSegmentIds.clear()
|
||||
pendingSegments.clear()
|
||||
clearLiveCaptions()
|
||||
})().finally(() => {
|
||||
transcriptionStopPromise = null
|
||||
})
|
||||
@@ -1720,6 +1869,7 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
||||
activeConfig = normalizeConfig(config)
|
||||
mode.value = activeConfig.mode
|
||||
patientName.value = activeConfig.patientName
|
||||
patientCase.value = activeConfig.patientCase
|
||||
messages.value = []
|
||||
nextReqMessageID = ''
|
||||
hasMoreMessages.value = false
|
||||
@@ -1805,12 +1955,14 @@ createApp(App, {
|
||||
transcriptionState: readonly(transcriptionState),
|
||||
localRecordingState: readonly(localRecordingState),
|
||||
liveCaptions: readonly(liveCaptions),
|
||||
patientCase: readonly(patientCase),
|
||||
onSendText: sendText,
|
||||
onSendAttachment: sendAttachment,
|
||||
onLoadMore: () => loadMessages(true),
|
||||
onReconnectChat: reconnectChat,
|
||||
onStartVideo: startVideo,
|
||||
onHangup: hangup,
|
||||
onOpenDiagnosis: openDiagnosis,
|
||||
onSaveScreenshot: saveScreenshot,
|
||||
}).mount('#app')
|
||||
|
||||
|
||||
@@ -272,6 +272,8 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
.video-layer {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
@@ -281,8 +283,21 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||
#090d14;
|
||||
}
|
||||
.video-layer--with-rail {
|
||||
grid-template-columns: minmax(0, 1fr) clamp(330px, 31vw, 380px);
|
||||
}
|
||||
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
||||
|
||||
.video-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||
#090d14;
|
||||
}
|
||||
|
||||
.call-kit,
|
||||
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||
width: 100% !important;
|
||||
@@ -336,40 +351,249 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
}
|
||||
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
||||
|
||||
.live-captions {
|
||||
position: absolute;
|
||||
z-index: 38;
|
||||
left: 50%;
|
||||
bottom: 92px;
|
||||
.consultation-rail {
|
||||
position: relative;
|
||||
z-index: 55;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
width: min(820px, calc(100% - 360px));
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
grid-template-rows: auto minmax(0, 1.08fr) minmax(0, .92fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid #dce3f0;
|
||||
color: #15233a;
|
||||
background: #f6f8fc;
|
||||
box-shadow: -16px 0 38px rgba(4, 12, 26, .16);
|
||||
}
|
||||
.live-captions p {
|
||||
.consultation-rail__header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
border-radius: 10px;
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
gap: 11px;
|
||||
align-items: center;
|
||||
min-height: 74px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid #e2e7f1;
|
||||
background: #fff;
|
||||
}
|
||||
.consultation-rail__avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 13px;
|
||||
color: #fff;
|
||||
background: rgba(9, 13, 20, .78);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
backdrop-filter: blur(8px);
|
||||
background: #5761f4;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.live-captions strong {
|
||||
color: #aeb8ff;
|
||||
.consultation-rail__identity {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.consultation-rail__identity strong {
|
||||
overflow: hidden;
|
||||
color: #111f46;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-captions span { min-width: 0; word-break: break-word; }
|
||||
.consultation-rail__identity span {
|
||||
overflow: hidden;
|
||||
color: #7886a3;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.diagnosis-chip {
|
||||
padding: 5px 7px;
|
||||
border-radius: 6px;
|
||||
color: #4a56d5;
|
||||
background: #eef0ff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.consultation-rail__actions {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
justify-items: end;
|
||||
gap: 5px;
|
||||
}
|
||||
.open-diagnosis-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #cfd5ff;
|
||||
border-radius: 7px;
|
||||
color: #3f4bc5;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.open-diagnosis-button:hover,
|
||||
.open-diagnosis-button:focus-visible {
|
||||
border-color: #6874e7;
|
||||
background: #f3f4ff;
|
||||
outline: none;
|
||||
}
|
||||
.open-diagnosis-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.case-panel,
|
||||
.transcript-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.case-panel { border-bottom: 1px solid #dde4f0; }
|
||||
.rail-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 48px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #e5e9f2;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
}
|
||||
.rail-section-heading > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.rail-section-heading h2 {
|
||||
margin: 0;
|
||||
color: #1b2945;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
.rail-section-heading > span {
|
||||
overflow: hidden;
|
||||
max-width: 154px;
|
||||
color: #8b97ad;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-section-heading__index {
|
||||
color: #6874e7;
|
||||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.case-panel__content,
|
||||
.live-captions {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: #b9c3d5 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.case-panel__content { padding: 5px 16px 14px; }
|
||||
.case-field-list { display: grid; }
|
||||
.case-field {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e5e9f2;
|
||||
}
|
||||
.case-field:last-child { border-bottom: 0; }
|
||||
.case-field > span {
|
||||
padding-top: 2px;
|
||||
color: #7a879d;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.case-field p {
|
||||
margin: 0;
|
||||
color: #263551;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.case-field--risk > span { color: #bc3e4e; }
|
||||
.case-field--risk p {
|
||||
color: #9f3141;
|
||||
font-weight: 600;
|
||||
}
|
||||
.live-captions {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 9px;
|
||||
padding: 12px 14px 18px;
|
||||
background: #f2f5fa;
|
||||
}
|
||||
.caption-entry {
|
||||
padding: 10px 11px;
|
||||
border-left: 2px solid #6571e8;
|
||||
border-radius: 0 9px 9px 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 14px rgba(30, 46, 76, .045);
|
||||
}
|
||||
.caption-entry--partial { border-left-color: #9ca6b7; opacity: .78; }
|
||||
.caption-entry header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.caption-entry strong {
|
||||
color: #4a56d5;
|
||||
font-size: 11px;
|
||||
}
|
||||
.caption-entry time {
|
||||
color: #9aa5b7;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.caption-entry p {
|
||||
margin: 0;
|
||||
color: #24334e;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.transcript-state {
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.transcript-state::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 50%;
|
||||
background: #a5adbb;
|
||||
content: "";
|
||||
}
|
||||
.transcript-state--active { color: #168260 !important; }
|
||||
.transcript-state--active::before {
|
||||
background: #24b987;
|
||||
box-shadow: 0 0 0 3px rgba(36, 185, 135, .12);
|
||||
}
|
||||
.rail-empty {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
align-content: center;
|
||||
min-height: 112px;
|
||||
padding: 18px;
|
||||
color: #8a96aa;
|
||||
text-align: center;
|
||||
}
|
||||
.rail-empty strong { color: #53617a; font-size: 12px; }
|
||||
.rail-empty span { font-size: 11px; line-height: 1.55; }
|
||||
.rail-empty--case { min-height: 100%; }
|
||||
|
||||
.video-actions {
|
||||
position: absolute;
|
||||
@@ -494,8 +718,54 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.consultation-shell { min-width: 620px; }
|
||||
.message-list { padding-inline: 18px; }
|
||||
.message-bubble { max-width: 82%; }
|
||||
.live-captions { width: calc(100% - 36px); bottom: 88px; }
|
||||
.live-captions p { font-size: 14px; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.consultation-shell { min-width: 760px; }
|
||||
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr) 310px; }
|
||||
.video-actions {
|
||||
right: 12px;
|
||||
bottom: 14px;
|
||||
left: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.recording-status {
|
||||
flex-basis: 100%;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
.consultation-rail__header { padding-inline: 12px; }
|
||||
.diagnosis-chip { display: none; }
|
||||
.case-panel__content { padding-inline: 12px; }
|
||||
.rail-section-heading { padding-inline: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.consultation-shell { min-width: 0; }
|
||||
.video-layer,
|
||||
.video-stage { min-height: 280px; }
|
||||
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr); }
|
||||
.consultation-rail { display: none; }
|
||||
.video-actions {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
left: auto;
|
||||
gap: 6px;
|
||||
}
|
||||
.recording-status,
|
||||
.capture-button { display: none; }
|
||||
.video-actions button {
|
||||
padding: 8px 11px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.live-status {
|
||||
top: 10px;
|
||||
padding: 7px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
|
||||
|
||||
@@ -1010,8 +1010,8 @@ class DiagnosisController extends BaseAdminController
|
||||
}
|
||||
if ($result === null) {
|
||||
$emit('error', [
|
||||
'code' => 'AI_ASSISTANT_FAILED',
|
||||
'message' => 'AI 助手暂时不可用,请稍后重试',
|
||||
'code' => DiagnosisAiLogic::getAssistantErrorCode(),
|
||||
'message' => DiagnosisAiLogic::getError(),
|
||||
]);
|
||||
} else {
|
||||
$emit('done', $result);
|
||||
|
||||
@@ -18,6 +18,9 @@ use think\facade\Log;
|
||||
*/
|
||||
class DiagnosisAiLogic extends BaseLogic
|
||||
{
|
||||
/** @var string Safe machine-readable code for the current assistant request. */
|
||||
private static $assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
|
||||
private const PROMPT_VERSION = 'patient-context-case-explain-v2';
|
||||
|
||||
private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-v2';
|
||||
@@ -354,6 +357,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
int $adminId,
|
||||
array $adminInfo
|
||||
): ?array {
|
||||
self::$assistantErrorCode = 'AI_ASSISTANT_FAILED';
|
||||
$diagnosis = self::loadAuthorizedDiagnosis(
|
||||
$diagnosisId,
|
||||
$adminId,
|
||||
@@ -433,6 +437,14 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
|
||||
$profile = (string) ($prepared['profile'] ?? '');
|
||||
$adminId = (int) ($prepared['admin_id'] ?? 0);
|
||||
$deliveredDelta = false;
|
||||
$forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) {
|
||||
$accepted = $onDelta($delta);
|
||||
if ($accepted !== false) {
|
||||
$deliveredDelta = true;
|
||||
}
|
||||
return $accepted;
|
||||
};
|
||||
|
||||
try {
|
||||
$result = DifyChatService::streamChat(
|
||||
@@ -440,7 +452,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
$onDelta,
|
||||
$forwardDelta,
|
||||
$shouldAbort,
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
@@ -452,6 +464,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
$e,
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE';
|
||||
self::setError('AI 助手暂时不可用,请稍后重试');
|
||||
return null;
|
||||
}
|
||||
@@ -464,6 +477,47 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
(string) ($prepared['task'] ?? ''),
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
|
||||
// Some Dify-compatible gateways accept blocking chat but reject or
|
||||
// incompletely terminate streaming responses. Before any delta has
|
||||
// reached the doctor it is safe to make one blocking compatibility
|
||||
// attempt; after a delta, retrying could duplicate clinical text.
|
||||
$streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
|
||||
if (
|
||||
!$deliveredDelta
|
||||
&& in_array(
|
||||
$streamErrorCode,
|
||||
['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'],
|
||||
true
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$result = DifyChatService::chat(
|
||||
$profile,
|
||||
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
|
||||
(string) ($prepared['query'] ?? ''),
|
||||
(string) ($prepared['user'] ?? ''),
|
||||
is_array($prepared['files'] ?? null) ? $prepared['files'] : []
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
self::logAssistantFailure(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
$e,
|
||||
(string) ($prepared['task'] ?? '')
|
||||
);
|
||||
}
|
||||
if (empty($result['ok'])) {
|
||||
self::logAssistantUpstreamError(
|
||||
$diagnosisId,
|
||||
$profile,
|
||||
$adminId,
|
||||
(string) ($prepared['task'] ?? ''),
|
||||
is_array($result) ? $result : []
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::formatAssistantResult($prepared, $result);
|
||||
@@ -480,6 +534,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
// 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。
|
||||
$message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试');
|
||||
$errorCode = trim((string) ($result['error_code'] ?? ''));
|
||||
self::$assistantErrorCode = self::normaliseAssistantErrorCode($errorCode);
|
||||
if ($errorCode !== '') {
|
||||
$message .= '(' . $errorCode . ')';
|
||||
}
|
||||
@@ -488,6 +543,7 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
}
|
||||
$content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true);
|
||||
if ($content === '') {
|
||||
self::$assistantErrorCode = 'EMPTY_RESPONSE';
|
||||
self::setError('AI 助手未返回内容,请重试');
|
||||
return null;
|
||||
}
|
||||
@@ -520,6 +576,20 @@ class DiagnosisAiLogic extends BaseLogic
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/** Return a safe code for the current SSE terminal error event. */
|
||||
public static function getAssistantErrorCode(): string
|
||||
{
|
||||
return self::normaliseAssistantErrorCode(self::$assistantErrorCode);
|
||||
}
|
||||
|
||||
private static function normaliseAssistantErrorCode(string $code): string
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
return preg_match('/^[A-Z][A-Z0-9_]{2,63}$/', $code) === 1
|
||||
? $code
|
||||
: 'AI_ASSISTANT_FAILED';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private static function parsePrescriptionDraft(string $content): ?array
|
||||
{
|
||||
|
||||
@@ -91,6 +91,9 @@ class DifyChatService
|
||||
);
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
// 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断
|
||||
// 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。
|
||||
$fileRejected = false;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
@@ -111,11 +114,12 @@ class DifyChatService
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if ($hasFallback && in_array($response['http_code'], [404, 405, 501], true)) {
|
||||
if ($hasFallback && self::shouldTryNextProtocol($response, false)) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
@@ -128,7 +132,7 @@ class DifyChatService
|
||||
return $formatted;
|
||||
}
|
||||
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
|
||||
if (!self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])) {
|
||||
if (!$fileRejected) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
@@ -203,6 +207,9 @@ class DifyChatService
|
||||
);
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
// 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断
|
||||
// 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。
|
||||
$fileRejected = false;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
@@ -226,13 +233,13 @@ class DifyChatService
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
||||
|
||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if (
|
||||
$hasFallback
|
||||
&& empty($response['emitted'])
|
||||
&& in_array($response['http_code'], [404, 405, 501], true)
|
||||
&& self::shouldTryNextProtocol($response, true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -246,9 +253,6 @@ class DifyChatService
|
||||
return $formatted;
|
||||
}
|
||||
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
|
||||
// 附件不可达时 Dify 会在 200 流里发 event:error,同样按附件问题降级。
|
||||
$fileRejected = self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])
|
||||
|| (!empty($lastResponse['upstream_error']) && $attempt['files'] !== []);
|
||||
if (!empty($lastResponse['emitted']) || !$fileRejected) {
|
||||
return $formatted;
|
||||
}
|
||||
@@ -458,6 +462,26 @@ class DifyChatService
|
||||
return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一次上游响应是否属于“这批附件我处理不了”。
|
||||
*
|
||||
* 除了 4xx 状态码,Dify 拉不到附件时会在 200 的 SSE 流里发 event:error,
|
||||
* 这两种形态都必须触发去掉附件的降级重试。
|
||||
*
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<int,array<string,string>> $files
|
||||
*/
|
||||
private static function isFileRejection(array $response, array $files): bool
|
||||
{
|
||||
if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) {
|
||||
return false;
|
||||
}
|
||||
if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) {
|
||||
return true;
|
||||
}
|
||||
return !empty($response['upstream_error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到,
|
||||
* 才不会把“没看到”当成“没有”。
|
||||
@@ -509,6 +533,30 @@ class DifyChatService
|
||||
return $baseUrl . '/v1/' . $endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an ambiguous base URL should be tried with the other wire
|
||||
* protocol. A 400/415/422 response cannot have started generation, and a
|
||||
* 2xx stream with no delivered delta but no valid terminal frame is also
|
||||
* safe to retry. Authentication, rate-limit and server failures retain
|
||||
* their original diagnosis instead of being hidden by a second request.
|
||||
*
|
||||
* @param array<string,mixed> $response
|
||||
*/
|
||||
private static function shouldTryNextProtocol(array $response, bool $streaming): bool
|
||||
{
|
||||
if ((int) ($response['errno'] ?? 0) !== 0) {
|
||||
return false;
|
||||
}
|
||||
$httpCode = (int) ($response['http_code'] ?? 0);
|
||||
if (in_array($httpCode, [400, 404, 405, 415, 422, 501], true)) {
|
||||
return true;
|
||||
}
|
||||
if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) {
|
||||
return false;
|
||||
}
|
||||
return !empty($response['upstream_error']) || empty($response['finished']);
|
||||
}
|
||||
|
||||
private static function isValidBaseUrl(string $baseUrl): bool
|
||||
{
|
||||
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
|
||||
|
||||
@@ -100,11 +100,14 @@ assistantStreamExpect(
|
||||
);
|
||||
assistantStreamExpect(
|
||||
str_contains($controller, "'text' => \$delta")
|
||||
&& str_contains($controller, "'code' => 'AI_ASSISTANT_FAILED'")
|
||||
&& str_contains($controller, 'DiagnosisAiLogic::getAssistantErrorCode()')
|
||||
&& str_contains($controller, 'DiagnosisAiLogic::getError()')
|
||||
&& str_contains($controller, 'ignore_user_abort(true)')
|
||||
&& str_contains($controller, 'connection_aborted() === 1')
|
||||
&& !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"),
|
||||
'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload'
|
||||
&& str_contains($logic, '!$deliveredDelta')
|
||||
&& str_contains($logic, "['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE']")
|
||||
&& str_contains($logic, 'DifyChatService::chat('),
|
||||
'delta carries text, disconnects abort upstream, and pre-delta stream failures safely fall back once'
|
||||
);
|
||||
assistantStreamExpect(
|
||||
str_contains($logic, 'DifyChatService::chat(')
|
||||
|
||||
@@ -95,6 +95,37 @@ $explicitOpenAi = callDifyStreamPrivate('buildRequestSpecs', [
|
||||
difyStreamExpect(count($explicitDify) === 1 && $explicitDify[0]['protocol'] === 'dify', 'explicit Dify endpoint never changes protocol');
|
||||
difyStreamExpect(count($explicitOpenAi) === 1 && $explicitOpenAi[0]['protocol'] === 'openai', 'explicit OpenAI endpoint never changes protocol');
|
||||
|
||||
$retryableStream = [
|
||||
'errno' => 0,
|
||||
'http_code' => 200,
|
||||
'emitted' => false,
|
||||
'upstream_error' => true,
|
||||
'finished' => false,
|
||||
];
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [$retryableStream, true]) === true,
|
||||
'a terminal-free stream error before any delta tries the alternate protocol'
|
||||
);
|
||||
$retryableStream['emitted'] = true;
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [$retryableStream, true]) === false,
|
||||
'an emitted delta forbids protocol retry'
|
||||
);
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||
'errno' => 0,
|
||||
'http_code' => 400,
|
||||
], false]) === true,
|
||||
'an ambiguous endpoint rejected before generation tries the alternate protocol'
|
||||
);
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||
'errno' => 0,
|
||||
'http_code' => 401,
|
||||
], true]) === false,
|
||||
'credential failures are not hidden by protocol retry'
|
||||
);
|
||||
|
||||
$difyWire = ": ping\r\n\r\n"
|
||||
. "data: {\"event\":\"message\",\"answer\":\"你\",\"message_id\":\"msg-safe\"}\r\n\r\n"
|
||||
. "data: {\"event\":\"agent_message\",\"answer\":\"好\"}\r\n\r\n"
|
||||
|
||||
@@ -192,6 +192,40 @@ expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-onl
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [401, $capped['kept']]), 'a credential failure is not retried');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [500, $capped['kept']]), 'an upstream outage is not retried here');
|
||||
|
||||
// 协议回退会把最初的“附件被拒”换成另一协议的状态码(Dify 400 -> OpenAI 404),
|
||||
// 降级判断必须按每次响应累计,否则去掉附件的重试永远不会发生。
|
||||
expectSame(
|
||||
true,
|
||||
callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 400], $capped['kept']]),
|
||||
'an attachment rejection is recognised on the response that carried it'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 404], $capped['kept']]),
|
||||
'the fallback protocol 404 is not itself an attachment rejection'
|
||||
);
|
||||
expectSame(
|
||||
true,
|
||||
callPrivate('isFileRejection', [
|
||||
['errno' => 0, 'http_code' => 200, 'upstream_error' => true],
|
||||
$capped['kept'],
|
||||
]),
|
||||
'a 200 stream carrying event:error counts as an attachment rejection'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isFileRejection', [
|
||||
['errno' => 0, 'http_code' => 200, 'upstream_error' => true],
|
||||
[],
|
||||
]),
|
||||
'a text-only request never degrades further'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isFileRejection', [['errno' => 28, 'http_code' => 0], $capped['kept']]),
|
||||
'a transport failure is not mistaken for an attachment rejection'
|
||||
);
|
||||
|
||||
// Dify 的 inputs 必须是 JSON 对象。PHP 空数组会被编码成 [],上游以
|
||||
// invalid_param 拒绝整单——空 inputs 的调用方会 100% 失败。
|
||||
$emptyInputs = callPrivate('buildRequestSpecs', [
|
||||
|
||||
Reference in New Issue
Block a user