$zqD&r$H_xNz3GBlyPq}|0
z{(=~kO5C`}&+Nb1Uo4aDkDjvzSHdYiMrN7s-)R!I@yu?YXlLbblHF`&n0dx?c^m@`
zM&Qd2%$t_%N|A%L^-D*3hdUZmMCF&FtNge!n4yK9lcfI~&wj0=xWYD2aOG)ecyM8+?zt;1FnW%P}_OSdYa
zmYML4BBHh#xOBF4*{*1^0?~bfe9n$9MvMxz-K6ew{>=*emxb&Z5R*iP&z%iw;(N}^Ful9ZMmj?S5?@QAL=nfwBL>4szC3!^L2VIb=
zMxPOEY~T|qI{bFum(0-<@E*(}#cF#O|KWu9a?If`e~KHX+C*mv4+5{km^3V{0}y0h
zG0Mkv5~ia|JD#{n#;quH#!z-kk6xTvF7g&P-QV$)G6)AmJRz3%Xk}#M3G*~f8ZH@a
z^+LvSv<9V*38q{WvR@qi3vk?(7Z+7Hn6nr81)+~S@H%Fpr5}-0GD4*GF_6R618xa%
z38DM>zdHaw!Q%%B<_Ml8~
z9v0pPpGI57Nw#(qBjBxD-_V8ED
zmAhvDJWNnpd@+!v%pqB(s5yrAjCnO=^lHjCY;3?erv+M%b>j`l~CjUKIR00USVY){(>Re%ra0GvN=hMcD@8o5Ry17iRRtnRC
z3pw8Qc_ntscOrxm7QnZ~{COtx_AnVhF7p=MUoOa_=~44F+?60R6uxdR#SuLiTd1<0
zqcBmNXGFYs#JI15X!Dt+6P=%6Q@AXML$&RHC3<3S9Vg7in$(4wCqm~mkMko`#OovO
zR^7~tgWTZYqLRqR%A0NFCp#oc)M+_$Vo9JMgNs0tnv!4#R*K+Ghj~|)DxLaA$X8^H
zcGQwj_(6`42II^*-GX`yE|1RBd7OL6GYtEH%?(57fbp-CFjYvrA4kdWt8Nm(+P*)$
z*=%w?7Wp3=slwN3-Ac8U4cpx$wp3i0HlEAC9X^@vN*Z0|VbQsw&uM5rF>|$_`(*q1
z%0#IF2}WsaBsD+v4U75d4ipJ!7d&3%tzF>_YQC9JxIFeg{^naNFzW9Uch7!1jhhc7
z`ci3^*&VMg$;9{p1M6*b3x~6H40$n&%rA(Uxaw!%Wi5nrt#B6H(94Qdntqh^a)o#?2l+dFg!15~OcBj!Na*UhkWX^g@J
zlS2OE)~M#zKeAoo*wGebedMhJX-&{of5>An&BzLlEnk@D^^l!!2xq&GQ+wMRWE4Am
zC+s^3?~^2_)-NT=HK9u6qqPjip+2p(RkU@_Y{Epi*i=jY
z;|02(3VF&?{7SCcDLm3_l#C%bK!7-=YFzLX&iIgOjtS0Eo#+~0{m@&Rii74x&&KPC
zKKL58Acf#}oM-UBT099SvX2<&7W>Ve5zBW=&4@F@5CG2>p6m;Q87Q++X>7hBCUHtH
zjXP+{5+_4L>##21oBOtJY5SM+Z$f
zqYZC=xwBTKE1(M&1j-urj#4r$g&a^*u%sPCgwL`L=J7;rb>pw-ljx!5#)*t;NGO1*
zq91hCv0y3;XF2AakBg5UI_m8qq9|%6)!Ry%cA0IHL6&%m$!>l_`lYk05}WMgYs>^Q
z1Z1WmY>mC*@K|KOG2_9Z3NMiv)P*N_s$WG_|6|Ok(KDi4Odl1qSy1Z%KR*7;aUTnv
zQ*k#+V!9Xkc@~hS*GGQU32$tR9&^X12Mxk32Fa0eBAwf90EfYu;y=6c?g+Ct%XLAT
zPrIaog>;!~3!ZiH;o9c)93QQYD%W}h5&xZx>=aC$#|ISgM-SBgN?@cCSuKau>udH|
zp#G<*KC~_{W+<6H)q@7%QLlJHG=~#%=|Pp+_T1^NO9`X&q?gqbs7IQ(q||
zTB7cywYKbV?(g181Q@`>$xj1d98y{T#VNLAE_)63!u;u+d8Yf=gri!W8;LcbGIR4?
z{Z5#9)H>&W!T0et8PgW1x0++y?z=ejaYk9VxDXs+?G;I*EhYybUL6}KF{p_PGn6Z_OzFa=SR
z1?^v3i17=DBzxD$Y3EZ}JJTHE;rV5eyeL}SFnptxkSYi*gVuAON%MmB<6s~GAxm8{BAyN(1tSd#?uDd+df
zsXx;>_B;aLc`p2p&Rcb}b>g!&{D?g|m{m6lePniQtL#scDHqFH5k$s8W%QOkdLSp=~<{BUAud~fMG#o2L?b}mJpG#nB-uXqw)fI>WG2%6PQ+@ncaWroK~
z+V4JLKwujehZL?XbQ&MdfQ5rWLnf#iuG}Fp=Ou3bsP@grJXKfK18Js`j^wM)Zud}i
zCR{3Kx>NAwmdw}@A&Ihz!#-j>DzX53??tQOu5c8Gs@gRUQ*_PLXLOJC=x=lQ?Ux$s
z&PK+`FsxMLj`ITuR^w%1Oo_PG`m92<-xrpBS~)!UP&vu6Zm=PUp7OTzUfZNga$W<)
zOuu90d82KJW;Jl57h}djNWR*%(8bZOjEI~v>`&F-2Mf60*J4Q(
zQ$?@5YmI6Nl;L$*ZvkXCKnRWjLef;@xmgM3EX1qC4leBA-%-WPiAZu!5f
zhnmo!Juz*Mk3f!5W>9%fzjPHHK6d|YQHk%X>BGep2
zD8GuXI}=^ha>C;s>>@^wCzMjax3?v8mIS*~g~#neAke{09wG9wTZ=F;ve3sYUf=po{;M8iDW{rTCgKLrk-Zr3#22Ylw
zwhWE|rusqg7h(OF9p36Yl{_(uGR%=S0$rWUVZprsX4%NA9iCD1lBdSP0+YaJ5?QG%O?SVb6k9gU@4a={g73{YqhG9i7^N9M5#6K;MpLPfW_}
zzpRj|EjEsVe=U+7C0Kunc14vZGT^RQ@p)s`sP%XI3=JJnM@bT6u1Wn>vId2stiq)0
zMY5`TM_Zxa8RR1?**%SX%}2g5bn%T4f&jYuMbp^Q>E6
zLgshX8q~qd>wvoI3|H5<%=T|M{VX#Y;B%H_tWC7U@U{Yl9K2t<#Y{V!qa`=x%{uAP$)f{r7!2)FPd!14B&>-8Fn##Z*KWP
zk=LIyZ=LH>$LE?_?#}@rk!amnpPs6JrGjQKw`Nr2z9J
zb;3Jc6n*tK=3x{M`)&)z^BMBCPl1XLhU1N>trwnoW(gf`xkiH0?JKL(-rBWS%x{-%
z239kgSpE0legT*h@f?Y7AaVX!1@mrmd+EDMu)%z;=n^)|g!2dEEc4xQa`NOS!P
zA_`OzmIRF-ta^@;5){bzYSXpS-I4pQ*+5c1>nZEJqaOB;For&>
zC7qBqx0(V0I8dd11xmC*avov4WA!hJnbxUno=BK0I#!;EdNNp_{?4pwN$$NT7a=-9
zo*8c43Yyfv0?`V)wRJwQ%F%7~Q;Wy^#pE#uNEYynO!#~|L||z&NO2o3bFDeSI0nax
zJDqqa(9^X`M`bcNG#F)vBu=XoB&A0nsVrce7|f^^W2&p_#>JYyMh^%Cf4NVm$8PN>
z3(>v_%T?9@{}i{`Tf*X>#SWCrGiqdNUsWAMN?{7*p>()
zFztU4yLN;B{6mZGs~Bum>DjZqY{T_<_osA(B^M=9v8N}}xdMtHaA#q;4i79`X(kO}
zak}cBv@_x$eZA{ZPSQfCYOSdl#3l{JxtDt}%D24o;R$hu@>ej>#jM_yn~N4(K-G_B
z(+Y{@s3NG+dpoo}DN=2W;q;*j+=Lg+2RNy7BsEX6{)$S|_Hh-LR5O9|8$xkB6oASj
zrU?>Yx?JPu(I37o
z7{pa134{JCB~;A1&N&D&^lbFbC6Zz)hzTn`XngPWL~I3D>mJ}FacPbfj7`8a
z?SxggALsd!MpdKxOO5Cy9;yHVcO@D>+_Qaob(Hu*Ersnv!jCT?y60oJUnfb^%?@gS
zcJdL@%xdqoIZM&a98G@`myPb|pf(_gBC!2Lye1*cuRSXw(-1}BF{^$7kVbmgbo*{F
zcM0ZH$@oqQ0i*nHqEL;o6{u|u@$WcWZal_)N+1xom{HqIH$!cpkS};uXh`PU&pgz>
zK-T?eJ`;AtL5;Qg8(ClhxJNym7GRof1+@pANims6-vSu2Vjd$!g2}wokUx08fai4~
zIxm3#+m-EZswu>K%vJGz^s9qHiE~+*_Q$t;L^Z6e6(WD!z1@q=^5p`XZz;J52Yy-;#s71ccxs;A0sn
z@l&Xa3F#^#>elb6=Z=6q<6yW
z{x}JS)*6Q%eY(cBD&v9&SZ+O`R;>Wf23jBU&Gc!+W3x8$Tfg$+RA*JEcKRy9Y<0zJUxpDOFY
z&pDX2bPxOLBR4>*ftrTz32knk<&j^&M1`{!%c87&Bi8uM5Z#3k<(+S0QTBcWyR{-{
zAH4gJsBg*UjDk5GGgz>X5asW^U-;T^uOnem@v}~Z;g`weJ1FbfV6@f+F{7$*?Z=J0
zB(z)TloM!!K43@36d1mA#SvaN(K1EZr14o83S5kpGM@O(2{ZLz2VF9TjKoBiqxNS6
z*1tTRj6*NcGt)$%OT}f5BiYW!$^&*~g6S2@1Di@TCk_2lh!~d<2dLY$TazJTkeErZ
z7T1C&JP*F3?09EQyg3}rEBE1FvN>3HY2FhRCK&MtA
zjVnR11H*Cuon+Sxu(Izc?fGUk;`x3Q5O9ojRlC3NH@|YZ3x4D(8R%3|wAwUloU4^C
zp6W5^@Ccfy+m+%(#b6W3K6U8bM@KK`f;Hj^4YbgvgKkB&6|QeQMerj3jiet6kP
z`c?xrnU&E?X0N~03mR&|8pN}iG|2XM<3>4{IN4q!7zf~>sat+CQ#8dAk>qX=|DNO9
z^M4#LUvsxE=0vW=w1G;5nd@uZk-L_F$wMBDz3fUPDmzerv28SZ#dBZ{;QLpea6sMr
z%`@8wq?i5WBa94Pnm?k(#Pd|njZiM`I{;|Jbtd>YBSoJ-8Xw4v?R{m&?yx}{%B-#T
z>#Uc=71jM)$c@*~jeZHfD{K$!d2Yk+0N>x`;^s^L8wc9nlK0VCvggB*2Ygd2EWDLW
z6m&RFP(v=Qp8`u2ZR}LWBCdzeY({;(D~(I>rvmi!fTQJJo>wc2@^y1Sn=tkjN
zV_E=bKf;rAnp#IkgaJk4Tti6l?>D}mV{RT3@54i-uw}XdPE6AWkj}uiS+F|_YKkF`
zTn7D{uC{XKrjf_8hMWv2%qcsoGRq{kWh!ox(@KMUky>Ad!3tr94!TI<`I2?CT9
z-F$j9Up00jrqJ-+!MuKL)Z^l05;(XEy|f<`!QBe>5^laZRk|%2v|LST(ADd5u{K2b
zVW9Hhh)6d4w#+^Gby5E2OI!-RK^F@f%XDexW3rQcFd!
z|3&!$H$0vQE$#9EzSc!7V)|_d00enohVCO-hOKg-5%T9D!*7qjv@mrSaj_Fh?b
zGKmG^is6<{C|t?Tq)gSAJkoJgySR*+VDCml1aER|hq78IUw~XwrBd3l?o6Gtt;$+k
z2TEnncwj=q*3=B`Dp#raDItS&JmTlQHnjr_(*#rKOZ=Ci8^W;WD^%$6#{lq7?Y2#@
z?_33R*k#`M27EX~b4Nm(a*Jo5>$pRz*r#!tExlk0D6SD(tcXT5GMWZYDoJ1yn;E3_
zg>2n;L9G2ASi%R@lSjG!>Es+{z=;^B-ZtD^bHHg;o#rM4n8SbVNkry?5no_#;~W}l
zxl?4gJmwAx5N(st^K6qo9@+3e$2q^%(W?oAV(%#lICNTy?U9#MT|)n?Xn$a`^vS=j
zLw^Or9my2eHamwE%ujL+OzoN$IYefAL(n$c$Ya~#c
z;`;@4mAZJX-N}rf^UZLZb~^=O=RPs{ul|Yv@Zh$}{(f%hx++%%I0d{kB_-MQo(p9_
z!pxav?9sK}6Jn~7i&Q@6hnOGe(i>Nw;*K6)gNL^<8Y;YwH4Ub=cAxWaN8G_DD5gRx
z2eh*b*VZ$mSH=o~K~9r_B`QJ3v~@v0px-e$M&(Y|esykN79g$m*)Z39HR)(zVJ(Hu
z*kddoE`uqP);E}nO51b^B0iE%UJP#&Cajg@zKU(FVC;kye#lE;5xA=`7_<{z$JcBO
zp3H$FFEC+7aBGnQb`8>)3=Iz;RAOOiHay$#@VBb+f6KRNJ3n1F#a)VYM<*ahAx0>i
z0hjBX1_squ)pA-VF&^jm%B(9w?mpl$khYqQ0S36p57@QuSK#BkRYB0x{zvr-k$rU1
z4*xQ`z66UmK563h8F&i4OIxbECB2s+@eIrTUzE27@&@HmjJc6M5!oi@>14a>reg1j
zn(ww?49VKcrA@Bia)WB~oe9ILVhCv%wd$6#MPb8wyP!rwC@{1iMp?`_3ZKxl)P8V@
z%CoME^1&=CbLwE_U}Ja15qMCq#2%ZKz)OSLBCr-UG{NduTY6T$5bpe-XF-otN#k<1
zi@!Jt55RfR+5N7Qa|@(9gId{+YT)1czqZWR-*L+a-ShbW^xUt@B-I_@k&`L}3|6
zAD$scw{iW|D-umo&V=S`(}zH^gOj+{>6A7mYaEHU42o~O&!-T4D*z3-@k!c%YX%2#
z+tI}UlJ6S1lt9j#Fn;@dY#ls|BI9U5q=-_ftBhIzxh5#7zR~iD9=>fIk(|?eoDTTj
z?$I+)%=@&~kSIKc!J}AT2Bg!$9XVmVh_LZ{GuG0A2?y&Ie#%HBH#SXDEmm&f9xN>U
zeieYqz6fy6tm=pGG+)77nfT9;QGPgfIV}aK0VXq%6>9YVaaqo}Sgy?sS1sL)?8J;N
z5XJ3~^J!UDRGfGFND-Gb-B9v6g6jKz@}7VQ8eYATQFkee1Q7&s-%FB`!9kMYm#HjIEi8{$61@&(ICNRjA`>)2u~>w@ptN~4a6
zmqf!oEQ$DZe3Ghcyk7v8)u~RUvjV?7wDx4HARi&Jm1JCDoeqvj+dtySc5;<(T}9v8
zN#Nzb#)c$8PS;hD;2g~VAm)Ab)`i!h>>gUdt_1(Cmh2=PSG0>GYQi$UK1W+6ym*IB
zjc`u+A(q{Fs1&4oScjf2=pluc0mJ#DD#%XI8aRqU51T^x%T*t2yNzA`H@0@0
zQMqTW(|L>N#Q*mv)*qGo7nS3L(_OMD!w5*TbI=M@>j3*KtHA2sghAgiwQcs1aUW^_
z&iD13n?pKh4@P6LUiLcE$*C0MQEIhn`mYd^>1)63=h^{=@61zNEp_1V+&2{GK1S=?
zqvqU$ok1s4a&rNgnD$qXkP+Hl+GYJa+rmOPlZA(=*u|mL6UKr`+qNN-_R?0aB(Z05
zDn=mYnR`@anslQO&vLRnha?^)mR=C!fq~%ln+q+cgi%P)QdV0J;gb%ZMZ`QFMXOl!Nw=x#>JXJ06u|W;vVo3G+56eki(va?5Q%tJI^e)M9@%-xi(`X^m%=
zj}>+ZWO%KA{jg?0@e#c+-l@*l1)guYvwoC$%KgT*l;=EL)yLUiU0nHlv(IBln&6K0
zR-ltbQh6ooJsC5Eg>CzfP*Z)xV@G9wY;sk%$}8agg_#h(DBmMpDmlZn(^fEO@_yW^{mz6
z5SqmRPB+R{^p7k
zn80^Pt0vguSr>K!wKnVS8vvo_d2O&jQHOSu{61UzD5n>)l_M8E*jH(UtGYIDs~QPe5EV3M{3zR0QPK!bql-~>0V!J=b>$Ly;M6BA
z5;~;pt1-MetVn1%9gv+&pe27T5ml_t2{<#lxbht)9Cw(r9S2TvI#6!@Dq6aJWL3GqDa#SR`a>mu16`OwoHS((
zcGSIYe`05z`mom%ARE7aq-6+Tp4kRb1*=@D$ywk+n;KPG;#?@-+xhr#@}XbBNJU^v
zONiH8Ds)3^v<<8x&kS1}vRQbqjKs4Ww&$L6>jmaGyRNafMA{8`sFM_Th4YoWNErpmk^81bxL{2f&$_-cz2(+I22Sb1Ri!
zKgJ_};y0omf3#my*PW?#2%dxJA~vFE2_D_eulCbj1?w-|%FTaF5$7Ble$3AHKWD<0
zm^j+s4(3ar`wWg5dn8V3ac1xBwD=);V`UtYSM!sGi_rOVj3o1oj?B=6bImONM5EQN
zw51j0BPycNBG>*w0Qo0^#nRaV45$HldQE_&?*Dyx4)18s_Es*cDzNEk1vVhnJeP7#
zCyzuuE%{Xoq@B~f5rV$nlKO+#0TZ@+J-pf5A&dvaoQ|;NWa^$*)6~|20#$2bdN@Ce
z1U8-;l<&*qEsSR&cKjb?B=uV`x>RLglqmWSNb8M!3etxK7TBWd9pm*MsCqj;kH8fz
zMANE{Bi!JJsI#c-)X}2RV<34R34pL#n=?|pgvuAp;^t^SjLS1fn3w=Zrwq5DRC%NZ
zs!oBIos+BhXVQ6
zwTfKa0hci_#RGcjV%h$ZQ$+gN_jg5{c>neE4-;kPhlq`iz@5Bw&pDfydY|_b`%qd6
z4&KHFyJPmk5=!~*5fw~I@Gy{rR^Cpf8m5iJ5~N;yhiSO293}y$sLh*zx&=f*%-4MZ
zxRX4Dt_0#Izmlc0z8P$^b^MLP0;GllLXUA&`mLdNPq1k4elkQ_IgeJlU6UJcucJN8
zyNRQ!b@bS*T?>$j4E5h5meWS^ayaZlZ(2q@%=UV@J&F2$?r?9hk}<
zL|&$zQR!H`WF@iq9wK89YgA)MfMzA~Q|o~zR*E=bW^1KN3=Tb28jmV*@FWzvzOfvL
z9B#Ki!GqU66eyP)OV=Ko2N_}F?j=k|J4^roBiayBZWR2{<$^AOPsBgwjh1i)Hj`~o
zi4yL33Waq!iyq72zjz8_YRCn#^MDISx}Vvx^6N2FHEeI8=7WY!Za6N7UZqX$j7lA7
RBT1s={{SsO04Cr7006?`e@XxV
literal 0
HcmV?d00001
diff --git a/docs/screenshots/src/session.html b/docs/screenshots/src/session.html
index 3fa349d..7b6f2a7 100644
--- a/docs/screenshots/src/session.html
+++ b/docs/screenshots/src/session.html
@@ -82,6 +82,11 @@
.chip {
border: 0.5px solid var(--line); border-radius: 999px; padding: 4px 10px; background: #fff;
}
+ .send-cluster { display: flex; align-items: center; gap: 8px; }
+ .mic {
+ width: 28px; height: 28px; border-radius: 8px;
+ display: grid; place-items: center; font-size: 14px; color: var(--muted);
+ }
.send {
width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 14px;
@@ -126,7 +131,10 @@
锚定式标准(实验)
工作区
- ↑
+
diff --git a/docs/screenshots/vision.webp b/docs/screenshots/vision.webp
new file mode 100644
index 0000000000000000000000000000000000000000..9012c505ae8f593db359788afa6647c9f4e436b9
GIT binary patch
literal 9566
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
literal 0
HcmV?d00001
diff --git a/docs/screenshots/voice.webp b/docs/screenshots/voice.webp
new file mode 100644
index 0000000000000000000000000000000000000000..3ecb5b8ece7de10cb8515b7709d9b0e8a848d78d
GIT binary patch
literal 11794
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 {
+ var module = { exports: {} }
+ var exports = module.exports
+ var React = require('react')
+ var jsx = require('react/jsx-runtime')
+
+ var css = [
+ '.dshdvoice{width:100%;max-width:640px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary)}',
+ '.dshdvoice h3{margin:0;font-size:15px;font-weight:600;line-height:22px}',
+ '.dshdvoice p{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}',
+ '.dshdvoice label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
+ '.dshdvoice .head{display:flex;align-items:center;justify-content:space-between;gap:12px}',
+ '.dshdvoice .row{flex-direction:row;align-items:center;gap:10px}',
+ '.dshdvoice .openai{display:flex;flex-direction:column;gap:14px}',
+ '.dshdvoice a{color:var(--dsw-alias-state-business-primary);text-decoration:none;font-size:12px;line-height:18px}',
+ '.dshdvoice a:hover{text-decoration:underline}',
+ '.dshdvoice input,.dshdvoice select{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font:inherit}',
+ '.dshdvoice input:focus,.dshdvoice select:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}',
+ '.dshdvoice input[type=checkbox]{width:16px;height:16px;padding:0}',
+ '.dshdvoice button{align-self:flex-start;height:32px;padding:0 14px;border:0;border-radius:8px;background:var(--dsw-alias-state-business-primary);color:#fff;font:inherit;cursor:pointer}',
+ '.dshdvoice button:disabled{opacity:.55;cursor:default}',
+ '.dshdvoice .ok{color:var(--dsw-alias-state-success-primary)}',
+ '.dshdvoice .err{color:var(--dsw-alias-state-error-primary)}',
+ '.dshdvoice-mic{appearance:none;border:0;background:transparent;width:28px;height:28px;border-radius:8px;color:var(--dsw-alias-label-secondary);display:grid;place-items:center;cursor:pointer;padding:0}',
+ '.dshdvoice-mic:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
+ '.dshdvoice-mic:disabled{opacity:.4;cursor:default}',
+ '.dshdvoice-mic.is-live{color:var(--dsw-alias-state-error-primary)}',
+ '.dshdvoice-mic.is-live svg{animation:dshdvoice-pulse 1.1s ease-in-out infinite}',
+ '@keyframes dshdvoice-pulse{0%,100%{opacity:1}50%{opacity:.45}}',
+ '#dsh-desktop-dictation{position:fixed;left:50%;bottom:48px;z-index:2147483645;transform:translateX(-50%);max-width:min(36rem,calc(100vw - 3rem));display:none;align-items:center;gap:8px;padding:6px 12px;border-radius:10px;background:rgba(28,28,30,.92);color:#f5f5f7;font:13px/1.3 -apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC",system-ui,sans-serif;box-shadow:0 8px 28px rgba(0,0,0,.28)}',
+ '#dsh-desktop-dictation.open{display:flex}',
+ '#dsh-desktop-dictation .label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
+ '#dsh-desktop-dictation .stop{appearance:none;border:0;background:rgba(255,255,255,.12);color:#f5f5f7;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;cursor:pointer;padding:0}',
+ '#dsh-desktop-dictation .kbd{opacity:.55;font-size:11px;letter-spacing:.02em;flex:none}',
+ '#dsh-desktop-dictation .progress{width:120px;height:6px;accent-color:var(--dsw-alias-state-business-primary)}',
+ '#dsh-desktop-dictation .percent{min-width:30px;text-align:right;font-variant-numeric:tabular-nums;font-size:11px;opacity:.75}',
+ ].join('')
+
+ function ensureCss() {
+ if (typeof document === 'undefined') return
+ if (document.querySelector('style[data-plugin-css="dsh-desktop-voice"]')) return
+ var tag = document.createElement('style')
+ tag.dataset.pluginCss = 'dsh-desktop-voice'
+ tag.textContent = css
+ document.head.appendChild(tag)
+ }
+
+
+ function isMac() {
+ return /Mac|iPhone|iPad/.test(navigator.platform || '')
+ }
+
+ function shortcutLabel() {
+ return isMac() ? '⌘E' : 'Ctrl+E'
+ }
+
+ function isDictationHotkey(event) {
+ if (event.repeat || event.altKey || event.shiftKey) return false
+ var mod = isMac() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
+ if (!mod) return false
+ return event.key === 'e' || event.key === 'E' || event.code === 'KeyE'
+ }
+
+ var WORD_BOUNDARY = /^[\p{L}\p{N}]$/u
+ var CJK_BOUNDARY = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]$/u
+ var NO_SPACE_BEFORE = /^[,.;:!?%。,、!?;:))\]}]$/u
+ var NO_SPACE_AFTER = /^[([{(《「『]$/u
+ var SPACE_AFTER = /^[,.;:!?%]$/u
+
+ function firstChar(text) {
+ return Array.from(String(text || '').trimStart())[0] || ''
+ }
+
+ function lastChar(text) {
+ var chars = Array.from(String(text || '').trimEnd())
+ return chars.length ? chars[chars.length - 1] : ''
+ }
+
+ function formatSegment(previous, next) {
+ var text = String(next || '').trim()
+ if (!text) return ''
+ if (!previous || /\s$/.test(previous) || /^\s/.test(text)) return text
+ var prev = lastChar(previous)
+ var cur = firstChar(text)
+ if (!prev || !cur) return text
+ if (CJK_BOUNDARY.test(prev) || CJK_BOUNDARY.test(cur) || NO_SPACE_BEFORE.test(cur) || NO_SPACE_AFTER.test(prev)) {
+ return text
+ }
+ if ((WORD_BOUNDARY.test(prev) || SPACE_AFTER.test(prev)) && WORD_BOUNDARY.test(cur)) return ' ' + text
+ return text
+ }
+
+ function remote() {
+ return fetch('/dsh-desktop/voice').then(function (res) {
+ if (!res.ok) throw new Error('load failed ' + res.status)
+ return res.json()
+ })
+ }
+
+ function installSenseVoice(form) {
+ if (form && form.modelInstalled) return Promise.resolve(form)
+ var size = (form && form.modelDownloadSizeMb) || 245
+ if (!window.confirm('SenseVoice 离线语音模型尚未安装(约 ' + size + ' MB)。现在安装吗?')) {
+ return Promise.reject(new Error('需要先安装 SenseVoice 离线语音模型'))
+ }
+ setState({ state: 'installing', partial: '', error: '', installPercent: 0, installStage: '正在准备安装…' })
+ var stopped = false
+ function pollProgress() {
+ return fetch('/dsh-desktop/voice/model')
+ .then(function (res) {
+ if (!res.ok) return null
+ return res.json()
+ })
+ .then(function (progress) {
+ if (!progress || stopped || session.state !== 'installing') return
+ setState({
+ installPercent: Math.max(0, Math.min(100, Number(progress.percent) || 0)),
+ installStage: progress.stage || '正在安装 SenseVoice…',
+ })
+ })
+ .catch(function () {})
+ }
+ pollProgress()
+ var timer = setInterval(pollProgress, 250)
+ if (timer && typeof timer.unref === 'function') timer.unref()
+ return fetch('/dsh-desktop/voice/model', { method: 'POST' }).then(function (res) {
+ return res.json().then(function (body) {
+ if (!res.ok) throw new Error(body.error || 'SenseVoice 安装失败')
+ setState({ installPercent: 100, installStage: 'SenseVoice 已安装' })
+ return body
+ })
+ })
+ .finally(function () {
+ stopped = true
+ clearInterval(timer)
+ })
+ }
+
+ function transcribeBlob(blob, language) {
+ var mime = blob.type || 'application/octet-stream'
+ return fetch(
+ '/dsh-desktop/voice/transcribe?language=' + encodeURIComponent(language || '') + '&mime=' + encodeURIComponent(mime),
+ { method: 'POST', headers: { 'content-type': mime }, body: blob },
+ ).then(function (res) {
+ return res.json().then(function (body) {
+ if (!res.ok) throw new Error(body.error || 'transcribe failed ' + res.status)
+ return String(body.text || '').trim()
+ })
+ })
+ }
+
+ function pickRecorderMime() {
+ if (!window.MediaRecorder) return ''
+ var types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']
+ for (var i = 0; i < types.length; i++) {
+ try {
+ if (MediaRecorder.isTypeSupported(types[i])) return types[i]
+ } catch (err) {}
+ }
+ return ''
+ }
+
+ function encodeWav(chunks, sampleRate) {
+ var length = 0
+ for (var i = 0; i < chunks.length; i++) length += chunks[i].length
+ var samples = new Float32Array(length)
+ var offset = 0
+ for (var j = 0; j < chunks.length; j++) {
+ samples.set(chunks[j], offset)
+ offset += chunks[j].length
+ }
+ var buffer = new ArrayBuffer(44 + samples.length * 2)
+ var view = new DataView(buffer)
+ function str(at, value) {
+ for (var n = 0; n < value.length; n++) view.setUint8(at + n, value.charCodeAt(n))
+ }
+ str(0, 'RIFF')
+ view.setUint32(4, 36 + samples.length * 2, true)
+ str(8, 'WAVE')
+ str(12, 'fmt ')
+ view.setUint32(16, 16, true)
+ view.setUint16(20, 1, true)
+ view.setUint16(22, 1, true)
+ view.setUint32(24, sampleRate, true)
+ view.setUint32(28, sampleRate * 2, true)
+ view.setUint16(32, 2, true)
+ view.setUint16(34, 16, true)
+ str(36, 'data')
+ view.setUint32(40, samples.length * 2, true)
+ var idx = 44
+ for (var s = 0; s < samples.length; s++, idx += 2) {
+ var v = Math.max(-1, Math.min(1, samples[s]))
+ view.setInt16(idx, v < 0 ? v * 0x8000 : v * 0x7fff, true)
+ }
+ return new Blob([buffer], { type: 'audio/wav' })
+ }
+
+ function openMic(deviceId) {
+ var audio = {
+ channelCount: 1,
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ }
+ if (deviceId) audio.deviceId = { exact: deviceId }
+ return navigator.mediaDevices.getUserMedia({ audio: audio }).catch(function (error) {
+ if (!deviceId) throw error
+ return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } })
+ })
+ }
+
+ var session = {
+ state: 'idle',
+ partial: '',
+ error: '',
+ stream: null,
+ recorder: null,
+ parts: [],
+ wav: null,
+ inserted: '',
+ target: null,
+ installPercent: 0,
+ installStage: '',
+ run: 0,
+ }
+ var listeners = []
+ var cachedForm = null
+
+ function currentForm() {
+ return session.liveForm || cachedForm || (session.target && session.target.form && session.target.form()) || null
+ }
+
+ function emit() {
+ for (var i = 0; i < listeners.length; i++) listeners[i]()
+ }
+
+ function setState(next) {
+ for (var key in next) session[key] = next[key]
+ renderIndicator()
+ emit()
+ }
+
+ function indicatorEl() {
+ var el = document.getElementById('dsh-desktop-dictation')
+ if (el) return el
+ el = document.createElement('div')
+ el.id = 'dsh-desktop-dictation'
+ el.innerHTML =
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ ''
+ document.documentElement.appendChild(el)
+ el.querySelector('.stop').addEventListener('mousedown', function (event) {
+ event.preventDefault()
+ })
+ el.querySelector('.stop').addEventListener('click', function () {
+ stopDictation()
+ })
+ return el
+ }
+
+ function renderIndicator() {
+ ensureCss()
+ var el = indicatorEl()
+ var open =
+ session.state === 'installing' || session.state === 'starting' || session.state === 'listening' || session.state === 'stopping'
+ el.classList.toggle('open', open)
+ var label = el.querySelector('.label')
+ var progress = el.querySelector('.progress')
+ var percent = el.querySelector('.percent')
+ var kbd = el.querySelector('.kbd')
+ if (session.state === 'installing') label.textContent = session.installStage || '正在安装 SenseVoice…'
+ else if (session.state === 'starting') label.textContent = '正在启动…'
+ else if (session.state === 'stopping') label.textContent = '正在识别…'
+ else label.textContent = session.partial || '正在听…'
+ var installing = session.state === 'installing'
+ progress.hidden = !installing
+ progress.value = session.installPercent || 0
+ percent.hidden = !installing
+ percent.textContent = installing ? Math.round(session.installPercent || 0) + '%' : ''
+ kbd.textContent = shortcutLabel()
+ el.querySelector('.stop').hidden = session.state === 'stopping' || installing
+ }
+
+ function cleanupCapture() {
+ if (session.recorder) {
+ try {
+ if (session.recorder.state !== 'inactive') session.recorder.stop()
+ } catch (err) {}
+ session.recorder = null
+ }
+ if (session.wav) {
+ try {
+ session.wav.processor.disconnect()
+ session.wav.source.disconnect()
+ session.wav.context.close()
+ } catch (err) {}
+ session.wav = null
+ }
+ if (session.stream) {
+ session.stream.getTracks().forEach(function (track) {
+ track.stop()
+ })
+ session.stream = null
+ }
+ session.parts = []
+ }
+
+ function appendToComposer(text) {
+ var target = session.target
+ if (!target || !text) return
+ var chunk = formatSegment(session.inserted || (target.draft ? target.draft() : ''), text)
+ if (!chunk) return
+ if (target.setDraft && target.draft) {
+ target.setDraft((target.draft() || '') + chunk)
+ }
+ session.inserted += chunk
+ }
+
+ function finishError(message) {
+ cleanupCapture()
+ setState({ state: 'idle', partial: '', error: message || '' })
+ }
+
+ function useSenseVoice(form) {
+ return form.engine === 'sensevoice'
+ }
+
+ function startRecorder(form, runId) {
+ return openMic(form.microphoneDeviceId).then(function (stream) {
+ if (session.run !== runId) {
+ stream.getTracks().forEach(function (track) {
+ track.stop()
+ })
+ return
+ }
+ session.stream = stream
+ var local = useSenseVoice(form)
+ var mime = local ? '' : pickRecorderMime()
+ if (!local && (mime || window.MediaRecorder)) {
+ var recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream)
+ session.parts = []
+ recorder.ondataavailable = function (event) {
+ if (event.data && event.data.size) session.parts.push(event.data)
+ }
+ session.recorder = recorder
+ recorder.start(250)
+ } else {
+ var context = new AudioContext()
+ var source = context.createMediaStreamSource(stream)
+ var processor = context.createScriptProcessor(4096, 1, 1)
+ var chunks = []
+ processor.onaudioprocess = function (event) {
+ chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)))
+ }
+ source.connect(processor)
+ processor.connect(context.destination)
+ session.wav = { context: context, source: source, processor: processor, chunks: chunks, sampleRate: context.sampleRate }
+ }
+ setState({ state: 'listening', partial: '', error: '' })
+ })
+ }
+
+ function recordedBlob() {
+ if (session.wav) return encodeWav(session.wav.chunks, session.wav.sampleRate)
+ if (!session.parts.length) return null
+ return new Blob(session.parts, { type: session.parts[0].type || 'audio/webm' })
+ }
+
+ function startDictation() {
+ if (session.state !== 'idle') return
+ var target = session.target
+ if (!target) {
+ setState({ error: '对话框还没就绪' })
+ return
+ }
+ var runId = session.run + 1
+ session.run = runId
+ session.inserted = target.draft ? target.draft() : ''
+ setState({ state: 'starting', partial: '', error: '' })
+ remote()
+ .catch(function () {
+ return target.form ? target.form() : null
+ })
+ .then(function (form) {
+ if (session.run !== runId) return
+ if (!form || form.enabled === false) throw new Error('语音输入已关闭,请到设置 → 语音输入开启')
+ session.liveForm = form
+ cachedForm = form
+ if (useSenseVoice(form)) {
+ return installSenseVoice(form).then(function (installed) {
+ if (session.run !== runId) return
+ session.liveForm = installed
+ cachedForm = installed
+ return startRecorder(installed, runId)
+ })
+ }
+ if (!form.apiKey) throw new Error('请先在设置 → 语音输入里填写 OpenAI API 密钥')
+ return startRecorder(form, runId)
+ })
+ .catch(function (error) {
+ if (session.run !== runId) return
+ var message = String(error && error.message ? error.message : error)
+ if (/NotAllowed|Permission/i.test(message)) message = '麦克风权限被拒绝,请在系统设置中允许后重试'
+ finishError(message)
+ })
+ }
+
+ function stopDictation() {
+ if (session.state === 'idle' || session.state === 'stopping' || session.state === 'installing') return
+ var runId = session.run
+ var form = currentForm()
+ setState({ state: 'stopping' })
+ var recorder = session.recorder
+ function afterBlob(blob) {
+ cleanupCapture()
+ if (session.run !== runId) return
+ if (!blob || !blob.size) {
+ setState({ state: 'idle', partial: '', error: '没有识别到语音' })
+ return
+ }
+ transcribeBlob(blob, form && form.language)
+ .then(function (text) {
+ if (session.run !== runId) return
+ if (text) appendToComposer(text)
+ else setState({ error: '没有识别到语音' })
+ setState({ state: 'idle', partial: '' })
+ })
+ .catch(function (error) {
+ if (session.run !== runId) return
+ finishError(String(error && error.message ? error.message : error))
+ })
+ }
+ if (recorder && recorder.state !== 'inactive') {
+ recorder.onstop = function () {
+ afterBlob(recordedBlob())
+ }
+ try {
+ recorder.stop()
+ } catch (err) {
+ afterBlob(recordedBlob())
+ }
+ return
+ }
+ afterBlob(recordedBlob())
+ }
+
+ function toggleDictation() {
+ if (session.state === 'installing') return
+ if (session.state === 'listening' || session.state === 'starting') stopDictation()
+ else startDictation()
+ }
+
+ if (typeof window !== 'undefined' && !window.__dshDesktopVoiceKeys) {
+ window.__dshDesktopVoiceKeys = true
+ window.addEventListener(
+ 'keydown',
+ function (event) {
+ if (!isDictationHotkey(event)) return
+ var form = currentForm()
+ var mode = form && form.dictationMode === 'hold' ? 'hold' : 'toggle'
+ event.preventDefault()
+ event.stopPropagation()
+ if (mode === 'hold') {
+ if (session.state === 'idle') startDictation()
+ return
+ }
+ toggleDictation()
+ },
+ true,
+ )
+ window.addEventListener(
+ 'keyup',
+ function (event) {
+ var form = currentForm()
+ if (!form || form.dictationMode !== 'hold') return
+ var key = event.key === 'e' || event.key === 'E' || event.code === 'KeyE'
+ var mod = event.key === 'Control' || event.key === 'Meta' || event.code.indexOf('Control') === 0 || event.code.indexOf('Meta') === 0
+ if (!key && !mod) return
+ event.preventDefault()
+ stopDictation()
+ },
+ true,
+ )
+ }
+
+ function MicIcon() {
+ return jsx.jsxs('svg', {
+ width: '16',
+ height: '16',
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: '2',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ 'aria-hidden': true,
+ children: [
+ jsx.jsx('path', { d: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z' }),
+ jsx.jsx('path', { d: 'M19 10v1a7 7 0 0 1-14 0v-1' }),
+ jsx.jsx('line', { x1: '12', y1: '19', x2: '12', y2: '22' }),
+ ],
+ })
+ }
+
+ function SquareIcon() {
+ return jsx.jsx('svg', {
+ width: '12',
+ height: '12',
+ viewBox: '0 0 12 12',
+ 'aria-hidden': true,
+ children: jsx.jsx('rect', { x: '2', y: '2', width: '8', height: '8', rx: '1.5', fill: 'currentColor' }),
+ })
+ }
+
+ function MicButton(props) {
+ ensureCss()
+ var formState = React.useState(null)
+ var form = formState[0]
+ var setForm = formState[1]
+ var tickState = React.useState(0)
+ var setTick = tickState[1]
+ var draft = props.useInput ? props.useInput(function (s) { return s.draft }) : ''
+ var draftRef = React.useRef(draft)
+ draftRef.current = draft
+ var formRef = React.useRef(form)
+ formRef.current = form
+
+ React.useEffect(function () {
+ var live = true
+ remote()
+ .then(function (next) {
+ if (live) {
+ cachedForm = next
+ setForm(next)
+ }
+ })
+ .catch(function () {
+ if (live) setForm({ enabled: true, engine: 'sensevoice', dictationMode: 'toggle', language: 'zh', modelInstalled: false })
+ })
+ return function () {
+ live = false
+ }
+ }, [])
+
+ React.useEffect(function () {
+ function onChange() {
+ setTick(function (n) { return n + 1 })
+ }
+ listeners.push(onChange)
+ return function () {
+ listeners = listeners.filter(function (fn) { return fn !== onChange })
+ }
+ }, [])
+
+ React.useEffect(function () {
+ session.target = {
+ setDraft: props.inputActions && props.inputActions.setDraft,
+ draft: function () {
+ return draftRef.current || ''
+ },
+ form: function () {
+ return formRef.current
+ },
+ }
+ })
+
+ var installing = session.state === 'installing'
+ var live = installing || session.state === 'starting' || session.state === 'listening' || session.state === 'stopping'
+ var title = installing
+ ? '正在安装 SenseVoice…'
+ : live
+ ? '停止听写(' + shortcutLabel() + ')'
+ : '语音输入(' + shortcutLabel() + ')'
+ return jsx.jsx('button', {
+ type: 'button',
+ className: 'dshdvoice-mic' + (session.state === 'listening' ? ' is-live' : ''),
+ 'aria-label': title,
+ title: session.error ? session.error : title,
+ disabled: session.state === 'stopping' || installing,
+ onMouseDown: function (event) {
+ event.preventDefault()
+ },
+ onClick: function () {
+ toggleDictation()
+ },
+ children: live && !installing && session.state !== 'starting' ? jsx.jsx(SquareIcon, {}) : jsx.jsx(MicIcon, {}),
+ })
+ }
+
+ function VoiceSettings() {
+ ensureCss()
+ var state = React.useState({ status: 'loading' })
+ var snap = state[0]
+ var setSnap = state[1]
+ var devicesState = React.useState([])
+ var devices = devicesState[0]
+ var setDevices = devicesState[1]
+
+ React.useEffect(function () {
+ var live = true
+ remote()
+ .then(function (form) {
+ if (live) setSnap({ status: 'ready', form: form, message: '' })
+ })
+ .catch(function (error) {
+ if (live) setSnap({ status: 'error', message: String(error.message || error) })
+ })
+ return function () {
+ live = false
+ }
+ }, [])
+
+ function patch(field, value) {
+ setSnap(function (cur) {
+ if (cur.status !== 'ready') return cur
+ return { status: 'ready', form: Object.assign({}, cur.form, { [field]: value }), message: '' }
+ })
+ }
+
+ function save() {
+ if (snap.status !== 'ready' || snap.saving) return
+ setSnap(Object.assign({}, snap, { saving: true, message: '' }))
+ fetch('/dsh-desktop/voice', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(snap.form),
+ })
+ .then(function (res) {
+ return res.json().then(function (body) {
+ if (!res.ok) throw new Error(body.error || 'save failed')
+ return body
+ })
+ })
+ .then(function (form) {
+ cachedForm = form
+ setSnap({ status: 'ready', form: form, message: '已保存', saving: false })
+ })
+ .catch(function (error) {
+ setSnap(Object.assign({}, snap, { saving: false, message: String(error.message || error) }))
+ })
+ }
+
+ function listMics() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
+ navigator.mediaDevices
+ .getUserMedia({ audio: true })
+ .then(function (stream) {
+ stream.getTracks().forEach(function (track) { track.stop() })
+ return navigator.mediaDevices.enumerateDevices()
+ })
+ .then(function (list) {
+ setDevices(
+ list
+ .filter(function (item) { return item.kind === 'audioinput' })
+ .map(function (item, index) {
+ return { id: item.deviceId, label: item.label || '麦克风 ' + (index + 1) }
+ }),
+ )
+ })
+ .catch(function () {})
+ }
+
+ if (snap.status === 'loading') {
+ return jsx.jsx('div', { className: 'dshdvoice', children: jsx.jsx('p', { children: '正在读取语音输入配置…' }) })
+ }
+ if (snap.status === 'error') {
+ return jsx.jsx('div', { className: 'dshdvoice', children: jsx.jsx('p', { className: 'err', children: snap.message }) })
+ }
+ var form = snap.form
+ var options = form.options || {}
+ var engines = options.engines || []
+ var modes = options.modes || []
+ var languages = options.languages || []
+ var openai = form.engine === 'openai'
+ var local = form.engine === 'sensevoice'
+ return jsx.jsxs('div', {
+ className: 'dshdvoice',
+ children: [
+ jsx.jsx('h3', { children: '语音输入' }),
+ jsx.jsx('p', {
+ children:
+ '对着麦克风说话,文字写进对话框。快捷键 ' +
+ shortcutLabel() +
+ ',和 Orca 一样点按开关或按住说话。默认使用本机 SenseVoice;模型首次点击麦克风时按需安装,不包含在安装包内。',
+ }),
+ jsx.jsxs('label', {
+ className: 'row',
+ children: [
+ jsx.jsx('input', {
+ type: 'checkbox',
+ checked: form.enabled !== false,
+ onChange: function (event) {
+ patch('enabled', event.target.checked)
+ },
+ }),
+ '启用语音输入',
+ ],
+ }),
+ jsx.jsxs('label', {
+ children: [
+ '听写方式',
+ jsx.jsx('select', {
+ value: form.dictationMode || 'toggle',
+ onChange: function (event) {
+ patch('dictationMode', event.target.value)
+ },
+ children: modes.map(function (item) {
+ return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
+ }),
+ }),
+ ],
+ }),
+ jsx.jsxs('label', {
+ children: [
+ '引擎',
+ jsx.jsx('select', {
+ value: form.engine || 'sensevoice',
+ onChange: function (event) {
+ patch('engine', event.target.value)
+ },
+ children: engines.map(function (item) {
+ return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
+ }),
+ }),
+ ],
+ }),
+ local
+ ? jsx.jsx('p', {
+ className: form.modelInstalled ? 'ok' : '',
+ children: form.modelInstalled
+ ? 'SenseVoice 离线模型已安装。'
+ : 'SenseVoice 离线模型未安装;点击对话框麦克风后会提示安装(约 ' +
+ (form.modelDownloadSizeMb || 245) +
+ ' MB)。',
+ })
+ : null,
+ jsx.jsxs('label', {
+ children: [
+ '语言',
+ jsx.jsx('select', {
+ value: form.language || '',
+ onChange: function (event) {
+ patch('language', event.target.value)
+ },
+ children: languages.map(function (item) {
+ return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
+ }),
+ }),
+ ],
+ }),
+ openai
+ ? jsx.jsxs('div', {
+ className: 'openai',
+ children: [
+ jsx.jsxs('label', {
+ children: [
+ jsx.jsxs('span', {
+ className: 'head',
+ children: [
+ '接口地址',
+ jsx.jsx('a', { href: 'https://console.groq.com/keys', children: 'Groq 密钥' }),
+ ],
+ }),
+ jsx.jsx('input', {
+ value: form.baseUrl || '',
+ placeholder: 'https://api.openai.com/v1',
+ onChange: function (event) {
+ patch('baseUrl', event.target.value)
+ },
+ }),
+ ],
+ }),
+ jsx.jsxs('label', {
+ children: [
+ jsx.jsxs('span', {
+ className: 'head',
+ children: [
+ 'API 密钥',
+ jsx.jsx('a', {
+ href: 'https://platform.openai.com/api-keys',
+ children: '获取 API',
+ }),
+ ],
+ }),
+ jsx.jsx('input', {
+ type: 'password',
+ value: form.apiKey || '',
+ onChange: function (event) {
+ patch('apiKey', event.target.value)
+ },
+ }),
+ ],
+ }),
+ jsx.jsxs('label', {
+ children: [
+ 'Whisper 模型',
+ jsx.jsx('input', {
+ value: form.model || '',
+ placeholder: '例如 whisper-1 或 whisper-large-v3',
+ onChange: function (event) {
+ patch('model', event.target.value)
+ },
+ }),
+ ],
+ }),
+ ],
+ })
+ : null,
+ jsx.jsxs('label', {
+ children: [
+ jsx.jsxs('span', {
+ className: 'head',
+ children: [
+ '麦克风',
+ jsx.jsx('a', {
+ href: '#',
+ onClick: function (event) {
+ event.preventDefault()
+ listMics()
+ },
+ children: '列出设备',
+ }),
+ ],
+ }),
+ jsx.jsxs('select', {
+ value: form.microphoneDeviceId || '',
+ onChange: function (event) {
+ var id = event.target.value
+ var match = devices.filter(function (item) { return item.id === id })[0]
+ patch('microphoneDeviceId', id)
+ patch('microphoneDeviceLabel', match ? match.label : '')
+ },
+ children: [jsx.jsx('option', { value: '', children: '系统默认' })].concat(
+ devices.map(function (item) {
+ return jsx.jsx('option', { value: item.id, children: item.label }, item.id)
+ }),
+ ),
+ }),
+ ],
+ }),
+ jsx.jsx('button', {
+ type: 'button',
+ disabled: !!snap.saving,
+ onClick: save,
+ children: snap.saving ? '保存中…' : '保存',
+ }),
+ snap.message
+ ? jsx.jsx('p', {
+ className: /失败|failed|error/i.test(snap.message) ? 'err' : 'ok',
+ children: snap.message,
+ })
+ : null,
+ ],
+ })
+ }
+
+ function apply(ctx) {
+ ctx.slots.inject('settings.section', function () {
+ return ctx.slots.register(
+ {
+ name: 'settings.section',
+ id: 'dsh-desktop-voice',
+ order: 13,
+ label: '语音输入',
+ },
+ VoiceSettings,
+ )
+ })
+ ctx.slots.inject('conversation.input.right', function () {
+ return ctx.slots.register(
+ {
+ name: 'conversation.input.right',
+ id: 'dsh-desktop-voice',
+ order: 20,
+ label: '语音输入',
+ },
+ MicButton,
+ )
+ })
+ }
+
+ exports.apply = apply
+ exports.inject = ['slots']
+ return module.exports
+ },
+})
diff --git a/plugins/dsh-desktop-voice/cordis.patch.yml b/plugins/dsh-desktop-voice/cordis.patch.yml
new file mode 100644
index 0000000..4b4d577
--- /dev/null
+++ b/plugins/dsh-desktop-voice/cordis.patch.yml
@@ -0,0 +1,4 @@
+# Settings page + composer mic + host route for desktop dictation.
+- insert:
+ - id: dsh-desktop-voice
+ name: dsh-desktop-voice
diff --git a/plugins/dsh-desktop-voice/index.js b/plugins/dsh-desktop-voice/index.js
new file mode 100644
index 0000000..f8f08d8
--- /dev/null
+++ b/plugins/dsh-desktop-voice/index.js
@@ -0,0 +1,576 @@
+import { createHash, randomUUID } from 'node:crypto'
+import { execFile } from 'node:child_process'
+import {
+ chmodSync,
+ createReadStream,
+ createWriteStream,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs'
+import { createRequire } from 'node:module'
+import { homedir, tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { Transform, Readable } from 'node:stream'
+import { pipeline } from 'node:stream/promises'
+import { promisify } from 'node:util'
+
+export const name = 'dsh-desktop-voice'
+export const inject = []
+
+const execFileAsync = promisify(execFile)
+const SHERPA_VERSION = '1.13.5'
+const SENSEVOICE_VERSION = '2365baeacb507f821a0c8120fcee3d484dba7a07'
+const SENSEVOICE_MODEL_BYTES = 239233841
+const SENSEVOICE_TOKENS_BYTES = 315894
+const SENSEVOICE_FILES = [
+ {
+ name: 'model.int8.onnx',
+ bytes: SENSEVOICE_MODEL_BYTES,
+ sha256: 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51',
+ },
+ {
+ name: 'tokens.txt',
+ bytes: SENSEVOICE_TOKENS_BYTES,
+ sha256: 'f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc',
+ },
+]
+const SENSEVOICE_BASE_URL =
+ 'https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/' +
+ SENSEVOICE_VERSION
+const SENSEVOICE_DOWNLOAD_BYTES = SENSEVOICE_FILES.reduce((sum, file) => sum + file.bytes, 0)
+const DOWNLOAD_PROGRESS_LIMIT = 95
+let installPromise = null
+let installState = { status: 'missing', percent: 0, stage: '' }
+let sherpa = null
+const recognizers = new Map()
+let recognitionQueue = Promise.resolve()
+
+const ENGINES = [
+ { id: 'sensevoice', label: 'SenseVoice(本机离线,推荐)' },
+ { id: 'openai', label: 'OpenAI 兼容接口' },
+]
+const ENGINE_IDS = new Set(ENGINES.map((item) => item.id))
+const MODES = [
+ { id: 'toggle', label: '点按开关(再按一次快捷键结束)' },
+ { id: 'hold', label: '按住说话(松开快捷键结束)' },
+]
+const MODE_IDS = new Set(MODES.map((item) => item.id))
+const LANGUAGES = [
+ { id: '', label: '自动检测' },
+ { id: 'zh', label: '中文' },
+ { id: 'yue', label: '粤语' },
+ { id: 'en', label: 'English' },
+ { id: 'ja', label: '日本語' },
+ { id: 'ko', label: '한국어' },
+]
+const LANGUAGE_IDS = new Set(LANGUAGES.map((item) => item.id))
+
+function configDir() {
+ if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'dsh-desktop')
+ return join(homedir(), '.config', 'dsh-desktop')
+}
+
+function configPath() {
+ return join(configDir(), 'voice.json')
+}
+
+function voiceDataDir() {
+ if (process.env.DSH_DESKTOP_VOICE_HOME) return process.env.DSH_DESKTOP_VOICE_HOME
+ if (process.env.XDG_CACHE_HOME) return join(process.env.XDG_CACHE_HOME, 'dsh-desktop', 'voice')
+ if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
+ return join(process.env.LOCALAPPDATA, 'dsh-desktop', 'Cache', 'voice')
+ }
+ if (process.platform === 'darwin') return join(homedir(), 'Library', 'Caches', 'dsh-desktop', 'voice')
+ return join(homedir(), '.cache', 'dsh-desktop', 'voice')
+}
+
+function senseVoiceDir() {
+ return join(voiceDataDir(), 'sensevoice')
+}
+
+function runtimeDir() {
+ return join(voiceDataDir(), 'runtime')
+}
+
+function markerPath() {
+ return join(senseVoiceDir(), 'installed.json')
+}
+
+function senseVoiceInstalled() {
+ try {
+ const marker = JSON.parse(readFileSync(markerPath(), 'utf8'))
+ const runtime = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
+ return (
+ marker.modelVersion === SENSEVOICE_VERSION &&
+ marker.runtimeVersion === SHERPA_VERSION &&
+ runtime.version === SHERPA_VERSION &&
+ SENSEVOICE_FILES.every((file) => statSync(join(senseVoiceDir(), file.name)).size === file.bytes)
+ )
+ } catch {
+ return false
+ }
+}
+
+function senseVoiceProgress() {
+ if (senseVoiceInstalled()) return { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
+ if (installPromise) return { ...installState }
+ if (installState.status === 'failed') return { ...installState }
+ return { status: 'missing', percent: 0, stage: '' }
+}
+
+function senseVoiceStatus() {
+ return senseVoiceProgress().status
+}
+
+function defaults() {
+ return {
+ enabled: true,
+ engine: 'sensevoice',
+ dictationMode: 'toggle',
+ language: 'zh',
+ apiKey: '',
+ baseUrl: 'https://api.openai.com/v1',
+ model: 'whisper-1',
+ microphoneDeviceId: '',
+ microphoneDeviceLabel: '',
+ }
+}
+
+function loadConfig() {
+ try {
+ const data = JSON.parse(readFileSync(configPath(), 'utf8'))
+ return data && typeof data === 'object' ? data : {}
+ } catch {
+ return {}
+ }
+}
+
+function formOf(cfg) {
+ const base = defaults()
+ const engine = ENGINE_IDS.has(cfg.engine) ? cfg.engine : base.engine
+ const dictationMode = MODE_IDS.has(cfg.dictationMode) ? cfg.dictationMode : base.dictationMode
+ const language = LANGUAGE_IDS.has(String(cfg.language ?? base.language))
+ ? String(cfg.language ?? base.language)
+ : base.language
+ return {
+ enabled: cfg.enabled !== false,
+ engine,
+ dictationMode,
+ language,
+ apiKey: String(cfg.apiKey || ''),
+ baseUrl: String(cfg.baseUrl || base.baseUrl),
+ model: String(cfg.model || base.model),
+ microphoneDeviceId: String(cfg.microphoneDeviceId || ''),
+ microphoneDeviceLabel: String(cfg.microphoneDeviceLabel || ''),
+ modelInstalled: senseVoiceInstalled(),
+ modelStatus: senseVoiceStatus(),
+ modelDownloadSizeMb: 245,
+ modelProgress: senseVoiceProgress(),
+ options: {
+ engines: ENGINES,
+ modes: MODES,
+ languages: LANGUAGES,
+ },
+ }
+}
+
+function saveForm(values) {
+ const next = formOf({ ...loadConfig(), ...values })
+ const path = configPath()
+ mkdirSync(dirname(path), { recursive: true })
+ writeFileSync(
+ path,
+ `${JSON.stringify(
+ {
+ enabled: next.enabled,
+ engine: next.engine,
+ dictationMode: next.dictationMode,
+ language: next.language,
+ apiKey: next.apiKey,
+ baseUrl: next.baseUrl,
+ model: next.model,
+ microphoneDeviceId: next.microphoneDeviceId,
+ microphoneDeviceLabel: next.microphoneDeviceLabel,
+ },
+ null,
+ 2,
+ )}\n`,
+ { encoding: 'utf8' },
+ )
+ try {
+ chmodSync(path, 0o600)
+ } catch {
+ // best-effort; some filesystems ignore mode
+ }
+ return formOf(loadConfig())
+}
+
+function whisperLanguage(code) {
+ const raw = String(code || '').trim().toLowerCase()
+ if (!raw || raw === 'auto') return ''
+ return raw.split(/[-_]/, 1)[0]
+}
+
+function transcriptionUrl(baseUrl) {
+ const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '')
+ if (!trimmed) return 'https://api.openai.com/v1/audio/transcriptions'
+ if (/\/audio\/transcriptions$/i.test(trimmed)) return trimmed
+ return `${trimmed}/audio/transcriptions`
+}
+
+function filenameFor(mime) {
+ const type = String(mime || '').toLowerCase()
+ if (type.includes('wav')) return 'audio.wav'
+ if (type.includes('mpeg') || type.includes('mp3')) return 'audio.mp3'
+ if (type.includes('mp4') || type.includes('m4a')) return 'audio.m4a'
+ if (type.includes('ogg')) return 'audio.ogg'
+ return 'audio.webm'
+}
+
+async function readBody(req) {
+ const chunks = []
+ for await (const chunk of req) chunks.push(chunk)
+ return Buffer.concat(chunks)
+}
+
+async function readJsonBody(req) {
+ const raw = (await readBody(req)).toString('utf8').trim()
+ if (!raw) return {}
+ return JSON.parse(raw)
+}
+
+function json(res, status, body) {
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
+ res.end(JSON.stringify(body))
+}
+
+async function sha256File(path) {
+ const hash = createHash('sha256')
+ for await (const chunk of createReadStream(path)) hash.update(chunk)
+ return hash.digest('hex')
+}
+
+async function ensureModelFile(file, reportBytes) {
+ const dest = join(senseVoiceDir(), file.name)
+ try {
+ if (statSync(dest).size === file.bytes && (await sha256File(dest)) === file.sha256) {
+ reportBytes(file.bytes)
+ return
+ }
+ } catch {}
+ mkdirSync(dirname(dest), { recursive: true })
+ const partial = `${dest}.part`
+ rmSync(partial, { force: true })
+ const response = await fetch(`${SENSEVOICE_BASE_URL}/${file.name}?download=true`, {
+ signal: AbortSignal.timeout(900000),
+ })
+ if (!response.ok || !response.body) {
+ throw new Error(`下载 ${file.name} 失败:HTTP ${response.status}`)
+ }
+ const hash = createHash('sha256')
+ let bytes = 0
+ const verify = new Transform({
+ transform(chunk, _encoding, callback) {
+ bytes += chunk.length
+ reportBytes(chunk.length)
+ hash.update(chunk)
+ callback(null, chunk)
+ },
+ })
+ try {
+ await pipeline(Readable.fromWeb(response.body), verify, createWriteStream(partial, { mode: 0o600 }))
+ const digest = hash.digest('hex')
+ if (bytes !== file.bytes || digest !== file.sha256) {
+ throw new Error(`下载的 ${file.name} 校验失败`)
+ }
+ renameSync(partial, dest)
+ } catch (error) {
+ rmSync(partial, { force: true })
+ throw error
+ }
+}
+
+function npmInvocation() {
+ if (process.env.npm_execpath && existsSync(process.env.npm_execpath)) {
+ return { file: process.execPath, prefix: [process.env.npm_execpath] }
+ }
+ const configured = process.env.DSH_DESKTOP_NPM
+ if (configured) return { file: configured, prefix: [] }
+ for (const candidate of ['/app/bin/npm', '/app/node24/bin/npm']) {
+ if (existsSync(candidate)) return { file: candidate, prefix: [] }
+ }
+ if (process.platform === 'win32') return { file: 'cmd.exe', prefix: ['/d', '/s', '/c', 'npm.cmd'] }
+ return { file: 'npm', prefix: [] }
+}
+
+async function installSherpaRuntime() {
+ try {
+ const pkg = JSON.parse(readFileSync(join(runtimeDir(), 'node_modules', 'sherpa-onnx', 'package.json'), 'utf8'))
+ if (pkg.version === SHERPA_VERSION) return
+ } catch {}
+ mkdirSync(runtimeDir(), { recursive: true })
+ const npm = npmInvocation()
+ try {
+ await execFileAsync(
+ npm.file,
+ [
+ ...npm.prefix,
+ 'install',
+ '--prefix',
+ runtimeDir(),
+ '--no-audit',
+ '--no-fund',
+ '--omit=dev',
+ '--save-exact',
+ `sherpa-onnx@${SHERPA_VERSION}`,
+ ],
+ { timeout: 300000, maxBuffer: 1024 * 1024 },
+ )
+ } catch (error) {
+ const detail = String(error?.stderr || error?.message || error).trim()
+ throw new Error(`安装离线识别运行时失败:${detail}`)
+ }
+}
+
+async function installSenseVoice() {
+ if (senseVoiceInstalled()) return { ...formOf(loadConfig()), installed: true }
+ if (!installPromise) {
+ installState = { status: 'installing', percent: 0, stage: '正在准备离线识别运行时…' }
+ installPromise = (async () => {
+ mkdirSync(senseVoiceDir(), { recursive: true })
+ rmSync(markerPath(), { force: true })
+ let downloadedBytes = 0
+ const reportBytes = (bytes) => {
+ downloadedBytes += bytes
+ installState = {
+ status: 'installing',
+ percent: Math.min(
+ DOWNLOAD_PROGRESS_LIMIT,
+ Math.floor((downloadedBytes / SENSEVOICE_DOWNLOAD_BYTES) * DOWNLOAD_PROGRESS_LIMIT),
+ ),
+ stage: '正在下载 SenseVoice 模型…',
+ }
+ }
+ const runtime = installSherpaRuntime()
+ await Promise.all(SENSEVOICE_FILES.map((file) => ensureModelFile(file, reportBytes)))
+ installState = {
+ status: 'installing',
+ percent: DOWNLOAD_PROGRESS_LIMIT,
+ stage: '正在完成离线识别运行时…',
+ }
+ await runtime
+ const partial = `${markerPath()}.part`
+ writeFileSync(
+ partial,
+ `${JSON.stringify({ modelVersion: SENSEVOICE_VERSION, runtimeVersion: SHERPA_VERSION }, null, 2)}\n`,
+ { encoding: 'utf8', mode: 0o600 },
+ )
+ renameSync(partial, markerPath())
+ installState = { status: 'installed', percent: 100, stage: 'SenseVoice 已安装' }
+ })()
+ .catch((error) => {
+ installState = {
+ status: 'failed',
+ percent: installState.percent,
+ stage: String(error?.message || error),
+ }
+ throw error
+ })
+ .finally(() => {
+ installPromise = null
+ })
+ }
+ await installPromise
+ return { ...formOf(loadConfig()), installed: true }
+}
+
+function sherpaModule() {
+ if (!sherpa) {
+ const require = createRequire(join(runtimeDir(), 'package.json'))
+ sherpa = require('sherpa-onnx')
+ }
+ return sherpa
+}
+
+function senseVoiceRecognizer(language) {
+ const code = whisperLanguage(language)
+ if (recognizers.has(code)) return recognizers.get(code)
+ const runtime = sherpaModule()
+ const recognizer = runtime.createOfflineRecognizer({
+ modelConfig: {
+ senseVoice: {
+ model: join(senseVoiceDir(), 'model.int8.onnx'),
+ language: code,
+ useInverseTextNormalization: 1,
+ },
+ tokens: join(senseVoiceDir(), 'tokens.txt'),
+ },
+ })
+ recognizers.set(code, recognizer)
+ return recognizer
+}
+
+function enqueueRecognition(task) {
+ const next = recognitionQueue.then(task, task)
+ recognitionQueue = next.catch(() => {})
+ return next
+}
+
+async function transcribeSenseVoice(audio, mime, language) {
+ if (!senseVoiceInstalled()) {
+ const error = new Error('SenseVoice 离线模型尚未安装')
+ error.status = 409
+ throw error
+ }
+ if (!String(mime).toLowerCase().includes('wav')) {
+ const error = new Error('SenseVoice 离线识别需要 WAV 音频')
+ error.status = 415
+ throw error
+ }
+ const path = join(tmpdir(), `dsh-desktop-voice-${randomUUID()}.wav`)
+ writeFileSync(path, audio, { mode: 0o600 })
+ try {
+ return await enqueueRecognition(() => {
+ const runtime = sherpaModule()
+ const recognizer = senseVoiceRecognizer(language)
+ const wave = runtime.readWave(path)
+ const stream = recognizer.createStream()
+ try {
+ stream.acceptWaveform(wave.sampleRate, wave.samples)
+ recognizer.decode(stream)
+ return String(recognizer.getResult(stream).text || '').trim()
+ } finally {
+ stream.free()
+ }
+ })
+ } finally {
+ rmSync(path, { force: true })
+ }
+}
+
+async function transcribeOpenAI(cfg, audio, mime, language) {
+ if (!cfg.apiKey) {
+ const error = new Error('未配置 OpenAI API 密钥')
+ error.status = 400
+ throw error
+ }
+ const form = new FormData()
+ form.append('file', new Blob([audio], { type: mime }), filenameFor(mime))
+ form.append('model', cfg.model || 'whisper-1')
+ form.append('response_format', 'json')
+ if (language) form.append('language', language)
+ const response = await fetch(transcriptionUrl(cfg.baseUrl), {
+ method: 'POST',
+ headers: { authorization: `Bearer ${cfg.apiKey}` },
+ body: form,
+ signal: AbortSignal.timeout(60000),
+ })
+ const text = await response.text()
+ let body = {}
+ try {
+ body = text ? JSON.parse(text) : {}
+ } catch {
+ body = { error: text.slice(0, 400) }
+ }
+ if (!response.ok) {
+ const detail = body.error?.message || body.error || body.message || text.slice(0, 400)
+ const error = new Error(String(detail || `OpenAI 接口返回 ${response.status}`))
+ error.status = response.status
+ throw error
+ }
+ return String(body.text || body.transcript || '').trim()
+}
+
+async function transcribe(req) {
+ const cfg = formOf(loadConfig())
+ const url = new URL(req.url, 'http://localhost')
+ const mime = String(req.headers['content-type'] || url.searchParams.get('mime') || 'application/octet-stream')
+ const language = whisperLanguage(url.searchParams.get('language') || cfg.language)
+ const audio = await readBody(req)
+ if (!audio.length) {
+ const error = new Error('没有收到音频')
+ error.status = 400
+ throw error
+ }
+ const transcript =
+ cfg.engine === 'openai'
+ ? await transcribeOpenAI(cfg, audio, mime, language)
+ : await transcribeSenseVoice(audio, mime, language)
+ if (!transcript) {
+ const error = new Error('没有识别到语音')
+ error.status = 422
+ throw error
+ }
+ return { text: transcript }
+}
+
+export function apply(ctx) {
+ if (typeof ctx.inject !== 'function') return
+ ctx.inject(['webServer'], (scope) => {
+ try {
+ scope.webServer.register({
+ name: 'dsh-desktop-voice',
+ kind: 'exact',
+ path: '/dsh-desktop/voice',
+ handler: async (req, res) => {
+ try {
+ if (req.method === 'GET') {
+ json(res, 200, formOf(loadConfig()))
+ return
+ }
+ if (req.method === 'PUT' || req.method === 'POST') {
+ json(res, 200, { ...saveForm(await readJsonBody(req)), saved: true })
+ return
+ }
+ res.writeHead(405).end()
+ } catch (error) {
+ json(res, error.status || 500, { error: String(error?.message || error) })
+ }
+ },
+ })
+ scope.webServer.register({
+ name: 'dsh-desktop-voice-model',
+ kind: 'exact',
+ path: '/dsh-desktop/voice/model',
+ handler: async (req, res) => {
+ try {
+ if (req.method === 'GET') {
+ json(res, 200, senseVoiceProgress())
+ return
+ }
+ if (req.method === 'POST') {
+ json(res, 200, await installSenseVoice())
+ return
+ }
+ res.writeHead(405).end()
+ } catch (error) {
+ json(res, error.status || 500, { error: String(error?.message || error) })
+ }
+ },
+ })
+ scope.webServer.register({
+ name: 'dsh-desktop-voice-transcribe',
+ kind: 'exact',
+ path: '/dsh-desktop/voice/transcribe',
+ handler: async (req, res) => {
+ try {
+ if (req.method !== 'POST') {
+ res.writeHead(405).end()
+ return
+ }
+ json(res, 200, await transcribe(req))
+ } catch (error) {
+ json(res, error.status || 500, { error: String(error?.message || error) })
+ }
+ },
+ })
+ } catch (error) {
+ console.error(`[dsh-desktop-voice] routes skipped: ${error}`)
+ }
+ })
+}
diff --git a/plugins/dsh-desktop-voice/package.json b/plugins/dsh-desktop-voice/package.json
new file mode 100644
index 0000000..cc86181
--- /dev/null
+++ b/plugins/dsh-desktop-voice/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "dsh-desktop-voice",
+ "version": "0.4.0",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": "./index.js",
+ "./client": "./client.js",
+ "./package.json": "./package.json"
+ },
+ "dsh": {
+ "bundle": {
+ "patch": "./cordis.patch.yml"
+ },
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-client-ui-settings",
+ "@deepseek-ai/dsh-client-ui-conversation"
+ ],
+ "platform": "web",
+ "immediately": true
+ }
+ }
+}
diff --git a/scripts/build-gitea-update.py b/scripts/build-gitea-update.py
new file mode 100755
index 0000000..9c5ccd0
--- /dev/null
+++ b/scripts/build-gitea-update.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""Normalize Tauri artifacts and build the static updater manifest."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+from pathlib import Path
+
+MATRIX_TARGETS = {
+ "gitea-macos-arm64": ("darwin", "aarch64"),
+ "gitea-macos-x64": ("darwin", "x86_64"),
+ "gitea-linux": ("linux", "x86_64"),
+ "gitea-windows": ("windows", "x86_64"),
+ "gitea-flatpak": ("linux", "x86_64"),
+}
+DELIVERABLE_SUFFIXES = (
+ ".app.tar.gz.sig",
+ ".app.tar.gz",
+ ".AppImage.sig",
+ ".AppImage",
+ ".flatpak",
+ ".dmg",
+ ".deb",
+ ".rpm",
+ ".exe.sig",
+ ".exe",
+ ".msi.sig",
+ ".msi",
+)
+UPDATER_SUFFIX = {
+ "darwin": ".app.tar.gz",
+ "linux": ".AppImage",
+ "windows": ".exe",
+}
+INSTALLER_KIND = {
+ "darwin": "app",
+ "linux": "appimage",
+ "windows": "nsis",
+}
+
+
+def artifact_target(path: Path, root: Path) -> tuple[str, str] | None:
+ relative = path.relative_to(root)
+ for part in relative.parts:
+ if part in MATRIX_TARGETS:
+ return MATRIX_TARGETS[part]
+ return None
+
+
+def deliverable_suffix(name: str) -> str | None:
+ return next((suffix for suffix in DELIVERABLE_SUFFIXES if name.endswith(suffix)), None)
+
+
+def stage_artifacts(source: Path, output: Path, version: str) -> dict[tuple[str, str, str], Path]:
+ output.mkdir(parents=True, exist_ok=True)
+ staged: dict[tuple[str, str, str], Path] = {}
+ for path in sorted(source.rglob("*")):
+ if not path.is_file():
+ continue
+ target = artifact_target(path, source)
+ suffix = deliverable_suffix(path.name)
+ if target is None or suffix is None:
+ continue
+ os_name, arch = target
+ key = (os_name, arch, suffix)
+ if key in staged:
+ raise ValueError(f"duplicate {os_name}-{arch}{suffix}: {staged[key]} and {path}")
+ destination = output / f"dsh-easy-desktop_{version}_{os_name}_{arch}{suffix}"
+ shutil.copy2(path, destination)
+ staged[key] = destination
+ return staged
+
+
+def build_manifest(
+ staged: dict[tuple[str, str, str], Path],
+ version: str,
+ package_base_url: str,
+ notes: str,
+ pub_date: str | None,
+) -> dict[str, object]:
+ platforms: dict[str, dict[str, str]] = {}
+ for (os_name, arch, suffix), artifact in staged.items():
+ if suffix != UPDATER_SUFFIX.get(os_name):
+ continue
+ signature = staged.get((os_name, arch, suffix + ".sig"))
+ if signature is None:
+ raise ValueError(f"missing signature for {artifact.name}")
+ entry = {
+ "url": f"{package_base_url.rstrip('/')}/{version}/{artifact.name}",
+ "signature": signature.read_text(encoding="utf-8").strip(),
+ }
+ platforms[f"{os_name}-{arch}-{INSTALLER_KIND[os_name]}"] = entry
+ platforms[f"{os_name}-{arch}"] = entry
+ required = {"darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64"}
+ missing = required.difference(platforms)
+ if missing:
+ raise ValueError(f"missing updater targets: {', '.join(sorted(missing))}")
+ manifest: dict[str, object] = {
+ "version": version,
+ "notes": notes,
+ "platforms": platforms,
+ }
+ if pub_date:
+ manifest["pub_date"] = pub_date
+ return manifest
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--artifacts", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--version", required=True)
+ parser.add_argument("--package-base-url", required=True)
+ parser.add_argument("--notes", default="DeepSeek Harness Desktop update")
+ parser.add_argument("--pub-date")
+ args = parser.parse_args()
+
+ staged = stage_artifacts(args.artifacts, args.output, args.version)
+ manifest = build_manifest(
+ staged,
+ args.version,
+ args.package_base_url,
+ args.notes,
+ args.pub_date,
+ )
+ (args.output / "latest.json").write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/patch-dshmarket-mainland.py b/scripts/patch-dshmarket-mainland.py
new file mode 100755
index 0000000..c0a5a82
--- /dev/null
+++ b/scripts/patch-dshmarket-mainland.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Apply the downstream mainland-connectivity warning to vendored dshmarket."""
+
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+MARKET = ROOT / "vendor" / "dshmarket" / "node_modules" / "dshmarket"
+
+
+def insert_after(path: Path, anchor: str, addition: str) -> None:
+ text = path.read_text(encoding="utf-8")
+ if addition.strip() in text:
+ return
+ if text.count(anchor) != 1:
+ raise RuntimeError(f"expected one patch anchor in {path}: {anchor!r}")
+ path.write_text(text.replace(anchor, anchor + addition), encoding="utf-8")
+
+def insert_before(path: Path, anchor: str, addition: str) -> None:
+ text = path.read_text(encoding="utf-8")
+ if addition.strip() in text:
+ return
+ if text.count(anchor) != 1:
+ raise RuntimeError(f"expected one patch anchor in {path}: {anchor!r}")
+ path.write_text(text.replace(anchor, addition + anchor), encoding="utf-8")
+
+
+def main() -> None:
+ locales = MARKET / "src" / "client" / "locales.ts"
+ insert_after(
+ locales,
+ " terminalWarn: '这看起来是终端/命令行插件:装进网页版可能无效,甚至导致 DeepSeek Harness 无法启动。建议先看它的使用说明,按说明装进对应的 profile。',\n",
+ " githubInstallWarn: '此插件没有 npm 安装包,安装时必须从 github.com 下载源码;中国大陆网络通常无法直连。请仅在当前网络能访问 GitHub 时继续。',\n",
+ )
+ insert_after(
+ locales,
+ " terminalWarn: 'This looks like a terminal/CLI plugin: installing it into the web profile may do nothing, or even break DeepSeek Harness startup. Read its README and install it into the profile it targets.',\n",
+ " githubInstallWarn: 'This plugin has no npm package. Installation must download its source from github.com and will fail where GitHub is unreachable. Continue only if this network can access GitHub.',\n",
+ )
+
+ section = MARKET / "src" / "client" / "MarketSection.tsx"
+ source_anchor = """ {' ' + t('confirmWarn')}
\n"""
+ source_addition = """ {typeof confirming.npm !== 'string' && (\n \n \n {' ' + t('githubInstallWarn')}\n
\n )}\n"""
+ insert_before(section, source_anchor, source_addition)
+
+ client = MARKET / "client" / "client.js"
+ insert_after(
+ client,
+ '\t\t\tterminalWarn: "这看起来是终端/命令行插件:装进网页版可能无效,甚至导致 DeepSeek Harness 无法启动。建议先看它的使用说明,按说明装进对应的 profile。",\n',
+ '\t\t\tgithubInstallWarn: "此插件没有 npm 安装包,安装时必须从 github.com 下载源码;中国大陆网络通常无法直连。请仅在当前网络能访问 GitHub 时继续。",\n',
+ )
+ insert_after(
+ client,
+ '\t\t\tterminalWarn: "This looks like a terminal/CLI plugin: installing it into the web profile may do nothing, or even break DeepSeek Harness startup. Read its README and install it into the profile it targets.",\n',
+ '\t\t\tgithubInstallWarn: "This plugin has no npm package. Installation must download its source from github.com and will fail where GitHub is unreachable. Continue only if this network can access GitHub.",\n',
+ )
+ built_anchor = """\t\t\t\t\t\t\t/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(\"p\", {\n\t\t\t\t\t\t\t\tclassName: Market_module_css_default.modalNote,\n"""
+ built_addition = """\t\t\t\t\t\t\ttypeof confirming.npm !== \"string\" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(\"p\", {\n\t\t\t\t\t\t\t\tclassName: Market_module_css_default.warnLine,\n\t\t\t\t\t\t\t\tchildren: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {\n\t\t\t\t\t\t\t\t\tsize: 14,\n\t\t\t\t\t\t\t\t\tclassName: Market_module_css_default.bannerIcon\n\t\t\t\t\t\t\t\t}), \" \" + t(\"githubInstallWarn\")]\n\t\t\t\t\t\t\t}),\n"""
+ insert_before(client, built_anchor, built_addition)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh
new file mode 100755
index 0000000..1cb03e4
--- /dev/null
+++ b/scripts/publish-gitea-release.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Publish normalized installers and signed updater artifacts to Gitea.
+set -euo pipefail
+
+STAGING_DIR="${1:?usage: publish-gitea-release.sh STAGING_DIR}"
+: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
+: "${GITEA_BASE_URL:?GITEA_BASE_URL is required}"
+: "${GITEA_OWNER:?GITEA_OWNER is required}"
+: "${GITEA_REPO:?GITEA_REPO is required}"
+: "${RELEASE_TAG:?RELEASE_TAG is required}"
+: "${RELEASE_VERSION:?RELEASE_VERSION is required}"
+
+API="${GITEA_BASE_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
+PACKAGE_BASE="${GITEA_BASE_URL%/}/api/packages/${GITEA_OWNER}/generic/dsh-easy-desktop-updater"
+AUTH="Authorization: token ${GITEA_TOKEN}"
+
+# The Gitea repository is a pull mirror. Sync the GitHub tag before creating
+# the matching Gitea release, then wait until the tag is queryable.
+curl --fail --silent --show-error -X POST -H "$AUTH" "$API/mirror-sync" >/dev/null
+for _ in $(seq 1 30); do
+ if curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 10
+done
+curl --fail --silent --show-error -H "$AUTH" "$API/tags/$RELEASE_TAG" >/dev/null
+
+release_json=$(curl --silent --show-error -H "$AUTH" "$API/releases/tags/$RELEASE_TAG")
+release_id=$(printf '%s' "$release_json" | jq -r '.id // empty')
+if [[ -z "$release_id" ]]; then
+ release_payload=$(jq -n \
+ --arg tag "$RELEASE_TAG" \
+ --arg name "DeepSeek Harness Desktop $RELEASE_TAG" \
+ --arg body "大陆镜像安装包;文件与 GitHub Release 同源。应用内更新包由 Tauri 签名校验。" \
+ '{tag_name:$tag,target_commitish:$tag,name:$name,body:$body,draft:false,prerelease:false}')
+ release_json=$(curl --fail --silent --show-error \
+ -X POST -H "$AUTH" -H 'Content-Type: application/json' \
+ --data "$release_payload" "$API/releases")
+ release_id=$(printf '%s' "$release_json" | jq -r '.id')
+fi
+
+# Versioned generic package: immutable URLs consumed by latest.json.
+curl --silent --show-error -X DELETE -H "$AUTH" \
+ "$PACKAGE_BASE/$RELEASE_VERSION" >/dev/null || true
+for file in "$STAGING_DIR"/*; do
+ [[ -f "$file" && "$(basename "$file")" != "latest.json" ]] || continue
+ curl --fail --silent --show-error -H "$AUTH" --upload-file "$file" \
+ "$PACKAGE_BASE/$RELEASE_VERSION/$(basename "$file")" >/dev/null
+done
+
+# Stable updater endpoint. Gitea generic packages are immutable, so replace
+# the synthetic "latest" version on each completed release.
+curl --silent --show-error -X DELETE -H "$AUTH" "$PACKAGE_BASE/latest" >/dev/null || true
+curl --fail --silent --show-error -H "$AUTH" --upload-file "$STAGING_DIR/latest.json" \
+ "$PACKAGE_BASE/latest/latest.json" >/dev/null
+
+assets=$(curl --fail --silent --show-error -H "$AUTH" "$API/releases/$release_id/assets")
+for file in "$STAGING_DIR"/*; do
+ [[ -f "$file" ]] || continue
+ name=$(basename "$file")
+ case "$name" in
+ *.dmg|*.deb|*.rpm|*.exe|*.msi|*.AppImage|*.flatpak) ;;
+ *) continue ;;
+ esac
+ old_id=$(printf '%s' "$assets" | jq -r --arg name "$name" '[.[] | select(.name == $name) | .id][0] // empty')
+ if [[ -n "$old_id" ]]; then
+ curl --fail --silent --show-error -X DELETE -H "$AUTH" \
+ "$API/releases/$release_id/assets/$old_id" >/dev/null
+ fi
+ curl --fail --silent --show-error -H "$AUTH" \
+ -F "attachment=@$file" "$API/releases/$release_id/assets?name=$name" >/dev/null
+done
diff --git a/scripts/setup-gitea-release.sh b/scripts/setup-gitea-release.sh
new file mode 100755
index 0000000..a7a27c9
--- /dev/null
+++ b/scripts/setup-gitea-release.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+# Interactive one-time setup for Gitea publishing and Tauri update signing.
+set -euo pipefail
+
+BOLD='\033[1m'
+DIM='\033[2m'
+RED='\033[31m'
+GREEN='\033[32m'
+RESET='\033[0m'
+GITEA_BASE_URL='https://git.fangsiyuan.top'
+GITEA_OWNER='TomHanck4'
+GITEA_REPO='dsh-easy-desktop'
+
+step() {
+ printf '\n%b%s%b\n' "$BOLD" "$1" "$RESET"
+ printf '%b%s%b\n\n' "$DIM" "$2" "$RESET"
+}
+
+confirm() {
+ local answer
+ read -r -p "$1 [y/N] " answer
+ [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+need() {
+ command -v "$1" >/dev/null 2>&1 || {
+ printf '%b缺少命令:%s%b\n' "$RED" "$1" "$RESET" >&2
+ exit 1
+ }
+}
+
+need curl
+need gh
+need jq
+
+printf '%bGitea 大陆发行通道设置%b\n' "$BOLD" "$RESET"
+printf '共 3 步:验证 Gitea → 配置签名 → 写入 GitHub Secrets\n'
+
+step '1/3 验证 Gitea' '需要一个可写入 TomHanck4/dsh-easy-desktop 发行版与 Generic Package 的 Gitea token。'
+printf '%bHTTPS 已启用:%btoken 与发行包传输均受 TLS 保护。\n' "$GREEN" "$RESET"
+read -r -s -p '粘贴 Gitea token: ' GITEA_TOKEN
+printf '\n'
+[[ -n "$GITEA_TOKEN" ]] || { printf '%btoken 不能为空%b\n' "$RED" "$RESET" >&2; exit 1; }
+AUTH="Authorization: token $GITEA_TOKEN"
+user=$(curl --fail --silent --show-error -H "$AUTH" "$GITEA_BASE_URL/api/v1/user")
+repo=$(curl --fail --silent --show-error -H "$AUTH" "$GITEA_BASE_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO")
+printf '已认证:%s;仓库:%s\n' "$(printf '%s' "$user" | jq -r .login)" "$(printf '%s' "$repo" | jq -r .full_name)"
+case "$(printf '%s' "$repo" | jq -r .html_url)" in
+ https://*) ;;
+ *)
+ printf '%bGitea API 仍生成 HTTP 链接。请先把 app.ini 的 [server] ROOT_URL 改为 https://git.fangsiyuan.top/ 并重启 Gitea。%b\n' "$RED" "$RESET" >&2
+ exit 1
+ ;;
+esac
+[[ "$(printf '%s' "$repo" | jq -r .mirror)" == 'true' ]] || {
+ printf '%b仓库不是 pull mirror,发布脚本无法同步 GitHub tag。%b\n' "$RED" "$RESET" >&2
+ exit 1
+}
+
+step '2/3 配置 Tauri 签名' '已有密钥可复用;否则向导通过 Tauri CLI 在 ~/.tauri 生成。私钥不会写入仓库。'
+if confirm '已有 Tauri updater 私钥吗?'; then
+ read -r -e -p '私钥路径: ' PRIVATE_KEY_PATH
+ read -r -e -p '公钥路径: ' PUBLIC_KEY_PATH
+ read -r -s -p '私钥密码(没有则留空): ' SIGNING_PASSWORD
+ printf '\n'
+else
+ need npx
+ PRIVATE_KEY_PATH="$HOME/.tauri/dsh-easy-desktop.key"
+ PUBLIC_KEY_PATH="$PRIVATE_KEY_PATH.pub"
+ mkdir -p "$(dirname "$PRIVATE_KEY_PATH")"
+ read -r -s -p '设置私钥密码(可留空): ' SIGNING_PASSWORD
+ printf '\n正在生成签名密钥…\n'
+ npm_config_registry='https://registry.npmmirror.com' \
+ npx --yes @tauri-apps/cli@2 signer generate --ci --password "$SIGNING_PASSWORD" -w "$PRIVATE_KEY_PATH"
+fi
+[[ -f "$PRIVATE_KEY_PATH" ]] || { printf '%b找不到私钥:%s%b\n' "$RED" "$PRIVATE_KEY_PATH" "$RESET" >&2; exit 1; }
+[[ -f "$PUBLIC_KEY_PATH" ]] || { printf '%b找不到公钥:%s%b\n' "$RED" "$PUBLIC_KEY_PATH" "$RESET" >&2; exit 1; }
+chmod 600 "$PRIVATE_KEY_PATH"
+printf '私钥:%s\n公钥:%s\n' "$PRIVATE_KEY_PATH" "$PUBLIC_KEY_PATH"
+
+step '3/3 写入 GitHub Secrets' 'gh 会把四项机密直接写到当前仓库;终端不会打印 secret 内容。'
+gh auth status >/dev/null
+confirm '现在写入 GITEA_TOKEN 与 Tauri 签名 secrets 吗?' || exit 1
+printf '%s' "$GITEA_TOKEN" | gh secret set GITEA_TOKEN
+cat "$PRIVATE_KEY_PATH" | gh secret set TAURI_SIGNING_PRIVATE_KEY
+cat "$PUBLIC_KEY_PATH" | gh secret set DSH_DESKTOP_UPDATER_PUBKEY
+if [[ -n "$SIGNING_PASSWORD" ]]; then
+ printf '%s' "$SIGNING_PASSWORD" | gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD
+fi
+unset GITEA_TOKEN SIGNING_PASSWORD AUTH
+
+printf '\n%b设置完成。%b 下次推送 v* tag 时,Release 工作流会发布 GitHub 与 Gitea 两套安装包。\n' "$GREEN" "$RESET"
+printf '首个版本发布后检查:%s/%s/%s/releases/latest\n' "$GITEA_BASE_URL" "$GITEA_OWNER" "$GITEA_REPO"
diff --git a/scripts/vendor-native.sh b/scripts/vendor-native.sh
index 340063e..ae3c423 100755
--- a/scripts/vendor-native.sh
+++ b/scripts/vendor-native.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Fetch ModLens + Anchored Standard into vendor/ for Tauri resource bundling.
+# Fetch ModLens, dshmarket, and Anchored Standard into vendor/ for Tauri resources.
# Does not vendor @deepseek-ai/dsh (that is Flatpak-only; see `make vendor`).
set -euo pipefail
@@ -7,6 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
MODLENS_VERSION="${MODLENS_VERSION:-3.16.6}"
+MARKET_VERSION="${MARKET_VERSION:-1.9.0}"
ANCHORED_COMMIT="${ANCHORED_COMMIT:-ffb845c5480adc953392a6db6f8a98ede621174b}"
ANCHORED_REPO="${ANCHORED_REPO:-https://github.com/xiaobright/dsh-anchored-standard.git}"
if [ -z "${PYTHON:-}" ]; then
@@ -20,6 +21,7 @@ fi
ANCHORED_DIR="vendor/anchored-standard"
ZERO_DIR="vendor/zero-anchored-standard"
MODLENS_DIR="vendor/modlens"
+MARKET_DIR="vendor/dshmarket"
rm -rf vendor/.anchored-src "$ANCHORED_DIR" "$ZERO_DIR"
mkdir -p vendor/.anchored-src
@@ -42,7 +44,13 @@ rm -rf "$MODLENS_DIR"
mkdir -p "$MODLENS_DIR"
npm install --prefix "$MODLENS_DIR" --prefer-offline --no-audit --no-fund "@liustack/modlens@${MODLENS_VERSION}"
+rm -rf "$MARKET_DIR"
+mkdir -p "$MARKET_DIR"
+npm install --prefix "$MARKET_DIR" --prefer-offline --no-audit --no-fund "dshmarket@${MARKET_VERSION}"
+"$PYTHON" scripts/patch-dshmarket-mainland.py
+
test -f "$ANCHORED_DIR/preset.yml"
test -f "$ZERO_DIR/preset.yml"
test -d "$MODLENS_DIR/node_modules/@liustack/modlens"
-echo "vendored ModLens ${MODLENS_VERSION} and Anchored Standard ${ANCHORED_COMMIT}"
+test -d "$MARKET_DIR/node_modules/dshmarket"
+echo "vendored ModLens ${MODLENS_VERSION}, dshmarket ${MARKET_VERSION}, and Anchored Standard ${ANCHORED_COMMIT}"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 6d7b395..f04e638 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -22,6 +22,7 @@ dsh-core = { path = "../crates/dsh-core" }
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
+tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
arboard = { version = "3", features = ["wayland-data-control"] }
@@ -34,3 +35,4 @@ env_logger = "0.11"
[target.'cfg(target_os = "linux")'.dependencies]
gdk = "0.18"
gtk = "0.18"
+webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist
new file mode 100644
index 0000000..7b15818
--- /dev/null
+++ b/src-tauri/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ NSMicrophoneUsageDescription
+ 语音输入需要使用麦克风,以便把说话内容写进对话框。
+
+
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 1e9dc6a..09736e9 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,6 +1,9 @@
+mod shell_updater;
+
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
@@ -38,7 +41,9 @@ pub struct Args {
impl Args {
pub fn parse() -> Self {
let mut args = Args {
- dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE).ok().filter(|s| !s.is_empty()),
+ dsh: std::env::var(dsh_core::ENV_BIN_OVERRIDE)
+ .ok()
+ .filter(|s| !s.is_empty()),
cwd: std::env::var(dsh_core::ENV_CWD_OVERRIDE)
.ok()
.filter(|s| !s.is_empty())
@@ -91,6 +96,7 @@ struct AppState {
paths: BundledPaths,
process: Mutex