From 20a666e0d4179cba9c25043b75876d4a6c5c26cf Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 17 Sep 2026 04:10:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=205:=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20exe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Окно настроек: модель и провайдер (со списком моделей с сервера), озвучка, голосовой ввод, слово активации, автозапуск с Windows - Сохранение в config.yaml через ruamel.yaml с комментариями и ссылками ${VAR}, проверка значений до записи; переключатели трея тоже сохраняются - Настройки LLM и переключатели применяются на лету, для остального — перезапуск - Один экземпляр приложения, лог в файл, аргументы --config и --wait-pid, русские диалоги Qt - Сборка PyInstaller (onedir) с библиотеками CUDA, иконка приложения - Тесты сохранения настроек и окна настроек Co-Authored-By: Claude Opus 5 --- .gitignore | 1 - README.md | 25 +- agr-assistent.spec | 44 +++ packaging/icon.ico | Bin 0 -> 270398 bytes packaging/launcher.py | 10 + pyproject.toml | 2 + src/agr_assistent/app.py | 105 ++++++- src/agr_assistent/config.py | 64 ++++- src/agr_assistent/core/assistant.py | 10 + src/agr_assistent/core/settings.py | 45 +++ src/agr_assistent/single_instance.py | 48 ++++ src/agr_assistent/stt/whisper.py | 10 +- src/agr_assistent/system.py | 76 +++++ src/agr_assistent/ui/chat_window.py | 6 + src/agr_assistent/ui/icons.py | 13 +- src/agr_assistent/ui/settings_dialog.py | 359 ++++++++++++++++++++++++ src/agr_assistent/ui/tray.py | 38 ++- tests/conftest.py | 13 + tests/test_settings.py | 85 ++++++ tests/test_speaker.py | 6 - tests/test_voice.py | 6 - tests/test_wake_listener.py | 6 - uv.lock | 96 ++++++- 23 files changed, 1022 insertions(+), 46 deletions(-) create mode 100644 agr-assistent.spec create mode 100644 packaging/icon.ico create mode 100644 packaging/launcher.py create mode 100644 src/agr_assistent/core/settings.py create mode 100644 src/agr_assistent/single_instance.py create mode 100644 src/agr_assistent/system.py create mode 100644 src/agr_assistent/ui/settings_dialog.py create mode 100644 tests/conftest.py create mode 100644 tests/test_settings.py diff --git a/.gitignore b/.gitignore index 6d1aed1..cbebc85 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ __pycache__/ .pytest_cache/ build/ dist/ -*.spec # Локальный конфиг может содержать API-ключи config.yaml diff --git a/README.md b/README.md index 32202da..10e5963 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,29 @@ Silero читает только кириллицу: числа переводя не нагружает процессор. Слова, начинающиеся с ключевого («ассистентка»), могут давать ложные срабатывания. -Закрытие окна сворачивает приложение в трей. Клик по значку открывает чат, в меню значка — -переключение провайдера, новый диалог и выход. +### Настройки + +Окно настроек открывается из меню значка или кнопкой «Настройки» в чате: провайдер и модель +(список моделей подгружается с сервера), голос, горячая клавиша, модель Whisper, слово активации, +автозапуск с Windows. Изменения сохраняются в `config.yaml` с сохранением комментариев; +ключи вида `${ПЕРЕМЕННАЯ}` остаются ссылками. Настройки модели и переключатели применяются сразу, +для смены моделей, голоса и горячей клавиши приложение предложит перезапуститься. + +Закрытие окна сворачивает приложение в трей. Клик по значку открывает чат. Повторный запуск +не создаёт второй экземпляр, а показывает окно уже запущенного. + +Лог пишется в `%LOCALAPPDATA%\agr-assistent\logs`. Путь к конфигу можно передать аргументом: +`agr-assistent --config D:\path\config.yaml`. + +## Сборка exe + +```bash +uv run pyinstaller agr-assistent.spec --noconfirm +``` + +Результат — папка `dist\agr-assistent` (~2.8 ГБ, из них ~2 ГБ — библиотеки CUDA) с +`agr-assistent.exe`; её можно переносить целиком. `config.yaml` создаётся рядом с exe, модели +скачиваются в `%LOCALAPPDATA%\agr-assistent\models` при первом запуске. ## Разработка diff --git a/agr-assistent.spec b/agr-assistent.spec new file mode 100644 index 0000000..c75b068 --- /dev/null +++ b/agr-assistent.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- +# Сборка: uv run pyinstaller agr-assistent.spec --noconfirm +# Результат: dist/agr-assistent/agr-assistent.exe (папка целиком переносима) + +from pathlib import Path + +import nvidia +from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules + +datas = [("src/agr_assistent/default_config.yaml", "agr_assistent")] +datas += collect_data_files("silero_vad") +datas += collect_data_files("faster_whisper") + +binaries = collect_dynamic_libs("vosk") + collect_dynamic_libs("ctranslate2") +# cuBLAS и cuDNN для распознавания на видеокарте; путь к ним добавляет stt/whisper.py +for package_dir in nvidia.__path__: + for dll in Path(package_dir).glob("*/bin/*.dll"): + binaries.append((str(dll), f"nvidia/{dll.parent.parent.name}/bin")) + +# num2words подключает языковые модули динамически +hiddenimports = collect_submodules("num2words") + +a = Analysis( + ["packaging/launcher.py"], + pathex=["src"], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + excludes=["tkinter", "matplotlib", "IPython", "pytest"], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="agr-assistent", + icon="packaging/icon.ico", + console=False, + upx=False, +) +coll = COLLECT(exe, a.binaries, a.datas, name="agr-assistent", upx=False) diff --git a/packaging/icon.ico b/packaging/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..01dc301c053531c75a80cc3f1b45f46f5473136a GIT binary patch literal 270398 zcmeHQYmgjO*e-Q0FJgd}XpZNp}B&*J8?*<8r(&iB4^*`1x~p6Th+ zefo6w)6}q&Ieq%P=Xsy!J*T_RrLOKW`j3yx>ONQZrO$n=?o;&rOSvEF>Mqay&i|~V zG#o$x2mk>f00e*l5C8%|00;m9AOHk_01yBIKmZ5;0U!VbfB+Bx0zd!=00AHX1b_e# z00L?ukZEeVJQP^b`W&Rsar&I1 z&pG;xW+(#f8tG_lKkA8>R-iPS( zDrv08BYlg+m7c8aR0pmL*U5e4Py!I}9|En3yKhLvqxH#{wwQF@NuOaK>b+X5;5v0? z>qvFwI{VL4uowu4Bam$R_Q$BN{Ehs5dI#xZG=O@q77w`29XSjdZe>iwk;N5&fSL%b znIFA|+j!#q3i_N3q28;-1IAP+`UhtiOU9J3_13#69S9gBuvTxl zKB-6MklwqYd11|)#e!}ggFtuTlH-B5B^s_wZ(V3F_|`81rO2 zdb3<_qck8;SI6VtTJ(n7>HT&q+IrX8daJQ3O?`s@#aKir(`^KD&-1iK()}Lj-gDhg zj1M`VG~#)o+bRg(^;ahnSEco+PVcIZK=+F2zI5y}`G|eRJ_{dHQDY|&=$bL(O1jUy zhvvcbLHCO5zBCT#+5sAO`~&u(lbpb{f{y^dXX1Hoqj`cLel*uXg~C^k2$a~Ic?gwMZ%{$$x3<5>vlGWA`>f_lSGeI1t&;);Q(!{$n~U zE=7R)bPeQlJljWmZ`9sb`t1yJr<70lw?7cz=Q*VNr>(_%w2Q#Wgah&DQ+PJ$56u^> zY?~GREb$rd_0Z;5YTs)~>tIe5_=UgfBG95oZX@mYqdhZe&kz%8(S7l`{k)HXy37x^ zWjwzl6^qU#-Q)Q_G+wM*C%=y{$1s;jtyq&C!%O>5q0L8|FM|WL&f^n!N2o>&$nA@# zt&-t)l!5qC-~g>T_6GJe^EL*oo7wOg^2gSKPw*G|l*)ma_6~EYaSHgKGg#yBs){jz_-ijT*vLYHMQ3Eu{Ma zXdkgpF%AsyUJ&6@qukoIG)5e^Cpzl=L(?FxSPQy+=usABH0c?=-k^FBP_G%yG* z2Wb5Oa|MJqCBJSD*6x7zgRA|VZ%43>(8M^GU#|~qc0v0=*M6aX{pr}9|2(dyfe%k z21mv>@&fPO$2@fZx-USs+7da)lL^d3gNm)Dv0rVP{AAX>NY;V}34PW=sPX#30C!Ej?T!rBOO4A?NY{_0dr+fy3*@Ncw-;6RoGyMw&f zXiASPf}f#vaG)Xw76!2o(2~$@BEJvA_Z5AQK5@A30oebSSSvw_0r@$-7~=48y`$ax7|;iWDWUo%`h70bC;iR_R7`|-uRoajxL-Wkgg#o?EYx* zOePhNO1IrBU)wD=sYmbhQy-vf#*8beu03|^8>W0ap8Sol{`mrJKfG6}j*fS35Vm_@ z=XR+!`Zk{jA<{)q~tw(j0`vBSb zeR1tb5U`e6hXZfDF07~iZC%o|w0+5A!uAedIFDz4wf0F!TdXlvV*uUv$7JdzC}nj& z{kX86hK?PUrvH6^dr{clQ~P#Hvo|O_$=9P%Mg8-91+s5%z_6CW(M?VBg!ME!GMq`z znkvQKBke81_8v;Dkz#A$_(b<8HOVJdM+Yvpw7X4I; zt+M1cy{Llz*Xj+| z69yN(!XYT-c078&u&&PZ_PDOAT`xW@Z0FGpDc9``3NI?u;{v@4{G``0AgM>@RIm|# zws_BxPBdgjJ`x*KH+u{V!bA z*7^S9Vm6MBX4>Zdz*QTo;T)uNFZT>d#kJ>ygr(PYapK;3@5<+0(^l)8yE7TmugD>D zoTAtMluKi2hhRBK(ocu+)I*}U9`aEo`HiyjcRkJk-w%f66achHMH zgVM3+_d<%dYy3^mnwA+I9v0FVk0Wx+FWt-jAY>b7?s@gEuG!~Sz16>NOJ0mr*Gikg<;@qKsA=54ld>ANSDW!pUv|g6}sov=OJM;dwOb`5SJg7(iwk>7#RZ2rH`BYLJSIE$R zelO3HlIj#XzVN$o(RXaB#t(je0N%LvOlp9&>2?gjm><{tH>QoDqlZ9QTWbz-xXteykotP&Q#63@xfWpznk|MaqSVe+kfI|XE9gt-T7Fs z?;n3rRl>II!d^el^Ta@V9d7EsHF5V1ROYBbxrply#`Sj}Uu0Wns%GSO65x&NF<_K6 z>Q)TEdq3Cxm(@u7kM2`d#ky)6eP+1wT;3oY zysnX19r3#heae{js?+)>KkbLFT<}$eIOoJ#n<~T)Ue^fRk;)z4;zOf`FH{r*>2kvJ0xYP}#g* zIJn~-capJbH;VQF_&oxA=$#LFU-03J7i{bB?2$vZHFzkw#&)@!5qtf3#~apcvN7^? zVg1LnyP$t>{mAoY>>k3%)tSx1=7uQ&j&+Ju(OWaK;~Y zaXgd~YlrzB!cN&2P7a93Kf})t?Uai=&hRZA*Jg|AAM5=(;|mc^@A~soLOSGegteC6 z_QQLH%EVqj-t&gm>k(cLI30^TSu1yNzO%mXTJ%#PE%I6-wfbbsV@*P3ez+Zb{nU!N z3+FEtZU4!pS6r~GWsj3`F4A+H`VGa zG7o3uql+1Sk625cKHUC}i2O4fe>S_O@@laS=UUdsb_ev|1H3Kc?HcFzy!@Py9yc}3 ztI4)={l{&W$8!c!GIPMqAS}ewX-``)ft;^x_=Pf^z<2i$N51y-3>$`^HI^w&dTpNM@ z9mUg@Zk^(^=zRQ*56-e=|JlO_?aDuMx@V82GMvH$*Qv;FC-jg0zr=AqmH6B~e}-*c za>i{>{Hm%p(==C5WUS%g^uMc?QK^ndtElH%v2~*VkLQ1i=%2MA@?M_T@~);Y-ltdO zz+OL&>bI)Ds`%51=YM$rr;z?d?#+1&VHLh)c-*eY(e=xpRV|~M4M=w^hB)#5595Cn z(*OR~SJ;*(ujO4$TO!xzwMInO8?R;`(rbzhPK^J-_+N$ezn$*k?H-qgs*lSRdBgv3pc{KQHoZA-mVl53$#ek-t3r zO?dq;tpBN={zcx=o!s4R+Ww2|_3gx7!yf9y6cb!qmwpw&t z0rvV)k0(y-|B3y-)$=R2X(DU%99;Kinf^uQ7GF5|pR((Px!qNy?vCvL&FcqHeReBS zC-5wPU%R>4{ye?^?A%_4qsU(0JRSl3P_CWWCA#)UAwTUSwg&!H%)dNVkJj|Da~SQ- zC$iT!uNMY>sKpO@Z~s<7zoLK1w@cw)wfxKeIJIw=9r|DgYT zzf2jO#I+@&x-aBU#-lTpsh4UvduY$03^Z05yYKMHbuv^8<}4NmFZ=sHyA zP6g{7>UL=B?;f?KVa~+Q@a-IfhYo}qb44}KVWe3nf5Upf^4fK)91|J8Z%6x_=jg^( z=wG?MzxIfeS~t2@OFZ&TtGvW{@?r(Azb*6Ndk*+lo`0=i&GYn(H0$JV$)<0AjBGh+ z#U7j^j~~4Lw#{1PXA9nzasi4Uh>|4ZXWMFwHW$WME~iyHd}_qE0wWUZ@8ZHe-SZ2jTmrX z%|G3ge`eEyInciv`oBQW{XSVqo3heUdh{jeUk&|tKl6Ln^nYpSY$iqb81S|CzNY7X zFUrz)wQ^b$Q*Hq#yypb_U~|(v*YtmKcQ>@JM%$lB#Ufv;rc0UW#Jx7e00m+|c3<#w zuEcS;@HeUFcr{{ZspNa?ks&=zr&O?DeAp$6NG<+pTC)<~b|t z>#rbe_NstS*ljqt?oC(p-|^`EYP5f<^ufKbBc|=iG&Nl=Q`=U{qI;59*tMrZ{oh~y zq~rP@`3QUcsNh?&q0y=yWuKRw6A;&qD1uXH+p5l=-|x8ovDc3R_jaT$ug3aYjU}}I zKpgt_mMiH5?F&xrwUfu;*0rIHr>gKdtw(jMI`rzi)rrJaG#;Q&70kkF2e;Qw9=y+J zSg};3-UnD)Ug^4)a_Kq1y^3HKTF!EN?c{;Ie!T6|(RiPGYSEyV=XTAQaV6=$2Qk1q z&hXxRP938owAT;qGYWrt=FfesDK9l$YuTxob{F{K8DBEoUORQ1J=6#7d-1L>UuIH| z-dU^uygolaH+Vf_fHyIqb@sFj?+bkP$RXiR-`oF!{yow^t+n0m^&DTju_PIf-VFVF zqkrHY2;4_o8mD~KuKrZWM?8EMTnq#k(Fc&;2Z%0HLBk?8w9e2zP5Q?=q0+g7nB((y zXD}I?cB6>)RLM(znCEj}pzTBalFfm6s_0kLj{KgXornQwlfeN?9O&fvbfUUbDSt~s zyNP^14F6m5zwEXK<*C!Zxx`vgNxM$jlF}oKpnvEe9H_{Fg-+>Bwd-W(2h#jy_`jn6 z(H5)I7R&VoX(Ksp+Q(JXt&{fT_Y4_93_uJ32PSY}#LK-roYZ)=Ytj6`hvEAPzL(q1 zgtdKDxZbpWUUN0w1SOs4GSIljO^5-A0pI{}Ag*oXcVt1)x0QOVOHBO&`5xnf6mFBC zO)2qtkhNe%GeJ8itw*MUV^TPV`wKLU#2wdj%C$oCi* zAgArY^8oK~9$~JBLDRL?k@Sywg5L2GF+m3QvFO}dbrF{H^J|7J4M`smdXu3ymCA@~ zOZ>W)m#i3+ohuyERwD-BSwO%zuqte0eC)=I_UY5FBj3LpF#n^ySE((QYs*&Vv|T-f zG2gnG4WA+XA4Cj5KfvD{i1sm;!>D1=I=3`x|3kh%?r;A?zajSD@ix8Ti=sLRbN<%G zhOZER213js+&9SWt2>4F0CP9Y+I6b#t%)hOkp3}O$e%iJN>6^e&KcSR^J`A=AJ?g? zYiW#5rWkMz{Q%rgD9(X%X?@DKpjqb;$j1Qm1JDmpwJ$IT?K`jg;w$Fw1@JroF+jB# zFu>d_<|@9sgFyb7Al?fg2B;DPj-}&~FT2BbT-68xey>9Jffyfv7~pXXi1y(fpOHe{ z|1Cd8XshDSgFNOZo)@~vkF6Nn6 zVu%4w$AEsU+aq7kc1p~zCx|sg5d)lx0i9T{&yEtk%foA{CS%$&crJ+N|AHKN8te3W zr(@BQ^Lvb7%rN=_we|%D!@f6%sP}o;bk1o`?H|*o!!zTpZX-eYF_g<9n}@pj)uAzW$12T>CD?fHuSdkK;g0 zYi-dRZfBnaop-38dI<17PxO4?C5i(V5euZnf(u#9vB#Erf(WTaYxRcfd2J|)0Xq=~ zT#ExclX_$hV;xeOq1uWg!0(w_;*oFCv%=>o4q!~7_?W^$dXB%GF~vJP#mNuzCLqAi zqSCSG_b3jmpwAHc1(y2-jMWOp4$t!@PzwL>DFSQeN3WqVXy4Drf|G~^WwC%US-}`F zRz7tz%mxBg5#V_z+@Gbj$sQ%n^ZY{|mo970n=0jSojVx=o_CMAI8|wb69S4rYvS%3 za(#oy%t-)Sajn5D-Fu_m#@+V-Q(D?c-|{5BB@8f3QDi zdt?FE1$$))Q39Dmj=-iwP1~h5l}*56@*hM4uD%IZdDQ z^x?Ho`NQA&H%{Yo_*}}9&HG9^7I~7d$=BvGQhLL!Tqc(-rIIKF2mk>f00e*l5C8%| z00;m9AOHk_01yBIKmZ5;0U!VbfB+Bx0zd!=00AHX1b_e#00Kb36oCwmA_N$wqJNft zO>z1arN55-`vND|)lC}vq2x<*Ho5MylAjCz%x5>fKq{Br%!1KEa$Q|h;gZFFPmQPl zSMl$K?_-xYDPTOiAx2}?^LdjB7ajlGTb%!@@!t#Inv2uRAu6gJpV(B$R`@%icmo59 zYR~!05J>T_<42j3$CGm33gzbimY7#&V2OEU29}stW?-tc{1OA3OVdjbEKM$P;c$8S zJoRBu8B zdyVWX;&{p^y$FD2qx2#GjFO80FsyzN0EX2s0>H5PMF1F9zX*U{WBbMcsFt2G&OZiF zvvK+uK*q`A098phfo}{z6ZpmeG=Xmnz-s9x@Qwl8Yi55Q;A-h9v;290o6XYm09Q*l z%U*Cmm2^}1^A0eDKktBQ>89}K9aJsd)IE6zRZH(RuYcBo)zVYu`LhlbO>Z{8ewF~$ z(#^By2v8-xg8Q=s5KXT@k1T;i(<{&?OEA&&3iQen(CzfzO8z4PyPck@R6Ys7>-6SI z_K|>8Pp@P?3mEB@5CA@v&;ftc=SOoT2l^m>r7GF4K>N^J$vz3~r`!F5YS$O-|5VE_ z+JCE-U$p;OEq}!ZmLFkM;t!`co1b8*|DH0>@5uc{ule;Y-Tzb|SoSGiwe$)En#A~3 zORqqX$(+AhdIbVBasCPp&L&q)Z#Dxs>;4J^$)~5x@@L&&p@*M!U-fiT2jw5anmS-o zuKv~1O&u^fm*3O@O}TVafb+>!(@h+Zhu8ELB@eGDfO&YUr<(vg_Ke#Ez)6MrnE*Js zkZuBCQz6|1ps{4*5H$vm2|#1;m;f*aPbOmsz*zN7BtB`}K0^Q|kEa^~&@`TI2tZM? z5dcNiHw2)l`i2!Os-9s5Cl!}(P{GN?=>`>;kX&AY3FaGAV1oGu6_{YYK?NpFEWgYH zO%v10Do~PK`k0`^yfOnz%quf+Qt9=3.14.1", "pyside6>=6.11.2", "pyyaml>=6.0.3", + "ruamel-yaml>=0.19.1", "silero-vad>=6.2.1", "sounddevice>=0.5.6", "torch>=2.14.0", @@ -24,6 +25,7 @@ agr-assistent = "agr_assistent.app:main" [dependency-groups] dev = [ + "pyinstaller>=6.22.3", "pytest>=9.1.1", ] diff --git a/src/agr_assistent/app.py b/src/agr_assistent/app.py index 4ffc337..10d9250 100644 --- a/src/agr_assistent/app.py +++ b/src/agr_assistent/app.py @@ -2,45 +2,75 @@ from __future__ import annotations +import argparse import logging +import os import signal import sys +import threading +from logging.handlers import RotatingFileHandler +from pathlib import Path +from PySide6.QtCore import QLibraryInfo, QLocale, QTranslator from PySide6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon -from agr_assistent import APP_NAME +from agr_assistent import APP_NAME, __version__, system from agr_assistent.audio.player import AudioPlayer from agr_assistent.audio.recorder import SpeechRecorder from agr_assistent.audio.wakeword import VoskWakeWord -from agr_assistent.config import ConfigError, data_dir, load_config +from agr_assistent.config import AppConfig, ConfigError, data_dir, load_config from agr_assistent.core.assistant import Assistant +from agr_assistent.core.settings import Settings from agr_assistent.core.speech import Speaker from agr_assistent.core.voice import VoiceInput from agr_assistent.core.wake import WakeWordListener from agr_assistent.hotkey import GlobalHotkey, HotkeyError +from agr_assistent.single_instance import SingleInstance from agr_assistent.stt.whisper import WhisperSTT from agr_assistent.tts.silero import SileroTTS from agr_assistent.ui.chat_window import ChatWindow from agr_assistent.ui.icons import state_icon +from agr_assistent.ui.settings_dialog import SettingsDialog from agr_assistent.ui.tray import TrayIcon log = logging.getLogger(__name__) +_WAIT_PREVIOUS_INSTANCE_SECONDS = 15 +_LOG_FILE_BYTES = 1_000_000 +_LOG_FILE_COUNT = 3 -def main() -> int: - logging.basicConfig( - level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" - ) + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog=APP_NAME, description="AI-ассистент в системном трее") + parser.add_argument("--config", type=Path, help="путь к config.yaml") + parser.add_argument("--wait-pid", type=int, help=argparse.SUPPRESS) + _ensure_standard_streams() + args, qt_args = parser.parse_known_args(sys.argv[1:] if argv is None else argv) + + _setup_logging() + log.info("%s %s", APP_NAME, __version__) # Ctrl+C в терминале завершает приложение signal.signal(signal.SIGINT, signal.SIG_DFL) _set_windows_app_id() + if args.wait_pid: + system.wait_for_process_exit(args.wait_pid, _WAIT_PREVIOUS_INSTANCE_SECONDS) - app = QApplication(sys.argv) + app = QApplication([sys.argv[0], *qt_args]) app.setApplicationName(APP_NAME) app.setQuitOnLastWindowClosed(False) + # Стандартные кнопки и диалоги Qt на языке системы + translator = QTranslator(app) + translations = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + if translator.load(QLocale.system(), "qtbase", "_", translations): + app.installTranslator(translator) + + instance = SingleInstance(app) + if not instance.acquire(): + log.info("Приложение уже запущено — показываю его окно") + return 0 try: - config = load_config() + config = load_config(args.config.resolve() if args.config else None) except ConfigError as exc: QMessageBox.critical(None, APP_NAME, str(exc)) return 1 @@ -50,6 +80,7 @@ def main() -> int: QMessageBox.critical(None, APP_NAME, "Системный трей недоступен") return 1 + settings = Settings(config, app) models_dir = data_dir() / "models" speaker = Speaker( SileroTTS(config.tts, models_dir), AudioPlayer(), enabled=config.tts.enabled @@ -75,6 +106,25 @@ def main() -> int: assistant = Assistant(config.llm, speaker, voice, wake_word) app.setWindowIcon(state_icon(assistant.state)) window = ChatWindow(assistant) + instance.activated.connect(window.show_and_raise) + + dialog: SettingsDialog | None = None + + def open_settings() -> None: + nonlocal dialog + if dialog is None or not dialog.isVisible(): + dialog = SettingsDialog(settings, window) + dialog.show() + dialog.raise_() + dialog.activateWindow() + + def apply_settings(new_config: AppConfig) -> None: + assistant.update_llm_config(new_config.llm) + assistant.set_speech_enabled(new_config.tts.enabled) + assistant.set_wake_word_enabled(new_config.wake_word.enabled) + + settings.changed.connect(apply_settings) + window.settings_requested.connect(open_settings) hotkey_spec = config.voice.hotkey if voice is not None else "" if hotkey_spec: @@ -89,7 +139,7 @@ def main() -> int: app.aboutToQuit.connect(hotkey.stop) hotkey.start() - tray = TrayIcon(assistant, window, hotkey_spec, config.wake_word.phrases) + tray = TrayIcon(assistant, window, settings, open_settings, hotkey_spec) tray.show() if config.ui.start_minimized: @@ -100,6 +150,43 @@ def main() -> int: return app.exec() +def _ensure_standard_streams() -> None: + """В сборке без консоли stdout/stderr равны None, а библиотеки (tqdm, argparse) пишут в них.""" + if sys.stdout is None: + sys.stdout = open(os.devnull, "w", encoding="utf-8") + if sys.stderr is None: + sys.stderr = open(os.devnull, "w", encoding="utf-8") + + +def _setup_logging() -> None: + log_dir = data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + handlers: list[logging.Handler] = [ + RotatingFileHandler( + log_dir / f"{APP_NAME}.log", + maxBytes=_LOG_FILE_BYTES, + backupCount=_LOG_FILE_COUNT, + encoding="utf-8", + ) + ] + if not getattr(sys, "frozen", False): + handlers.append(logging.StreamHandler()) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + handlers=handlers, + ) + logging.getLogger("httpx").setLevel(logging.WARNING) + + def log_uncaught(exc_type, exc_value, exc_traceback) -> None: # type: ignore[no-untyped-def] + log.critical("Необработанное исключение", exc_info=(exc_type, exc_value, exc_traceback)) + + sys.excepthook = log_uncaught + threading.excepthook = lambda hook_args: log_uncaught( + hook_args.exc_type, hook_args.exc_value, hook_args.exc_traceback + ) + + def _set_windows_app_id() -> None: """Своя группа на панели задач и корректное имя в уведомлениях Windows.""" if sys.platform == "win32": diff --git a/src/agr_assistent/config.py b/src/agr_assistent/config.py index 51ea4b3..e14c817 100644 --- a/src/agr_assistent/config.py +++ b/src/agr_assistent/config.py @@ -125,6 +125,11 @@ def default_config_text() -> str: def load_config(path: Path | None = None) -> AppConfig: path = path or resolve_config_path() + return parse_config(load_config_data(path), path) + + +def load_config_data(path: Path) -> dict[str, Any]: + """Настройки как есть (без подстановки ${VAR}), недостающие ключи — из умолчаний.""" if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(default_config_text(), encoding="utf-8") @@ -138,7 +143,60 @@ def load_config(path: Path | None = None) -> AppConfig: raise ConfigError(f"{path}: ожидался словарь настроек верхнего уровня") defaults = yaml.safe_load(default_config_text()) - return parse_config(_deep_merge(defaults, user_data), path) + return _deep_merge(defaults, user_data) + + +def get_value(data: dict[str, Any], key: str) -> Any: + """get_value(data, "llm.provider")""" + node: Any = data + for part in key.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + +def save_config_updates(path: Path, updates: dict[str, Any]) -> AppConfig: + """Записывает изменения вида {"llm.provider": "openrouter"}, сохраняя комментарии. + + Итоговый конфиг проверяется до записи: ошибочные значения не попадут в файл. + """ + merged = load_config_data(path) + for key, value in updates.items(): + _set_value(merged, key, value) + config = parse_config(merged, path) + if not updates: + return config + + from ruamel.yaml import YAML + from ruamel.yaml.comments import CommentedMap + + round_trip = YAML() + round_trip.preserve_quotes = True + round_trip.width = 4096 + round_trip.indent(mapping=2, sequence=4, offset=2) + document = round_trip.load(path.read_text(encoding="utf-8")) or CommentedMap() + for key, value in updates.items(): + _set_value(document, key, value, CommentedMap) + + temp_path = path.with_suffix(".tmp") + with temp_path.open("w", encoding="utf-8") as file: + round_trip.dump(document, file) + temp_path.replace(path) + log.info("Настройки сохранены: %s", ", ".join(updates)) + return config + + +def _set_value( + data: dict[str, Any], key: str, value: Any, mapping_type: type[dict] = dict +) -> None: + *parents, last = key.split(".") + node = data + for part in parents: + if not isinstance(node.get(part), dict): + node[part] = mapping_type() + node = node[part] + node[last] = value def parse_config(data: dict[str, Any], path: Path) -> AppConfig: @@ -148,7 +206,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig: str(name): ProviderConfig( name=str(name), base_url=str(item["base_url"]).rstrip("/"), - api_key=_expand_env(str(item.get("api_key") or "")), + api_key=expand_env(str(item.get("api_key") or "")), model=str(item["model"]), ) for name, item in (llm_data.get("providers") or {}).items() @@ -214,7 +272,7 @@ def parse_config(data: dict[str, Any], path: Path) -> AppConfig: ) -def _expand_env(value: str) -> str: +def expand_env(value: str) -> str: """Подставляет ${VAR} из окружения; незаданная переменная даёт пустую строку.""" return _ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), ""), value) diff --git a/src/agr_assistent/core/assistant.py b/src/agr_assistent/core/assistant.py index 6faf53a..78c0417 100644 --- a/src/agr_assistent/core/assistant.py +++ b/src/agr_assistent/core/assistant.py @@ -167,6 +167,16 @@ class Assistant(QObject): log.info("Провайдер LLM: %s (%s)", name, self.model_name) self.provider_changed.emit(name) + def update_llm_config(self, config: LLMConfig) -> None: + """Применяет новые настройки модели; текущий ответ прерывается, только если они изменились.""" + changed = config != self._config + self._config = config + if changed: + self.cancel() + self._client = None + log.info("Настройки LLM обновлены: %s (%s)", config.provider, self.model_name) + self.provider_changed.emit(config.provider) + def set_speech_enabled(self, enabled: bool) -> None: if enabled == self._speaker.enabled: return diff --git a/src/agr_assistent/core/settings.py b/src/agr_assistent/core/settings.py new file mode 100644 index 0000000..1a552f3 --- /dev/null +++ b/src/agr_assistent/core/settings.py @@ -0,0 +1,45 @@ +"""Текущие настройки приложения и их сохранение.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any + +from PySide6.QtCore import QObject, Signal + +from agr_assistent.config import AppConfig, load_config_data, save_config_updates + + +def needs_restart(old: AppConfig, new: AppConfig) -> bool: + """На лету применяются настройки LLM, интерфейса и включение озвучки и слова активации; + смена моделей, голоса, горячей клавиши и параметров записи требует перезапуска.""" + return ( + replace(old.tts, enabled=False) != replace(new.tts, enabled=False) + or old.stt != new.stt + or old.voice != new.voice + or replace(old.wake_word, enabled=False) != replace(new.wake_word, enabled=False) + ) + + +class Settings(QObject): + changed = Signal(object) # AppConfig + + def __init__(self, config: AppConfig, parent: QObject | None = None) -> None: + super().__init__(parent) + self._config = config + + @property + def config(self) -> AppConfig: + return self._config + + def raw(self) -> dict[str, Any]: + """Значения как в файле: ${VAR} не подставлены, чтобы не записать секрет вместо ссылки.""" + return load_config_data(self._config.path) + + def save(self, updates: dict[str, Any]) -> AppConfig: + """Бросает ConfigError, если значения некорректны; файл при этом не меняется.""" + if not updates: + return self._config + self._config = save_config_updates(self._config.path, updates) + self.changed.emit(self._config) + return self._config diff --git a/src/agr_assistent/single_instance.py b/src/agr_assistent/single_instance.py new file mode 100644 index 0000000..e25b39b --- /dev/null +++ b/src/agr_assistent/single_instance.py @@ -0,0 +1,48 @@ +"""Не даёт запустить второй экземпляр: вместо этого показывает окно уже запущенного.""" + +from __future__ import annotations + +import getpass +import logging + +from PySide6.QtCore import QObject, Signal +from PySide6.QtNetwork import QLocalServer, QLocalSocket + +from agr_assistent import APP_NAME + +log = logging.getLogger(__name__) + +_CONNECT_TIMEOUT_MS = 500 + + +class SingleInstance(QObject): + activated = Signal() # кто-то попытался запустить приложение повторно + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._name = f"{APP_NAME}-{getpass.getuser()}" + self._server: QLocalServer | None = None + + def acquire(self) -> bool: + """True — мы единственный экземпляр; False — уже запущенный разбужен.""" + socket = QLocalSocket() + socket.connectToServer(self._name) + if socket.waitForConnected(_CONNECT_TIMEOUT_MS): + socket.write(b"show") + socket.waitForBytesWritten(_CONNECT_TIMEOUT_MS) + socket.disconnectFromServer() + return False + + # Сокет мог остаться после аварийного завершения + QLocalServer.removeServer(self._name) + self._server = QLocalServer(self) + self._server.newConnection.connect(self._on_new_connection) + if not self._server.listen(self._name): + log.warning("Не удалось занять имя экземпляра: %s", self._server.errorString()) + return True + + def _on_new_connection(self) -> None: + assert self._server is not None + while connection := self._server.nextPendingConnection(): + connection.disconnected.connect(connection.deleteLater) + self.activated.emit() diff --git a/src/agr_assistent/stt/whisper.py b/src/agr_assistent/stt/whisper.py index 2b05bf3..cf18ec7 100644 --- a/src/agr_assistent/stt/whisper.py +++ b/src/agr_assistent/stt/whisper.py @@ -96,11 +96,17 @@ def _add_cuda_dll_directories() -> None: """cuBLAS и cuDNN из pip-пакетов nvidia-* не лежат в PATH — добавляем их сами.""" if sys.platform != "win32": return + package_dirs: list[str] = [] try: import nvidia + + package_dirs.extend(nvidia.__path__) except ImportError: - return - for package_dir in nvidia.__path__: + pass + # В сборке PyInstaller библиотеки лежат рядом с программой + if bundle_dir := getattr(sys, "_MEIPASS", None): + package_dirs.append(os.path.join(bundle_dir, "nvidia")) + for package_dir in package_dirs: for bin_dir in Path(package_dir).glob("*/bin"): if str(bin_dir) not in os.environ["PATH"]: os.add_dll_directory(str(bin_dir)) diff --git a/src/agr_assistent/system.py b/src/agr_assistent/system.py new file mode 100644 index 0000000..7b1c3c3 --- /dev/null +++ b/src/agr_assistent/system.py @@ -0,0 +1,76 @@ +"""Интеграция с Windows: автозапуск, перезапуск, ожидание завершения процесса.""" + +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +from pathlib import Path + +from agr_assistent import APP_NAME + +_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" +_SYNCHRONIZE = 0x00100000 +_DETACHED_PROCESS = 0x00000008 + + +def launch_command(config_path: Path) -> list[str]: + """Команда запуска приложения без консольного окна.""" + if getattr(sys, "frozen", False): + command = [sys.executable] + else: + pythonw = Path(sys.executable).with_name("pythonw.exe") + interpreter = pythonw if pythonw.exists() else Path(sys.executable) + command = [str(interpreter), "-m", "agr_assistent"] + return [*command, "--config", str(config_path)] + + +def autostart_supported() -> bool: + return sys.platform == "win32" + + +def is_autostart_enabled() -> bool: + if not autostart_supported(): + return False + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY) as key: + winreg.QueryValueEx(key, APP_NAME) + return True + except FileNotFoundError: + return False + + +def set_autostart(enabled: bool, config_path: Path) -> None: + import winreg + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _RUN_KEY, 0, winreg.KEY_SET_VALUE) as key: + if enabled: + command = subprocess.list2cmdline(launch_command(config_path)) + winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, command) + else: + try: + winreg.DeleteValue(key, APP_NAME) + except FileNotFoundError: + pass + + +def restart(config_path: Path) -> None: + """Запускает новый экземпляр, который дождётся завершения текущего.""" + command = [*launch_command(config_path), "--wait-pid", str(os.getpid())] + flags = _DETACHED_PROCESS if sys.platform == "win32" else 0 + subprocess.Popen(command, creationflags=flags, close_fds=True) + + +def wait_for_process_exit(pid: int, timeout_seconds: float) -> None: + if sys.platform != "win32": + return + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(_SYNCHRONIZE, False, pid) + if handle: + try: + kernel32.WaitForSingleObject(handle, int(timeout_seconds * 1000)) + finally: + kernel32.CloseHandle(handle) diff --git a/src/agr_assistent/ui/chat_window.py b/src/agr_assistent/ui/chat_window.py index f895756..51514ae 100644 --- a/src/agr_assistent/ui/chat_window.py +++ b/src/agr_assistent/ui/chat_window.py @@ -63,6 +63,8 @@ class _MessageInput(QPlainTextEdit): class ChatWindow(QWidget): + settings_requested = Signal() + def __init__(self, assistant: Assistant) -> None: super().__init__() self._assistant = assistant @@ -95,9 +97,13 @@ class ChatWindow(QWidget): self._voice_button.clicked.connect(assistant.toggle_listening) self._voice_button.setVisible(assistant.voice_available) + settings_button = QPushButton("Настройки") + settings_button.clicked.connect(self.settings_requested) + header = QHBoxLayout() header.addWidget(self._provider_label, 1) header.addWidget(new_chat_button) + header.addWidget(settings_button) buttons = QVBoxLayout() buttons.addWidget(self._send_button) diff --git a/src/agr_assistent/ui/icons.py b/src/agr_assistent/ui/icons.py index 4646d83..6c4d7cf 100644 --- a/src/agr_assistent/ui/icons.py +++ b/src/agr_assistent/ui/icons.py @@ -30,21 +30,26 @@ _ICON_SIZE = 64 @cache def state_icon(state: AssistantState) -> QIcon: - pixmap = QPixmap(_ICON_SIZE, _ICON_SIZE) + return QIcon(state_pixmap(state, _ICON_SIZE)) + + +def state_pixmap(state: AssistantState, size: int) -> QPixmap: + pixmap = QPixmap(size, size) pixmap.fill(Qt.GlobalColor.transparent) + margin = max(1, size // 32) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(QColor(_STATE_COLORS[state])) - painter.drawEllipse(2, 2, _ICON_SIZE - 4, _ICON_SIZE - 4) + painter.drawEllipse(margin, margin, size - 2 * margin, size - 2 * margin) font = painter.font() - font.setPixelSize(38) + font.setPixelSize(round(size * 0.6)) font.setBold(True) painter.setFont(font) painter.setPen(QColor("white")) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "A") painter.end() - return QIcon(pixmap) + return pixmap diff --git a/src/agr_assistent/ui/settings_dialog.py b/src/agr_assistent/ui/settings_dialog.py new file mode 100644 index 0000000..d0f8172 --- /dev/null +++ b/src/agr_assistent/ui/settings_dialog.py @@ -0,0 +1,359 @@ +"""Окно настроек.""" + +from __future__ import annotations + +import logging +import threading +from pathlib import Path +from typing import Any + +from PySide6.QtCore import QObject, Qt, QUrl, Signal +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSpinBox, + QTabWidget, + QToolButton, + QVBoxLayout, + QWidget, +) + +from agr_assistent import APP_NAME, system +from agr_assistent.config import ConfigError, data_dir, expand_env, get_value +from agr_assistent.core.settings import Settings, needs_restart + +log = logging.getLogger(__name__) + +_TTS_SPEAKERS = ["xenia", "baya", "kseniya", "aidar", "eugene"] +_STT_MODELS = ["large-v3-turbo", "large-v3", "medium", "small", "base", "tiny"] +_STT_DEVICES = ["auto", "cuda", "cpu"] + + +class _ModelListLoader(QObject): + """Загружает список моделей провайдера в фоне.""" + + loaded = Signal(list) + failed = Signal(str) + + def load(self, base_url: str, api_key: str) -> None: + threading.Thread( + target=self._run, args=(base_url, api_key), name="llm-models", daemon=True + ).start() + + def _run(self, base_url: str, api_key: str) -> None: + try: + from openai import OpenAI + + client = OpenAI(base_url=base_url, api_key=api_key or "none", timeout=15, max_retries=0) + self.loaded.emit(sorted(model.id for model in client.models.list())) + except Exception as exc: + self.failed.emit(str(exc)) + + +class SettingsDialog(QDialog): + def __init__(self, settings: Settings, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._settings = settings + self._raw = settings.raw() + self._provider_edits: dict[str, dict[str, str]] = { + name: {field: str(values.get(field) or "") for field in ("base_url", "api_key", "model")} + for name, values in (get_value(self._raw, "llm.providers") or {}).items() + } + self._current_provider = "" + + self.setWindowTitle(f"{APP_NAME} — настройки") + self.setMinimumWidth(560) + + tabs = QTabWidget() + tabs.addTab(self._build_llm_tab(), "Модель") + tabs.addTab(self._build_speech_tab(), "Озвучка") + tabs.addTab(self._build_voice_tab(), "Голосовой ввод") + tabs.addTab(self._build_general_tab(), "Общие") + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self._save) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addWidget(tabs) + layout.addWidget(buttons) + + # --- вкладки + + def _build_llm_tab(self) -> QWidget: + self._provider = QComboBox() + self._provider.addItems(list(self._provider_edits)) + self._base_url = QLineEdit() + self._api_key = QLineEdit() + self._api_key.setEchoMode(QLineEdit.EchoMode.Password) + self._api_key.setPlaceholderText("ключ или ${ПЕРЕМЕННАЯ_ОКРУЖЕНИЯ}") + + self._model = QComboBox() + self._model.setEditable(True) + self._model.setSizePolicy(self._base_url.sizePolicy()) + refresh_models = QToolButton() + refresh_models.setText("↻") + refresh_models.setToolTip("Загрузить список моделей с сервера") + refresh_models.clicked.connect(self._load_models) + model_row = QHBoxLayout() + model_row.addWidget(self._model, 1) + model_row.addWidget(refresh_models) + self._models_status = QLabel() + self._models_status.setWordWrap(True) + + self._model_loader = _ModelListLoader(self) + self._model_loader.loaded.connect(self._on_models_loaded) + self._model_loader.failed.connect( + lambda message: self._models_status.setText(f"Не удалось загрузить: {message}") + ) + + self._system_prompt = QPlainTextEdit(str(get_value(self._raw, "llm.system_prompt") or "")) + self._system_prompt.setMaximumHeight(90) + self._temperature = QDoubleSpinBox() + self._temperature.setRange(0, 2) + self._temperature.setSingleStep(0.1) + self._temperature.setValue(float(get_value(self._raw, "llm.temperature"))) + self._max_history = QSpinBox() + self._max_history.setRange(0, 1000) + self._max_history.setSpecialValueText("вся история") + self._max_history.setValue(int(get_value(self._raw, "llm.max_history_messages"))) + + self._provider.currentTextChanged.connect(self._switch_provider) + self._provider.setCurrentText(str(get_value(self._raw, "llm.provider"))) + self._switch_provider(self._provider.currentText()) + + form = QFormLayout() + form.addRow("Провайдер", self._provider) + form.addRow("Адрес API", self._base_url) + form.addRow("API-ключ", self._api_key) + form.addRow("Модель", model_row) + form.addRow("", self._models_status) + form.addRow("Системный промпт", self._system_prompt) + form.addRow("Температура", self._temperature) + form.addRow("Сообщений истории", self._max_history) + return _page(form) + + def _build_speech_tab(self) -> QWidget: + self._tts_enabled = QCheckBox("Озвучивать ответы") + self._tts_enabled.setChecked(bool(get_value(self._raw, "tts.enabled"))) + self._tts_speaker = _combo(_TTS_SPEAKERS, str(get_value(self._raw, "tts.speaker"))) + + form = QFormLayout() + form.addRow(self._tts_enabled) + form.addRow("Голос", self._tts_speaker) + return _page(form) + + def _build_voice_tab(self) -> QWidget: + self._voice_enabled = QCheckBox("Голосовой ввод") + self._voice_enabled.setChecked(bool(get_value(self._raw, "voice.enabled"))) + self._hotkey = QLineEdit(str(get_value(self._raw, "voice.hotkey") or "")) + self._hotkey.setPlaceholderText("например, win+alt+space; пусто — без клавиши") + self._sound_cues = QCheckBox("Звуковые сигналы начала и конца записи") + self._sound_cues.setChecked(bool(get_value(self._raw, "voice.sound_cues"))) + self._silence = QDoubleSpinBox() + self._silence.setRange(0.3, 5) + self._silence.setSingleStep(0.1) + self._silence.setSuffix(" с") + self._silence.setValue(float(get_value(self._raw, "voice.silence_seconds"))) + self._vad_threshold = QDoubleSpinBox() + self._vad_threshold.setRange(0.1, 0.95) + self._vad_threshold.setSingleStep(0.05) + self._vad_threshold.setValue(float(get_value(self._raw, "voice.vad_threshold"))) + + self._stt_model = _combo(_STT_MODELS, str(get_value(self._raw, "stt.model"))) + self._stt_device = _combo(_STT_DEVICES, str(get_value(self._raw, "stt.device"))) + self._stt_language = QLineEdit(str(get_value(self._raw, "stt.language") or "")) + self._stt_language.setPlaceholderText("ru, en…; пусто — определять автоматически") + + self._wake_enabled = QCheckBox("Слушать слово активации") + self._wake_enabled.setChecked(bool(get_value(self._raw, "wake_word.enabled"))) + phrases = get_value(self._raw, "wake_word.phrases") or [] + if isinstance(phrases, str): + phrases = [phrases] + self._wake_phrases = QLineEdit(", ".join(str(phrase) for phrase in phrases)) + self._wake_phrases.setPlaceholderText("через запятую, например: ассистент, эй компьютер") + + form = QFormLayout() + form.addRow(self._voice_enabled) + form.addRow("Горячая клавиша", self._hotkey) + form.addRow(self._sound_cues) + form.addRow("Пауза в конце фразы", self._silence) + form.addRow("Порог детектора речи", self._vad_threshold) + form.addRow(_section("Распознавание")) + form.addRow("Модель Whisper", self._stt_model) + form.addRow("Устройство", self._stt_device) + form.addRow("Язык", self._stt_language) + form.addRow(_section("Слово активации")) + form.addRow(self._wake_enabled) + form.addRow("Фразы", self._wake_phrases) + return _page(form) + + def _build_general_tab(self) -> QWidget: + self._start_minimized = QCheckBox("Запускаться свёрнутым в трей") + self._start_minimized.setChecked(bool(get_value(self._raw, "ui.start_minimized"))) + + self._autostart = QCheckBox("Запускать вместе с Windows") + self._autostart.setVisible(system.autostart_supported()) + self._autostart.setChecked(system.is_autostart_enabled()) + + config_path = self._settings.config.path + open_config = QPushButton("Открыть config.yaml") + open_config.clicked.connect(lambda: _open_path(config_path)) + open_data = QPushButton("Открыть папку с моделями и логами") + open_data.clicked.connect(lambda: _open_path(data_dir())) + + note = QLabel( + "Редко используемые параметры (провайдеры, таймауты, модели Silero и Vosk) " + f"настраиваются в файле:
{config_path}" + ) + note.setWordWrap(True) + note.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + form = QFormLayout() + form.addRow(self._start_minimized) + form.addRow(self._autostart) + form.addRow(note) + form.addRow(open_config) + form.addRow(open_data) + return _page(form) + + # --- провайдеры и модели + + def _switch_provider(self, name: str) -> None: + if self._current_provider in self._provider_edits: + self._provider_edits[self._current_provider] = self._current_provider_fields() + self._current_provider = name + fields = self._provider_edits.get(name, {}) + self._base_url.setText(fields.get("base_url", "")) + self._api_key.setText(fields.get("api_key", "")) + self._model.clear() + self._model.setEditText(fields.get("model", "")) + self._models_status.clear() + + def _current_provider_fields(self) -> dict[str, str]: + return { + "base_url": self._base_url.text().strip(), + "api_key": self._api_key.text().strip(), + "model": self._model.currentText().strip(), + } + + def _load_models(self) -> None: + self._models_status.setText("Загружаю список моделей…") + self._model_loader.load( + self._base_url.text().strip(), expand_env(self._api_key.text().strip()) + ) + + def _on_models_loaded(self, models: list[str]) -> None: + current = self._model.currentText() + self._model.clear() + self._model.addItems(models) + self._model.setEditText(current) + self._models_status.setText(f"Доступно моделей: {len(models)}") + + # --- сохранение + + def _collect(self) -> dict[str, Any]: + self._provider_edits[self._current_provider] = self._current_provider_fields() + phrases = [phrase.strip() for phrase in self._wake_phrases.text().split(",") if phrase.strip()] + values: dict[str, Any] = { + "llm.provider": self._provider.currentText(), + "llm.system_prompt": self._system_prompt.toPlainText().strip(), + "llm.temperature": round(self._temperature.value(), 2), + "llm.max_history_messages": self._max_history.value(), + "tts.enabled": self._tts_enabled.isChecked(), + "tts.speaker": self._tts_speaker.currentText(), + "voice.enabled": self._voice_enabled.isChecked(), + "voice.hotkey": self._hotkey.text().strip().lower(), + "voice.sound_cues": self._sound_cues.isChecked(), + "voice.silence_seconds": round(self._silence.value(), 2), + "voice.vad_threshold": round(self._vad_threshold.value(), 2), + "stt.model": self._stt_model.currentText().strip(), + "stt.device": self._stt_device.currentText(), + "stt.language": self._stt_language.text().strip(), + "wake_word.enabled": self._wake_enabled.isChecked(), + "wake_word.phrases": phrases, + "ui.start_minimized": self._start_minimized.isChecked(), + } + for name, fields in self._provider_edits.items(): + for field, value in fields.items(): + values[f"llm.providers.{name}.{field}"] = value + # Записываем только изменённое, чтобы не раздувать пользовательский конфиг + return { + key: value for key, value in values.items() if _differs(get_value(self._raw, key), value) + } + + def _save(self) -> None: + old_config = self._settings.config + try: + new_config = self._settings.save(self._collect()) + except ConfigError as exc: + QMessageBox.warning(self, APP_NAME, f"Настройки не сохранены:\n{exc}") + return + + if self._autostart.isVisible() and self._autostart.isChecked() != system.is_autostart_enabled(): + try: + system.set_autostart(self._autostart.isChecked(), new_config.path) + except OSError as exc: + QMessageBox.warning(self, APP_NAME, f"Не удалось изменить автозапуск: {exc}") + + self.accept() + if needs_restart(old_config, new_config): + answer = QMessageBox.question( + self.parentWidget(), + APP_NAME, + "Часть изменений вступит в силу после перезапуска. Перезапустить сейчас?", + ) + if answer == QMessageBox.StandardButton.Yes: + system.restart(new_config.path) + from PySide6.QtWidgets import QApplication + + QApplication.quit() + + +def _differs(old: Any, new: Any) -> bool: + if old is None: + return new not in ("", [], None) + if isinstance(old, (int, float)) and not isinstance(old, bool) and isinstance(new, (int, float)): + return abs(float(old) - float(new)) > 1e-9 + if isinstance(new, str): + return str(old) != new + return old != new + + +def _combo(items: list[str], current: str) -> QComboBox: + combo = QComboBox() + combo.setEditable(True) + combo.addItems(items) + combo.setCurrentText(current) + return combo + + +def _section(title: str) -> QLabel: + label = QLabel(f"{title}") + label.setContentsMargins(0, 8, 0, 0) + return label + + +def _page(form: QFormLayout) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.addLayout(form) + layout.addStretch() + return page + + +def _open_path(path: Path) -> None: + QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) diff --git a/src/agr_assistent/ui/tray.py b/src/agr_assistent/ui/tray.py index c437072..a63562b 100644 --- a/src/agr_assistent/ui/tray.py +++ b/src/agr_assistent/ui/tray.py @@ -2,11 +2,16 @@ from __future__ import annotations +from collections.abc import Callable +from typing import Any + from PySide6.QtGui import QAction, QActionGroup from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon from agr_assistent import APP_NAME +from agr_assistent.config import ConfigError from agr_assistent.core.assistant import Assistant, AssistantState +from agr_assistent.core.settings import Settings from agr_assistent.ui.chat_window import ChatWindow from agr_assistent.ui.icons import STATE_TITLES, state_icon @@ -19,13 +24,14 @@ class TrayIcon(QSystemTrayIcon): self, assistant: Assistant, window: ChatWindow, + settings: Settings, + open_settings: Callable[[], None], hotkey: str = "", - wake_phrases: list[str] | None = None, ) -> None: - wake_phrases = wake_phrases or [] super().__init__(state_icon(assistant.state)) self._assistant = assistant self._window = window + self._settings = settings menu = QMenu() menu.addAction("Открыть чат", window.show_and_raise) @@ -34,10 +40,11 @@ class TrayIcon(QSystemTrayIcon): label = f"Говорить\t{hotkey.title()}" if hotkey else "Говорить" menu.addAction(label, assistant.toggle_listening) if assistant.wake_word_available: - phrases = ", ".join(f"«{phrase}»" for phrase in wake_phrases) + phrases = ", ".join(f"«{phrase}»" for phrase in settings.config.wake_word.phrases) wake_action = QAction(f"Слушать слово активации: {phrases}", self, checkable=True) wake_action.setChecked(assistant.wake_word_enabled) - wake_action.toggled.connect(assistant.set_wake_word_enabled) + # triggered — только действие пользователя: автоотключение после ошибки не сохраняем + wake_action.triggered.connect(self._on_wake_word_triggered) assistant.wake_word_enabled_changed.connect(wake_action.setChecked) menu.addAction(wake_action) menu.addSeparator() @@ -48,17 +55,18 @@ class TrayIcon(QSystemTrayIcon): for name in assistant.provider_names: action = QAction(name, self, checkable=True) action.setChecked(name == assistant.provider_name) - action.triggered.connect(lambda _checked=False, n=name: assistant.set_provider(n)) + action.triggered.connect(lambda _checked=False, n=name: self._on_provider_triggered(n)) provider_group.addAction(action) provider_menu.addAction(action) self._provider_actions[name] = action self._speech_action = QAction("Озвучивать ответы", self, checkable=True) self._speech_action.setChecked(assistant.speech_enabled) - self._speech_action.toggled.connect(assistant.set_speech_enabled) + self._speech_action.triggered.connect(self._on_speech_triggered) menu.addAction(self._speech_action) menu.addAction("Новый диалог", assistant.clear_history) + menu.addAction("Настройки…", open_settings) menu.addSeparator() menu.addAction("Выход", QApplication.quit) # Меню нужно держать в атрибуте, иначе его соберёт сборщик мусора @@ -82,6 +90,24 @@ class TrayIcon(QSystemTrayIcon): ): self._window.toggle_visibility() + def _on_provider_triggered(self, name: str) -> None: + self._assistant.set_provider(name) + self._persist({"llm.provider": name}) + + def _on_speech_triggered(self, enabled: bool) -> None: + self._assistant.set_speech_enabled(enabled) + self._persist({"tts.enabled": enabled}) + + def _on_wake_word_triggered(self, enabled: bool) -> None: + self._assistant.set_wake_word_enabled(enabled) + self._persist({"wake_word.enabled": enabled}) + + def _persist(self, updates: dict[str, Any]) -> None: + try: + self._settings.save(updates) + except (ConfigError, OSError) as exc: + self._assistant.error_occurred.emit(f"Не удалось сохранить настройки: {exc}") + def _on_state_changed(self, state: AssistantState) -> None: self.setIcon(state_icon(state)) self.setToolTip(f"{APP_NAME} — {STATE_TITLES[state]}") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1973fff --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import os + +import pytest + +# Окна в тестах не показываем +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication # noqa: E402 + + +@pytest.fixture(scope="session") +def qapp() -> QApplication: + return QApplication.instance() or QApplication([]) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..d165a9d --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,85 @@ +from dataclasses import replace +from pathlib import Path + +import pytest +from PySide6.QtWidgets import QApplication + +from agr_assistent.config import ConfigError, load_config, save_config_updates +from agr_assistent.core.settings import Settings, needs_restart +from agr_assistent.ui.settings_dialog import SettingsDialog + +_USER_CONFIG = """\ +# мой конфиг +llm: + provider: ollama # локально + providers: + openrouter: + api_key: ${TEST_SETTINGS_KEY} +""" + + +@pytest.fixture +def config_path(tmp_path: Path) -> Path: + path = tmp_path / "config.yaml" + path.write_text(_USER_CONFIG, encoding="utf-8") + return path + + +def test_save_keeps_comments_and_env_references(config_path: Path) -> None: + config = save_config_updates( + config_path, {"llm.provider": "openrouter", "wake_word.phrases": ["эй", "компьютер"]} + ) + + text = config_path.read_text(encoding="utf-8") + assert "# мой конфиг" in text + assert "provider: openrouter # локально" in text + assert "api_key: ${TEST_SETTINGS_KEY}" in text + assert config.llm.provider == "openrouter" + assert load_config(config_path).wake_word.phrases == ["эй", "компьютер"] + + +def test_invalid_update_does_not_touch_file(config_path: Path) -> None: + with pytest.raises(ConfigError): + save_config_updates(config_path, {"llm.provider": "nope"}) + + assert config_path.read_text(encoding="utf-8") == _USER_CONFIG + + +def test_restart_is_needed_only_for_heavy_settings(config_path: Path) -> None: + config = load_config(config_path) + + live = replace(config, tts=replace(config.tts, enabled=False)) + live.llm = replace(config.llm, temperature=0.1) + assert not needs_restart(config, live) + assert needs_restart(config, replace(config, voice=replace(config.voice, hotkey="f9"))) + assert needs_restart(config, replace(config, tts=replace(config.tts, speaker="aidar"))) + + +def test_dialog_saves_only_changed_values( + qapp: QApplication, config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("TEST_SETTINGS_KEY", "secret") + settings = Settings(load_config(config_path)) + changes: list[object] = [] + settings.changed.connect(changes.append) + dialog = SettingsDialog(settings) + + # Переключаемся на openrouter: ключ показан ссылкой на переменную, а не секретом + dialog._provider.setCurrentText("openrouter") + assert dialog._api_key.text() == "${TEST_SETTINGS_KEY}" + dialog._model.setEditText("anthropic/claude-sonnet-5") + dialog._tts_enabled.setChecked(False) + + assert dialog._collect() == { + "llm.provider": "openrouter", + "llm.providers.openrouter.model": "anthropic/claude-sonnet-5", + "tts.enabled": False, + } + + dialog._save() + + text = config_path.read_text(encoding="utf-8") + assert "secret" not in text + assert settings.config.llm.active_provider.model == "anthropic/claude-sonnet-5" + assert settings.config.llm.active_provider.api_key == "secret" + assert len(changes) == 1 diff --git a/tests/test_speaker.py b/tests/test_speaker.py index 0f39a1e..31e96c1 100644 --- a/tests/test_speaker.py +++ b/tests/test_speaker.py @@ -3,17 +3,11 @@ import time from collections.abc import Callable import numpy as np -import pytest from PySide6.QtCore import QCoreApplication from agr_assistent.core.speech import Speaker -@pytest.fixture(scope="module") -def qapp() -> QCoreApplication: - return QCoreApplication.instance() or QCoreApplication([]) - - class FakeEngine: sample_rate = 1000 diff --git a/tests/test_voice.py b/tests/test_voice.py index cab62e4..3616582 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -3,17 +3,11 @@ import time from collections.abc import Callable import numpy as np -import pytest from PySide6.QtCore import QCoreApplication from agr_assistent.core.voice import VoiceInput -@pytest.fixture(scope="module") -def qapp() -> QCoreApplication: - return QCoreApplication.instance() or QCoreApplication([]) - - class FakeRecorder: def __init__(self, *, wait_for_stop: bool = False, speech: bool = True) -> None: self.wait_for_stop = wait_for_stop diff --git a/tests/test_wake_listener.py b/tests/test_wake_listener.py index 46dff36..48beb24 100644 --- a/tests/test_wake_listener.py +++ b/tests/test_wake_listener.py @@ -2,17 +2,11 @@ import threading import time from collections.abc import Callable -import pytest from PySide6.QtCore import QCoreApplication from agr_assistent.core.wake import WakeWordListener -@pytest.fixture(scope="module") -def qapp() -> QCoreApplication: - return QCoreApplication.instance() or QCoreApplication([]) - - class FakeStream: def __init__(self, owner: "FakeMicrophone") -> None: self.owner = owner diff --git a/uv.lock b/uv.lock index e0c8cdc..1a48858 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,7 @@ dependencies = [ { name = "openai" }, { name = "pyside6" }, { name = "pyyaml" }, + { name = "ruamel-yaml" }, { name = "silero-vad" }, { name = "sounddevice" }, { name = "torch" }, @@ -29,6 +30,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pyinstaller" }, { name = "pytest" }, ] @@ -42,6 +44,7 @@ requires-dist = [ { name = "openai", specifier = ">=3.14.1" }, { name = "pyside6", specifier = ">=6.11.2" }, { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "ruamel-yaml", specifier = ">=0.19.1" }, { name = "silero-vad", specifier = ">=6.2.1" }, { name = "sounddevice", specifier = ">=0.5.6" }, { name = "torch", specifier = ">=2.14.0" }, @@ -49,7 +52,19 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "pyinstaller", specifier = ">=6.22.3" }, + { name = "pytest", specifier = ">=9.1.1" }, +] + +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] [[package]] name = "annotated-types" @@ -796,6 +811,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1311,6 +1338,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1470,6 +1506,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.22.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" }, + { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" }, + { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/60/d881fa1ba8c160c18d8e6f782bb16ec4640c08bc08fc50f704c368ad4f9e/pyinstaller_hooks_contrib-2026.7.tar.gz", hash = "sha256:5fbcaacb22c4f4aac869a127dce283f67a4b4cfcc37d496f2446603e6d68aefa", size = 175992, upload-time = "2026-08-24T21:26:58.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/67/350377af7b50416344ab8792756d414eef7629c618a73e9a0b13bb1552d9/pyinstaller_hooks_contrib-2026.7-py3-none-any.whl", hash = "sha256:24257a04c7a5a7a034cf28e39dcee20fbeeb9f043076729480f2e1b69904408a", size = 459445, upload-time = "2026-08-24T21:26:57.274Z" }, +] + [[package]] name = "pyside6" version = "6.11.2" @@ -1534,6 +1610,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1604,6 +1689,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "setuptools" version = "84.0.0"