audex 1.0.7a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. audex/__init__.py +9 -0
  2. audex/__main__.py +7 -0
  3. audex/cli/__init__.py +189 -0
  4. audex/cli/apis/__init__.py +12 -0
  5. audex/cli/apis/init/__init__.py +34 -0
  6. audex/cli/apis/init/gencfg.py +130 -0
  7. audex/cli/apis/init/setup.py +330 -0
  8. audex/cli/apis/init/vprgroup.py +125 -0
  9. audex/cli/apis/serve.py +141 -0
  10. audex/cli/args.py +356 -0
  11. audex/cli/exceptions.py +44 -0
  12. audex/cli/helper/__init__.py +0 -0
  13. audex/cli/helper/ansi.py +193 -0
  14. audex/cli/helper/display.py +288 -0
  15. audex/config/__init__.py +64 -0
  16. audex/config/core/__init__.py +30 -0
  17. audex/config/core/app.py +29 -0
  18. audex/config/core/audio.py +45 -0
  19. audex/config/core/logging.py +163 -0
  20. audex/config/core/session.py +11 -0
  21. audex/config/helper/__init__.py +1 -0
  22. audex/config/helper/client/__init__.py +1 -0
  23. audex/config/helper/client/http.py +28 -0
  24. audex/config/helper/client/websocket.py +21 -0
  25. audex/config/helper/provider/__init__.py +1 -0
  26. audex/config/helper/provider/dashscope.py +13 -0
  27. audex/config/helper/provider/unisound.py +18 -0
  28. audex/config/helper/provider/xfyun.py +23 -0
  29. audex/config/infrastructure/__init__.py +31 -0
  30. audex/config/infrastructure/cache.py +51 -0
  31. audex/config/infrastructure/database.py +48 -0
  32. audex/config/infrastructure/recorder.py +32 -0
  33. audex/config/infrastructure/store.py +19 -0
  34. audex/config/provider/__init__.py +18 -0
  35. audex/config/provider/transcription.py +109 -0
  36. audex/config/provider/vpr.py +99 -0
  37. audex/container.py +40 -0
  38. audex/entity/__init__.py +468 -0
  39. audex/entity/doctor.py +109 -0
  40. audex/entity/doctor.pyi +51 -0
  41. audex/entity/fields.py +401 -0
  42. audex/entity/segment.py +115 -0
  43. audex/entity/segment.pyi +38 -0
  44. audex/entity/session.py +133 -0
  45. audex/entity/session.pyi +47 -0
  46. audex/entity/utterance.py +142 -0
  47. audex/entity/utterance.pyi +48 -0
  48. audex/entity/vp.py +68 -0
  49. audex/entity/vp.pyi +35 -0
  50. audex/exceptions.py +157 -0
  51. audex/filters/__init__.py +692 -0
  52. audex/filters/generated/__init__.py +21 -0
  53. audex/filters/generated/doctor.py +987 -0
  54. audex/filters/generated/segment.py +723 -0
  55. audex/filters/generated/session.py +978 -0
  56. audex/filters/generated/utterance.py +939 -0
  57. audex/filters/generated/vp.py +815 -0
  58. audex/helper/__init__.py +1 -0
  59. audex/helper/hash.py +33 -0
  60. audex/helper/mixin.py +65 -0
  61. audex/helper/net.py +19 -0
  62. audex/helper/settings/__init__.py +830 -0
  63. audex/helper/settings/fields.py +317 -0
  64. audex/helper/stream.py +153 -0
  65. audex/injectors/__init__.py +1 -0
  66. audex/injectors/config.py +12 -0
  67. audex/injectors/lifespan.py +7 -0
  68. audex/lib/__init__.py +1 -0
  69. audex/lib/cache/__init__.py +383 -0
  70. audex/lib/cache/inmemory.py +513 -0
  71. audex/lib/database/__init__.py +83 -0
  72. audex/lib/database/sqlite.py +406 -0
  73. audex/lib/exporter.py +189 -0
  74. audex/lib/injectors/__init__.py +1 -0
  75. audex/lib/injectors/cache.py +25 -0
  76. audex/lib/injectors/container.py +47 -0
  77. audex/lib/injectors/exporter.py +26 -0
  78. audex/lib/injectors/recorder.py +33 -0
  79. audex/lib/injectors/server.py +17 -0
  80. audex/lib/injectors/session.py +18 -0
  81. audex/lib/injectors/sqlite.py +24 -0
  82. audex/lib/injectors/store.py +13 -0
  83. audex/lib/injectors/transcription.py +42 -0
  84. audex/lib/injectors/usb.py +12 -0
  85. audex/lib/injectors/vpr.py +65 -0
  86. audex/lib/injectors/wifi.py +7 -0
  87. audex/lib/recorder.py +844 -0
  88. audex/lib/repos/__init__.py +149 -0
  89. audex/lib/repos/container.py +23 -0
  90. audex/lib/repos/database/__init__.py +1 -0
  91. audex/lib/repos/database/sqlite.py +672 -0
  92. audex/lib/repos/decorators.py +74 -0
  93. audex/lib/repos/doctor.py +286 -0
  94. audex/lib/repos/segment.py +302 -0
  95. audex/lib/repos/session.py +285 -0
  96. audex/lib/repos/tables/__init__.py +70 -0
  97. audex/lib/repos/tables/doctor.py +137 -0
  98. audex/lib/repos/tables/segment.py +113 -0
  99. audex/lib/repos/tables/session.py +140 -0
  100. audex/lib/repos/tables/utterance.py +131 -0
  101. audex/lib/repos/tables/vp.py +102 -0
  102. audex/lib/repos/utterance.py +288 -0
  103. audex/lib/repos/vp.py +286 -0
  104. audex/lib/restful.py +251 -0
  105. audex/lib/server/__init__.py +97 -0
  106. audex/lib/server/auth.py +98 -0
  107. audex/lib/server/handlers.py +248 -0
  108. audex/lib/server/templates/index.html.j2 +226 -0
  109. audex/lib/server/templates/login.html.j2 +111 -0
  110. audex/lib/server/templates/static/script.js +68 -0
  111. audex/lib/server/templates/static/style.css +579 -0
  112. audex/lib/server/types.py +123 -0
  113. audex/lib/session.py +503 -0
  114. audex/lib/store/__init__.py +238 -0
  115. audex/lib/store/localfile.py +411 -0
  116. audex/lib/transcription/__init__.py +33 -0
  117. audex/lib/transcription/dashscope.py +525 -0
  118. audex/lib/transcription/events.py +62 -0
  119. audex/lib/usb.py +554 -0
  120. audex/lib/vpr/__init__.py +38 -0
  121. audex/lib/vpr/unisound/__init__.py +185 -0
  122. audex/lib/vpr/unisound/types.py +469 -0
  123. audex/lib/vpr/xfyun/__init__.py +483 -0
  124. audex/lib/vpr/xfyun/types.py +679 -0
  125. audex/lib/websocket/__init__.py +8 -0
  126. audex/lib/websocket/connection.py +485 -0
  127. audex/lib/websocket/pool.py +991 -0
  128. audex/lib/wifi.py +1146 -0
  129. audex/lifespan.py +75 -0
  130. audex/service/__init__.py +27 -0
  131. audex/service/decorators.py +73 -0
  132. audex/service/doctor/__init__.py +652 -0
  133. audex/service/doctor/const.py +36 -0
  134. audex/service/doctor/exceptions.py +96 -0
  135. audex/service/doctor/types.py +54 -0
  136. audex/service/export/__init__.py +236 -0
  137. audex/service/export/const.py +17 -0
  138. audex/service/export/exceptions.py +34 -0
  139. audex/service/export/types.py +21 -0
  140. audex/service/injectors/__init__.py +1 -0
  141. audex/service/injectors/container.py +53 -0
  142. audex/service/injectors/doctor.py +34 -0
  143. audex/service/injectors/export.py +27 -0
  144. audex/service/injectors/session.py +49 -0
  145. audex/service/session/__init__.py +754 -0
  146. audex/service/session/const.py +34 -0
  147. audex/service/session/exceptions.py +67 -0
  148. audex/service/session/types.py +91 -0
  149. audex/types.py +39 -0
  150. audex/utils.py +287 -0
  151. audex/valueobj/__init__.py +81 -0
  152. audex/valueobj/common/__init__.py +1 -0
  153. audex/valueobj/common/auth.py +84 -0
  154. audex/valueobj/common/email.py +16 -0
  155. audex/valueobj/common/ops.py +22 -0
  156. audex/valueobj/common/phone.py +84 -0
  157. audex/valueobj/common/version.py +72 -0
  158. audex/valueobj/session.py +19 -0
  159. audex/valueobj/utterance.py +15 -0
  160. audex/view/__init__.py +51 -0
  161. audex/view/container.py +17 -0
  162. audex/view/decorators.py +303 -0
  163. audex/view/pages/__init__.py +1 -0
  164. audex/view/pages/dashboard/__init__.py +286 -0
  165. audex/view/pages/dashboard/wifi.py +407 -0
  166. audex/view/pages/login.py +110 -0
  167. audex/view/pages/recording.py +348 -0
  168. audex/view/pages/register.py +202 -0
  169. audex/view/pages/sessions/__init__.py +196 -0
  170. audex/view/pages/sessions/details.py +224 -0
  171. audex/view/pages/sessions/export.py +443 -0
  172. audex/view/pages/settings.py +374 -0
  173. audex/view/pages/voiceprint/__init__.py +1 -0
  174. audex/view/pages/voiceprint/enroll.py +195 -0
  175. audex/view/pages/voiceprint/update.py +195 -0
  176. audex/view/static/css/dashboard.css +452 -0
  177. audex/view/static/css/glass.css +22 -0
  178. audex/view/static/css/global.css +541 -0
  179. audex/view/static/css/login.css +386 -0
  180. audex/view/static/css/recording.css +439 -0
  181. audex/view/static/css/register.css +293 -0
  182. audex/view/static/css/sessions/styles.css +501 -0
  183. audex/view/static/css/settings.css +186 -0
  184. audex/view/static/css/voiceprint/enroll.css +43 -0
  185. audex/view/static/css/voiceprint/styles.css +209 -0
  186. audex/view/static/css/voiceprint/update.css +44 -0
  187. audex/view/static/images/logo.svg +95 -0
  188. audex/view/static/js/recording.js +42 -0
  189. audex-1.0.7a3.dist-info/METADATA +361 -0
  190. audex-1.0.7a3.dist-info/RECORD +192 -0
  191. audex-1.0.7a3.dist-info/WHEEL +4 -0
  192. audex-1.0.7a3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ from dependency_injector.wiring import Provide
