feat(voice-client): 双层配置持久化、精简手术号 UI 与 WS/服务端排查日志
- machine_config:系统级 + 用户级 voice_client.json 合并,界面失焦保存至用户目录 - 移除「当前手术号」表单项与占位文案;指派后仅在窗口标题显示手术号 - WebSocket 连接日志附带绑定/开录路径排查说明 - 开录未推送时服务端 WARNING(无站点绑定或 camera_ids 不匹配) - 测试、README、.env.example 同步 Made-with: Cursor
This commit is contained in:
@@ -40,9 +40,20 @@ uv run --group voice-client voice-confirmation-client
|
||||
|
||||
**术间 / 摄像头 / 语音终端对应关系**只在服务端 `**OR_SITE_CONFIG_JSON_FILE`** 里维护一份(`voice_or_room_bindings`);桌面程序不读该文件。
|
||||
|
||||
本机要做的只有两件事:**服务端 Base URL**,以及 **本机语音终端 ID**(须等于 JSON 里某条 `voice_terminal_id`)。**手术号不在客户端输入**:勾选 **启用服务端自动指派** 后,开录/停录仅通过 **WebSocket** `voice_assignment` 下发(断线后自动重连,不用 HTTP 轮询);界面只读展示当前手术号;停录后自动停止。可用 **停止监控(本机)** 做本地紧急中断。
|
||||
本机要做的只有两件事:**服务端 Base URL**,以及 **本机语音终端 ID**(须等于 JSON 里某条 `voice_terminal_id`)。**手术号不在客户端输入**:勾选 **启用服务端自动指派** 后,开录/停录仅通过 **WebSocket** `voice_assignment` 下发(断线后自动重连,不用 HTTP 轮询);当前手术号在**窗口标题**中显示;停录后自动停止。可用 **停止监控(本机)** 做本地紧急中断。
|
||||
|
||||
可选:用环境变量 `**VOICE_TERMINAL_ID`** 预填界面里的「本机语音终端 ID」(仅客户端进程使用,**不要**写进监控服务的 `.env`)。
|
||||
### 配置文件(系统级 + 用户级)
|
||||
|
||||
字段均为 **UTF-8 JSON**(`voice_terminal_id`、`http_base_url`,均可选)。启动时 **合并** 两层配置:**用户级覆盖系统级** 同名字段。
|
||||
|
||||
| 层级 | 用途 | 默认路径 |
|
||||
|------|------|----------|
|
||||
| **系统级** | 运维装机下发(只读亦可) | **Windows** `%ProgramData%\OperationRoomMonitor\voice_client.json`;**macOS** `/Library/Application Support/OperationRoomMonitor/voice_client.json`;**Linux** `/etc/operation-room-monitor/voice_client.json` |
|
||||
| **用户级** | 在界面修改「服务端 Base URL」或「本机语音终端 ID」并 **离开输入框** 后自动保存 | **Windows** `%LOCALAPPDATA%\OperationRoomMonitor\voice_client.json`;**macOS** `~/Library/Application Support/OperationRoomMonitor/voice_client.json`;**Linux** `~/.config/operation-room-monitor/voice_client.json` |
|
||||
|
||||
测试或定制安装可用环境变量 **`VOICE_CLIENT_MACHINE_CONFIG_FILE`**、**`VOICE_CLIENT_USER_CONFIG_FILE`** 分别覆盖上述两个路径。
|
||||
|
||||
示例见 `voice_confirmation_client/resources/voice_client.sample.json`(通常用作系统级模板)。
|
||||
|
||||
## 日志(loguru)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -76,7 +75,12 @@ class VoiceAssignmentListener:
|
||||
try:
|
||||
|
||||
def _on_open(_ws: Any) -> None:
|
||||
logger.info("WebSocket 已连接,等待服务端 voice_assignment 消息")
|
||||
logger.info("WebSocket 已连接 terminal_id={!r}", self._terminal_id)
|
||||
logger.info(
|
||||
"若开录后仍无 voice_assignment:核对本机 ID 与 OR_SITE_CONFIG「voice_or_room_bindings」、"
|
||||
"开录请求 camera_ids 能否解析到该终端;开录须 POST /client/surgeries/start "
|
||||
"或(联调)POST /internal/demo/orchestrate-and-start。"
|
||||
)
|
||||
|
||||
def _on_close(_ws: Any, close_status_code: Any, close_msg: Any) -> None:
|
||||
logger.warning(
|
||||
@@ -137,7 +141,3 @@ class VoiceAssignmentListener:
|
||||
self._on_end(sid)
|
||||
else:
|
||||
logger.debug("忽略 voice_assignment action={!r}", action)
|
||||
|
||||
|
||||
def default_terminal_id_from_env() -> str:
|
||||
return (os.environ.get("VOICE_TERMINAL_ID") or "").strip()
|
||||
|
||||
104
voice_confirmation_client/core/machine_config.py
Normal file
104
voice_confirmation_client/core/machine_config.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""语音客户端配置:系统级(运维下发)+ 用户级(界面保存,覆盖同名字段)。
|
||||
|
||||
- ``VOICE_CLIENT_MACHINE_CONFIG_FILE``:仅改变**系统级**配置文件路径(测试或定制)。
|
||||
- ``VOICE_CLIENT_USER_CONFIG_FILE``:仅改变**用户级**配置文件路径(测试或定制)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
_DEFAULT_HTTP_BASE = "http://127.0.0.1:38080"
|
||||
_CONFIG_FILENAME = "voice_client.json"
|
||||
|
||||
|
||||
def machine_config_file_path() -> Path:
|
||||
"""系统级配置:运维部署;只读亦可。"""
|
||||
override = (os.environ.get("VOICE_CLIENT_MACHINE_CONFIG_FILE") or "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
if sys.platform == "win32":
|
||||
base = os.environ.get("PROGRAMDATA", r"C:\ProgramData")
|
||||
return Path(base) / "OperationRoomMonitor" / _CONFIG_FILENAME
|
||||
if sys.platform == "darwin":
|
||||
return (
|
||||
Path("/Library/Application Support/OperationRoomMonitor")
|
||||
/ _CONFIG_FILENAME
|
||||
)
|
||||
return Path("/etc/operation-room-monitor") / _CONFIG_FILENAME
|
||||
|
||||
|
||||
def user_voice_client_config_path() -> Path:
|
||||
"""用户级配置:当前登录用户可写,界面编辑后保存到此。"""
|
||||
override = (os.environ.get("VOICE_CLIENT_USER_CONFIG_FILE") or "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
if sys.platform == "win32":
|
||||
base = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local"))
|
||||
return Path(base) / "OperationRoomMonitor" / _CONFIG_FILENAME
|
||||
if sys.platform == "darwin":
|
||||
return (
|
||||
Path.home() / "Library/Application Support/OperationRoomMonitor" / _CONFIG_FILENAME
|
||||
)
|
||||
return Path.home() / ".config/operation-room-monitor" / _CONFIG_FILENAME
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except OSError as exc:
|
||||
logger.warning("无法读取语音客户端配置 {}: {}", path, exc)
|
||||
return {}
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("语音客户端配置 JSON 无效 {}: {}", path, exc)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("语音客户端配置须为 JSON 对象: {}", path)
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def load_voice_client_config() -> dict[str, Any]:
|
||||
"""合并系统配置与用户配置;同键时用户覆盖系统。"""
|
||||
system = _read_json_object(machine_config_file_path())
|
||||
user = _read_json_object(user_voice_client_config_path())
|
||||
merged: dict[str, Any] = dict(system)
|
||||
merged.update(user)
|
||||
return merged
|
||||
|
||||
|
||||
def save_user_voice_client_config(*, voice_terminal_id: str, http_base_url: str) -> None:
|
||||
"""将当前界面上的连接参数写入用户级配置文件。"""
|
||||
path = user_voice_client_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"voice_terminal_id": voice_terminal_id.strip(),
|
||||
"http_base_url": http_base_url.strip().rstrip("/"),
|
||||
}
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def voice_terminal_id_from_config(file_data: dict[str, Any]) -> str:
|
||||
"""合并后 dict 中的 ``voice_terminal_id``;缺省为空串。"""
|
||||
v = file_data.get("voice_terminal_id")
|
||||
return str(v).strip() if v is not None else ""
|
||||
|
||||
|
||||
def http_base_url_from_config(file_data: dict[str, Any]) -> str:
|
||||
"""合并后 dict 中的 ``http_base_url``;缺省为 ``http://127.0.0.1:38080``。"""
|
||||
v = file_data.get("http_base_url")
|
||||
if v is not None and str(v).strip():
|
||||
return str(v).strip().rstrip("/")
|
||||
return _DEFAULT_HTTP_BASE
|
||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
from PySide6.QtGui import QCloseEvent, QShowEvent
|
||||
from PySide6.QtCore import Qt, QObject, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent, QFont, QShowEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -26,9 +27,14 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from voice_confirmation_client.core.assignment_listener import (
|
||||
VoiceAssignmentListener,
|
||||
default_terminal_id_from_env,
|
||||
from voice_confirmation_client.core.assignment_listener import VoiceAssignmentListener
|
||||
from voice_confirmation_client.core.machine_config import (
|
||||
http_base_url_from_config,
|
||||
load_voice_client_config,
|
||||
machine_config_file_path,
|
||||
save_user_voice_client_config,
|
||||
user_voice_client_config_path,
|
||||
voice_terminal_id_from_config,
|
||||
)
|
||||
from voice_confirmation_client.core.monitor_worker import MonitorWorker
|
||||
from voice_confirmation_client.logging_config import setup_voice_client_logging
|
||||
@@ -52,54 +58,40 @@ class MainWindow(QMainWindow):
|
||||
self.resize(920, 640)
|
||||
self._assignment_listener: VoiceAssignmentListener | None = None
|
||||
self._assigned_surgery_id: str = ""
|
||||
self._rec_banner_pulse_phase: bool = False
|
||||
self._rec_pulse_timer = QTimer(self)
|
||||
self._rec_pulse_timer.setInterval(550)
|
||||
self._rec_pulse_timer.timeout.connect(self._pulse_recording_banner)
|
||||
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.log_line.connect(self._append_log_plain)
|
||||
self._bridge.pending_payload.connect(self._show_pending)
|
||||
self._bridge.voice_assign_start.connect(self._on_server_assign_start)
|
||||
self._bridge.voice_assign_end.connect(self._on_server_assign_end)
|
||||
|
||||
setup_voice_client_logging(
|
||||
gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")),
|
||||
)
|
||||
logger.info(
|
||||
"语音确认客户端已启动;请填写「本机语音终端 ID」与 OR_SITE_CONFIG 一致,"
|
||||
"开录后由服务端自动填入手术号并监控"
|
||||
)
|
||||
|
||||
self._worker = MonitorWorker(
|
||||
on_log=None,
|
||||
on_state=lambda s: self._bridge.state_text.emit(s),
|
||||
on_pending=lambda p: self._bridge.pending_payload.emit(p),
|
||||
)
|
||||
self._worker.start_thread()
|
||||
_mc = load_voice_client_config()
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root = QVBoxLayout(central)
|
||||
|
||||
form_box = QGroupBox("连接与当前手术")
|
||||
form_box = QGroupBox("连接")
|
||||
form = QFormLayout(form_box)
|
||||
self._base_url = QLineEdit("http://127.0.0.1:38080")
|
||||
self._surgery_id_display = QLabel("—(等待服务端开录指派)")
|
||||
self._surgery_id_display.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
self._base_url = QLineEdit()
|
||||
self._record_sec = QDoubleSpinBox()
|
||||
self._record_sec.setRange(2.0, 60.0)
|
||||
self._record_sec.setValue(8.0)
|
||||
self._record_sec.setSuffix(" s")
|
||||
form.addRow("服务端 Base URL", self._base_url)
|
||||
self._terminal_id = QLineEdit(default_terminal_id_from_env())
|
||||
self._base_url.setText(http_base_url_from_config(_mc))
|
||||
self._terminal_id = QLineEdit(voice_terminal_id_from_config(_mc))
|
||||
self._terminal_id.setPlaceholderText("与 OR_SITE_CONFIG 中 voice_terminal_id 一致")
|
||||
self._auto_assign = QCheckBox("启用服务端自动指派(开录后自动监控该手术)")
|
||||
self._auto_assign.setChecked(True)
|
||||
form.addRow("本机语音终端 ID", self._terminal_id)
|
||||
form.addRow(self._auto_assign)
|
||||
self._terminal_id.editingFinished.connect(self._sync_assignment_listener)
|
||||
self._base_url.editingFinished.connect(self._sync_assignment_listener)
|
||||
self._terminal_id.editingFinished.connect(self._on_connection_fields_edited)
|
||||
self._base_url.editingFinished.connect(self._on_connection_fields_edited)
|
||||
self._auto_assign.toggled.connect(self._on_auto_assign_toggled)
|
||||
form.addRow("当前手术号(仅展示,由服务端指派)", self._surgery_id_display)
|
||||
form.addRow("录音时长", self._record_sec)
|
||||
self._record_sec.valueChanged.connect(lambda _: self._apply_settings_silent())
|
||||
root.addWidget(form_box)
|
||||
@@ -130,9 +122,25 @@ class MainWindow(QMainWindow):
|
||||
btn_row.addStretch()
|
||||
root.addLayout(btn_row)
|
||||
|
||||
self._recording_banner = QFrame()
|
||||
self._recording_banner.setObjectName("recordingBanner")
|
||||
self._recording_banner.setVisible(False)
|
||||
bl = QHBoxLayout(self._recording_banner)
|
||||
bl.setContentsMargins(14, 12, 14, 12)
|
||||
self._recording_banner_label = QLabel("● 正在录音 — 请对着麦克风清晰作答")
|
||||
self._recording_banner_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
bf = QFont()
|
||||
bf.setPointSize(15)
|
||||
bf.setBold(True)
|
||||
self._recording_banner_label.setFont(bf)
|
||||
self._recording_banner_label.setStyleSheet("color: #ffffff; background: transparent;")
|
||||
bl.addWidget(self._recording_banner_label, stretch=1)
|
||||
self._apply_recording_banner_style("#b71c1c")
|
||||
root.addWidget(self._recording_banner)
|
||||
|
||||
self._status_label = QLabel("待机")
|
||||
root.addWidget(self._status_label)
|
||||
self._bridge.state_text.connect(self._status_label.setText)
|
||||
self._bridge.state_text.connect(self._on_worker_state)
|
||||
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
self._pending_view = QPlainTextEdit()
|
||||
@@ -146,6 +154,27 @@ class MainWindow(QMainWindow):
|
||||
split.setSizes([360, 520])
|
||||
root.addWidget(split, stretch=1)
|
||||
|
||||
self._bridge.log_line.connect(self._append_log_plain)
|
||||
setup_voice_client_logging(
|
||||
gui_line_sink=lambda m: self._bridge.log_line.emit(m.rstrip("\n")),
|
||||
)
|
||||
logger.info(
|
||||
"语音确认客户端已启动;本机终端 ID 须与 OR_SITE_CONFIG 中 voice_terminal_id 一致"
|
||||
)
|
||||
_sys_cfg = machine_config_file_path()
|
||||
if _sys_cfg.is_file():
|
||||
logger.info("系统级配置: {}", _sys_cfg)
|
||||
_user_cfg = user_voice_client_config_path()
|
||||
if _user_cfg.is_file():
|
||||
logger.info("用户级配置(界面保存): {}", _user_cfg)
|
||||
|
||||
self._worker = MonitorWorker(
|
||||
on_log=None,
|
||||
on_state=lambda s: self._bridge.state_text.emit(s),
|
||||
on_pending=lambda p: self._bridge.pending_payload.emit(p),
|
||||
)
|
||||
self._worker.start_thread()
|
||||
|
||||
self._btn_stop.clicked.connect(self._stop_monitoring)
|
||||
self._btn_retry.clicked.connect(self._worker.retry_failed)
|
||||
self._btn_replay.clicked.connect(self._worker.replay_prompt_only)
|
||||
@@ -156,16 +185,60 @@ class MainWindow(QMainWindow):
|
||||
super().showEvent(event)
|
||||
self._sync_assignment_listener()
|
||||
|
||||
def _apply_recording_banner_style(self, bg_hex: str) -> None:
|
||||
self._recording_banner.setStyleSheet(
|
||||
f"QFrame#recordingBanner {{ background-color: {bg_hex}; border-radius: 8px; }}"
|
||||
)
|
||||
|
||||
def _pulse_recording_banner(self) -> None:
|
||||
self._rec_banner_pulse_phase = not self._rec_banner_pulse_phase
|
||||
self._apply_recording_banner_style(
|
||||
"#c62828" if self._rec_banner_pulse_phase else "#b71c1c"
|
||||
)
|
||||
|
||||
def _set_recording_banner_active(self, active: bool) -> None:
|
||||
if active:
|
||||
self._recording_banner.setVisible(True)
|
||||
self._rec_banner_pulse_phase = False
|
||||
self._apply_recording_banner_style("#b71c1c")
|
||||
if not self._rec_pulse_timer.isActive():
|
||||
self._rec_pulse_timer.start()
|
||||
else:
|
||||
self._rec_pulse_timer.stop()
|
||||
self._recording_banner.setVisible(False)
|
||||
self._apply_recording_banner_style("#b71c1c")
|
||||
|
||||
def _on_worker_state(self, s: str) -> None:
|
||||
self._status_label.setText(s)
|
||||
self._set_recording_banner_active("录音中" in s)
|
||||
|
||||
def _refresh_window_title(self) -> None:
|
||||
base = "手术室耗材语音确认客户端"
|
||||
if self._assigned_surgery_id:
|
||||
self.setWindowTitle(f"{base} — 手术 {self._assigned_surgery_id}")
|
||||
else:
|
||||
self.setWindowTitle(base)
|
||||
|
||||
def _on_auto_assign_toggled(self, _checked: bool) -> None:
|
||||
if not self._auto_assign.isChecked():
|
||||
self._worker.set_monitoring(False)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(未启用自动指派)")
|
||||
self._refresh_window_title()
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._status_label.setText("已关闭自动指派")
|
||||
self._on_worker_state("已关闭自动指派")
|
||||
self._apply_settings_silent()
|
||||
self._sync_assignment_listener()
|
||||
|
||||
def _on_connection_fields_edited(self) -> None:
|
||||
try:
|
||||
save_user_voice_client_config(
|
||||
voice_terminal_id=self._terminal_id.text(),
|
||||
http_base_url=self._base_url.text(),
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning("无法保存用户级配置: {}", exc)
|
||||
self._sync_assignment_listener()
|
||||
|
||||
def _sync_assignment_listener(self) -> None:
|
||||
if self._assignment_listener:
|
||||
self._assignment_listener.stop()
|
||||
@@ -174,7 +247,7 @@ class MainWindow(QMainWindow):
|
||||
logger.info("未勾选「启用服务端自动指派」,不连接 WebSocket")
|
||||
self._worker.set_monitoring(False)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(未启用自动指派)")
|
||||
self._refresh_window_title()
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._apply_settings_silent()
|
||||
return
|
||||
@@ -182,7 +255,7 @@ class MainWindow(QMainWindow):
|
||||
base = self._base_url.text().strip()
|
||||
if not tid:
|
||||
logger.warning(
|
||||
"「本机语音终端 ID」为空,无法接收开录指派;请在环境变量 VOICE_TERMINAL_ID 或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)"
|
||||
"「本机语音终端 ID」为空,无法接收开录指派;请在每机配置文件或界面中填写(须与 OR_SITE_CONFIG 中 voice_terminal_id 一致)"
|
||||
)
|
||||
return
|
||||
if not base:
|
||||
@@ -201,7 +274,7 @@ class MainWindow(QMainWindow):
|
||||
logger.warning("服务端指派无效手术号: {!r}(须为 6 位数字)", sid)
|
||||
return
|
||||
self._assigned_surgery_id = sid
|
||||
self._surgery_id_display.setText(sid)
|
||||
self._refresh_window_title()
|
||||
self._apply_settings_silent()
|
||||
self._worker.set_monitoring(True)
|
||||
self._btn_stop.setEnabled(True)
|
||||
@@ -210,10 +283,10 @@ class MainWindow(QMainWindow):
|
||||
def _on_server_assign_end(self, sid: str) -> None:
|
||||
self._worker.set_monitoring(False)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(等待服务端开录指派)")
|
||||
self._refresh_window_title()
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._apply_settings_silent()
|
||||
self._status_label.setText("已停止(服务端结束)")
|
||||
self._on_worker_state("已停止(服务端结束)")
|
||||
logger.info("服务端已结束手术 {},已自动停止监控", sid)
|
||||
|
||||
def _show_pending(self, payload: object) -> None:
|
||||
@@ -262,20 +335,25 @@ class MainWindow(QMainWindow):
|
||||
def _stop_monitoring(self) -> None:
|
||||
self._worker.set_monitoring(False)
|
||||
self._assigned_surgery_id = ""
|
||||
self._surgery_id_display.setText("—(等待服务端开录指派)")
|
||||
self._refresh_window_title()
|
||||
self._btn_stop.setEnabled(False)
|
||||
self._apply_settings_silent()
|
||||
logger.info("—— 本地已停止监控;服务端结束手术或再次开录后将自动恢复指派 ——")
|
||||
self._status_label.setText("已停止(本地)")
|
||||
self._on_worker_state("已停止(本地)")
|
||||
|
||||
def _append_log_plain(self, line: str) -> None:
|
||||
"""由 loguru GUI sink 写入,已含时间与级别,不再加前缀。"""
|
||||
self._log.appendPlainText(line)
|
||||
sb = self._log.verticalScrollBar()
|
||||
w = getattr(self, "_log", None)
|
||||
if w is None:
|
||||
return
|
||||
w.appendPlainText(line)
|
||||
sb = w.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""停止后台线程;窗口关闭与 Ctrl+C(aboutToQuit)共用。"""
|
||||
self._rec_pulse_timer.stop()
|
||||
self._set_recording_banner_active(False)
|
||||
if self._assignment_listener:
|
||||
self._assignment_listener.stop()
|
||||
self._assignment_listener = None
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"voice_terminal_id": "desktop-1",
|
||||
"http_base_url": "http://127.0.0.1:38080"
|
||||
}
|
||||
Reference in New Issue
Block a user