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,2258 @@
|
|
|
1
|
+
"""The settings screen: everything the owner can change, in one scrolling page.
|
|
2
|
+
|
|
3
|
+
The screen is a stack of panels rather than a tab strip. N-30 requires the
|
|
4
|
+
main features to stay reachable at 1280x720 with 100-200% OS scaling, and a
|
|
5
|
+
tab strip solves that by hiding five sixths of the screen; a single column
|
|
6
|
+
that scrolls keeps everything reachable at any scale and needs no second
|
|
7
|
+
navigation model for the keyboard to walk.
|
|
8
|
+
|
|
9
|
+
It is a *view*. It holds no ``Application``, writes no file, and starts no
|
|
10
|
+
job: every edit leaves as a signal carrying the new value, and the window
|
|
11
|
+
turns that into ``Application.update_settings`` or into the one call that
|
|
12
|
+
belongs to it (issuing a credential, restarting the service, deleting a
|
|
13
|
+
scope). That split is what lets F-78 be honest -- the screen can show a
|
|
14
|
+
value the running job is *not* using, because it never applied it itself --
|
|
15
|
+
and it is what keeps the destructive paths in one place where the
|
|
16
|
+
confirmation and the doing sit next to each other.
|
|
17
|
+
|
|
18
|
+
Two seams exist for testing and for honesty about modality:
|
|
19
|
+
:attr:`SettingsView.confirm` and :attr:`SettingsView.ask_text`. Both default
|
|
20
|
+
to Qt dialogs; a caller may replace them. Nothing else in this module opens
|
|
21
|
+
a dialog, so every confirmation F-76 and F-66 require passes through one
|
|
22
|
+
function.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import time
|
|
29
|
+
import weakref
|
|
30
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import StrEnum
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
35
|
+
|
|
36
|
+
import psutil
|
|
37
|
+
from PySide6.QtCore import Qt, Signal
|
|
38
|
+
from PySide6.QtWidgets import (
|
|
39
|
+
QCheckBox,
|
|
40
|
+
QComboBox,
|
|
41
|
+
QDoubleSpinBox,
|
|
42
|
+
QFrame,
|
|
43
|
+
QGridLayout,
|
|
44
|
+
QHBoxLayout,
|
|
45
|
+
QInputDialog,
|
|
46
|
+
QLabel,
|
|
47
|
+
QLineEdit,
|
|
48
|
+
QListWidget,
|
|
49
|
+
QListWidgetItem,
|
|
50
|
+
QMessageBox,
|
|
51
|
+
QPushButton,
|
|
52
|
+
QScrollArea,
|
|
53
|
+
QSizePolicy,
|
|
54
|
+
QSlider,
|
|
55
|
+
QSpinBox,
|
|
56
|
+
QTreeWidget,
|
|
57
|
+
QTreeWidgetItem,
|
|
58
|
+
QVBoxLayout,
|
|
59
|
+
QWidget,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
from .. import __version__
|
|
63
|
+
from ..audio import devices as audio_devices
|
|
64
|
+
from ..audio.devices import OutputDevice
|
|
65
|
+
from ..config.budget import default_memory_bytes, memory_ceiling_bytes
|
|
66
|
+
from ..config.settings import (
|
|
67
|
+
DisplayLanguage,
|
|
68
|
+
ResourcePolicyView,
|
|
69
|
+
Settings,
|
|
70
|
+
VoicePreset,
|
|
71
|
+
)
|
|
72
|
+
from ..domain import Budget, Capability
|
|
73
|
+
from ..errors import EchoActError
|
|
74
|
+
from ..models.catalog import MANIFEST
|
|
75
|
+
from ..paths import log_dir, model_cache_dir, temp_dir
|
|
76
|
+
from ..policy import (
|
|
77
|
+
API_PREFIX,
|
|
78
|
+
CPU_PERCENT_DEFAULT,
|
|
79
|
+
CPU_PERCENT_MAX,
|
|
80
|
+
CPU_PERCENT_MIN,
|
|
81
|
+
CREDENTIAL_DAYS_MAX,
|
|
82
|
+
CREDENTIAL_DAYS_MIN,
|
|
83
|
+
GB,
|
|
84
|
+
GIB,
|
|
85
|
+
MEMORY_FLOOR_BYTES,
|
|
86
|
+
REST_HOST,
|
|
87
|
+
RETENTION_DEFAULT_BYTES,
|
|
88
|
+
RETENTION_MAX_BYTES,
|
|
89
|
+
RETENTION_MIN_BYTES,
|
|
90
|
+
VOICE_PRESET_MAX,
|
|
91
|
+
)
|
|
92
|
+
from ..security.credentials import Credential, CredentialStatus
|
|
93
|
+
from . import icons
|
|
94
|
+
from .i18n import add_korean, bytes_size, memory_size, on_change, tr
|
|
95
|
+
from .theme import METRICS, Palette
|
|
96
|
+
|
|
97
|
+
if TYPE_CHECKING: # pragma: no cover - imports for types only
|
|
98
|
+
from ..db.store import Store
|
|
99
|
+
from ..models.registry import ModelRegistry
|
|
100
|
+
|
|
101
|
+
#: The lowest port a standard user account can bind, not a product policy.
|
|
102
|
+
#: N-17 pins the address to loopback, so only the port is the owner's to pick.
|
|
103
|
+
MIN_USER_PORT: Final = 1024
|
|
104
|
+
MAX_PORT: Final = 65535
|
|
105
|
+
|
|
106
|
+
#: The author credit shown at the top of the screen. It is the same in
|
|
107
|
+
#: every language, so it is deliberately absent from the translation table.
|
|
108
|
+
AUTHOR: Final = "Huijae Lee (ZeroAct)"
|
|
109
|
+
AUTHOR_GITHUB_URL: Final = "https://github.com/ZeroAct"
|
|
110
|
+
AUTHOR_GITHUB_LABEL: Final = "github.com/ZeroAct"
|
|
111
|
+
|
|
112
|
+
#: F-75's change history. There is no changelog file in the repository yet,
|
|
113
|
+
#: so the history lives beside the screen that shows it rather than being
|
|
114
|
+
#: invented at display time; a release that adds one should read it instead.
|
|
115
|
+
CHANGE_HISTORY: Final[tuple[tuple[str, str, tuple[str, ...]], ...]] = (
|
|
116
|
+
(
|
|
117
|
+
"0.1.0",
|
|
118
|
+
"2026-09-11",
|
|
119
|
+
(
|
|
120
|
+
"Korean and English speech generation that runs entirely on this computer.",
|
|
121
|
+
"Local REST and MCP integrations, reachable from this computer only.",
|
|
122
|
+
"Documents, history, and audio kept under a retention limit you set.",
|
|
123
|
+
),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
add_korean(
|
|
129
|
+
{
|
|
130
|
+
# -- resources -----------------------------------------------------
|
|
131
|
+
"Resources": "자원",
|
|
132
|
+
"How much of this computer EchoAct may use while it generates speech.":
|
|
133
|
+
"음성을 생성하는 동안 에코액트가 사용할 수 있는 이 컴퓨터의 자원입니다.",
|
|
134
|
+
"Processor share": "프로세서 사용률",
|
|
135
|
+
"10-70% of this computer's total processor capacity. The default is 20%.":
|
|
136
|
+
"이 컴퓨터 전체 프로세서 용량의 10~70%입니다. 기본값은 20%입니다.",
|
|
137
|
+
"Memory limit": "메모리 한도",
|
|
138
|
+
"Use the amount recommended for this computer ({size})":
|
|
139
|
+
"이 컴퓨터에 권장되는 값 사용 ({size})",
|
|
140
|
+
"At most {ceiling} on this computer: 32 GiB, or half of installed memory, "
|
|
141
|
+
"whichever is smaller.":
|
|
142
|
+
"이 컴퓨터에서는 최대 {ceiling}입니다. 32GiB와 설치된 메모리의 절반 중 작은 값입니다.",
|
|
143
|
+
"This computer has less memory than EchoAct needs to load a model ({floor}).":
|
|
144
|
+
"이 컴퓨터의 메모리가 모델을 불러오는 데 필요한 값({floor})보다 적습니다.",
|
|
145
|
+
"Configured: {cpu}% processor, {memory} memory":
|
|
146
|
+
"설정값: 프로세서 {cpu}%, 메모리 {memory}",
|
|
147
|
+
"Applied to the job running now: {cpu}% processor, {memory} memory":
|
|
148
|
+
"지금 실행 중인 작업에 적용된 값: 프로세서 {cpu}%, 메모리 {memory}",
|
|
149
|
+
"No job is running. These values apply to the next job.":
|
|
150
|
+
"실행 중인 작업이 없습니다. 이 값은 다음 작업에 적용됩니다.",
|
|
151
|
+
"The applied memory can be lower than the configured value when the computer "
|
|
152
|
+
"has little free memory.":
|
|
153
|
+
"컴퓨터의 여유 메모리가 적으면 적용되는 메모리가 설정값보다 낮을 수 있습니다.",
|
|
154
|
+
"This change applies to the next job.": "이 변경은 다음 작업부터 적용됩니다.",
|
|
155
|
+
"Apply to the next job": "다음 작업에 적용",
|
|
156
|
+
"Cancel the job and apply now": "작업을 취소하고 지금 적용",
|
|
157
|
+
"Cancel the running job?": "실행 중인 작업을 취소할까요?",
|
|
158
|
+
"Applying the new limits now cancels the job that is running. Audio already "
|
|
159
|
+
"generated stays available; the rest is not generated.":
|
|
160
|
+
"새 한도를 지금 적용하면 실행 중인 작업이 취소됩니다. 이미 생성된 오디오는 그대로 "
|
|
161
|
+
"남고 나머지는 생성되지 않습니다.",
|
|
162
|
+
# -- audio ---------------------------------------------------------
|
|
163
|
+
"Audio": "오디오",
|
|
164
|
+
"Output device": "출력 장치",
|
|
165
|
+
"System default output": "시스템 기본 출력",
|
|
166
|
+
"{name} (not connected)": "{name} (연결되지 않음)",
|
|
167
|
+
"The audio system could not be listed: {reason}":
|
|
168
|
+
"오디오 장치 목록을 읽지 못했습니다: {reason}",
|
|
169
|
+
"Look for devices again": "장치 다시 찾기",
|
|
170
|
+
"Volume": "음량",
|
|
171
|
+
"Mute": "음소거",
|
|
172
|
+
"Volume changes what you hear now. It does not change the saved WAV file.":
|
|
173
|
+
"음량은 지금 들리는 소리만 바꿉니다. 저장된 WAV 파일은 바뀌지 않습니다.",
|
|
174
|
+
# -- reading and display -------------------------------------------
|
|
175
|
+
"Reading and display": "읽기와 표시",
|
|
176
|
+
"Display language": "표시 언어",
|
|
177
|
+
"Changing the display language does not change your documents, saved jobs, "
|
|
178
|
+
"or what the local service answers.":
|
|
179
|
+
"표시 언어를 바꿔도 문서, 저장된 작업, 로컬 서비스의 응답은 바뀌지 않습니다.",
|
|
180
|
+
# -- integrations ---------------------------------------------------
|
|
181
|
+
"Integrations": "연동",
|
|
182
|
+
"Local REST service": "로컬 REST 서비스",
|
|
183
|
+
"Listening on {host}:{port} — this computer only. Every request needs a "
|
|
184
|
+
"credential.":
|
|
185
|
+
"{host}:{port}에서 대기 중이며 이 컴퓨터에서만 접근할 수 있습니다. 모든 요청에는 "
|
|
186
|
+
"자격 증명이 필요합니다.",
|
|
187
|
+
"MCP works through this service, so turning it off also makes MCP unavailable.":
|
|
188
|
+
"MCP는 이 서비스를 통해 동작하므로, 이 서비스를 끄면 MCP도 사용할 수 없습니다.",
|
|
189
|
+
"Turn the local service off?": "로컬 서비스를 끌까요?",
|
|
190
|
+
"MCP is a client of this service, so turning it off makes MCP unavailable "
|
|
191
|
+
"too. Generation, playback, and the library keep working.":
|
|
192
|
+
"MCP는 이 서비스의 클라이언트이므로 서비스를 끄면 MCP도 사용할 수 없습니다. 생성, "
|
|
193
|
+
"재생, 라이브러리는 그대로 사용할 수 있습니다.",
|
|
194
|
+
"Turn off": "끄기",
|
|
195
|
+
"Port": "포트",
|
|
196
|
+
"Restart the service on this port": "이 포트로 서비스 다시 시작",
|
|
197
|
+
"Port {port} is in use by another program. EchoAct does not close it and "
|
|
198
|
+
"does not pick another port on its own.":
|
|
199
|
+
"포트 {port}는 다른 프로그램이 사용 중입니다. 에코액트는 그 프로그램을 종료하지 "
|
|
200
|
+
"않으며 다른 포트를 임의로 선택하지도 않습니다.",
|
|
201
|
+
"Use port {port} instead": "대신 포트 {port} 사용",
|
|
202
|
+
"Use a different port": "다른 포트 사용",
|
|
203
|
+
"MCP server": "MCP 서버",
|
|
204
|
+
"Off by default. It exposes nothing the REST service does not already expose.":
|
|
205
|
+
"기본값은 꺼짐입니다. REST 서비스가 제공하지 않는 기능은 제공하지 않습니다.",
|
|
206
|
+
"New credentials expire after": "새 자격 증명 유효 기간",
|
|
207
|
+
"days": "일",
|
|
208
|
+
"Clients connect to {url} with their credential. Nothing else on the network "
|
|
209
|
+
"can reach it.":
|
|
210
|
+
"클라이언트는 자격 증명을 사용해 {url}에 연결합니다. 네트워크의 다른 어떤 것도 "
|
|
211
|
+
"접근할 수 없습니다.",
|
|
212
|
+
"Started by your MCP client over stdio, using its own REST credential.":
|
|
213
|
+
"MCP 클라이언트가 stdio로 실행하며, 자체 REST 자격 증명을 사용합니다.",
|
|
214
|
+
"Permissions for the next credential": "다음에 발급할 자격 증명의 권한",
|
|
215
|
+
"Permissions for {name}": "{name}의 권한",
|
|
216
|
+
"Clients": "클라이언트",
|
|
217
|
+
"Name": "이름",
|
|
218
|
+
"Permissions": "권한",
|
|
219
|
+
"Last access": "마지막 접근",
|
|
220
|
+
"Expires": "만료",
|
|
221
|
+
"never": "없음",
|
|
222
|
+
"Owner (every permission)": "소유자 (모든 권한)",
|
|
223
|
+
"Generate": "생성",
|
|
224
|
+
"Read results": "결과 읽기",
|
|
225
|
+
"Read history": "기록 읽기",
|
|
226
|
+
"Revoked": "해지됨",
|
|
227
|
+
"Expired": "만료됨",
|
|
228
|
+
"{date} (in {days} days)": "{date} ({days}일 남음)",
|
|
229
|
+
"No clients yet.": "아직 클라이언트가 없습니다.",
|
|
230
|
+
"Issue a credential": "자격 증명 발급",
|
|
231
|
+
"Reissue": "재발급",
|
|
232
|
+
"Revoke": "해지",
|
|
233
|
+
"A credential for {name} expires in {days} days. Reissue it before then.":
|
|
234
|
+
"{name}의 자격 증명이 {days}일 후 만료됩니다. 그 전에 재발급하세요.",
|
|
235
|
+
"Name this client": "클라이언트 이름",
|
|
236
|
+
"What is connecting? For example, an editor or a script.":
|
|
237
|
+
"무엇이 연결되나요? 예를 들어 편집기나 스크립트입니다.",
|
|
238
|
+
"Copy this credential now. It is shown once.": "이 자격 증명을 지금 복사하세요. 한 번만 표시됩니다.",
|
|
239
|
+
"EchoAct keeps only a verifier, from which the credential cannot be "
|
|
240
|
+
"recovered. If it is lost, reissue it.":
|
|
241
|
+
"에코액트는 검증값만 보관하며, 여기에서 자격 증명을 복원할 수 없습니다. 잃어버리면 "
|
|
242
|
+
"재발급하세요.",
|
|
243
|
+
"Copied": "복사됨",
|
|
244
|
+
"Revoke this credential?": "이 자격 증명을 해지할까요?",
|
|
245
|
+
"{name} loses access immediately, including to results it already "
|
|
246
|
+
"generated, and its running job is canceled.":
|
|
247
|
+
"{name}은(는) 이미 생성한 결과를 포함해 즉시 접근할 수 없게 되며, 실행 중인 작업은 "
|
|
248
|
+
"취소됩니다.",
|
|
249
|
+
"Reissue this credential?": "이 자격 증명을 재발급할까요?",
|
|
250
|
+
"The current credential for {name} stops working at once. The new one is "
|
|
251
|
+
"shown once, here.":
|
|
252
|
+
"{name}의 현재 자격 증명은 즉시 사용할 수 없게 됩니다. 새 자격 증명은 여기에 한 번만 "
|
|
253
|
+
"표시됩니다.",
|
|
254
|
+
# -- storage ---------------------------------------------------------
|
|
255
|
+
"Storage": "저장 공간",
|
|
256
|
+
"Model cache": "모델 캐시",
|
|
257
|
+
"Documents and history": "문서와 기록",
|
|
258
|
+
"Temporary data": "임시 데이터",
|
|
259
|
+
"Logs": "로그",
|
|
260
|
+
"Retention limit": "보관 한도",
|
|
261
|
+
"1-100 GB for documents, history, and audio together. Lowering it below "
|
|
262
|
+
"what is stored keeps that data and refuses new retention.":
|
|
263
|
+
"문서, 기록, 오디오를 합쳐 1~100GB입니다. 이미 저장된 양보다 낮추면 기존 데이터는 "
|
|
264
|
+
"유지되고 새로운 보관만 거부됩니다.",
|
|
265
|
+
"{used} of {limit} used": "{limit} 중 {used} 사용",
|
|
266
|
+
"Clean up expired data": "만료된 데이터 정리",
|
|
267
|
+
"Data in use by playback, generation, or a backup is left alone.":
|
|
268
|
+
"재생, 생성, 백업이 사용 중인 데이터는 건드리지 않습니다.",
|
|
269
|
+
"Removed {n} items, freeing {size}.": "{n}개 항목을 지워 {size}를 확보했습니다.",
|
|
270
|
+
"Removed 1 item, freeing {size}.": "항목 1개를 지워 {size}를 확보했습니다.",
|
|
271
|
+
"Nothing had expired.": "만료된 항목이 없습니다.",
|
|
272
|
+
"Reset voice and display settings": "음성과 표시 설정 초기화",
|
|
273
|
+
"Voice, tempo, style, display language, and playback options return to "
|
|
274
|
+
"their defaults. Documents, history, and audio are untouched.":
|
|
275
|
+
"음성, 속도, 스타일, 표시 언어, 재생 옵션이 기본값으로 돌아갑니다. 문서, 기록, "
|
|
276
|
+
"오디오는 그대로 유지됩니다.",
|
|
277
|
+
"Revoke integration permissions": "연동 권한 해지",
|
|
278
|
+
"Every client credential stops working at once and their running jobs are "
|
|
279
|
+
"canceled. A new owner credential is issued on the next launch.":
|
|
280
|
+
"모든 클라이언트 자격 증명이 즉시 무효화되고 실행 중인 작업은 취소됩니다. 새 소유자 "
|
|
281
|
+
"자격 증명은 다음 실행 시 발급됩니다.",
|
|
282
|
+
"Delete retained data": "보관된 데이터 삭제",
|
|
283
|
+
"Saved documents, job history, and generated audio are deleted from this "
|
|
284
|
+
"app and cannot be recovered. Files you exported yourself and your own "
|
|
285
|
+
"backups are not touched.":
|
|
286
|
+
"저장된 문서, 작업 기록, 생성된 오디오가 이 앱에서 삭제되며 복구할 수 없습니다. "
|
|
287
|
+
"사용자가 직접 내보낸 파일과 백업은 삭제되지 않습니다.",
|
|
288
|
+
"Delete the model cache": "모델 캐시 삭제",
|
|
289
|
+
"The downloaded model files are deleted, about {size}. Generation needs "
|
|
290
|
+
"them downloaded again before it can run, which needs a network.":
|
|
291
|
+
"내려받은 모델 파일 약 {size}가 삭제됩니다. 다시 생성하려면 네트워크를 통해 다시 "
|
|
292
|
+
"내려받아야 합니다.",
|
|
293
|
+
"Delete": "삭제",
|
|
294
|
+
"Reset": "초기화",
|
|
295
|
+
"Revoke all": "모두 해지",
|
|
296
|
+
"This cannot be undone.": "이 작업은 되돌릴 수 없습니다.",
|
|
297
|
+
"These items could not be deleted: {items}": "다음 항목은 삭제하지 못했습니다: {items}",
|
|
298
|
+
# -- presets -----------------------------------------------------------
|
|
299
|
+
"Voice presets": "음성 사전 설정",
|
|
300
|
+
"{n} of {max} saved": "{max}개 중 {n}개 저장됨",
|
|
301
|
+
"No presets yet.": "아직 사전 설정이 없습니다.",
|
|
302
|
+
"{name} — this model is not available": "{name} — 이 모델을 사용할 수 없습니다",
|
|
303
|
+
"{name} — this voice is not available": "{name} — 이 음성을 사용할 수 없습니다",
|
|
304
|
+
"The limit of {max} presets has been reached.": "사전 설정 {max}개 한도에 도달했습니다.",
|
|
305
|
+
"Apply": "적용",
|
|
306
|
+
"Rename": "이름 변경",
|
|
307
|
+
"Save the current voice settings as": "현재 음성 설정을 다음 이름으로 저장",
|
|
308
|
+
"A name for this preset": "사전 설정 이름",
|
|
309
|
+
"Replace the preset {name}?": "사전 설정 {name}을(를) 바꿀까요?",
|
|
310
|
+
"A preset with this name already exists. Saving replaces its settings.":
|
|
311
|
+
"같은 이름의 사전 설정이 이미 있습니다. 저장하면 설정이 바뀝니다.",
|
|
312
|
+
"A preset with this name already exists. Renaming replaces its settings.":
|
|
313
|
+
"같은 이름의 사전 설정이 이미 있습니다. 이름을 바꾸면 그 설정이 바뀝니다.",
|
|
314
|
+
"Replace": "바꾸기",
|
|
315
|
+
"Delete the preset {name}?": "사전 설정 {name}을(를) 삭제할까요?",
|
|
316
|
+
"New name": "새 이름",
|
|
317
|
+
# -- about ---------------------------------------------------------------
|
|
318
|
+
"About": "정보",
|
|
319
|
+
"Installed version {version}": "설치된 버전 {version}",
|
|
320
|
+
"Check for a released version": "출시된 버전 확인",
|
|
321
|
+
"Nothing is downloaded, installed, or restarted without your consent. "
|
|
322
|
+
"Checking sends only a version query.":
|
|
323
|
+
"사용자의 동의 없이는 아무것도 내려받거나 설치하거나 다시 시작하지 않습니다. 확인 "
|
|
324
|
+
"시에는 버전 조회만 보냅니다.",
|
|
325
|
+
"Version {version} has been released.": "버전 {version}이(가) 출시되었습니다.",
|
|
326
|
+
"This is the newest released version.": "현재 최신 출시 버전입니다.",
|
|
327
|
+
"The released version could not be checked: {reason}":
|
|
328
|
+
"출시된 버전을 확인하지 못했습니다: {reason}",
|
|
329
|
+
"Change history": "변경 내역",
|
|
330
|
+
"Korean and English speech generation that runs entirely on this computer.":
|
|
331
|
+
"이 컴퓨터에서만 동작하는 한국어와 영어 음성 생성.",
|
|
332
|
+
"Local REST and MCP integrations, reachable from this computer only.":
|
|
333
|
+
"이 컴퓨터에서만 접근할 수 있는 로컬 REST 및 MCP 연동.",
|
|
334
|
+
"Documents, history, and audio kept under a retention limit you set.":
|
|
335
|
+
"사용자가 정한 보관 한도 안에서 유지되는 문서, 기록, 오디오.",
|
|
336
|
+
# -- shared ----------------------------------------------------------------
|
|
337
|
+
"Settings": "설정",
|
|
338
|
+
"Everything EchoAct remembers between launches.":
|
|
339
|
+
"에코액트가 실행 사이에 기억하는 모든 설정입니다.",
|
|
340
|
+
"Refresh": "새로 고침",
|
|
341
|
+
"Done": "완료",
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ResetScope(StrEnum):
|
|
347
|
+
"""F-76's four scopes, offered separately and never as one button."""
|
|
348
|
+
|
|
349
|
+
VOICE_AND_DISPLAY = "voice_and_display"
|
|
350
|
+
INTEGRATIONS = "integrations"
|
|
351
|
+
RETAINED_DATA = "retained_data"
|
|
352
|
+
MODEL_CACHE = "model_cache"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass(frozen=True, slots=True)
|
|
356
|
+
class Machine:
|
|
357
|
+
"""The two machine facts F-20 and F-21 are expressed against.
|
|
358
|
+
|
|
359
|
+
Passed in rather than read at every repaint so that a test can describe
|
|
360
|
+
an 8 GiB laptop and a 128 GiB workstation without owning either.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
total_ram_bytes: int
|
|
364
|
+
logical_cpus: int
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def detect(cls) -> Machine:
|
|
368
|
+
return cls(
|
|
369
|
+
total_ram_bytes=int(psutil.virtual_memory().total),
|
|
370
|
+
logical_cpus=int(psutil.cpu_count(logical=True) or 1),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass(frozen=True, slots=True)
|
|
375
|
+
class StorageSizes:
|
|
376
|
+
"""F-73's five figures, each measured apart from the others.
|
|
377
|
+
|
|
378
|
+
They are not summed into a single "app data" number anywhere: F-73 asks
|
|
379
|
+
for them separately because the answers to "why is this large" and "what
|
|
380
|
+
may I delete" differ per line -- the model cache is re-downloadable, logs
|
|
381
|
+
expire on their own, and retained data is the only one the retention
|
|
382
|
+
limit governs.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
model_cache_bytes: int
|
|
386
|
+
documents_bytes: int
|
|
387
|
+
audio_bytes: int
|
|
388
|
+
temp_bytes: int
|
|
389
|
+
log_bytes: int
|
|
390
|
+
retained_used_bytes: int
|
|
391
|
+
retention_limit_bytes: int
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def directory_bytes(root: Path) -> int:
|
|
395
|
+
"""Bytes under a directory, ignoring what cannot be read.
|
|
396
|
+
|
|
397
|
+
A settings screen that raises because a log file vanished mid-walk would
|
|
398
|
+
be worse than one that under-reports by that file's size, so every
|
|
399
|
+
``OSError`` here is skipped rather than propagated.
|
|
400
|
+
"""
|
|
401
|
+
total = 0
|
|
402
|
+
stack = [root]
|
|
403
|
+
while stack:
|
|
404
|
+
current = stack.pop()
|
|
405
|
+
try:
|
|
406
|
+
entries = list(os.scandir(current))
|
|
407
|
+
except OSError:
|
|
408
|
+
continue
|
|
409
|
+
for entry in entries:
|
|
410
|
+
try:
|
|
411
|
+
if entry.is_dir(follow_symlinks=False):
|
|
412
|
+
stack.append(Path(entry.path))
|
|
413
|
+
elif entry.is_file(follow_symlinks=False):
|
|
414
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
415
|
+
except OSError:
|
|
416
|
+
continue
|
|
417
|
+
return total
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def storage_snapshot(
|
|
421
|
+
store: Store | None = None, registry: ModelRegistry | None = None
|
|
422
|
+
) -> StorageSizes:
|
|
423
|
+
"""Measure F-73's five figures now.
|
|
424
|
+
|
|
425
|
+
Called by the window, not by the view: it walks directories, and the
|
|
426
|
+
view must not decide when the main thread does that. The database half
|
|
427
|
+
comes from ``Store.storage_usage`` rather than from a second walk of the
|
|
428
|
+
audio tree, because that is the number the retention limit is enforced
|
|
429
|
+
against and two disagreeing figures would be worse than one.
|
|
430
|
+
"""
|
|
431
|
+
if store is None:
|
|
432
|
+
documents = audio = used = 0
|
|
433
|
+
limit = RETENTION_DEFAULT_BYTES
|
|
434
|
+
else:
|
|
435
|
+
usage = store.storage_usage()
|
|
436
|
+
documents = usage.document_bytes + usage.job_text_bytes
|
|
437
|
+
audio = usage.audio_bytes
|
|
438
|
+
used = usage.total_bytes
|
|
439
|
+
limit = usage.limit_bytes
|
|
440
|
+
cache = registry.total_disk_usage() if registry is not None else directory_bytes(
|
|
441
|
+
model_cache_dir()
|
|
442
|
+
)
|
|
443
|
+
return StorageSizes(
|
|
444
|
+
model_cache_bytes=cache,
|
|
445
|
+
documents_bytes=documents,
|
|
446
|
+
audio_bytes=audio,
|
|
447
|
+
temp_bytes=directory_bytes(temp_dir()),
|
|
448
|
+
log_bytes=directory_bytes(log_dir()),
|
|
449
|
+
retained_used_bytes=used,
|
|
450
|
+
retention_limit_bytes=limit,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
_CAPABILITY_TEXT: Final[dict[Capability, str]] = {
|
|
455
|
+
Capability.OWNER: "Owner (every permission)",
|
|
456
|
+
Capability.GENERATE: "Generate",
|
|
457
|
+
Capability.READ_RESULTS: "Read results",
|
|
458
|
+
Capability.READ_HISTORY: "Read history",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _default_confirm(parent: QWidget, title: str, body: str, accept: str) -> bool:
|
|
463
|
+
"""The one modal path in this module.
|
|
464
|
+
|
|
465
|
+
The accepting button is never the default: F-76's deletions and F-78's
|
|
466
|
+
cancellation are acts, and a dialog whose Enter key performs them turns
|
|
467
|
+
an act into a reflex.
|
|
468
|
+
"""
|
|
469
|
+
box = QMessageBox(parent)
|
|
470
|
+
box.setIcon(QMessageBox.Icon.Warning)
|
|
471
|
+
box.setWindowTitle(title)
|
|
472
|
+
box.setText(title)
|
|
473
|
+
box.setInformativeText(body)
|
|
474
|
+
go = box.addButton(accept, QMessageBox.ButtonRole.AcceptRole)
|
|
475
|
+
keep = box.addButton(tr("Cancel"), QMessageBox.ButtonRole.RejectRole)
|
|
476
|
+
box.setDefaultButton(keep)
|
|
477
|
+
box.exec()
|
|
478
|
+
return box.clickedButton() is go
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _default_ask_text(parent: QWidget, title: str, prompt: str, value: str) -> str | None:
|
|
482
|
+
text, ok = QInputDialog.getText(parent, title, prompt, QLineEdit.EchoMode.Normal, value)
|
|
483
|
+
return text if ok else None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _date(at: float | None) -> str:
|
|
487
|
+
if at is None:
|
|
488
|
+
return ""
|
|
489
|
+
return time.strftime("%Y-%m-%d", time.localtime(at))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _date_time(at: float | None) -> str:
|
|
493
|
+
if at is None:
|
|
494
|
+
return ""
|
|
495
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(at))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class SettingsView(QScrollArea):
|
|
499
|
+
"""F-20, F-21, F-24, F-46, F-66, F-67, F-71, F-73, F-75, F-76, F-78, F-79,
|
|
500
|
+
F-83, F-86, N-30 and Section 4.1's operational defaults, on one page.
|
|
501
|
+
|
|
502
|
+
Every signal carries the new value in the type the settings field holds,
|
|
503
|
+
so the window's slot is ``update_settings(field=value)`` and no widget
|
|
504
|
+
state has to be read back out of here.
|
|
505
|
+
"""
|
|
506
|
+
|
|
507
|
+
# -- resources (F-20, F-21, F-78) -------------------------------------
|
|
508
|
+
cpu_changed = Signal(int)
|
|
509
|
+
memory_changed = Signal(object) # int bytes, or None for F-21's default
|
|
510
|
+
apply_now_requested = Signal()
|
|
511
|
+
|
|
512
|
+
# -- audio (F-67) -------------------------------------------------------
|
|
513
|
+
output_device_changed = Signal(object) # OutputDevice.key, or None
|
|
514
|
+
devices_refresh_requested = Signal()
|
|
515
|
+
volume_changed = Signal(float)
|
|
516
|
+
muted_changed = Signal(bool)
|
|
517
|
+
|
|
518
|
+
# -- reading and display (F-83, F-30, F-86) -----------------------------
|
|
519
|
+
autoplay_changed = Signal(bool)
|
|
520
|
+
follow_changed = Signal(bool)
|
|
521
|
+
#: A ``DisplayLanguage``, not its bare ``str`` value. ``Settings``
|
|
522
|
+
#: holds the enum and ``Settings.with_`` is ``dataclasses.replace``,
|
|
523
|
+
#: which coerces nothing: a ``"ko"`` stored through it turns the field
|
|
524
|
+
#: into a plain string, and every ``.value`` read on it afterwards --
|
|
525
|
+
#: ``Settings.to_dict`` when saving, and ``_render_languages`` here --
|
|
526
|
+
#: raises. F-86 would then neither persist nor retranslate.
|
|
527
|
+
display_language_changed = Signal(object)
|
|
528
|
+
|
|
529
|
+
# -- integrations (F-46, F-71, F-79) ------------------------------------
|
|
530
|
+
rest_enabled_changed = Signal(bool)
|
|
531
|
+
rest_port_changed = Signal(int)
|
|
532
|
+
mcp_enabled_changed = Signal(bool)
|
|
533
|
+
credential_days_changed = Signal(int)
|
|
534
|
+
credential_issue_requested = Signal(str, object, int) # name, capabilities, days
|
|
535
|
+
credential_capabilities_changed = Signal(str, object) # ref, capabilities
|
|
536
|
+
credential_reissue_requested = Signal(str) # ref
|
|
537
|
+
credential_revoke_requested = Signal(str) # ref
|
|
538
|
+
|
|
539
|
+
# -- storage (F-73, F-76) ------------------------------------------------
|
|
540
|
+
#: Bytes, as ``object``: Section 4.1's 100 GB maximum does not fit the
|
|
541
|
+
#: 32-bit C++ ``int`` a ``Signal(int)`` marshals through, and Qt truncates
|
|
542
|
+
#: rather than refusing -- the owner would set 100 GB and be given 1.4 GB.
|
|
543
|
+
retention_changed = Signal(object)
|
|
544
|
+
cleanup_requested = Signal()
|
|
545
|
+
storage_refresh_requested = Signal()
|
|
546
|
+
reset_requested = Signal(str) # a ResetScope value
|
|
547
|
+
|
|
548
|
+
# -- presets (F-66) --------------------------------------------------------
|
|
549
|
+
preset_apply_requested = Signal(str)
|
|
550
|
+
preset_save_requested = Signal(str)
|
|
551
|
+
preset_rename_requested = Signal(str, str)
|
|
552
|
+
preset_delete_requested = Signal(str)
|
|
553
|
+
|
|
554
|
+
# -- about (F-75) ------------------------------------------------------------
|
|
555
|
+
version_check_requested = Signal()
|
|
556
|
+
|
|
557
|
+
def __init__(
|
|
558
|
+
self,
|
|
559
|
+
palette: Palette,
|
|
560
|
+
settings: Settings,
|
|
561
|
+
*,
|
|
562
|
+
machine: Machine | None = None,
|
|
563
|
+
device_lister: Callable[[], Sequence[OutputDevice]] | None = None,
|
|
564
|
+
parent: QWidget | None = None,
|
|
565
|
+
) -> None:
|
|
566
|
+
super().__init__(parent)
|
|
567
|
+
self._palette = palette
|
|
568
|
+
self._settings = settings
|
|
569
|
+
self._machine = machine or Machine.detect()
|
|
570
|
+
self._device_lister = device_lister or audio_devices.list_output_devices
|
|
571
|
+
|
|
572
|
+
self.confirm: Callable[[str, str, str], bool] = (
|
|
573
|
+
lambda title, body, accept: _default_confirm(self, title, body, accept)
|
|
574
|
+
)
|
|
575
|
+
self.ask_text: Callable[[str, str, str], str | None] = (
|
|
576
|
+
lambda title, prompt, value: _default_ask_text(self, title, prompt, value)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
self._loading = False
|
|
580
|
+
self._translatables: list[tuple[QWidget, str, str, dict[str, Any]]] = []
|
|
581
|
+
self._applied: Budget | None = None
|
|
582
|
+
self._job_running = False
|
|
583
|
+
self._pending_resource_change = False
|
|
584
|
+
self._devices: list[OutputDevice] = []
|
|
585
|
+
self._device_error = ""
|
|
586
|
+
self._credentials: list[Credential] = []
|
|
587
|
+
# F-61 authorises the three separately, so the set a new credential
|
|
588
|
+
# gets is a choice made before issuing rather than a constant. The
|
|
589
|
+
# two an integration needs to be useful are on by default; reading
|
|
590
|
+
# the owner's retained history is not.
|
|
591
|
+
self._pending_capabilities = frozenset(
|
|
592
|
+
{Capability.GENERATE, Capability.READ_RESULTS}
|
|
593
|
+
)
|
|
594
|
+
self._sizes: StorageSizes | None = None
|
|
595
|
+
self._presets: tuple[VoicePreset, ...] = settings.presets
|
|
596
|
+
self._conflict_port: int | None = None
|
|
597
|
+
# The port the service is on, as far as this screen has been told.
|
|
598
|
+
# It is not ``self.port.value()``: that is the port the owner has
|
|
599
|
+
# *typed*, and F-79 binds nothing until they press restart.
|
|
600
|
+
self._service_port = settings.rest_port
|
|
601
|
+
self._service_port_before: int | None = None
|
|
602
|
+
self._unavailable: set[str] = set()
|
|
603
|
+
|
|
604
|
+
self.setObjectName("Root")
|
|
605
|
+
self.setWidgetResizable(True)
|
|
606
|
+
self.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
607
|
+
# Horizontal scrolling is left on rather than forced off: if a future
|
|
608
|
+
# panel does outgrow 1280x720 at 200% scaling, N-30 prefers a
|
|
609
|
+
# scrollbar to a clipped control, and the test that would then fail
|
|
610
|
+
# says so in one line.
|
|
611
|
+
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
612
|
+
|
|
613
|
+
self.content = QWidget()
|
|
614
|
+
self.content.setObjectName("Root")
|
|
615
|
+
column = QVBoxLayout(self.content)
|
|
616
|
+
m = METRICS
|
|
617
|
+
column.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
|
|
618
|
+
column.setSpacing(m.gap_wide)
|
|
619
|
+
|
|
620
|
+
title = QLabel()
|
|
621
|
+
title.setProperty("role", "title")
|
|
622
|
+
self._text(title, "Settings")
|
|
623
|
+
column.addWidget(title)
|
|
624
|
+
column.addWidget(
|
|
625
|
+
self._label("Everything EchoAct remembers between launches.", "secondary")
|
|
626
|
+
)
|
|
627
|
+
column.addWidget(self._credit_line())
|
|
628
|
+
column.addWidget(self._build_resources())
|
|
629
|
+
column.addWidget(self._build_audio())
|
|
630
|
+
column.addWidget(self._build_display())
|
|
631
|
+
column.addWidget(self._build_integrations())
|
|
632
|
+
column.addWidget(self._build_storage())
|
|
633
|
+
column.addWidget(self._build_presets())
|
|
634
|
+
column.addWidget(self._build_about())
|
|
635
|
+
column.addStretch(1)
|
|
636
|
+
self.setWidget(self.content)
|
|
637
|
+
|
|
638
|
+
self.apply(settings)
|
|
639
|
+
self.refresh_devices()
|
|
640
|
+
_follow_language(self)
|
|
641
|
+
|
|
642
|
+
# ==================================================================
|
|
643
|
+
# Small builders
|
|
644
|
+
# ==================================================================
|
|
645
|
+
|
|
646
|
+
def _panel(self, title: str) -> tuple[QFrame, QVBoxLayout]:
|
|
647
|
+
panel = QFrame()
|
|
648
|
+
panel.setObjectName("Panel")
|
|
649
|
+
panel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
|
650
|
+
box = QVBoxLayout(panel)
|
|
651
|
+
m = METRICS
|
|
652
|
+
box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
|
|
653
|
+
box.setSpacing(m.gap)
|
|
654
|
+
heading = QLabel()
|
|
655
|
+
heading.setProperty("role", "section")
|
|
656
|
+
self._text(heading, title)
|
|
657
|
+
box.addWidget(heading)
|
|
658
|
+
return panel, box
|
|
659
|
+
|
|
660
|
+
def _text(self, widget: QWidget, source: str, *, setter: str = "setText", **fmt: Any) -> None:
|
|
661
|
+
"""Set a translated string and remember how to set it again.
|
|
662
|
+
|
|
663
|
+
F-86 switches the language in place, so every literal the screen
|
|
664
|
+
shows is recorded here with the arguments it was formatted with.
|
|
665
|
+
Re-rendering from the recorded source is what keeps the switch from
|
|
666
|
+
needing the screen to be rebuilt -- rebuilding would lose the
|
|
667
|
+
scroll position and the keyboard focus the user was at.
|
|
668
|
+
"""
|
|
669
|
+
self._translatables.append((widget, setter, source, fmt))
|
|
670
|
+
getattr(widget, setter)(tr(source).format(**fmt) if fmt else tr(source))
|
|
671
|
+
|
|
672
|
+
def _label(self, source: str, role: str | None = None, **fmt: Any) -> QLabel:
|
|
673
|
+
lb = QLabel()
|
|
674
|
+
if role:
|
|
675
|
+
lb.setProperty("role", role)
|
|
676
|
+
lb.setWordWrap(True)
|
|
677
|
+
self._text(lb, source, **fmt)
|
|
678
|
+
return lb
|
|
679
|
+
|
|
680
|
+
def _credit_line(self) -> QLabel:
|
|
681
|
+
"""The author credit at the top of the screen.
|
|
682
|
+
|
|
683
|
+
Deliberately not recorded through :meth:`_text`: the name and the
|
|
684
|
+
handle are the same in every language, so recording them would only
|
|
685
|
+
give the translator a string they cannot improve. The link opens in
|
|
686
|
+
the browser rather than anywhere in-app -- EchoAct has no page to
|
|
687
|
+
show it in, and opening externally is what makes the one network
|
|
688
|
+
request it causes the user's own.
|
|
689
|
+
"""
|
|
690
|
+
lb = QLabel()
|
|
691
|
+
lb.setProperty("role", "muted")
|
|
692
|
+
lb.setWordWrap(True)
|
|
693
|
+
lb.setTextFormat(Qt.TextFormat.RichText)
|
|
694
|
+
lb.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
|
|
695
|
+
lb.setOpenExternalLinks(True)
|
|
696
|
+
lb.setText(
|
|
697
|
+
f'{AUTHOR} · <a href="{AUTHOR_GITHUB_URL}" '
|
|
698
|
+
f'style="color:{self._palette.accent};">{AUTHOR_GITHUB_LABEL}</a>'
|
|
699
|
+
)
|
|
700
|
+
lb.setAccessibleName(f"{AUTHOR} — {AUTHOR_GITHUB_URL}")
|
|
701
|
+
return lb
|
|
702
|
+
|
|
703
|
+
def _value_label(self, role: str | None = "secondary") -> QLabel:
|
|
704
|
+
lb = QLabel()
|
|
705
|
+
if role:
|
|
706
|
+
lb.setProperty("role", role)
|
|
707
|
+
lb.setWordWrap(True)
|
|
708
|
+
return lb
|
|
709
|
+
|
|
710
|
+
def _field(
|
|
711
|
+
self, box: QVBoxLayout, name: str, widget: QWidget, note: str | None = None
|
|
712
|
+
) -> None:
|
|
713
|
+
"""A caption, a control, and an optional explanation, in that order.
|
|
714
|
+
|
|
715
|
+
The caption is a separate label rather than a placeholder or a
|
|
716
|
+
tooltip because N-30 needs the control to carry an accessible name
|
|
717
|
+
that a screen reader announces without hovering.
|
|
718
|
+
"""
|
|
719
|
+
caption = self._label(name, "secondary")
|
|
720
|
+
box.addWidget(caption)
|
|
721
|
+
self._text(widget, name, setter="setAccessibleName")
|
|
722
|
+
box.addWidget(widget)
|
|
723
|
+
if note:
|
|
724
|
+
box.addWidget(self._label(note, "muted"))
|
|
725
|
+
|
|
726
|
+
def _bullet(self, source: str) -> QWidget:
|
|
727
|
+
"""A list item whose mark is not part of the translated string.
|
|
728
|
+
|
|
729
|
+
Prefixing the text with the bullet would make "· Local REST and MCP
|
|
730
|
+
integrations" the catalogue key, which no translation would ever
|
|
731
|
+
match; keeping the mark in its own label also gives the wrapped
|
|
732
|
+
lines a hanging indent.
|
|
733
|
+
"""
|
|
734
|
+
row = QWidget()
|
|
735
|
+
line = QHBoxLayout(row)
|
|
736
|
+
line.setContentsMargins(0, 0, 0, 0)
|
|
737
|
+
line.setSpacing(METRICS.gap)
|
|
738
|
+
mark = QLabel("·")
|
|
739
|
+
mark.setProperty("role", "muted")
|
|
740
|
+
mark.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
|
741
|
+
line.addWidget(mark)
|
|
742
|
+
line.addWidget(self._label(source, "muted"), 1)
|
|
743
|
+
return row
|
|
744
|
+
|
|
745
|
+
def _button(self, source: str, variant: str | None = None) -> QPushButton:
|
|
746
|
+
b = QPushButton()
|
|
747
|
+
if variant:
|
|
748
|
+
b.setProperty("variant", variant)
|
|
749
|
+
self._text(b, source)
|
|
750
|
+
self._text(b, source, setter="setAccessibleName")
|
|
751
|
+
b.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
752
|
+
return b
|
|
753
|
+
|
|
754
|
+
def _check(self, source: str, **fmt: Any) -> QCheckBox:
|
|
755
|
+
c = QCheckBox()
|
|
756
|
+
self._text(c, source, **fmt)
|
|
757
|
+
self._text(c, source, setter="setAccessibleName", **fmt)
|
|
758
|
+
return c
|
|
759
|
+
|
|
760
|
+
# ==================================================================
|
|
761
|
+
# Resources (F-20, F-21, F-78)
|
|
762
|
+
# ==================================================================
|
|
763
|
+
|
|
764
|
+
def _build_resources(self) -> QFrame:
|
|
765
|
+
panel, box = self._panel("Resources")
|
|
766
|
+
box.addWidget(
|
|
767
|
+
self._label(
|
|
768
|
+
"How much of this computer EchoAct may use while it generates speech.",
|
|
769
|
+
"secondary",
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
self.cpu = QSpinBox()
|
|
774
|
+
self.cpu.setRange(CPU_PERCENT_MIN, CPU_PERCENT_MAX)
|
|
775
|
+
self.cpu.setSingleStep(5)
|
|
776
|
+
self.cpu.setSuffix(" %")
|
|
777
|
+
self.cpu.setValue(CPU_PERCENT_DEFAULT)
|
|
778
|
+
self._field(
|
|
779
|
+
box,
|
|
780
|
+
"Processor share",
|
|
781
|
+
self.cpu,
|
|
782
|
+
"10-70% of this computer's total processor capacity. The default is 20%.",
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
ceiling = memory_ceiling_bytes(self._machine.total_ram_bytes)
|
|
786
|
+
recommended = default_memory_bytes(self._machine.total_ram_bytes)
|
|
787
|
+
box.addWidget(self._label("Memory limit", "secondary"))
|
|
788
|
+
self.memory_auto = self._check(
|
|
789
|
+
"Use the amount recommended for this computer ({size})",
|
|
790
|
+
size=memory_size(recommended),
|
|
791
|
+
)
|
|
792
|
+
self.memory = QDoubleSpinBox()
|
|
793
|
+
self.memory.setDecimals(1)
|
|
794
|
+
self.memory.setSingleStep(0.5)
|
|
795
|
+
self.memory.setSuffix(" GiB")
|
|
796
|
+
# F-23's floor is the smallest configuration that can load a model at
|
|
797
|
+
# all, so it is the bottom of the range rather than a value the user
|
|
798
|
+
# may pick and then be refused for picking. A machine whose F-21
|
|
799
|
+
# ceiling falls under that floor gets the ceiling as both ends and a
|
|
800
|
+
# plain statement, which is more honest than an empty range.
|
|
801
|
+
low = min(MEMORY_FLOOR_BYTES, ceiling)
|
|
802
|
+
self.memory.setRange(low / GIB, max(low, ceiling) / GIB)
|
|
803
|
+
box.addWidget(self.memory_auto)
|
|
804
|
+
self._text(self.memory, "Memory limit", setter="setAccessibleName")
|
|
805
|
+
box.addWidget(self.memory)
|
|
806
|
+
box.addWidget(
|
|
807
|
+
self._label(
|
|
808
|
+
"At most {ceiling} on this computer: 32 GiB, or half of installed memory, "
|
|
809
|
+
"whichever is smaller.",
|
|
810
|
+
"muted",
|
|
811
|
+
ceiling=memory_size(ceiling),
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
if ceiling < MEMORY_FLOOR_BYTES:
|
|
815
|
+
box.addWidget(
|
|
816
|
+
self._label(
|
|
817
|
+
"This computer has less memory than EchoAct needs to load a model "
|
|
818
|
+
"({floor}).",
|
|
819
|
+
"warn",
|
|
820
|
+
floor=memory_size(MEMORY_FLOOR_BYTES),
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
box.addWidget(_separator())
|
|
825
|
+
self.resources_configured = self._value_label()
|
|
826
|
+
self.resources_applied = self._value_label()
|
|
827
|
+
box.addWidget(self.resources_configured)
|
|
828
|
+
box.addWidget(self.resources_applied)
|
|
829
|
+
box.addWidget(
|
|
830
|
+
self._label(
|
|
831
|
+
"The applied memory can be lower than the configured value when the "
|
|
832
|
+
"computer has little free memory.",
|
|
833
|
+
"muted",
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
self.apply_row = QWidget()
|
|
838
|
+
row = QVBoxLayout(self.apply_row)
|
|
839
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
840
|
+
row.setSpacing(METRICS.gap_tight)
|
|
841
|
+
row.addWidget(self._label("This change applies to the next job.", "warn"))
|
|
842
|
+
buttons = QHBoxLayout()
|
|
843
|
+
buttons.setSpacing(METRICS.gap)
|
|
844
|
+
self.apply_next = self._button("Apply to the next job", "quiet")
|
|
845
|
+
self.apply_cancel = self._button("Cancel the job and apply now", "danger")
|
|
846
|
+
buttons.addWidget(self.apply_next)
|
|
847
|
+
buttons.addWidget(self.apply_cancel)
|
|
848
|
+
buttons.addStretch(1)
|
|
849
|
+
row.addLayout(buttons)
|
|
850
|
+
self.apply_row.setVisible(False)
|
|
851
|
+
box.addWidget(self.apply_row)
|
|
852
|
+
|
|
853
|
+
self.cpu.valueChanged.connect(self._on_cpu)
|
|
854
|
+
self.memory.valueChanged.connect(self._on_memory)
|
|
855
|
+
self.memory_auto.toggled.connect(self._on_memory_auto)
|
|
856
|
+
self.apply_next.clicked.connect(self._dismiss_apply_row)
|
|
857
|
+
self.apply_cancel.clicked.connect(self._on_apply_now)
|
|
858
|
+
return panel
|
|
859
|
+
|
|
860
|
+
def _on_cpu(self, value: int) -> None:
|
|
861
|
+
if self._loading:
|
|
862
|
+
return
|
|
863
|
+
self._note_resource_change()
|
|
864
|
+
self.cpu_changed.emit(int(value))
|
|
865
|
+
|
|
866
|
+
def _on_memory(self, value: float) -> None:
|
|
867
|
+
if self._loading or self.memory_auto.isChecked():
|
|
868
|
+
return
|
|
869
|
+
self._note_resource_change()
|
|
870
|
+
self.memory_changed.emit(int(round(value * GIB)))
|
|
871
|
+
|
|
872
|
+
def _on_memory_auto(self, checked: bool) -> None:
|
|
873
|
+
self.memory.setEnabled(not checked)
|
|
874
|
+
if self._loading:
|
|
875
|
+
return
|
|
876
|
+
self._note_resource_change()
|
|
877
|
+
if checked:
|
|
878
|
+
self.memory_changed.emit(None)
|
|
879
|
+
else:
|
|
880
|
+
self.memory_changed.emit(int(round(self.memory.value() * GIB)))
|
|
881
|
+
|
|
882
|
+
def _note_resource_change(self) -> None:
|
|
883
|
+
"""F-78: a change during a job is a choice between two outcomes.
|
|
884
|
+
|
|
885
|
+
The change itself has already been saved by the time this runs --
|
|
886
|
+
the signal reaches ``update_settings``, which F-78 says applies to
|
|
887
|
+
the *next* job -- so this offers the other outcome rather than
|
|
888
|
+
holding the change hostage to a decision the user may not want to
|
|
889
|
+
make now.
|
|
890
|
+
|
|
891
|
+
The two lines above the offer are re-rendered first: the window does
|
|
892
|
+
not hand the saved settings back, so the "Configured:" line would
|
|
893
|
+
otherwise keep the value the screen was opened with while the control
|
|
894
|
+
beside it shows the new one.
|
|
895
|
+
"""
|
|
896
|
+
self._render_resources()
|
|
897
|
+
if not self._job_running:
|
|
898
|
+
return
|
|
899
|
+
self._pending_resource_change = True
|
|
900
|
+
self.apply_row.setVisible(True)
|
|
901
|
+
|
|
902
|
+
def _dismiss_apply_row(self) -> None:
|
|
903
|
+
self._pending_resource_change = False
|
|
904
|
+
self.apply_row.setVisible(False)
|
|
905
|
+
|
|
906
|
+
def _on_apply_now(self) -> None:
|
|
907
|
+
if not self.confirm(
|
|
908
|
+
tr("Cancel the running job?"),
|
|
909
|
+
tr(
|
|
910
|
+
"Applying the new limits now cancels the job that is running. Audio "
|
|
911
|
+
"already generated stays available; the rest is not generated."
|
|
912
|
+
),
|
|
913
|
+
tr("Cancel the job and apply now"),
|
|
914
|
+
):
|
|
915
|
+
return
|
|
916
|
+
self._dismiss_apply_row()
|
|
917
|
+
self.apply_now_requested.emit()
|
|
918
|
+
|
|
919
|
+
def set_resource_state(self, applied: Budget | None, *, job_running: bool) -> None:
|
|
920
|
+
"""F-78's two numbers: what is set, and what the job is running under.
|
|
921
|
+
|
|
922
|
+
``applied`` is the ``Budget`` the running job recorded, not a fresh
|
|
923
|
+
resolution of the current settings -- resolving again would show the
|
|
924
|
+
user a number no job has ever used and call it "applied".
|
|
925
|
+
"""
|
|
926
|
+
self._applied = applied
|
|
927
|
+
self._job_running = job_running
|
|
928
|
+
if not job_running:
|
|
929
|
+
self._pending_resource_change = False
|
|
930
|
+
self.apply_row.setVisible(job_running and self._pending_resource_change)
|
|
931
|
+
self._render_resources()
|
|
932
|
+
|
|
933
|
+
def _configured_memory_bytes(self) -> int | None:
|
|
934
|
+
"""The memory limit the screen is offering, in bytes, or ``None`` for
|
|
935
|
+
F-21's per-machine default.
|
|
936
|
+
|
|
937
|
+
Read from the control rather than from ``self._settings`` for the same
|
|
938
|
+
reason as :meth:`_configured_view`, but with one deference to the
|
|
939
|
+
store: the box shows one decimal, so a saved 3.33 GiB reads back as
|
|
940
|
+
3.3. While the box is still showing the saved figure, the saved
|
|
941
|
+
figure *is* the configured one -- taking the rounded one instead would
|
|
942
|
+
report a difference from the running job's budget that nobody made.
|
|
943
|
+
"""
|
|
944
|
+
if self.memory_auto.isChecked():
|
|
945
|
+
return None
|
|
946
|
+
saved = self._settings.memory_bytes
|
|
947
|
+
if saved is not None and round(saved / GIB, self.memory.decimals()) == self.memory.value():
|
|
948
|
+
return saved
|
|
949
|
+
return int(round(self.memory.value() * GIB))
|
|
950
|
+
|
|
951
|
+
def _configured_view(self) -> ResourcePolicyView:
|
|
952
|
+
"""F-78's pair, with the configured half taken from the controls.
|
|
953
|
+
|
|
954
|
+
The screen owns the configured value between the edit and the save:
|
|
955
|
+
an edit leaves as a signal, the window turns it into
|
|
956
|
+
``update_settings``, and nothing hands the result back here. Reading
|
|
957
|
+
``self._settings`` for this line would therefore show the owner the
|
|
958
|
+
value the screen was opened with, and ``differs`` -- which decides
|
|
959
|
+
whether the applied line is flagged -- would be computed against that
|
|
960
|
+
same stale number and stay ``False`` exactly when it should not.
|
|
961
|
+
"""
|
|
962
|
+
return ResourcePolicyView(
|
|
963
|
+
configured_cpu_percent=int(self.cpu.value()),
|
|
964
|
+
configured_memory_bytes=self._configured_memory_bytes(),
|
|
965
|
+
applied=self._applied,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
def _render_resources(self) -> None:
|
|
969
|
+
view = self._configured_view()
|
|
970
|
+
configured_memory = (
|
|
971
|
+
memory_size(view.configured_memory_bytes)
|
|
972
|
+
if view.configured_memory_bytes is not None
|
|
973
|
+
else memory_size(default_memory_bytes(self._machine.total_ram_bytes))
|
|
974
|
+
)
|
|
975
|
+
self.resources_configured.setText(
|
|
976
|
+
tr("Configured: {cpu}% processor, {memory} memory").format(
|
|
977
|
+
cpu=view.configured_cpu_percent, memory=configured_memory
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
if self._job_running and self._applied is not None:
|
|
981
|
+
self.resources_applied.setText(
|
|
982
|
+
tr("Applied to the job running now: {cpu}% processor, {memory} memory").format(
|
|
983
|
+
cpu=self._applied.cpu_percent,
|
|
984
|
+
memory=memory_size(self._applied.memory_bytes),
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
self.resources_applied.setProperty("role", "warn" if view.differs else "secondary")
|
|
988
|
+
else:
|
|
989
|
+
self.resources_applied.setText(
|
|
990
|
+
tr("No job is running. These values apply to the next job.")
|
|
991
|
+
)
|
|
992
|
+
self.resources_applied.setProperty("role", "secondary")
|
|
993
|
+
_repolish(self.resources_applied)
|
|
994
|
+
|
|
995
|
+
# ==================================================================
|
|
996
|
+
# Audio (F-67)
|
|
997
|
+
# ==================================================================
|
|
998
|
+
|
|
999
|
+
def _build_audio(self) -> QFrame:
|
|
1000
|
+
panel, box = self._panel("Audio")
|
|
1001
|
+
|
|
1002
|
+
self.device = QComboBox()
|
|
1003
|
+
self._field(box, "Output device", self.device)
|
|
1004
|
+
self.device_note = self._value_label("warn")
|
|
1005
|
+
self.device_note.setVisible(False)
|
|
1006
|
+
box.addWidget(self.device_note)
|
|
1007
|
+
self.device_refresh = self._button("Look for devices again", "quiet")
|
|
1008
|
+
refresh_row = QHBoxLayout()
|
|
1009
|
+
refresh_row.addWidget(self.device_refresh)
|
|
1010
|
+
refresh_row.addStretch(1)
|
|
1011
|
+
box.addLayout(refresh_row)
|
|
1012
|
+
|
|
1013
|
+
self.volume = QSlider(Qt.Orientation.Horizontal)
|
|
1014
|
+
self.volume.setRange(0, 100)
|
|
1015
|
+
self.volume.setSingleStep(5)
|
|
1016
|
+
self.volume.setPageStep(10)
|
|
1017
|
+
self.volume.setValue(100)
|
|
1018
|
+
volume_row = QHBoxLayout()
|
|
1019
|
+
volume_row.setSpacing(METRICS.gap)
|
|
1020
|
+
volume_row.addWidget(self._label("Volume", "secondary"))
|
|
1021
|
+
volume_row.addStretch(1)
|
|
1022
|
+
self.volume_value = self._value_label()
|
|
1023
|
+
volume_row.addWidget(self.volume_value)
|
|
1024
|
+
box.addLayout(volume_row)
|
|
1025
|
+
self._text(self.volume, "Volume", setter="setAccessibleName")
|
|
1026
|
+
box.addWidget(self.volume)
|
|
1027
|
+
|
|
1028
|
+
self.muted = self._check("Mute")
|
|
1029
|
+
box.addWidget(self.muted)
|
|
1030
|
+
box.addWidget(
|
|
1031
|
+
self._label(
|
|
1032
|
+
"Volume changes what you hear now. It does not change the saved WAV file.",
|
|
1033
|
+
"muted",
|
|
1034
|
+
)
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
self.device.currentIndexChanged.connect(self._on_device)
|
|
1038
|
+
self.device_refresh.clicked.connect(self._on_device_refresh)
|
|
1039
|
+
self.volume.valueChanged.connect(self._on_volume)
|
|
1040
|
+
self.muted.toggled.connect(self._on_muted)
|
|
1041
|
+
return panel
|
|
1042
|
+
|
|
1043
|
+
def _on_device_refresh(self) -> None:
|
|
1044
|
+
self.refresh_devices()
|
|
1045
|
+
self.devices_refresh_requested.emit()
|
|
1046
|
+
|
|
1047
|
+
def refresh_devices(self) -> None:
|
|
1048
|
+
"""Re-read the device list (F-67, F-68).
|
|
1049
|
+
|
|
1050
|
+
Enumeration failure is a state of the screen, not an exception:
|
|
1051
|
+
F-79's spirit -- a subsystem being unavailable never takes the rest
|
|
1052
|
+
of the app with it -- applies just as well to the audio system, and
|
|
1053
|
+
the user still has every other setting to change.
|
|
1054
|
+
"""
|
|
1055
|
+
try:
|
|
1056
|
+
self._devices = list(self._device_lister())
|
|
1057
|
+
self._device_error = ""
|
|
1058
|
+
except EchoActError as exc:
|
|
1059
|
+
self._devices = []
|
|
1060
|
+
self._device_error = exc.message
|
|
1061
|
+
self._render_devices()
|
|
1062
|
+
|
|
1063
|
+
def _render_devices(self) -> None:
|
|
1064
|
+
was = self._loading
|
|
1065
|
+
self._loading = True
|
|
1066
|
+
try:
|
|
1067
|
+
self.device.clear()
|
|
1068
|
+
self.device.addItem(tr("System default output"), None)
|
|
1069
|
+
keys = set()
|
|
1070
|
+
for d in self._devices:
|
|
1071
|
+
self.device.addItem(f"{d.name} — {d.host_api}", d.key)
|
|
1072
|
+
keys.add(d.key)
|
|
1073
|
+
chosen = self._settings.output_device
|
|
1074
|
+
if chosen is not None and chosen not in keys:
|
|
1075
|
+
# F-67 forbids moving to another speaker without confirmation,
|
|
1076
|
+
# so a remembered device that is not present stays selected and
|
|
1077
|
+
# is labelled. Dropping it from the list would silently reselect
|
|
1078
|
+
# the system default, which is that same unconfirmed switch.
|
|
1079
|
+
self.device.addItem(
|
|
1080
|
+
tr("{name} (not connected)").format(name=chosen.split("::", 1)[-1]), chosen
|
|
1081
|
+
)
|
|
1082
|
+
index = self.device.findData(chosen)
|
|
1083
|
+
self.device.setCurrentIndex(max(0, index))
|
|
1084
|
+
self.device_note.setVisible(bool(self._device_error))
|
|
1085
|
+
if self._device_error:
|
|
1086
|
+
self.device_note.setText(
|
|
1087
|
+
tr("The audio system could not be listed: {reason}").format(
|
|
1088
|
+
reason=self._device_error
|
|
1089
|
+
)
|
|
1090
|
+
)
|
|
1091
|
+
finally:
|
|
1092
|
+
self._loading = was
|
|
1093
|
+
|
|
1094
|
+
def _on_device(self, _index: int) -> None:
|
|
1095
|
+
if self._loading:
|
|
1096
|
+
return
|
|
1097
|
+
self.output_device_changed.emit(self.device.currentData())
|
|
1098
|
+
|
|
1099
|
+
def _on_volume(self, value: int) -> None:
|
|
1100
|
+
self.volume_value.setText(f"{value}%")
|
|
1101
|
+
if self._loading:
|
|
1102
|
+
return
|
|
1103
|
+
self.volume_changed.emit(value / 100)
|
|
1104
|
+
|
|
1105
|
+
def _on_muted(self, checked: bool) -> None:
|
|
1106
|
+
if self._loading:
|
|
1107
|
+
return
|
|
1108
|
+
self.muted_changed.emit(bool(checked))
|
|
1109
|
+
|
|
1110
|
+
# ==================================================================
|
|
1111
|
+
# Reading and display (F-83, F-30, F-86)
|
|
1112
|
+
# ==================================================================
|
|
1113
|
+
|
|
1114
|
+
def _build_display(self) -> QFrame:
|
|
1115
|
+
panel, box = self._panel("Reading and display")
|
|
1116
|
+
|
|
1117
|
+
self.language = QComboBox()
|
|
1118
|
+
self._field(box, "Display language", self.language)
|
|
1119
|
+
box.addWidget(
|
|
1120
|
+
self._label(
|
|
1121
|
+
"Changing the display language does not change your documents, saved "
|
|
1122
|
+
"jobs, or what the local service answers.",
|
|
1123
|
+
"muted",
|
|
1124
|
+
)
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
box.addWidget(_separator())
|
|
1128
|
+
self.autoplay = self._check("Play as soon as ready")
|
|
1129
|
+
self.follow = self._check("Follow the reading position")
|
|
1130
|
+
box.addWidget(self.autoplay)
|
|
1131
|
+
box.addWidget(self.follow)
|
|
1132
|
+
|
|
1133
|
+
self._render_languages()
|
|
1134
|
+
self.language.currentIndexChanged.connect(self._on_language)
|
|
1135
|
+
self.autoplay.toggled.connect(self._on_autoplay)
|
|
1136
|
+
self.follow.toggled.connect(self._on_follow)
|
|
1137
|
+
return panel
|
|
1138
|
+
|
|
1139
|
+
def _render_languages(self) -> None:
|
|
1140
|
+
was = self._loading
|
|
1141
|
+
self._loading = True
|
|
1142
|
+
try:
|
|
1143
|
+
self.language.clear()
|
|
1144
|
+
self.language.addItem(tr("Korean"), DisplayLanguage.KO.value)
|
|
1145
|
+
self.language.addItem(tr("English"), DisplayLanguage.EN.value)
|
|
1146
|
+
index = self.language.findData(self._settings.display_language.value)
|
|
1147
|
+
self.language.setCurrentIndex(max(0, index))
|
|
1148
|
+
finally:
|
|
1149
|
+
self._loading = was
|
|
1150
|
+
|
|
1151
|
+
def _on_language(self, _index: int) -> None:
|
|
1152
|
+
if self._loading:
|
|
1153
|
+
return
|
|
1154
|
+
# The combo carries the plain value because Qt marshals a ``str``
|
|
1155
|
+
# subclass into the item data as a ``str``; the enum is rebuilt here so
|
|
1156
|
+
# that what leaves the screen is what ``Settings.display_language``
|
|
1157
|
+
# holds, per this module's contract.
|
|
1158
|
+
self.display_language_changed.emit(DisplayLanguage(str(self.language.currentData())))
|
|
1159
|
+
|
|
1160
|
+
def _on_autoplay(self, checked: bool) -> None:
|
|
1161
|
+
if not self._loading:
|
|
1162
|
+
self.autoplay_changed.emit(bool(checked))
|
|
1163
|
+
|
|
1164
|
+
def _on_follow(self, checked: bool) -> None:
|
|
1165
|
+
if not self._loading:
|
|
1166
|
+
self.follow_changed.emit(bool(checked))
|
|
1167
|
+
|
|
1168
|
+
# ==================================================================
|
|
1169
|
+
# Integrations (F-46, F-71, F-79, N-31, 4.1)
|
|
1170
|
+
# ==================================================================
|
|
1171
|
+
|
|
1172
|
+
def _build_integrations(self) -> QFrame:
|
|
1173
|
+
panel, box = self._panel("Integrations")
|
|
1174
|
+
|
|
1175
|
+
self.rest = self._check("Local REST service")
|
|
1176
|
+
box.addWidget(self.rest)
|
|
1177
|
+
self.rest_note = self._value_label("muted")
|
|
1178
|
+
box.addWidget(self.rest_note)
|
|
1179
|
+
self.rest_how = self._value_label("muted")
|
|
1180
|
+
box.addWidget(self.rest_how)
|
|
1181
|
+
# F-46 wants the consequence stated at the point of the change, so it
|
|
1182
|
+
# is standing text beside the switch and not only in the confirmation
|
|
1183
|
+
# the user sees after deciding.
|
|
1184
|
+
box.addWidget(
|
|
1185
|
+
self._label(
|
|
1186
|
+
"MCP works through this service, so turning it off also makes MCP "
|
|
1187
|
+
"unavailable.",
|
|
1188
|
+
"muted",
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
self.port = QSpinBox()
|
|
1193
|
+
self.port.setRange(MIN_USER_PORT, MAX_PORT)
|
|
1194
|
+
self.port.setGroupSeparatorShown(False)
|
|
1195
|
+
self._field(box, "Port", self.port)
|
|
1196
|
+
self.port_restart = self._button("Restart the service on this port")
|
|
1197
|
+
port_row = QHBoxLayout()
|
|
1198
|
+
port_row.addWidget(self.port_restart)
|
|
1199
|
+
port_row.addStretch(1)
|
|
1200
|
+
box.addLayout(port_row)
|
|
1201
|
+
|
|
1202
|
+
self.conflict = QWidget()
|
|
1203
|
+
conflict_box = QVBoxLayout(self.conflict)
|
|
1204
|
+
conflict_box.setContentsMargins(0, 0, 0, 0)
|
|
1205
|
+
conflict_box.setSpacing(METRICS.gap_tight)
|
|
1206
|
+
self.conflict_text = self._value_label("warn")
|
|
1207
|
+
conflict_box.addWidget(self.conflict_text)
|
|
1208
|
+
self.conflict_use = self._button("Use a different port")
|
|
1209
|
+
conflict_row = QHBoxLayout()
|
|
1210
|
+
conflict_row.addWidget(self.conflict_use)
|
|
1211
|
+
conflict_row.addStretch(1)
|
|
1212
|
+
conflict_box.addLayout(conflict_row)
|
|
1213
|
+
self.conflict.setVisible(False)
|
|
1214
|
+
box.addWidget(self.conflict)
|
|
1215
|
+
|
|
1216
|
+
box.addWidget(_separator())
|
|
1217
|
+
self.mcp = self._check("MCP server")
|
|
1218
|
+
box.addWidget(self.mcp)
|
|
1219
|
+
box.addWidget(
|
|
1220
|
+
self._label(
|
|
1221
|
+
"Off by default. It exposes nothing the REST service does not already "
|
|
1222
|
+
"expose.",
|
|
1223
|
+
"muted",
|
|
1224
|
+
)
|
|
1225
|
+
)
|
|
1226
|
+
box.addWidget(
|
|
1227
|
+
self._label(
|
|
1228
|
+
"Started by your MCP client over stdio, using its own REST credential.",
|
|
1229
|
+
"muted",
|
|
1230
|
+
)
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
box.addWidget(_separator())
|
|
1234
|
+
self.credential_days = QSpinBox()
|
|
1235
|
+
self.credential_days.setRange(CREDENTIAL_DAYS_MIN, CREDENTIAL_DAYS_MAX)
|
|
1236
|
+
self.credential_days.setSuffix(" " + tr("days"))
|
|
1237
|
+
self._field(box, "New credentials expire after", self.credential_days)
|
|
1238
|
+
|
|
1239
|
+
box.addWidget(self._label("Clients", "section"))
|
|
1240
|
+
self.clients = QTreeWidget()
|
|
1241
|
+
self.clients.setRootIsDecorated(False)
|
|
1242
|
+
self.clients.setColumnCount(4)
|
|
1243
|
+
self.clients.setUniformRowHeights(True)
|
|
1244
|
+
self.clients.setMinimumHeight(120)
|
|
1245
|
+
self.clients.setMinimumWidth(280)
|
|
1246
|
+
self._text(self.clients, "Clients", setter="setAccessibleName")
|
|
1247
|
+
box.addWidget(self.clients)
|
|
1248
|
+
self.clients_empty = self._label("No clients yet.", "muted")
|
|
1249
|
+
box.addWidget(self.clients_empty)
|
|
1250
|
+
self.expiry_warning = self._value_label("warn")
|
|
1251
|
+
self.expiry_warning.setVisible(False)
|
|
1252
|
+
box.addWidget(self.expiry_warning)
|
|
1253
|
+
|
|
1254
|
+
self.permission_heading = self._value_label("secondary")
|
|
1255
|
+
box.addWidget(self.permission_heading)
|
|
1256
|
+
self.permission_boxes: dict[Capability, QCheckBox] = {}
|
|
1257
|
+
permission_row = QHBoxLayout()
|
|
1258
|
+
permission_row.setSpacing(METRICS.gap)
|
|
1259
|
+
for capability in (
|
|
1260
|
+
Capability.GENERATE,
|
|
1261
|
+
Capability.READ_RESULTS,
|
|
1262
|
+
Capability.READ_HISTORY,
|
|
1263
|
+
):
|
|
1264
|
+
check = self._check(_CAPABILITY_TEXT[capability])
|
|
1265
|
+
check.toggled.connect(lambda checked, c=capability: self._on_capability(c, checked))
|
|
1266
|
+
self.permission_boxes[capability] = check
|
|
1267
|
+
permission_row.addWidget(check)
|
|
1268
|
+
permission_row.addStretch(1)
|
|
1269
|
+
box.addLayout(permission_row)
|
|
1270
|
+
|
|
1271
|
+
client_buttons = QHBoxLayout()
|
|
1272
|
+
client_buttons.setSpacing(METRICS.gap)
|
|
1273
|
+
self.issue = self._button("Issue a credential")
|
|
1274
|
+
self.reissue = self._button("Reissue")
|
|
1275
|
+
self.revoke = self._button("Revoke", "danger")
|
|
1276
|
+
for b in (self.issue, self.reissue, self.revoke):
|
|
1277
|
+
client_buttons.addWidget(b)
|
|
1278
|
+
client_buttons.addStretch(1)
|
|
1279
|
+
box.addLayout(client_buttons)
|
|
1280
|
+
|
|
1281
|
+
self.secret = QFrame()
|
|
1282
|
+
self.secret.setObjectName("Panel")
|
|
1283
|
+
secret_box = QVBoxLayout(self.secret)
|
|
1284
|
+
secret_box.setContentsMargins(METRICS.pad, METRICS.pad, METRICS.pad, METRICS.pad)
|
|
1285
|
+
secret_box.setSpacing(METRICS.gap_tight)
|
|
1286
|
+
secret_box.addWidget(self._label("Copy this credential now. It is shown once.", "warn"))
|
|
1287
|
+
self.secret_value = QLineEdit()
|
|
1288
|
+
self.secret_value.setReadOnly(True)
|
|
1289
|
+
self._text(self.secret_value, "Copy this credential now. It is shown once.",
|
|
1290
|
+
setter="setAccessibleName")
|
|
1291
|
+
secret_box.addWidget(self.secret_value)
|
|
1292
|
+
secret_row = QHBoxLayout()
|
|
1293
|
+
secret_row.setSpacing(METRICS.gap)
|
|
1294
|
+
self.secret_copy = self._button("Copy")
|
|
1295
|
+
self.secret_copy.setIcon(icons.icon("copy", self._palette.text_secondary))
|
|
1296
|
+
self.secret_done = self._button("Done", "quiet")
|
|
1297
|
+
secret_row.addWidget(self.secret_copy)
|
|
1298
|
+
secret_row.addWidget(self.secret_done)
|
|
1299
|
+
secret_row.addStretch(1)
|
|
1300
|
+
secret_box.addLayout(secret_row)
|
|
1301
|
+
secret_box.addWidget(
|
|
1302
|
+
self._label(
|
|
1303
|
+
"EchoAct keeps only a verifier, from which the credential cannot be "
|
|
1304
|
+
"recovered. If it is lost, reissue it.",
|
|
1305
|
+
"muted",
|
|
1306
|
+
)
|
|
1307
|
+
)
|
|
1308
|
+
self.secret.setVisible(False)
|
|
1309
|
+
box.addWidget(self.secret)
|
|
1310
|
+
|
|
1311
|
+
self._render_client_columns()
|
|
1312
|
+
# Without this the three permission boxes would start blank while the
|
|
1313
|
+
# next credential really would carry two grants, and the reissue and
|
|
1314
|
+
# revoke buttons would look available with nothing selected (N-09).
|
|
1315
|
+
self._render_client_buttons()
|
|
1316
|
+
self.rest.toggled.connect(self._on_rest)
|
|
1317
|
+
self.port_restart.clicked.connect(self._on_port_restart)
|
|
1318
|
+
self.conflict_use.clicked.connect(self._on_use_offered_port)
|
|
1319
|
+
self.mcp.toggled.connect(self._on_mcp)
|
|
1320
|
+
self.credential_days.valueChanged.connect(self._on_credential_days)
|
|
1321
|
+
self.issue.clicked.connect(self._on_issue)
|
|
1322
|
+
self.reissue.clicked.connect(self._on_reissue)
|
|
1323
|
+
self.revoke.clicked.connect(self._on_revoke)
|
|
1324
|
+
self.secret_copy.clicked.connect(self._on_copy_secret)
|
|
1325
|
+
self.secret_done.clicked.connect(self.dismiss_new_credential)
|
|
1326
|
+
self.clients.currentItemChanged.connect(lambda *_: self._render_client_buttons())
|
|
1327
|
+
return panel
|
|
1328
|
+
|
|
1329
|
+
def _render_client_columns(self) -> None:
|
|
1330
|
+
self.clients.setHeaderLabels(
|
|
1331
|
+
[tr("Name"), tr("Permissions"), tr("Last access"), tr("Expires")]
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
def _on_rest(self, checked: bool) -> None:
|
|
1335
|
+
if self._loading:
|
|
1336
|
+
return
|
|
1337
|
+
if not checked and self.mcp.isChecked():
|
|
1338
|
+
# The consequence is confirmed only when it actually bites: with
|
|
1339
|
+
# MCP already off, turning REST off costs the user nothing MCP
|
|
1340
|
+
# related, and a dialog there would train them to dismiss this one.
|
|
1341
|
+
if not self.confirm(
|
|
1342
|
+
tr("Turn the local service off?"),
|
|
1343
|
+
tr(
|
|
1344
|
+
"MCP is a client of this service, so turning it off makes MCP "
|
|
1345
|
+
"unavailable too. Generation, playback, and the library keep working."
|
|
1346
|
+
),
|
|
1347
|
+
tr("Turn off"),
|
|
1348
|
+
):
|
|
1349
|
+
self._set_checked(self.rest, True)
|
|
1350
|
+
return
|
|
1351
|
+
self.rest_enabled_changed.emit(bool(checked))
|
|
1352
|
+
self._render_service()
|
|
1353
|
+
|
|
1354
|
+
def _on_port_restart(self) -> None:
|
|
1355
|
+
self.conflict.setVisible(False)
|
|
1356
|
+
self._request_port(int(self.port.value()))
|
|
1357
|
+
|
|
1358
|
+
def _request_port(self, port: int) -> None:
|
|
1359
|
+
"""Ask for the service to move, and say so (F-79, F-46, N-31).
|
|
1360
|
+
|
|
1361
|
+
The disclosure moves with the request rather than with the typing,
|
|
1362
|
+
and :meth:`show_port_conflict` moves it back if the bind was refused
|
|
1363
|
+
-- those two are the only things this screen is ever told about where
|
|
1364
|
+
the service is.
|
|
1365
|
+
"""
|
|
1366
|
+
self._service_port_before = self._service_port
|
|
1367
|
+
self._service_port = port
|
|
1368
|
+
self._render_service()
|
|
1369
|
+
self.rest_port_changed.emit(port)
|
|
1370
|
+
|
|
1371
|
+
def _on_use_offered_port(self) -> None:
|
|
1372
|
+
"""F-79: the alternative port is offered, and taking it is an act.
|
|
1373
|
+
|
|
1374
|
+
The port is not bound until the owner presses this, which is the
|
|
1375
|
+
difference between offering an alternative and choosing one -- the
|
|
1376
|
+
latter is what F-79 forbids.
|
|
1377
|
+
"""
|
|
1378
|
+
if self._conflict_port is None:
|
|
1379
|
+
return
|
|
1380
|
+
chosen = self._conflict_port
|
|
1381
|
+
self._set_value(self.port, chosen)
|
|
1382
|
+
self.conflict.setVisible(False)
|
|
1383
|
+
self._request_port(int(chosen))
|
|
1384
|
+
|
|
1385
|
+
def show_port_conflict(self, port: int, suggestion: int | None = None) -> None:
|
|
1386
|
+
"""Report that the port is occupied and offer another one (F-79).
|
|
1387
|
+
|
|
1388
|
+
A refused bind is also the only way this screen learns that the port
|
|
1389
|
+
it just asked for is not the one in service, so the disclosure above
|
|
1390
|
+
goes back to the port that is.
|
|
1391
|
+
"""
|
|
1392
|
+
if self._service_port == port and self._service_port_before is not None:
|
|
1393
|
+
self._service_port = self._service_port_before
|
|
1394
|
+
self._render_service()
|
|
1395
|
+
self._service_port_before = None
|
|
1396
|
+
self._conflict_port = suggestion or _next_port(port)
|
|
1397
|
+
self.conflict_text.setText(
|
|
1398
|
+
tr(
|
|
1399
|
+
"Port {port} is in use by another program. EchoAct does not close it "
|
|
1400
|
+
"and does not pick another port on its own."
|
|
1401
|
+
).format(port=port)
|
|
1402
|
+
)
|
|
1403
|
+
text = tr("Use port {port} instead").format(port=self._conflict_port)
|
|
1404
|
+
self.conflict_use.setText(text)
|
|
1405
|
+
self.conflict_use.setAccessibleName(text)
|
|
1406
|
+
self.conflict.setVisible(True)
|
|
1407
|
+
|
|
1408
|
+
def clear_port_conflict(self) -> None:
|
|
1409
|
+
self._conflict_port = None
|
|
1410
|
+
self.conflict.setVisible(False)
|
|
1411
|
+
|
|
1412
|
+
def _on_mcp(self, checked: bool) -> None:
|
|
1413
|
+
if not self._loading:
|
|
1414
|
+
self.mcp_enabled_changed.emit(bool(checked))
|
|
1415
|
+
|
|
1416
|
+
def _on_credential_days(self, value: int) -> None:
|
|
1417
|
+
if not self._loading:
|
|
1418
|
+
self.credential_days_changed.emit(int(value))
|
|
1419
|
+
|
|
1420
|
+
def _on_issue(self) -> None:
|
|
1421
|
+
name = self.ask_text(
|
|
1422
|
+
tr("Name this client"),
|
|
1423
|
+
tr("What is connecting? For example, an editor or a script."),
|
|
1424
|
+
"",
|
|
1425
|
+
)
|
|
1426
|
+
if not name or not name.strip():
|
|
1427
|
+
return
|
|
1428
|
+
self.credential_issue_requested.emit(
|
|
1429
|
+
name.strip(), self._pending_capabilities, int(self.credential_days.value())
|
|
1430
|
+
)
|
|
1431
|
+
|
|
1432
|
+
def _selected_credential(self) -> Credential | None:
|
|
1433
|
+
item = self.clients.currentItem()
|
|
1434
|
+
if item is None:
|
|
1435
|
+
return None
|
|
1436
|
+
ref = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1437
|
+
for cred in self._credentials:
|
|
1438
|
+
if cred.ref == ref:
|
|
1439
|
+
return cred
|
|
1440
|
+
return None
|
|
1441
|
+
|
|
1442
|
+
def _on_reissue(self) -> None:
|
|
1443
|
+
cred = self._selected_credential()
|
|
1444
|
+
if cred is None:
|
|
1445
|
+
return
|
|
1446
|
+
if not self.confirm(
|
|
1447
|
+
tr("Reissue this credential?"),
|
|
1448
|
+
tr(
|
|
1449
|
+
"The current credential for {name} stops working at once. The new one "
|
|
1450
|
+
"is shown once, here."
|
|
1451
|
+
).format(name=cred.name),
|
|
1452
|
+
tr("Reissue"),
|
|
1453
|
+
):
|
|
1454
|
+
return
|
|
1455
|
+
self.credential_reissue_requested.emit(cred.ref)
|
|
1456
|
+
|
|
1457
|
+
def _on_revoke(self) -> None:
|
|
1458
|
+
cred = self._selected_credential()
|
|
1459
|
+
if cred is None:
|
|
1460
|
+
return
|
|
1461
|
+
if not self.confirm(
|
|
1462
|
+
tr("Revoke this credential?"),
|
|
1463
|
+
tr(
|
|
1464
|
+
"{name} loses access immediately, including to results it already "
|
|
1465
|
+
"generated, and its running job is canceled."
|
|
1466
|
+
).format(name=cred.name),
|
|
1467
|
+
tr("Revoke"),
|
|
1468
|
+
):
|
|
1469
|
+
return
|
|
1470
|
+
self.credential_revoke_requested.emit(cred.ref)
|
|
1471
|
+
|
|
1472
|
+
def show_new_credential(self, token: str) -> None:
|
|
1473
|
+
"""F-71: the credential exists on screen once, and is copyable.
|
|
1474
|
+
|
|
1475
|
+
The token is put in a read-only line edit rather than a label so the
|
|
1476
|
+
keyboard can select it; nothing here writes it anywhere, and
|
|
1477
|
+
:meth:`dismiss_new_credential` clears the widget so it does not
|
|
1478
|
+
survive in the screen's state either.
|
|
1479
|
+
"""
|
|
1480
|
+
self.secret_value.setText(token)
|
|
1481
|
+
self.secret.setVisible(True)
|
|
1482
|
+
self.secret_value.setFocus()
|
|
1483
|
+
self.secret_value.selectAll()
|
|
1484
|
+
|
|
1485
|
+
def dismiss_new_credential(self) -> None:
|
|
1486
|
+
self.secret_value.clear()
|
|
1487
|
+
self.secret.setVisible(False)
|
|
1488
|
+
|
|
1489
|
+
def _on_copy_secret(self) -> None:
|
|
1490
|
+
self.secret_value.selectAll()
|
|
1491
|
+
self.secret_value.copy()
|
|
1492
|
+
|
|
1493
|
+
def set_credentials(
|
|
1494
|
+
self, credentials: Iterable[Credential], *, now: float | None = None
|
|
1495
|
+
) -> None:
|
|
1496
|
+
"""F-71's list: name, capabilities, last access, expiry."""
|
|
1497
|
+
self._credentials = list(credentials)
|
|
1498
|
+
self._render_credentials(now)
|
|
1499
|
+
|
|
1500
|
+
def _render_credentials(self, now: float | None = None) -> None:
|
|
1501
|
+
selected = self.clients.currentItem()
|
|
1502
|
+
keep = selected.data(0, Qt.ItemDataRole.UserRole) if selected is not None else None
|
|
1503
|
+
self.clients.clear()
|
|
1504
|
+
for cred in self._credentials:
|
|
1505
|
+
item = QTreeWidgetItem(
|
|
1506
|
+
[
|
|
1507
|
+
cred.name,
|
|
1508
|
+
", ".join(
|
|
1509
|
+
tr(_CAPABILITY_TEXT[c])
|
|
1510
|
+
for c in sorted(cred.capabilities, key=lambda c: c.value)
|
|
1511
|
+
if c in _CAPABILITY_TEXT
|
|
1512
|
+
),
|
|
1513
|
+
_date_time(cred.last_access_at) or tr("never"),
|
|
1514
|
+
_expiry_text(cred, now),
|
|
1515
|
+
]
|
|
1516
|
+
)
|
|
1517
|
+
item.setData(0, Qt.ItemDataRole.UserRole, cred.ref)
|
|
1518
|
+
self.clients.addTopLevelItem(item)
|
|
1519
|
+
if cred.ref == keep:
|
|
1520
|
+
self.clients.setCurrentItem(item)
|
|
1521
|
+
for column in range(4):
|
|
1522
|
+
self.clients.resizeColumnToContents(column)
|
|
1523
|
+
self.clients.setVisible(bool(self._credentials))
|
|
1524
|
+
self.clients_empty.setVisible(not self._credentials)
|
|
1525
|
+
|
|
1526
|
+
warned = [c for c in self._credentials if c.expiry_warning(now)]
|
|
1527
|
+
self.expiry_warning.setVisible(bool(warned))
|
|
1528
|
+
if warned:
|
|
1529
|
+
first = warned[0]
|
|
1530
|
+
self.expiry_warning.setText(
|
|
1531
|
+
tr("A credential for {name} expires in {days} days. Reissue it before then.")
|
|
1532
|
+
.format(name=first.name, days=max(0, int(first.days_until_expiry(now))))
|
|
1533
|
+
)
|
|
1534
|
+
self._render_client_buttons()
|
|
1535
|
+
|
|
1536
|
+
def _render_client_buttons(self) -> None:
|
|
1537
|
+
cred = self._selected_credential()
|
|
1538
|
+
self.reissue.setEnabled(cred is not None and cred.revoked_at is None)
|
|
1539
|
+
self.revoke.setEnabled(cred is not None and cred.revoked_at is None)
|
|
1540
|
+
self._render_permissions()
|
|
1541
|
+
|
|
1542
|
+
def _render_permissions(self) -> None:
|
|
1543
|
+
"""F-61's three grants, for the selected client or for the next one.
|
|
1544
|
+
|
|
1545
|
+
One group of checkboxes serves both, because they are the same three
|
|
1546
|
+
permissions either way; the heading above says which set a tick
|
|
1547
|
+
changes, so the owner is never guessing whose permissions they are
|
|
1548
|
+
editing.
|
|
1549
|
+
"""
|
|
1550
|
+
cred = self._selected_credential()
|
|
1551
|
+
if cred is None:
|
|
1552
|
+
self.permission_heading.setText(tr("Permissions for the next credential"))
|
|
1553
|
+
chosen: frozenset[Capability] = self._pending_capabilities
|
|
1554
|
+
editable = True
|
|
1555
|
+
else:
|
|
1556
|
+
self.permission_heading.setText(
|
|
1557
|
+
tr("Permissions for {name}").format(name=cred.name)
|
|
1558
|
+
)
|
|
1559
|
+
chosen = cred.effective_capabilities
|
|
1560
|
+
# An owner credential is the app's own, and narrowing it here
|
|
1561
|
+
# would lock the owner out of their own service; a revoked one
|
|
1562
|
+
# has no permissions left to change.
|
|
1563
|
+
editable = not cred.is_owner and cred.revoked_at is None
|
|
1564
|
+
was, self._loading = self._loading, True
|
|
1565
|
+
try:
|
|
1566
|
+
for capability, check in self.permission_boxes.items():
|
|
1567
|
+
check.setChecked(capability in chosen)
|
|
1568
|
+
check.setEnabled(editable)
|
|
1569
|
+
finally:
|
|
1570
|
+
self._loading = was
|
|
1571
|
+
|
|
1572
|
+
def _on_capability(self, _capability: Capability, _checked: bool) -> None:
|
|
1573
|
+
if self._loading:
|
|
1574
|
+
return
|
|
1575
|
+
chosen = frozenset(c for c, box in self.permission_boxes.items() if box.isChecked())
|
|
1576
|
+
cred = self._selected_credential()
|
|
1577
|
+
if cred is None:
|
|
1578
|
+
self._pending_capabilities = chosen
|
|
1579
|
+
return
|
|
1580
|
+
# F-71: the change takes effect immediately, which is the store's to
|
|
1581
|
+
# do -- and 5.3 may require that client's jobs to be cancelled with
|
|
1582
|
+
# it, which only the window can arrange.
|
|
1583
|
+
self.credential_capabilities_changed.emit(cred.ref, chosen)
|
|
1584
|
+
|
|
1585
|
+
def _render_service(self) -> None:
|
|
1586
|
+
self.rest_note.setText(
|
|
1587
|
+
tr(
|
|
1588
|
+
"Listening on {host}:{port} — this computer only. Every request needs a "
|
|
1589
|
+
"credential."
|
|
1590
|
+
).format(host=REST_HOST, port=self._service_port)
|
|
1591
|
+
)
|
|
1592
|
+
# F-46 lists the connection method among what must be reviewable, and
|
|
1593
|
+
# for a REST client the base URL is the whole of it.
|
|
1594
|
+
self.rest_how.setText(
|
|
1595
|
+
tr(
|
|
1596
|
+
"Clients connect to {url} with their credential. Nothing else on the "
|
|
1597
|
+
"network can reach it."
|
|
1598
|
+
).format(url=f"http://{REST_HOST}:{self._service_port}{API_PREFIX}")
|
|
1599
|
+
)
|
|
1600
|
+
on = self.rest.isChecked()
|
|
1601
|
+
self.port.setEnabled(on)
|
|
1602
|
+
self.port_restart.setEnabled(on)
|
|
1603
|
+
# F-58: MCP is a REST client, so it cannot be enabled while REST is off.
|
|
1604
|
+
self.mcp.setEnabled(on)
|
|
1605
|
+
self.issue.setEnabled(on)
|
|
1606
|
+
|
|
1607
|
+
# ==================================================================
|
|
1608
|
+
# Storage (F-73, F-76)
|
|
1609
|
+
# ==================================================================
|
|
1610
|
+
|
|
1611
|
+
def _build_storage(self) -> QFrame:
|
|
1612
|
+
panel, box = self._panel("Storage")
|
|
1613
|
+
|
|
1614
|
+
grid = QGridLayout()
|
|
1615
|
+
grid.setHorizontalSpacing(METRICS.pad)
|
|
1616
|
+
grid.setVerticalSpacing(METRICS.gap_tight)
|
|
1617
|
+
self.size_labels: dict[str, QLabel] = {}
|
|
1618
|
+
rows = (
|
|
1619
|
+
("model_cache", "Model cache"),
|
|
1620
|
+
("documents", "Documents and history"),
|
|
1621
|
+
("audio", "Audio"),
|
|
1622
|
+
("temp", "Temporary data"),
|
|
1623
|
+
("logs", "Logs"),
|
|
1624
|
+
)
|
|
1625
|
+
for row, (key, name) in enumerate(rows):
|
|
1626
|
+
grid.addWidget(self._label(name, "secondary"), row, 0)
|
|
1627
|
+
value = self._value_label()
|
|
1628
|
+
value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
1629
|
+
grid.addWidget(value, row, 1)
|
|
1630
|
+
self.size_labels[key] = value
|
|
1631
|
+
grid.setColumnStretch(0, 1)
|
|
1632
|
+
box.addLayout(grid)
|
|
1633
|
+
|
|
1634
|
+
self.storage_refresh = self._button("Refresh", "quiet")
|
|
1635
|
+
refresh_row = QHBoxLayout()
|
|
1636
|
+
refresh_row.addWidget(self.storage_refresh)
|
|
1637
|
+
refresh_row.addStretch(1)
|
|
1638
|
+
box.addLayout(refresh_row)
|
|
1639
|
+
|
|
1640
|
+
box.addWidget(_separator())
|
|
1641
|
+
self.retention = QSpinBox()
|
|
1642
|
+
self.retention.setRange(RETENTION_MIN_BYTES // GB, RETENTION_MAX_BYTES // GB)
|
|
1643
|
+
self.retention.setSuffix(" GB")
|
|
1644
|
+
self._field(
|
|
1645
|
+
box,
|
|
1646
|
+
"Retention limit",
|
|
1647
|
+
self.retention,
|
|
1648
|
+
"1-100 GB for documents, history, and audio together. Lowering it below "
|
|
1649
|
+
"what is stored keeps that data and refuses new retention.",
|
|
1650
|
+
)
|
|
1651
|
+
self.retention_used = self._value_label("muted")
|
|
1652
|
+
box.addWidget(self.retention_used)
|
|
1653
|
+
|
|
1654
|
+
self.cleanup = self._button("Clean up expired data")
|
|
1655
|
+
cleanup_row = QHBoxLayout()
|
|
1656
|
+
cleanup_row.addWidget(self.cleanup)
|
|
1657
|
+
cleanup_row.addStretch(1)
|
|
1658
|
+
box.addLayout(cleanup_row)
|
|
1659
|
+
box.addWidget(
|
|
1660
|
+
self._label("Data in use by playback, generation, or a backup is left alone.", "muted")
|
|
1661
|
+
)
|
|
1662
|
+
self.cleanup_result = self._value_label("ok")
|
|
1663
|
+
self.cleanup_result.setVisible(False)
|
|
1664
|
+
box.addWidget(self.cleanup_result)
|
|
1665
|
+
|
|
1666
|
+
box.addWidget(_separator())
|
|
1667
|
+
self.reset_buttons: dict[ResetScope, QPushButton] = {}
|
|
1668
|
+
for scope, button_text, description in (
|
|
1669
|
+
(
|
|
1670
|
+
ResetScope.VOICE_AND_DISPLAY,
|
|
1671
|
+
"Reset",
|
|
1672
|
+
"Voice, tempo, style, display language, and playback options return to "
|
|
1673
|
+
"their defaults. Documents, history, and audio are untouched.",
|
|
1674
|
+
),
|
|
1675
|
+
(
|
|
1676
|
+
ResetScope.INTEGRATIONS,
|
|
1677
|
+
"Revoke all",
|
|
1678
|
+
"Every client credential stops working at once and their running jobs "
|
|
1679
|
+
"are canceled. A new owner credential is issued on the next launch.",
|
|
1680
|
+
),
|
|
1681
|
+
(
|
|
1682
|
+
ResetScope.RETAINED_DATA,
|
|
1683
|
+
"Delete",
|
|
1684
|
+
"Saved documents, job history, and generated audio are deleted from this "
|
|
1685
|
+
"app and cannot be recovered. Files you exported yourself and your own "
|
|
1686
|
+
"backups are not touched.",
|
|
1687
|
+
),
|
|
1688
|
+
(
|
|
1689
|
+
ResetScope.MODEL_CACHE,
|
|
1690
|
+
"Delete",
|
|
1691
|
+
"The downloaded model files are deleted, about {size}. Generation needs "
|
|
1692
|
+
"them downloaded again before it can run, which needs a network.",
|
|
1693
|
+
),
|
|
1694
|
+
):
|
|
1695
|
+
box.addWidget(self._label(_SCOPE_TITLE[scope], "secondary"))
|
|
1696
|
+
row = QHBoxLayout()
|
|
1697
|
+
row.setSpacing(METRICS.gap)
|
|
1698
|
+
button = self._button(button_text, "danger")
|
|
1699
|
+
button.setMinimumWidth(120)
|
|
1700
|
+
self._text(button, _SCOPE_TITLE[scope], setter="setAccessibleName")
|
|
1701
|
+
row.addWidget(button)
|
|
1702
|
+
row.addStretch(1)
|
|
1703
|
+
box.addLayout(row)
|
|
1704
|
+
if scope is ResetScope.MODEL_CACHE:
|
|
1705
|
+
self.model_cache_note = self._label(description, "muted", size=bytes_size(0))
|
|
1706
|
+
box.addWidget(self.model_cache_note)
|
|
1707
|
+
else:
|
|
1708
|
+
box.addWidget(self._label(description, "muted"))
|
|
1709
|
+
button.clicked.connect(lambda _=False, s=scope: self._on_reset(s))
|
|
1710
|
+
self.reset_buttons[scope] = button
|
|
1711
|
+
|
|
1712
|
+
self.failures = self._value_label("danger")
|
|
1713
|
+
self.failures.setVisible(False)
|
|
1714
|
+
box.addWidget(self.failures)
|
|
1715
|
+
|
|
1716
|
+
self.retention.valueChanged.connect(self._on_retention)
|
|
1717
|
+
self.cleanup.clicked.connect(self.cleanup_requested)
|
|
1718
|
+
self.storage_refresh.clicked.connect(self.storage_refresh_requested)
|
|
1719
|
+
return panel
|
|
1720
|
+
|
|
1721
|
+
def _on_retention(self, value: int) -> None:
|
|
1722
|
+
if not self._loading:
|
|
1723
|
+
self.retention_changed.emit(int(value) * GB)
|
|
1724
|
+
|
|
1725
|
+
def _on_reset(self, scope: ResetScope) -> None:
|
|
1726
|
+
"""F-76: each scope confirms its own impact before anything goes.
|
|
1727
|
+
|
|
1728
|
+
The confirmation names what is deleted, what cannot be recovered, and
|
|
1729
|
+
what is deliberately left alone -- the user's own exported files and
|
|
1730
|
+
backups -- because F-76 requires all three and a generic "are you
|
|
1731
|
+
sure" answers none of them.
|
|
1732
|
+
"""
|
|
1733
|
+
if not self.confirm(
|
|
1734
|
+
tr(_SCOPE_TITLE[scope]) + "?",
|
|
1735
|
+
self._scope_body(scope),
|
|
1736
|
+
tr(_SCOPE_ACCEPT[scope]),
|
|
1737
|
+
):
|
|
1738
|
+
return
|
|
1739
|
+
self.failures.setVisible(False)
|
|
1740
|
+
self.reset_requested.emit(scope.value)
|
|
1741
|
+
|
|
1742
|
+
def _scope_body(self, scope: ResetScope) -> str:
|
|
1743
|
+
if scope is ResetScope.MODEL_CACHE:
|
|
1744
|
+
size = bytes_size(self._sizes.model_cache_bytes if self._sizes else 0)
|
|
1745
|
+
body = tr(
|
|
1746
|
+
"The downloaded model files are deleted, about {size}. Generation needs "
|
|
1747
|
+
"them downloaded again before it can run, which needs a network."
|
|
1748
|
+
).format(size=size)
|
|
1749
|
+
elif scope is ResetScope.RETAINED_DATA:
|
|
1750
|
+
body = tr(
|
|
1751
|
+
"Saved documents, job history, and generated audio are deleted from this "
|
|
1752
|
+
"app and cannot be recovered. Files you exported yourself and your own "
|
|
1753
|
+
"backups are not touched."
|
|
1754
|
+
)
|
|
1755
|
+
elif scope is ResetScope.INTEGRATIONS:
|
|
1756
|
+
body = tr(
|
|
1757
|
+
"Every client credential stops working at once and their running jobs "
|
|
1758
|
+
"are canceled. A new owner credential is issued on the next launch."
|
|
1759
|
+
)
|
|
1760
|
+
else:
|
|
1761
|
+
body = tr(
|
|
1762
|
+
"Voice, tempo, style, display language, and playback options return to "
|
|
1763
|
+
"their defaults. Documents, history, and audio are untouched."
|
|
1764
|
+
)
|
|
1765
|
+
if scope is ResetScope.VOICE_AND_DISPLAY:
|
|
1766
|
+
return body
|
|
1767
|
+
return body + " " + tr("This cannot be undone.")
|
|
1768
|
+
|
|
1769
|
+
def set_storage(self, sizes: StorageSizes) -> None:
|
|
1770
|
+
"""F-73's five figures, each on its own line."""
|
|
1771
|
+
self._sizes = sizes
|
|
1772
|
+
self._render_storage()
|
|
1773
|
+
|
|
1774
|
+
def _render_storage(self) -> None:
|
|
1775
|
+
sizes = self._sizes
|
|
1776
|
+
if sizes is None:
|
|
1777
|
+
return
|
|
1778
|
+
self.size_labels["model_cache"].setText(bytes_size(sizes.model_cache_bytes))
|
|
1779
|
+
self.size_labels["documents"].setText(bytes_size(sizes.documents_bytes))
|
|
1780
|
+
self.size_labels["audio"].setText(bytes_size(sizes.audio_bytes))
|
|
1781
|
+
self.size_labels["temp"].setText(bytes_size(sizes.temp_bytes))
|
|
1782
|
+
self.size_labels["logs"].setText(bytes_size(sizes.log_bytes))
|
|
1783
|
+
self.retention_used.setText(
|
|
1784
|
+
tr("{used} of {limit} used").format(
|
|
1785
|
+
used=bytes_size(sizes.retained_used_bytes),
|
|
1786
|
+
limit=bytes_size(sizes.retention_limit_bytes),
|
|
1787
|
+
)
|
|
1788
|
+
)
|
|
1789
|
+
self.model_cache_note.setText(
|
|
1790
|
+
tr(
|
|
1791
|
+
"The downloaded model files are deleted, about {size}. Generation needs "
|
|
1792
|
+
"them downloaded again before it can run, which needs a network."
|
|
1793
|
+
).format(size=bytes_size(sizes.model_cache_bytes))
|
|
1794
|
+
)
|
|
1795
|
+
|
|
1796
|
+
def report_cleanup(self, removed: int, freed_bytes: int) -> None:
|
|
1797
|
+
"""F-73's cleanup, reported in the number the user can act on.
|
|
1798
|
+
|
|
1799
|
+
Three phrasings rather than one with a count, because "Removed 1
|
|
1800
|
+
items" and "Removed 0 items" are both wrong in English and the
|
|
1801
|
+
second is not even about a removal.
|
|
1802
|
+
"""
|
|
1803
|
+
self.cleanup_result.setVisible(True)
|
|
1804
|
+
if removed <= 0:
|
|
1805
|
+
self.cleanup_result.setText(tr("Nothing had expired."))
|
|
1806
|
+
elif removed == 1:
|
|
1807
|
+
self.cleanup_result.setText(
|
|
1808
|
+
tr("Removed 1 item, freeing {size}.").format(size=bytes_size(freed_bytes))
|
|
1809
|
+
)
|
|
1810
|
+
else:
|
|
1811
|
+
self.cleanup_result.setText(
|
|
1812
|
+
tr("Removed {n} items, freeing {size}.").format(
|
|
1813
|
+
n=removed, size=bytes_size(freed_bytes)
|
|
1814
|
+
)
|
|
1815
|
+
)
|
|
1816
|
+
|
|
1817
|
+
def report_failures(self, items: Sequence[str]) -> None:
|
|
1818
|
+
"""F-76: what could not be deleted is named, not swallowed."""
|
|
1819
|
+
self.failures.setVisible(bool(items))
|
|
1820
|
+
if items:
|
|
1821
|
+
self.failures.setText(
|
|
1822
|
+
tr("These items could not be deleted: {items}").format(items=", ".join(items))
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
# ==================================================================
|
|
1826
|
+
# Voice presets (F-66)
|
|
1827
|
+
# ==================================================================
|
|
1828
|
+
|
|
1829
|
+
def _build_presets(self) -> QFrame:
|
|
1830
|
+
panel, box = self._panel("Voice presets")
|
|
1831
|
+
|
|
1832
|
+
self.preset_count = self._value_label("muted")
|
|
1833
|
+
box.addWidget(self.preset_count)
|
|
1834
|
+
self.presets = QListWidget()
|
|
1835
|
+
self.presets.setMinimumHeight(110)
|
|
1836
|
+
self._text(self.presets, "Voice presets", setter="setAccessibleName")
|
|
1837
|
+
box.addWidget(self.presets)
|
|
1838
|
+
self.presets_empty = self._label("No presets yet.", "muted")
|
|
1839
|
+
box.addWidget(self.presets_empty)
|
|
1840
|
+
|
|
1841
|
+
buttons = QHBoxLayout()
|
|
1842
|
+
buttons.setSpacing(METRICS.gap)
|
|
1843
|
+
self.preset_apply = self._button("Apply")
|
|
1844
|
+
self.preset_rename = self._button("Rename")
|
|
1845
|
+
self.preset_delete = self._button("Delete", "danger")
|
|
1846
|
+
for b in (self.preset_apply, self.preset_rename, self.preset_delete):
|
|
1847
|
+
buttons.addWidget(b)
|
|
1848
|
+
buttons.addStretch(1)
|
|
1849
|
+
box.addLayout(buttons)
|
|
1850
|
+
|
|
1851
|
+
box.addWidget(_separator())
|
|
1852
|
+
box.addWidget(self._label("Save the current voice settings as", "secondary"))
|
|
1853
|
+
save_row = QHBoxLayout()
|
|
1854
|
+
save_row.setSpacing(METRICS.gap)
|
|
1855
|
+
self.preset_name = QLineEdit()
|
|
1856
|
+
self._text(self.preset_name, "A name for this preset", setter="setPlaceholderText")
|
|
1857
|
+
self._text(self.preset_name, "A name for this preset", setter="setAccessibleName")
|
|
1858
|
+
self.preset_save = self._button("Save")
|
|
1859
|
+
save_row.addWidget(self.preset_name, 1)
|
|
1860
|
+
save_row.addWidget(self.preset_save)
|
|
1861
|
+
box.addLayout(save_row)
|
|
1862
|
+
self.preset_limit = self._label(
|
|
1863
|
+
"The limit of {max} presets has been reached.", "warn", max=VOICE_PRESET_MAX
|
|
1864
|
+
)
|
|
1865
|
+
self.preset_limit.setVisible(False)
|
|
1866
|
+
box.addWidget(self.preset_limit)
|
|
1867
|
+
|
|
1868
|
+
self.presets.currentRowChanged.connect(lambda *_: self._render_preset_buttons())
|
|
1869
|
+
self.preset_apply.clicked.connect(self._on_preset_apply)
|
|
1870
|
+
self.preset_rename.clicked.connect(self._on_preset_rename)
|
|
1871
|
+
self.preset_delete.clicked.connect(self._on_preset_delete)
|
|
1872
|
+
self.preset_save.clicked.connect(self._on_preset_save)
|
|
1873
|
+
self.preset_name.returnPressed.connect(self._on_preset_save)
|
|
1874
|
+
return panel
|
|
1875
|
+
|
|
1876
|
+
def _render_presets(self) -> None:
|
|
1877
|
+
was = self._loading
|
|
1878
|
+
self._loading = True
|
|
1879
|
+
try:
|
|
1880
|
+
current = self._selected_preset()
|
|
1881
|
+
self.presets.clear()
|
|
1882
|
+
self._unavailable = set()
|
|
1883
|
+
for preset in self._presets:
|
|
1884
|
+
problem = _preset_problem(preset)
|
|
1885
|
+
item = QListWidgetItem(
|
|
1886
|
+
preset.name if problem is None else tr(problem).format(name=preset.name)
|
|
1887
|
+
)
|
|
1888
|
+
item.setData(Qt.ItemDataRole.UserRole, preset.name)
|
|
1889
|
+
self.presets.addItem(item)
|
|
1890
|
+
if problem is not None:
|
|
1891
|
+
self._unavailable.add(preset.name)
|
|
1892
|
+
if current is not None:
|
|
1893
|
+
for row in range(self.presets.count()):
|
|
1894
|
+
item = self.presets.item(row)
|
|
1895
|
+
if item.data(Qt.ItemDataRole.UserRole) == current:
|
|
1896
|
+
self.presets.setCurrentItem(item)
|
|
1897
|
+
break
|
|
1898
|
+
finally:
|
|
1899
|
+
self._loading = was
|
|
1900
|
+
self.presets.setVisible(bool(self._presets))
|
|
1901
|
+
self.presets_empty.setVisible(not self._presets)
|
|
1902
|
+
self.preset_count.setText(
|
|
1903
|
+
tr("{n} of {max} saved").format(n=len(self._presets), max=VOICE_PRESET_MAX)
|
|
1904
|
+
)
|
|
1905
|
+
full = len(self._presets) >= VOICE_PRESET_MAX
|
|
1906
|
+
self.preset_limit.setVisible(full)
|
|
1907
|
+
self.preset_save.setEnabled(not full)
|
|
1908
|
+
self._render_preset_buttons()
|
|
1909
|
+
|
|
1910
|
+
def _render_preset_buttons(self) -> None:
|
|
1911
|
+
name = self._selected_preset()
|
|
1912
|
+
for b in (self.preset_rename, self.preset_delete):
|
|
1913
|
+
b.setEnabled(name is not None)
|
|
1914
|
+
# F-66: an unavailable model or voice is flagged and never
|
|
1915
|
+
# substituted, so applying such a preset is refused rather than
|
|
1916
|
+
# silently corrected to a voice the user did not choose. Renaming
|
|
1917
|
+
# and deleting stay available -- deleting it is the likely fix.
|
|
1918
|
+
self.preset_apply.setEnabled(name is not None and name not in self._unavailable)
|
|
1919
|
+
|
|
1920
|
+
def _selected_preset(self) -> str | None:
|
|
1921
|
+
item = self.presets.currentItem()
|
|
1922
|
+
if item is None:
|
|
1923
|
+
return None
|
|
1924
|
+
return str(item.data(Qt.ItemDataRole.UserRole))
|
|
1925
|
+
|
|
1926
|
+
def _on_preset_apply(self) -> None:
|
|
1927
|
+
name = self._selected_preset()
|
|
1928
|
+
if name:
|
|
1929
|
+
self.preset_apply_requested.emit(name)
|
|
1930
|
+
|
|
1931
|
+
def _on_preset_rename(self) -> None:
|
|
1932
|
+
"""F-66 / 4.1: a rename onto an occupied name is an overwrite.
|
|
1933
|
+
|
|
1934
|
+
``Settings.rename_preset`` drops the preset that was already called
|
|
1935
|
+
this, deliberately and without asking -- the same overwrite
|
|
1936
|
+
``save_preset`` performs -- so the confirmation 4.1 requires for a
|
|
1937
|
+
duplicate name has to be given here, on the way in, exactly as it is
|
|
1938
|
+
for the Save box.
|
|
1939
|
+
"""
|
|
1940
|
+
name = self._selected_preset()
|
|
1941
|
+
if not name:
|
|
1942
|
+
return
|
|
1943
|
+
new = self.ask_text(tr("Rename"), tr("New name"), name)
|
|
1944
|
+
if not new or not new.strip() or new.strip() == name:
|
|
1945
|
+
return
|
|
1946
|
+
clean = new.strip()
|
|
1947
|
+
if any(p.name == clean for p in self._presets) and not self.confirm(
|
|
1948
|
+
tr("Replace the preset {name}?").format(name=clean),
|
|
1949
|
+
tr("A preset with this name already exists. Renaming replaces its settings."),
|
|
1950
|
+
tr("Replace"),
|
|
1951
|
+
):
|
|
1952
|
+
return
|
|
1953
|
+
self.preset_rename_requested.emit(name, clean)
|
|
1954
|
+
|
|
1955
|
+
def _on_preset_delete(self) -> None:
|
|
1956
|
+
name = self._selected_preset()
|
|
1957
|
+
if not name:
|
|
1958
|
+
return
|
|
1959
|
+
if not self.confirm(
|
|
1960
|
+
tr("Delete the preset {name}?").format(name=name),
|
|
1961
|
+
tr("This cannot be undone."),
|
|
1962
|
+
tr("Delete"),
|
|
1963
|
+
):
|
|
1964
|
+
return
|
|
1965
|
+
self.preset_delete_requested.emit(name)
|
|
1966
|
+
|
|
1967
|
+
def _on_preset_save(self) -> None:
|
|
1968
|
+
"""4.1: a duplicate name is confirmed, then overwritten.
|
|
1969
|
+
|
|
1970
|
+
The confirmation is here rather than in ``Settings.save_preset``,
|
|
1971
|
+
which overwrites without asking on purpose -- by the time the model
|
|
1972
|
+
is asked, the answer has been given.
|
|
1973
|
+
"""
|
|
1974
|
+
name = self.preset_name.text().strip()
|
|
1975
|
+
if not name:
|
|
1976
|
+
return
|
|
1977
|
+
if len(self._presets) >= VOICE_PRESET_MAX and all(p.name != name for p in self._presets):
|
|
1978
|
+
return
|
|
1979
|
+
if any(p.name == name for p in self._presets):
|
|
1980
|
+
if not self.confirm(
|
|
1981
|
+
tr("Replace the preset {name}?").format(name=name),
|
|
1982
|
+
tr("A preset with this name already exists. Saving replaces its settings."),
|
|
1983
|
+
tr("Replace"),
|
|
1984
|
+
):
|
|
1985
|
+
return
|
|
1986
|
+
self.preset_name.clear()
|
|
1987
|
+
self.preset_save_requested.emit(name)
|
|
1988
|
+
|
|
1989
|
+
# ==================================================================
|
|
1990
|
+
# About (F-75, F-80)
|
|
1991
|
+
# ==================================================================
|
|
1992
|
+
|
|
1993
|
+
def _build_about(self) -> QFrame:
|
|
1994
|
+
panel, box = self._panel("About")
|
|
1995
|
+
box.addWidget(self._label("Installed version {version}", "secondary", version=__version__))
|
|
1996
|
+
|
|
1997
|
+
self.check_version = self._button("Check for a released version")
|
|
1998
|
+
check_row = QHBoxLayout()
|
|
1999
|
+
check_row.addWidget(self.check_version)
|
|
2000
|
+
check_row.addStretch(1)
|
|
2001
|
+
box.addLayout(check_row)
|
|
2002
|
+
box.addWidget(
|
|
2003
|
+
self._label(
|
|
2004
|
+
"Nothing is downloaded, installed, or restarted without your consent. "
|
|
2005
|
+
"Checking sends only a version query.",
|
|
2006
|
+
"muted",
|
|
2007
|
+
)
|
|
2008
|
+
)
|
|
2009
|
+
self.release_note = self._value_label()
|
|
2010
|
+
self.release_note.setVisible(False)
|
|
2011
|
+
box.addWidget(self.release_note)
|
|
2012
|
+
|
|
2013
|
+
box.addWidget(_separator())
|
|
2014
|
+
box.addWidget(self._label("Change history", "section"))
|
|
2015
|
+
for version, released, lines in CHANGE_HISTORY:
|
|
2016
|
+
box.addWidget(self._value_with_text(f"{version} — {released}", "secondary"))
|
|
2017
|
+
for line in lines:
|
|
2018
|
+
box.addWidget(self._bullet(line))
|
|
2019
|
+
|
|
2020
|
+
self.check_version.clicked.connect(self.version_check_requested)
|
|
2021
|
+
return panel
|
|
2022
|
+
|
|
2023
|
+
def _value_with_text(self, text: str, role: str | None = None) -> QLabel:
|
|
2024
|
+
lb = self._value_label(role)
|
|
2025
|
+
lb.setText(text)
|
|
2026
|
+
return lb
|
|
2027
|
+
|
|
2028
|
+
def set_released_version(self, version: str | None, *, error: str | None = None) -> None:
|
|
2029
|
+
"""F-75: report what a check found, and download nothing.
|
|
2030
|
+
|
|
2031
|
+
Called only after :attr:`version_check_requested`; there is no timer
|
|
2032
|
+
and no start-up call anywhere in this module, because F-75 makes the
|
|
2033
|
+
query the user's to ask for.
|
|
2034
|
+
"""
|
|
2035
|
+
self.release_note.setVisible(True)
|
|
2036
|
+
if error:
|
|
2037
|
+
self.release_note.setText(
|
|
2038
|
+
tr("The released version could not be checked: {reason}").format(reason=error)
|
|
2039
|
+
)
|
|
2040
|
+
elif version and version != __version__:
|
|
2041
|
+
self.release_note.setText(
|
|
2042
|
+
tr("Version {version} has been released.").format(version=version)
|
|
2043
|
+
)
|
|
2044
|
+
else:
|
|
2045
|
+
self.release_note.setText(tr("This is the newest released version."))
|
|
2046
|
+
|
|
2047
|
+
# ==================================================================
|
|
2048
|
+
# Applying settings, and retranslating
|
|
2049
|
+
# ==================================================================
|
|
2050
|
+
|
|
2051
|
+
def apply(self, settings: Settings) -> None:
|
|
2052
|
+
"""Show remembered settings without emitting a change (F-24).
|
|
2053
|
+
|
|
2054
|
+
The view keeps a reference rather than a copy: ``Settings`` is frozen,
|
|
2055
|
+
so the window replacing it is the only way this changes, and the
|
|
2056
|
+
screen can never disagree with what was saved.
|
|
2057
|
+
"""
|
|
2058
|
+
self._settings = settings
|
|
2059
|
+
self._presets = settings.presets
|
|
2060
|
+
self._loading = True
|
|
2061
|
+
try:
|
|
2062
|
+
self.cpu.setValue(
|
|
2063
|
+
max(CPU_PERCENT_MIN, min(CPU_PERCENT_MAX, settings.cpu_percent))
|
|
2064
|
+
)
|
|
2065
|
+
auto = settings.memory_bytes is None
|
|
2066
|
+
self.memory_auto.setChecked(auto)
|
|
2067
|
+
self.memory.setEnabled(not auto)
|
|
2068
|
+
shown = (
|
|
2069
|
+
settings.memory_bytes
|
|
2070
|
+
if settings.memory_bytes is not None
|
|
2071
|
+
else default_memory_bytes(self._machine.total_ram_bytes)
|
|
2072
|
+
)
|
|
2073
|
+
self.memory.setValue(shown / GIB)
|
|
2074
|
+
self.volume.setValue(int(round(settings.volume * 100)))
|
|
2075
|
+
self.volume_value.setText(f"{int(round(settings.volume * 100))}%")
|
|
2076
|
+
self.muted.setChecked(settings.muted)
|
|
2077
|
+
self.autoplay.setChecked(settings.autoplay)
|
|
2078
|
+
self.follow.setChecked(settings.follow)
|
|
2079
|
+
self.rest.setChecked(settings.rest_enabled)
|
|
2080
|
+
self.port.setValue(
|
|
2081
|
+
max(MIN_USER_PORT, min(MAX_PORT, settings.rest_port))
|
|
2082
|
+
)
|
|
2083
|
+
self.mcp.setChecked(settings.mcp_enabled)
|
|
2084
|
+
self.credential_days.setValue(
|
|
2085
|
+
max(CREDENTIAL_DAYS_MIN, min(CREDENTIAL_DAYS_MAX, settings.credential_days))
|
|
2086
|
+
)
|
|
2087
|
+
self.retention.setValue(
|
|
2088
|
+
max(
|
|
2089
|
+
RETENTION_MIN_BYTES // GB,
|
|
2090
|
+
min(RETENTION_MAX_BYTES // GB, settings.retention_bytes // GB),
|
|
2091
|
+
)
|
|
2092
|
+
)
|
|
2093
|
+
finally:
|
|
2094
|
+
self._loading = False
|
|
2095
|
+
# A remembered port is one the service was started on; a port typed
|
|
2096
|
+
# into the box afterwards is not, so the disclosure follows this and
|
|
2097
|
+
# not the control.
|
|
2098
|
+
self._service_port = int(self.port.value())
|
|
2099
|
+
self._service_port_before = None
|
|
2100
|
+
self._render_languages()
|
|
2101
|
+
self._render_devices()
|
|
2102
|
+
self._render_service()
|
|
2103
|
+
self._render_resources()
|
|
2104
|
+
self._render_presets()
|
|
2105
|
+
|
|
2106
|
+
@property
|
|
2107
|
+
def settings(self) -> Settings:
|
|
2108
|
+
return self._settings
|
|
2109
|
+
|
|
2110
|
+
def retranslate(self) -> None:
|
|
2111
|
+
"""F-86, in place.
|
|
2112
|
+
|
|
2113
|
+
Every literal is re-set from its recorded source, and every rendered
|
|
2114
|
+
value is recomputed; the widgets themselves are the same objects, so
|
|
2115
|
+
the focused control and the scroll position survive the switch --
|
|
2116
|
+
which is the difference between changing a language and losing your
|
|
2117
|
+
place.
|
|
2118
|
+
"""
|
|
2119
|
+
for widget, setter, source, fmt in self._translatables:
|
|
2120
|
+
getattr(widget, setter)(tr(source).format(**fmt) if fmt else tr(source))
|
|
2121
|
+
self.credential_days.setSuffix(" " + tr("days"))
|
|
2122
|
+
self._render_client_columns()
|
|
2123
|
+
self._render_languages()
|
|
2124
|
+
self._render_devices()
|
|
2125
|
+
self._render_service()
|
|
2126
|
+
self._render_resources()
|
|
2127
|
+
self._render_credentials()
|
|
2128
|
+
self._render_storage()
|
|
2129
|
+
self._render_presets()
|
|
2130
|
+
if self._conflict_port is not None and self.conflict.isVisible():
|
|
2131
|
+
text = tr("Use port {port} instead").format(port=self._conflict_port)
|
|
2132
|
+
self.conflict_use.setText(text)
|
|
2133
|
+
self.conflict_use.setAccessibleName(text)
|
|
2134
|
+
|
|
2135
|
+
# -- guarded programmatic changes ------------------------------------
|
|
2136
|
+
|
|
2137
|
+
def _set_checked(self, box: QCheckBox, checked: bool) -> None:
|
|
2138
|
+
was, self._loading = self._loading, True
|
|
2139
|
+
try:
|
|
2140
|
+
box.setChecked(checked)
|
|
2141
|
+
finally:
|
|
2142
|
+
self._loading = was
|
|
2143
|
+
|
|
2144
|
+
def _set_value(self, spin: QSpinBox, value: int) -> None:
|
|
2145
|
+
was, self._loading = self._loading, True
|
|
2146
|
+
try:
|
|
2147
|
+
spin.setValue(value)
|
|
2148
|
+
finally:
|
|
2149
|
+
self._loading = was
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
def _separator() -> QFrame:
|
|
2153
|
+
f = QFrame()
|
|
2154
|
+
f.setProperty("role", "separator")
|
|
2155
|
+
f.setFrameShape(QFrame.Shape.HLine)
|
|
2156
|
+
f.setFixedHeight(1)
|
|
2157
|
+
return f
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def _repolish(widget: QWidget) -> None:
|
|
2161
|
+
"""Re-evaluate a property selector after the property changed."""
|
|
2162
|
+
style = widget.style()
|
|
2163
|
+
style.unpolish(widget)
|
|
2164
|
+
style.polish(widget)
|
|
2165
|
+
|
|
2166
|
+
|
|
2167
|
+
def _next_port(port: int) -> int:
|
|
2168
|
+
"""The port to offer after a conflict (F-79).
|
|
2169
|
+
|
|
2170
|
+
One above the occupied one, wrapping to the bottom of the user range at
|
|
2171
|
+
the top end. It is only a suggestion: nothing binds it until the owner
|
|
2172
|
+
presses the button, so the choice stays theirs.
|
|
2173
|
+
"""
|
|
2174
|
+
if port >= MAX_PORT:
|
|
2175
|
+
return MIN_USER_PORT
|
|
2176
|
+
return max(MIN_USER_PORT, port + 1)
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
def _preset_problem(preset: VoicePreset) -> str | None:
|
|
2180
|
+
"""F-66's warning: the model or the voice this preset names is gone.
|
|
2181
|
+
|
|
2182
|
+
Checked against the shipped manifest rather than against what is
|
|
2183
|
+
downloaded: a model that is merely not prepared yet can still be
|
|
2184
|
+
prepared, and flagging that as unavailable would warn about every
|
|
2185
|
+
preset on a fresh install.
|
|
2186
|
+
"""
|
|
2187
|
+
voice = preset.voice
|
|
2188
|
+
if not MANIFEST.has(voice.model_id):
|
|
2189
|
+
return "{name} — this model is not available"
|
|
2190
|
+
try:
|
|
2191
|
+
MANIFEST.get(voice.model_id).validate_voice(voice.voice_id, voice.gender)
|
|
2192
|
+
except EchoActError:
|
|
2193
|
+
return "{name} — this voice is not available"
|
|
2194
|
+
return None
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def _expiry_text(cred: Credential, now: float | None = None) -> str:
|
|
2198
|
+
status = cred.status(now)
|
|
2199
|
+
if status is CredentialStatus.REVOKED:
|
|
2200
|
+
return tr("Revoked")
|
|
2201
|
+
if status is CredentialStatus.EXPIRED:
|
|
2202
|
+
return tr("Expired")
|
|
2203
|
+
return tr("{date} (in {days} days)").format(
|
|
2204
|
+
date=_date(cred.expires_at), days=max(0, int(cred.days_until_expiry(now)))
|
|
2205
|
+
)
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
_SCOPE_TITLE: Final[dict[ResetScope, str]] = {
|
|
2209
|
+
ResetScope.VOICE_AND_DISPLAY: "Reset voice and display settings",
|
|
2210
|
+
ResetScope.INTEGRATIONS: "Revoke integration permissions",
|
|
2211
|
+
ResetScope.RETAINED_DATA: "Delete retained data",
|
|
2212
|
+
ResetScope.MODEL_CACHE: "Delete the model cache",
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
_SCOPE_ACCEPT: Final[dict[ResetScope, str]] = {
|
|
2216
|
+
ResetScope.VOICE_AND_DISPLAY: "Reset",
|
|
2217
|
+
ResetScope.INTEGRATIONS: "Revoke all",
|
|
2218
|
+
ResetScope.RETAINED_DATA: "Delete",
|
|
2219
|
+
ResetScope.MODEL_CACHE: "Delete",
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def _follow_language(view: SettingsView) -> None:
|
|
2224
|
+
"""Retranslate this view whenever the display language changes.
|
|
2225
|
+
|
|
2226
|
+
``i18n.on_change`` keeps its listeners for the life of the process, so
|
|
2227
|
+
the hook holds a weak reference and does nothing once the view is gone;
|
|
2228
|
+
a bound method here would keep a closed settings screen alive and, worse,
|
|
2229
|
+
would eventually call into a deleted C++ object.
|
|
2230
|
+
"""
|
|
2231
|
+
ref = weakref.ref(view)
|
|
2232
|
+
|
|
2233
|
+
def hook(_lang: object) -> None:
|
|
2234
|
+
target = ref()
|
|
2235
|
+
if target is None or not _alive(target):
|
|
2236
|
+
return
|
|
2237
|
+
target.retranslate()
|
|
2238
|
+
|
|
2239
|
+
on_change(hook)
|
|
2240
|
+
|
|
2241
|
+
|
|
2242
|
+
def _alive(widget: QWidget) -> bool:
|
|
2243
|
+
try:
|
|
2244
|
+
from shiboken6 import isValid
|
|
2245
|
+
except ImportError: # pragma: no cover - shiboken ships with PySide6
|
|
2246
|
+
return True
|
|
2247
|
+
return bool(isValid(widget))
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
__all__ = [
|
|
2251
|
+
"CHANGE_HISTORY",
|
|
2252
|
+
"Machine",
|
|
2253
|
+
"ResetScope",
|
|
2254
|
+
"SettingsView",
|
|
2255
|
+
"StorageSizes",
|
|
2256
|
+
"directory_bytes",
|
|
2257
|
+
"storage_snapshot",
|
|
2258
|
+
]
|