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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- 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"]
|