echoact 0.1.0__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 (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1142 @@
1
+ """The model management screen: F-63, F-64, F-65, F-09, F-84, and the
2
+ review half of F-80's licence surface.
3
+
4
+ One card per manifest entry, and never fewer than the manifest has. F-04
5
+ forbids hiding a model that cannot run under the current budget and forbids
6
+ substituting another for it, so a card that cannot run is drawn exactly like
7
+ one that can, with :meth:`~echoact.models.registry.ModelRegistry.can_run`'s
8
+ reason printed under the name. The list is therefore built from the
9
+ manifest, not from what happens to be on disk or to fit.
10
+
11
+ Two things this screen does not do. It does not decide anything the job
12
+ engine owns: F-65 requires a model in use to be released by cancelling the
13
+ job first, and the registry cannot see jobs, so the card asks for a release
14
+ through :attr:`ModelCard.release_requested` and the window answers. And it
15
+ does not present the licence for acceptance twice: :mod:`echoact.ui.licence`
16
+ owns the N-11 acceptance dialog and is used unchanged; what this screen adds
17
+ is a *review* pane, because F-80 wants the restrictions readable afterwards
18
+ and not only once, at the moment they are agreed to.
19
+
20
+ Everything the registry does that touches bytes runs in
21
+ :class:`RegistryTask`, a ``QThread``. A deep verification pass hashes 385 MB
22
+ and a download moves it; either one on the GUI thread would freeze the
23
+ window for minutes, which rule 7 forbids outright. Progress comes back as a
24
+ queued signal and is throttled at the source, because a per-chunk emit from
25
+ a fast local mirror can post events faster than the main thread drains them.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import threading
31
+ import time
32
+ from enum import StrEnum
33
+
34
+ from PySide6.QtCore import QObject, Qt, QThread, Signal
35
+ from PySide6.QtWidgets import (
36
+ QCheckBox,
37
+ QDialog,
38
+ QFrame,
39
+ QGridLayout,
40
+ QHBoxLayout,
41
+ QLabel,
42
+ QMessageBox,
43
+ QProgressBar,
44
+ QPushButton,
45
+ QScrollArea,
46
+ QSizePolicy,
47
+ QVBoxLayout,
48
+ QWidget,
49
+ )
50
+
51
+ from ..domain import Budget, Gender, RequestPath, SpeakingStyle
52
+ from ..errors import Code, EchoActError
53
+ from ..models.manifest import ModelEntry
54
+ from ..models.registry import (
55
+ CancelToken,
56
+ DownloadOutcome,
57
+ DownloadPhase,
58
+ DownloadProgress,
59
+ ModelRegistry,
60
+ ModelState,
61
+ ModelStatus,
62
+ VerifyReport,
63
+ )
64
+ from ..policy import TEMPO_MAX, TEMPO_MIN, WORKER_RELEASE_DEADLINE_S
65
+ from ..util.logging import get_logger
66
+ from . import icons
67
+ from .controls import label, separator
68
+ from .i18n import add_korean, bytes_size, count, memory_size, tr
69
+ from .licence import LicenceDialog
70
+ from .theme import METRICS, Palette
71
+
72
+ log = get_logger("ui.models")
73
+
74
+ #: Smallest gap between two progress repaints, in seconds. Like
75
+ #: ``main_window.TICK_MS`` this is a repaint cadence rather than a limit, so
76
+ #: it lives with the widget that paints: the registry reports every chunk,
77
+ #: and a 385 MB transfer from a fast mirror would otherwise queue tens of
78
+ #: thousands of events onto the main thread to redraw a bar 400 pixels wide.
79
+ PROGRESS_INTERVAL_S = 0.04
80
+
81
+ #: How long the screen waits for a worker to notice its cancel token when
82
+ #: the window is closing. N-22's five seconds, which is what the registry's
83
+ #: between-chunk cancellation checks are written against.
84
+ SHUTDOWN_WAIT_MS = int(WORKER_RELEASE_DEADLINE_S * 1000)
85
+
86
+ add_korean(
87
+ {
88
+ "Model cache uses {size}": "모델 캐시 사용량 {size}",
89
+ "Not downloaded": "다운로드되지 않음",
90
+ "Partially downloaded": "일부만 다운로드됨",
91
+ "Damaged": "손상됨",
92
+ "Version": "버전",
93
+ "Source": "출처",
94
+ "Storage": "저장 공간",
95
+ "Languages": "언어",
96
+ "Audio": "오디오",
97
+ "Licence": "라이선스",
98
+ "Accepted": "동의함",
99
+ "Not accepted yet": "아직 동의하지 않음",
100
+ "{used} of {total}": "{total} 중 {used}",
101
+ "{rate} kHz mono": "{rate} kHz 모노",
102
+ "Runs within the current budget": "현재 자원 한도에서 실행할 수 있습니다",
103
+ "Unavailable: {reason}": "사용할 수 없음: {reason}",
104
+ "Requires {size} of storage.": "저장 공간 {size}이(가) 필요합니다.",
105
+ "{size} of it is still to download.": "그중 {size}을(를) 더 내려받아야 합니다.",
106
+ "Needs at least {memory} of memory and {cpu}% CPU; the current budget is "
107
+ "{have_memory} and {have_cpu}%.": "최소 메모리 {memory}, CPU {cpu}%가 필요합니다. "
108
+ "현재 한도는 {have_memory}, {have_cpu}%입니다.",
109
+ "These files are read from the supertonic package's cache. EchoAct never writes "
110
+ "there, and Delete does not remove them.": "이 파일은 supertonic 패키지의 캐시에서 "
111
+ "읽습니다. 에코액트는 그곳에 쓰지 않으며, 삭제해도 그 파일은 지우지 않습니다.",
112
+ "Download": "다운로드",
113
+ "Resume download": "다운로드 이어받기",
114
+ "Retry download": "다운로드 다시 시도",
115
+ "Check files": "파일 검사",
116
+ "Repair": "복구",
117
+ "Checking files": "파일 검사 중",
118
+ "Checking {file}": "{file} 검사 중",
119
+ "Downloading {file}": "{file} 내려받는 중",
120
+ "{done} of {total}": "{total} 중 {done}",
121
+ "All {n} files match the manifest.": "{n}개 파일이 모두 매니페스트와 일치합니다.",
122
+ "{n} files do not match the manifest.": "{n}개 파일이 매니페스트와 일치하지 않습니다.",
123
+ "{n} files are missing.": "{n}개 파일이 없습니다.",
124
+ "The check was stopped. Nothing was changed.": "검사를 중지했습니다. 변경된 것은 "
125
+ "없습니다.",
126
+ "The download was canceled. The model is not ready.": "다운로드를 취소했습니다. "
127
+ "모델은 준비되지 않았습니다.",
128
+ "Downloaded {size}. {n} files already on disk were reused.": "{size}을(를) "
129
+ "내려받았습니다. 이미 있던 파일 {n}개는 다시 사용했습니다.",
130
+ "The model is ready.": "모델을 사용할 준비가 되었습니다.",
131
+ "Nothing needed downloading.": "내려받을 것이 없습니다.",
132
+ "Freed {size}. Documents and audio results were kept.": "{size}을(를) 확보했습니다. "
133
+ "문서와 오디오 결과는 그대로 두었습니다.",
134
+ "Delete this model?": "이 모델을 삭제할까요?",
135
+ "Only EchoAct's own copy is removed. Documents and audio results are kept, and no "
136
+ "other program's cache is touched.": "에코액트가 가진 사본만 지웁니다. 문서와 오디오 "
137
+ "결과는 유지되며, 다른 프로그램의 캐시는 건드리지 않습니다.",
138
+ "A job is using this model.": "실행 중인 작업이 이 모델을 사용하고 있습니다.",
139
+ "Deleting it cancels that job first and releases the model. Documents and audio "
140
+ "results are kept.": "삭제하려면 그 작업을 먼저 취소하고 모델을 해제합니다. 문서와 "
141
+ "오디오 결과는 유지됩니다.",
142
+ "Cancel the job and delete": "작업을 취소하고 삭제",
143
+ "Keep": "유지",
144
+ "The licence was not accepted, so nothing was downloaded.": "라이선스에 동의하지 않아 "
145
+ "아무것도 내려받지 않았습니다.",
146
+ "Voices and styles": "음성과 말하기 스타일",
147
+ "Licence and restrictions": "라이선스와 제한 사항",
148
+ "{n} voices": "음성 {n}개",
149
+ "Speaking styles": "말하기 스타일",
150
+ "Tempo range": "속도 범위",
151
+ "Why these restrictions apply to you": "이 제한 사항이 적용되는 이유",
152
+ "Allow apps to request this download": "앱이 이 다운로드를 요청하도록 허용",
153
+ "Without this, a REST or MCP request cannot start a download.": "이 설정이 없으면 "
154
+ "REST나 MCP 요청으로는 다운로드를 시작할 수 없습니다.",
155
+ }
156
+ )
157
+
158
+ #: state -> (word, icon, palette role). N-30: never the colour alone.
159
+ _STATE_TEXT: dict[ModelState, tuple[str, str, str]] = {
160
+ ModelState.READY: ("Ready", "check-circle", "ok"),
161
+ ModelState.PARTIAL: ("Partially downloaded", "warning", "warn"),
162
+ ModelState.CORRUPT: ("Damaged", "error", "danger"),
163
+ ModelState.NOT_PRESENT: ("Not downloaded", "download", "muted"),
164
+ }
165
+
166
+ _LANGUAGE_NAMES = {"ko": "Korean", "en": "English"}
167
+
168
+ _STYLE_NAMES = {
169
+ SpeakingStyle.NATURAL: "Natural",
170
+ SpeakingStyle.CALM: "Calm",
171
+ SpeakingStyle.BRIGHT: "Bright",
172
+ SpeakingStyle.NARRATION: "Narration",
173
+ }
174
+
175
+
176
+ class TaskKind(StrEnum):
177
+ """Which registry operation a :class:`RegistryTask` is running."""
178
+
179
+ VERIFY = "verify"
180
+ DOWNLOAD = "download"
181
+ REPAIR = "repair"
182
+
183
+
184
+ def _restyle(widget: QWidget, role: str | None) -> None:
185
+ """Change a widget's ``role`` property after it has been polished.
186
+
187
+ Qt resolves a stylesheet selector against a dynamic property once, when
188
+ the widget is polished; setting the property later changes nothing on
189
+ screen until the style is asked again. Every status colour here is a
190
+ role, and every one of them changes at runtime.
191
+ """
192
+ widget.setProperty("role", role)
193
+ style = widget.style()
194
+ style.unpolish(widget)
195
+ style.polish(widget)
196
+
197
+
198
+ def _short(revision: str) -> str:
199
+ return revision[:12] if len(revision) > 12 else revision
200
+
201
+
202
+ class RegistryTask(QThread):
203
+ """One registry operation, off the GUI thread (rule 7, F-64, F-65).
204
+
205
+ A ``QThread`` rather than a pool: there is at most one operation per
206
+ model, it owns a :class:`~echoact.models.registry.CancelToken` that has
207
+ to outlive the call, and the window has to be able to wait for it on the
208
+ way out. Signals emitted from :meth:`run` cross to the main thread as
209
+ queued connections because the object was created there.
210
+ """
211
+
212
+ progressed = Signal(object) # DownloadProgress
213
+ verified = Signal(object) # VerifyReport
214
+ prepared = Signal(object) # DownloadOutcome
215
+ failed = Signal(object) # EchoActError
216
+
217
+ def __init__(
218
+ self,
219
+ registry: ModelRegistry,
220
+ model_id: str,
221
+ kind: TaskKind,
222
+ parent: QObject | None = None,
223
+ ) -> None:
224
+ super().__init__(parent)
225
+ self.registry = registry
226
+ self.model_id = model_id
227
+ self.kind = kind
228
+ self.token = CancelToken()
229
+ #: The thread the work actually ran on. Recorded because "not on
230
+ #: the GUI thread" is a requirement rather than an implementation
231
+ #: detail, and this is the only way to state it in a test.
232
+ self.worker_ident: int | None = None
233
+ self._last_emit = 0.0
234
+ self._last_path: str | None = None
235
+
236
+ def stop(self) -> None:
237
+ """Ask the work to stop between chunks (F-64, N-22)."""
238
+ self.token.cancel()
239
+
240
+ def run(self) -> None:
241
+ self.worker_ident = threading.get_ident()
242
+ try:
243
+ if self.kind is TaskKind.VERIFY:
244
+ self.verified.emit(
245
+ self.registry.verify(
246
+ self.model_id, deep=True, cancel=self.token, progress=self._tick
247
+ )
248
+ )
249
+ elif self.kind is TaskKind.REPAIR:
250
+ self.prepared.emit(
251
+ self.registry.repair(
252
+ self.model_id, self._tick, self.token, request_path=RequestPath.GUI
253
+ )
254
+ )
255
+ else:
256
+ self.prepared.emit(
257
+ self.registry.download(
258
+ self.model_id, self._tick, self.token, request_path=RequestPath.GUI
259
+ )
260
+ )
261
+ except EchoActError as exc:
262
+ self.failed.emit(exc)
263
+ except Exception as exc:
264
+ # A worker thread that dies with a traceback leaves the screen
265
+ # spinning for ever, so anything unexpected becomes the one
266
+ # exception type the app knows how to show. Only the kind, the
267
+ # model and the exception class are logged: N-20 allows a code
268
+ # and an identifier, not free text.
269
+ log.warning("%s failed for %s: %s", self.kind.value, self.model_id, type(exc).__name__)
270
+ self.failed.emit(EchoActError(Code.INTERNAL, cause=exc))
271
+
272
+ def _tick(self, progress: DownloadProgress) -> None:
273
+ """Throttle progress at the source, on the worker thread.
274
+
275
+ Dropping a tick costs nothing -- each one carries absolute totals,
276
+ not a delta -- while posting every one of them would fill the main
277
+ thread's queue with repaints of the same bar. A change of file or a
278
+ terminal phase always goes through, so the text never lags behind
279
+ the work by a whole file.
280
+ """
281
+ now = time.monotonic()
282
+ terminal = progress.phase in (DownloadPhase.COMPLETE, DownloadPhase.CANCELLED)
283
+ if (
284
+ terminal
285
+ or progress.relative_path != self._last_path
286
+ or now - self._last_emit >= PROGRESS_INTERVAL_S
287
+ ):
288
+ self._last_emit = now
289
+ self._last_path = progress.relative_path
290
+ self.progressed.emit(progress)
291
+
292
+
293
+ class ModelCard(QFrame):
294
+ """One model, as F-63 lists it and F-64 and F-65 act on it."""
295
+
296
+ licence_accepted = Signal(str)
297
+ prepared = Signal(str)
298
+ deleted = Signal(str)
299
+ #: F-65: the model is held by a running job and the owner confirmed
300
+ #: cancelling it. The registry cannot see jobs, so the window does this.
301
+ release_requested = Signal(str)
302
+ problem = Signal(object) # EchoActError
303
+ busy_changed = Signal(bool)
304
+
305
+ def __init__(
306
+ self,
307
+ palette: Palette,
308
+ registry: ModelRegistry,
309
+ entry: ModelEntry,
310
+ budget: Budget,
311
+ parent: QWidget | None = None,
312
+ ) -> None:
313
+ super().__init__(parent)
314
+ self.setObjectName("Panel")
315
+ self._palette = palette
316
+ self._registry = registry
317
+ self._entry = entry
318
+ self._budget = budget
319
+ self._status: ModelStatus | None = None
320
+ self._task: RegistryTask | None = None
321
+ self._in_use = False
322
+ self._failed = False
323
+ #: Evidence from the last operation that established something --
324
+ #: a completed deep pass, an interrupted download, a stopped check
325
+ #: that found damage -- which outranks the cheap presence-and-size
326
+ #: pass a redraw would use. Never a verdict an attempt merely
327
+ #: failed to reach: see :meth:`_on_verified`.
328
+ self._pinned_state: ModelState | None = None
329
+
330
+ self._build()
331
+ self.reload()
332
+
333
+ # ------------------------------------------------------------------
334
+ # Construction
335
+ # ------------------------------------------------------------------
336
+
337
+ def _build(self) -> None:
338
+ m = METRICS
339
+ p = self._palette
340
+ box = QVBoxLayout(self)
341
+ box.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
342
+ box.setSpacing(m.gap)
343
+
344
+ head = QHBoxLayout()
345
+ head.setSpacing(m.gap)
346
+ head.addWidget(label(self._entry.display_name, "title"))
347
+ self.state_icon = QLabel()
348
+ self.state_text = label("", "muted")
349
+ head.addWidget(self.state_icon)
350
+ head.addWidget(self.state_text)
351
+ head.addStretch(1)
352
+ box.addLayout(head)
353
+
354
+ # F-04: whether the model can run stands on its own line, in the same
355
+ # place whether the answer is yes or no, so no layout accident can
356
+ # bury the reason.
357
+ avail = QHBoxLayout()
358
+ avail.setSpacing(m.gap)
359
+ self.avail_icon = QLabel()
360
+ self.avail_text = label("", "secondary")
361
+ self.avail_text.setWordWrap(True)
362
+ avail.addWidget(self.avail_icon, 0, Qt.AlignmentFlag.AlignTop)
363
+ avail.addWidget(self.avail_text, 1)
364
+ box.addLayout(avail)
365
+
366
+ grid = QGridLayout()
367
+ grid.setHorizontalSpacing(m.pad)
368
+ grid.setVerticalSpacing(m.gap_tight)
369
+ self._values: dict[str, QLabel] = {}
370
+ rows = (
371
+ ("version", tr("Version")),
372
+ ("source", tr("Source")),
373
+ ("storage", tr("Storage")),
374
+ ("languages", tr("Languages")),
375
+ ("audio", tr("Audio")),
376
+ ("licence", tr("Licence")),
377
+ )
378
+ for row, (key, name) in enumerate(rows):
379
+ grid.addWidget(label(name, "muted"), row, 0, Qt.AlignmentFlag.AlignTop)
380
+ value = label("", "secondary")
381
+ value.setWordWrap(True)
382
+ value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
383
+ value.setAccessibleName(name)
384
+ grid.addWidget(value, row, 1)
385
+ self._values[key] = value
386
+ grid.setColumnStretch(1, 1)
387
+ box.addLayout(grid)
388
+
389
+ # F-63's "before downloading, report the required storage and the
390
+ # execution constraints under the current resource budget".
391
+ self.requirements = label("", "muted")
392
+ self.requirements.setWordWrap(True)
393
+ box.addWidget(self.requirements)
394
+
395
+ self.package_note = label(
396
+ tr(
397
+ "These files are read from the supertonic package's cache. EchoAct never "
398
+ "writes there, and Delete does not remove them."
399
+ ),
400
+ "muted",
401
+ )
402
+ self.package_note.setWordWrap(True)
403
+ self.package_note.hide()
404
+ box.addWidget(self.package_note)
405
+
406
+ self.progress_row = QWidget()
407
+ prow = QVBoxLayout(self.progress_row)
408
+ prow.setContentsMargins(0, 0, 0, 0)
409
+ prow.setSpacing(m.gap_tight)
410
+ self.progress_text = label("", "secondary")
411
+ self.progress_text.setWordWrap(True)
412
+ self.progress = QProgressBar()
413
+ self.progress.setRange(0, 100)
414
+ self.progress.setTextVisible(False)
415
+ self.progress.setAccessibleName(tr("Download"))
416
+ prow.addWidget(self.progress_text)
417
+ prow.addWidget(self.progress)
418
+ self.progress_row.hide()
419
+ box.addWidget(self.progress_row)
420
+
421
+ self.result = label("", "muted")
422
+ self.result.setWordWrap(True)
423
+ box.addWidget(self.result)
424
+
425
+ actions = QHBoxLayout()
426
+ actions.setSpacing(m.gap)
427
+ self.download_button = QPushButton(tr("Download"))
428
+ self.download_button.setProperty("variant", "primary")
429
+ self.download_button.setIcon(icons.icon("download", p.text_on_accent))
430
+ self.verify_button = QPushButton(tr("Check files"))
431
+ self.verify_button.setIcon(icons.icon("check-circle", p.text_secondary, p.text_muted))
432
+ self.repair_button = QPushButton(tr("Repair"))
433
+ self.repair_button.setIcon(icons.icon("refresh", p.text_secondary, p.text_muted))
434
+ self.delete_button = QPushButton(tr("Delete"))
435
+ self.delete_button.setProperty("variant", "danger")
436
+ self.delete_button.setIcon(icons.icon("trash", p.danger, p.text_muted))
437
+ self.cancel_button = QPushButton(tr("Cancel"))
438
+ self.cancel_button.setProperty("variant", "quiet")
439
+ self.cancel_button.setIcon(icons.icon("close", p.text_secondary, p.text_muted))
440
+ for button in (
441
+ self.download_button,
442
+ self.verify_button,
443
+ self.repair_button,
444
+ self.delete_button,
445
+ self.cancel_button,
446
+ ):
447
+ button.setIconSize(icons.icon_size(16))
448
+ button.setAccessibleName(button.text())
449
+ actions.addWidget(button)
450
+ actions.addStretch(1)
451
+ box.addLayout(actions)
452
+
453
+ # Section 5.3: an integration may only start a download for a model
454
+ # the owner authorised in the GUI first, so the switch belongs beside
455
+ # the download it authorises.
456
+ self.authorise = QCheckBox(tr("Allow apps to request this download"))
457
+ self.authorise.setToolTip(tr("Without this, a REST or MCP request cannot start a download."))
458
+ box.addWidget(self.authorise)
459
+
460
+ box.addWidget(separator())
461
+
462
+ self.details_button, self.details = self._pane(box, tr("Voices and styles"))
463
+ self._fill_details()
464
+ self.licence_button, self.licence_pane = self._pane(box, tr("Licence and restrictions"))
465
+ self._fill_licence()
466
+
467
+ self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
468
+
469
+ self.download_button.clicked.connect(self._on_download)
470
+ self.verify_button.clicked.connect(self._on_verify)
471
+ self.repair_button.clicked.connect(self._on_repair)
472
+ self.delete_button.clicked.connect(self._on_delete)
473
+ self.cancel_button.clicked.connect(self._on_cancel)
474
+ self.authorise.toggled.connect(self._on_authorise)
475
+
476
+ def _pane(self, box: QVBoxLayout, title: str) -> tuple[QPushButton, QWidget]:
477
+ """A disclosure button and the panel it shows.
478
+
479
+ Collapsed by default: the shipped licence has thirteen restrictions
480
+ and the model has ten voices, and a screen that opens with all of
481
+ that is one nobody reads. F-80 asks for them to be reviewable, not
482
+ unavoidable.
483
+ """
484
+ p = self._palette
485
+ button = QPushButton(" " + title)
486
+ button.setProperty("variant", "quiet")
487
+ button.setCheckable(True)
488
+ button.setIcon(icons.icon("chevron-right", p.text_secondary, p.text_muted))
489
+ button.setIconSize(icons.icon_size(14))
490
+ button.setAccessibleName(title)
491
+ row = QHBoxLayout()
492
+ row.addWidget(button)
493
+ row.addStretch(1)
494
+ box.addLayout(row)
495
+
496
+ panel = QWidget()
497
+ panel.hide()
498
+ box.addWidget(panel)
499
+
500
+ def toggled(open_: bool) -> None:
501
+ panel.setVisible(open_)
502
+ glyph = "chevron-down" if open_ else "chevron-right"
503
+ button.setIcon(icons.icon(glyph, p.text_secondary, p.text_muted))
504
+
505
+ button.toggled.connect(toggled)
506
+ return button, panel
507
+
508
+ def _fill_details(self) -> None:
509
+ """F-63: the voice and speaking-style controls the model offers.
510
+
511
+ The styles are the app's four (F-08) rather than a per-model list:
512
+ the manifest records voices but not styles, so claiming a per-model
513
+ set here would be inventing one.
514
+ """
515
+ m = METRICS
516
+ entry = self._entry
517
+ box = QVBoxLayout(self.details)
518
+ box.setContentsMargins(m.pad, 0, 0, m.gap)
519
+ box.setSpacing(m.gap_tight)
520
+ box.addWidget(label(tr("{n} voices").format(n=count(len(entry.voices))), "section"))
521
+ for gender in (Gender.FEMALE, Gender.MALE):
522
+ for voice in entry.voices_for(gender):
523
+ # F-53 puts the description in front of a person too, not
524
+ # only in the API answer.
525
+ line = label(f"· {voice.display_name} — {voice.description}", "muted")
526
+ line.setWordWrap(True)
527
+ box.addWidget(line)
528
+ styles = ", ".join(tr(_STYLE_NAMES[style]) for style in SpeakingStyle)
529
+ box.addWidget(label(f"{tr('Speaking styles')}: {styles}", "muted"))
530
+ box.addWidget(label(f"{tr('Tempo range')}: {TEMPO_MIN:.2f}x – {TEMPO_MAX:.2f}x", "muted"))
531
+
532
+ def _fill_licence(self) -> None:
533
+ """F-80 and N-11: the restrictions, reviewable after acceptance.
534
+
535
+ The acceptance dialog in :mod:`echoact.ui.licence` is shown once,
536
+ before first preparation. F-80 also requires licences to be
537
+ reviewable inside the app, and a term that can only be read at the
538
+ moment it is agreed to is not reviewable -- so the same text,
539
+ quoted rather than paraphrased, is on the screen that owns the model.
540
+ """
541
+ m = METRICS
542
+ terms = self._entry.license
543
+ box = QVBoxLayout(self.licence_pane)
544
+ box.setContentsMargins(m.pad, 0, 0, m.gap)
545
+ box.setSpacing(m.gap_tight)
546
+ box.addWidget(label(terms.name, "section"))
547
+ box.addWidget(label(tr("You may not use the model to:"), "secondary"))
548
+ for restriction in terms.restrictions:
549
+ box.addWidget(self._quote(restriction))
550
+ if terms.notes:
551
+ box.addWidget(label(tr("Also worth knowing"), "secondary"))
552
+ for note in terms.notes:
553
+ box.addWidget(self._quote(note))
554
+ box.addWidget(label(tr("Why these restrictions apply to you"), "secondary"))
555
+ box.addWidget(self._quote(terms.pass_through_obligation))
556
+ box.addWidget(
557
+ label(
558
+ tr("The full licence is in {file} in the model's repository.").format(
559
+ file=terms.source_file
560
+ ),
561
+ "muted",
562
+ )
563
+ )
564
+
565
+ @staticmethod
566
+ def _quote(text: str) -> QLabel:
567
+ lb = label("· " + text, "muted")
568
+ lb.setWordWrap(True)
569
+ lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
570
+ return lb
571
+
572
+ # ------------------------------------------------------------------
573
+ # State
574
+ # ------------------------------------------------------------------
575
+
576
+ @property
577
+ def model_id(self) -> str:
578
+ return self._entry.model_id
579
+
580
+ @property
581
+ def busy(self) -> bool:
582
+ return self._task is not None
583
+
584
+ @property
585
+ def task(self) -> RegistryTask | None:
586
+ """The running operation, for a caller that has to wait on it."""
587
+ return self._task
588
+
589
+ @property
590
+ def status(self) -> ModelStatus | None:
591
+ return self._status
592
+
593
+ @property
594
+ def state(self) -> ModelState | None:
595
+ """What the card is showing, which is not always the cheap answer:
596
+ a completed deep pass, an interrupted download, and a check that
597
+ found damage before it stopped each pin their own evidence."""
598
+ if self._pinned_state is not None:
599
+ return self._pinned_state
600
+ return self._status.state if self._status else None
601
+
602
+ def set_budget(self, budget: Budget) -> None:
603
+ """F-78: the budget changed, so what can run may have changed."""
604
+ self._budget = budget
605
+ self.reload()
606
+
607
+ def set_in_use(self, in_use: bool) -> None:
608
+ """F-65: whether a running job is holding this model."""
609
+ self._in_use = in_use
610
+ self._update_buttons()
611
+
612
+ def reload(self) -> None:
613
+ """Re-read the model's state and redraw.
614
+
615
+ A presence-and-size pass, not a digest one: this screen redraws on
616
+ every visit and on every budget change, and hashing 385 MB to do so
617
+ would be exactly the freeze rule 7 forbids. ``Check files`` is the
618
+ deep pass, and it is explicit and cancellable.
619
+
620
+ The cheap pass cannot *prove* a model sound, but it can disprove it:
621
+ a file that is gone or the wrong size settles the question. So a
622
+ pinned deep verdict survives a redraw that agrees with it and is
623
+ dropped by one that does not, which is what keeps a stale "ready"
624
+ from outliving the files it was about.
625
+ """
626
+ try:
627
+ self._status = self._registry.status(self.model_id, self._budget)
628
+ except EchoActError as exc:
629
+ self.problem.emit(exc)
630
+ return
631
+ if self._pinned_state is not None and self._status.state is not ModelState.READY:
632
+ self._pinned_state = None
633
+ self._render()
634
+
635
+ # ------------------------------------------------------------------
636
+ # Rendering
637
+ # ------------------------------------------------------------------
638
+
639
+ def _render(self) -> None:
640
+ status = self._status
641
+ if status is None:
642
+ return
643
+ entry = self._entry
644
+ p = self._palette
645
+ dpr = self.devicePixelRatioF()
646
+
647
+ state = self._pinned_state or status.state
648
+ word, glyph, role = _STATE_TEXT[state]
649
+ colour = {"ok": p.ok, "warn": p.warn, "danger": p.danger, "muted": p.text_muted}[role]
650
+ self.state_icon.setPixmap(icons.pixmap(glyph, colour, 16, dpr))
651
+ self.state_text.setText(tr(word))
652
+ _restyle(self.state_text, role)
653
+ self.state_text.setAccessibleName(f"{entry.display_name}: {tr(word)}")
654
+
655
+ if status.runnable:
656
+ self.avail_icon.setPixmap(icons.pixmap("check", p.ok, 14, dpr))
657
+ self.avail_text.setText(tr("Runs within the current budget"))
658
+ _restyle(self.avail_text, "secondary")
659
+ else:
660
+ self.avail_icon.setPixmap(icons.pixmap("warning", p.warn, 14, dpr))
661
+ self.avail_text.setText(
662
+ tr("Unavailable: {reason}").format(reason=status.unavailable_reason or "")
663
+ )
664
+ _restyle(self.avail_text, "warn")
665
+
666
+ # F-84: the pinned revision is the version, and the full one is a
667
+ # tooltip because twelve characters identify it and forty crowd the
668
+ # row.
669
+ self._values["version"].setText(_short(entry.revision))
670
+ self._values["version"].setToolTip(entry.revision)
671
+ self._values["source"].setText(entry.repo_id)
672
+ self._values["storage"].setText(
673
+ tr("{used} of {total}").format(
674
+ used=bytes_size(max(status.disk_bytes, status.bytes_present)),
675
+ total=bytes_size(status.bytes_total),
676
+ )
677
+ )
678
+ self._values["languages"].setText(
679
+ ", ".join(tr(_LANGUAGE_NAMES.get(code, code)) for code in status.languages)
680
+ )
681
+ self._values["audio"].setText(
682
+ tr("{rate} kHz mono").format(rate=f"{entry.sample_rate / 1000:.1f}")
683
+ )
684
+ self._values["licence"].setText(
685
+ f"{status.license_name} — "
686
+ + tr("Accepted" if status.license_accepted else "Not accepted yet")
687
+ )
688
+
689
+ self.requirements.setText(self._requirements(status, state))
690
+ self.requirements.setVisible(bool(self.requirements.text()))
691
+ self.package_note.setVisible(status.using_package_cache)
692
+
693
+ blocked = self.authorise.blockSignals(True)
694
+ self.authorise.setChecked(status.download_authorised)
695
+ self.authorise.blockSignals(blocked)
696
+
697
+ self._update_buttons()
698
+
699
+ def _requirements(self, status: ModelStatus, state: ModelState) -> str:
700
+ """F-63's pre-download report: storage, then the budget constraint.
701
+
702
+ Shown whenever the model is not ready, which is exactly the set of
703
+ moments that are "before downloading". The remaining figure is
704
+ given as well as the total because F-64 reuses what is already
705
+ sound, so the total would overstate what the next attempt costs.
706
+ """
707
+ if state is ModelState.READY:
708
+ return ""
709
+ parts = [tr("Requires {size} of storage.").format(size=bytes_size(status.bytes_total))]
710
+ remaining = status.bytes_total - status.bytes_present
711
+ if 0 < remaining < status.bytes_total:
712
+ parts.append(tr("{size} of it is still to download.").format(size=bytes_size(remaining)))
713
+ parts.append(
714
+ tr(
715
+ "Needs at least {memory} of memory and {cpu}% CPU; the current budget is "
716
+ "{have_memory} and {have_cpu}%."
717
+ ).format(
718
+ memory=memory_size(status.minimum_memory_bytes),
719
+ cpu=status.minimum_cpu_percent,
720
+ have_memory=memory_size(self._budget.memory_bytes),
721
+ have_cpu=self._budget.cpu_percent,
722
+ )
723
+ )
724
+ return " ".join(parts)
725
+
726
+ def _download_label(self) -> str:
727
+ """F-64 names the three cases, and so does the button."""
728
+ if self._failed:
729
+ return tr("Retry download")
730
+ if self.state is ModelState.PARTIAL:
731
+ return tr("Resume download")
732
+ return tr("Download")
733
+
734
+ def _update_buttons(self) -> None:
735
+ busy = self.busy
736
+ state = self.state
737
+ present = bool(self._status and self._status.disk_bytes > 0)
738
+ self.download_button.setText(self._download_label())
739
+ self.download_button.setAccessibleName(self.download_button.text())
740
+ self.download_button.setVisible(state is not ModelState.READY)
741
+ self.download_button.setEnabled(not busy)
742
+ self.repair_button.setVisible(state in (ModelState.CORRUPT, ModelState.PARTIAL))
743
+ self.repair_button.setEnabled(not busy)
744
+ self.verify_button.setEnabled(not busy)
745
+ # A model whose only sound copy is another program's cache has
746
+ # nothing of ours to delete, and F-65 does not let this screen reach
747
+ # into that cache.
748
+ self.delete_button.setVisible(present)
749
+ self.delete_button.setEnabled(not busy)
750
+ self.cancel_button.setVisible(busy)
751
+ self.authorise.setEnabled(not busy)
752
+
753
+ def _set_result(self, text: str, role: str | None) -> None:
754
+ self.result.setText(text)
755
+ _restyle(self.result, role or "muted")
756
+
757
+ # ------------------------------------------------------------------
758
+ # Actions
759
+ # ------------------------------------------------------------------
760
+
761
+ def _on_authorise(self, allowed: bool) -> None:
762
+ try:
763
+ self._registry.set_download_authorised(self.model_id, allowed)
764
+ except EchoActError as exc:
765
+ self.problem.emit(exc)
766
+ self.reload()
767
+
768
+ def _on_download(self) -> None:
769
+ if not self._licence_gate():
770
+ return
771
+ self._start(TaskKind.DOWNLOAD)
772
+
773
+ def _on_repair(self) -> None:
774
+ if not self._licence_gate():
775
+ return
776
+ self._start(TaskKind.REPAIR)
777
+
778
+ def _on_verify(self) -> None:
779
+ """F-65's check. No licence gate: reading files that are already
780
+ here to say whether they are sound is not preparing the model."""
781
+ self._start(TaskKind.VERIFY)
782
+
783
+ def _licence_gate(self) -> bool:
784
+ """N-11: the terms are accepted before the model is first prepared.
785
+
786
+ Asked here rather than left to the registry's refusal, because a
787
+ code in a status line is not "presented as terms the user accepts".
788
+ Declining is not an error: nothing is downloaded, and the card says
789
+ so rather than showing a failure.
790
+ """
791
+ if not self._registry.license_acceptance_required(self.model_id):
792
+ return True
793
+ if not self.request_licence():
794
+ self._set_result(tr("The licence was not accepted, so nothing was downloaded."), "warn")
795
+ return False
796
+ self._registry.accept_license(self.model_id)
797
+ self.licence_accepted.emit(self.model_id)
798
+ self.reload()
799
+ return True
800
+
801
+ def request_licence(self) -> bool:
802
+ """Show N-11's acceptance dialog and report the answer.
803
+
804
+ Its own method because a modal dialog spins an event loop of its
805
+ own: nothing outside it can press its buttons, so a test replaces
806
+ this one method and the flow around it stays real.
807
+ """
808
+ dialog = LicenceDialog(self._entry, self._palette, self)
809
+ return dialog.exec() == QDialog.DialogCode.Accepted
810
+
811
+ def confirm(self, title: str, body: str, accept_text: str) -> bool:
812
+ """A destructive-action confirmation, replaceable for the same
813
+ reason as :meth:`request_licence`.
814
+
815
+ The rejecting button is the default: F-65's delete is irreversible
816
+ and 385 MB to fetch again.
817
+ """
818
+ box = QMessageBox(self)
819
+ box.setIcon(QMessageBox.Icon.Warning)
820
+ box.setWindowTitle(title)
821
+ box.setText(title)
822
+ box.setInformativeText(body)
823
+ accept = box.addButton(accept_text, QMessageBox.ButtonRole.DestructiveRole)
824
+ keep = box.addButton(tr("Keep"), QMessageBox.ButtonRole.RejectRole)
825
+ box.setDefaultButton(keep)
826
+ box.exec()
827
+ return box.clickedButton() is accept
828
+
829
+ def _on_delete(self) -> None:
830
+ """F-65's delete, including the in-use case.
831
+
832
+ The confirmation says what is *not* deleted, because that is the
833
+ part of F-65 a user cannot check by looking: retained documents and
834
+ audio results live under other roots, and the supertonic package's
835
+ cache belongs to another program.
836
+ """
837
+ if self._in_use:
838
+ if not self.confirm(
839
+ tr("A job is using this model."),
840
+ tr(
841
+ "Deleting it cancels that job first and releases the model. Documents "
842
+ "and audio results are kept."
843
+ ),
844
+ tr("Cancel the job and delete"),
845
+ ):
846
+ return
847
+ # The window cancels the job and answers with set_in_use(False).
848
+ # If it does not, the registry refuses below and says why, which
849
+ # is better than this screen assuming a release it did not see.
850
+ self.release_requested.emit(self.model_id)
851
+ elif not self.confirm(
852
+ tr("Delete this model?"),
853
+ tr(
854
+ "Only EchoAct's own copy is removed. Documents and audio results are kept, "
855
+ "and no other program's cache is touched."
856
+ ),
857
+ tr("Delete"),
858
+ ):
859
+ return
860
+ try:
861
+ freed = self._registry.delete(self.model_id, in_use=self._in_use)
862
+ except EchoActError as exc:
863
+ self._set_result(exc.message, "danger")
864
+ self.problem.emit(exc)
865
+ return
866
+ self._failed = False
867
+ self._pinned_state = None
868
+ self._set_result(
869
+ tr("Freed {size}. Documents and audio results were kept.").format(
870
+ size=bytes_size(freed)
871
+ ),
872
+ "muted",
873
+ )
874
+ self.deleted.emit(self.model_id)
875
+ self.reload()
876
+
877
+ def _on_cancel(self) -> None:
878
+ if self._task is not None:
879
+ self._task.stop()
880
+ # Disabled rather than hidden: N-09 wants an unavailable control
881
+ # to look unavailable, and the row must not resize while the
882
+ # worker is winding down.
883
+ self.cancel_button.setEnabled(False)
884
+
885
+ # ------------------------------------------------------------------
886
+ # The worker
887
+ # ------------------------------------------------------------------
888
+
889
+ def _start(self, kind: TaskKind) -> None:
890
+ if self._task is not None:
891
+ return
892
+ self._failed = False
893
+ # The pinned verdict is left alone: it is the best evidence there is
894
+ # until this task produces its own, and dropping it here would show
895
+ # a damaged model as ready for as long as the repair takes.
896
+ self._set_result("", None)
897
+ task = RegistryTask(self._registry, self.model_id, kind, self)
898
+ task.progressed.connect(self._on_progress)
899
+ task.verified.connect(self._on_verified)
900
+ task.prepared.connect(self._on_prepared)
901
+ task.failed.connect(self._on_failed)
902
+ task.finished.connect(self._on_finished)
903
+ self._task = task
904
+ self.progress.setValue(0)
905
+ self.progress_text.setText(tr("Checking files"))
906
+ self.progress_row.setVisible(True)
907
+ self.cancel_button.setEnabled(True)
908
+ self._update_buttons()
909
+ self.busy_changed.emit(True)
910
+ task.start()
911
+
912
+ def _on_progress(self, progress: DownloadProgress) -> None:
913
+ if progress.phase is DownloadPhase.CHECKING:
914
+ self.progress_text.setText(tr("Checking {file}").format(file=progress.relative_path))
915
+ elif progress.phase is DownloadPhase.DOWNLOADING:
916
+ self.progress_text.setText(
917
+ tr("Downloading {file}").format(file=progress.relative_path)
918
+ + " · "
919
+ + tr("{done} of {total}").format(
920
+ done=bytes_size(progress.bytes_done), total=bytes_size(progress.bytes_total)
921
+ )
922
+ )
923
+ self.progress.setValue(int(progress.fraction * 100))
924
+
925
+ def _on_verified(self, report: VerifyReport) -> None:
926
+ # A deep pass that ran to the end is the strongest evidence there
927
+ # is, so it outranks whatever the presence-and-size redraw after it
928
+ # would conclude. A pass that was *stopped* is not that: every file
929
+ # it never reached is reported unchecked, an unchecked file reads as
930
+ # absent, and so a check cancelled over a fully present, digest-sound
931
+ # model collapses to NOT_PRESENT. Pinning that would be a verdict
932
+ # the card could never recover from -- the cheap pass that follows
933
+ # says READY, and only a cheap pass that *disproves* READY drops a
934
+ # pin -- so "Not downloaded" would outlive every reload, refresh and
935
+ # budget change until some other operation happened to replace it.
936
+ #
937
+ # What a stopped pass can still prove is damage it saw before it
938
+ # stopped, and that is the case the pin exists for at all: a
939
+ # tampered file is the right size, so the cheap pass cannot see it.
940
+ # Damage is therefore pinned and nothing else is, which leaves any
941
+ # earlier verdict -- still the best evidence available -- in place.
942
+ if not report.cancelled or report.damaged:
943
+ self._pinned_state = report.state
944
+ if report.cancelled:
945
+ self._set_result(tr("The check was stopped. Nothing was changed."), "warn")
946
+ elif report.state is ModelState.READY:
947
+ self._set_result(
948
+ tr("All {n} files match the manifest.").format(n=count(len(report.files))), "ok"
949
+ )
950
+ elif report.damaged:
951
+ self._set_result(
952
+ tr("{n} files do not match the manifest.").format(n=count(len(report.damaged))),
953
+ "danger",
954
+ )
955
+ else:
956
+ self._set_result(
957
+ tr("{n} files are missing.").format(n=count(len(report.missing))), "warn"
958
+ )
959
+
960
+ def _on_prepared(self, outcome: DownloadOutcome) -> None:
961
+ # Either way the state shown is the one the attempt established.
962
+ # F-64: a cancelled download is never shown as ready, and the
963
+ # interrupted report is what says so -- a fresh presence-and-size
964
+ # pass over files whose digests nobody finished checking would not.
965
+ self._pinned_state = outcome.report.state
966
+ if outcome.cancelled:
967
+ self._failed = True
968
+ self._set_result(tr("The download was canceled. The model is not ready."), "warn")
969
+ return
970
+ if outcome.bytes_downloaded:
971
+ self._set_result(
972
+ tr("Downloaded {size}. {n} files already on disk were reused.").format(
973
+ size=bytes_size(outcome.bytes_downloaded), n=count(len(outcome.reused))
974
+ )
975
+ + " "
976
+ + tr("The model is ready."),
977
+ "ok",
978
+ )
979
+ else:
980
+ self._set_result(
981
+ tr("Nothing needed downloading.") + " " + tr("The model is ready."), "ok"
982
+ )
983
+ self.prepared.emit(self.model_id)
984
+
985
+ def _on_failed(self, exc: EchoActError) -> None:
986
+ self._failed = True
987
+ self._set_result(exc.message, "danger")
988
+ self.problem.emit(exc)
989
+
990
+ def _on_finished(self) -> None:
991
+ task = self._task
992
+ self._task = None
993
+ if task is not None:
994
+ task.deleteLater()
995
+ self.progress_row.hide()
996
+ self.reload()
997
+ self.busy_changed.emit(False)
998
+
999
+ def shutdown(self) -> None:
1000
+ """Stop any work and wait for it, within N-22's five seconds."""
1001
+ task = self._task
1002
+ if task is None:
1003
+ return
1004
+ task.stop()
1005
+ if not task.wait(SHUTDOWN_WAIT_MS):
1006
+ # N-22's deadline passed. Said out loud rather than waited out:
1007
+ # the window is closing, and a silent extra minute looks like a
1008
+ # hang to whoever asked for it.
1009
+ log.warning("model worker for %s did not stop within the deadline", self.model_id)
1010
+
1011
+
1012
+ class ModelsView(QWidget):
1013
+ """F-63's screen: every model in the manifest, and what may be done to it.
1014
+
1015
+ The view holds the registry because the registry is its subject, but it
1016
+ holds nothing else: anything needing the job engine or the rest of the
1017
+ application leaves as a signal, so there is still one place -- the
1018
+ window -- that knows what is running.
1019
+ """
1020
+
1021
+ licence_accepted = Signal(str)
1022
+ model_prepared = Signal(str)
1023
+ model_deleted = Signal(str)
1024
+ release_requested = Signal(str)
1025
+ problem = Signal(object) # EchoActError
1026
+ busy_changed = Signal(bool)
1027
+
1028
+ def __init__(
1029
+ self,
1030
+ palette: Palette,
1031
+ registry: ModelRegistry,
1032
+ budget: Budget,
1033
+ parent: QWidget | None = None,
1034
+ ) -> None:
1035
+ super().__init__(parent)
1036
+ self.setObjectName("Root")
1037
+ self._registry = registry
1038
+ self._cards: dict[str, ModelCard] = {}
1039
+
1040
+ m = METRICS
1041
+ outer = QVBoxLayout(self)
1042
+ outer.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
1043
+ outer.setSpacing(m.gap_wide)
1044
+
1045
+ head = QHBoxLayout()
1046
+ head.setSpacing(m.gap)
1047
+ head.addWidget(label(tr("Models"), "title"))
1048
+ head.addStretch(1)
1049
+ self.usage = label("", "muted")
1050
+ head.addWidget(self.usage)
1051
+ outer.addLayout(head)
1052
+
1053
+ body = QWidget()
1054
+ self._list = QVBoxLayout(body)
1055
+ self._list.setContentsMargins(0, 0, m.gap, 0)
1056
+ self._list.setSpacing(m.gap_wide)
1057
+ # Built from the manifest, never from the disk or from what fits:
1058
+ # F-04 requires a model that cannot run to be listed with its
1059
+ # reason, and a list assembled from what is usable is exactly the
1060
+ # list that drops it.
1061
+ for entry in registry.manifest:
1062
+ card = ModelCard(palette, registry, entry, budget, body)
1063
+ card.licence_accepted.connect(self.licence_accepted)
1064
+ card.prepared.connect(self._on_prepared)
1065
+ card.deleted.connect(self._on_deleted)
1066
+ card.release_requested.connect(self.release_requested)
1067
+ card.problem.connect(self.problem)
1068
+ card.busy_changed.connect(self.busy_changed)
1069
+ self._cards[entry.model_id] = card
1070
+ self._list.addWidget(card)
1071
+ self._list.addStretch(1)
1072
+
1073
+ scroll = QScrollArea()
1074
+ scroll.setWidget(body)
1075
+ scroll.setWidgetResizable(True)
1076
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
1077
+ outer.addWidget(scroll, 1)
1078
+
1079
+ self._refresh_usage()
1080
+
1081
+ # ------------------------------------------------------------------
1082
+ # Access
1083
+ # ------------------------------------------------------------------
1084
+
1085
+ @property
1086
+ def cards(self) -> tuple[ModelCard, ...]:
1087
+ return tuple(self._cards.values())
1088
+
1089
+ def card(self, model_id: str) -> ModelCard:
1090
+ return self._cards[model_id]
1091
+
1092
+ @property
1093
+ def busy(self) -> bool:
1094
+ return any(card.busy for card in self._cards.values())
1095
+
1096
+ # ------------------------------------------------------------------
1097
+ # What the window tells this screen
1098
+ # ------------------------------------------------------------------
1099
+
1100
+ def set_budget(self, budget: Budget) -> None:
1101
+ for card in self._cards.values():
1102
+ card.set_budget(budget)
1103
+
1104
+ def set_in_use(self, model_id: str | None) -> None:
1105
+ """F-65: which model, if any, a running job is holding."""
1106
+ for card in self._cards.values():
1107
+ card.set_in_use(card.model_id == model_id)
1108
+
1109
+ def refresh(self) -> None:
1110
+ """Redraw from the registry, leaving any running operation alone."""
1111
+ for card in self._cards.values():
1112
+ if not card.busy:
1113
+ card.reload()
1114
+ self._refresh_usage()
1115
+
1116
+ def shutdown(self) -> None:
1117
+ for card in self._cards.values():
1118
+ card.shutdown()
1119
+
1120
+ # ------------------------------------------------------------------
1121
+ # Internals
1122
+ # ------------------------------------------------------------------
1123
+
1124
+ def _on_prepared(self, model_id: str) -> None:
1125
+ self._refresh_usage()
1126
+ self.model_prepared.emit(model_id)
1127
+
1128
+ def _on_deleted(self, model_id: str) -> None:
1129
+ self._refresh_usage()
1130
+ self.model_deleted.emit(model_id)
1131
+
1132
+ def _refresh_usage(self) -> None:
1133
+ """F-73's model-cache figure, on the screen that changes it."""
1134
+ try:
1135
+ total = self._registry.total_disk_usage()
1136
+ except EchoActError as exc:
1137
+ self.problem.emit(exc)
1138
+ return
1139
+ self.usage.setText(tr("Model cache uses {size}").format(size=bytes_size(total)))
1140
+
1141
+
1142
+ __all__ = ["ModelCard", "ModelsView", "RegistryTask", "TaskKind"]