4
+ from dependency_injector.wiring import inject
5
+ from fastapi import Depends
6
+ from nicegui import ui
7
+
8
+ from audex.container import Container
9
+ from audex.service.doctor import DoctorService
10
+ from audex.service.session import SessionService
11
+ from audex.view.decorators import handle_errors
12
+
13
+
14
+ @ui.page("/sessions")
15
+ @handle_errors
16
+ @inject
17
+ async def render(
18
+ doctor_service: DoctorService = Depends(Provide[Container.service.doctor]),
19
+ session_service: SessionService = Depends(Provide[Container.service.session]),
20
+ ) -> None:
21
+ """Render sessions history page with clean design."""
22
+
23
+ # Get current doctor
24
+ doctor = await doctor_service.current_doctor()
25
+
26
+ # Add CSS
27
+ ui.add_head_html('<link rel="stylesheet" href="/static/css/sessions/styles.css">')
28
+
29
+ # Fetch sessions
30
+ sessions = await session_service.list(doctor_id=doctor.id, page_size=100)
31
+
32
+ # Dialog Functions
33
+ async def show_delete_dialog(session_id: str, session_name: str):
34
+ """Show delete confirmation dialog."""
35
+ with (
36
+ ui.dialog() as dialog,
37
+ ui.card().classes("dialog-card").style("width: 450px; padding: 28px;"),
38
+ ):
39
+ with ui.row().classes("w-full items-center mb-6"):
40
+ ui.icon("warning", size="xl").classes("text-warning")
41
+ ui.label("确认删除").classes("text-h5 font-bold text-grey-9 ml-3 flex-1")
42
+ ui.button(icon="close", on_click=dialog.close).props("flat round dense")
43
+
44
+ ui.label(f"确定要删除会话「{session_name}」吗?").classes("text-body1 text-grey-8 mb-2")
45
+ ui.label("此操作不可恢复。").classes("text-body2 text-grey-7 mb-6")
46
+
47
+ with ui.row().classes("w-full gap-3 justify-end"):
48
+ ui.button("取消", on_click=dialog.close).props(
49
+ "outline color=grey-8 no-caps"
50
+ ).classes("action-btn btn-secondary")
51
+
52
+ async def do_delete():
53
+ dialog.close()
54
+ try:
55
+ await session_service.delete(session_id)
56
+ ui.notify("会话已删除", type="positive", position="top")
57
+ ui.navigate.to("/sessions")
58
+ except Exception:
59
+ ui.notify("删除失败", type="negative", position="top")
60
+
61
+ ui.button("删除", on_click=do_delete).props(
62
+ "unelevated color=negative no-caps"
63
+ ).classes("action-btn")
64
+
65
+ dialog.open()
66
+
67
+ # Header
68
+ with ui.header().classes("header-glass items-center justify-between px-6 py-4"):
69
+ with ui.row().classes("items-center gap-3"):
70
+ ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/")).props("flat round")
71
+ ui.label("历史会话").classes("text-h6 font-semibold text-grey-9")
72
+
73
+ with ui.row().classes("items-center gap-2"):
74
+ ui.button(
75
+ "导出", icon="download", on_click=lambda: ui.navigate.to("/sessions/export")
76
+ ).props("flat no-caps").classes("header-btn export-btn")
77
+
78
+ ui.button("新建录音", on_click=lambda: ui.navigate.to("/recording")).props(
79
+ "unelevated color=primary no-caps"
80
+ ).classes("header-btn")
81
+
82
+ # Main Content
83
+ if not sessions:
84
+ with (
85
+ ui.element("div")
86
+ .classes("w-full bg-white")
87
+ .style(
88
+ "display: flex; align-items: center; justify-content: center; "
89
+ "min-height: calc(100vh - 64px);"
90
+ ),
91
+ ui.element("div").classes("empty-state"),
92
+ ):
93
+ ui.icon("chat_bubble_outline", size="4em").classes("text-grey-4 mb-4")
94
+ ui.label("还没有会话记录").classes("text-h5 font-semibold text-grey-7 mb-2")
95
+ ui.label("开始您的第一次录音会话").classes("text-body2 text-grey-6 mb-6")
96
+ ui.button("创建新会话", on_click=lambda: ui.navigate.to("/recording")).props(
97
+ "color=primary size=lg no-caps"
98
+ )
99
+
100
+ else:
101
+ with (
102
+ ui.scroll_area().classes("w-full").style("height: calc(100vh - 64px);"),
103
+ (
104
+ ui.element("div")
105
+ .classes("w-full bg-white")
106
+ .style(
107
+ "display: flex; justify-content: center; align-items: flex-start; "
108
+ "min-height: 100%; padding: 60px 80px;"
109
+ )
110
+ ),
111
+ ui.element("div").style(
112
+ "display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; "
113
+ "max-width: 850px; width: 100%;"
114
+ ),
115
+ ):
116
+ for session in sessions:
117
+ with ui.card().classes("super-card cursor-pointer p-7"):
118
+ with ui.row().classes("items-start justify-between w-full mb-2"):
119
+ ui.label(session.patient_name or "未知患者").classes(
120
+ "text-h6 font-bold text-grey-9"
121
+ )
122
+
123
+ if session.status.value != "DRAFT":
124
+ status_map = {
125
+ "COMPLETED": ("已完成", "status-completed"),
126
+ "IN_PROGRESS": ("进行中", "status-in-progress"),
127
+ "CANCELLED": ("已取消", "status-cancelled"),
128
+ }
129
+ if session.status.value in status_map:
130
+ status_text, status_class = status_map[session.status.value]
131
+ ui.html(
132
+ f'<div class="status-badge {status_class}">{status_text}</div>',
133
+ sanitize=False,
134
+ )
135
+
136
+ with ui.column().classes("gap-2 mb-auto"):
137
+ if session.clinic_number:
138
+ ui.label(f"门诊号: {session.clinic_number}").classes(
139
+ "text-sm text-grey-7"
140
+ )
141
+ if session.medical_record_number:
142
+ ui.label(f"病历号: {session.medical_record_number}").classes(
143
+ "text-sm text-grey-7"
144
+ )
145
+ if session.diagnosis:
146
+ ui.label(f"诊断: {session.diagnosis}").classes("text-sm text-grey-7")
147
+
148
+ time_text = session.created_at.strftime("%m月%d日 %H:%M")
149
+ if session.started_at:
150
+ time_text = session.started_at.strftime("%m月%d日 %H:%M")
151
+ ui.label(time_text).classes("text-sm text-grey-6")
152
+
153
+ with ui.element("div").classes("button-layout"):
154
+
155
+ def create_delete_handler(sid, sname):
156
+ async def handler():
157
+ await show_delete_dialog(sid, sname)
158
+
159
+ return handler
160
+
161
+ ui.button(
162
+ icon="delete_outline",
163
+ on_click=create_delete_handler(
164
+ session.id, session.patient_name or "未知患者"
165
+ ),
166
+ ).props("flat").classes("btn-delete")
167
+
168
+ with ui.element("div").classes("right-buttons"):
169
+
170
+ def create_view_handler(sid):
171
+ def handler():
172
+ ui.navigate.to(f"/sessions/details?session_id={sid}")
173
+
174
+ return handler
175
+
176
+ ui.button(
177
+ "查看",
178
+ icon="visibility",
179
+ on_click=create_view_handler(session.id),
180
+ ).props("outline color=grey-8 no-caps").classes(
181
+ "action-btn btn-secondary"
182
+ )
183
+
184
+ def create_continue_handler(sid):
185
+ def handler():
186
+ ui.navigate.to(f"/recording?session_id={sid}")
187
+
188
+ return handler
189
+
190
+ ui.button(
191
+ "继续",
192
+ icon="play_arrow",
193
+ on_click=create_continue_handler(session.id),
194
+ ).props("unelevated color=primary no-caps").classes(
195
+ "action-btn btn-primary"
196
+ )
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ from dependency_injector.wiring import Provide
4
+ from dependency_injector.wiring import inject
5
+ from fastapi import Depends
6
+ from fastapi import Query
7
+ from nicegui import ui
8
+
9
+ from audex.container import Container
10
+ from audex.service.session import SessionService
11
+ from audex.service.session.types import UpdateSessionCommand
12
+ from audex.view.decorators import handle_errors
13
+
14
+
15
+ @ui.page("/sessions/details")
16
+ @handle_errors
17
+ @inject
18
+ async def render(
19
+ session_service: SessionService = Depends(Provide[Container.service.session]),
20
+ session_id: str = Query(...),
21
+ ) -> None:
22
+ """Render session detail page with left form and right
23
+ conversation."""
24
+ # Add CSS
25
+ ui.add_head_html('<link rel="stylesheet" href="/static/css/sessions/styles.css">')
26
+
27
+ # Fetch session and utterances
28
+ session = await session_service.get(session_id)
29
+ if not session:
30
+ ui.notify("会话不存在", type="negative", position="top")
31
+ ui.navigate.to("/sessions")
32
+ return
33
+
34
+ utterances = await session_service.get_utterances(session_id)
35
+
36
+ # State
37
+ is_editing = {"value": False}
38
+
39
+ # Header
40
+ with ui.header().classes("header-glass items-center justify-between px-6 py-3"):
41
+ with ui.row().classes("items-center gap-3"):
42
+ ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/sessions")).props(
43
+ "flat round"
44
+ ).tooltip("返回历史会话")
45
+ ui.label(f"{session.patient_name or '未知患者'}").classes(
46
+ "text-h6 font-semibold text-grey-9"
47
+ )
48
+
49
+ ui.button(
50
+ "继续录音",
51
+ icon="mic",
52
+ on_click=lambda: ui.navigate.to(f"/recording?session_id={session_id}"),
53
+ ).props("unelevated color=primary no-caps").classes("header-btn")
54
+
55
+ # Main container
56
+ with (
57
+ ui.element("div")
58
+ .classes("w-full bg-white")
59
+ .style("display: flex; padding: 40px 80px; gap: 40px;")
60
+ ):
61
+ # Left sidebar: Details
62
+ with ui.column().classes("gap-2").style("width: 450px; flex-shrink: 0;"):
63
+ # Header with edit button
64
+ with ui.row().classes("w-full items-center justify-between mb-6"):
65
+ ui.label("会话信息").classes("text-h4 font-bold text-grey-9")
66
+
67
+ def toggle_edit():
68
+ """Toggle edit mode."""
69
+ is_editing["value"] = not is_editing["value"]
70
+
71
+ if is_editing["value"]:
72
+ edit_btn.props("icon=close")
73
+ edit_btn.text = "取消"
74
+ info_display.visible = False
75
+ edit_form.visible = True
76
+ else:
77
+ edit_btn.props("icon=edit")
78
+ edit_btn.text = "编辑"
79
+ info_display.visible = True
80
+ edit_form.visible = False
81
+
82
+ edit_btn = (
83
+ ui.button("编辑", icon="edit", on_click=toggle_edit)
84
+ .props("flat no-caps")
85
+ .classes("action-button")
86
+ )
87
+
88
+ # Info display (read-only)
89
+ info_display = ui.column().classes("w-full gap-0")
90
+ with info_display:
91
+ with ui.element("div").classes("info-field"):
92
+ ui.label("患者姓名").classes("text-xs text-grey-6 mb-1")
93
+ ui.label(session.patient_name or "未填写").classes(
94
+ "text-body1 text-grey-9 font-medium"
95
+ )
96
+
97
+ with ui.element("div").classes("info-field"):
98
+ ui.label("门诊号").classes("text-xs text-grey-6 mb-1")
99
+ ui.label(session.clinic_number or "未填写").classes(
100
+ "text-body1 text-grey-9 font-medium"
101
+ )
102
+
103
+ with ui.element("div").classes("info-field"):
104
+ ui.label("病历号").classes("text-xs text-grey-6 mb-1")
105
+ ui.label(session.medical_record_number or "未填写").classes(
106
+ "text-body1 text-grey-9 font-medium"
107
+ )
108
+
109
+ with ui.element("div").classes("info-field"):
110
+ ui.label("诊断").classes("text-xs text-grey-6 mb-1")
111
+ ui.label(session.diagnosis or "未填写").classes(
112
+ "text-body1 text-grey-9 font-medium"
113
+ )
114
+
115
+ if session.notes:
116
+ with ui.element("div").classes("info-field"):
117
+ ui.label("备注").classes("text-xs text-grey-6 mb-1")
118
+ ui.label(session.notes).classes("text-body1 text-grey-9 font-medium")
119
+
120
+ # Edit form - 复用 recording 的模态框样式
121
+ edit_form = ui.column().classes("w-full gap-4")
122
+ edit_form.visible = False
123
+ with edit_form:
124
+ with ui.row().classes("w-full gap-4"):
125
+ patient_name_input = (
126
+ ui.input("", placeholder="患者姓名")
127
+ .classes("flex-1 clean-input")
128
+ .props("standout dense hide-bottom-space")
129
+ )
130
+ patient_name_input.value = session.patient_name or ""
131
+
132
+ clinic_number_input = (
133
+ ui.input("", placeholder="门诊号")
134
+ .classes("flex-1 clean-input")
135
+ .props("standout dense hide-bottom-space")
136
+ )
137
+ clinic_number_input.value = session.clinic_number or ""
138
+
139
+ with ui.row().classes("w-full gap-4 mt-3"):
140
+ medical_record_number_input = (
141
+ ui.input("", placeholder="病历号")
142
+ .classes("flex-1 clean-input")
143
+ .props("standout dense hide-bottom-space")
144
+ )
145
+ medical_record_number_input.value = session.medical_record_number or ""
146
+
147
+ diagnosis_input = (
148
+ ui.input("", placeholder="诊断")
149
+ .classes("flex-1 clean-input")
150
+ .props("standout dense hide-bottom-space")
151
+ )
152
+ diagnosis_input.value = session.diagnosis or ""
153
+
154
+ notes_input = (
155
+ ui.textarea("", placeholder="备注")
156
+ .classes("w-full mt-3 clean-input notes-textarea")
157
+ .props("standout hide-bottom-space")
158
+ )
159
+ notes_input.value = session.notes or ""
160
+
161
+ @handle_errors
162
+ async def save_session():
163
+ """Save session changes."""
164
+ await session_service.update(
165
+ UpdateSessionCommand(
166
+ session_id=session_id,
167
+ patient_name=patient_name_input.value.strip() or None,
168
+ clinic_number=clinic_number_input.value.strip() or None,
169
+ medical_record_number=medical_record_number_input.value.strip() or None,
170
+ diagnosis=diagnosis_input.value.strip() or None,
171
+ notes=notes_input.value.strip() or None,
172
+ )
173
+ )
174
+
175
+ ui.notify("会话信息已更新", type="positive", position="top")
176
+ ui.navigate.to(f"/sessions/details?session_id={session_id}")
177
+
178
+ ui.button("保存更改", on_click=save_session).props(
179
+ "unelevated color=primary size=lg no-caps"
180
+ ).classes("action-button").style("height: 48px;")
181
+
182
+ # Right content area: Utterances (scrollable)
183
+ content_scroll = (
184
+ ui.scroll_area()
185
+ .classes("flex-1")
186
+ .style("height: calc(100vh - 190px); padding: 0 20px;")
187
+ )
188
+ with content_scroll:
189
+ if not utterances:
190
+ with (
191
+ ui.element("div")
192
+ .classes("empty-content")
193
+ .style(
194
+ "height: 100%; display: flex; align-items: center; justify-content: center;"
195
+ ),
196
+ ui.column().classes("items-center"),
197
+ ):
198
+ ui.icon("chat_bubble_outline", size="3xl").classes("text-grey-4 mb-4")
199
+ ui.label("暂无对话记录").classes("text-lg text-grey-6")
200
+ else:
201
+ with ui.element("div").classes("utterances-container"):
202
+ for utterance in utterances:
203
+ speaker_name = "医生" if utterance.is_doctor else "患者"
204
+ time_str = utterance.timestamp.strftime("%H:%M:%S")
205
+ duration = f"{utterance.duration_ms / 1000:.1f}s"
206
+
207
+ if utterance.is_doctor:
208
+ with ui.element("div").classes("utterance-final is-doctor"):
209
+ ui.label(f"{speaker_name} • {time_str} • {duration}").classes(
210
+ "speaker-label"
211
+ )
212
+ ui.html(
213
+ f'<div class="bubble-doctor">{utterance.text}</div>',
214
+ sanitize=False,
215
+ )
216
+ else:
217
+ with ui.element("div").classes("utterance-final is-patient"):
218
+ ui.label(f"{speaker_name} • {time_str} • {duration}").classes(
219
+ "speaker-label"
220
+ )
221
+ ui.html(
222
+ f'<div class="bubble-patient">{utterance.text}</div>',
223
+ sanitize=False,
224
+ )