更新
This commit is contained in:
@@ -19,7 +19,6 @@ from doctor_workstation.core.errors import (
|
||||
ApiBusinessError,
|
||||
ApiHttpError,
|
||||
ApiProtocolError,
|
||||
ApiTimeoutError,
|
||||
ApiTransportError,
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
@@ -1413,11 +1412,11 @@ class RemoteDoctorRepository:
|
||||
task: str = "custom",
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Stream a diagnosis answer, with one legacy fallback before first content."""
|
||||
"""Stream a diagnosis answer; use the legacy route only when streaming is absent."""
|
||||
|
||||
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
|
||||
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
|
||||
received_delta = False
|
||||
received_event = False
|
||||
received_done = False
|
||||
try:
|
||||
for raw_event in self.client.post_event_stream(
|
||||
@@ -1428,13 +1427,12 @@ class RemoteDoctorRepository:
|
||||
):
|
||||
if cancelled is not None and cancelled():
|
||||
return
|
||||
received_event = True
|
||||
event = _normalise_diagnosis_ai_event(raw_event)
|
||||
if event is None:
|
||||
continue
|
||||
kind = event["event"]
|
||||
if kind == "delta":
|
||||
received_delta = True
|
||||
elif kind == "done":
|
||||
if kind == "done":
|
||||
received_done = True
|
||||
yield event
|
||||
if kind == "done":
|
||||
@@ -1443,12 +1441,16 @@ class RemoteDoctorRepository:
|
||||
return
|
||||
if not received_done:
|
||||
raise ApiProtocolError("AI assistant stream ended before done")
|
||||
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
|
||||
if received_delta or (cancelled is not None and cancelled()):
|
||||
except ApiHttpError as error:
|
||||
if (
|
||||
error.status_code not in {404, 405}
|
||||
or received_event
|
||||
or (cancelled is not None and cancelled())
|
||||
):
|
||||
raise
|
||||
|
||||
# Older deployments do not expose the stream route. Submit exactly one
|
||||
# request through the confirmed non-streaming endpoint in that case.
|
||||
# Only a missing stream route on an older deployment justifies another
|
||||
# full request. Timeouts, transport failures and malformed streams do not.
|
||||
result = self.analyze_diagnosis_ai(
|
||||
diagnosis_id,
|
||||
clean_prompt,
|
||||
|
||||
@@ -3146,6 +3146,8 @@ class _AiStreamWorker(QRunnable):
|
||||
@Slot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
if self.is_cancelled():
|
||||
return
|
||||
stream = getattr(self.repository, "stream_diagnosis_ai", None)
|
||||
if callable(stream):
|
||||
events = stream(
|
||||
@@ -3169,6 +3171,8 @@ class _AiStreamWorker(QRunnable):
|
||||
{"event": "delta", "text": answer, "fallback": True},
|
||||
{**payload, "event": "done", "fallback": True},
|
||||
)
|
||||
if self.is_cancelled():
|
||||
return
|
||||
for event in events:
|
||||
if self.is_cancelled():
|
||||
return
|
||||
@@ -3207,13 +3211,18 @@ class _ClickCard(QFrame):
|
||||
AI_CONTEXT_MAX_CHARS = 320
|
||||
AI_PROMPT_LIMIT = 500
|
||||
|
||||
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
|
||||
AI_STREAM_PENDING_TEXT = "正在生成…"
|
||||
#: 等待服务端整理诊单资料时显示的占位文案。
|
||||
AI_STREAM_PENDING_TEXT = "正在整理诊单资料…"
|
||||
|
||||
#: 服务端确认资料已准备好、尚未收到回答时显示的占位文案。
|
||||
AI_STREAM_WAITING_TEXT = "资料已整理,正在等待 AI 回复…"
|
||||
|
||||
#: 连接结束但一个字都没收到时的兜底文案。
|
||||
AI_STREAM_SILENT_TEXT = (
|
||||
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
|
||||
)
|
||||
AI_STREAM_TOTAL_TIMEOUT_MS = 180_000
|
||||
AI_STREAM_TIMEOUT_TEXT = "等待 AI 回复超时,已停止本次等待,请稍后重试。"
|
||||
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
|
||||
|
||||
|
||||
@@ -3534,6 +3543,9 @@ class AiConsultDialog(QDialog):
|
||||
self._flush_timer.setSingleShot(True)
|
||||
self._flush_timer.setInterval(40)
|
||||
self._flush_timer.timeout.connect(self._flush_stream_chunks)
|
||||
self._stream_timeout_timer = QTimer(self)
|
||||
self._stream_timeout_timer.setSingleShot(True)
|
||||
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
|
||||
self.setObjectName("AiConsultDialog")
|
||||
self.setWindowTitle("问诊详情")
|
||||
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
|
||||
@@ -6028,6 +6040,7 @@ class AiConsultDialog(QDialog):
|
||||
worker.signals.finished.connect(
|
||||
lambda: self._stream_finished(generation, stream_generation, worker)
|
||||
)
|
||||
self._stream_timeout_timer.start(AI_STREAM_TOTAL_TIMEOUT_MS)
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||
|
||||
@@ -6046,6 +6059,8 @@ class AiConsultDialog(QDialog):
|
||||
kind = str(payload.get("event") or "").lower()
|
||||
if kind == "start":
|
||||
self._stream_meta.update(payload)
|
||||
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
|
||||
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
|
||||
return
|
||||
if kind == "delta":
|
||||
chunk = payload.get("text")
|
||||
@@ -6058,6 +6073,7 @@ class AiConsultDialog(QDialog):
|
||||
return
|
||||
self._stream_meta.update(payload)
|
||||
self._stream_completed = True
|
||||
self._stream_timeout_timer.stop()
|
||||
if not self._stream_text and not self._pending_chunks:
|
||||
answer = first_value(payload, "answer", "content", default="")
|
||||
if answer not in (None, ""):
|
||||
@@ -6094,6 +6110,7 @@ class AiConsultDialog(QDialog):
|
||||
) -> None:
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
return
|
||||
self._stream_timeout_timer.stop()
|
||||
self._flush_stream_chunks()
|
||||
message = friendly_error(error)
|
||||
if self._stream_text:
|
||||
@@ -6112,10 +6129,11 @@ class AiConsultDialog(QDialog):
|
||||
) -> None:
|
||||
if not self._stream_is_current(generation, stream_generation):
|
||||
return
|
||||
self._stream_timeout_timer.stop()
|
||||
if self._stream_worker is worker:
|
||||
self._stream_worker = None
|
||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
|
||||
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
|
||||
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
|
||||
# 将阶段占位文案替换成明确说明。
|
||||
if (
|
||||
not self._stream_completed
|
||||
and not self._stream_text
|
||||
@@ -6127,9 +6145,23 @@ class AiConsultDialog(QDialog):
|
||||
self._asking = False
|
||||
self.send_button.setEnabled(True)
|
||||
|
||||
def _stream_timed_out(self) -> None:
|
||||
if not self._asking or self._stream_completed:
|
||||
return
|
||||
self._flush_stream_chunks()
|
||||
bubble = self._stream_bubble
|
||||
text = self._stream_text
|
||||
self._cancel_stream()
|
||||
if bubble is not None:
|
||||
bubble.set_payload(
|
||||
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
|
||||
)
|
||||
bubble.set_time_text(datetime.now().strftime("%H:%M"))
|
||||
|
||||
def _cancel_stream(self) -> None:
|
||||
self._stream_generation += 1
|
||||
self._flush_timer.stop()
|
||||
self._stream_timeout_timer.stop()
|
||||
if self._stream_worker is not None:
|
||||
self._stream_worker.cancel()
|
||||
self._stream_worker = None
|
||||
|
||||
Reference in New Issue
Block a user