From 4066be734c7442055cd5dda44d156b65f9c49735 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 20:44:27 +0800 Subject: [PATCH 01/20] =?UTF-8?q?chore(plugin):=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=AE=9A=E5=88=B6=E6=8F=92=E4=BB=B6=E5=8C=85=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=88a6api/metaso-h3/antigravity-proxy/openai-images=20?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E9=80=82=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugin-packages/a6api.yingce-plugin | Bin 8332 -> 8332 bytes .../antigravity-proxy.yingce-plugin | Bin 7286 -> 7286 bytes plugin-packages/metaso-h3.yingce-plugin | Bin 8173 -> 8173 bytes plugin-packages/openai-images.yingce-plugin | Bin 9471 -> 9919 bytes .../openai-images/docs/interface.md | 186 ++++--- plugin-packages/openai-images/manifest.json | 176 ++++--- plugin-packages/src-a6api/README.md | 2 +- plugin-packages/src-a6api/docs/interface.md | 495 +++++++++++++++++- plugin-packages/src-a6api/manifest.json | 473 ++++++++++++++--- .../src-antigravity-proxy/docs/interface.md | 233 +++++++++ .../src-antigravity-proxy/manifest.json | 228 ++++++-- .../src-metaso-h3/docs/interface.md | 376 +++++++++++++ plugin-packages/src-metaso-h3/manifest.json | 355 +++++++++++-- 13 files changed, 2235 insertions(+), 289 deletions(-) diff --git a/plugin-packages/a6api.yingce-plugin b/plugin-packages/a6api.yingce-plugin index cd70adef534b9c7fa8db6ba287d2e0d228a109b3..1414493ce5116fa82c89ba00671cb36f8d678947 100644 GIT binary patch delta 58 zcmeBi>~Z7?@MdNaVPIh3U^r5{kw;UA=}7Hnb0JnH5N#(inHxw?zASGEqWBa{L6ob4 FI{?){5rzN& delta 58 zcmeBi>~Z7?@MdNaVPIh3VA##Hkw;UAX*bhmb0JnH5N#(inHxw?zASGEqWBa{L6ob4 FI{=K@4sHMd diff --git a/plugin-packages/antigravity-proxy.yingce-plugin b/plugin-packages/antigravity-proxy.yingce-plugin index f5ad9ab1fc3239e846deefd06ff0e12d0ad71f5c..d7ed5de732f8d29f5bbdb0a0540bbbec57b713d9 100644 GIT binary patch delta 58 zcmexn@y&uKz?+#xgn@y9gW*W+Mjk&trX#hRWB67vfoKlF&0Iiw@?L365cNaa6h!IE GxB~zp5fjD$ delta 58 zcmexn@y&uKz?+#xgn@y9gJCz*Mjk&trrk`NWB67vfoKlF&0Iiw@?L365cNaa6h!IE GxB~#$oDi-6 diff --git a/plugin-packages/metaso-h3.yingce-plugin b/plugin-packages/metaso-h3.yingce-plugin index 6b4ece702d7c61651e5058214e459df3fb08bccd..f2aea82394317561628044a17cbed513c3a777bc 100644 GIT binary patch delta 58 zcmaEB|JI%-z?+#xgn@y9gW*W+Mjlx~rX#hRwFU1mfoM(Q7P*+kwFMCHqyf~aNk F?f?O#5`zE$ delta 58 zcmaEB|JI%-z?+#xgn@y9gJCz*Mjlx~rrk`NwFU1mfoM(Q7P*+kwFMCHqyf~aNk F?f|(j4{HDb diff --git a/plugin-packages/openai-images.yingce-plugin b/plugin-packages/openai-images.yingce-plugin index 0305559ca6424147926e75a333841a89537b53fd..385a9f4b5ed730a2010fa1859e9ec1fc15545a34 100644 GIT binary patch delta 9735 zcmZviWlWt>v$i+x&PI#7yHniV-Q68F{@_k=hvKxwiWS!)MT)x?C=MI9Vm*C-ecyX> z)?{Te$=u2MwKDgW`K#EcN~EC-2agW|fsjGaIBwIyM=KmVFbI?#pD}}l4~RN@f*1X7hVBOYBt!@PfCXqXk;t32-&xcTB< zZkhMZ={zi(l&@w`w!F;@aKkKEe8~&xAcwck)-bPg)A99gGn|dZNc7RUO9=1$AhkR& zxMo>(suJd7e(`bZeTA<38qGIE9@?)$HE2aSZHkbwh6IdexA}0Y7Nf&)tRh1F&KvPA zw8O$<=gkhGhqb%=YxTaQqVjU*VY}haYn=jb0!GNvz_-4&B4DWl5I?Nl^1Aqm7d5p( z_Ha*1P};>iAHa+0P?T3wM0f+;uPG|P+EnH#3Of82o{AqB-|dGNfZ;H;5asBm%^x{3(I-jYNnheu$4D-dcH za$piP_xjxKhnw37T-z=3lSm8KDpcw`E*s4E%?@-yF602}Gbi|hn`>vtx%|do(&UC! z)4*9Xvtb&(?@fM>8Rq)u(Rrl(D-h=&B;R=K#vD8>L{ramYNqy|KMO2$_Xd9wdC+-Q zXhVl`{P>G@m$KrwDV#bTxps%E(lcx{Ou_kQ?e$>d?t-uom}o{0oS7k&8$9)8--Kl6 zXgoKTGd#7t_iOvCQe!oH)BUU z9Ai01sKNgTTnt#p@*PQ9`1{Qy-0u?Q&Gy`k2KauQ>2LLBa^x`cBP1Ndeq1dApj#cx zbV*cSwOEL4Rf;x2(7P{a!iCSyMPrHQXV$#X9d!v4ABP^zng!Rbm6BM@UjF%j&V{04 zOhoNU*wh3~?z;>G*15jfS(!mjm0nA9VvKSl-WDogo-yZ==y0v$d?hDrqsOPK*{iGT z9&Et$0x#{nFpkLjAQg1=(fdzhvq$}n|5RQVVcwtLM$HK*GKjqB-hZ0)Z?et4?V~1|S2{7nTM>^Y+a+SMO0)ZWg(BXiWoo$5Ri=r{;Zf z?3Y^shbKaZO9+tbAp0GHSx;fQR+O@}p|MCrpz~L^DMdjkeUsph$}cGo=rGIXkLHFw z8%%_#&+Ed@aA(i_I?DAafhhhg7r` zfax+vL9s6pj}0~e+$hmCm!UXo(-8MYoMDn9@5OV{I#Qn!v#&Xm)QHo67prxxrBhV` zL!2odyTu=jIXm7q!rx2=L5ha_$I$6-{xopLhnRGm5sl$^{npOlc`^8z@-UcJ{@77M zi)tHcM2Bb2Z4nRL5EfE1V;{2!k^@)Z?u@A=@X7l=4$FA!ePmhqb>f~4nImM}a+gxWnn^EWl6Dp>Z?|z&WLm~d{q**v>4jmi`QB$BHRflG z?Zw&CCrgFaw_OB%64X0TQHgKbXij07>{i|&xRIMWI zr`m_%74IhTSOm-;cn(YrDpZY#J~bAbbi@HS+OXpX8%L*j>Snq-fE zU^`5e7Os>MK|6nS(Wn{XM8r_3 z5b}6aol(Cc!F{kL@-jf^<#bZ*+G}czxs~u?+WW9~=9Spk(EAny5aVO_nl12_x zB02vwZ_g(c3se0lx-0OeJ;r@+9FOixNt~;hU}NzClLf1cITf8kH-8Vu=1a!TrPhwi z>b@bwrO<|PX6Nb99=eDGoTvPr8uEu=syTR|7e-5y6*`QSVv>n?Mqtp-kO~(LZe!;P z)b#6nKfGve-KK3z&2j*qpbENNkN)H}SBC(HZdWfX_BpFcU9Z=5`WA z=&IYnq<9?UF9CSq99h(LYQk^?bxAH@%jqzwEvRotiG! z%b)BG&O6Gq8?>ZR*P(TfTsyq-q)t)1U-0mZx1kW35ZG|12qS4DH19J1yC>SWW!4j( z-k$$_WCb|~gPi2ZR6vF5#I+^f-r+~$2CsGuI>v^>`(Z&|D^QxT9cGQ za~eI}1qmZpujiDZZNK^z4v(cC?s(GKY3vSERDFt7+H>r-fvNr2q-51vL2vg? zXLsSYD$d{L;@3CPn-u1be*<=paFP|v!os_-=vOnX9-cO*_UI@G_N?F*GH(tTGQSK8 zz|#sjI-Ub{`bGOU&@B0>A)dqI_xAf~2NdtaX-N(okD4qlj$eA`|Ga*J+X*BNM3%gv zrJX8w$ZEQN24FJYiU;N2Zm&wUi^)a39#dk~Zi}8CkJ5U7BZ;4j_?BI4Ux_t5iCcUW zU#vO6c2Oh=^4|Wr3Fo+jzk1DM0QVh(5kYlCRaFomq%Z7nU%pUtfCDF#5*~|prj<;g z==B9%eJhqK7vc}L1+m0l-$3lQ(Z-6(IRqWC%q2=F-@)G;T|M0}Eky9#U+z;X{E~Z* zWhvV?*wD6F%sxO^<{)FPA);qpf$Og@8WNF-C^!^2_DWU7lF^!e(FJ9OYKKz-%QPNU9N63sFoDMF4= zj!rY0*PS5NuYb`+ka}%zx<+M<9U35u2ZI1-n`@z=&4Vips-F;Y`mYQyWy8P-X#_2% zv`FaDHbXJ_NnK`Yf>Yxu+hk7@Y~UtY!FQ%j3vj&AGulCK@QEnGX}(#XAXKzSF2HuB zyyxyuq`J$dgO19(yOLLBk$5A`pl#wn(n)3nc`{yO>!Svx&vg~aQtl|-5)_1uGqfqSeL8w@jTE%wx=E|f16X4MwFr`&H|GJt#noohjBZ3 zUK!lH=A3NQFnq!O;b@VWx{3ikstg$0vYKylw#qFtdtYvRW#<#x?@Vz#6MIFaFT;r{ zqtFc}{-|I20{-@a4{5^gO1mO8tpA!o5I)i6qoLAQAq{4)A`%)B7FPonxG+731j@h< zitl-y4VI5xrPT2wG$Fx4!!YqqKIkB+WnpsW0>*>mUHwAVY0LV`+JH7P<7uG4gC58G zC)h>q;Ai`oTwXb(17!o_7+a5_iJ=QlZ&zf3>`G2X`eqIHYG&dfIgue)v5q=s3l`>F(dvTOfzcXcF$5-P6ESJvmUoN%#G<)+6|d&AH+k7pcFwSx3USBCG~rw3?4C~GvsdaAV&XTQ%DL-OAN zo!>cZW8?~BNVX4B%%KX4cyy0t6?NOM>)VU(h;Bf<14-$-2(Um^?-~SszW9tK zu_`#EVmuNCpMSxJplGUU-tb$QsryjVqKolnCmvM9Gp&`2KqW~W+2Ev|y1YCPDH5|c zY%3!!@uCg%BeVDkE9Net*4l zB}N1kJ({9H`7*or_y)5Pj6N=oA*B~4L}}Q0Y2*0BNwn~|eq-FslI&!J{m8rUFcDbH zv@d5RsL*vT@U>Ebn1d$rQ_n)#t4Nh>g#$DaGrHuLG8v#XYB)Y9bGync`@7is%E8QA z659%~@`GcU%JGS-Qq?ch$<2%61MU!}k)GM!s-VB?6GsZDGzpKP+KP;Prh^x4NR-U*=OX zu~L&YjkuI!&8v{0Q|2Y-zBOnB*YE8b+HZ_AHlUkps7)Gnc-Zo->&KN?Cf3r$y#$_O zvybEim`g|dy)F{4W2iEpz4zI}(^^t~Bi61`!O?6%qe@y}w32YZK*jg&!uEFX|15}u zQV4{lKOBfjCAa<-yD0XgTQtWPI+x_w(Zphsw+ZqHr#LCgigPJ`5EPR?$#;-P`^$DF zm8ghvhl&ly{dyuTR(S=)XDe&J{3@@+cIlzwfg4Dk{lkV}N5sUNgt@It%$)%P|7nO5 zUE&0d#PVD4bkQe$)1Va3qEXg^v!8f7SB*|}KH4Yo?knC;uFJZQHkhNvH*8kn zDTUK;+L*c7NIXvRROAjQB~d6^lr+Dk^qz8t%(AQdRB-#<5oNSWvO-rYvFk6DErbEB zjht+gW4xhakjf1YbD3OB-MPD$H2+RzFjz^&B zLn6RPS)Qup;sXv?dC`fM7-)AoR#Yj=w`tLMKPz7mHRhybq4k;h>2d+wb_3`TgzP#c zifJx_%Pw^cXh}T)Ze^KxjxWgFv1f{2N-{F3Tib<6QyHrs6mel`VzyB_P2f?ItO}1-JKmwGBZa&a*n^a;V%v@Ct3rk1swH{U zr+5r*Mp~KO1tbmI3?TT*j{s!hc-QuY!Mj6=Tz>J;=8oD6PU{{#z6Cq$7+kf@p(83s zUtGG)m-!O2;Y}-e^k8ePdnM3v&uR%|UCa>4d3x)%korz2_E~3e5uag%*d&BM;|cKj z)~HzKFtKV+^79ydN)KT+a!606XB**%H$)K;i&6ZRXMfFp@l$R(0~itMnz(rfQY`2- zHWMW)ouk|88n}~W6}sIJ;L4P~2wt4b)sATyJ7Cq~X>^`e?8Xo6XDLg) zu+Vm9XTMB8Dk`&Mh~s5%@L4;pjBjS-WIDiQ=BnPqJ^9ReE6may#Qjz0&UW-YJTtun zW!BHIFuA2kytApZCEz-ap-rE}K8^-mGeuB-o6bpcFM^i~I6``*EH0W&30beFs6alA zOz0M1ZUWYI;dKo{G*TbrB-5Lmo3PPCS|y^}KM9GZ&l_zcy0>^Jy${W(ot3k0D|unm zemq!=ujr=!V9r^MufRkx5p_s5>xcqjD9%OB9`ex`hfAp-2b2Oq>GT>i1zcB0XPB8( z+QH^D%E8-?nfO9}ksP{v8dS4g)f1M~S?ZB@Cm!zvGyHR<%2l|>`&mV7nbRXl%GnsQ z@|eN#X$fU)pLm4LnXNp9X0tOC>=b2*0;ziTeRdT0Y@oX#_U7zCyD6#CH5u4Up{`nw zd@0+$Nk?z;K(OrK|euWhW#1QD))kWE!(9PhwZDN>jyjL}Pm-hj2SCitM+AxN7^AREBs-B8d)9 z2u7g}3!y5sVviCMi;m5DJObu?t$w)iZUec!>nnk5g~C@8_ZdDoBT7nbQX^FWc^4w+ zaADV$j-}=uHrn^@-bni5UA0zU_tP{(V{EZBUc<-R7jch=hWeZq>o1u)TIu-e+<^Aa z^-H|dk>%xwUUTUTWg>rxY#qaI!n1Xs9Gvk4ibaA-^%YFT3d>}wV)i0xl{&RpSKtLM z#O2MQC110X?58c^^Ygf~31@ql*Sb?+8nEw~7Z21Xv&VB_LvVZX^PP>43f3>fwsdA* zSY9}9svEhef>AU!H#cDqIc_V$%YnWL8b^n@y(H$-OAAq>@V@tlYA7>s8va`cOE+>M zNmF*B`O-B4muD_4IWck}bDx)wL6460wWKNMQE3a*tmZ$*g`{%onWL#<>-CTVJJ$<| z1hNmVDn6-&#T|{N8~x1j*_zQy;qwcu>S%DR*C}euhJleWn_`|D7NZ`$s09WV3XxTm zvXE?b*==krHPXBdxe-;(d4ANxx&2;p@eW$rcpYCU3@`3;QU5Yrh>mLJyii;+7SQ5X zCuA=Djo~~#g>V8v>Z8`oK2`2`UU%wTMKBY?gS`v3_X9b}FJAK6^JH}q1KK)T``eq9 zYLgdZ1KxdcuY~WnHTRPb3txH=s zlTpvY6i0ui3~!rTewXs3$nTMt#1S>+?c$uu!m^Z04KCAzC)|c(Vkee8mv~{EelU_4 z+}4Zm>4+#xq*9C~K{Cb4j$QTSg}?)OeR)CFK?dp36yNQ371z~!qaZ*+wO}f4Gi*AVo1J-IMJeeI1%6tA#_y_+Qdx67B!pOw=r9aO*CZ9Y>6Ap%=Polp$*CPyB^Fz z64i!3WjpGjq09S_u2fl=PSx;by2WY$E@e2?r(5_n`ceLp?JD>4I3=;_C(~U!6)dnietw_RMU0PDVHq%}Kbw<1v!&|$18%HVblu?%ASBL_=XMa_Cv zh{wYvBTHYW`zfB0-X-fH`@%y2!%422xvQK?6JqSta4)q;V$^*T%+TvhL~kE~-<5C%R z3Og&TmB27d_O@l|_9|NQljW6|h+Z$l!;R`#4fI{yML>I>18gWJI(U9itE24Kw}@dWze8 zT>{TyCy4ryAF1MJB^5Y{e?s4(igDkDjpjylFaw}n#XyYAy`186frvKDj{jZKWkUIj zMnVf}NDcj7hU*J1EXLu0W9%H1q{#VgWyq0r_cl`K^M%2iFlY>ti9@4QHY>xX)TN z1948cD7+muNv4&?*4@p|j*X2sKOe7{t4;Wv8vymNx=~ygRtsra0RsPZ?E-G{TDU;P zEDLcEXX;$2P{brK|kC3SRC>! zg*XQ6K52gCZ!OOK)hqQ)Ao(f#&n|PmzvD8GgKs1Oaa z?G?Za@sDC)-oZ@5B89T8_d zjK_V9X0Q{?jUKYyXn(By^VmWBWIu@HUKjflN|kjBH;m+iI$UaKuz!p48LHIW;UpQE zI^>bXxt;!C)ub_`%ssBQTK4Xp3*ooK*eBp~gCgD;N^_I?T7U<;#9(Awr;LcGhKS?B z#n~p{QeR7p>jQCBQPV2G_8BeK))`|WQ{k8O+Q%=Q=P!SvXEWeyJ171n3Ww*iu$&zG zb#|gFxhSZ8fq}^Tx1B2(gwO$hfQCbZM1Wop;M6s#-==xj=O5eGY^`GI>Y_8VzzS^t zady8D3fDq>_BYza;heDHP#0`c#!D73A5>^*u&d{o6GrpJ;nn3b*GV$wxx`WxgS?bC zB|DKGoXgz|;B&ruOfhlgiPxh7T^{#I{VqcTT*>x$F5vO*=XN2_iExCxBM@ISzuE6= zHE0@Fm*lBozJ;iPtJ|i7%XH~DHDKf)(Rv7P{}q1EAU+dB&i<}9Q`n2i_f|`}!S9Md zs}o<9PFwJT#wGX`>2d@p628g#&G4-LP$+bFm_Zo$oKpOLKB)K5-~Dg&*2kNk?FI^K zKXqjUTkLGia5Kv)zTKtioa46`<&{=Qt40 zSmNUyy!oZuppsZi2{C%-1p1rUzhqJOMMA0b64V6WVUgSMrzc0dJsRI+g?; zC8O>rXSG*P1fm+xWp)wk@lY#eN@)`=EcnbR0-k+Q9|I|l zWTeXY8A+mgA$J(nI(33?F2LA30OcQ(yszg7uLLOvSwm*0OfA;aSG}xjVAJ|KL}6fM z7ln{({dr~1|5P4)uWEdO4aF_0oOfjXK17HjiCvj~t!{v&pn*GtQ^}c`mQiUEpukkY zPJz?KnUp>pQqL_4W$br~=rUOhS@Z3PzE!x^xW;ykeh0LF=9boi1=b9m*{A2uS#7GS zwdU%=&!r;|pcMV`j+2u{*>Tn349>hP&Z2KL%qFNyMu?^!yzt0}>i30$A>4V>R;T}d z;pnJnFAZ2vi5kx!QuL*F1=O;=*%6ln& z87z3#y$X2>rZn&)PXfnS;h8Y|BjnZDhI ztz#`bg&?KZs*%bucMk#?Ic7yTRCpd)JNuG$6yZ;mDa2K_fQ8a85L1CZs-mk*V+=x-}+T9~L5q_24_4}7@j=87nP zluv_luM203GMWV-6>I99ir~5Cuyu6?NQfeoJgk*I10JoB+oMZ75&osmpI(uOE;Kzd z6o=%Lku69_?Q3M0;2JXkPC0@KGIKFWDlQ^Q<>Awpu z^2Zk=@M4?4|AD1~lzP(>X11ZOgc9+vk%T5T?nnmR9;|3GOifId+?w0W4~>oH>S{x(y@}B^BAEdb-#grJSKrba*>Z%7=zaeFF5L9>bqTqHlK^z|A z2$V(j$At%&Q(9>-mcugytW)C0*+*i&wH<;Qht#T1EbYd-=W(V>R|%Qc?Ck*Mte*Vs z&aSWPiNmk!q(1Q^f2XC~YUA((4tz*tzmqZg)y9#raTYzghK>5;Fj)EE^H|9w8+g(H zqFyraN596ulQ3)vUSvlH$~>Z~T3E5ybG_2{9=7yZ&2y1GlDMS=+FE+X8@qlwQjn=hdC*8TLH zEa@}$DqH!u1&)UM+GTb;h*keR={$^_%C9oa84ZgQU%H6fqle*gp^$ZCC|L zO2~UwBVK*=PA`*?`f|u&u?sEX@8Fk_&!pHw?~3|pZ(`rvC~j{~SAL{H+$fd+vY210 zdu>+#9@G(^I>FdSI_f?wQC972$wc(_E)2yJ;%!6w>03%cw@_Qw?twd%@S6Mi$cFX_ zZHTie*y%KG1?VR_rPtlK74qoAYcZzYoE4!@plf=!a1g_A_Fex|sO;BxG6|5csQ?-* zCQERtqZM(}1-$q|>Lz4*OWPRm!y^t7i2FL*Lw41W^IJGg6X|D@cB?G6cgJ~&qHUE^ zqRd4|cVyMPh`I659BBxWDs@pM+xJo3{>Z9C6B?r#6l{{K=Be`e_F zzy6(n7v;ZnY5$h7v}7e^RAv7e`Ts5JkojNM|NlwT2>uTcprMR__@5TIf6@3aKhpiD F`X8tJc~$@b delta 9300 zcmZX4Wl)_#n=J0`7F>b^*97-NaCdiyg9mtny9M{)5Zv9}-7Po)4iMbgd|S8b?%g|6 zT|HCJ^so8VUEQtl4nkM{3JZq^1qFo!wQ0hfn1lZc{9^+=7(tf^3@9i}DJUrHe^YZO zGccQtqnm}RrHPpZtAja&)J~05=BnL)aO+Sw&|JUD_p(U!V5RHJ`BYrpsD?r7#|Bwg z`x)kT<}dpBOf@uS?3@F<59uod{8DF>KVxY*+1}HvwZ=IOWSx3l9zLU`W)IU^ z$KJF~gY74=W1-vYyMA->Lpj%G`6GIOq#Fh4DEY-gKmiXUGQ`AieYS*xYDTE7*?qf$ z6`4Yo30t`15)&@YT31XcM^+YIVCr~44S1}G5ot8>^>(;eclYxlPC$$(#2SP-;UoW} zm&xz@wlOG7<4^=U zT%%RR?eVKgQy6!JVTUWPwyOwm#}z)V9)H^X`oex+_hu{ifL7Di-VQ(24Fw961ONsz z5RocKj*7&W{k*!bn_=o61qzx)zq$6d(2&%socKQX#4dO5c?T@cqrYnS;lMg>5&#vqV(N-coGT3YEmk7T0t#JKK)M zA)_4Ms_H=Wdjg$}PTPVK{$mUekluNIq{*gA7x-VXy)8?~GZl`Um5$xJfOvun( zVC!>8tpFnM)*)1|&jp9pVOTA+P(pZnTOKp`%h2!G;K95rt>A1_AO@{pb!xXYn8^Z_ zm@~dkWn%No(|$2WcN3q$sdB;s49JFXrd;q!PhI_So3_^(0grT4yHjr)=tL$vwaa|6 zMfc*-(&_<6r!z9|mo8@U;N>53N!{pe;V68(zK*J))tHPqJv?{aTPm!5sOXwke;Afx zq_Hy-RAr5Ctwl3^Dqcen&@vW-^Lp%!6K(oA*`{LYEvwiXJ$QwPu%Tb{7jn4OiT>)- z`u7t2x}@Rv*2ZyL*(E#;=x0`joAkV_>c7rvcca8w`9AtM`A$Vf>grTa44dArDS%yw zStgI$U8uTZNWrDQovQfAKOqG@-mEC?;ZhJr1a`}W!&UqW^#3M3Lco=wACITNPC%b9 zX=P^|F(hNIJ#Q?4MpV`2~H`c zKY*DgyvBK%PUQ@zC_jbpIn(zjGCg|IkQqgA%OHWo+^Cbu&wl2jca;40$Ka=*X?`?Gao&t^BmFc^!H(e^z}Hs;(+o6Fyi`8uvO?fv zk#rco^BVW|&$5mrD%;3Q>i*Qkz4QEWskE^vcl#@jIF?6AwL<=vTs102ao5Kl1-XSJ zYHWiOcdE!HBelk)r&gS!DBZkLbhLxyio#BlJfVh!RdGk{XmePmaTxo8Uyc0;-hX`E z2|)?%+=tpJz$9jec_JRb_`3iqab%}-UFHM6@WXj6bQ|QdvC7sgJ$_=nw=D_9=yyI! zNkGt?3^nwX1n0*;a|v%=ietLpJ`EB`%q;k-fCTFLYKB}VvCfiB+EXK$lGK?$$YCkP z*W)#*>8-4eSvlsABZ`%;WgXt03asJj0Fbq{CiN&f&8WQjRY&PKpK1jq z&b&Mo8TuJiJETw3-;@#FA?IWtttRk0d-WtT!DPv8eRh6n`E}vHWe9RQCB9*>ky=El zHf55M%_xMlxYT4I8lt6DsQ;FZ3du_09z`b50cdo>@zc-OrRXFWF0V+bhp?fU2(UrE z)>PMbz%m|#Q~Rl1PiP_>99@xHR`wuO#Tz%$;6eLv+dvgN^(&nd;|!XBaoR7 zkntG&Q%oGhPQ$?Myb3p|h&)yTk#1C2%D1uKF&50` zF1P#sF&?Ic%{&^-i=E6U{estUYk~Q_mkTl#%fY1rXe?O~e4jFUewsp!=HhRyD-Qim zJwMN!JR%&uYy=>4-4?ln_XJK^YOrPjtFgm+PO|VF5x=JhT#5sS&Zn^rzs0m%=)4Od z_Y9`Pd%M1E)|@ZQEwMRRX+wLAp4)>RuRl55VO=t1q>)l@D9r7@l^kNez~cOqI)r8gc9 z&pIRqCerjoAMS_F3)QTlHhe&#@4?s)5_stO9Q+W_M7INLJ)CAFkyR4)jm^?In3ry+ zA6dC5G@ijYqnhzN@NYl*Lyur6KukVd#+UE#2TzFr7M^bYQJn)7syx`7%*>W-due%z z!{U5nwuC7~&YB!yK49*i-thDwGC7@sEi;i#ly+ultI?uHD7=BsUvIk=-Bj60hw411`n)(BaRS>2#!df^o~654=sH)oWQR7Eo~j+ z-`q(}k@_Psf&>eFA@dB1*#aM? zi)Wv)VDAN2oi$z_hY61{D&;lQs=C1e{+~Z^zQzmctJ`ykSd>--9AV*A zxS-TS1~S$NTu^3*Q1JRPHXLQ8Noa}TbkLpn(8eWosE!G|1{$E~fTwnfw@<6leL8+M zr6%Bh%lRrDaenkRlGKu){W?U9Wt8Y+@e}5|h5@t1`&kM8%iKKhUlHhK|5Ri}m76iy zr)`#DGo}VDwr>z=n-8i9yWFe6zyQe_KjMc?F(|) zFf8{Mh}|gDqUz8=K+SkNI|tE+er>ZRk(zcAwy=eMeaHPCIoABi{MizPdrsC=bA<_3 zwwb?jYmavlep^RD;$@mWAd?0^0eUYT>bA?T29755SEJ+N;pT}M6X7S z4IU+tcEF!v@l>=VD+ku6A+ufFJ)h^ObZgFY?yBK03^Y*l0KaAJ?8rSw#fxPwy>Q$X zZaXTK=X;xMl=Tk1FDyFV&7PGVyw0`DW!fHJp5o0>s4ng$F3|Uce-yM!Vbi02!TBKA z`*6Xf*J{q7l}$d|`pj+T_SmfM5fMH<%Pcva63G{igxo5E_WKT7ma>-zN-h(jOH4Cp zRUsG(%W6j`2e8U)0s=?u#X~PatIlg5uwG&$S$!h7bWe8C$dRLOpQT~uD~7^l>TepD z5!iJuXciF2Z&cHsqpHRYG^rn}Jo@9eb(3BjoK#Bu5_)27_#mAjBs|7pyps_MQ$Io4 zVxt_9Mj*7=_7paJon!;j0hS3_TR~i(wpe|m9UmfiKUfYo zMPKeuik-OaRB_OXFh0GBO!Spj4OuM%)b+3&<6M`v{VNLn5dB@3yuHD)%x_A9_OUq0 z-LzhVb?J$Hx&mw2(##MWs5I0D2?Q6%VVUpI=JuSTVk4+fbzo8m=cA?}Br9%Zc* zh)ubpKrlbYx6inyc{2(SIrWWtcNznc$QFg4*;vMD4k6GIx2uzy_EG6Oc{3;^BsdSa zMIVftthgiD{GlYp#JyeQV0|wJ*yJD7wx1B6AKhQ}j@iyBmiil;S+BmiB$#!#M9mO} zJAK%{-=)MpAPkX7Hhusju?Eu-ZwK%fyl(7H0^a#+j;{nJ1wl)WRl{Y^jN2KQ1Yd0B z;Np55gmN94eKCYCg0oXTw%kTUsPqoIAfnji-l$aj-jiybIb2fFDo>N{v74W|&8d{a z4Z{c($jaOa^S{MB)uoO$DFr?FUrm+{zf${%d|nHyIj8$#x_X=cRHNhmX%Rc>&1$(@ z1!%smUv1hGm`1`p_B%^|Fwsk>oWnM|@q0A6mLiO>9KTI%R#V8;K-8z{sXog2vchI{ z(Jvucn;Uhl$9|?_FC(OWeharwC7+2PXcJN`+;ji;y{kx{$-E)@J z?Xn8!k((K?Gu#YEvk%r@iFl9rBv*QKtan+bsxHD3{VjMmRZPPdOa8`SIH_}z1E zo2^EEMsateV)e-nFcwvEMW!xZei958k)yskjMn&chKj6`%3xxofs~Eym%Y%X4ye~* zA-x~t7Ka@t$`6NK%sAxuO;Qsnzg-%8<(X#~jU#Cfy}qJ(T3$|g0xC4ofv+*>{yq_{ z!2EDuEsikA>90oMyp_@{8_o;i|6^_qPyr5DjelNu!y5_;{hz6IFmbf8v;e!Y+Jc=N zA^Lh?Jed^#BS356?yTQ2SQ%Dq0`?0BcJ$o}Ei z3Y0vEq^BxY-yH<13@Z2Uh+C1{IW=pZ?24Y!3`TR;5`j}1ZjXcp4Li-j2G2DM@Pun^ zo@vUbIX9w=I|^Si_ZC*~{%vhXq4t1Dsqs(z9+PGtaQjIr zqqZA!4DH!oiCA*~9|{1=uL7nW;w zrWKzF&zt#p{S9*+swQ~&x@`fV;C8ujX-(+;%+1bhmWaH%ZL7<~% zeT=H_y(R1|8h$$ngS=;Q#s;xFZdG}uaM=a|Qe%U801o*p*J%g%Nb!n>t}3A5ak>kQ zpt}Up&7nMTcQSvth5)hoIWp9X2^e-~<2k0qcFptesw=HZjVH_m(?X_sUr;?|?RNhz ztxewT((GXt+YtkTPez4)8`rkyUB2YT%W*Wfguf0-`zNA)mrpl!8Qih4V4Yx(yRiTa z_P5j9f0s2!uW7>K(1{+^+OJ1!{3m{CWAJ<%;7OZ!H|>8nrE2+x+4gW<-&;TYwkQDH zlFXBo!pcKQNATQ~1#HJC#VlQAyx&Sa>~{bAH90(JtreT#36XN@|tGaiF%JH?4rHVk$q#l+$7~W{R_ti0)Nn{3El$0tSJ~o)?w`&c+ z2EN6CO$ZhbzMg9WzYMYqfXK!xQ%>MU>jZW+CEqyG5^axOR__kQPv=^H_x2wml-$If zT8`e)o13{PKnPJOZQ7iyllytxSSTxEUyuiI(VG*)9pcvDr%YRFuqd)}?flx?ZVyoF z;I`g2y4{Al{dvq0&3g7#R;%zS&Y=tV7KG9hLc(ysmF z^^70k8j1)uM<<;C&>0wQi}|M34#qQQ3Y6gmwK@VKG6p|qH0qABDqp?iZrDm4i@VKH zct2k<1hoQisiYGS(g^_q>#^G!clPD@+h{+7HT+C`KW3F?+toV^s2w;r`tJ3D2l#!) zwP4RXU$oFQPkmmMvn6(&?W#QKZR07@=Dt{;tKKXP*H4td%BHoffP1=~`(T9ua{4C? zB|VI9D6v|a?1PnzJsJhGp1$mSX|U(U1#n8CUU^Kns*HzU#;sL*`MXoc4s)71`}h+v zw>DUD|F~7GRSlYhrK!(_(|{)o!G?qL2X|PqQ>iLDrJYQ>cn$@S$eY|h3+oc_Si^+;4bSTQ{Yu=W@ z6aEj~yL}`W%)cpcf&vw@K0Bv2Aj&AZkN8HMRC3U2NjIJ^o@_|&O-Xh9V+fTIe?a`6 z|B2BYz}k*`wfDf(=Rjbj|JsHJG*b%y=|NVy-6B@d3MzMmf zXy0zs%KZ_r#1Lec8LCIrJVqZSUAgTDTrU{;F6!&piDX?_%FdU!=ZP$#!jCiQ4z7$-_wk#HZkF3Gad!M- z9g7nk-gWhbY@E{yT}e=?zuXcfYy#Cgk-Msc22S=~s`QCEOeYGx(A@8qXZC$)@<%qB zn$OIX6#^>qe` zbnGCum$wGrUBCxYmj{?{iOiER<+KadaQD-Vgt@SJCW}4r#(xFthJe@lF)b6sFn{FnT7xj4sle?Seb}To? zZ2V<2qM`D!hTmdvLo2#*`~t$gWDEKPvCm9zp$NdNYhC^(HjrwO9Of z*7ov@>SYX#Pf$=kHDnB62*U3t+Ghp#Z13FPxMS!bGUgdb21@pb*wQ`2+beD521807 z1dbt!3&Dhf5mP((N|ODXSF)C~o7V#pmxn!K{$T{7YY!S3KL_Y~_vPrt2U0MS3)7RQhzzfe#iK#_MYO!+6;ueHRbRwmY z?$t>c%}X`vnSg#rSxt7k!*F!eVh6e-Gu)k#zx{cVPR&qcBTwmrbq0St8T7quE?A;7 zlTl2H*+7zaVXq-=ry7gg@b#`^ZiB<-U?qoKTlQgVS&2dRRo&kh2>uC1y`!H_$jjoA z&iZ8F;@P2+y%&NvWdhUqg{;OeU80#fhH+bpOBMP!=>con;&8r!z%|@@`rfI&6+ask zjEfshN%0?{4Wdx(vm}sTnX=j^1MNJ9hlLBevwiH(cjLubAUZP7`{e@XrsH~>ofSG$ z#6NS|$x$Wkz7w>bY^jOW1tcCZc`BIg2flu{v`#Yh)->+l)(WyB+M}1@y%7XLPS;uT zExWfMDgf%=kPAJPz%ze=L|CNzX`wMv1BDKFlSObuyrNjpPg6GxtJQa2R~bCe}Ib{3!Yf`9%x`V7UFDGQy}tefWiA>Q`g8bvY}GlK7@- ze{E&cf?GZevAeCQ2?X`Ng;)lC^b0yOpBK|rMNb+`?#dc+fjt_!;;0O=6gtuBlG>tL zEFgT9KpF1~hxbb+VzxA;#jaRGErfmR6ECXc@^LV1>UqHyBi=DYrdk>4THVb&DW0zO z_|JZ*oc)9QW>L9e7(EY5G6|Kw907k9v6{0wo~rphlGnuR984o>?s2JY1bI86Uqkmf z@x2OD2F^2!h=PUGWcyu%Kcfkm+a(Ev449={?FhrRx}lW*l*X&-Q*uticCu1&M}IYI z8|AD}_wL4xGCa#$rn7s`&ONGJo2rU-NiIG%{UvxN#}grs-S0*y5@(@X4iDmI^|5c@ z_dSuE@;ehp;U+b8gny>2@H#wqorC&!`8zM!+QI1g(e_mf+sb=sk2GI$@99i=R=})f zJv^3$HU877&@-_pIA1LnC4qW;G(JX(OToy~25TX2a`s}Ap?~AeK`&d)0%nYSXSVz+ zU!owMnM-y}?l0npgMN`gXTFkuvE~axa!RT5u?|=$A~&tg?(Ok&MDqZjh{f^?3Os(d zZyE}VC+q^h(!IjZWR0K>I*Iz#HsBB^3bcij$gjgd{%ACRkJ7*{9!QP+Tvmws#{p6E zd+A`5oVYp_OUn3`yR4oIW1%>`LMUI0I4+}xwr~DUalVJx)u>ld&vNa}Rn<@%p?AAr zPliP|W37|6aK9>M%Pv)?k@ZgSMz@UGu;0-(U&gL3%jDDGWbQ;bnvwbseE_8*$2dOb z)J~Pz#M*21fInqzM7cst8^<8RCyQkaSqz`x3<<93Nt)cwN8NDtuTavVFe}uUtsipR zLmw5L)tZF<5=~Z5n>s-lBQdiYT+zl^Xk}v5u zWWz?^lnz<69_>)O;gqf*1=v_2KeSnO4wAJ2)&5I(ql|$3HMT`v0)K@hBs5KuhSDN6 z&|H87g{c_M+YVb*PoQ%{5L()=s-MA^+^m%&5X!!U^N2c>d}rE0$yRI8AfXb69SY(8 z26tY52P#J2LU>fh2Ljy;(F|>L_-z8q+XHs0Odjsz>AVjEY03rbt%@5H52)TcCj8GLyNI`4v>x!=)KCDo~FgrjGaEbGk7HWqT9`qg8l` z(MY1`;xM{Np=J-978&L-pwA32ClhGLh+pW+F44PTebz{sspQw6lAj(qql{G!oDs`E zTSVMMAzg;HPpznv3sNk_k3?(3kJ^-VNR^M=ndg^Z!nvsd_^)<2l3BDxE}u~4+Z=!5 z(zY`a%Z9W1DVa5jJ|#yM!I*zm{TT0C^rH}%qn;KbwCN}NyFfOBBEz*qb>4d^NpC~j zIn=RW_xUA!U?tCdGz?|k#6c!(r=LfF(OcrGVV1$W<*OHOk`J|80YiV1r46>I;!j!z zx)^kveRTX6;8D_lgPJaJ6wI`HPcW$D5hcg@yeN%-7C)Osn`^|4?D&yR8pMo@oCq#T zp>C=lXD2FHh^6PEB9v7%mWpIwE0iI=)1;2^RrgY*LTNUPR8&PvhzqB=0-1pXB*Ph# zgAF*Cas!}UnrBk(l0%oe%V&sd;|o(l&aA&aPlE-7TL zWQ6YmCVzI;Wa*P5C5WZJiLy>MEdOSusryo@uCDEQjNY8l2}M+Yl1xd@cqs?SSgoENs;aGeRY$;-FNhZ=yLu(-5SaF zF?>>7zKJCk*mgA{jLbtZ@I1pT* zP0?MfS5ml4TWp6Z`g!{&=+DVkC)-c5Ot~X8K#9SiHS$3|oY*EHYvlN?$W&m|{BYj5 zl9L|izL1=Cubtt;EhUSHg%->3=+^oa6Q70_OYv1bP*(G`KAqUhM0Nf&Kdu<;)Q<0x zQbj~VOujxvm@PR?aIeRys=ze>Z;kXqf8ILiB~X0DB-txysDTt(sO>>4k!(mY0&Up_ z?DQ%`U#Y=lG?k}!=U9z;N}D|2*4pp zT7Q=rnQ@$dtBS`ib}V=5-qm?DuexlE@0uHmc8F|cH~Qk;J8I9kWs@?QVb7yM9U_68 zn)j(9Iq(Jv);fzzBky6}q}YjA=$K*(YgF$CaTbSIi~%L*tfN zzn9{J_&0E9JHZc+N+Bdh*D4+>pI3KPkFrwpNP@>%nIgnjQ1NMh^A(ShS}`3)vIl0N z8U}8O25QQV>#xVR2qY=$TwNZR0uUMw8{3zMTy7mN8MEPSDCMuvFyyfR-NOO?Q^YZF z_F{sp{>vzL{sH+P{8YHJtyU`)}F*?(O{N@&A$iqyMkP^S|Z) jNl1nMXGHpc^(~nHD==053XkxgG3>ux`;Yl)|8x5 ## Manifest 完整接口定义 @@ -131,7 +127,7 @@ "agent" ], "baseUrl": "https://api.openai.com", - "requiresPublicMediaUrls": false, + "requiresPublicMediaUrls": true, "auth": { "type": "bearer", "field": "apiKey" @@ -214,22 +210,6 @@ } }, "contentType": "application/json", - "contentTypeTemplate": { - "$if": { - "condition": { - "$gt": [ - { - "$len": { - "$ref": "request.images" - } - }, - 0 - ] - }, - "then": "multipart/form-data", - "else": "application/json" - } - }, "body": { "model": { "$ref": "request.model" @@ -237,6 +217,111 @@ "prompt": { "$ref": "request.prompt" }, + "images": { + "$omitEmpty": { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.images" + } + }, + 0 + ] + }, + "then": { + "$map": { + "from": { + "$filter": { + "from": { + "$sortByOrder": { + "$ref": "request.images" + } + }, + "as": "media", + "where": { + "$ne": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, + "as": "media", + "in": { + "image_url": { + "$ref": "media.value" + } + } + } + }, + "else": null + } + } + }, + "mask": { + "$omitEmpty": { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$filter": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "where": { + "$eq": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + } + }, + 0 + ] + }, + "then": { + "image_url": { + "$first": { + "$map": { + "from": { + "$filter": { + "from": { + "$sortByOrder": { + "$ref": "request.images" + } + }, + "as": "media", + "where": { + "$eq": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, + "as": "media", + "in": { + "$ref": "media.value" + } + } + } + } + }, + "else": null + } + } + }, "n": { "$omitEmpty": { "$if": { @@ -405,12 +490,7 @@ }, "response_format": { "$omitEmpty": { - "$coalesce": [ - { - "$ref": "request.providerOptions.openai-image.response_format" - }, - "b64_json" - ] + "$ref": "request.providerOptions.openai-image.response_format" } }, "style": { @@ -423,49 +503,7 @@ "$ref": "request.providerOptions.openai-image.user" } } - }, - "files": [ - { - "name": "image", - "source": { - "$filter": { - "from": { - "$ref": "request.images" - }, - "as": "media", - "where": { - "$ne": [ - { - "$ref": "media.role" - }, - "mask" - ] - } - } - }, - "filename": "source.png" - }, - { - "name": "mask", - "source": { - "$filter": { - "from": { - "$ref": "request.images" - }, - "as": "media", - "where": { - "$eq": [ - { - "$ref": "media.role" - }, - "mask" - ] - } - } - }, - "filename": "mask.png" - } - ] + } }, "response": { "status": "succeeded", diff --git a/plugin-packages/openai-images/manifest.json b/plugin-packages/openai-images/manifest.json index 289314634..16d42fe95 100644 --- a/plugin-packages/openai-images/manifest.json +++ b/plugin-packages/openai-images/manifest.json @@ -5,7 +5,7 @@ "version": "2.0.0", "author": "OpenAI / 影策", "description": "OpenAI Images 独立请求协议插件。", - "documentation": "# OpenAI Images\n\n该目录是独立官方协议插件源码。后端从生成的 `openai-images.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# OpenAI Images 接口字段\n\n## 协议身份\n\n- 插件 ID:`openai-images`。\n- Provider ID:`openai-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.openai.com`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.n` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$ref\":\"request.imageCount\"},0]},\"then\":{\"$ref\":\"request.imageCount\"},\"else\":1}}}` |\n| `create.body.size` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"\",\"auto\"]]},\"then\":null,\"else\":{\"$ref\":\"request.aspectRatio\"}}}}` |\n| `create.body.quality` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"1k\"]]},\"then\":\"low\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"2k\"]]},\"then\":\"medium\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"4k\"]]},\"then\":\"high\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"\",\"auto\"]]},\"then\":null}],\"default\":{\"$ref\":\"request.quality\"}}}}` |\n| `create.body.background` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.background\"},{\"$if\":{\"condition\":{\"$eq\":[{\"$ref\":\"request.extra.transparentBackground\"},\"true\"]},\"then\":\"transparent\",\"else\":null}}]}}` |\n| `create.body.output_format` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.output_format\"},\"png\"]}}` |\n| `create.body.output_compression` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.output_compression\"}}` |\n| `create.body.moderation` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.moderation\"}}` |\n| `create.body.response_format` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.response_format\"},\"b64_json\"]}}` |\n| `create.body.style` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.style\"}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.user\"}}` |\n| `create.files[0].name` | `\"image\"` |\n| `create.files[0].source` | `{\"$filter\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"where\":{\"$ne\":[{\"$ref\":\"media.role\"},\"mask\"]}}}` |\n| `create.files[0].filename` | `\"source.png\"` |\n| `create.files[1].name` | `\"mask\"` |\n| `create.files[1].source` | `{\"$filter\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}}` |\n| `create.files[1].filename` | `\"mask.png\"` |\n\n## Provider 扩展键\n\n- `providerOptions.openai-image.background`\n- `providerOptions.openai-image.moderation`\n- `providerOptions.openai-image.output_compression`\n- `providerOptions.openai-image.output_format`\n- `providerOptions.openai-image.response_format`\n- `providerOptions.openai-image.style`\n- `providerOptions.openai-image.user`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.usage` | `{\"$ref\":\"response.usage\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n无参考图走 JSON generations;有参考图或蒙版走 multipart edits。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"openai-images\",\n \"name\": \"OpenAI Images\",\n \"version\": \"2.0.0\",\n \"author\": \"OpenAI / 影策\",\n \"description\": \"OpenAI Images 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"openai-image\",\n \"label\": \"OpenAI Images\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.openai.com\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"contentTypeTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"multipart/form-data\",\n \"else\": \"application/json\"\n }\n },\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"n\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n }\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n }\n },\n \"quality\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"1k\"\n ]\n ]\n },\n \"then\": \"low\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"2k\"\n ]\n ]\n },\n \"then\": \"medium\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"4k\"\n ]\n ]\n },\n \"then\": \"high\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null\n }\n ],\n \"default\": {\n \"$ref\": \"request.quality\"\n }\n }\n }\n },\n \"background\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.background\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.extra.transparentBackground\"\n },\n \"true\"\n ]\n },\n \"then\": \"transparent\",\n \"else\": null\n }\n }\n ]\n }\n },\n \"output_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.output_format\"\n },\n \"png\"\n ]\n }\n },\n \"output_compression\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.output_compression\"\n }\n },\n \"moderation\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.moderation\"\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.response_format\"\n },\n \"b64_json\"\n ]\n }\n },\n \"style\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.style\"\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.user\"\n }\n }\n },\n \"files\": [\n {\n \"name\": \"image\",\n \"source\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"filename\": \"source.png\"\n },\n {\n \"name\": \"mask\",\n \"source\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"filename\": \"mask.png\"\n }\n ]\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", + "documentation": "# OpenAI Images\n\n该目录是独立官方协议插件源码。后端从生成的 `openai-images.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# OpenAI Images 接口字段\n\n## 协议身份\n\n- 插件 ID:`openai-images`。\n- Provider ID:`openai-image`。\n- 能力:`image`。\n- 默认 Base URL:`https://api.openai.com`。\n- 鉴权驱动:`bearer`。\n- 创建:`POST /v1/images/generations`。\n- 生命周期:同步响应。\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 图片模型 ID。 |\n| `prompt` | string | 是 | `prompt` | 图片提示词。 |\n| `images` | media[] | 否 | `provider image/reference fields` | 参考图或编辑源图,role 由业务层确定。 |\n| `imageCount` | integer | 否 | `n/sample_count` | 输出数量。 |\n| `aspectRatio` | string | 否 | `size/aspect_ratio` | 比例或尺寸,语义按协议说明。 |\n| `resolution` | string | 否 | `resolution/imageSize` | 分辨率档位。 |\n| `quality` | string | 否 | `quality` | 质量档位。 |\n| `providerOptions` | object | 否 | `provider-specific fields` | 插件命名空间内的厂商扩展字段。 |\n\n## 上游请求模板逐字段清单\n\n下表由插件请求模板生成,覆盖 body、query、headers 和 multipart 文件声明中的每个字段。\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/v1/images/generations\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.prompt` | `{\"$ref\":\"request.prompt\"}` |\n| `create.body.images` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$ref\":\"request.images\"}},0]},\"then\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$ne\":[{\"$ref\":\"media.role\"},\"mask\"]}}},\"as\":\"media\",\"in\":{\"image_url\":{\"$ref\":\"media.value\"}}}},\"else\":null}}}` |\n| `create.body.mask` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$len\":{\"$filter\":{\"from\":{\"$ref\":\"request.images\"},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}}},0]},\"then\":{\"image_url\":{\"$first\":{\"$map\":{\"from\":{\"$filter\":{\"from\":{\"$sortByOrder\":{\"$ref\":\"request.images\"}},\"as\":\"media\",\"where\":{\"$eq\":[{\"$ref\":\"media.role\"},\"mask\"]}}},\"as\":\"media\",\"in\":{\"$ref\":\"media.value\"}}}}},\"else\":null}}}` |\n| `create.body.n` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$gt\":[{\"$ref\":\"request.imageCount\"},0]},\"then\":{\"$ref\":\"request.imageCount\"},\"else\":1}}}` |\n| `create.body.size` | `{\"$omitEmpty\":{\"$if\":{\"condition\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.aspectRatio\"}}},[\"\",\"auto\"]]},\"then\":null,\"else\":{\"$ref\":\"request.aspectRatio\"}}}}` |\n| `create.body.quality` | `{\"$omitEmpty\":{\"$switch\":{\"cases\":[{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"1k\"]]},\"then\":\"low\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"2k\"]]},\"then\":\"medium\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"4k\"]]},\"then\":\"high\"},{\"when\":{\"$in\":[{\"$lower\":{\"$trim\":{\"$ref\":\"request.quality\"}}},[\"\",\"auto\"]]},\"then\":null}],\"default\":{\"$ref\":\"request.quality\"}}}}` |\n| `create.body.background` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.background\"},{\"$if\":{\"condition\":{\"$eq\":[{\"$ref\":\"request.extra.transparentBackground\"},\"true\"]},\"then\":\"transparent\",\"else\":null}}]}}` |\n| `create.body.output_format` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.providerOptions.openai-image.output_format\"},\"png\"]}}` |\n| `create.body.output_compression` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.output_compression\"}}` |\n| `create.body.moderation` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.moderation\"}}` |\n| `create.body.response_format` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.response_format\"}}` |\n| `create.body.style` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.style\"}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.openai-image.user\"}}` |\n\n## Provider 扩展键\n\n- `providerOptions.openai-image.background`\n- `providerOptions.openai-image.moderation`\n- `providerOptions.openai-image.output_compression`\n- `providerOptions.openai-image.output_format`\n- `providerOptions.openai-image.response_format`\n- `providerOptions.openai-image.style`\n- `providerOptions.openai-image.user`\n\n动态模型或工作流允许使用文档声明的完整 `parameters/input/extra_body` 对象;该对象是协议本身的开放 schema,不会被宿主裁剪。\n\n## 响应映射逐字段清单\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.images` | `{\"$map\":{\"from\":{\"$ref\":\"response.data\"},\"as\":\"item\",\"in\":{\"url\":{\"$omitEmpty\":{\"$ref\":\"item.url\"}},\"dataUrl\":{\"$if\":{\"condition\":{\"$ref\":\"item.b64_json\"},\"then\":{\"$concat\":[\"data:image/png;base64,\",{\"$ref\":\"item.b64_json\"}]},\"else\":null}}}}}` |\n| `response.usage` | `{\"$ref\":\"response.usage\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 响应与错误\n\n插件把上游 task/status/text/media/usage 映射为统一结果。临时媒体 URL 标记为 ephemeral,由宿主立即下载持久化。HTTP 错误、业务 code 和 error object 保持失败语义,不包装成成功。\n\n## 兼容边界\n\n无参考图走 JSON generations;有参考图或蒙版走 JSON edits,并按官方 images 数组传入多张 image_url。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"openai-images\",\n \"name\": \"OpenAI Images\",\n \"version\": \"2.0.0\",\n \"author\": \"OpenAI / 影策\",\n \"description\": \"OpenAI Images 独立请求协议插件。\",\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"openai-image\",\n \"label\": \"OpenAI Images\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.openai.com\",\n \"requiresPublicMediaUrls\": true,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"图片模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/v1/images/edits\",\n \"else\": \"/v1/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"images\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"image_url\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"mask\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n }\n },\n 0\n ]\n },\n \"then\": {\n \"image_url\": {\n \"$first\": {\n \"$map\": {\n \"from\": {\n \"$filter\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n },\n \"else\": null\n }\n }\n },\n \"n\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n }\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null,\n \"else\": {\n \"$ref\": \"request.aspectRatio\"\n }\n }\n }\n },\n \"quality\": {\n \"$omitEmpty\": {\n \"$switch\": {\n \"cases\": [\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"1k\"\n ]\n ]\n },\n \"then\": \"low\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"2k\"\n ]\n ]\n },\n \"then\": \"medium\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"4k\"\n ]\n ]\n },\n \"then\": \"high\"\n },\n {\n \"when\": {\n \"$in\": [\n {\n \"$lower\": {\n \"$trim\": {\n \"$ref\": \"request.quality\"\n }\n }\n },\n [\n \"\",\n \"auto\"\n ]\n ]\n },\n \"then\": null\n }\n ],\n \"default\": {\n \"$ref\": \"request.quality\"\n }\n }\n }\n },\n \"background\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.background\"\n },\n {\n \"$if\": {\n \"condition\": {\n \"$eq\": [\n {\n \"$ref\": \"request.extra.transparentBackground\"\n },\n \"true\"\n ]\n },\n \"then\": \"transparent\",\n \"else\": null\n }\n }\n ]\n }\n },\n \"output_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.openai-image.output_format\"\n },\n \"png\"\n ]\n }\n },\n \"output_compression\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.output_compression\"\n }\n },\n \"moderation\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.moderation\"\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.response_format\"\n }\n },\n \"style\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.style\"\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.openai-image.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n }\n}\n```\n\n", "permissions": [ "generation.run", "media.read" @@ -36,7 +36,7 @@ "agent" ], "baseUrl": "https://api.openai.com", - "requiresPublicMediaUrls": false, + "requiresPublicMediaUrls": true, "auth": { "type": "bearer", "field": "apiKey" @@ -119,22 +119,6 @@ } }, "contentType": "application/json", - "contentTypeTemplate": { - "$if": { - "condition": { - "$gt": [ - { - "$len": { - "$ref": "request.images" - } - }, - 0 - ] - }, - "then": "multipart/form-data", - "else": "application/json" - } - }, "body": { "model": { "$ref": "request.model" @@ -142,6 +126,111 @@ "prompt": { "$ref": "request.prompt" }, + "images": { + "$omitEmpty": { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.images" + } + }, + 0 + ] + }, + "then": { + "$map": { + "from": { + "$filter": { + "from": { + "$sortByOrder": { + "$ref": "request.images" + } + }, + "as": "media", + "where": { + "$ne": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, + "as": "media", + "in": { + "image_url": { + "$ref": "media.value" + } + } + } + }, + "else": null + } + } + }, + "mask": { + "$omitEmpty": { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$filter": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "where": { + "$eq": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + } + }, + 0 + ] + }, + "then": { + "image_url": { + "$first": { + "$map": { + "from": { + "$filter": { + "from": { + "$sortByOrder": { + "$ref": "request.images" + } + }, + "as": "media", + "where": { + "$eq": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, + "as": "media", + "in": { + "$ref": "media.value" + } + } + } + } + }, + "else": null + } + } + }, "n": { "$omitEmpty": { "$if": { @@ -310,12 +399,7 @@ }, "response_format": { "$omitEmpty": { - "$coalesce": [ - { - "$ref": "request.providerOptions.openai-image.response_format" - }, - "b64_json" - ] + "$ref": "request.providerOptions.openai-image.response_format" } }, "style": { @@ -328,49 +412,7 @@ "$ref": "request.providerOptions.openai-image.user" } } - }, - "files": [ - { - "name": "image", - "source": { - "$filter": { - "from": { - "$ref": "request.images" - }, - "as": "media", - "where": { - "$ne": [ - { - "$ref": "media.role" - }, - "mask" - ] - } - } - }, - "filename": "source.png" - }, - { - "name": "mask", - "source": { - "$filter": { - "from": { - "$ref": "request.images" - }, - "as": "media", - "where": { - "$eq": [ - { - "$ref": "media.role" - }, - "mask" - ] - } - } - }, - "filename": "mask.png" - } - ] + } }, "response": { "status": "succeeded", diff --git a/plugin-packages/src-a6api/README.md b/plugin-packages/src-a6api/README.md index 7bb76ee60..afeacbe92 100644 --- a/plugin-packages/src-a6api/README.md +++ b/plugin-packages/src-a6api/README.md @@ -17,4 +17,4 @@ A6api 模型网关协议插件。统一入口 `https://api.a6api.com`,OpenAI - 模型名以 A6api 模型市场当前列表为准。 - 图像编辑(带参考图)走 `/images/edits` multipart 请求。 -- 错误体为 OpenAI 风格,携带 `request_id`。 \ No newline at end of file +- 错误体为 OpenAI 风格,携带 `request_id`。 diff --git a/plugin-packages/src-a6api/docs/interface.md b/plugin-packages/src-a6api/docs/interface.md index 48bade060..d50b716d3 100644 --- a/plugin-packages/src-a6api/docs/interface.md +++ b/plugin-packages/src-a6api/docs/interface.md @@ -26,4 +26,497 @@ POST `https://api.a6api.com/images/edits`(multipart,带参考图时使用) ## 模型列表 -GET `https://api.a6api.com/models` \ No newline at end of file +GET `https://api.a6api.com/models` + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v2", + "id": "a6api", + "name": "A6api", + "version": "1.0.0", + "author": "A6api", + "description": "A6api 模型网关协议插件:OpenAI 兼容对话与图像生成。", + "permissions": [ + "generation.run", + "media.read" + ], + "configuration": { + "fields": [ + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } + ] + }, + "contributes": { + "providers": [ + { + "id": "a6api-chat", + "label": "A6api Chat", + "capabilities": [ + "text" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], + "baseUrl": "https://api.a6api.com", + "auth": { + "type": "bearer", + "field": "apiKey" + }, + "parameters": [ + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "A6api 模型市场中的模型 ID。" + }, + { + "name": "messages", + "type": "message[]", + "required": true, + "mapping": "provider message container", + "description": "包含历史消息和当前用户输入。" + }, + { + "name": "instructions", + "type": "string", + "required": false, + "mapping": "system/instructions", + "description": "系统指令。" + }, + { + "name": "temperature", + "type": "number", + "required": false, + "mapping": "temperature", + "description": "采样温度。" + }, + { + "name": "top_p", + "type": "number", + "required": false, + "mapping": "top_p", + "description": "核采样参数。" + }, + { + "name": "max_tokens", + "type": "integer", + "required": false, + "mapping": "max_tokens/max_output_tokens", + "description": "最大输出 token。" + }, + { + "name": "tools", + "type": "array", + "required": false, + "mapping": "tools/toolConfig", + "description": "工具定义。" + }, + { + "name": "tool_choice", + "type": "object|string", + "required": false, + "mapping": "tool_choice", + "description": "工具选择策略。" + }, + { + "name": "response_format", + "type": "object", + "required": false, + "mapping": "response_format/text", + "description": "结构化输出配置。" + }, + { + "name": "stream", + "type": "boolean", + "required": false, + "mapping": "stream", + "description": "流式开关;后台任务当前以最终响应归一。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } + ], + "create": { + "method": "POST", + "path": "/chat/completions", + "contentType": "application/json", + "body": { + "model": { + "$ref": "request.model" + }, + "messages": { + "$ref": "request.messages" + }, + "temperature": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.temperature" + } + }, + "top_p": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.top_p" + } + }, + "max_tokens": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.extra.max_tokens" + }, + { + "$ref": "request.providerOptions.a6api-chat.max_tokens" + } + ] + } + }, + "tools": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.tools" + } + }, + "tool_choice": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.tool_choice" + } + }, + "response_format": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.response_format" + } + }, + "stream": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.stream" + } + }, + "stop": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.stop" + } + }, + "seed": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.seed" + } + }, + "frequency_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.frequency_penalty" + } + }, + "presence_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.presence_penalty" + } + }, + "user": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.user" + } + } + } + }, + "response": { + "status": "succeeded", + "textPaths": [ + "choices.0.message.content", + "choices.0.text" + ], + "reasoningPaths": [ + "choices.0.message.reasoning_content" + ], + "usage": { + "$ref": "response.usage" + }, + "errorPaths": [ + "error.code" + ], + "messagePaths": [ + "error.message" + ] + } + }, + { + "id": "a6api-image", + "label": "A6api Image", + "capabilities": [ + "image" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], + "baseUrl": "https://api.a6api.com", + "auth": { + "type": "bearer", + "field": "apiKey" + }, + "parameters": [ + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "A6api 模型市场中的图像模型 ID。" + }, + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt", + "description": "图片提示词。" + }, + { + "name": "images", + "type": "media[]", + "required": false, + "mapping": "provider image/reference fields", + "description": "参考图或编辑源图,role 由业务层确定。" + }, + { + "name": "imageCount", + "type": "integer", + "required": false, + "mapping": "n/sample_count", + "description": "输出数量。" + }, + { + "name": "aspectRatio", + "type": "string", + "required": false, + "mapping": "size/aspect_ratio", + "description": "比例或尺寸,语义按协议说明。" + }, + { + "name": "resolution", + "type": "string", + "required": false, + "mapping": "resolution/imageSize", + "description": "分辨率档位。" + }, + { + "name": "quality", + "type": "string", + "required": false, + "mapping": "quality", + "description": "质量档位。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } + ], + "create": { + "method": "POST", + "path": "/images/generations", + "pathTemplate": { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.images" + } + }, + 0 + ] + }, + "then": "/images/edits", + "else": "/images/generations" + } + }, + "contentType": "application/json", + "contentTypeTemplate": { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.images" + } + }, + 0 + ] + }, + "then": "multipart/form-data", + "else": "application/json" + } + }, + "body": { + "model": { + "$ref": "request.model" + }, + "prompt": { + "$ref": "request.prompt" + }, + "n": { + "$omitEmpty": { + "$if": { + "condition": { + "$gt": [ + { + "$ref": "request.imageCount" + }, + 0 + ] + }, + "then": { + "$ref": "request.imageCount" + }, + "else": 1 + } + } + }, + "size": { + "$omitEmpty": { + "$ref": "request.aspectRatio" + } + }, + "quality": { + "$omitEmpty": { + "$ref": "request.quality" + } + }, + "output_format": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.providerOptions.a6api-image.output_format" + }, + "png" + ] + } + }, + "response_format": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.providerOptions.a6api-image.response_format" + }, + "b64_json" + ] + } + }, + "user": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-image.user" + } + } + }, + "files": [ + { + "name": "image", + "source": { + "$filter": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "where": { + "$ne": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, + "filename": "source.png" + }, + { + "name": "mask", + "source": { + "$filter": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "where": { + "$eq": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, + "filename": "mask.png" + } + ] + }, + "response": { + "status": "succeeded", + "images": { + "$map": { + "from": { + "$ref": "response.data" + }, + "as": "item", + "in": { + "url": { + "$omitEmpty": { + "$ref": "item.url" + } + }, + "dataUrl": { + "$if": { + "condition": { + "$ref": "item.b64_json" + }, + "then": { + "$concat": [ + "data:image/png;base64,", + { + "$ref": "item.b64_json" + } + ] + }, + "else": null + } + } + } + } + }, + "usage": { + "$ref": "response.usage" + }, + "errorPaths": [ + "error.code" + ], + "messagePaths": [ + "error.message" + ] + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/src-a6api/manifest.json b/plugin-packages/src-a6api/manifest.json index 8bb12ca02..2278f2780 100644 --- a/plugin-packages/src-a6api/manifest.json +++ b/plugin-packages/src-a6api/manifest.json @@ -5,10 +5,18 @@ "version": "1.0.0", "author": "A6api", "description": "A6api 模型网关协议插件:OpenAI 兼容对话与图像生成。", - "permissions": ["generation.run", "media.read"], + "permissions": [ + "generation.run", + "media.read" + ], "configuration": { "fields": [ - { "name": "apiKey", "type": "secret", "label": "API Key", "required": true } + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } ] }, "contributes": { @@ -16,76 +24,291 @@ { "id": "a6api-chat", "label": "A6api Chat", - "capabilities": ["text"], - "scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"], + "capabilities": [ + "text" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], "baseUrl": "https://api.a6api.com", - "auth": { "type": "bearer", "field": "apiKey" }, + "auth": { + "type": "bearer", + "field": "apiKey" + }, "parameters": [ - { "name": "model", "type": "string", "required": true, "mapping": "model", "description": "A6api 模型市场中的模型 ID。" }, - { "name": "messages", "type": "message[]", "required": true, "mapping": "provider message container", "description": "包含历史消息和当前用户输入。" }, - { "name": "instructions", "type": "string", "required": false, "mapping": "system/instructions", "description": "系统指令。" }, - { "name": "temperature", "type": "number", "required": false, "mapping": "temperature", "description": "采样温度。" }, - { "name": "top_p", "type": "number", "required": false, "mapping": "top_p", "description": "核采样参数。" }, - { "name": "max_tokens", "type": "integer", "required": false, "mapping": "max_tokens/max_output_tokens", "description": "最大输出 token。" }, - { "name": "tools", "type": "array", "required": false, "mapping": "tools/toolConfig", "description": "工具定义。" }, - { "name": "tool_choice", "type": "object|string", "required": false, "mapping": "tool_choice", "description": "工具选择策略。" }, - { "name": "response_format", "type": "object", "required": false, "mapping": "response_format/text", "description": "结构化输出配置。" }, - { "name": "stream", "type": "boolean", "required": false, "mapping": "stream", "description": "流式开关;后台任务当前以最终响应归一。" }, - { "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" } + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "A6api 模型市场中的模型 ID。" + }, + { + "name": "messages", + "type": "message[]", + "required": true, + "mapping": "provider message container", + "description": "包含历史消息和当前用户输入。" + }, + { + "name": "instructions", + "type": "string", + "required": false, + "mapping": "system/instructions", + "description": "系统指令。" + }, + { + "name": "temperature", + "type": "number", + "required": false, + "mapping": "temperature", + "description": "采样温度。" + }, + { + "name": "top_p", + "type": "number", + "required": false, + "mapping": "top_p", + "description": "核采样参数。" + }, + { + "name": "max_tokens", + "type": "integer", + "required": false, + "mapping": "max_tokens/max_output_tokens", + "description": "最大输出 token。" + }, + { + "name": "tools", + "type": "array", + "required": false, + "mapping": "tools/toolConfig", + "description": "工具定义。" + }, + { + "name": "tool_choice", + "type": "object|string", + "required": false, + "mapping": "tool_choice", + "description": "工具选择策略。" + }, + { + "name": "response_format", + "type": "object", + "required": false, + "mapping": "response_format/text", + "description": "结构化输出配置。" + }, + { + "name": "stream", + "type": "boolean", + "required": false, + "mapping": "stream", + "description": "流式开关;后台任务当前以最终响应归一。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } ], "create": { "method": "POST", "path": "/chat/completions", "contentType": "application/json", "body": { - "model": { "$ref": "request.model" }, - "messages": { "$ref": "request.messages" }, - "temperature": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.temperature" } }, - "top_p": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.top_p" } }, - "max_tokens": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.extra.max_tokens" }, { "$ref": "request.providerOptions.a6api-chat.max_tokens" } ] } }, - "tools": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.tools" } }, - "tool_choice": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.tool_choice" } }, - "response_format": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.response_format" } }, - "stream": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.stream" } }, - "stop": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.stop" } }, - "seed": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.seed" } }, - "frequency_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.frequency_penalty" } }, - "presence_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.presence_penalty" } }, - "user": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-chat.user" } } + "model": { + "$ref": "request.model" + }, + "messages": { + "$ref": "request.messages" + }, + "temperature": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.temperature" + } + }, + "top_p": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.top_p" + } + }, + "max_tokens": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.extra.max_tokens" + }, + { + "$ref": "request.providerOptions.a6api-chat.max_tokens" + } + ] + } + }, + "tools": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.tools" + } + }, + "tool_choice": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.tool_choice" + } + }, + "response_format": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.response_format" + } + }, + "stream": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.stream" + } + }, + "stop": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.stop" + } + }, + "seed": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.seed" + } + }, + "frequency_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.frequency_penalty" + } + }, + "presence_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.presence_penalty" + } + }, + "user": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-chat.user" + } + } } }, "response": { "status": "succeeded", - "textPaths": ["choices.0.message.content", "choices.0.text"], - "reasoningPaths": ["choices.0.message.reasoning_content"], - "usage": { "$ref": "response.usage" }, - "errorPaths": ["error.code"], - "messagePaths": ["error.message"] + "textPaths": [ + "choices.0.message.content", + "choices.0.text" + ], + "reasoningPaths": [ + "choices.0.message.reasoning_content" + ], + "usage": { + "$ref": "response.usage" + }, + "errorPaths": [ + "error.code" + ], + "messagePaths": [ + "error.message" + ] } }, { "id": "a6api-image", "label": "A6api Image", - "capabilities": ["image"], - "scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"], + "capabilities": [ + "image" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], "baseUrl": "https://api.a6api.com", - "auth": { "type": "bearer", "field": "apiKey" }, + "auth": { + "type": "bearer", + "field": "apiKey" + }, "parameters": [ - { "name": "model", "type": "string", "required": true, "mapping": "model", "description": "A6api 模型市场中的图像模型 ID。" }, - { "name": "prompt", "type": "string", "required": true, "mapping": "prompt", "description": "图片提示词。" }, - { "name": "images", "type": "media[]", "required": false, "mapping": "provider image/reference fields", "description": "参考图或编辑源图,role 由业务层确定。" }, - { "name": "imageCount", "type": "integer", "required": false, "mapping": "n/sample_count", "description": "输出数量。" }, - { "name": "aspectRatio", "type": "string", "required": false, "mapping": "size/aspect_ratio", "description": "比例或尺寸,语义按协议说明。" }, - { "name": "resolution", "type": "string", "required": false, "mapping": "resolution/imageSize", "description": "分辨率档位。" }, - { "name": "quality", "type": "string", "required": false, "mapping": "quality", "description": "质量档位。" }, - { "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" } + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "A6api 模型市场中的图像模型 ID。" + }, + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt", + "description": "图片提示词。" + }, + { + "name": "images", + "type": "media[]", + "required": false, + "mapping": "provider image/reference fields", + "description": "参考图或编辑源图,role 由业务层确定。" + }, + { + "name": "imageCount", + "type": "integer", + "required": false, + "mapping": "n/sample_count", + "description": "输出数量。" + }, + { + "name": "aspectRatio", + "type": "string", + "required": false, + "mapping": "size/aspect_ratio", + "description": "比例或尺寸,语义按协议说明。" + }, + { + "name": "resolution", + "type": "string", + "required": false, + "mapping": "resolution/imageSize", + "description": "分辨率档位。" + }, + { + "name": "quality", + "type": "string", + "required": false, + "mapping": "quality", + "description": "质量档位。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } ], "create": { "method": "POST", "path": "/images/generations", "pathTemplate": { "$if": { - "condition": { "$gt": [ { "$len": { "$ref": "request.images" } }, 0 ] }, + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.images" + } + }, + 0 + ] + }, "then": "/images/edits", "else": "/images/generations" } @@ -93,30 +316,120 @@ "contentType": "application/json", "contentTypeTemplate": { "$if": { - "condition": { "$gt": [ { "$len": { "$ref": "request.images" } }, 0 ] }, + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.images" + } + }, + 0 + ] + }, "then": "multipart/form-data", "else": "application/json" } }, "body": { - "model": { "$ref": "request.model" }, - "prompt": { "$ref": "request.prompt" }, - "n": { "$omitEmpty": { "$if": { "condition": { "$gt": [ { "$ref": "request.imageCount" }, 0 ] }, "then": { "$ref": "request.imageCount" }, "else": 1 } } }, - "size": { "$omitEmpty": { "$ref": "request.aspectRatio" } }, - "quality": { "$omitEmpty": { "$ref": "request.quality" } }, - "output_format": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.providerOptions.a6api-image.output_format" }, "png" ] } }, - "response_format": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.providerOptions.a6api-image.response_format" }, "b64_json" ] } }, - "user": { "$omitEmpty": { "$ref": "request.providerOptions.a6api-image.user" } } + "model": { + "$ref": "request.model" + }, + "prompt": { + "$ref": "request.prompt" + }, + "n": { + "$omitEmpty": { + "$if": { + "condition": { + "$gt": [ + { + "$ref": "request.imageCount" + }, + 0 + ] + }, + "then": { + "$ref": "request.imageCount" + }, + "else": 1 + } + } + }, + "size": { + "$omitEmpty": { + "$ref": "request.aspectRatio" + } + }, + "quality": { + "$omitEmpty": { + "$ref": "request.quality" + } + }, + "output_format": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.providerOptions.a6api-image.output_format" + }, + "png" + ] + } + }, + "response_format": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.providerOptions.a6api-image.response_format" + }, + "b64_json" + ] + } + }, + "user": { + "$omitEmpty": { + "$ref": "request.providerOptions.a6api-image.user" + } + } }, "files": [ { "name": "image", - "source": { "$filter": { "from": { "$ref": "request.images" }, "as": "media", "where": { "$ne": [ { "$ref": "media.role" }, "mask" ] } } }, + "source": { + "$filter": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "where": { + "$ne": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, "filename": "source.png" }, { "name": "mask", - "source": { "$filter": { "from": { "$ref": "request.images" }, "as": "media", "where": { "$eq": [ { "$ref": "media.role" }, "mask" ] } } }, + "source": { + "$filter": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "where": { + "$eq": [ + { + "$ref": "media.role" + }, + "mask" + ] + } + } + }, "filename": "mask.png" } ] @@ -125,19 +438,47 @@ "status": "succeeded", "images": { "$map": { - "from": { "$ref": "response.data" }, + "from": { + "$ref": "response.data" + }, "as": "item", "in": { - "url": { "$omitEmpty": { "$ref": "item.url" } }, - "dataUrl": { "$if": { "condition": { "$ref": "item.b64_json" }, "then": { "$concat": [ "data:image/png;base64,", { "$ref": "item.b64_json" } ] }, "else": null } } + "url": { + "$omitEmpty": { + "$ref": "item.url" + } + }, + "dataUrl": { + "$if": { + "condition": { + "$ref": "item.b64_json" + }, + "then": { + "$concat": [ + "data:image/png;base64,", + { + "$ref": "item.b64_json" + } + ] + }, + "else": null + } + } } } }, - "usage": { "$ref": "response.usage" }, - "errorPaths": ["error.code"], - "messagePaths": ["error.message"] + "usage": { + "$ref": "response.usage" + }, + "errorPaths": [ + "error.code" + ], + "messagePaths": [ + "error.message" + ] } } ] - } -} \ No newline at end of file + }, + "documentation": "# A6api\n\nA6api 模型网关协议插件。统一入口 `https://api.a6api.com`,OpenAI 兼容(`Authorization: Bearer `)。\n\n## Providers\n\n| Provider | 能力 | 端点 |\n|---|---|---|\n| a6api-chat | text | POST /chat/completions |\n| a6api-image | image | POST /images/generations、POST /images/edits |\n\n## 配置\n\n在渠道中填写 API Key(A6api 控制台创建,形如 `sk-xxxxxxxx`),baseUrl 使用 `https://api.a6api.com`。\n\n## 说明\n\n- 模型名以 A6api 模型市场当前列表为准。\n- 图像编辑(带参考图)走 `/images/edits` multipart 请求。\n- 错误体为 OpenAI 风格,携带 `request_id`。\n\n---\n\n# A6api 接口说明\n\n## 鉴权\n\n`Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx`\n\n## 文本对话\n\nPOST `https://api.a6api.com/chat/completions`\n\n请求体 OpenAI Chat Completions 格式:`model`、`messages`、`temperature`、`max_tokens`、`tools`、`tool_choice`、`response_format`、`stream` 等。\n\n响应 `choices[0].message.content` 为正文,`choices[0].message.reasoning_content` 为推理内容,`usage` 为用量。\n\n## 图像生成\n\nPOST `https://api.a6api.com/images/generations`\n\n请求体:`model`、`prompt`、`n`、`size`、`quality`、`response_format`。\n\n响应 `data[].url` 或 `data[].b64_json`。\n\n## 图像编辑\n\nPOST `https://api.a6api.com/images/edits`(multipart,带参考图时使用)\n\n## 模型列表\n\nGET `https://api.a6api.com/models`\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"a6api\",\n \"name\": \"A6api\",\n \"version\": \"1.0.0\",\n \"author\": \"A6api\",\n \"description\": \"A6api 模型网关协议插件:OpenAI 兼容对话与图像生成。\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"a6api-chat\",\n \"label\": \"A6api Chat\",\n \"capabilities\": [\n \"text\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.a6api.com\",\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"A6api 模型市场中的模型 ID。\"\n },\n {\n \"name\": \"messages\",\n \"type\": \"message[]\",\n \"required\": true,\n \"mapping\": \"provider message container\",\n \"description\": \"包含历史消息和当前用户输入。\"\n },\n {\n \"name\": \"instructions\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"system/instructions\",\n \"description\": \"系统指令。\"\n },\n {\n \"name\": \"temperature\",\n \"type\": \"number\",\n \"required\": false,\n \"mapping\": \"temperature\",\n \"description\": \"采样温度。\"\n },\n {\n \"name\": \"top_p\",\n \"type\": \"number\",\n \"required\": false,\n \"mapping\": \"top_p\",\n \"description\": \"核采样参数。\"\n },\n {\n \"name\": \"max_tokens\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"max_tokens/max_output_tokens\",\n \"description\": \"最大输出 token。\"\n },\n {\n \"name\": \"tools\",\n \"type\": \"array\",\n \"required\": false,\n \"mapping\": \"tools/toolConfig\",\n \"description\": \"工具定义。\"\n },\n {\n \"name\": \"tool_choice\",\n \"type\": \"object|string\",\n \"required\": false,\n \"mapping\": \"tool_choice\",\n \"description\": \"工具选择策略。\"\n },\n {\n \"name\": \"response_format\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"response_format/text\",\n \"description\": \"结构化输出配置。\"\n },\n {\n \"name\": \"stream\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"stream\",\n \"description\": \"流式开关;后台任务当前以最终响应归一。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/chat/completions\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"messages\": {\n \"$ref\": \"request.messages\"\n },\n \"temperature\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.temperature\"\n }\n },\n \"top_p\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.top_p\"\n }\n },\n \"max_tokens\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.extra.max_tokens\"\n },\n {\n \"$ref\": \"request.providerOptions.a6api-chat.max_tokens\"\n }\n ]\n }\n },\n \"tools\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.tools\"\n }\n },\n \"tool_choice\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.tool_choice\"\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.response_format\"\n }\n },\n \"stream\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.stream\"\n }\n },\n \"stop\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.stop\"\n }\n },\n \"seed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.seed\"\n }\n },\n \"frequency_penalty\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.frequency_penalty\"\n }\n },\n \"presence_penalty\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.presence_penalty\"\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-chat.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"textPaths\": [\n \"choices.0.message.content\",\n \"choices.0.text\"\n ],\n \"reasoningPaths\": [\n \"choices.0.message.reasoning_content\"\n ],\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n },\n {\n \"id\": \"a6api-image\",\n \"label\": \"A6api Image\",\n \"capabilities\": [\n \"image\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://api.a6api.com\",\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"A6api 模型市场中的图像模型 ID。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"图片提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"provider image/reference fields\",\n \"description\": \"参考图或编辑源图,role 由业务层确定。\"\n },\n {\n \"name\": \"imageCount\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"n/sample_count\",\n \"description\": \"输出数量。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"size/aspect_ratio\",\n \"description\": \"比例或尺寸,语义按协议说明。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution/imageSize\",\n \"description\": \"分辨率档位。\"\n },\n {\n \"name\": \"quality\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"quality\",\n \"description\": \"质量档位。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/images/generations\",\n \"pathTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"/images/edits\",\n \"else\": \"/images/generations\"\n }\n },\n \"contentType\": \"application/json\",\n \"contentTypeTemplate\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.images\"\n }\n },\n 0\n ]\n },\n \"then\": \"multipart/form-data\",\n \"else\": \"application/json\"\n }\n },\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"n\": {\n \"$omitEmpty\": {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$ref\": \"request.imageCount\"\n },\n 0\n ]\n },\n \"then\": {\n \"$ref\": \"request.imageCount\"\n },\n \"else\": 1\n }\n }\n },\n \"size\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.aspectRatio\"\n }\n },\n \"quality\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.quality\"\n }\n },\n \"output_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.a6api-image.output_format\"\n },\n \"png\"\n ]\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.a6api-image.response_format\"\n },\n \"b64_json\"\n ]\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.a6api-image.user\"\n }\n }\n },\n \"files\": [\n {\n \"name\": \"image\",\n \"source\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"filename\": \"source.png\"\n },\n {\n \"name\": \"mask\",\n \"source\": {\n \"$filter\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"where\": {\n \"$eq\": [\n {\n \"$ref\": \"media.role\"\n },\n \"mask\"\n ]\n }\n }\n },\n \"filename\": \"mask.png\"\n }\n ]\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"images\": {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"url\": {\n \"$omitEmpty\": {\n \"$ref\": \"item.url\"\n }\n },\n \"dataUrl\": {\n \"$if\": {\n \"condition\": {\n \"$ref\": \"item.b64_json\"\n },\n \"then\": {\n \"$concat\": [\n \"data:image/png;base64,\",\n {\n \"$ref\": \"item.b64_json\"\n }\n ]\n },\n \"else\": null\n }\n }\n }\n }\n },\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" +} diff --git a/plugin-packages/src-antigravity-proxy/docs/interface.md b/plugin-packages/src-antigravity-proxy/docs/interface.md index 5a19a4f61..66f7d83c9 100644 --- a/plugin-packages/src-antigravity-proxy/docs/interface.md +++ b/plugin-packages/src-antigravity-proxy/docs/interface.md @@ -68,3 +68,236 @@ ## 兼容边界 该包只代表 Antigravity 中转渠道的 OpenAI Chat Completions 线协议;其他端点或网关包装必须使用独立插件。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v2", + "id": "antigravity-proxy", + "name": "Antigravity Proxy", + "version": "1.0.0", + "author": "Yingce", + "description": "Antigravity 中转渠道插件:OpenAI 兼容对话(gemini-3.8-flash-high / gemini-pro-agent)。", + "permissions": [ + "generation.run", + "media.read" + ], + "configuration": { + "fields": [ + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } + ] + }, + "contributes": { + "providers": [ + { + "id": "antigravity-chat", + "label": "Antigravity Chat", + "capabilities": [ + "text" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], + "baseUrl": "http://103.242.14.110:8317", + "auth": { + "type": "bearer", + "field": "apiKey" + }, + "parameters": [ + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "模型 ID(gemini-3.8-flash-high / gemini-pro-agent)。" + }, + { + "name": "messages", + "type": "message[]", + "required": true, + "mapping": "provider message container", + "description": "包含历史消息和当前用户输入。" + }, + { + "name": "instructions", + "type": "string", + "required": false, + "mapping": "system/instructions", + "description": "系统指令。" + }, + { + "name": "temperature", + "type": "number", + "required": false, + "mapping": "temperature", + "description": "采样温度。" + }, + { + "name": "top_p", + "type": "number", + "required": false, + "mapping": "top_p", + "description": "核采样参数。" + }, + { + "name": "max_tokens", + "type": "integer", + "required": false, + "mapping": "max_tokens/max_output_tokens", + "description": "最大输出 token。" + }, + { + "name": "tools", + "type": "array", + "required": false, + "mapping": "tools/toolConfig", + "description": "工具定义。" + }, + { + "name": "tool_choice", + "type": "object|string", + "required": false, + "mapping": "tool_choice", + "description": "工具选择策略。" + }, + { + "name": "response_format", + "type": "object", + "required": false, + "mapping": "response_format/text", + "description": "结构化输出配置。" + }, + { + "name": "stream", + "type": "boolean", + "required": false, + "mapping": "stream", + "description": "流式开关;后台任务当前以最终响应归一。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } + ], + "create": { + "method": "POST", + "path": "/chat/completions", + "contentType": "application/json", + "body": { + "model": { + "$ref": "request.model" + }, + "messages": { + "$ref": "request.messages" + }, + "temperature": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.temperature" + } + }, + "top_p": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.top_p" + } + }, + "max_tokens": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.extra.max_tokens" + }, + { + "$ref": "request.providerOptions.antigravity-chat.max_tokens" + } + ] + } + }, + "tools": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.tools" + } + }, + "tool_choice": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.tool_choice" + } + }, + "response_format": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.response_format" + } + }, + "stream": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.stream" + } + }, + "stop": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.stop" + } + }, + "seed": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.seed" + } + }, + "frequency_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.frequency_penalty" + } + }, + "presence_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.presence_penalty" + } + }, + "user": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.user" + } + } + } + }, + "response": { + "status": "succeeded", + "textPaths": [ + "choices.0.message.content", + "choices.0.text" + ], + "reasoningPaths": [ + "choices.0.message.reasoning_content" + ], + "usage": { + "$ref": "response.usage" + }, + "errorPaths": [ + "error.code" + ], + "messagePaths": [ + "error.message" + ] + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/src-antigravity-proxy/manifest.json b/plugin-packages/src-antigravity-proxy/manifest.json index 290fd46b2..e914d6856 100644 --- a/plugin-packages/src-antigravity-proxy/manifest.json +++ b/plugin-packages/src-antigravity-proxy/manifest.json @@ -5,10 +5,18 @@ "version": "1.0.0", "author": "Yingce", "description": "Antigravity 中转渠道插件:OpenAI 兼容对话(gemini-3.8-flash-high / gemini-pro-agent)。", - "permissions": ["generation.run", "media.read"], + "permissions": [ + "generation.run", + "media.read" + ], "configuration": { "fields": [ - { "name": "apiKey", "type": "secret", "label": "API Key", "required": true } + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } ] }, "contributes": { @@ -16,53 +24,201 @@ { "id": "antigravity-chat", "label": "Antigravity Chat", - "capabilities": ["text"], - "scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"], + "capabilities": [ + "text" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], "baseUrl": "http://103.242.14.110:8317", - "auth": { "type": "bearer", "field": "apiKey" }, + "auth": { + "type": "bearer", + "field": "apiKey" + }, "parameters": [ - { "name": "model", "type": "string", "required": true, "mapping": "model", "description": "模型 ID(gemini-3.8-flash-high / gemini-pro-agent)。" }, - { "name": "messages", "type": "message[]", "required": true, "mapping": "provider message container", "description": "包含历史消息和当前用户输入。" }, - { "name": "instructions", "type": "string", "required": false, "mapping": "system/instructions", "description": "系统指令。" }, - { "name": "temperature", "type": "number", "required": false, "mapping": "temperature", "description": "采样温度。" }, - { "name": "top_p", "type": "number", "required": false, "mapping": "top_p", "description": "核采样参数。" }, - { "name": "max_tokens", "type": "integer", "required": false, "mapping": "max_tokens/max_output_tokens", "description": "最大输出 token。" }, - { "name": "tools", "type": "array", "required": false, "mapping": "tools/toolConfig", "description": "工具定义。" }, - { "name": "tool_choice", "type": "object|string", "required": false, "mapping": "tool_choice", "description": "工具选择策略。" }, - { "name": "response_format", "type": "object", "required": false, "mapping": "response_format/text", "description": "结构化输出配置。" }, - { "name": "stream", "type": "boolean", "required": false, "mapping": "stream", "description": "流式开关;后台任务当前以最终响应归一。" }, - { "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" } + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "模型 ID(gemini-3.8-flash-high / gemini-pro-agent)。" + }, + { + "name": "messages", + "type": "message[]", + "required": true, + "mapping": "provider message container", + "description": "包含历史消息和当前用户输入。" + }, + { + "name": "instructions", + "type": "string", + "required": false, + "mapping": "system/instructions", + "description": "系统指令。" + }, + { + "name": "temperature", + "type": "number", + "required": false, + "mapping": "temperature", + "description": "采样温度。" + }, + { + "name": "top_p", + "type": "number", + "required": false, + "mapping": "top_p", + "description": "核采样参数。" + }, + { + "name": "max_tokens", + "type": "integer", + "required": false, + "mapping": "max_tokens/max_output_tokens", + "description": "最大输出 token。" + }, + { + "name": "tools", + "type": "array", + "required": false, + "mapping": "tools/toolConfig", + "description": "工具定义。" + }, + { + "name": "tool_choice", + "type": "object|string", + "required": false, + "mapping": "tool_choice", + "description": "工具选择策略。" + }, + { + "name": "response_format", + "type": "object", + "required": false, + "mapping": "response_format/text", + "description": "结构化输出配置。" + }, + { + "name": "stream", + "type": "boolean", + "required": false, + "mapping": "stream", + "description": "流式开关;后台任务当前以最终响应归一。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } ], "create": { "method": "POST", "path": "/chat/completions", "contentType": "application/json", "body": { - "model": { "$ref": "request.model" }, - "messages": { "$ref": "request.messages" }, - "temperature": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.temperature" } }, - "top_p": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.top_p" } }, - "max_tokens": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.extra.max_tokens" }, { "$ref": "request.providerOptions.antigravity-chat.max_tokens" } ] } }, - "tools": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.tools" } }, - "tool_choice": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.tool_choice" } }, - "response_format": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.response_format" } }, - "stream": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.stream" } }, - "stop": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.stop" } }, - "seed": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.seed" } }, - "frequency_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.frequency_penalty" } }, - "presence_penalty": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.presence_penalty" } }, - "user": { "$omitEmpty": { "$ref": "request.providerOptions.antigravity-chat.user" } } + "model": { + "$ref": "request.model" + }, + "messages": { + "$ref": "request.messages" + }, + "temperature": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.temperature" + } + }, + "top_p": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.top_p" + } + }, + "max_tokens": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.extra.max_tokens" + }, + { + "$ref": "request.providerOptions.antigravity-chat.max_tokens" + } + ] + } + }, + "tools": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.tools" + } + }, + "tool_choice": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.tool_choice" + } + }, + "response_format": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.response_format" + } + }, + "stream": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.stream" + } + }, + "stop": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.stop" + } + }, + "seed": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.seed" + } + }, + "frequency_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.frequency_penalty" + } + }, + "presence_penalty": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.presence_penalty" + } + }, + "user": { + "$omitEmpty": { + "$ref": "request.providerOptions.antigravity-chat.user" + } + } } }, "response": { "status": "succeeded", - "textPaths": ["choices.0.message.content", "choices.0.text"], - "reasoningPaths": ["choices.0.message.reasoning_content"], - "usage": { "$ref": "response.usage" }, - "errorPaths": ["error.code"], - "messagePaths": ["error.message"] + "textPaths": [ + "choices.0.message.content", + "choices.0.text" + ], + "reasoningPaths": [ + "choices.0.message.reasoning_content" + ], + "usage": { + "$ref": "response.usage" + }, + "errorPaths": [ + "error.code" + ], + "messagePaths": [ + "error.message" + ] } } ] - } + }, + "documentation": "# Antigravity Proxy\n\nAntigravity 中转渠道插件:OpenAI Chat Completions 兼容对话协议。\n\n## 协议身份\n\n- 插件 ID:`antigravity-proxy`\n- Provider ID:`antigravity-chat`\n- 能力:`text`\n- 默认 Base URL:`http://103.242.14.110:8317`\n- 鉴权驱动:`bearer`\n- 创建:`POST /chat/completions`\n- 生命周期:同步响应\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | 中转渠道 API Key |\n\n## 模型\n\n- `gemini-3.8-flash-high`\n- `gemini-pro-agent`\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 |\n| --- | --- | --- | --- |\n| `model` | string | 是 | `model` |\n| `messages` | message[] | 是 | `messages` |\n| `instructions` | string | 否 | `system/instructions` |\n| `temperature` | number | 否 | `temperature` |\n| `top_p` | number | 否 | `top_p` |\n| `max_tokens` | integer | 否 | `max_tokens` |\n| `tools` | array | 否 | `tools` |\n| `tool_choice` | object\\|string | 否 | `tool_choice` |\n| `response_format` | object | 否 | `response_format` |\n| `stream` | boolean | 否 | `stream` |\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# Antigravity Proxy 接口字段\n\n## 协议身份\n\n- 插件 ID:`antigravity-proxy`\n- Provider ID:`antigravity-chat`\n- 能力:`text`\n- 默认 Base URL:`http://103.242.14.110:8317`\n- 鉴权驱动:`bearer`\n- 创建:`POST /chat/completions`\n- 生命周期:同步响应\n\n## 配置字段\n\n| 字段 | 类型 | 必填 | 含义 |\n| --- | --- | --- | --- |\n| `apiKey` | secret | 是 | 中转渠道 API Key |\n\n## 统一字段映射\n\n| 统一字段 | 类型 | 必填 | 上游映射 | 说明 |\n| --- | --- | --- | --- | --- |\n| `model` | string | 是 | `model` | 上游模型 ID。 |\n| `messages` | message[] | 是 | `messages` | 包含历史消息和当前用户输入。 |\n| `instructions` | string | 否 | `system/instructions` | 系统指令。 |\n| `temperature` | number | 否 | `temperature` | 采样温度。 |\n| `top_p` | number | 否 | `top_p` | 核采样参数。 |\n| `max_tokens` | integer | 否 | `max_tokens/max_output_tokens` | 最大输出 token。 |\n| `tools` | array | 否 | `tools/toolConfig` | 工具定义。 |\n| `tool_choice` | object\\|string | 否 | `tool_choice` | 工具选择策略。 |\n| `response_format` | object | 否 | `response_format/text` | 结构化输出配置。 |\n| `stream` | boolean | 否 | `stream` | 流式开关。 |\n\n## 上游请求模板\n\n| 上游位置 | 值或转换表达式 |\n| --- | --- |\n| `create.method` | `\"POST\"` |\n| `create.path` | `\"/chat/completions\"` |\n| `create.contentType` | `\"application/json\"` |\n| `create.body.model` | `{\"$ref\":\"request.model\"}` |\n| `create.body.messages` | `{\"$ref\":\"request.messages\"}` |\n| `create.body.temperature` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.temperature\"}}` |\n| `create.body.top_p` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.top_p\"}}` |\n| `create.body.max_tokens` | `{\"$omitEmpty\":{\"$coalesce\":[{\"$ref\":\"request.extra.max_tokens\"},{\"$ref\":\"request.providerOptions.antigravity-chat.max_tokens\"}]}}` |\n| `create.body.tools` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.tools\"}}` |\n| `create.body.tool_choice` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.tool_choice\"}}` |\n| `create.body.response_format` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.response_format\"}}` |\n| `create.body.stream` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.stream\"}}` |\n| `create.body.stop` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.stop\"}}` |\n| `create.body.seed` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.seed\"}}` |\n| `create.body.frequency_penalty` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.frequency_penalty\"}}` |\n| `create.body.presence_penalty` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.presence_penalty\"}}` |\n| `create.body.user` | `{\"$omitEmpty\":{\"$ref\":\"request.providerOptions.antigravity-chat.user\"}}` |\n\n## 响应映射\n\n| 映射位置 | 上游路径或转换表达式 |\n| --- | --- |\n| `response.status` | `\"succeeded\"` |\n| `response.textPaths[0]` | `\"choices.0.message.content\"` |\n| `response.textPaths[1]` | `\"choices.0.text\"` |\n| `response.reasoningPaths[0]` | `\"choices.0.message.reasoning_content\"` |\n| `response.usage` | `{\"$ref\":\"response.usage\"}` |\n| `response.errorPaths[0]` | `\"error.code\"` |\n| `response.messagePaths[0]` | `\"error.message\"` |\n\n## 兼容边界\n\n该包只代表 Antigravity 中转渠道的 OpenAI Chat Completions 线协议;其他端点或网关包装必须使用独立插件。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"antigravity-proxy\",\n \"name\": \"Antigravity Proxy\",\n \"version\": \"1.0.0\",\n \"author\": \"Yingce\",\n \"description\": \"Antigravity 中转渠道插件:OpenAI 兼容对话(gemini-3.8-flash-high / gemini-pro-agent)。\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"antigravity-chat\",\n \"label\": \"Antigravity Chat\",\n \"capabilities\": [\n \"text\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"http://103.242.14.110:8317\",\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"模型 ID(gemini-3.8-flash-high / gemini-pro-agent)。\"\n },\n {\n \"name\": \"messages\",\n \"type\": \"message[]\",\n \"required\": true,\n \"mapping\": \"provider message container\",\n \"description\": \"包含历史消息和当前用户输入。\"\n },\n {\n \"name\": \"instructions\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"system/instructions\",\n \"description\": \"系统指令。\"\n },\n {\n \"name\": \"temperature\",\n \"type\": \"number\",\n \"required\": false,\n \"mapping\": \"temperature\",\n \"description\": \"采样温度。\"\n },\n {\n \"name\": \"top_p\",\n \"type\": \"number\",\n \"required\": false,\n \"mapping\": \"top_p\",\n \"description\": \"核采样参数。\"\n },\n {\n \"name\": \"max_tokens\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"max_tokens/max_output_tokens\",\n \"description\": \"最大输出 token。\"\n },\n {\n \"name\": \"tools\",\n \"type\": \"array\",\n \"required\": false,\n \"mapping\": \"tools/toolConfig\",\n \"description\": \"工具定义。\"\n },\n {\n \"name\": \"tool_choice\",\n \"type\": \"object|string\",\n \"required\": false,\n \"mapping\": \"tool_choice\",\n \"description\": \"工具选择策略。\"\n },\n {\n \"name\": \"response_format\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"response_format/text\",\n \"description\": \"结构化输出配置。\"\n },\n {\n \"name\": \"stream\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"stream\",\n \"description\": \"流式开关;后台任务当前以最终响应归一。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/chat/completions\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"messages\": {\n \"$ref\": \"request.messages\"\n },\n \"temperature\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.temperature\"\n }\n },\n \"top_p\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.top_p\"\n }\n },\n \"max_tokens\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.extra.max_tokens\"\n },\n {\n \"$ref\": \"request.providerOptions.antigravity-chat.max_tokens\"\n }\n ]\n }\n },\n \"tools\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.tools\"\n }\n },\n \"tool_choice\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.tool_choice\"\n }\n },\n \"response_format\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.response_format\"\n }\n },\n \"stream\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.stream\"\n }\n },\n \"stop\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.stop\"\n }\n },\n \"seed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.seed\"\n }\n },\n \"frequency_penalty\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.frequency_penalty\"\n }\n },\n \"presence_penalty\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.presence_penalty\"\n }\n },\n \"user\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.antigravity-chat.user\"\n }\n }\n }\n },\n \"response\": {\n \"status\": \"succeeded\",\n \"textPaths\": [\n \"choices.0.message.content\",\n \"choices.0.text\"\n ],\n \"reasoningPaths\": [\n \"choices.0.message.reasoning_content\"\n ],\n \"usage\": {\n \"$ref\": \"response.usage\"\n },\n \"errorPaths\": [\n \"error.code\"\n ],\n \"messagePaths\": [\n \"error.message\"\n ]\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" } diff --git a/plugin-packages/src-metaso-h3/docs/interface.md b/plugin-packages/src-metaso-h3/docs/interface.md index 85cdb615a..57f64e02c 100644 --- a/plugin-packages/src-metaso-h3/docs/interface.md +++ b/plugin-packages/src-metaso-h3/docs/interface.md @@ -59,3 +59,379 @@ status 取值:queued / running / succeeded / failed / cancelled。 ## 错误 创建与查询均返回 OpenAI 风格错误体,含 `error.type` / `error.message` / `request_id`;余额不足为 402,速率限制为 429。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v2", + "id": "metaso-h3", + "name": "METASO MiniMax H3", + "version": "1.0.0", + "author": "METASO", + "description": "秘塔 METASO MiniMax H3 文生视频/图生视频协议插件(异步任务)。", + "permissions": [ + "generation.run", + "media.read" + ], + "configuration": { + "fields": [ + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } + ] + }, + "contributes": { + "providers": [ + { + "id": "metaso-h3", + "label": "METASO MiniMax H3", + "capabilities": [ + "video" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], + "baseUrl": "https://metaso.cn", + "auth": { + "type": "bearer", + "field": "apiKey" + }, + "requiresPublicMediaUrls": true, + "parameters": [ + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "视频模型 ID(MiniMax-H3 / MiniMax-H3-Max)。" + }, + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt/content/input", + "description": "视频提示词。" + }, + { + "name": "images", + "type": "media[]", + "required": false, + "mapping": "first/last/reference image", + "description": "显式 role 图片输入(首帧/尾帧/参考图)。" + }, + { + "name": "videos", + "type": "media[]", + "required": false, + "mapping": "reference video", + "description": "参考视频。" + }, + { + "name": "audios", + "type": "media[]", + "required": false, + "mapping": "reference audio/voice", + "description": "参考音频或音色。" + }, + { + "name": "duration", + "type": "integer", + "required": false, + "mapping": "duration/seconds", + "description": "时长秒数(4-15)。" + }, + { + "name": "aspectRatio", + "type": "string", + "required": false, + "mapping": "ratio/aspect_ratio/size", + "description": "画幅比例(16:9 等)。" + }, + { + "name": "resolution", + "type": "string", + "required": false, + "mapping": "resolution", + "description": "分辨率档位(768P / 2K)。" + }, + { + "name": "watermark", + "type": "boolean", + "required": false, + "mapping": "watermark", + "description": "AIGC 水印开关。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } + ], + "create": { + "method": "POST", + "path": "/api/minimax/v2/video_generation", + "contentType": "application/json", + "body": { + "model": { + "$coalesce": [ + { + "$ref": "request.model" + }, + "MiniMax-H3" + ] + }, + "content": { + "$concatArrays": [ + { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.prompt" + } + }, + 0 + ] + }, + "then": [ + { + "type": "text", + "text": { + "$ref": "request.prompt" + } + } + ], + "else": [] + } + }, + { + "$map": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "in": { + "type": "image_url", + "image_url": { + "url": { + "$coalesce": [ + { + "$ref": "media.url" + }, + { + "$ref": "media.dataUrl" + } + ] + } + }, + "role": { + "$omitEmpty": { + "$ref": "media.role" + } + } + } + } + }, + { + "$map": { + "from": { + "$ref": "request.videos" + }, + "as": "media", + "in": { + "type": "video_url", + "video_url": { + "url": { + "$coalesce": [ + { + "$ref": "media.url" + }, + { + "$ref": "media.dataUrl" + } + ] + } + }, + "role": { + "$omitEmpty": { + "$ref": "media.role" + } + } + } + } + }, + { + "$map": { + "from": { + "$ref": "request.audios" + }, + "as": "media", + "in": { + "type": "audio_url", + "audio_url": { + "url": { + "$coalesce": [ + { + "$ref": "media.url" + }, + { + "$ref": "media.dataUrl" + } + ] + } + }, + "role": { + "$omitEmpty": { + "$ref": "media.role" + } + } + } + } + } + ] + }, + "resolution": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.resolution" + }, + { + "$ref": "request.providerOptions.metaso-h3.resolution" + }, + "768P" + ] + } + }, + "duration": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.duration" + }, + { + "$ref": "request.providerOptions.metaso-h3.duration" + }, + 5 + ] + } + }, + "ratio": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.aspectRatio" + }, + { + "$ref": "request.providerOptions.metaso-h3.ratio" + }, + "16:9" + ] + } + }, + "aigc_watermark": { + "$omitEmpty": { + "$ref": "request.providerOptions.metaso-h3.aigc_watermark" + } + } + } + }, + "poll": { + "method": "GET", + "path": "/api/minimax/v2/query/video_generation/{{taskId}}" + }, + "response": { + "taskId": { + "$coalesce": [ + { + "$ref": "response.task_id" + }, + { + "$ref": "response.taskId" + }, + { + "$ref": "response.data.task_id" + } + ] + }, + "status": { + "$coalesce": [ + { + "$ref": "response.task.status" + }, + { + "$ref": "response.status" + }, + { + "$ref": "response.data.status" + }, + "pending" + ] + }, + "message": { + "$coalesce": [ + { + "$ref": "response.task.error.message" + }, + { + "$ref": "response.task.fail_reason" + }, + { + "$ref": "response.error.message" + }, + { + "$ref": "response.message" + } + ] + }, + "videos": { + "$coalesce": [ + { + "$ref": "response.task.content.url" + }, + { + "$ref": "response.task.content.video_url" + }, + { + "$ref": "response.content.url" + }, + { + "$ref": "response.data.url" + } + ] + }, + "usage": { + "$ref": "response.task.usage" + }, + "errorPaths": [ + "task.error.type", + "error.type", + "error.code" + ], + "resultEphemeral": true, + "messagePaths": [ + "task.error.message", + "error.message" + ] + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/src-metaso-h3/manifest.json b/plugin-packages/src-metaso-h3/manifest.json index 1a051962a..0e70020df 100644 --- a/plugin-packages/src-metaso-h3/manifest.json +++ b/plugin-packages/src-metaso-h3/manifest.json @@ -5,10 +5,18 @@ "version": "1.0.0", "author": "METASO", "description": "秘塔 METASO MiniMax H3 文生视频/图生视频协议插件(异步任务)。", - "permissions": ["generation.run", "media.read"], + "permissions": [ + "generation.run", + "media.read" + ], "configuration": { "fields": [ - { "name": "apiKey", "type": "secret", "label": "API Key", "required": true } + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } ] }, "contributes": { @@ -16,41 +24,262 @@ { "id": "metaso-h3", "label": "METASO MiniMax H3", - "capabilities": ["video"], - "scopes": ["admin.system-channel", "user.custom-channel", "canvas", "creation", "agent"], + "capabilities": [ + "video" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], "baseUrl": "https://metaso.cn", - "auth": { "type": "bearer", "field": "apiKey" }, + "auth": { + "type": "bearer", + "field": "apiKey" + }, "requiresPublicMediaUrls": true, "parameters": [ - { "name": "model", "type": "string", "required": true, "mapping": "model", "description": "视频模型 ID(MiniMax-H3 / MiniMax-H3-Max)。" }, - { "name": "prompt", "type": "string", "required": true, "mapping": "prompt/content/input", "description": "视频提示词。" }, - { "name": "images", "type": "media[]", "required": false, "mapping": "first/last/reference image", "description": "显式 role 图片输入(首帧/尾帧/参考图)。" }, - { "name": "videos", "type": "media[]", "required": false, "mapping": "reference video", "description": "参考视频。" }, - { "name": "audios", "type": "media[]", "required": false, "mapping": "reference audio/voice", "description": "参考音频或音色。" }, - { "name": "duration", "type": "integer", "required": false, "mapping": "duration/seconds", "description": "时长秒数(4-15)。" }, - { "name": "aspectRatio", "type": "string", "required": false, "mapping": "ratio/aspect_ratio/size", "description": "画幅比例(16:9 等)。" }, - { "name": "resolution", "type": "string", "required": false, "mapping": "resolution", "description": "分辨率档位(768P / 2K)。" }, - { "name": "watermark", "type": "boolean", "required": false, "mapping": "watermark", "description": "AIGC 水印开关。" }, - { "name": "providerOptions", "type": "object", "required": false, "mapping": "provider-specific fields", "description": "插件命名空间内的厂商扩展字段。" } + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "视频模型 ID(MiniMax-H3 / MiniMax-H3-Max)。" + }, + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt/content/input", + "description": "视频提示词。" + }, + { + "name": "images", + "type": "media[]", + "required": false, + "mapping": "first/last/reference image", + "description": "显式 role 图片输入(首帧/尾帧/参考图)。" + }, + { + "name": "videos", + "type": "media[]", + "required": false, + "mapping": "reference video", + "description": "参考视频。" + }, + { + "name": "audios", + "type": "media[]", + "required": false, + "mapping": "reference audio/voice", + "description": "参考音频或音色。" + }, + { + "name": "duration", + "type": "integer", + "required": false, + "mapping": "duration/seconds", + "description": "时长秒数(4-15)。" + }, + { + "name": "aspectRatio", + "type": "string", + "required": false, + "mapping": "ratio/aspect_ratio/size", + "description": "画幅比例(16:9 等)。" + }, + { + "name": "resolution", + "type": "string", + "required": false, + "mapping": "resolution", + "description": "分辨率档位(768P / 2K)。" + }, + { + "name": "watermark", + "type": "boolean", + "required": false, + "mapping": "watermark", + "description": "AIGC 水印开关。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "插件命名空间内的厂商扩展字段。" + } ], "create": { "method": "POST", "path": "/api/minimax/v2/video_generation", "contentType": "application/json", "body": { - "model": { "$coalesce": [ { "$ref": "request.model" }, "MiniMax-H3" ] }, - "content": { - "$concatArrays": [ - { "$if": { "condition": { "$gt": [ { "$len": { "$ref": "request.prompt" } }, 0 ] }, "then": [ { "type": "text", "text": { "$ref": "request.prompt" } } ], "else": [] } }, - { "$map": { "from": { "$ref": "request.images" }, "as": "media", "in": { "type": "image_url", "image_url": { "url": { "$coalesce": [ { "$ref": "media.url" }, { "$ref": "media.dataUrl" } ] } }, "role": { "$omitEmpty": { "$ref": "media.role" } } } } }, - { "$map": { "from": { "$ref": "request.videos" }, "as": "media", "in": { "type": "video_url", "video_url": { "url": { "$coalesce": [ { "$ref": "media.url" }, { "$ref": "media.dataUrl" } ] } }, "role": { "$omitEmpty": { "$ref": "media.role" } } } } }, - { "$map": { "from": { "$ref": "request.audios" }, "as": "media", "in": { "type": "audio_url", "audio_url": { "url": { "$coalesce": [ { "$ref": "media.url" }, { "$ref": "media.dataUrl" } ] } }, "role": { "$omitEmpty": { "$ref": "media.role" } } } } } + "model": { + "$coalesce": [ + { + "$ref": "request.model" + }, + "MiniMax-H3" ] }, - "resolution": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.resolution" }, { "$ref": "request.providerOptions.metaso-h3.resolution" }, "768P" ] } }, - "duration": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.duration" }, { "$ref": "request.providerOptions.metaso-h3.duration" }, 5 ] } }, - "ratio": { "$omitEmpty": { "$coalesce": [ { "$ref": "request.aspectRatio" }, { "$ref": "request.providerOptions.metaso-h3.ratio" }, "16:9" ] } }, - "aigc_watermark": { "$omitEmpty": { "$ref": "request.providerOptions.metaso-h3.aigc_watermark" } } + "content": { + "$concatArrays": [ + { + "$if": { + "condition": { + "$gt": [ + { + "$len": { + "$ref": "request.prompt" + } + }, + 0 + ] + }, + "then": [ + { + "type": "text", + "text": { + "$ref": "request.prompt" + } + } + ], + "else": [] + } + }, + { + "$map": { + "from": { + "$ref": "request.images" + }, + "as": "media", + "in": { + "type": "image_url", + "image_url": { + "url": { + "$coalesce": [ + { + "$ref": "media.url" + }, + { + "$ref": "media.dataUrl" + } + ] + } + }, + "role": { + "$omitEmpty": { + "$ref": "media.role" + } + } + } + } + }, + { + "$map": { + "from": { + "$ref": "request.videos" + }, + "as": "media", + "in": { + "type": "video_url", + "video_url": { + "url": { + "$coalesce": [ + { + "$ref": "media.url" + }, + { + "$ref": "media.dataUrl" + } + ] + } + }, + "role": { + "$omitEmpty": { + "$ref": "media.role" + } + } + } + } + }, + { + "$map": { + "from": { + "$ref": "request.audios" + }, + "as": "media", + "in": { + "type": "audio_url", + "audio_url": { + "url": { + "$coalesce": [ + { + "$ref": "media.url" + }, + { + "$ref": "media.dataUrl" + } + ] + } + }, + "role": { + "$omitEmpty": { + "$ref": "media.role" + } + } + } + } + } + ] + }, + "resolution": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.resolution" + }, + { + "$ref": "request.providerOptions.metaso-h3.resolution" + }, + "768P" + ] + } + }, + "duration": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.duration" + }, + { + "$ref": "request.providerOptions.metaso-h3.duration" + }, + 5 + ] + } + }, + "ratio": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.aspectRatio" + }, + { + "$ref": "request.providerOptions.metaso-h3.ratio" + }, + "16:9" + ] + } + }, + "aigc_watermark": { + "$omitEmpty": { + "$ref": "request.providerOptions.metaso-h3.aigc_watermark" + } + } } }, "poll": { @@ -60,41 +289,79 @@ "response": { "taskId": { "$coalesce": [ - { "$ref": "response.task_id" }, - { "$ref": "response.taskId" }, - { "$ref": "response.data.task_id" } + { + "$ref": "response.task_id" + }, + { + "$ref": "response.taskId" + }, + { + "$ref": "response.data.task_id" + } ] }, "status": { "$coalesce": [ - { "$ref": "response.task.status" }, - { "$ref": "response.status" }, - { "$ref": "response.data.status" }, + { + "$ref": "response.task.status" + }, + { + "$ref": "response.status" + }, + { + "$ref": "response.data.status" + }, "pending" ] }, "message": { "$coalesce": [ - { "$ref": "response.task.error.message" }, - { "$ref": "response.task.fail_reason" }, - { "$ref": "response.error.message" }, - { "$ref": "response.message" } + { + "$ref": "response.task.error.message" + }, + { + "$ref": "response.task.fail_reason" + }, + { + "$ref": "response.error.message" + }, + { + "$ref": "response.message" + } ] }, "videos": { "$coalesce": [ - { "$ref": "response.task.content.url" }, - { "$ref": "response.task.content.video_url" }, - { "$ref": "response.content.url" }, - { "$ref": "response.data.url" } + { + "$ref": "response.task.content.url" + }, + { + "$ref": "response.task.content.video_url" + }, + { + "$ref": "response.content.url" + }, + { + "$ref": "response.data.url" + } ] }, - "usage": { "$ref": "response.task.usage" }, - "errorPaths": ["task.error.type", "error.type", "error.code"], + "usage": { + "$ref": "response.task.usage" + }, + "errorPaths": [ + "task.error.type", + "error.type", + "error.code" + ], "resultEphemeral": true, - "messagePaths": ["task.error.message", "error.message"] + "messagePaths": [ + "task.error.message", + "error.message" + ] } } ] - } + }, + "documentation": "# METASO MiniMax H3\n\n秘塔 METASO MiniMax H3 文生视频 / 图生视频协议插件(异步任务模式)。\n\n## Provider\n\n| Provider | 能力 | 端点 |\n|---|---|---|\n| metaso-h3 | video | POST /api/minimax/v2/video_generation(创建)
GET /api/minimax/v2/query/video_generation/{taskId}(轮询) |\n\n## 配置\n\n在渠道中填写 API Key(METASO 控制台创建,形如 `mk-xxxxxxxx`),baseUrl 使用 `https://metaso.cn`。\n\n## 说明\n\n- 模型:`MiniMax-H3`(支持 768P / 2K,4-15 秒,文生图/图生视频/多模态参考),`MiniMax-H3-Max`(480P / 768P,5-15 秒)。\n- 文生视频:仅文本提示词,ratio 必填(21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16)。\n- 图生视频:图片 role 为 first_frame / last_frame(首尾帧)。\n- 多模态参考:图片 reference_image / 视频 reference_video / 音频 reference_audio。\n- 异步任务:创建后返回 task_id,轮询查询接口直到 status=succeeded,成片 URL 在 task.content.url。\n- 鉴权:`Authorization: Bearer mk-xxx`。\n\n---\n\n# METASO MiniMax H3 接口说明\n\n## 鉴权\n\n`Authorization: Bearer mk-xxxxxxxxxxxxxxxxxxxxxxxx`\n\n## 创建视频生成任务\n\nPOST `https://metaso.cn/api/minimax/v2/video_generation`\n\n请求体:\n\n```json\n{\n \"model\": \"MiniMax-H3\",\n \"content\": [\n { \"type\": \"text\", \"text\": \"视频描述\" },\n { \"type\": \"image_url\", \"image_url\": { \"url\": \"https://...\" }, \"role\": \"first_frame\" },\n { \"type\": \"video_url\", \"video_url\": { \"url\": \"https://...\" }, \"role\": \"reference_video\" },\n { \"type\": \"audio_url\", \"audio_url\": { \"url\": \"https://...\" }, \"role\": \"reference_audio\" }\n ],\n \"resolution\": \"768P\",\n \"duration\": 5,\n \"ratio\": \"16:9\"\n}\n```\n\n- content 必须包含一个非空 text 项。\n- 首尾帧(first_frame/last_frame)与多模态参考(reference_*)互斥。\n- 图片 ≤ 9 张、视频 ≤ 3 段、音频 ≤ 3 段;请求体总大小 ≤ 64 MB。\n\n响应:\n\n```json\n{ \"task_id\": \"424010985738629\" }\n```\n\n## 查询任务\n\nGET `https://metaso.cn/api/minimax/v2/query/video_generation/{task_id}`\n\n响应:\n\n```json\n{\n \"task\": {\n \"id\": \"424010985738629\",\n \"status\": \"succeeded\",\n \"content\": { \"url\": \"https://.../output.mp4\" },\n \"resolution\": \"2K\",\n \"duration\": 5,\n \"usage\": { \"total_seconds\": 5, \"output_seconds\": 5 }\n }\n}\n```\n\nstatus 取值:queued / running / succeeded / failed / cancelled。\n\n## 错误\n\n创建与查询均返回 OpenAI 风格错误体,含 `error.type` / `error.message` / `request_id`;余额不足为 402,速率限制为 429。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"metaso-h3\",\n \"name\": \"METASO MiniMax H3\",\n \"version\": \"1.0.0\",\n \"author\": \"METASO\",\n \"description\": \"秘塔 METASO MiniMax H3 文生视频/图生视频协议插件(异步任务)。\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"metaso-h3\",\n \"label\": \"METASO MiniMax H3\",\n \"capabilities\": [\n \"video\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://metaso.cn\",\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"requiresPublicMediaUrls\": true,\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"视频模型 ID(MiniMax-H3 / MiniMax-H3-Max)。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt/content/input\",\n \"description\": \"视频提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"first/last/reference image\",\n \"description\": \"显式 role 图片输入(首帧/尾帧/参考图)。\"\n },\n {\n \"name\": \"videos\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"reference video\",\n \"description\": \"参考视频。\"\n },\n {\n \"name\": \"audios\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"reference audio/voice\",\n \"description\": \"参考音频或音色。\"\n },\n {\n \"name\": \"duration\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"duration/seconds\",\n \"description\": \"时长秒数(4-15)。\"\n },\n {\n \"name\": \"aspectRatio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"ratio/aspect_ratio/size\",\n \"description\": \"画幅比例(16:9 等)。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution\",\n \"description\": \"分辨率档位(768P / 2K)。\"\n },\n {\n \"name\": \"watermark\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"watermark\",\n \"description\": \"AIGC 水印开关。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"插件命名空间内的厂商扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/api/minimax/v2/video_generation\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.model\"\n },\n \"MiniMax-H3\"\n ]\n },\n \"content\": {\n \"$concatArrays\": [\n {\n \"$if\": {\n \"condition\": {\n \"$gt\": [\n {\n \"$len\": {\n \"$ref\": \"request.prompt\"\n }\n },\n 0\n ]\n },\n \"then\": [\n {\n \"type\": \"text\",\n \"text\": {\n \"$ref\": \"request.prompt\"\n }\n }\n ],\n \"else\": []\n }\n },\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"request.images\"\n },\n \"as\": \"media\",\n \"in\": {\n \"type\": \"image_url\",\n \"image_url\": {\n \"url\": {\n \"$coalesce\": [\n {\n \"$ref\": \"media.url\"\n },\n {\n \"$ref\": \"media.dataUrl\"\n }\n ]\n }\n },\n \"role\": {\n \"$omitEmpty\": {\n \"$ref\": \"media.role\"\n }\n }\n }\n }\n },\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"request.videos\"\n },\n \"as\": \"media\",\n \"in\": {\n \"type\": \"video_url\",\n \"video_url\": {\n \"url\": {\n \"$coalesce\": [\n {\n \"$ref\": \"media.url\"\n },\n {\n \"$ref\": \"media.dataUrl\"\n }\n ]\n }\n },\n \"role\": {\n \"$omitEmpty\": {\n \"$ref\": \"media.role\"\n }\n }\n }\n }\n },\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"request.audios\"\n },\n \"as\": \"media\",\n \"in\": {\n \"type\": \"audio_url\",\n \"audio_url\": {\n \"url\": {\n \"$coalesce\": [\n {\n \"$ref\": \"media.url\"\n },\n {\n \"$ref\": \"media.dataUrl\"\n }\n ]\n }\n },\n \"role\": {\n \"$omitEmpty\": {\n \"$ref\": \"media.role\"\n }\n }\n }\n }\n }\n ]\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.resolution\"\n },\n {\n \"$ref\": \"request.providerOptions.metaso-h3.resolution\"\n },\n \"768P\"\n ]\n }\n },\n \"duration\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.duration\"\n },\n {\n \"$ref\": \"request.providerOptions.metaso-h3.duration\"\n },\n 5\n ]\n }\n },\n \"ratio\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.aspectRatio\"\n },\n {\n \"$ref\": \"request.providerOptions.metaso-h3.ratio\"\n },\n \"16:9\"\n ]\n }\n },\n \"aigc_watermark\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.metaso-h3.aigc_watermark\"\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/api/minimax/v2/query/video_generation/{{taskId}}\"\n },\n \"response\": {\n \"taskId\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.task_id\"\n },\n {\n \"$ref\": \"response.taskId\"\n },\n {\n \"$ref\": \"response.data.task_id\"\n }\n ]\n },\n \"status\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.task.status\"\n },\n {\n \"$ref\": \"response.status\"\n },\n {\n \"$ref\": \"response.data.status\"\n },\n \"pending\"\n ]\n },\n \"message\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.task.error.message\"\n },\n {\n \"$ref\": \"response.task.fail_reason\"\n },\n {\n \"$ref\": \"response.error.message\"\n },\n {\n \"$ref\": \"response.message\"\n }\n ]\n },\n \"videos\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.task.content.url\"\n },\n {\n \"$ref\": \"response.task.content.video_url\"\n },\n {\n \"$ref\": \"response.content.url\"\n },\n {\n \"$ref\": \"response.data.url\"\n }\n ]\n },\n \"usage\": {\n \"$ref\": \"response.task.usage\"\n },\n \"errorPaths\": [\n \"task.error.type\",\n \"error.type\",\n \"error.code\"\n ],\n \"resultEphemeral\": true,\n \"messagePaths\": [\n \"task.error.message\",\n \"error.message\"\n ]\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" } From fd4872a4bfcad2166432e978304d413cd3966de3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 21:10:29 +0800 Subject: [PATCH 02/20] =?UTF-8?q?chore(version):=20=E5=8F=91=E5=B8=83=20v1?= =?UTF-8?q?.7.0=EF=BC=88=E8=9E=8D=E5=90=88=20PR#535=20PoloX=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B=20+=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AE=9A=E5=88=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b37e0c6..93fdaffa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## v1.7.0 + +- 融合 PR#535 PoloX 图片编辑能力:图片编辑、去背景、图片文字识别与修改、图片标注编辑、AI 图层拆分、Cloud Agent 图片工具链及权限控制、WaveSpeed 异步图片编辑协议与插件。 +- 新增 wavespeed-image-edit / image-tools 插件;画布端新增图片编辑对话框、图层拆分对话框、文字编辑对话框;后端新增 wavespeed_image_edit 测试。 +- 同步融合官方 v1.5.1 release 源码,修复更新流程引用与前端重复 import。 +- 保留本地定制:PR#530 多维表格、自建插件(a6api/metaso-h3/antigravity-proxy/openai-images 渠道适配)、ddcat 多参考图尺寸校验、万有引力 lxmone 连接错误提示、模型 Description(schema v24)、postgres seccomp、Windows 启动脚本。 +- 镜像推送 `ghcr.io/15657306288/open-ai-canvas-web:1.7.0` 与 `ghcr.io/15657306288/open-ai-canvas-backend:1.7.0`。 + ## v1.6.0 - 融合官方 main 最新源码(banner 常驻通知、画布版本历史与恢复、提示词编辑、方舟 Agent Plan、支付对账导出)。 diff --git a/VERSION b/VERSION index b7c0a9b1d..a20e2d82d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.6.0 +v1.7.0 From 27bcca625a237031b87c102bddeddc5e83b371dd Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 13:35:15 +0800 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E8=A1=A8=E5=A2=9E=E5=8A=A0=E5=AE=8C=E6=95=B4=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=AE=BE=E7=BD=AE=E5=AF=B9=E8=AF=9D=E6=A1=86=EF=BC=8C?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=9B=BE=E4=B8=8A=E9=99=90=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E5=88=B010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BatchGenerationSettingsDialog 组件:模型选择 + 图像质量/尺寸/宽高比/透明背景/生成张数 - 替换原简单确认框为完整设置面板,用户可在生成前调整参数 - 参考图列上限从 6 提升到 10,列宽调整为 72px 以适配 --- .../batch-generation-settings-dialog.tsx | 101 ++++ web/src/lib/canvas/canvas-batch-table.ts | 6 +- web/src/pages/canvas/project.tsx | 14 +- .../pages/canvas/use-canvas-batch-table.ts | 443 ++++++++++-------- 4 files changed, 360 insertions(+), 204 deletions(-) create mode 100644 web/src/components/canvas/batch-generation-settings-dialog.tsx diff --git a/web/src/components/canvas/batch-generation-settings-dialog.tsx b/web/src/components/canvas/batch-generation-settings-dialog.tsx new file mode 100644 index 000000000..49df76d5c --- /dev/null +++ b/web/src/components/canvas/batch-generation-settings-dialog.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { Button, Modal } from "antd"; +import { WandSparkles, X } from "lucide-react"; + +import { ImageSettingsPanel } from "@/components/image-settings-panel"; +import { ModelPicker } from "@/components/model-picker"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { defaultImageParamsForModel } from "@/lib/model-selection"; +import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; +import type { AiConfig } from "@/stores/use-config-store"; + +export type BatchGenerationSettings = Pick; + +type BatchGenerationSettingsDialogProps = { + open: boolean; + config: AiConfig; + rowCount: number; + concurrency: number; + onClose: () => void; + onConfirm: (settings: BatchGenerationSettings) => void; +}; + +export function BatchGenerationSettingsDialog({ open, config, rowCount, concurrency, onClose, onConfirm }: BatchGenerationSettingsDialogProps) { + const theme = canvasThemes[useActiveTheme()]; + const [generationConfig, setGenerationConfig] = useState(config); + + useEffect(() => { + if (open) setGenerationConfig(config); + }, [config, open]); + + const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => { + setGenerationConfig((current) => ({ ...current, [key]: value })); + }; + + const handleModelChange = (model: string) => { + setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) })); + }; + + const imageModel = generationConfig.imageModel || generationConfig.model; + + return ( + +
+
+ 共 {rowCount} 个未完成任务 · 并发上限 {concurrency} +
+ +
+
生成模型
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts index d7bddfd0b..e61764fa9 100644 --- a/web/src/lib/canvas/canvas-batch-table.ts +++ b/web/src/lib/canvas/canvas-batch-table.ts @@ -1,4 +1,4 @@ -import { nanoid } from "nanoid"; +import { nanoid } from "nanoid"; import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasNodeData } from "@/types/canvas"; @@ -7,7 +7,7 @@ export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图 export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:"; export const BATCH_REFERENCE_HANDLE_TOP = 112; export const BATCH_REFERENCE_HANDLE_GAP = 38; -export const MAX_BATCH_REFERENCE_COLUMNS = 6; +export const MAX_BATCH_REFERENCE_COLUMNS = 10; export const MAX_BATCH_TEXT_COLUMNS = 4; export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:"; @@ -15,7 +15,7 @@ export function batchGridTemplateColumns(referenceCount: number, textCount: numb return [ "36px", "68px", - `repeat(${referenceCount}, 88px)`, + `repeat(\${referenceCount}, 72px)`, textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined, "minmax(280px, 1fr)", "148px", diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 322ae02e4..e9961393a 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -1,4 +1,4 @@ -import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; +import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -110,6 +110,7 @@ import { TapNowImportDialog } from "./components/tapnow-import-dialog"; import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; +import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog"; import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar"; import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs"; import { CanvasProjectWorldLayers } from "./canvas-project-world-layers"; @@ -1910,7 +1911,7 @@ function InfiniteCanvasPage() { handleGenerateNode, }); - const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({ + const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow, batchGenDialogOpen, batchGenDialogRowCount, batchGenDialogConcurrency, batchGenDialogConfig, closeBatchGenDialog, confirmBatchGenDialog } = useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, @@ -3176,6 +3177,15 @@ function InfiniteCanvasPage() { config={effectiveConfig} /> + + >; - setConnections: Dispatch>; - setSelectedNodeIds: Dispatch>>; - enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined; -}; - -export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) { - const { message, modal } = App.useApp(); - const effectiveConfig = useEffectiveConfig(); - const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); - - const patchTable = useCallback((nodeId: string, patch: Partial) => { - setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, rows: [], ...node.metadata?.batchTable, ...patch } } })); - }, [setNodes]); - - const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial) => { - const node = nodesRef.current.find((item) => item.id === nodeId); - if (!node?.metadata?.batchTable) return; - patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) }); - }, [nodesRef, patchTable]); - - const addRow = useCallback((nodeId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] }); - }, [nodesRef, patchTable]); - - const removeRow = useCallback((nodeId: string, rowId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) }); - }, [nodesRef, patchTable]); - - const addReferenceColumn = useCallback((nodeId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const columns = batchReferenceColumns(table); - if (columns.length >= 6) return message.info("最多支持 6 组参考图"); - const nextIndex = columns.length + 1; - patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); - }, [message, nodesRef, patchTable]); - - const addTextColumn = useCallback((nodeId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const columns = table.textColumns || []; - if (columns.length >= 4) return message.info("最多支持 4 组文字"); - patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] }); - }, [message, nodesRef, patchTable]); - - const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => { - const node = nodesRef.current.find((item) => item.id === nodeId); - const table = node?.metadata?.batchTable; - if (!node || !table) return false; - const nodeById = new Map(nodesRef.current.map((item) => [item.id, item])); - const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { - const input = nodeById.get(inputNodeId); - return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey); - })); - if (!columns.some((column) => column.length)) { - if (!silent) message.warning("请先把图片节点连接到批量创作表"); - return false; - } - const rows = createBatchRowsFromColumns(table.operation, columns, table.rows); - const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { - const input = nodeById.get(inputNodeId); - return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt); - })); - const rowsWithText = rows.map((row, index) => ({ - ...row, - textNodeIds: textColumns.flatMap((column) => { - const input = column.length === 1 ? column[0] : column[index]; - return input ? [input] : []; - }), - })); - if (!rows.length) { - if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); - return false; - } - const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText); - if (!rowsChanged) return false; - patchTable(nodeId, { rows: rowsWithText }); - if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`); - return true; - }, [connectionsRef, message, nodesRef, patchTable]); - - const fillRowsFromConnections = useCallback((nodeId: string) => { - syncRowsFromConnections(nodeId); - }, [syncRowsFromConnections]); - - const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId); - if (nextTable !== table) patchTable(nodeId, nextTable); - }, [nodesRef, patchTable]); - - const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex); - if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows }); - }, [nodesRef, patchTable]); - - const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => { - const sourceNode = nodesRef.current.find((item) => item.id === nodeId); - const table = sourceNode?.metadata?.batchTable; - if (!sourceNode || !table) return; - const imageModel = effectiveConfig.imageModel || effectiveConfig.model; - if (!isAiConfigReady(effectiveConfig, imageModel)) { - navigateToSettings({ continueCreation: true }); - return; - } - const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId))); - const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null; - const rows = table.rows.filter((row) => { - if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false; - if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false; - if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false; - const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined; - return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id)); - }); - if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词"); - const confirmed = await new Promise((resolve) => modal.confirm({ - title: `确认提交 ${rows.length} 个批量图片任务`, - content: `模型:${modelDisplayName(effectiveConfig, imageModel)};并发上限:${table.concurrency}。这些任务可能消耗积分或产生外部模型费用。`, - okText: "确认生成", - cancelText: "取消", - centered: true, - onOk: () => resolve(true), - onCancel: () => resolve(false), - })); - if (!confirmed) return; - - const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; - const nextNodes = [...nodesRef.current]; - let nextConnections = [...connectionsRef.current]; - const outputByRowId = new Map(); - const targets: Array<{ rowId: string; nodeId: string }> = []; - rows.forEach((row, index) => { - const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1; - const prompt = batchPromptForRow(table, row).trim(); - const metadata = { - ...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}), - prompt, - composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => { - const textNode = nodesRef.current.find((node) => node.id === textNodeId); - return textNode?.metadata?.content || textNode?.metadata?.prompt || ""; - })].filter(Boolean).join("\n\n"), - model: buildGenerationConfig(effectiveConfig, undefined, "image").model, - generationMode: "image" as const, - generationType: "edit" as const, - workflowKind: "final" as const, - workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`, - status: "idle" as const, - batchSourceNodeId: nodeId, - batchRowId: row.id, - batchOperation: table.operation, - batchInputNodeIds: row.inputNodeIds, - }; - const output = existingIndex >= 0 - ? { ...nextNodes[existingIndex], metadata } - : createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata); - output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`; - if (existingIndex >= 0) nextNodes[existingIndex] = output; - else nextNodes.push(output); - nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id); - row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id })); - nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id }); - outputByRowId.set(row.id, output.id); - targets.push({ rowId: row.id, nodeId: output.id }); - }); - const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id); - nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } }; - nodesRef.current = nextNodes; - connectionsRef.current = nextConnections; - setNodes(nextNodes); - setConnections(nextConnections); - setSelectedNodeIds(new Set(targets.map((target) => target.nodeId))); - if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`); - }, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]); - - return { addReferenceColumn, addTextColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow }; -} +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { App } from "antd"; +import { nanoid } from "nanoid"; + +import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { MAX_BATCH_REFERENCE_COLUMNS, batchInputColumns, batchPromptForRow, batchReferenceColumns, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table"; +import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; +import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; +import { navigateToSettings } from "@/lib/settings-navigation"; +import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas"; +import type { BatchGenerationSettings } from "@/components/canvas/batch-generation-settings-dialog"; + +type Options = { + nodesRef: { current: CanvasNodeData[] }; + connectionsRef: { current: CanvasConnection[] }; + setNodes: Dispatch>; + setConnections: Dispatch>; + setSelectedNodeIds: Dispatch>>; + enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined; +}; + +type PendingBatchGen = { + nodeId: string; + rows: CanvasBatchRow[]; + concurrency: number; +}; + +export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) { + const { message } = App.useApp(); + const effectiveConfig = useEffectiveConfig(); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + + const [batchGenDialog, setBatchGenDialog] = useState<{ open: boolean; pending: PendingBatchGen | null }>({ open: false, pending: null }); + + const patchTable = useCallback((nodeId: string, patch: Partial) => { + setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, rows: [], ...node.metadata?.batchTable, ...patch } } })); + }, [setNodes]); + + const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + if (!node?.metadata?.batchTable) return; + patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) }); + }, [nodesRef, patchTable]); + + const addRow = useCallback((nodeId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] }); + }, [nodesRef, patchTable]); + + const removeRow = useCallback((nodeId: string, rowId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) }); + }, [nodesRef, patchTable]); + + const addReferenceColumn = useCallback((nodeId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const columns = batchReferenceColumns(table); + if (columns.length >= MAX_BATCH_REFERENCE_COLUMNS) return message.info("最多支持 10 组参考图"); + const nextIndex = columns.length + 1; + patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); + }, [message, nodesRef, patchTable]); + + const addTextColumn = useCallback((nodeId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const columns = table.textColumns || []; + if (columns.length >= 4) return message.info("最多支持 4 组文字"); + patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] }); + }, [message, nodesRef, patchTable]); + + const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + const table = node?.metadata?.batchTable; + if (!node || !table) return false; + const nodeById = new Map(nodesRef.current.map((item) => [item.id, item])); + const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { + const input = nodeById.get(inputNodeId); + return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey); + })); + if (!columns.some((column) => column.length)) { + if (!silent) message.warning("请先把图片节点连接到批量创作表"); + return false; + } + const rows = createBatchRowsFromColumns(table.operation, columns, table.rows); + const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { + const input = nodeById.get(inputNodeId); + return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt); + })); + const rowsWithText = rows.map((row, index) => ({ + ...row, + textNodeIds: textColumns.flatMap((column) => { + const input = column.length === 1 ? column[0] : column[index]; + return input ? [input] : []; + }), + })); + if (!rows.length) { + if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); + return false; + } + const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText); + if (!rowsChanged) return false; + patchTable(nodeId, { rows: rowsWithText }); + if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`); + return true; + }, [connectionsRef, message, nodesRef, patchTable]); + + const fillRowsFromConnections = useCallback((nodeId: string) => { + syncRowsFromConnections(nodeId); + }, [syncRowsFromConnections]); + + const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId); + if (nextTable !== table) patchTable(nodeId, nextTable); + }, [nodesRef, patchTable]); + + const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex); + if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows }); + }, [nodesRef, patchTable]); + + const executeBatchGeneration = useCallback((pending: PendingBatchGen, settings: BatchGenerationSettings) => { + const { nodeId, rows, concurrency } = pending; + const sourceNode = nodesRef.current.find((item) => item.id === nodeId); + const table = sourceNode?.metadata?.batchTable; + if (!sourceNode || !table) return; + + const mergedConfig = { ...effectiveConfig, ...settings }; + + const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const nextNodes = [...nodesRef.current]; + let nextConnections = [...connectionsRef.current]; + const outputByRowId = new Map(); + const targets: Array<{ rowId: string; nodeId: string }> = []; + rows.forEach((row, index) => { + const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1; + const prompt = batchPromptForRow(table, row).trim(); + const metadata = { + ...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}), + prompt, + composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => { + const textNode = nodesRef.current.find((node) => node.id === textNodeId); + return textNode?.metadata?.content || textNode?.metadata?.prompt || ""; + })].filter(Boolean).join("\n\n"), + model: buildGenerationConfig(mergedConfig, undefined, "image").model, + size: mergedConfig.size, + quality: mergedConfig.quality, + transparentBackground: mergedConfig.transparentBackground, + count: Number(mergedConfig.count) || 1, + generationMode: "image" as const, + generationType: "edit" as const, + workflowKind: "final" as const, + workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`, + status: "idle" as const, + batchSourceNodeId: nodeId, + batchRowId: row.id, + batchOperation: table.operation, + batchInputNodeIds: row.inputNodeIds, + }; + const output = existingIndex >= 0 + ? { ...nextNodes[existingIndex], metadata } + : createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata); + output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`; + if (existingIndex >= 0) nextNodes[existingIndex] = output; + else nextNodes.push(output); + nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id); + row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id })); + nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id }); + outputByRowId.set(row.id, output.id); + targets.push({ rowId: row.id, nodeId: output.id }); + }); + const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id); + nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } }; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + setSelectedNodeIds(new Set(targets.map((target) => target.nodeId))); + if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency })) message.success(`${targets.length} 个任务已加入并发队列`); + }, [connectionsRef, effectiveConfig, enqueueGenerationBatch, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]); + + const generateRows = useCallback((nodeId: string, requestedRowIds?: string[]) => { + const sourceNode = nodesRef.current.find((item) => item.id === nodeId); + const table = sourceNode?.metadata?.batchTable; + if (!sourceNode || !table) return; + const imageModel = effectiveConfig.imageModel || effectiveConfig.model; + if (!isAiConfigReady(effectiveConfig, imageModel)) { + navigateToSettings({ continueCreation: true }); + return; + } + const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId))); + const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null; + const rows = table.rows.filter((row) => { + if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false; + if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false; + if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false; + const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined; + return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id)); + }); + if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词"); + + setBatchGenDialog({ open: true, pending: { nodeId, rows, concurrency: table.concurrency } }); + }, [effectiveConfig, isAiConfigReady, message, nodesRef]); + + const closeBatchGenDialog = useCallback(() => { + setBatchGenDialog({ open: false, pending: null }); + }, []); + + const confirmBatchGenDialog = useCallback((settings: BatchGenerationSettings) => { + if (batchGenDialog.pending) { + executeBatchGeneration(batchGenDialog.pending, settings); + } + setBatchGenDialog({ open: false, pending: null }); + }, [batchGenDialog.pending, executeBatchGeneration]); + + const dialogConfig = useMemo(() => effectiveConfig, [effectiveConfig]); + + return { + addReferenceColumn, + addTextColumn, + addRow, + fillRowsFromConnections, + generateRows, + moveReferenceCell, + patchTable, + removeRow, + reorderReferenceColumns, + syncRowsFromConnections, + updateRow, + batchGenDialogOpen: batchGenDialog.open, + batchGenDialogRowCount: batchGenDialog.pending?.rows.length ?? 0, + batchGenDialogConcurrency: batchGenDialog.pending?.concurrency ?? 1, + batchGenDialogConfig: dialogConfig, + closeBatchGenDialog, + confirmBatchGenDialog, + }; +} From 6f1b45777ba5a5f1d35dae10c559f224a0df4caf Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:00:00 +0800 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DbatchGridTemplat?= =?UTF-8?q?eColumns=E4=B8=AD=E6=A8=A1=E6=9D=BF=E5=8F=98=E9=87=8F=E8=A2=AB?= =?UTF-8?q?=E8=BD=AC=E4=B9=89=E5=AF=BC=E8=87=B4grid=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E9=94=99=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/lib/canvas/canvas-batch-table.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts index e61764fa9..b1c765dc4 100644 --- a/web/src/lib/canvas/canvas-batch-table.ts +++ b/web/src/lib/canvas/canvas-batch-table.ts @@ -15,7 +15,7 @@ export function batchGridTemplateColumns(referenceCount: number, textCount: numb return [ "36px", "68px", - `repeat(\${referenceCount}, 72px)`, + `repeat(${referenceCount}, 72px)`, textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined, "minmax(280px, 1fr)", "148px", From e921deac741187971fab2ede4ebe2e72d1cdf296 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:25:24 +0800 Subject: [PATCH 05/20] feat: add list mode for AI-powered batch table generation - Add text/list toggle in prompt panel - List mode: AI analyzes connected images + prompt, outputs structured JSON table - Batch table node renders AI-generated text columns as read-only content - Column names and row count determined by AI (3-6 columns, N rows) --- .../canvas/canvas-batch-table-node.tsx | 1167 +-- .../canvas/canvas-node-prompt-panel.tsx | 2250 +++--- web/src/pages/canvas/list-mode-generator.ts | 204 + web/src/pages/canvas/project.tsx | 6461 +++++++++-------- web/src/types/canvas.ts | 1176 +-- 5 files changed, 5762 insertions(+), 5496 deletions(-) create mode 100644 web/src/pages/canvas/list-mode-generator.ts diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index e5e55cc97..baaa29ce3 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,580 +1,587 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { Button, Input, Select, Switch, Tooltip } from "antd"; -import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; - -import { CachedResourceImage } from "@/components/cached-resource-image"; -import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; -import type { CanvasTheme } from "@/lib/canvas-theme"; -import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas"; - -type Props = { - node: CanvasNodeData; - nodes: CanvasNodeData[]; - connections: CanvasConnection[]; - batch?: CanvasGenerationBatch; - theme: CanvasTheme; - onPatchTable: (patch: Partial) => void; - onAddRow: () => void; - onRemoveRow: (rowId: string) => void; - onUpdateRow: (rowId: string, patch: Partial) => void; - onFillRows: () => void; - onGenerate: (rowIds?: string[]) => void; - onRetryItem: (batchId: string, itemId: string) => void; - onAddReferenceColumn: () => void; - onAddTextColumn: () => void; - onReorderReferenceColumns: (fromColumnId: string, toColumnId: string) => void; - onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; - onReplaceReference: (node: CanvasNodeData) => void; - onUploadReference: (rowId: string, columnIndex: number, file: File) => void; - onConnectStart: (event: ReactPointerEvent, handleId: string) => void; - onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; - readOnly?: boolean; -}; - -const OPERATION_OPTIONS = [ - { value: "try_on", label: "批量换装" }, - { value: "creative", label: "创意生图" }, -] satisfies Array<{ value: CanvasBatchOperation; label: string }>; - -type ReferenceCell = { rowId: string; columnIndex: number }; - -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { - const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; - const referenceColumns = batchReferenceColumns(table); - const textColumns = batchTextColumns(table); - const globalPrompt = table.globalPrompt || ""; - const hasGlobalPrompt = Boolean(globalPrompt.trim()); - const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); - const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); - const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); - const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; - const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); - const [draggingColumnId, setDraggingColumnId] = useState(null); - const [draggingCell, setDraggingCell] = useState(null); - const [dropCell, setDropCell] = useState(null); - const draggingCellRef = useRef(null); - const dropCellRef = useRef(null); - const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); - const lastPointerRef = useRef({ x: 0, y: 0 }); - const suppressClickRef = useRef(false); - const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); - const uploadInputRef = useRef(null); - - const clearReferenceDrag = useCallback(() => { - dragStartRef.current = null; - draggingCellRef.current = null; - dropCellRef.current = null; - setDraggingCell(null); - setDropCell(null); - }, []); - - const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => { - const hit = document.elementsFromPoint(clientX, clientY) - .map((element) => element.closest("[data-batch-reference-cell]")) - .find((element): element is HTMLElement => Boolean(element)); - if (!hit) return null; - const rowId = hit.dataset.batchRowId; - const columnIndex = Number(hit.dataset.batchColumnIndex); - return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null; - }, []); - - const updateReferenceDrag = useCallback((clientX: number, clientY: number) => { - const start = dragStartRef.current; - if (!start || readOnly) return; - const distance = Math.hypot(clientX - start.x, clientY - start.y); - if (!draggingCellRef.current && distance < 5) return; - if (!draggingCellRef.current) { - draggingCellRef.current = start.cell; - setDraggingCell(start.cell); - suppressClickRef.current = true; - } - const nextDropCell = findReferenceCellAtPoint(clientX, clientY); - dropCellRef.current = nextDropCell; - setDropCell(nextDropCell); - }, [findReferenceCellAtPoint, readOnly]); - - const finishReferenceDrag = useCallback((clientX: number, clientY: number, cancel = false) => { - const start = dragStartRef.current; - if (!start) return; - const sourceCell = draggingCellRef.current; - const targetCell = cancel ? null : dropCellRef.current || findReferenceCellAtPoint(clientX, clientY); - if (sourceCell && targetCell) { - onMoveReferenceCell(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); - } - clearReferenceDrag(); - window.setTimeout(() => { suppressClickRef.current = false; }, 0); - }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell]); - - const cancelReferenceDrag = useCallback(() => { - if (!dragStartRef.current) return; - clearReferenceDrag(); - window.setTimeout(() => { suppressClickRef.current = false; }, 0); - }, [clearReferenceDrag]); - - useEffect(() => { - const handleWindowPointerMove = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || event.pointerId !== start.pointerId) return; - const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); - if (distance >= 5) event.preventDefault(); - updateReferenceDrag(event.clientX, event.clientY); - }; - const handleWindowPointerUp = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || event.pointerId !== start.pointerId) return; - finishReferenceDrag(event.clientX, event.clientY); - }; - const handleWindowPointerCancel = (event: PointerEvent) => { - const start = dragStartRef.current; - if (!start || event.pointerId !== start.pointerId) return; - cancelReferenceDrag(); - }; - const handleWindowMouseUp = () => { - if (!dragStartRef.current) return; - finishReferenceDrag(lastPointerRef.current.x, lastPointerRef.current.y); - }; - const handleWindowBlur = () => cancelReferenceDrag(); - window.addEventListener("pointermove", handleWindowPointerMove, { passive: false }); - window.addEventListener("pointerup", handleWindowPointerUp, true); - window.addEventListener("pointercancel", handleWindowPointerCancel, true); - window.addEventListener("mouseup", handleWindowMouseUp, true); - window.addEventListener("blur", handleWindowBlur); - return () => { - window.removeEventListener("pointermove", handleWindowPointerMove); - window.removeEventListener("pointerup", handleWindowPointerUp, true); - window.removeEventListener("pointercancel", handleWindowPointerCancel, true); - window.removeEventListener("mouseup", handleWindowMouseUp, true); - window.removeEventListener("blur", handleWindowBlur); - }; - }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); - - const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { - if (readOnly || !hasMaterial) return; - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - lastPointerRef.current = { x: event.clientX, y: event.clientY }; - dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; - suppressClickRef.current = false; - }, [readOnly]); - - const handleReferencePointerMove = useCallback((event: ReactPointerEvent) => { - event.stopPropagation(); - lastPointerRef.current = { x: event.clientX, y: event.clientY }; - updateReferenceDrag(event.clientX, event.clientY); - }, [updateReferenceDrag]); - - const handleReferencePointerUp = useCallback((event: ReactPointerEvent) => { - event.stopPropagation(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); - finishReferenceDrag(event.clientX, event.clientY); - }, [finishReferenceDrag]); - - const handleReferencePointerCancel = useCallback((event: ReactPointerEvent) => { - event.stopPropagation(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); - cancelReferenceDrag(); - }, [cancelReferenceDrag]); - - return ( -
event.stopPropagation()} - onMouseDown={(event) => { - // Only the top padding is a node drag surface; keep child controls isolated. - if (event.target !== event.currentTarget) event.stopPropagation(); - }} - > - { - const file = event.currentTarget.files?.[0]; - const target = uploadTargetRef.current; - uploadTargetRef.current = null; - event.currentTarget.value = ""; - if (file && target) onUploadReference(target.rowId, target.columnIndex, file); - }} - /> - {!readOnly ? : null} -
event.stopPropagation()} - > - ({ value, label: String(value) }))} - disabled={readOnly} - onChange={(concurrency) => onPatchTable({ concurrency })} - className="w-20" - aria-label="并发数" - /> - - {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} - - {!readOnly ? ( -
- 全局提示词 - onPatchTable({ globalPrompt: event.target.value })} - /> - {hasGlobalPrompt ? 已覆盖全部 : null} -
- ) : null} - {!readOnly ? ( -
- - - -
- ) : null} -
- -
event.stopPropagation()}> -
- # - 任务 - {referenceColumns.map((column) => ( - setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} /> - ))} - {textColumns.map((column, index) => ( - {column.label} - ))} - 任务提示词 - 生成结果 - -
- {table.rows.length ? ( - table.rows.map((row, index) => { - const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; - const item = batchItemByRowId.get(row.id); - const status = rowStatus(item, output); - return ( -
- {index + 1} -
- onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> - {row.enabled === false ? "关" : "开"} -
- {referenceColumns.map((column, columnIndex) => ( - { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} - onPointerDown={handleReferencePointerDown} - onPointerMove={handleReferencePointerMove} - onPointerUp={handleReferencePointerUp} - onPointerCancel={handleReferencePointerCancel} - onLostPointerCapture={handleReferencePointerCancel} - /> - ))} - {textColumns.map((column, columnIndex) => ( -
- -
- ))} - onUpdateRow(row.id, { prompt: event.target.value })} /> -
- {output?.metadata?.content || output?.metadata?.storageKey ? ( - } - /> - ) : ( - - )} -
-
{status.label}
- {!readOnly && status.retryable && batch && item ? ( - - ) : null} -
- {status.loading ? : null} -
- {!readOnly ? ( - -
- ); - }) - ) : ( -
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
- )} -
-
- ); -} - -function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) { - return ( - { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", column.id); - onDragStart(); - }} - onDragEnd={onDragEnd} - onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} - onDrop={(event) => { event.preventDefault(); onDrop(column.id); }} - > - {!readOnly ? : null} - {column.label} - - ); -} - -function BatchTextHandle(props: Omit, "column"> & { column: { id: string; label: string } }) { - return ; -} - -function BatchReferenceHandles({ - columns, - textColumns, - theme, - onAdd, - onAddText, - onConnectStart, - onConnectDrop, -}: { - columns: ReturnType; - textColumns: ReturnType; - theme: CanvasTheme; - onAdd: () => void; - onAddText: () => void; - onConnectStart: (event: ReactPointerEvent, handleId: string) => void; - onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; -}) { - const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" }; - const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP; - const textTop = batchTextHandleTop(columns.length); - return ( - <> - {columns.map((column, index) => ( - - ))} - {textColumns.map((column, index) => ( - - ))} - {columns.length < MAX_BATCH_REFERENCE_COLUMNS ? ( - - - - ) : null} - {textColumns.length < MAX_BATCH_TEXT_COLUMNS ? ( - - - - ) : null} - - ); -} - -function BatchReferenceHandle({ - column, - top, - badge, - theme, - commonStyle, - onConnectStart, - onConnectDrop, - handlePrefix = "reference", -}: { - column: { id: string; label: string }; - top: number; - badge: string; - theme: CanvasTheme; - commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; - onConnectStart: (event: ReactPointerEvent, handleId: string) => void; - onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; - handlePrefix?: "reference" | "text"; -}) { - const [hovered, setHovered] = useState(false); - const [offset, setOffset] = useState({ x: 0, y: 0 }); - const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id); - const reset = useCallback(() => { - setHovered(false); - setOffset({ x: 0, y: 0 }); - }, []); - const update = useCallback((event: ReactPointerEvent) => { - const bounds = event.currentTarget.getBoundingClientRect(); - const dx = event.clientX - (bounds.left + bounds.width / 2); - const dy = event.clientY - (bounds.top + bounds.height / 2); - const limit = 16; - setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); - }, []); - return ( - - - - ); -} -function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) { - const content = node?.metadata?.content || node?.metadata?.prompt; - return
{content || "未连接文字"}
; -} - -function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { - node?: CanvasNodeData; - theme: CanvasTheme; - readOnly: boolean; - rowId: string; - columnIndex: number; - columnId: string; - draggingColumnId: string | null; - isDraggingCell: boolean; - isDropTarget: boolean; - suppressClickRef: MutableRefObject; - onReplace: (node: CanvasNodeData) => void; - onUpload: () => void; - onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; - onLostPointerCapture: (event: ReactPointerEvent) => void; -}) { - const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) }; - const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined; - if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) { - return ( - - - - ); - } - const fallback = ; - return ( - - - - ); -} - -function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode { - return ( -
- -
- ); -} - -function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { - if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false }; - if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; - if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; - if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) - return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; - if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; - return { label: "待生成", loading: false, retryable: false }; -} +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { Button, Input, Select, Switch, Tooltip } from "antd"; +import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; + +import { CachedResourceImage } from "@/components/cached-resource-image"; +import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; +import type { CanvasTheme } from "@/lib/canvas-theme"; +import type { CanvasBatchOperation, CanvasBatchRow, CanvasBatchTableData, CanvasConnection, CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasNodeData } from "@/types/canvas"; + +type Props = { + node: CanvasNodeData; + nodes: CanvasNodeData[]; + connections: CanvasConnection[]; + batch?: CanvasGenerationBatch; + theme: CanvasTheme; + onPatchTable: (patch: Partial) => void; + onAddRow: () => void; + onRemoveRow: (rowId: string) => void; + onUpdateRow: (rowId: string, patch: Partial) => void; + onFillRows: () => void; + onGenerate: (rowIds?: string[]) => void; + onRetryItem: (batchId: string, itemId: string) => void; + onAddReferenceColumn: () => void; + onAddTextColumn: () => void; + onReorderReferenceColumns: (fromColumnId: string, toColumnId: string) => void; + onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; + onReplaceReference: (node: CanvasNodeData) => void; + onUploadReference: (rowId: string, columnIndex: number, file: File) => void; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; + readOnly?: boolean; +}; + +const OPERATION_OPTIONS = [ + { value: "try_on", label: "批量换装" }, + { value: "creative", label: "创意生图" }, +] satisfies Array<{ value: CanvasBatchOperation; label: string }>; + +type ReferenceCell = { rowId: string; columnIndex: number }; + +export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { + const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; + const referenceColumns = batchReferenceColumns(table); + const textColumns = batchTextColumns(table); + const globalPrompt = table.globalPrompt || ""; + const hasGlobalPrompt = Boolean(globalPrompt.trim()); + const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); + const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); + const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); + const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; + const isAiTable = Boolean(table.aiGenerated); + const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); + const [draggingColumnId, setDraggingColumnId] = useState(null); + const [draggingCell, setDraggingCell] = useState(null); + const [dropCell, setDropCell] = useState(null); + const draggingCellRef = useRef(null); + const dropCellRef = useRef(null); + const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const suppressClickRef = useRef(false); + const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); + const uploadInputRef = useRef(null); + + const clearReferenceDrag = useCallback(() => { + dragStartRef.current = null; + draggingCellRef.current = null; + dropCellRef.current = null; + setDraggingCell(null); + setDropCell(null); + }, []); + + const findReferenceCellAtPoint = useCallback((clientX: number, clientY: number) => { + const hit = document.elementsFromPoint(clientX, clientY) + .map((element) => element.closest("[data-batch-reference-cell]")) + .find((element): element is HTMLElement => Boolean(element)); + if (!hit) return null; + const rowId = hit.dataset.batchRowId; + const columnIndex = Number(hit.dataset.batchColumnIndex); + return rowId && Number.isInteger(columnIndex) ? { rowId, columnIndex } : null; + }, []); + + const updateReferenceDrag = useCallback((clientX: number, clientY: number) => { + const start = dragStartRef.current; + if (!start || readOnly) return; + const distance = Math.hypot(clientX - start.x, clientY - start.y); + if (!draggingCellRef.current && distance < 5) return; + if (!draggingCellRef.current) { + draggingCellRef.current = start.cell; + setDraggingCell(start.cell); + suppressClickRef.current = true; + } + const nextDropCell = findReferenceCellAtPoint(clientX, clientY); + dropCellRef.current = nextDropCell; + setDropCell(nextDropCell); + }, [findReferenceCellAtPoint, readOnly]); + + const finishReferenceDrag = useCallback((clientX: number, clientY: number, cancel = false) => { + const start = dragStartRef.current; + if (!start) return; + const sourceCell = draggingCellRef.current; + const targetCell = cancel ? null : dropCellRef.current || findReferenceCellAtPoint(clientX, clientY); + if (sourceCell && targetCell) { + onMoveReferenceCell(sourceCell.rowId, sourceCell.columnIndex, targetCell.rowId, targetCell.columnIndex); + } + clearReferenceDrag(); + window.setTimeout(() => { suppressClickRef.current = false; }, 0); + }, [clearReferenceDrag, findReferenceCellAtPoint, onMoveReferenceCell]); + + const cancelReferenceDrag = useCallback(() => { + if (!dragStartRef.current) return; + clearReferenceDrag(); + window.setTimeout(() => { suppressClickRef.current = false; }, 0); + }, [clearReferenceDrag]); + + useEffect(() => { + const handleWindowPointerMove = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y); + if (distance >= 5) event.preventDefault(); + updateReferenceDrag(event.clientX, event.clientY); + }; + const handleWindowPointerUp = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + finishReferenceDrag(event.clientX, event.clientY); + }; + const handleWindowPointerCancel = (event: PointerEvent) => { + const start = dragStartRef.current; + if (!start || event.pointerId !== start.pointerId) return; + cancelReferenceDrag(); + }; + const handleWindowMouseUp = () => { + if (!dragStartRef.current) return; + finishReferenceDrag(lastPointerRef.current.x, lastPointerRef.current.y); + }; + const handleWindowBlur = () => cancelReferenceDrag(); + window.addEventListener("pointermove", handleWindowPointerMove, { passive: false }); + window.addEventListener("pointerup", handleWindowPointerUp, true); + window.addEventListener("pointercancel", handleWindowPointerCancel, true); + window.addEventListener("mouseup", handleWindowMouseUp, true); + window.addEventListener("blur", handleWindowBlur); + return () => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp, true); + window.removeEventListener("pointercancel", handleWindowPointerCancel, true); + window.removeEventListener("mouseup", handleWindowMouseUp, true); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + + const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { + if (readOnly || !hasMaterial) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + dragStartRef.current = { x: event.clientX, y: event.clientY, pointerId: event.pointerId, cell: { rowId, columnIndex } }; + suppressClickRef.current = false; + }, [readOnly]); + + const handleReferencePointerMove = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + updateReferenceDrag(event.clientX, event.clientY); + }, [updateReferenceDrag]); + + const handleReferencePointerUp = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + finishReferenceDrag(event.clientX, event.clientY); + }, [finishReferenceDrag]); + + const handleReferencePointerCancel = useCallback((event: ReactPointerEvent) => { + event.stopPropagation(); + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + cancelReferenceDrag(); + }, [cancelReferenceDrag]); + + return ( +
event.stopPropagation()} + onMouseDown={(event) => { + // Only the top padding is a node drag surface; keep child controls isolated. + if (event.target !== event.currentTarget) event.stopPropagation(); + }} + > + { + const file = event.currentTarget.files?.[0]; + const target = uploadTargetRef.current; + uploadTargetRef.current = null; + event.currentTarget.value = ""; + if (file && target) onUploadReference(target.rowId, target.columnIndex, file); + }} + /> + {!readOnly ? : null} +
event.stopPropagation()} + > + ({ value, label: String(value) }))} + disabled={readOnly} + onChange={(concurrency) => onPatchTable({ concurrency })} + className="w-20" + aria-label="并发数" + /> + + {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} + + {!readOnly ? ( +
+ 全局提示词 + onPatchTable({ globalPrompt: event.target.value })} + /> + {hasGlobalPrompt ? 已覆盖全部 : null} +
+ ) : null} + {!readOnly ? ( +
+ + + +
+ ) : null} +
+ +
event.stopPropagation()}> +
+ # + 任务 + {referenceColumns.map((column) => ( + setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} /> + ))} + {textColumns.map((column, index) => ( + {column.label} + ))} + 任务提示词 + 生成结果 + +
+ {table.rows.length ? ( + table.rows.map((row, index) => { + const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; + const item = batchItemByRowId.get(row.id); + const status = rowStatus(item, output); + return ( +
+ {index + 1} +
+ onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> + {row.enabled === false ? "关" : "开"} +
+ {referenceColumns.map((column, columnIndex) => ( + { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + /> + ))} + {textColumns.map((column, columnIndex) => ( +
+ {table.aiGenerated ? ( +
+ {row.cells?.[column.id] || ""} +
+ ) : ( + + )} +
+ ))} + onUpdateRow(row.id, { prompt: event.target.value })} /> +
+ {output?.metadata?.content || output?.metadata?.storageKey ? ( + } + /> + ) : ( + + )} +
+
{status.label}
+ {!readOnly && status.retryable && batch && item ? ( + + ) : null} +
+ {status.loading ? : null} +
+ {!readOnly ? ( + +
+ ); + }) + ) : ( +
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
+ )} +
+
+ ); +} + +function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) { + return ( + { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", column.id); + onDragStart(); + }} + onDragEnd={onDragEnd} + onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} + onDrop={(event) => { event.preventDefault(); onDrop(column.id); }} + > + {!readOnly ? : null} + {column.label} + + ); +} + +function BatchTextHandle(props: Omit, "column"> & { column: { id: string; label: string } }) { + return ; +} + +function BatchReferenceHandles({ + columns, + textColumns, + theme, + onAdd, + onAddText, + onConnectStart, + onConnectDrop, +}: { + columns: ReturnType; + textColumns: ReturnType; + theme: CanvasTheme; + onAdd: () => void; + onAddText: () => void; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; +}) { + const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" }; + const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP; + const textTop = batchTextHandleTop(columns.length); + return ( + <> + {columns.map((column, index) => ( + + ))} + {textColumns.map((column, index) => ( + + ))} + {columns.length < MAX_BATCH_REFERENCE_COLUMNS ? ( + + + + ) : null} + {textColumns.length < MAX_BATCH_TEXT_COLUMNS ? ( + + + + ) : null} + + ); +} + +function BatchReferenceHandle({ + column, + top, + badge, + theme, + commonStyle, + onConnectStart, + onConnectDrop, + handlePrefix = "reference", +}: { + column: { id: string; label: string }; + top: number; + badge: string; + theme: CanvasTheme; + commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; + handlePrefix?: "reference" | "text"; +}) { + const [hovered, setHovered] = useState(false); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id); + const reset = useCallback(() => { + setHovered(false); + setOffset({ x: 0, y: 0 }); + }, []); + const update = useCallback((event: ReactPointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const dx = event.clientX - (bounds.left + bounds.width / 2); + const dy = event.clientY - (bounds.top + bounds.height / 2); + const limit = 16; + setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); + }, []); + return ( + + + + ); +} +function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) { + const content = node?.metadata?.content || node?.metadata?.prompt; + return
{content || "未连接文字"}
; +} + +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { + node?: CanvasNodeData; + theme: CanvasTheme; + readOnly: boolean; + rowId: string; + columnIndex: number; + columnId: string; + draggingColumnId: string | null; + isDraggingCell: boolean; + isDropTarget: boolean; + suppressClickRef: MutableRefObject; + onReplace: (node: CanvasNodeData) => void; + onUpload: () => void; + onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; + onLostPointerCapture: (event: ReactPointerEvent) => void; +}) { + const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) }; + const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined; + if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) { + return ( + + + + ); + } + const fallback = ; + return ( + + + + ); +} + +function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode { + return ( +
+ +
+ ); +} + +function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { + if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false }; + if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; + if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; + if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) + return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; + if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; + return { label: "待生成", loading: false, retryable: false }; +} diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 03a01ff0d..285e8c9e6 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,1109 +1,1141 @@ -import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; -import { Tooltip } from "@/components/ui/base/tooltip"; -import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; - -import { ModelPicker } from "@/components/model-picker"; -import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; -import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; -import { clampPromptEditorModalSize, PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size"; -import { CreditSymbol, requestCreditCost } from "@/constant/credits"; -import { canvasThemes } from "@/lib/canvas-theme"; -import { modelQuoteRequest } from "@/lib/model-pricing"; -import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; -import { navigateToSettings } from "@/lib/settings-navigation"; -import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; -import { useUserStore } from "@/stores/use-user-store"; -import { CanvasCameraControlPopover } from "./canvas-camera-control-popover"; -import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; -import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; -import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; -import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; -import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; -import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; -import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover"; -import { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer"; -import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; -import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; -import { createPluginHostContext } from "@/services/plugin-host"; -import { usePluginStore } from "@/stores/use-plugin-store"; -import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references"; -import { quoteLogicalModel } from "@/services/api/logical-models"; - -export type CanvasNodeGenerationMode = CanvasGenerationMode; - -type CanvasNodePromptPanelProps = { - projectId: string; - node: CanvasNodeData; - isRunning: boolean; - onPromptChange: (nodeId: string, prompt: string) => void; - onConfigChange: (nodeId: string, patch: Partial) => void; - onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; - mentionReferences?: CanvasResourceReference[]; - onAddReference?: (nodeId: string, reference: CanvasResourceReference) => CanvasResourceReference | undefined; - onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void; - onReorderReferences?: (nodeId: string, orderedNodeIds: string[]) => void; - onReplaceReference?: (nodeId: string, oldReference: CanvasResourceReference, sourceNodeId: string) => void; - onReplaceReferenceFiles?: (nodeId: string, oldReference: CanvasResourceReference, files: File[]) => void; - onClose?: () => void; - onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; - onImageSettingsOpenChange?: (open: boolean) => void; - workspaceMode?: CanvasWorkspaceMode; -}; - -type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; - -const PROMPT_REFERENCE_SHELF_HEIGHT = 58; -// Keep the compact editor readable at rest: three 20px lines plus 12px vertical padding. -const PROMPT_EDITOR_MIN_HEIGHT = 72; -const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 200; -const PROMPT_EDITOR_LINE_HEIGHT = 20; -const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24; -const PROMPT_EDITOR_VERTICAL_PADDING = 12; -const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; -const PROMPT_EDITOR_MAX_LINES = 8; -const PROMPT_EDITOR_EXPANDED_MAX_LINES = 14; -const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)"; -const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200; -const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420; - -export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { - const globalConfig = useEffectiveConfig(); - const themeName = useActiveTheme(); - const theme = canvasThemes[themeName]; - const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); - const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)); - const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); - const simpleMode = workspaceMode === "simple"; - const mode = defaultMode(node.type); - const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); - const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); - const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; - const [prompt, setPrompt] = useState(savedPrompt); - const [presetOpen, setPresetOpen] = useState(false); - const [expandedPresetOpen, setExpandedPresetOpen] = useState(false); - const [expandedPromptOpen, setExpandedPromptOpen] = useState(false); - const [expandedModalSize, setExpandedModalSize] = useState<{ width: number; height: number } | null>(null); - const expandedModalRef = useRef(null); - const [promptContentHeight, setPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, false)); - const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, true)); - const [manualPromptHeight, setManualPromptHeight] = useState(null); - const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) - const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); - const [autoLinkEnabled, setAutoLinkEnabled] = useState(true); - const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId }); - const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]); - const activeReferences = resolvedMentionReferences.filter((item) => item.active && item.kind !== "skill"); - const requirements: ModelRequirements = { - capability: mode, - input: { - textCount: (prompt.trim() ? 1 : 0) + activeReferences.filter((item) => item.kind === "text").length, - imageCount: activeReferences.filter((item) => item.kind === "image").length, - videoCount: activeReferences.filter((item) => item.kind === "video").length, - audioCount: activeReferences.filter((item) => item.kind === "audio").length, - characterCount: activeReferences.filter((item) => item.kind === "character").length, - }, - videoOperation: node.metadata?.videoEditOperation, - videoSeconds: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, - options: modelRequestOptions({ - ...globalConfig, - size: node.metadata?.size || globalConfig.size, - quality: node.metadata?.quality || globalConfig.quality, - count: String(node.metadata?.count || globalConfig.count), - transparentBackground: node.metadata?.transparentBackground || globalConfig.transparentBackground, - videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, - vquality: node.metadata?.vquality || globalConfig.vquality, - videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, - videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, - audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, - audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, - audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, - }, mode), - }; - const config = buildNodeConfig(globalConfig, node, mode, requirements); - const resolvedRequirements: ModelRequirements = { - ...requirements, - options: modelRequestOptions(config, mode), - videoSeconds: mode === "video" ? config.videoSeconds : undefined, - }; - const promptOptimizerProvider = useMemo(() => { - if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null; - return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig)); - }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]); - const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); - const priceChannel = resolveModelChannel(config, config.model); - const configuredCredits = requestCreditCost({ - channelMode: priceChannel.scope === "system" ? "remote" : "local", - modelCosts: priceChannel.modelCosts, - model: modelOptionName(config.model), - count: mode === "image" ? generationCount : 1, - seconds: mode === "video" ? config.videoSeconds : 1, - capability: mode, - config, - requirements: resolvedRequirements, - }); - const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements); - const quoteRequestKey = JSON.stringify(quoteRequest || null); - const [quotedCredits, setQuotedCredits] = useState(null); - const credits = quotedCredits ?? configuredCredits; - const activeReferenceCount = activeReferences.length; - const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); - const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0; - const monochromeAccent = theme.node.activeStroke; - const composerTokens = { - "--canvas-composer-surface": theme.node.panel, - "--canvas-composer-control-surface": theme.toolbar.itemHover, - "--canvas-composer-control-hover": theme.toolbar.activeBg, - "--canvas-composer-shadow": theme.node.shadow, - "--cn-text": theme.node.text, - } as CSSProperties; - const composerSurfaceStyle = { - ...composerTokens, - background: theme.node.panel, - color: theme.node.text, - boxShadow: theme.node.shadow, - } as CSSProperties; - const controlSurface = "var(--canvas-composer-control-surface)"; - const promptBounds = promptEditorBounds(false, activeReferenceCount > 0); - const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0); - const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds); - const expandedComposerHeight = clampPromptHeight(expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds); - const measureExpandedModalSize = () => { - const rect = expandedModalRef.current?.getBoundingClientRect(); - if (!rect?.width || !rect.height) return { width: PROMPT_EDITOR_MODAL_DEFAULT_WIDTH, height: PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT }; - return { width: Math.round(rect.width), height: Math.round(rect.height) }; - }; - const isSubmitDisabled = !isRunning && !prompt.trim(); - const canExpandPrompt = mode === "image" || mode === "video"; - const canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt; - const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); - const autoMentionedPrompt = useMemo(() => autoMentionCanvasResourceReferences(prompt, resolvedMentionReferences), [prompt, resolvedMentionReferences]); - const canAutoMention = autoMentionedPrompt !== prompt; - - useEffect(() => { - setPrompt(normalizedSavedPrompt); - if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt); - }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]); - - useEffect(() => { - setExpandedPromptOpen(false); - setExpandedPresetOpen(false); - setExpandedModalSize(null); - setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false)); - setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true)); - setManualPromptHeight(null); - }, [node.id]); - - useEffect(() => { - if (!expandedPromptOpen) return; - const constrainSize = () => setExpandedModalSize((size) => { - if (!size) return size; - const next = clampExpandedModalSize(size); - return next.width === size.width && next.height === size.height ? size : next; - }); - constrainSize(); - window.addEventListener("resize", constrainSize); - return () => window.removeEventListener("resize", constrainSize); - }, [expandedPromptOpen]); - - useEffect(() => { - if (!creditsEnabled || !quoteRequest) { - setQuotedCredits(null); - return; - } - const controller = new AbortController(); - setQuotedCredits(null); - quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal) - .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000)) - .catch(() => { - if (!controller.signal.aborted) setQuotedCredits(null); - }); - return () => controller.abort(); - // quoteRequestKey captures the full normalized request without retriggering on object identity. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [creditsEnabled, quoteRequestKey]); - - const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]); - - const updatePrompt = (value: string) => { - setPrompt(value); - onPromptChange(node.id, value); - if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) { - if (expandedPromptOpen) setExpandedPresetOpen(true); - else setPresetOpen(true); - } - }; - - const applyPreset = (preset: CanvasPromptPreset) => { - const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd(); - updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt); - }; - - const insertPromptReference = (reference: CanvasResourceReference) => { - const insertText = `${canvasResourceMentionToken(reference)} `; - const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt); - if (pendingMentionMatch) { - const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, ""); - updatePrompt(prefix ? `${prefix} ${insertText}` : insertText); - return; - } - const basePrompt = prompt.replace(/\s*$/, ""); - updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText); - }; - - const submit = () => { - const text = prompt.trim(); - if (!text || isRunning) return false; - onGenerate(node.id, mode, text); - return true; - }; - - const submitExpandedPrompt = () => { - if (submit()) { - setExpandedPresetOpen(false); - setExpandedPromptOpen(false); - } - }; - - const renderComposerHeader = (expanded: boolean) => ( -
{ - const target = event.target instanceof Element ? event.target : null; - if (!target?.closest("button, input, textarea, select, a, [contenteditable=\"true\"], [data-canvas-no-drag]")) onNodeMouseDown?.(event, node.id); - }} - > - {isPortraitTexture ? ( - <> - onConfigChange(node.id, { portraitTexture })} /> - {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} - - ) : ( -
- - - - {modeDisplayName(mode)}生成 - {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} -
- )} -
- {!simpleMode ? : null} - {canOptimizePrompt ? ( - - - - ) : null} - {!expanded && canExpandPrompt ? ( - - - - ) : null} - {!expanded && onClose ? ( - - - - ) : null} -
-
- ); - - const renderSubmitButton = (expanded: boolean) => { - const showCost = creditsEnabled && credits !== null; - const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); - const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; - return ( - - ); - }; - - const renderComposerControls = (expanded: boolean) => - simpleMode ? ( -
- - {activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} - - updatePrompt(autoMentionedPrompt)} - onAutoLinkEnabledChange={setAutoLinkEnabled} - accent={monochromeAccent} - compact - /> - {renderSubmitButton(expanded)} -
- ) : ( -
-
- onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} - capability={mode} - requirements={resolvedRequirements} - onMissingConfig={() => navigateToSettings({ continueCreation: true })} - showSelectedPrice={false} - showOptionPrices={creditsEnabled} - variant="creation" - showConfiguredModelName - /> -
-
- updatePrompt(autoMentionedPrompt)} - onAutoLinkEnabledChange={setAutoLinkEnabled} - accent={monochromeAccent} - compact={!expanded} - /> - {mode === "text" ? ( - - onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} - aria-label="文本生成份数" - className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" - /> - - ) : mode === "image" ? ( - // 图片模式下,显示相机配置与镜头配置 - <> - onConfigChange(node.id, { cameraControl: options })} - theme={theme} - compact={!expanded} - /> - onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} - onMissingConfig={() => navigateToSettings({ continueCreation: true })} - onOpenChange={expanded ? undefined : onImageSettingsOpenChange} - /> - - ) : mode === "video" ? ( - onConfigChange(node.id, videoConfigPatch(key, value))} - /> - ) : mode === "audio" ? ( - onConfigChange(node.id, audioConfigPatch(key, value))} - /> - ) : null} - {renderSubmitButton(expanded)} -
-
- ); - - const renderPromptEditor = (expanded: boolean, fill = false) => { - const bounds = expanded ? expandedPromptBounds : promptBounds; - const height = expanded ? expandedComposerHeight : composerHeight; - return ( - <> -
- onRemoveReference?.(node.id, reference)} - onReorder={onReorderReferences ? (orderedNodeIds) => onReorderReferences(node.id, orderedNodeIds) : undefined} - onReplaceReference={onReplaceReference ? (oldReference, sourceNodeId) => onReplaceReference(node.id, oldReference, sourceNodeId) : undefined} - onReplaceReferenceFiles={onReplaceReferenceFiles ? (oldReference, files) => onReplaceReferenceFiles(node.id, oldReference, files) : undefined} - /> - onAddReference(node.id, reference) : undefined} - includeAssetLibrary - onChange={updatePrompt} - autoLinkEnabled={autoLinkEnabled} - onReferenceFilesDrop={onReplaceReferenceFiles ? (reference, files) => onReplaceReferenceFiles(node.id, reference, files) : undefined} - onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight} - containerClassName="min-h-0 flex-1" - className={expanded - ? "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-3 py-2.5 text-[var(--fs-body-lg)] leading-6 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35" - : "thin-scrollbar h-full w-full resize-none overflow-y-auto border-none bg-transparent px-2.5 py-1.5 text-[var(--fs-body)] leading-5 !outline-none !ring-0 !shadow-none focus:!outline-none focus:!ring-0 focus:!shadow-none placeholder:text-current placeholder:opacity-35"} - style={{ color: theme.node.text, outline: "none", boxShadow: "none" }} - placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)} - aria-label={`${modeDisplayName(mode)}提示词`} - /> -
- {!expanded && } - - ); - }; - - return ( - item.model === modelOptionName(config.model))?.protocol || priceChannel.interfaceType} - config={globalConfig} - optimizerModel={globalConfig.textModel} - references={activeReferences} - provider={promptOptimizerProvider} - onClose={() => setPromptOptimizerOpen(false)} - onApply={(nextPrompt) => updatePrompt(nextPrompt)} - > -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - {renderComposerHeader(false)} - - {renderPromptEditor(false)} - - {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/} - {hasVideoPromptTools ? ( -
- - {paramsExpanded ? ( -
- onConfigChange(node.id, patch)} /> -
- ) : null} -
- ) : null} - - {renderComposerControls(false)} - - { - setExpandedPresetOpen(false); - setExpandedPromptOpen(false); - }} - styles={{ - container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow }, - body: { minHeight: 0, padding: 0 }, - }} - > -
-
-
{renderComposerHeader(true)}
- {renderPromptEditor(true, Boolean(expandedModalSize))} - {hasVideoPromptTools ? ( -
- onConfigChange(node.id, patch)} /> -
- ) : null} -
{renderComposerControls(true)}
-
- -
-
- -
-
- ); -} - -function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) { - return ( - -
-
智能引用
-
输入素材序号或名称后按 Tab,可快速引用
-
-
-
AutoLink
- -
- - - } - > - -
- ); -} - -function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) { - if (mode === "image") return ; - if (mode === "video") return
- 跳转到画布主内容 - -
- {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? ( - openProjectAssets()} /> - ) : null} - -
-
- {!focusMode ? ( - } - versionsOpen={versions.open} - onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }} - title={currentProject?.title || "未命名画布"} - titleDraft={titleDraft} - isTitleEditing={titleEditing} - onTitleDraftChange={setTitleDraft} - onStartTitleEditing={startTitleEditing} - onFinishTitleEditing={finishTitleEditing} - onCancelTitleEditing={() => setTitleEditing(false)} - canUndo={historyState.canUndo} - canRedo={historyState.canRedo} - onCreateProject={createAndOpenProject} - onDeleteProject={deleteCurrentProject} - onSave={() => void saveCanvasProject()} - onForceSave={confirmForceSaveCanvas} - onImportImage={() => handleUploadRequest()} - onImportLibTV={() => setLibTVImportOpen(true)} - onImportTapNow={() => setTapNowImportOpen(true)} - onUndo={undoCanvas} - onRedo={redoCanvas} - onShare={() => setShareModalOpen(true)} - shortcutRequestNonce={shortcutRequestNonce} - mediaPerformanceMode={mediaPerformanceMode} - onMediaPerformanceModeChange={setMediaPerformanceMode} - onOpenSearch={() => setNodeSearchOpen(true)} - projectContext={ - shortDramaEnabled && currentProject?.projectId - ? { - ...canvasContext, - projectId: currentProject.projectId, - projectName: linkedProjectQuery.data?.project.name || currentProject.title, - } - : undefined - } - onEnterFocusMode={enterFocusMode} - shortDramaGuide={shortDramaGuide} - /> - ) : null} - - setNodeSearchOpen(false)} - onFocus={(nodeId) => { - const target = nodeById.get(nodeId); - const parent = target?.parentId ? nodeById.get(target.parentId) : null; - if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); - const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; - if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); - const selection = new Set([nodeId]); - selectedNodeIdsRef.current = selection; - setSelectedNodeIds(selection); - setSelectedConnectionId(null); - focusCanvasNode(nodeId); - }} - /> - - {!focusMode && shortDramaGuide ? ( - - ) : null} - - setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> - setLibTVImportOpen(false)} onApply={applyLibTVImport} /> - setTapNowImportOpen(false)} onApply={applyTapNowImport} /> - - setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> - - setDirectorTemplateRequest(null)} onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)} /> - -
-
- - } - onViewportChange={handleViewportChange} - onViewportPreviewChange={handleViewportPreviewChange} - onCanvasMouseDown={handleCanvasMouseDown} - boxSelectEnabled={canvasTool === "box-select"} - onCanvasDoubleClick={handleCanvasDoubleClick} - onCanvasDeselect={deselectCanvas} - onContextMenu={handleCanvasContextMenu} - onDrop={handleDrop} - onFileDragEnter={handleFileDragEnter} - onFileDragLeave={handleFileDragLeave} - onFileDragOver={handleFileDragOver} - > - - - { - setSelectedConnectionId(connectionId); - setSelectedNodeIds(new Set()); - setContextMenu(null); - }} - onConnectionContextMenu={(event, connectionId) => { - setSelectedConnectionId(connectionId); - setSelectedNodeIds(new Set()); - closeConnectionCreateMenu(); - setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); - }} - onNodeMouseDown={handleNodeMouseDown} - onNodeHoverStart={handleCanvasNodeHoverStart} - onNodeHoverEnd={handleCanvasNodeHoverEnd} - onConnectStart={handleConnectStart} - onNodeResize={handleNodeResize} - onToggleFrame={handleFrameToggle} - onFolderStyleChange={handleFolderStyleChange} - onFolderThemeChange={handleFolderThemeChange} - onNodeTitleChange={handleNodeTitleChange} - onNodeContextMenu={handleNodeContextMenu} - onNodeContentChange={handleNodeContentChange} - onToggleBatch={toggleBatchExpanded} - onSetBatchPrimary={setBatchPrimary} - onRetry={retryCanvasNode} - onReloadResource={reloadCanvasNodeResource} - onOpenTaskDetails={openCanvasNodeTaskDetails} - onOpenVersions={openCanvasNodeVersions} - onViewImage={viewCanvasNodeImage} - onReplaceMedia={replaceCanvasNodeMedia} - onOpenTextEditor={openTextNodeEditor} - onOpenDirector={editCanvasDirector} - onOpenDrawing={openDrawingNode} - onStartBatchConnection={startBatchConnection} - /> - - - - - - - {focusMode ? ( - } - versionsOpen={versions.open} - onToggleVersions={() => { closeAgent(); versions.toggle(); }} - dockRevealed={focusDockRevealed} - zoomPercent={viewport.k} - onToggleDock={() => setFocusDockRevealed((value) => !value)} - onExit={exitFocusMode} - onZoomIn={zoomCanvasIn} - onZoomOut={zoomCanvasOut} - onFit={fitCanvasContent} - /> - ) : null} - - - - {emptyCanvasState} - - {!focusMode || focusDockRevealed ? ( - createNode(CanvasNodeType.Image)} - onAddVideo={() => createNode(CanvasNodeType.Video)} - onAddAudio={() => createNode(CanvasNodeType.Audio)} - onAddText={() => createNode(CanvasNodeType.Text)} - onChooseStyle={() => setStylePickerOpen(true)} - onAddScript={() => createNode(CanvasNodeType.Script)} - onAddFrame={() => createNode(CanvasNodeType.Frame)} - onAddFolder={createFolder} - onAddDrawing={() => createNode(CanvasNodeType.Drawing)} - onAddExtensionNode={(type) => createNode(type)} - onAddWorkflow={() => createNode(CanvasNodeType.Config)} - onOpenDirector={() => setDirectorTemplateRequest({})} - onUndo={undoCanvas} - onRedo={redoCanvas} - onUpload={() => handleUploadRequest()} - onDelete={() => deleteNodes(new Set(selectedNodeIds))} - onClear={() => setClearConfirmOpen(true)} - onDeselect={deselectCanvas} - onAppearanceChange={applyCanvasAppearance} - onSaveAppearanceDefault={saveCanvasAppearanceDefault} - onBackgroundModeChange={setBackgroundMode} - onShowImageInfoChange={setShowImageInfo} - onOpenMyAssets={() => { - openCanvasAssetLibrary(); - }} - onOpenProjectCharacters={() => openProjectAssets("character")} - /> - ) : null} -
- -
- { - if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; } - focusCanvasNode(nodeId); - }} /> -
-
- - {angleNode?.metadata?.content ? ( - - setAngleNodeId(null)} - onConfirm={(params) => { - void generateAngleNode(angleNode, params); - }} - /> - - ) : null} - - {lightingNode?.metadata?.content ? ( - setLightingNodeId(null)}> - setLightingNodeId(null)} - onConfirm={(options, prompt) => { - generateLightingNode(lightingNode, options, prompt); - }} - /> - - ) : null} - - {emotionNode?.metadata?.content && !isCanvasNodeMoving ? ( - setEmotionNodeId(null)} - onConfirm={(payload: CanvasImageEmotionPayload) => { - void generateEmotionNode(emotionNode, payload); - }} - /> - ) : null} - - {dialogNode && - !isCanvasImageSourceNode(dialogNode) && - !dialogNode.metadata?.fileUpload && - dialogNode.type !== CanvasNodeType.Script && - dialogNode.type !== CanvasNodeType.BatchTable && - dialogNode.type !== CanvasNodeType.Drawing && - dialogNode.type !== CanvasNodeType.Panorama && - !selectionBox && - !isCanvasNodeMoving ? ( - - {renderCanvasNodePanel(dialogNode)} - - ) : null} - - {pendingConnectionCreate ? ( - getConnectionCreateDisabledReason(type, pendingConnectionCreate)} - onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} - onClose={cancelPendingConnectionCreate} - /> - ) : null} - - {connectionReplaceHover ? ( -
- - 松开替换 - @{connectionReplaceHover.referenceLabel} -
- ) : null} - - {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? ( - beginBatchConnectionMode(Array.from(selectedNodeIds))} - onMergeVideos={() => void mergeSelectedVideos()} - onSendSelectionToAgent={() => sendSelectionToAgent()} - /> - ) : null} - - {uploadStatus ? : null} - {mergeVideoProgress ? : null} - {lastAgentChange ? ( - { - undoAgentOps(); - }} - onClose={dismissLastAgentChange} - /> - ) : null} - - (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))} - onEditText={openTextNodeEditor} - onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} - onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} - onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} - onGenerateImage={generateImageFromTextNode} - onUpload={(node) => handleUploadRequest(node.id)} - onDownload={downloadNodeImage} - onSaveAsset={(node) => void saveNodeAsset(node)} - onAnnotate={(node) => setAnnotationNodeId(node.id)} - onAnnotationEdit={openAnnotationEditNode} - onMaskEdit={(node) => setMaskEditNodeId(node.id)} - onImageEdit={openImageEditNode} - onRemoveBackground={openBackgroundRemoval} - onLayerDecomposition={openLayerDecomposition} - onTextEdit={openTextEditNode} - onEmotion={(node) => { - setDialogNodeId(null); - setEmotionNodeId((current) => (current === node.id ? null : node.id)); - }} - onPortraitTexture={openPortraitTextureEditor} - onCrop={(node) => setCropNodeId(node.id)} - onSplit={(node, params) => void splitImageNode(node, params)} - onUpscale={(node) => setUpscaleNodeId(node.id)} - onSuperResolve={(node) => setSuperResolveNodeId(node.id)} - onAngle={(node) => { - setDialogNodeId(null); - setAngleNodeId((current) => (current === node.id ? null : node.id)); - }} - onLighting={(node) => { - setDialogNodeId(null); - setLightingNodeId((current) => (current === node.id ? null : node.id)); - }} - onPanorama={openPanoramaConfig} - onViewImage={(node) => setPreviewNodeId(node.id)} - onExtractVideoFrames={openVideoFrameExtractor} - onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)} - onTrimVideoSegments={openVideoSegmentExtractor} - onSubtitles={(node) => setSubtitleNodeId(node.id)} - onTimeline={(node) => setTimelineNodeId(node.id)} - extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId} - extractingAudio={segmentRunningMode === "audio"} - trimmingVideo={segmentRunningMode === "video"} - onReversePrompt={createImageReversePromptNodes} - onRetry={retryCanvasNode} - onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} - onToggleLocked={(node) => toggleNodeLocked(node.id)} - onDelete={(node) => deleteNodes(new Set([node.id]))} - /> - -{isMiniMapOpen && !focusMode ? : null} - - {!focusMode ? ( - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - setIsMiniMapOpen((value) => !value)} - onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} - /> - void createImageAssetNode(asset)} - onFocusCanvasImage={focusCanvasImageNode} - /> - - ) : null} - - setContextMenu(null)} - onAddNode={(type, position) => createNode(type, position)} - onAddFolder={createFolder} - onChooseStyle={() => setStylePickerOpen(true)} - onOpenDirector={(position) => setDirectorTemplateRequest({ position })} - onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)} - onOpenAssets={openCanvasAssetLibrary} - onOpenProjectCharacters={(position) => openProjectAssets("character", position)} - onUndo={undoCanvas} - onRedo={redoCanvas} - onPaste={pasteAtPosition} - onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))} - onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")} - onDuplicate={duplicateNode} - onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))} - onDeleteConnection={deleteConnection} - onSaveAsset={(node) => { - void saveNodeAsset(node); - }} - onViewMedia={(node) => setPreviewNodeId(node.id)} - onEditText={openTextNodeEditor} - onOpenDrawing={openDrawingNode} - onGenerateImage={generateImageFromTextNode} - onCopyContent={(node) => { - void copyNodeContentToClipboard(node); - }} - onCopyMediaUrl={(node) => { - void copyNodeMediaUrlToClipboard(node); - }} - onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset} - onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })} - onToggleFrame={(node) => handleFrameToggle(node.id)} - onSpreadSelection={spreadSelectedNodes} - onCopySelection={copySelectedNodes} - onDeleteSelection={() => deleteNodes(selectedNodeIds)} - onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)} - /> - - - - - - setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} /> - - {subtitleNode ? ( - setSubtitleNodeId(null)} - onSave={(nodeId, patch) => { - handleConfigNodeChange(nodeId, patch); - const currentTimeline = currentProject?.timeline; - if (currentTimeline) { - const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []); - if (next !== currentTimeline) updateProject(projectId, { timeline: next }); - } - }} - /> - ) : null} - - {frameNode ? void extractVideoFrames(frameNode, params)} /> : null} - - {segmentNode && segmentDialogMode ? ( - void handleSegmentConfirm(segmentNode, params)} - /> - ) : null} - - {timelineNode ? ( - setTimelineNodeId(null)} - onOpenSubtitleDialog={(subNodeId) => { - setTimelineNodeId(null); - setSubtitleNodeId(subNodeId); - }} - onSave={(next) => updateProject(projectId, { timeline: next })} - onSaveSubtitles={(subNodeId, entries) => - handleConfigNodeChange(subNodeId, { - subtitleEntries: entries, - ...(entries.length ? {} : { subtitleHighlights: [] }), - subtitleUpdatedAt: new Date().toISOString(), - }) - } - onOpenAssetLibrary={openTimelineAssetLibrary} - onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")} - onUploadLocalFiles={uploadTimelineMedia} - addNodeToTimelineRef={timelineAddNodeRef} - addMediaToTimelineRef={timelineMediaAddRef} - onCreateAssembledNode={createVideoNodeFromBlob} - /> - ) : null} - - setCharacterReferenceNodeId(null)} /> - - setTextEditorNodeId(null)} - onSave={(nodeId, title, content, richText) => { - setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node))); - }} - /> - - {drawingNode ? ( - - -
- } - > - setDrawingNodeId(null)} - onSaved={(nodeId, summary) => { - setNodes((current) => - current.map((node) => - node.id === nodeId - ? { - ...node, - metadata: { - ...node.metadata, - drawingEngine: summary.engine, - drawingRevision: summary.revision, - drawingUpdatedAt: summary.updatedAt, - drawingShapeCount: summary.shapeCount, - drawingPageCount: summary.pageCount, - }, - } - : node, - ), - ); - message.success("绘图已保存"); - }} - /> - - ) : null} - - { - artCritiqueRunningRef.current = running; - }} - node={artCritiqueNode} - upstreamNodes={artCritiqueInputs} - open={Boolean(artCritiqueNode)} - onClose={() => setArtCritiqueNodeId(null)} - onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })} - /> - - setPanoramaConfigNodeId(null)} - onConfirm={(composedPrompt, config) => { - const node = nodes.find((n) => n.id === panoramaConfigNodeId); - if (node) { - createPanoramaViewerWithConfig(node, composedPrompt, config); - } - }} - onCopyPrompt={(prompt) => { - void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词")); - }} - previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined} - nodes={nodes} - /> - - setScriptEditorNodeId(null)} - onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)} - onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => { - if (!activeScriptNode || !visibleColumns.length) return; - setNodes((prev) => - prev.map((node) => - node.id === activeScriptNode.id - ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } - : node, - ), - ); - }} - onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)} - onGenerateVideos={(rowIds) => { - if (!activeScriptNode) return; - if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds); - else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds); - }} - onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })} - /> - - {directorNodeId && activeDirectorScene ? ( - - - - } - > - node.type === CanvasNodeType.Image && Boolean(node.metadata?.content))} - onClose={() => setDirectorNodeId(null)} - onChange={saveDirectorScene} - onApply={applyDirectorOutput} - onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))} - onFlush={() => flushCanvasStorePersistence()} - onboardingScope={directorOnboardingScope} - /> - - ) : null} - - setVersionCompareRootId(null)} - onSetPrimary={setPrimaryVersion} - onFocus={(nodeId) => { - setVersionCompareRootId(null); - focusCanvasNode(nodeId); - }} - /> - - setCropNodeId(null)} - onCloseAnnotation={() => setAnnotationNodeId(null)} - onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)} - onCloseMaskEdit={() => setMaskEditNodeId(null)} - onCloseImageEdit={() => { setImageEditNodeId(null); setImageEditPreset(null); }} - onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)} - onCloseTextEdit={() => setTextEditNodeId(null)} - onCloseUpscale={() => setUpscaleNodeId(null)} - onCrop={(node, crop) => void cropImageNode(node, crop)} - onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)} - onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)} - onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)} - onImageEdit={(node, payload) => void editImageNode(node, payload)} - onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)} - onDetectText={() => { - const node = textEditNodeId ? nodeById.get(textEditNodeId) : null; - return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在")); - }} - onTextEdit={(node, payload) => void editTextImageNode(node, payload)} - onUpscale={(node, params) => void upscaleImageNode(node, params)} - config={effectiveConfig} - /> - - - - setTaskDetail(null)} - onCancelTask={cancelCanvasTask} - superResolveNode={superResolveNode} - onCloseSuperResolve={() => setSuperResolveNodeId(null)} - previewNode={previewNode} - onClosePreview={() => setPreviewNodeId(null)} - clearConfirmOpen={clearConfirmOpen} - onCancelClear={() => setClearConfirmOpen(false)} - onConfirmClear={clearCanvas} - /> - - - - - {versions.preview ? : null} - -
- -
- - ); -} +import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state"; +import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useParams, useSearchParams } from "react-router"; +import { loadAssetsForUse } from "@/services/user-data-sync"; +import { canvasAssetHandoffIds } from "@/lib/canvas/canvas-asset-handoff"; +import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { uploadMediaFile } from "@/services/file-storage"; +import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer"; +import { getActiveUserScope } from "@/lib/user-scope"; +import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources"; +import { uploadImage } from "@/services/image-storage"; +import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; +import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source"; +import { getCachedResourceBlob } from "@/services/resource-blob-cache"; +import copyToClipboard from "copy-to-clipboard"; +import { nanoid } from "nanoid"; +import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; +import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; +import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode"; +import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary"; +import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference"; +import { useAssetStore } from "@/stores/use-asset-store"; +import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store"; +import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; +import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store"; +import { useUserStore } from "@/stores/use-user-store"; +import { App, Button } from "antd"; +import { ArrowLeftRight } from "lucide-react"; +import { AppModal } from "@/components/ui/product/app-modal"; +import { getNodeSpec } from "@/constant/canvas"; +import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer"; +import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel"; +import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-panel"; +import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel"; +import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray"; +import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar"; +import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal"; +import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node"; +import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal"; +import { WorkspaceState } from "@/components/layout/workspace-state"; +import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal"; +import { createStyleProfileSnapshot, resolveStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile"; +import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar"; +import { CanvasSubtitleDialog } from "@/components/canvas/canvas-subtitle-dialog"; +import { CanvasVideoFrameDialog } from "@/components/canvas/canvas-video-frame-dialog"; +import { CanvasVideoSegmentDialog } from "@/components/canvas/canvas-video-segment-dialog"; +import { CanvasTimelineDialog } from "@/components/canvas/canvas-timeline-dialog"; +import { syncNodeSubtitlesToTimeline } from "@/lib/timeline/timeline-build"; +import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog"; +import { CanvasNodeLightingPanel } from "@/components/canvas/canvas-node-lighting-dialog"; +import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal"; +import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal"; +import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal"; +import { CanvasDirectorTemplateModal } from "@/components/canvas/director/canvas-director-template-modal"; +import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay"; +import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal"; +import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-config-modal"; +import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; +import { Minimap } from "@/components/canvas/canvas-mini-map"; +import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; +import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"; +import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands"; +import { AssetPickerModal } from "@/components/canvas/asset-picker-modal"; +import { getProject } from "@/services/api/projects"; +import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; +import { CanvasShareModal } from "@/components/canvas/canvas-share-modal"; +import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node"; +import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node"; +import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; +import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel"; +import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal"; +import { useFocusMode } from "@/hooks/use-focus-mode"; +import { connectCanvasTextMention } from "@/lib/canvas/canvas-text-mention"; +import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt"; +import { + applyCanvasConnectionPromptSync, + buildCanvasAgentMentionReferences, + canvasResourceMentionToken, + buildCanvasNodeMentionReferenceMap, + buildCanvasResourceReferences, + getContextResourceNodes, + normalizeCanvasNodeMentionTokens, + reorderCanvasResourceConnections, + replaceCanvasReferenceMentions, + type CanvasResourceReference, +} from "@/lib/canvas/canvas-resource-references"; +import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; +import { CanvasOverlayLayerContainer, CanvasOverlayLayerProvider } from "@/components/canvas/canvas-overlay-layer"; +import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer"; +import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry"; +import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter"; +import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry"; +import { createCanvasNode, getInputSummary, isHiddenBatchChild } from "@/lib/canvas/canvas-project-domain"; +import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps"; +import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff"; +import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection"; +import { batchReferenceColumns } from "@/lib/canvas/canvas-batch-table"; +import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress"; +import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback"; +import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation"; +import { cancelGenerationTask } from "@/services/api/task-center"; +import { CanvasSyncStatus } from "./canvas-sync-status"; +import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history"; +import { CanvasVersionPreview } from "./canvas-version-preview"; +import { CanvasTopBar } from "./canvas-project-top-bar"; +import { LibTVImportDialog } from "./components/libtv-import-dialog"; +import { TapNowImportDialog } from "./components/tapnow-import-dialog"; +import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar"; +import { CanvasProjectContextMenu } from "./canvas-project-context-menu"; +import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs"; +import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog"; +import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar"; +import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs"; +import { CanvasProjectWorldLayers } from "./canvas-project-world-layers"; +import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context"; +import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order"; +import { AiArtCritiqueModal } from "@/components/canvas/art-critique/ai-art-critique-modal"; +import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context"; +import { CanvasRefreshShell } from "./canvas-refresh-shell"; +import { queryGenerationTask } from "@/services/api/task-center"; +import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel"; +import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace"; +import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage"; +import { useCanvasConnectionController } from "./use-canvas-connection-controller"; +import { useCanvasOperationHistory } from "./use-canvas-operation-history"; +import { useCanvasAssistantVisibility } from "./use-canvas-assistant-visibility"; +import { useCanvasActiveTasks } from "./use-canvas-active-tasks"; +import { useCanvasStyleWorkflow } from "./use-canvas-style-workflow"; +import { useCanvasDirector } from "./use-canvas-director"; +import { useCanvasGeneration } from "./use-canvas-generation"; +import { useCanvasGenerationBatches } from "./use-canvas-generation-batches"; +import { handleListGenerate } from "./list-mode-generator"; +import { useCanvasBatchTable } from "./use-canvas-batch-table"; +import { useCanvasGenerationExecutor, type CanvasNodeGenerationOptions } from "./use-canvas-generation-executor"; +import { useCanvasGenerationRetry } from "./use-canvas-generation-retry"; +import { useCanvasHistory } from "./use-canvas-history"; +import { useCanvasKeyboard } from "./use-canvas-keyboard"; +import { useCanvasMediaTools } from "./use-canvas-media-tools"; +import { useCanvasNodeEditor } from "./use-canvas-node-editor"; +import { useCanvasNodeOperations } from "./use-canvas-node-operations"; +import { useCanvasProjectLifecycle } from "./use-canvas-project-lifecycle"; +import { useCanvasRenderModel } from "./use-canvas-render-model"; +import { useCanvasSelectionController } from "./use-canvas-selection-controller"; +import { useCanvasShortDrama } from "./use-canvas-short-drama"; +import { useCanvasStoryboard } from "./use-canvas-storyboard"; +import { useCanvasUpload } from "./use-canvas-upload"; +import { useCanvasTimelineAssetInsert } from "./use-canvas-timeline-asset-insert"; +import { useCanvasViewportController } from "./use-canvas-viewport-controller"; +import { + CanvasNodeType, + type CanvasAssistantSession, + type CanvasConnection, + type CanvasFolderStyle, + type CanvasFolderTheme, + type CanvasNodeData, + type CanvasNodeMetadata, + type CanvasMediaPerformanceMode, + type StoryboardColumn, + type StoryboardShotCount, + type StoryboardShotDuration, + type CanvasWorkflowKind, + type CanvasWorkspaceMode, + type CanvasToolMode, + type ContextMenuState, + type Position, + type ViewportTransform, +} from "@/types/canvas"; +import type { ReferenceImage } from "@/types/image"; +import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts"; + +const CanvasDirectorWorkbench = lazy(() => import("@/components/canvas/director/canvas-director-workbench").then((module) => ({ default: module.CanvasDirectorWorkbench }))); +const CanvasDrawingEditorModal = lazy(() => import("@/components/canvas/canvas-drawing-editor-modal").then((module) => ({ default: module.CanvasDrawingEditorModal }))); + +const NODE_STATUS_SUCCESS = "success" as const; +const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = []; + + +async function copyImageToSystemClipboard(source: string, storageKey?: string) { + if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) throw new Error("当前浏览器不支持复制图片"); + if (typeof window !== "undefined") window.focus(); + + const fetchPNG = async (): Promise => { + let sourceBlob: Blob | null = null; + if (storageKey) { + sourceBlob = await getCachedResourceBlob(storageKey).catch(() => null); + } + if (!sourceBlob) { + const response = await fetch(source); + if (!response.ok) throw new Error(`图片读取失败(HTTP ${response.status})`); + sourceBlob = await response.blob(); + } + return sourceBlob.type === "image/png" ? sourceBlob : await convertClipboardImageToPNG(sourceBlob); + }; + + // 优先尝试将 Promise 直接传给 ClipboardItem(现代浏览器标准),在用户激活手势内立即声明写入, + // 避免因 fetch / 格式转换耗时导致手势过期或窗口失焦抛出 "Document is not focused" 错误。 + try { + const item = new ClipboardItem({ "image/png": fetchPNG() }); + await navigator.clipboard.write([item]); + return; + } catch { + // 部分浏览器环境不支持延迟 Promise,回退到先取 Blob 再写入 + } + + const blob = await fetchPNG(); + if (typeof window !== "undefined") window.focus(); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); +} + +async function convertClipboardImageToPNG(blob: Blob) { + if (typeof createImageBitmap !== "function") throw new Error("当前浏览器无法转换这张图片的格式"); + const bitmap = await createImageBitmap(blob); + try { + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("当前浏览器无法处理这张图片"); + context.drawImage(bitmap, 0, 0); + return await new Promise((resolve, reject) => canvas.toBlob((value) => (value ? resolve(value) : reject(new Error("图片格式转换失败"))), "image/png")); + } finally { + bitmap.close(); + } +} + +function visibleGenerationBatch(node: CanvasNodeData) { + const batches = node.metadata?.generationBatches || []; + for (let index = batches.length - 1; index >= 0; index -= 1) { + if (batches[index].status === "queued" || batches[index].status === "running") return batches[index]; + } + return batches.at(-1); +} + +export default function CanvasPage() { + useCanvasThemeScope(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return ; + + return ; +} + +function InfiniteCanvasPage() { + // 命令式确认必须走 App.useApp().modal;静态 Modal.confirm 拿不到主题和 App 上下文。 + const { message, modal } = App.useApp(); + const queryClient = useQueryClient(); + const params = useParams<{ id: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const projectId = params.id || ""; + const canvasStorageScope = getActiveUserScope(); + const containerRef = useRef(null); + const didInitialCenterRef = useRef(false); + const toolbarHideTimerRef = useRef | null>(null); + const assetHandoffRef = useRef(""); + + const config = useConfigStore((state) => state.config); + const effectiveConfig = useEffectiveConfig(); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + const assets = useAssetStore((state) => state.assets); + const assetsHydrated = useAssetStore((state) => state.hydrated); + const cleanupAssetImages = useAssetStore((state) => state.cleanupImages); + const colorTheme = useCanvasThemeStore((state) => state.theme); + const setTheme = useCanvasThemeStore((state) => state.setTheme); + const theme = canvasThemes[colorTheme]; + const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine); + const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); + const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || ""); + const nodesRef = useRef([]); + const [nodes, setNodesState] = useState([]); + const setNodes = useCallback>>((value) => { + if (typeof value === "function") { + setNodesState((current) => { + const next = stampCanvasNodeChanges(current, value(current)); + nodesRef.current = next; + return next; + }); + return; + } + const next = stampCanvasNodeChanges(nodesRef.current, value); + nodesRef.current = next; + setNodesState(next); + }, []); + const [nodeStackOrder, setNodeStackOrder] = useState([]); + const bringNodeToFront = useCallback((nodeId: string) => { + setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId)); + }, []); + const [connections, setConnections] = useState([]); + const [chatSessions, setChatSessions] = useState([]); + const [activeChatId, setActiveChatId] = useState(null); + const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); + const [size, setSize] = useState({ width: 1200, height: 720 }); + const [selectedNodeIds, setSelectedNodeIds] = useState>(new Set()); + const [selectedConnectionId, setSelectedConnectionId] = useState(null); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [agentPrefillPrompt, setAgentPrefillPrompt] = useState(""); + const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); + const [canvasAppearance, setCanvasAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); + const [backgroundMode, setBackgroundMode] = useState(DEFAULT_CANVAS_BACKGROUND_MODE); + const [showImageInfo, setShowImageInfo] = useState(false); + const [canvasTool, setCanvasTool] = useState("box-select"); + const [mediaPerformanceMode, setMediaPerformanceMode] = useState(readCanvasMediaPerformanceMode); + const [projectLoaded, setProjectLoaded] = useState(false); + const workspaceMode: CanvasWorkspaceMode = "professional"; + const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + const [tapNowImportOpen, setTapNowImportOpen] = useState(false); + const [nodeSearchOpen, setNodeSearchOpen] = useState(false); + const [toolbarNodeId, setToolbarNodeId] = useState(null); + const [arkPrivateAssetUploadNodeId, setArkPrivateAssetUploadNodeId] = useState(null); + const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); + const [dialogNodeId, setDialogNodeId] = useState(null); + const [textEditorNodeId, setTextEditorNodeId] = useState(null); + const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState(null); + const [drawingNodeId, setDrawingNodeId] = useState(null); + const [stylePickerOpen, setStylePickerOpen] = useState(false); + // 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。 + const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null); + const [infoNodeId, setInfoNodeId] = useState(null); + const [subtitleNodeId, setSubtitleNodeId] = useState(null); + const [timelineNodeId, setTimelineNodeId] = useState(null); + const [superResolveNodeId, setSuperResolveNodeId] = useState(null); + const [previewNodeId, setPreviewNodeId] = useState(null); + const [scriptEditorNodeId, setScriptEditorNodeId] = useState(null); + const [artCritiqueNodeId, setArtCritiqueNodeId] = useState(null); + const artCritiqueRunningRef = useRef(false); + const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null); + const [scriptScrollTopById, setScriptScrollTopById] = useState>({}); + const [directorNodeId, setDirectorNodeId] = useState(null); + const [versionCompareRootId, setVersionCompareRootId] = useState(null); + const [libTVImportOpen, setLibTVImportOpen] = useState(false); + const [titleEditing, setTitleEditing] = useState(false); + const [titleDraft, setTitleDraft] = useState(""); + const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0); + const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false); + const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility(); + const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]); + + const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded); + const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode(); + const [focusDockRevealed, setFocusDockRevealed] = useState(false); + + useEffect(() => { + persistCanvasMediaPerformanceMode(mediaPerformanceMode); + }, [mediaPerformanceMode]); + + useEffect(() => { + didInitialCenterRef.current = false; + setNodeStackOrder([]); + }, [projectId]); + + useEffect(() => { + const nodeIds = new Set(nodes.map((node) => node.id)); + setNodeStackOrder((current) => { + const next = current.filter((nodeId) => nodeIds.has(nodeId)); + return next.length === current.length ? current : next; + }); + }, [nodes]); + + const connectionsRef = useRef(connections); + const chatSessionsRef = useRef(chatSessions); + const activeChatIdRef = useRef(activeChatId); + const selectedNodeIdsRef = useRef(selectedNodeIds); + const viewportRef = useRef(viewport); + const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise) | null>(null); + + useEffect(() => { + if (!projectId) return; + return registerCanvasGenerationLiveProject({ + scope: canvasStorageScope, + projectId, + adapter: createCanvasGenerationLiveProjectAdapter({ nodesRef, connectionsRef, chatSessionsRef, activeChatIdRef, setNodes, setConnections, setChatSessions, setActiveChatId }), + }); + }, [canvasStorageScope, projectId]); + + const resolvedCanvasAppearance = useMemo(() => resolveCanvasAppearance(canvasAppearance, colorTheme), [canvasAppearance, colorTheme]); + const applyCanvasAppearance = useCallback( + (next: CanvasAppearance) => { + const fallback = canvasAppearanceBaseTheme(next, colorTheme); + const normalized = normalizeCanvasAppearance(next, fallback); + setCanvasAppearance(normalized); + setTheme(canvasAppearanceBaseTheme(normalized, fallback)); + }, + [colorTheme, setTheme], + ); + const saveCanvasAppearanceDefault = useCallback( + (next: CanvasAppearance) => { + writeCanvasAppearanceDefault({ appearance: next, backgroundMode }); + message.success("已保存为当前账号在本机的新建画布默认外观"); + }, + [backgroundMode, message], + ); + + const { getHistoryCleanupContext, historyPausedRef, historyState, redoCanvas, resetHistory, undoCanvas } = useCanvasHistory({ + projectLoaded, + nodes, + connections, + chatSessions, + activeChatId, + canvasAppearance, + backgroundMode, + showImageInfo, + setNodes, + setConnections, + setChatSessions, + setActiveChatId, + applyCanvasAppearance, + setBackgroundMode, + setShowImageInfo, + setSelectedNodeIds, + setSelectedConnectionId, + setContextMenu, + }); + + const cleanupCanvasFiles = useCallback( + (extra?: unknown) => { + cleanupAssetImages({ extra, ...getHistoryCleanupContext() }); + }, + [cleanupAssetImages, getHistoryCleanupContext], + ); + + const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({ + projectId, + projectLoaded, + nodes, + connections, + chatSessions, + activeChatId, + canvasAppearance, + backgroundMode, + showImageInfo, + viewport, + nodesRef, + connectionsRef, + chatSessionsRef, + activeChatIdRef, + viewportRef, + historyPausedRef, + setNodes, + setConnections, + setChatSessions, + setActiveChatId, + setCanvasAppearance, + setBackgroundMode, + setShowImageInfo, + setViewport, + setProjectLoaded, + resetHistory, + cleanupAssetImages, + cleanupCanvasFiles, + }); + + const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion); + const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); }; + const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]); + + const sendSelectionToAgent = useCallback((nodeId?: string) => { + const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current); + const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference)); + if (!references.length) return; + setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `); + openAgent(); + setContextMenu(null); + }, [agentMentionReferences, openAgent]); + // 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。 + const confirmForceSaveCanvas = useCallback(() => { + modal.confirm({ + title: "修复素材关联并保存?", + content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。", + okText: "修复并保存", + cancelText: "取消", + onOk: () => forceSaveCanvasProject(), + }); + }, [forceSaveCanvasProject, modal]); + + const applyLibTVImport = useCallback( + async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const nextNodes = [...nodesRef.current, ...importedNodes]; + const nextConnections = [...connectionsRef.current, ...importedConnections]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + const saved = await saveCanvasProject({ requireRemote: false }); + if (!saved) { + nodesRef.current = previousNodes; + connectionsRef.current = previousConnections; + setNodes(previousNodes); + setConnections(previousConnections); + throw new Error("画布保存失败,已撤销本次 LibTV 导入"); + } + }, + [saveCanvasProject, setConnections, setNodes], + ); + const applyTapNowImport = useCallback( + async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => { + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const nextNodes = [...nodesRef.current, ...importedNodes]; + const nextConnections = [...connectionsRef.current, ...importedConnections]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + const saved = await saveCanvasProject({ requireRemote: false }); + if (!saved) { + nodesRef.current = previousNodes; + connectionsRef.current = previousConnections; + setNodes(previousNodes); + setConnections(previousConnections); + throw new Error("画布保存失败,已撤销本次 TapNow 导入"); + } + }, + [saveCanvasProject, setConnections, setNodes], + ); + const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : ""; + const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) }); + const refetchLinkedProject = linkedProjectQuery.refetch; + const archiveNodesToLinkedFolder = useCallback( + (folder: CanvasNodeData, droppedNodes: CanvasNodeData[]) => { + const folderId = folder.metadata?.folder?.assetFolderId; + const domainProjectId = folder.metadata?.folder?.projectId || linkedProjectId; + if (!folderId || !domainProjectId || !droppedNodes.length) return; + void Promise.all(droppedNodes.map((node) => ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, folderId, node, source: "canvas-manual" }))) + .then((results) => { + const archivedByNodeId = new Map(droppedNodes.map((node, index) => [node.id, { assetId: results[index].assetId, content: node.metadata?.content, previousAssetId: node.metadata?.assetId }])); + setNodes((current) => + current.map((node) => { + const archived = archivedByNodeId.get(node.id); + if (!archived || node.metadata?.content !== archived.content || node.metadata?.assetId !== archived.previousAssetId) return node; + return { ...node, metadata: { ...node.metadata, assetId: archived.assetId } }; + }), + ); + void refetchLinkedProject(); + message.success(`已归档到“${folder.title}”`); + }) + .catch((error) => message.error(error instanceof Error ? error.message : "素材归档失败")); + }, + [linkedProjectId, message, projectId, refetchLinkedProject, setNodes], + ); + useEffect(() => { + if (!projectLoaded || !linkedProjectQuery.data) return; + setNodes((current) => refreshCanvasCharacterReferenceNodes(current, linkedProjectQuery.data.assets)); + }, [linkedProjectQuery.data, projectLoaded, setNodes]); + const canvasContext = useMemo(() => summarizeCanvasContext(nodes, selectedNodeIds, linkedProjectQuery.data?.units), [linkedProjectQuery.data?.units, nodes, selectedNodeIds]); + // 扩展节点(对比/图表/调色)要读自己的上游才能渲染,经 Context 下发; + // 取上游复用 canvas-resource-references 的实现,别在这里另写一份。必须 memo—— + // 每帧新对象会让所有节点跟着重渲染,错题本里多条崩溃都出在画布高频更新。 + const nodeGraphContext = useMemo(() => ({ getUpstreamNodes: (nodeId: string) => getContextResourceNodes(nodeId, nodes, connections) }), [connections, nodes]); + + const { applyGenerationTaskResult, bindGenerationTask, finishGenerationRequest, openNodeTaskDetails, runningNodeId, setRunningNodeId, setTaskDetail, startGenerationRequest, taskDetail, taskDetailLoading, taskDetailLogs } = useCanvasGeneration({ + projectId, + domainProjectId: linkedProjectId, + projectLoaded, + nodes, + nodesRef, + setNodes, + }); + + const cancelCanvasTask = useCallback( + (task: import("@/services/api/task-center").GenerationTask) => { + modal.confirm({ + title: "取消生成任务?", + content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。", + okText: "取消任务", + okButtonProps: { danger: true }, + cancelText: "继续等待", + onOk: async () => { + try { + const next = await cancelGenerationTask(task.id); + const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id); + if (node) bindGenerationTask(node.id, next); + setTaskDetail((current) => (current?.id === task.id ? next : current)); + await queryClient.invalidateQueries({ queryKey: ["canvas-active-tasks", projectId] }); + message.success("任务已取消"); + } catch (error) { + message.error(error instanceof Error ? error.message : "取消任务失败"); + } + }, + }); + }, + [bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail], + ); + + useEffect(() => { + const sessionId = searchParams.get("conversation"); + if (!projectLoaded || !sessionId) return; + if (!chatSessions.some((session) => session.id === sessionId)) { + message.warning("未找到要接续的会话,请从首页重新进入。"); + } else { + activeChatIdRef.current = sessionId; + setActiveChatId(sessionId); + openAgent(); + } + const next = new URLSearchParams(searchParams); + next.delete("conversation"); + setSearchParams(next, { replace: true }); + }, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]); + + useEffect(() => { + if (!projectLoaded || searchParams.get("agent") !== "1") return; + openAgent(); + const next = new URLSearchParams(searchParams); + next.delete("agent"); + setSearchParams(next, { replace: true }); + }, [projectLoaded, searchParams, setSearchParams, openAgent]); + + // 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。 + const prevFocusModeRef = useRef(focusMode); + useEffect(() => { + const enteredFocus = focusMode && !prevFocusModeRef.current; + prevFocusModeRef.current = focusMode; + if (!enteredFocus) return; + closeAgent(); + setIsMiniMapOpen(false); + setFocusDockRevealed(false); + }, [closeAgent, focusMode]); + + useEffect(() => { + if (!dialogNodeId) setNodeImageSettingsOpen(false); + }, [dialogNodeId]); + + useLayoutEffect(() => { + nodesRef.current = nodes; + connectionsRef.current = connections; + chatSessionsRef.current = chatSessions; + activeChatIdRef.current = activeChatId; + selectedNodeIdsRef.current = selectedNodeIds; + viewportRef.current = viewport; + }, [activeChatId, chatSessions, nodes, connections, selectedNodeIds, viewport]); + + useEffect(() => { + if (!projectLoaded) return; + const el = containerRef.current; + if (!el) return; + + const updateSize = () => { + const rect = el.getBoundingClientRect(); + setSize((current) => (current.width === rect.width && current.height === rect.height ? current : { width: rect.width, height: rect.height })); + if (!didInitialCenterRef.current) { + didInitialCenterRef.current = true; + const current = viewportRef.current; + if (current.x === 0 && current.y === 0 && current.k === 1) { + const centered = { x: rect.width / 2, y: rect.height / 2, k: 1 }; + viewportRef.current = centered; + setViewport(centered); + } + } + }; + + updateSize(); + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(el); + return () => resizeObserver.disconnect(); + }, [projectLoaded]); + + const { + fitCanvasContent, + fitCanvasSelection, + focusCanvasImageNode, + focusCanvasNode, + getCanvasCenter, + handleCanvasDoubleClick, + handleViewportChange, + handleViewportPreviewChange, + previewViewport, + screenToCanvas, + setZoomScale, + zoomCanvasIn, + zoomCanvasOut, + zoomToActualSize, + } = useCanvasViewportController({ + agentCreatedNodes, + containerRef, + size, + viewportRef, + nodesRef, + selectedNodeIdsRef, + setViewport, + setSelectedNodeIds, + setSelectedConnectionId, + setContextMenu, + setDialogNodeId, + setToolbarNodeId, + }); + + useEffect(() => { + const project = linkedProjectQuery.data?.project; + const preset = resolveProjectCanvasStyle(project?.stylePresetId, project?.styleProfileJson); + if (!projectLoaded || !preset) return; + const profile = resolveStyleProfile(project?.stylePresetId, project?.styleProfileJson, preset.profile || createStyleProfileSnapshot(preset)); + if (!profile) return; + const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard"); + const nextMetadata = { + content: profile.prompt, + prompt: profile.prompt, + status: NODE_STATUS_SUCCESS, + workflowKind: "styleboard" as const, + workflowTitle: "项目画风", + workflowDescription: profile.description, + stylePresetId: profile.presetId, + styleProfileJson: serializeStyleProfile(profile), + fontSize: 14, + locked: true, + }; + if (current) { + if (current.metadata?.stylePresetId === profile.presetId && current.metadata?.content === profile.prompt && current.metadata?.styleProfileJson === nextMetadata.styleProfileJson && current.metadata?.locked) return; + setNodes((nodes) => nodes.map((node) => (node.id === current.id ? { ...node, title: `项目画风 · ${profile.title}`, metadata: { ...node.metadata, ...nextMetadata } } : node))); + return; + } + const node = createCanvasNode(CanvasNodeType.Text, getCanvasCenter(), nextMetadata); + node.title = `项目画风 · ${profile.title}`; + node.width = 420; + node.height = 240; + setNodes((nodes) => [...nodes, node]); + }, [getCanvasCenter, linkedProjectQuery.data?.project, projectLoaded, setNodes]); + + const { + assetPickerOpen, + closeUploadModal, + closeAssetPicker, + createVideoNodeFromBlob, + createImageAssetNode, + fileDropActive, + handleAssetsInsert, + handleDrop, + handleFileDragEnter, + handleFileDragLeave, + handleFileDragOver, + handleImageInputChange, + handleProjectAssetsInsert, + handleProjectChapterInsert, + handleUploadFiles, + handleUploadRequest, + imageInputRef, + openAssetsAtPosition, + pasteAssistantImage, + pasteSystemClipboard, + replaceNodeMedia, + createFileNode, + startUploadStatus, + uploadModalOpen, + uploadTimelineMedia, + uploadStatus, + } = useCanvasUpload({ + canvasId: projectId, + domainProjectId: linkedProjectId, + nodesRef, + selectedNodeIdsRef, + getCanvasCenter, + screenToCanvas, + setNodes, + setSelectedNodeIds, + setSelectedConnectionId, + setContextMenu, + setDialogNodeId, + }); + const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]); + const { + timelineAddNodeRef, + timelineMediaAddRef, + assetInsertScope, + projectAssetScope, + projectAssetOpen, + projectAssetInitialCategory, + projectAssetInitialFolderId, + projectAssetInsertPosition, + handleLibraryAssetsInsert, + handleTimelineProjectAssetsInsert, + openProjectAssets, + openCanvasAssetLibrary, + openTimelineAssetLibrary, + closeProjectAssets, + } = useCanvasTimelineAssetInsert({ + linkedProjectId, + refetchLinkedProject, + handleAssetsInsert, + handleProjectAssetsInsert, + openAssetsAtPosition, + }); + + useEffect(() => { + if (!projectLoaded || searchParams.get("mode") !== "handoff") return; + void loadAssetsForUse(canvasAssetHandoffIds(searchParams)).catch((error) => message.error(error instanceof Error ? error.message : "转入素材读取失败")); + }, [projectLoaded, searchParams, message]); + + useEffect(() => { + if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return; + const attempt = canvasAssetHandoffAttempt(assets, searchParams); + const { assetIds, payloads } = attempt; + if (!assetIds.length) return; + const assetReadiness = assetIds + .map((assetId) => { + const asset = assets.find((candidate) => candidate.id === assetId); + return `${assetId}:${asset?.kind || "missing"}`; + }) + .join("|"); + const handoffKey = `${projectId}:${assetReadiness}`; + if (assetHandoffRef.current === handoffKey) return; + assetHandoffRef.current = handoffKey; + + if (attempt.kind === "retry") return; + const pendingPayloads = uninsertedCanvasAssetHandoffPayloads(nodesRef.current, payloads); + const persistHandoff = async (createdNodes: CanvasNodeData[]) => { + const finalized = await finalizeCanvasAssetHandoff({ + searchParams, + currentNodes: nodesRef.current, + createdNodes, + persist: async (nextNodes) => { + nodesRef.current = nextNodes; + updateProject(projectId, { nodes: nextNodes }); + await flushCanvasStorePersistence(); + }, + }); + setSearchParams(finalized.searchParams, { replace: true }); + }; + const insertion = pendingPayloads.length ? handleProjectAssetsInsert(pendingPayloads) : Promise.resolve([] as CanvasNodeData[]); + void insertion.then(persistHandoff).catch(() => { + assetHandoffRef.current = ""; + }); + }, [assets, assetsHydrated, handleProjectAssetsInsert, message, nodesRef, projectId, projectLoaded, searchParams, setSearchParams, updateProject]); + + const { + angleNodeId, + lightingNodeId, + emotionNodeId, + annotationNodeId, + annotationEditNodeId, + createImageReversePromptNodes, + openPortraitTextureEditor, + cropImageNode, + cropNodeId, + closeFrameDialog, + closeSegmentDialog, + extractAudioFromVideo, + extractVideoFrames, + extractingVideoFramesNodeId, + frameDialogNodeId, + generateAngleNode, + generateLightingNode, + openPanoramaConfig, + createPanoramaViewerWithConfig, + addPanoramaCaptureNode, + panoramaConfigNodeId, + setPanoramaConfigNodeId, + generateEmotionNode, + handleSegmentConfirm, + maskEditImageNode, + maskEditNodeId, + imageEditNodeId, + imageEditPreset, + layerDecompositionNodeId, + textEditNodeId, + mergeSelectedVideos, + mergeVideosByIds, + mergeVideoProgress, + saveAnnotatedImageNode, + segmentDialogMode, + segmentDialogNodeId, + segmentRunningMode, + setFrameDialogNodeId, + setSegmentDialogNodeId, + setAngleNodeId, + setLightingNodeId, + setEmotionNodeId, + setAnnotationNodeId, + setAnnotationEditNodeId, + setCropNodeId, + setMaskEditNodeId, + setImageEditNodeId, + setImageEditPreset, + openImageEditNode, + openBackgroundRemoval, + openLayerDecomposition, + decomposeImageLayers, + setLayerDecompositionNodeId, + setTextEditNodeId, + openTextEditNode, + openAnnotationEditNode, + detectImageText, + editTextImageNode, + editAnnotatedImageNode, + editImageNode, + setUpscaleNodeId, + splitImageNode, + openVideoFrameExtractor, + openVideoSegmentExtractor, + upscaleImageNode, + upscaleNodeId, + } = useCanvasMediaTools({ + projectId, + domainProjectId: linkedProjectId, + nodesRef, + connectionsRef, + selectedNodeIdsRef, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setDialogNodeId, + setContextMenu, + setHoveredNodeId, + setToolbarNodeId, + setRunningNodeId, + startUploadStatus, + startGenerationRequest, + finishGenerationRequest, + bindGenerationTask, + }); + + const handleNodesDeleted = useCallback( + (removedIds: Set, nextNodes: CanvasNodeData[], removedNodes: CanvasNodeData[]) => { + const clearDeletedId = (current: string | null) => (current && removedIds.has(current) ? null : current); + setHoveredNodeId(clearDeletedId); + setToolbarNodeId(clearDeletedId); + setDialogNodeId(clearDeletedId); + setTextEditorNodeId(clearDeletedId); + setCharacterReferenceNodeId(clearDeletedId); + setDrawingNodeId(clearDeletedId); + setInfoNodeId(clearDeletedId); + setSubtitleNodeId(clearDeletedId); + setFrameDialogNodeId(clearDeletedId); + setSegmentDialogNodeId(clearDeletedId); + setCropNodeId(clearDeletedId); + setMaskEditNodeId(clearDeletedId); + setAnnotationNodeId(clearDeletedId); + setUpscaleNodeId(clearDeletedId); + setAngleNodeId(clearDeletedId); + setLightingNodeId(clearDeletedId); + setEmotionNodeId(clearDeletedId); + setSuperResolveNodeId(clearDeletedId); + setPreviewNodeId(clearDeletedId); + setRunningNodeId(clearDeletedId); + setScriptEditorNodeId(clearDeletedId); + setArtCritiqueNodeId(clearDeletedId); + setDirectorNodeId(clearDeletedId); + setVersionCompareRootId(clearDeletedId); + setScriptScrollTopById((current) => Object.fromEntries(Object.entries(current).filter(([id]) => !removedIds.has(id)))); + setContextMenu((current) => (current?.type === "node" && removedIds.has(current.nodeId) ? null : current)); + const removedDrawingIds = removedNodes.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : [])); + if (removedDrawingIds.length) { + void Promise.all(removedDrawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("绘图节点已删除,但本地绘图缓存清理失败")); + } + cleanupCanvasFiles({ projectId, nodes: nextNodes, chatSessions }); + }, + [ + chatSessions, + cleanupCanvasFiles, + message, + projectId, + setAngleNodeId, + setAnnotationNodeId, + setArtCritiqueNodeId, + setCropNodeId, + setEmotionNodeId, + setFrameDialogNodeId, + setLightingNodeId, + setMaskEditNodeId, + setSegmentDialogNodeId, + setUpscaleNodeId, + setRunningNodeId, + ], + ); + + const { + alignSelectedNodes, + autoArrangeCanvasNodes, + arrangeSelectedNodes, + spreadSelectedNodes, + copyNodesToClipboard, + copySelectedNodes, + createFolder, + createNode, + createReferenceGroup, + createStoryboardGroup, + deleteConnection, + deleteNodes, + duplicateNode, + hasCopiedNodes, + pasteCopiedNodes, + restoreCopiedNodesFromText, + releaseCopiedNodesPastePriority, + setPrimaryVersion, + shouldPreferCopiedNodes, + toggleNodeLocked, + } = useCanvasNodeOperations({ + projectId, + defaultDrawingEngine, + nodesRef, + connectionsRef, + selectedNodeIdsRef, + getCanvasCenter, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setContextMenu, + setDialogNodeId, + onNodesDeleted: handleNodesDeleted, + }); + + const handleReplaceNodeReference = useCallback( + (targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, sourceNodeId: string) => { + const sourceNode = nodesRef.current.find((n) => n.id === sourceNodeId); + if (!sourceNode || sourceNode.id === oldReference.nodeId) return; + + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + + const configNodeId = previousConnections.find((c) => c.fromNodeId === targetNodeId && previousNodes.find((n) => n.id === c.toNodeId)?.type === CanvasNodeType.Config)?.toNodeId; + const receiverId = configNodeId || targetNodeId; + + let rewired = false; + const nextConnections = previousConnections.map((c) => { + if (!rewired && c.fromNodeId === oldReference.nodeId && (c.toNodeId === targetNodeId || c.toNodeId === configNodeId)) { + rewired = true; + return { ...c, fromNodeId: sourceNodeId }; + } + return c; + }); + + if (!rewired) { + nextConnections.push({ + id: nanoid(), + fromNodeId: sourceNodeId, + toNodeId: receiverId, + }); + } + + const nextReferencesMap = buildCanvasNodeMentionReferenceMap(previousNodes, nextConnections, previousNodes); + const targetNextReferences = nextReferencesMap.get(targetNodeId) || []; + const newRef = targetNextReferences.find((r) => r.nodeId === sourceNodeId); + + const targetNode = previousNodes.find((n) => n.id === targetNodeId); + let nextNodes = previousNodes; + if (targetNode && newRef) { + const currentPrompt = targetNode.metadata?.composerContent ?? targetNode.metadata?.prompt ?? ""; + const replacementToken = `@${newRef.label}`; + const updatedPrompt = replaceCanvasReferenceMentions(currentPrompt, oldReference, replacementToken, sourceNode.title ? sourceNode.title : newRef.label); + + nextNodes = previousNodes.map((n) => (n.id === targetNodeId ? writeCanvasNodePrompt(n, updatedPrompt) : n)); + } + + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + message.success(`已将参考图「${oldReference.label || "参考图"}」替换为「${sourceNode.title || "新图片"}」,提示词已同步更新`); + }, + [connectionsRef, message, nodesRef, setConnections, setNodes], + ); + + const handleReplaceNodeReferenceFiles = useCallback( + (_targetNodeId: string, oldReference: { id: string; nodeId?: string; label?: string; title?: string }, files: File[]) => { + const file = files.find((f) => f.type.startsWith("image/")); + if (!file || !oldReference.nodeId) return; + void replaceNodeMedia(oldReference.nodeId, file).then((success: boolean) => { + if (success) { + message.success("参考图片已替换"); + } + }); + }, + [message, replaceNodeMedia], + ); + + const { + cancelPendingConnectionCreate, + closeConnectionCreateMenu, + connectionTargetAnchorRatio, + connectionTargetNodeId, + connectionApproach, + connectionReplaceHover, + connectingParams, + createConnectedNode, + getConnectionCreateDisabledReason, + handleConnectStart, + handleConnectDrop, + handleBatchConnectionTargetClick, + batchConnectionPreview, + beginBatchConnectionMode, + startBatchConnection, + mouseWorld, + pendingConnectionCreate, + setConnecting, + } = useCanvasConnectionController({ + projectId, + config: effectiveConfig, + defaultDrawingEngine, + nodesRef, + connectionsRef, + viewportRef, + scriptScrollTopById, + screenToCanvas, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setContextMenu, + setDialogNodeId, + setDrawingNodeId, + onReplaceReference: handleReplaceNodeReference, + }); + + const batchSourceNodeIds = useMemo(() => nodes.filter((node) => selectedNodeIds.has(node.id) && !batchSourceRestriction(node)).map((node) => node.id), [nodes, selectedNodeIds]); + + const handleCanvasSelectionStart = useCallback(() => { + setContextMenu(null); + }, []); + + const handleNodeInteractionStart = useCallback((selectionModifier: boolean) => { + setContextMenu(null); + setHoveredNodeId(null); + setToolbarNodeId(null); + if (selectionModifier) setDialogNodeId(null); + }, []); + + const handleSelectedNodeClick = useCallback( + (node: CanvasNodeData) => { + // Selection is transient, but the LibTV-style paint order survives + // deselection so a clicked lower node stays above its neighbours. + if (node.type !== CanvasNodeType.Frame) bringNodeToFront(node.id); + if (node.type === CanvasNodeType.Drawing) { + setDialogNodeId(null); + setDrawingNodeId(node.id); + } else if (node.type === CanvasNodeType.Script) { + setDialogNodeId(null); + } else if (node.type === CanvasNodeType.Text) { + setDialogNodeId(node.id); + } else if (node.type === CanvasNodeType.Frame) { + setDialogNodeId((current) => (current === node.id ? current : null)); + } else if (node.type === ART_CRITIQUE_NODE_TYPE) { + setDialogNodeId(null); + setArtCritiqueNodeId(node.id); + } else if (node.type === CanvasNodeType.Panorama) { + // 全景节点是纯查看器,没有可编辑提示词,不弹提示词面板。 + setDialogNodeId(null); + } else { + // 选择参考媒体时保留当前工作流配置面板,避免点击图片后配置“返回/消失”。 + // 没有工作流配置面板时,媒体节点仍按原逻辑打开自己的面板。 + setDialogNodeId((current) => { + const currentNode = current ? nodesRef.current.find((item) => item.id === current) : undefined; + return currentNode?.type === CanvasNodeType.Config ? current : node.id; + }); + } + }, + [bringNodeToFront, nodesRef], + ); + + const handleNodeBringToFront = useCallback( + (nodeId: string) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + if (node && node.type !== CanvasNodeType.Frame) bringNodeToFront(nodeId); + }, + [bringNodeToFront, nodesRef], + ); + + const handleNodeDragEnd = useCallback( + (nodeId: string) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === ART_CRITIQUE_NODE_TYPE) { + setDialogNodeId(null); + return; + } + // A drag selects a new node even though it is not a click. Keep the + // generation editor bound to the node most recently moved so a stale + // panel from the previous node cannot reappear after mouse-up. + setDialogNodeId(node.id); + }, + [nodesRef], + ); + + const handleCanvasDeselect = useCallback(() => { + setContextMenu(null); + setHoveredNodeId(null); + setToolbarNodeId(null); + setDialogNodeId(null); + }, []); + + const { alignmentGuides, cancelSelectionBox, deselectCanvas, dragPreview, frameDropTargetId, handleCanvasMouseDown, handleNodeMouseDown, isNodeDragging, nodeDraggingRef, selectionBoundsElementRef, selectionBox } = useCanvasSelectionController({ + containerRef, + nodesRef, + viewportRef, + selectedNodeIdsRef, + historyPausedRef, + screenToCanvas, + setNodes, + setSelectedNodeIds, + setSelectedConnectionId, + cancelPendingConnectionCreate, + onCanvasSelectionStart: handleCanvasSelectionStart, + onNodeInteractionStart: handleNodeInteractionStart, + onNodeBringToFront: handleNodeBringToFront, + onNodeClick: handleSelectedNodeClick, + onNodeDragEnd: handleNodeDragEnd, + onBatchConnectionTarget: handleBatchConnectionTargetClick, + onLinkedFolderDrop: archiveNodesToLinkedFolder, + onDeselect: handleCanvasDeselect, + }); + + const keepNodeToolbar = useCallback( + (nodeId: string) => { + if (nodeDraggingRef.current || nodeImageSettingsOpen) return; + if (toolbarHideTimerRef.current) { + clearTimeout(toolbarHideTimerRef.current); + toolbarHideTimerRef.current = null; + } + setToolbarNodeId(nodeId); + }, + [nodeImageSettingsOpen], + ); + + const hideNodeToolbar = useCallback(() => { + if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current); + toolbarHideTimerRef.current = setTimeout(() => { + setToolbarNodeId(null); + toolbarHideTimerRef.current = null; + }, 120); + }, []); + + const { + collapsingBatchIds, + downloadNodeImage, + handleConfigNodeChange, + handleFolderStyleChange, + handleFolderThemeChange, + handleFontSizeChange, + handleNodeContentChange, + handleNodePromptChange, + handleNodeResize, + handleNodeTitleChange, + openingBatchIds, + saveNodeAsset, + setBatchPrimary, + toggleBatchExpanded, + toggleFrameCollapsed, + toggleNodeFreeResize, + } = useCanvasNodeEditor({ + canvasId: projectId, + canvasTitle: currentProject?.title || "未命名画布", + domainProjectId: linkedProjectId, + nodesRef, + setNodes, + setSelectedNodeIds, + setSelectedConnectionId, + setDialogNodeId, + setToolbarNodeId, + setHoveredNodeId, + }); + + const handleRemoveNodeReference = useCallback( + (targetNodeId: string, reference: CanvasResourceReference) => { + const referenceNodeId = reference.nodeId; + if (!referenceNodeId) return; + // 生成节点可能通过配置节点接收参考,只移除参考来源边,保留目标到配置节点的主链。 + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const configNodeId = previousConnections.find((connection) => { + if (connection.fromNodeId !== targetNodeId) return false; + return previousNodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config; + })?.toNodeId; + const removedConnectionIds = new Set(previousConnections.filter((connection) => connection.fromNodeId === referenceNodeId && (connection.toNodeId === targetNodeId || connection.toNodeId === configNodeId)).map((connection) => connection.id)); + if (!removedConnectionIds.size) return; + const nextConnections = previousConnections.filter((connection) => !removedConnectionIds.has(connection.id)); + const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections); + if (nextNodes !== previousNodes) { + nodesRef.current = nextNodes; + setNodes(nextNodes); + } + connectionsRef.current = nextConnections; + setConnections(nextConnections); + setSelectedConnectionId((current) => (current && removedConnectionIds.has(current) ? null : current)); + }, + [connectionsRef, nodesRef, setConnections, setNodes, setSelectedConnectionId], + ); + + const handleReorderNodeReferences = useCallback( + (targetNodeId: string, orderedNodeIds: string[]) => { + const previousNodes = nodesRef.current; + const previousConnections = connectionsRef.current; + const nextConnections = reorderCanvasResourceConnections(targetNodeId, orderedNodeIds, previousNodes, previousConnections); + if (nextConnections === previousConnections) return; + const nextNodes = applyCanvasConnectionPromptSync(previousNodes, previousConnections, previousNodes, nextConnections); + if (nextNodes !== previousNodes) { + nodesRef.current = nextNodes; + setNodes(nextNodes); + } + connectionsRef.current = nextConnections; + setConnections(nextConnections); + }, + [connectionsRef, nodesRef, setConnections, setNodes], + ); + + const handleProjectFolderInsert = useCallback( + (folderId: string) => { + const folder = linkedProjectQuery.data?.assetFolders.find((item) => item.id === folderId); + if (!folder || !linkedProjectId) throw new Error("素材文件夹已不存在,请刷新后重试"); + const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass"; + const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora"; + createFolder(projectAssetInsertPosition, { id: folder.id, projectId: linkedProjectId, title: folder.name, style, theme, createdAt: folder.createdAt }); + }, + [createFolder, linkedProjectId, linkedProjectQuery.data?.assetFolders, projectAssetInsertPosition], + ); + + const handleFrameToggle = useCallback( + (nodeId: string) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + const linkedFolderId = node?.metadata?.folder?.assetFolderId; + if (linkedFolderId) { + openProjectAssets("all", node ? { x: node.position.x + node.width + 40, y: node.position.y } : undefined, "canvas", linkedFolderId); + return; + } + toggleFrameCollapsed(nodeId); + }, + [nodesRef, openProjectAssets, toggleFrameCollapsed], + ); + + const linkedFolderPreviewNodesById = useMemo(() => { + const result = new Map(); + const localById = new Map(assets.map((asset) => [asset.id, asset])); + for (const asset of linkedProjectQuery.data?.assets || []) { + if (!asset.folderId) continue; + const local = localById.get(asset.id); + const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0]; + const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text; + const remoteResourceId = resourceIdFromStorageKey(asset.storageKey); + const content = characterCover + ? resourceFileUrl(characterCover.resourceId) + : local?.kind === "image" + ? local.data.dataUrl || local.coverUrl + : local?.kind === "video" || local?.kind === "audio" + ? local.data.url + : local?.kind === "text" + ? local.data.content + : remoteResourceId + ? resourceFileUrl(remoteResourceId) + : asset.previewText || ""; + const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } }; + const current = result.get(asset.folderId) || []; + current.push(preview); + result.set(asset.folderId, current); + } + return result; + }, [assets, linkedProjectQuery.data?.assets]); + + useEffect(() => { + const folders = linkedProjectQuery.data?.assetFolders; + if (!folders?.length) return; + const byId = new Map(folders.map((folder) => [folder.id, folder])); + setNodes((current) => { + let changed = false; + const next = current.map((node) => { + const folderId = node.metadata?.folder?.assetFolderId; + const folder = folderId ? byId.get(folderId) : undefined; + if (!folder) return node; + const style: CanvasFolderStyle = folder.style === "stacked" || folder.style === "midnight" || folder.style === "paper" || folder.style === "cinema" || folder.style === "compact" ? folder.style : "glass"; + const theme: CanvasFolderTheme = folder.theme === "obsidian" || folder.theme === "ember" || folder.theme === "pearl" ? folder.theme : "aurora"; + if (node.title === folder.name && node.metadata?.folder?.style === style && node.metadata?.folder?.theme === theme) return node; + changed = true; + return { ...node, title: folder.name, metadata: { ...node.metadata, folder: { ...node.metadata!.folder!, style, theme, themeCover: undefined } } }; + }); + return changed ? next : current; + }); + }, [linkedProjectQuery.data?.assetFolders, setNodes]); + + const { + activeDirectorScene, + activeNodeId, + activeScriptNode, + activeStylePresetId, + angleNode, + lightingNode, + emotionNode, + annotationNode, + batchChildCountById, + batchMotionById, + canvasImageNodes, + configInputsById, + connectionLayerBounds, + contextMenuNode, + cropNode, + displayConnections, + frameChildrenById, + imageAssets, + infoNode, + maskEditNode, + imageEditNode, + mentionReferencesByNodeId, + nodeById, + previewNode, + reduceMediaEffects, + relatedHighlight, + resourceReferenceByNodeId, + selectedNodeBounds, + selectedVideoNodes, + skillMentionReferences, + superResolveNode, + toolbarNode, + upscaleNode, + versionCompareNodes, + visibleNodes, + } = useCanvasRenderModel({ + nodes, + connections, + assets, + viewport, + viewportSize: size, + mediaPerformanceMode, + selectedNodeIds, + hoveredNodeId, + dragPreview, + collapsingBatchIds, + addedSkills, + directorScenes: currentProject?.directorScenes, + infoNodeId, + cropNodeId, + maskEditNodeId, + imageEditNodeId, + annotationNodeId, + splitNodeId: null, + upscaleNodeId, + superResolveNodeId, + angleNodeId, + lightingNodeId, + emotionNodeId, + previewNodeId, + contextMenu, + versionCompareRootId, + directorNodeId, + scriptEditorNodeId, + dialogNodeId, + }); + useEffect(() => { + setNodes((current) => { + let changed = false; + const next = current.map((node) => { + const references = mentionReferencesByNodeId.get(node.id); + const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt; + if (!references?.length || !savedPrompt?.includes("@[node:")) return node; + const normalizedPrompt = normalizeCanvasNodeMentionTokens(savedPrompt, references); + if (normalizedPrompt === savedPrompt) return node; + changed = true; + return { + ...node, + metadata: node.metadata?.composerContent !== undefined ? { ...node.metadata, composerContent: normalizedPrompt } : { ...node.metadata, prompt: normalizedPrompt }, + }; + }); + return changed ? next : current; + }); + }, [mentionReferencesByNodeId, setNodes]); + const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null; + // dragPreview is published on the same pointer-down frame as isNodeDragging. + // Treat either signal as moving so floating editors disappear before the + // first preview transform is painted and never affect drag layout. + const isCanvasNodeMoving = isNodeDragging || Boolean(dragPreview?.nodeIds.size); + const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null; + const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null; + const frameNode = frameDialogNodeId ? nodeById.get(frameDialogNodeId) || null : null; + const segmentNode = segmentDialogNodeId ? nodeById.get(segmentDialogNodeId) || null : null; + const textEditorNode = textEditorNodeId ? nodeById.get(textEditorNodeId) || null : null; + const characterReferenceNode = characterReferenceNodeId ? nodeById.get(characterReferenceNodeId) || null : null; + const drawingNode = drawingNodeId ? nodeById.get(drawingNodeId) || null : null; + const artCritiqueNode = artCritiqueNodeId ? nodeById.get(artCritiqueNodeId) || null : null; + const artCritiqueInputs = artCritiqueNode + ? connections + .filter((connection) => connection.toNodeId === artCritiqueNode.id) + .sort((left, right) => left.id.localeCompare(right.id)) + .map((connection) => nodeById.get(connection.fromNodeId)) + .filter((node): node is CanvasNodeData => Boolean(node)) + : []; + const pendingConnectionSourceNode = pendingConnectionCreate?.connection.handleType === "source" ? nodeById.get(pendingConnectionCreate.connection.nodeId) : null; + const canCreateDrawingFromConnection = !pendingConnectionCreate?.batchSourceNodeIds?.length && pendingConnectionSourceNode?.type === CanvasNodeType.Image && Boolean(pendingConnectionSourceNode.metadata?.content); + + const openTextNodeEditor = useCallback((node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Text) return; + setSelectedNodeIds(new Set([node.id])); + setSelectedConnectionId(null); + setContextMenu(null); + setDialogNodeId(null); + setToolbarNodeId(null); + if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) { + setCharacterReferenceNodeId(node.id); + return; + } + setTextEditorNodeId(node.id); + }, []); + + const openDrawingNode = useCallback((node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Drawing) return; + setSelectedNodeIds(new Set([node.id])); + setSelectedConnectionId(null); + setContextMenu(null); + setDialogNodeId(null); + setToolbarNodeId(null); + setDrawingNodeId(node.id); + }, []); + + const openArtCritique = useCallback((node: CanvasNodeData) => { + if (node.type !== ART_CRITIQUE_NODE_TYPE) return; + setSelectedNodeIds(new Set([node.id])); + setSelectedConnectionId(null); + setContextMenu(null); + setDialogNodeId(null); + setToolbarNodeId(null); + setArtCritiqueNodeId(node.id); + }, []); + const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]); + const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]); + const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => { + setNodesState((current) => { + const next = updateCanvasNode(current, nodeId, update); + nodesRef.current = next; + return next; + }); + }, []); + const pendingMediaUpdatesRef = useRef(new Map CanvasNodeData>()); + const mediaUpdateTimerRef = useRef | null>(null); + const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => { + const previous = pendingMediaUpdatesRef.current.get(nodeId); + pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update); + if (mediaUpdateTimerRef.current) return; + mediaUpdateTimerRef.current = setTimeout(() => { + const updates = pendingMediaUpdatesRef.current; + pendingMediaUpdatesRef.current = new Map(); + mediaUpdateTimerRef.current = null; + if (!updates.size) return; + setNodesState((current) => { + const next = updateCanvasNodes(current, updates); + nodesRef.current = next; + return next; + }); + }, 120); + }, []); + const updateNodeMetadataFromContent = useCallback( + (nodeId: string, patch: CanvasNodeMetadata) => { + updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } })); + }, + [updateNodeFromContent], + ); + const canvasNodeActions = useMemo( + () => ({ + upload: replaceCanvasNodeMedia, + download: downloadNodeImage, + duplicate: duplicateNodeFromContent, + deleteNode: deleteNodeFromContent, + updateMetadata: updateNodeMetadataFromContent, + updateNode: updateNodeFromContent, + updateMediaNode: updateMediaNodeFromContent, + openArtCritique, + addPanoramaCaptureNode, + }), + [addPanoramaCaptureNode, deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent], + ); + const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasOperationHistory({ + projectId, + domainProjectId: currentProject?.projectId, + projectTitle: currentProject?.title || "未命名画布", + nodes, + connections, + selectedNodeIds, + viewport, + nodesRef, + connectionsRef, + selectedNodeIdsRef, + viewportRef, + generateNodeRef, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setViewport, + setContextMenu, + focusSelection: fitCanvasSelection, + }); + + const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({ + canvasId: projectId, + domainProjectId: currentProject?.projectId, + nodesRef, + selectedNodeIdsRef, + getCanvasCenter, + setNodes, + setSelectedNodeIds, + setSelectedConnectionId, + setDialogNodeId, + setStylePickerOpen, + }); + + const { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene } = useCanvasDirector({ + projectId, + domainProjectId: currentProject?.projectId, + directorNodeId, + directorScenes: currentProject?.directorScenes || [], + nodesRef, + connectionsRef, + getCanvasCenter, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setDirectorNodeId, + updateProject, + }); + + const { + activateStep: activateShortDramaStep, + createPipeline: createShortDramaPipeline, + guideCollapsed: shortDramaGuideCollapsed, + openStoryInput, + progress: shortDramaProgress, + setGuideCollapsed: setShortDramaGuideCollapsed, + skipGuide: skipShortDramaGuide, + } = useCanvasShortDrama({ + nodes, + connections, + nodesRef, + connectionsRef, + selectedNodeIdsRef, + getCanvasCenter, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setStylePickerOpen, + fitCanvasSelection, + focusCanvasNode, + openTextEditor: openTextNodeEditor, + }); + + const shortDramaGuide = shortDramaEnabled && !currentProject?.projectId && shortDramaProgress.active ? { progress: shortDramaProgress, collapsed: shortDramaGuideCollapsed, onToggle: () => setShortDramaGuideCollapsed((value) => !value) } : undefined; + + const clearCanvas = useCallback(() => { + const drawingIds = nodesRef.current.flatMap((node) => (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId ? [node.metadata.drawingId] : [])); + if (drawingIds.length) { + void Promise.all(drawingIds.map((drawingId) => removeCanvasDrawing(projectId, drawingId))).catch(() => message.warning("画布已清空,但部分本地绘图缓存清理失败")); + } + setNodes([]); + setConnections([]); + setTextEditorNodeId(null); + setDrawingNodeId(null); + setInfoNodeId(null); + setSubtitleNodeId(null); + setCropNodeId(null); + setMaskEditNodeId(null); + setAnnotationNodeId(null); + setAngleNodeId(null); + setLightingNodeId(null); + setEmotionNodeId(null); + setPreviewNodeId(null); + setRunningNodeId(null); + setArtCritiqueNodeId(null); + deselectCanvas(); + setClearConfirmOpen(false); + clearCanvasFiles(); + }, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]); + + useCanvasKeyboard({ + enabled: projectLoaded && !versions.preview, + nodesRef, + selectedNodeIdsRef, + selectedConnectionId, + setSelectedNodeIds, + setSelectedConnectionId, + setContextMenu, + setShortcutRequestNonce, + setInfoNodeId, + setCropNodeId, + setMaskEditNodeId, + setAnnotationNodeId, + saveCanvasProject, + zoomToActualSize, + fitCanvasContent, + fitCanvasSelection, + undoCanvas, + redoCanvas, + cancelSelectionBox, + copySelectedNodes, + pasteCopiedNodes, + restoreCopiedNodesFromText, + shouldPreferCopiedNodes, + pasteSystemClipboard, + deleteNodes, + deleteConnection, + deselectCanvas, + zoomCanvasIn, + zoomCanvasOut, + focusMode, + exitFocusMode, + toggleFocusMode, + onOpenSearch: () => setNodeSearchOpen(true), + beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)), + }); + + const handleAssistantSessionsChange = useCallback((sessions: CanvasAssistantSession[], activeId: string | null) => { + chatSessionsRef.current = sessions; + activeChatIdRef.current = activeId; + setChatSessions(sessions); + setActiveChatId(activeId); + }, []); + + const startTitleEditing = useCallback(() => { + setTitleDraft(currentProject?.title || "未命名画布"); + setTitleEditing(true); + }, [currentProject?.title]); + + const finishTitleEditing = useCallback(() => { + const nextTitle = titleDraft.trim(); + if (nextTitle) renameCurrentProject(nextTitle); + setTitleEditing(false); + }, [renameCurrentProject, titleDraft]); + + const pasteAtPosition = useCallback( + (position: Position) => { + if (shouldPreferCopiedNodes() && pasteCopiedNodes(position)) return; + void (async () => { + try { + // 标记写入成功时仍优先系统图片,兼容截图和从外部应用复制的媒体。 + const handled = await pasteSystemClipboard(position); + if (!handled) pasteCopiedNodes(position); + } catch { + if (!pasteCopiedNodes(position)) message.warning("无法读取剪贴板内容"); + } + })(); + }, + [message, pasteCopiedNodes, pasteSystemClipboard, shouldPreferCopiedNodes], + ); + + const copyingNodeContentRef = useRef(false); + const copyNodeContentToClipboard = useCallback( + async (node: CanvasNodeData | null) => { + if (copyingNodeContentRef.current) return; + copyingNodeContentRef.current = true; + releaseCopiedNodesPastePriority(); + const content = node?.metadata?.content?.trim(); + const resourceId = resourceIdFromStorageKey(node?.metadata?.storageKey); + const copySource = content || (node?.type === CanvasNodeType.Image && resourceId ? resourceFileUrl(resourceId) : ""); + if (!node || !copySource) { + copyingNodeContentRef.current = false; + message.warning("没有可复制的内容"); + return; + } + + try { + if (node.type === CanvasNodeType.Image) { + try { + await copyImageToSystemClipboard(copySource, node.metadata?.storageKey); + message.success("图片已复制到剪贴板"); + return; + } catch (imageErr) { + const fallbackUrl = new URL(copySource, window.location.href).toString(); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(fallbackUrl).catch(() => undefined); + message.info("由于浏览器未获得焦点,已为您复制图片地址"); + return; + } + if (await copyToClipboard(fallbackUrl)) { + message.info("由于浏览器未获得焦点,已为您复制图片地址"); + return; + } + throw imageErr; + } + } + + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(copySource); + else if (!copyToClipboard(copySource)) throw new Error("当前浏览器不支持写入剪贴板"); + message.success(node.type === CanvasNodeType.Text ? "文本已复制" : "内容链接已复制"); + } catch (error) { + message.error(error instanceof Error ? error.message : "复制失败,请检查浏览器剪贴板权限"); + } finally { + copyingNodeContentRef.current = false; + } + }, + [message, releaseCopiedNodesPastePriority], + ); + + const copyNodeMediaUrlToClipboard = useCallback( + async (node: CanvasNodeData | null) => { + releaseCopiedNodesPastePriority(); + try { + const storageKey = node?.metadata?.storageKey; + const content = node?.metadata?.content?.trim(); + const resourceId = resourceIdFromStorageKey(storageKey); + const mediaPath = content && !content.startsWith("data:") && !content.startsWith("blob:") ? content : resourceId ? resourceFileUrl(resourceId) : ""; + const mediaURL = mediaPath ? new URL(mediaPath, window.location.href).toString() : ""; + if (!mediaURL) throw new Error("当前媒体只有本地内容,没有可复制的地址"); + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(mediaURL); + else if (!(await copyToClipboard(mediaURL))) throw new Error("当前浏览器不支持写入剪贴板"); + message.success(node?.type === CanvasNodeType.Video ? "视频地址已复制" : "图片地址已复制"); + } catch (error) { + message.error(error instanceof Error ? error.message : "媒体地址复制失败"); + } + }, + [message, releaseCopiedNodesPastePriority], + ); + + const uploadNodeImageToArkPrivateAsset = useCallback( + async (node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { + message.warning("请选择一张可用图片后再上传"); + return; + } + if (arkPrivateAssetUploadNodeId === node.id) return; + const feedbackKey = `ark-private-asset-${node.id}`; + setArkPrivateAssetUploadNodeId(node.id); + message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 }); + try { + let resourceID = resourceIdFromStorageKey(node.metadata.storageKey); + let persistedNode = node; + if (!resourceID) { + const uploaded = await uploadImage(node.metadata.content); + resourceID = resourceIdFromStorageKey(uploaded.storageKey); + if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试"); + handleConfigNodeChange(node.id, imageMetadata(uploaded)); + persistedNode = { ...node, metadata: { ...node.metadata, ...imageMetadata(uploaded) } }; + } + const asset = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId: currentProject?.projectId, node: persistedNode, source: "canvas-manual" }); + handleConfigNodeChange(node.id, { assetId: asset.assetId }); + await syncResourceToArkPrivateAsset(resourceID); + message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 }); + } catch (error) { + message.error({ key: feedbackKey, content: error instanceof Error ? error.message : "上传到方舟素材库失败", duration: 5 }); + } finally { + setArkPrivateAssetUploadNodeId((current) => (current === node.id ? null : current)); + } + }, + [arkPrivateAssetUploadNodeId, currentProject?.projectId, handleConfigNodeChange, message, projectId], + ); + + const confirmUploadNodeImageToArkPrivateAsset = useCallback( + (node: CanvasNodeData) => { + modal.confirm({ + title: "上传到方舟素材库", + content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。", + okText: "确认拥有使用权并上传", + cancelText: "取消", + onOk: () => uploadNodeImageToArkPrivateAsset(node), + }); + }, + [modal, uploadNodeImageToArkPrivateAsset], + ); + + const handleCanvasContextMenu = useCallback( + (event: ReactMouseEvent) => { + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("[data-node-id],[data-connection-id]")) return; + + event.preventDefault(); + event.stopPropagation(); + if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown")) { + setContextMenu(null); + return; + } + + closeConnectionCreateMenu(); + setContextMenu({ type: "canvas", x: event.clientX, y: event.clientY, position: screenToCanvas(event.clientX, event.clientY) }); + }, + [closeConnectionCreateMenu, screenToCanvas], + ); + + const handleNodeContextMenu = useCallback( + (event: ReactMouseEvent, id: string) => { + event.preventDefault(); + event.stopPropagation(); + setSelectedNodeIds((current) => { + if (current.has(id) && current.size > 1) return current; + return new Set([id]); + }); + setSelectedConnectionId(null); + closeConnectionCreateMenu(); + setToolbarNodeId(null); + setDialogNodeId(null); + setContextMenu({ type: "node", x: event.clientX, y: event.clientY, nodeId: id }); + }, + [closeConnectionCreateMenu], + ); + + const handleGenerateNode = useCanvasGenerationExecutor({ + projectId, + domainProjectId: currentProject?.projectId, + addedSkills, + assets, + nodesRef, + connectionsRef, + setNodes, + setConnections, + setSelectedNodeIds, + setSelectedConnectionId, + setDialogNodeId, + setRunningNodeId, + startGenerationRequest, + finishGenerationRequest, + bindGenerationTask, + applyGenerationTaskResult, + }); + useEffect(() => { + generateNodeRef.current = handleGenerateNode; + }, [handleGenerateNode]); + + const { enqueueGenerationBatch, retryFailedBatchItems, stopRemainingBatchItems } = useCanvasGenerationBatches({ + projectId, + projectLoaded, + nodes, + nodesRef, + setNodes, + handleGenerateNode, + }); + + const { addReferenceColumn: addBatchReferenceColumn, addTextColumn: addBatchTextColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow, batchGenDialogOpen, batchGenDialogRowCount, batchGenDialogConcurrency, batchGenDialogConfig, closeBatchGenDialog, confirmBatchGenDialog } = useCanvasBatchTable({ + nodesRef, + connectionsRef, + setNodes, + setConnections, + setSelectedNodeIds, + enqueueGenerationBatch, + }); + + useEffect(() => { + if (!projectLoaded) return; + nodesRef.current.filter((node) => node.type === CanvasNodeType.BatchTable).forEach((node) => { + syncRowsFromConnections(node.id, true); + }); + }, [connections, projectLoaded, syncRowsFromConnections]); + + const handleUploadBatchReference = useCallback(async (tableNodeId: string, rowId: string, columnIndex: number, file: File) => { + const tableNode = nodesRef.current.find((item) => item.id === tableNodeId); + const table = tableNode?.metadata?.batchTable; + const row = table?.rows.find((item) => item.id === rowId); + if (!tableNode || !table || !row) return; + const rowIndex = table.rows.findIndex((item) => item.id === rowId); + const uploadedNodeId = await createFileNode(file, { + x: tableNode.position.x - 240, + y: tableNode.position.y + 170 + Math.max(0, rowIndex) * 220, + }); + if (!uploadedNodeId) return; + const inputNodeIds = Array.from({ length: Math.max(batchReferenceColumns(table).length, columnIndex + 1) }, (_, index) => row.inputNodeIds[index] || ""); + inputNodeIds[columnIndex] = uploadedNodeId; + updateBatchRow(tableNodeId, rowId, { inputNodeIds }); + message.success(`已上传并填入参考图 ${columnIndex + 1}`); + }, [createFileNode, message, nodesRef, updateBatchRow]); + + const { addScriptRow, createAndGenerateScriptVideos, createScriptActionBoards, createScriptImageNodes, createScriptVideoNodes, generateScriptImages, generateScriptRows, generateScriptVideos, removeScriptRow, replaceScriptRows, updateScriptRow } = + useCanvasStoryboard({ + projectId, + addedSkills, + nodesRef, + connectionsRef, + setNodes, + setConnections, + setSelectedNodeIds, + enqueueGenerationBatch, + }); + + const handleRetryNode = useCanvasGenerationRetry({ + projectId, + domainProjectId: currentProject?.projectId, + addedSkills, + assets, + nodesRef, + connectionsRef, + setNodes, + setRunningNodeId, + startGenerationRequest, + finishGenerationRequest, + bindGenerationTask, + applyGenerationTaskResult, + }); + const reloadCanvasNodeResource = useCallback( + async (node: CanvasNodeData) => { + const taskId = node.metadata?.taskId; + if (!taskId || !node.metadata?.resourceReloadAvailable) return; + setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "loading", taskStage: "正在重新加载资源", errorDetails: undefined } } : item))); + try { + const task = await queryGenerationTask(taskId); + if (task.status !== "succeeded") throw new Error("原生成任务尚未成功,无法重新加载资源"); + await applyGenerationTaskResult(node.id, task); + } catch (error) { + setNodes((current) => + current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: "error", errorDetails: error instanceof Error ? error.message : "资源重新加载失败", resourceReloadAvailable: true } } : item)), + ); + } + }, + [applyGenerationTaskResult, setNodes], + ); + const reconcileImageBatchRootNode = useCallback( + (rootId: string) => { + setNodes((current) => { + const root = current.find((item) => item.id === rootId); + if (!root) return current; + const reconciled = reconcileImageBatchRoot(root, current); + return current.map((item) => (item.id === root.id ? reconciled : item)); + }); + }, + [setNodes], + ); + const retryImageBatchChildren = useCallback( + (rootId: string, children: CanvasNodeData[]) => { + const childIds = children.map((child) => child.id); + setNodes((current) => markImageBatchRetrying(rootId, childIds, current)); + void Promise.allSettled( + children.map(async (child) => { + await handleRetryNode(child); + setNodes((current) => current.map((item) => (item.id === child.id ? restoreUnsubmittedImageBatchChild(item, child) : item))); + }), + ).finally(() => reconcileImageBatchRootNode(rootId)); + }, + [handleRetryNode, reconcileImageBatchRootNode, setNodes], + ); + + const generateImageFromTextNode = useCallback( + (node: CanvasNodeData) => { + const prompt = (node.metadata?.content || node.metadata?.prompt || "").trim(); + if (!prompt) { + message.warning("文本节点为空,无法生图"); + return; + } + const sourceNode = nodesRef.current.find((item) => item.id === node.id); + if (!sourceNode) return; + const nodeSize = getNodeSpec(CanvasNodeType.Image); + const imageNode = createCanvasNode( + CanvasNodeType.Image, + { + x: sourceNode.position.x + sourceNode.width + 96 + nodeSize.width / 2, + y: sourceNode.position.y + sourceNode.height / 2, + }, + { + prompt: "@文本1", + composerContent: "@文本1", + model: effectiveConfig.imageModel || effectiveConfig.model, + size: effectiveConfig.size, + quality: effectiveConfig.quality, + transparentBackground: effectiveConfig.transparentBackground, + count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), + }, + ); + imageNode.title = "图片生成"; + const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: imageNode.id }; + const nextNodes = nodesRef.current.map((item) => (item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, content: prompt, richText: undefined, prompt, status: NODE_STATUS_SUCCESS } } : item)).concat(imageNode); + const nextConnections = [...connectionsRef.current, connection]; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + setSelectedNodeIds(new Set([imageNode.id])); + setSelectedConnectionId(null); + setDialogNodeId(imageNode.id); + }, + [effectiveConfig, message], + ); + + const renderCanvasNodePanel = useCallback( + (panelNode: CanvasNodeData) => { + if (panelNode.type === CanvasNodeType.Script || panelNode.type === CanvasNodeType.Drawing) return null; + return panelNode.type === CanvasNodeType.Config ? ( + handleConfigNodeChange(panelNode.id, { composerContent })} + onMetadataChange={(patch) => handleConfigNodeChange(panelNode.id, patch)} + onClose={() => setDialogNodeId(null)} + /> + ) : ( + reference.kind === "text" && reference.nodeId !== panelNode.id && !(mentionReferencesByNodeId.get(panelNode.id) || []).some((active) => active.nodeId === reference.nodeId)), + ]} + onAddReference={(nodeId, reference) => { + if (reference.active || reference.assetId || reference.kind === "skill") return reference; + if (reference.kind !== "text" || !reference.nodeId || reference.nodeId === nodeId) return undefined; + try { + const linked = connectCanvasTextMention(nodesRef.current, connectionsRef.current, nodeId, reference.nodeId, nanoid()); + nodesRef.current = linked.nodes; + connectionsRef.current = linked.connections; + setNodes(linked.nodes); + setConnections(linked.connections); + return linked.reference; + } catch (cause) { + message.warning(cause instanceof Error ? cause.message : "文本引用失败"); + return undefined; + } + }} + onPromptChange={handleNodePromptChange} + onConfigChange={handleConfigNodeChange} + onGenerate={handleGenerateNode} + onRemoveReference={handleRemoveNodeReference} + onReorderReferences={handleReorderNodeReferences} + onReplaceReference={handleReplaceNodeReference} + onReplaceReferenceFiles={handleReplaceNodeReferenceFiles} + onClose={() => setDialogNodeId(null)} + onNodeMouseDown={handleNodeMouseDown} + workspaceMode={workspaceMode} + onImageSettingsOpenChange={(open) => { + setNodeImageSettingsOpen(open); + if (open) setToolbarNodeId(null); + }} + onListGenerate={(nodeId, listPrompt) => { + void handleListGenerate({ + sourceNodeId: nodeId, + prompt: listPrompt, + nodes: nodesRef.current, + connections: connectionsRef.current, + config: effectiveConfig, + projectId, + setNodes, + setConnections, + setRunningNodeId, + setDialogNodeId, + }); + }} + /> + ); + }, + [ + configInputsById, + handleConfigNodeChange, + handleGenerateNode, + handleNodePromptChange, + handleRemoveNodeReference, + handleReorderNodeReferences, + handleReplaceNodeReference, + handleReplaceNodeReferenceFiles, + mentionReferencesByNodeId, + message, + projectId, + runningNodeId, + skillMentionReferences, + workspaceMode, + ], + ); + + const renderCanvasNodeContent = useCallback( + (contentNode: CanvasNodeData) => { + if (contentNode.metadata?.workflowKind === "character" && contentNode.metadata.characterAssetId) { + return ; + } + if (contentNode.metadata?.workflowKind === "styleboard" && !contentNode.metadata.content) { + return setStylePickerOpen(true)} />; + } + if (contentNode.metadata?.workflowKind === "story_input") { + return openStoryInput(contentNode.id)} />; + } + if (contentNode.type === CanvasNodeType.BatchTable) { + return ( + patchBatchTable(contentNode.id, patch)} + onAddRow={() => addBatchRow(contentNode.id)} + onRemoveRow={(rowId) => removeBatchRow(contentNode.id, rowId)} + onUpdateRow={(rowId, patch) => updateBatchRow(contentNode.id, rowId, patch)} + onFillRows={() => fillRowsFromConnections(contentNode.id)} + onGenerate={(rowIds) => void generateBatchRows(contentNode.id, rowIds)} + onRetryItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)} + onAddReferenceColumn={() => addBatchReferenceColumn(contentNode.id)} + onAddTextColumn={() => addBatchTextColumn(contentNode.id)} + onReorderReferenceColumns={(fromColumnId, toColumnId) => reorderBatchReferenceColumns(contentNode.id, fromColumnId, toColumnId)} + onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)} + onReplaceReference={replaceCanvasNodeMedia} + onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} + onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)} + onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)} + /> + ); + } + if (contentNode.type === CanvasNodeType.Script) { + const pipeline = deriveStoryboardPipelineProgress(contentNode, nodesRef.current, connectionsRef.current); + return ( + setScriptEditorNodeId(contentNode.id)} + onCreateImageNodes={() => createScriptImageNodes(contentNode.id)} + onCreateVideoNodes={() => createScriptVideoNodes(contentNode.id)} + onGenerateImages={(rowIds) => void generateScriptImages(contentNode.id, rowIds)} + onGenerateVideos={(rowIds) => (contentNode.metadata?.storyboardVideoInputMode === "keyframe" ? void generateScriptVideos(contentNode.id, rowIds) : void createAndGenerateScriptVideos(contentNode.id, rowIds))} + onVideoInputModeChange={(storyboardVideoInputMode) => handleConfigNodeChange(contentNode.id, { storyboardVideoInputMode })} + onMergeVideos={() => void mergeVideosByIds(pipeline.successfulVideoNodeIds)} + onCreateActionBoards={() => void createScriptActionBoards(contentNode.id)} + onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)} + onRetryBatchItem={(batchId, itemId) => retryFailedBatchItems(contentNode.id, batchId, itemId)} + onStopBatch={(batchId) => stopRemainingBatchItems(contentNode.id, batchId)} + onAddRow={() => addScriptRow(contentNode.id)} + onRemoveRow={(rowId) => removeScriptRow(contentNode.id, rowId)} + onUpdateRow={(rowId, patch) => updateScriptRow(contentNode.id, rowId, patch)} + onPromptChange={(composerContent) => handleConfigNodeChange(contentNode.id, { composerContent })} + onGenerateScript={(prompt) => void generateScriptRows(contentNode.id, prompt)} + onModelChange={(model) => handleConfigNodeChange(contentNode.id, { model })} + onShotDurationChange={(duration: StoryboardShotDuration) => handleConfigNodeChange(contentNode.id, { storyboardShotDuration: duration })} + onShotCountChange={(count: StoryboardShotCount) => handleConfigNodeChange(contentNode.id, { storyboardShotCount: count })} + workspaceMode={workspaceMode} + onComposerHeightChange={(height) => { + if (contentNode.metadata?.storyboardComposerHeight === height) return; + handleConfigNodeChange(contentNode.id, { storyboardComposerHeight: height }); + const minHeight = storyboardMinNodeHeight(height); + if (contentNode.height < minHeight) handleNodeResize(contentNode.id, contentNode.width, minHeight); + }} + onConnectStart={(event, rowId, handleType) => handleConnectStart(event, contentNode.id, handleType, rowId === "context" ? "storyboard:context" : `row:${rowId}`)} + onScrollTopChange={(scrollTop) => setScriptScrollTopById((current) => (current[contentNode.id] === scrollTop ? current : { ...current, [contentNode.id]: scrollTop }))} + /> + ); + } + if (contentNode.metadata?.directorSceneId) { + return ( + scene.id === contentNode.metadata?.directorSceneId) || null} + readNodeContent={(nodeId) => (nodeId ? nodesRef.current.find((item) => item.id === nodeId)?.metadata?.content : undefined)} + professional={workspaceMode === "professional"} + onOpen={() => openDirectorWorkbench(contentNode.id)} + /> + ); + } + return ( + setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))} + onGenerate={(nodeId) => { + const target = nodesRef.current.find((item) => item.id === nodeId); + void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? ""); + }} + workspaceMode={workspaceMode} + /> + ); + }, + [ + addBatchReferenceColumn, + addBatchTextColumn, + addBatchRow, + addScriptRow, + configInputsById, + connections, + createAndGenerateScriptVideos, + createScriptActionBoards, + createScriptImageNodes, + createScriptVideoNodes, + currentProject?.directorScenes, + fillRowsFromConnections, + generateBatchRows, + generateScriptImages, + generateScriptRows, + generateScriptVideos, + handleUploadBatchReference, + moveBatchReferenceCell, + handleConfigNodeChange, + handleConnectDrop, + handleConnectStart, + handleGenerateNode, + handleNodeResize, + mentionReferencesByNodeId, + mergeVideosByIds, + openDirectorWorkbench, + openStoryInput, + patchBatchTable, + removeBatchRow, + reorderBatchReferenceColumns, + replaceCanvasNodeMedia, + removeScriptRow, + retryFailedBatchItems, + runningNodeId, + stopRemainingBatchItems, + theme, + updateBatchRow, + updateScriptRow, + viewport.k, + workspaceMode, + ], + ); + + const handleCanvasNodeHoverStart = useCallback( + (nodeId: string) => { + if (nodeDraggingRef.current) return; + setHoveredNodeId(nodeId); + keepNodeToolbar(nodeId); + }, + [keepNodeToolbar], + ); + const handleCanvasNodeHoverEnd = useCallback( + (nodeId: string) => { + setHoveredNodeId((current) => (current === nodeId ? null : current)); + hideNodeToolbar(); + }, + [hideNodeToolbar], + ); + const retryCanvasNode = useCallback( + (node: CanvasNodeData) => { + if (node.type === CanvasNodeType.Script) { + const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim(); + if (!prompt) { + message.warning("分镜脚本缺少剧情内容,无法重试"); + return; + } + void generateScriptRows(node.id, prompt); + return; + } + if (node.type === CanvasNodeType.Image && node.metadata?.isBatchRoot) { + const failedChildren = failedImageBatchChildren(node, nodesRef.current); + if (!failedChildren.length) { + message.info("当前批次没有需要重试的失败图片"); + return; + } + message.info(`正在重试 ${failedChildren.length} 个失败图片`); + retryImageBatchChildren(node.id, failedChildren); + return; + } + if (node.type === CanvasNodeType.Image && node.metadata?.batchRootId) { + retryImageBatchChildren(node.metadata.batchRootId, [node]); + return; + } + void handleRetryNode(node); + }, + [generateScriptRows, handleRetryNode, message, nodesRef, retryImageBatchChildren], + ); + const openCanvasNodeTaskDetails = useCallback( + (node: CanvasNodeData) => { + void openNodeTaskDetails(node); + }, + [openNodeTaskDetails], + ); + const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []); + const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []); + const editCanvasDirector = useCallback((node: CanvasNodeData) => openDirectorWorkbench(node.id), [openDirectorWorkbench]); + const locateProjectStyleNode = useCallback(() => { + const styleNode = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard"); + if (!styleNode) { + message.info("项目画风节点正在同步,请稍后再试"); + return; + } + focusCanvasNode(styleNode.id); + }, [focusCanvasNode, message, nodesRef]); + const freeformCreateCommands = useCanvasCreateCommands({ + workspaceMode, + isProjectLinked: Boolean(shortDramaEnabled && currentProject?.projectId), + handlers: { + onAddText: () => createNode(CanvasNodeType.Text), + onAddImage: () => createNode(CanvasNodeType.Image), + onAddVideo: () => createNode(CanvasNodeType.Video), + onAddAudio: () => createNode(CanvasNodeType.Audio), + onAddScript: () => createNode(CanvasNodeType.Script), + onAddFrame: () => createNode(CanvasNodeType.Frame), + onAddFolder: createFolder, + onAddDrawing: () => createNode(CanvasNodeType.Drawing), + onAddWorkflow: () => createNode(CanvasNodeType.Config), + onAddExtensionNode: (type) => createNode(type), + onChooseStyle: () => setStylePickerOpen(true), + onOpenDirector: () => setDirectorTemplateRequest({}), + onUpload: () => handleUploadRequest(), + onOpenMyAssets: () => openCanvasAssetLibrary(), + onOpenProjectCharacters: () => openProjectAssets("character"), + }, + }); + const emptyStateKind = resolveCanvasEmptyStateKind({ + nodeCount: nodes.length, + shortDramaEnabled, + isProjectLinked: Boolean(currentProject?.projectId), + starterMode: currentProject?.starterMode, + }); + const emptyCanvasState = + emptyStateKind === "freeform" ? ( + + ) : emptyStateKind === "linked" ? ( + { + const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0]; + if (first) void handleProjectChapterInsert({ id: first.id, projectId: linkedProjectId, title: first.title, position: first.position }); + }} + onOpenAssets={() => openProjectAssets()} + onAddText={() => createNode(CanvasNodeType.Text)} + /> + ) : emptyStateKind === "guided" ? ( + { + setCinematicAgentEntry(true); + openAgent(); + }} + onStartFreeform={() => updateProject(projectId, { starterMode: "freeform" })} + onUpload={() => handleUploadRequest()} + onAddText={() => createNode(CanvasNodeType.Text)} + onAddScript={() => createNode(CanvasNodeType.Script)} + /> + ) : null; + if (!projectLoaded && loadError) + return ( +
+

{loadError}

+ + 返回画布库 +
+ ); + if (!projectLoaded) return ; + + return ( + <> + + 跳转到画布主内容 + +
+ {!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? ( + openProjectAssets()} /> + ) : null} + +
+
+ {!focusMode ? ( + } + versionsOpen={versions.open} + onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }} + title={currentProject?.title || "未命名画布"} + titleDraft={titleDraft} + isTitleEditing={titleEditing} + onTitleDraftChange={setTitleDraft} + onStartTitleEditing={startTitleEditing} + onFinishTitleEditing={finishTitleEditing} + onCancelTitleEditing={() => setTitleEditing(false)} + canUndo={historyState.canUndo} + canRedo={historyState.canRedo} + onCreateProject={createAndOpenProject} + onDeleteProject={deleteCurrentProject} + onSave={() => void saveCanvasProject()} + onForceSave={confirmForceSaveCanvas} + onImportImage={() => handleUploadRequest()} + onImportLibTV={() => setLibTVImportOpen(true)} + onImportTapNow={() => setTapNowImportOpen(true)} + onUndo={undoCanvas} + onRedo={redoCanvas} + onShare={() => setShareModalOpen(true)} + shortcutRequestNonce={shortcutRequestNonce} + mediaPerformanceMode={mediaPerformanceMode} + onMediaPerformanceModeChange={setMediaPerformanceMode} + onOpenSearch={() => setNodeSearchOpen(true)} + projectContext={ + shortDramaEnabled && currentProject?.projectId + ? { + ...canvasContext, + projectId: currentProject.projectId, + projectName: linkedProjectQuery.data?.project.name || currentProject.title, + } + : undefined + } + onEnterFocusMode={enterFocusMode} + shortDramaGuide={shortDramaGuide} + /> + ) : null} + + setNodeSearchOpen(false)} + onFocus={(nodeId) => { + const target = nodeById.get(nodeId); + const parent = target?.parentId ? nodeById.get(target.parentId) : null; + if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id); + const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null; + if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id); + const selection = new Set([nodeId]); + selectedNodeIdsRef.current = selection; + setSelectedNodeIds(selection); + setSelectedConnectionId(null); + focusCanvasNode(nodeId); + }} + /> + + {!focusMode && shortDramaGuide ? ( + + ) : null} + + setShareModalOpen(false)} beforeCreate={saveCanvasProject} /> + setLibTVImportOpen(false)} onApply={applyLibTVImport} /> + setTapNowImportOpen(false)} onApply={applyTapNowImport} /> + + setStylePickerOpen(false)} onSelect={selectCanvasStyle} /> + + setDirectorTemplateRequest(null)} onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)} /> + +
+
+ + } + onViewportChange={handleViewportChange} + onViewportPreviewChange={handleViewportPreviewChange} + onCanvasMouseDown={handleCanvasMouseDown} + boxSelectEnabled={canvasTool === "box-select"} + onCanvasDoubleClick={handleCanvasDoubleClick} + onCanvasDeselect={deselectCanvas} + onContextMenu={handleCanvasContextMenu} + onDrop={handleDrop} + onFileDragEnter={handleFileDragEnter} + onFileDragLeave={handleFileDragLeave} + onFileDragOver={handleFileDragOver} + > + + + { + setSelectedConnectionId(connectionId); + setSelectedNodeIds(new Set()); + setContextMenu(null); + }} + onConnectionContextMenu={(event, connectionId) => { + setSelectedConnectionId(connectionId); + setSelectedNodeIds(new Set()); + closeConnectionCreateMenu(); + setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); + }} + onNodeMouseDown={handleNodeMouseDown} + onNodeHoverStart={handleCanvasNodeHoverStart} + onNodeHoverEnd={handleCanvasNodeHoverEnd} + onConnectStart={handleConnectStart} + onNodeResize={handleNodeResize} + onToggleFrame={handleFrameToggle} + onFolderStyleChange={handleFolderStyleChange} + onFolderThemeChange={handleFolderThemeChange} + onNodeTitleChange={handleNodeTitleChange} + onNodeContextMenu={handleNodeContextMenu} + onNodeContentChange={handleNodeContentChange} + onToggleBatch={toggleBatchExpanded} + onSetBatchPrimary={setBatchPrimary} + onRetry={retryCanvasNode} + onReloadResource={reloadCanvasNodeResource} + onOpenTaskDetails={openCanvasNodeTaskDetails} + onOpenVersions={openCanvasNodeVersions} + onViewImage={viewCanvasNodeImage} + onReplaceMedia={replaceCanvasNodeMedia} + onOpenTextEditor={openTextNodeEditor} + onOpenDirector={editCanvasDirector} + onOpenDrawing={openDrawingNode} + onStartBatchConnection={startBatchConnection} + /> + + + + + + + {focusMode ? ( + } + versionsOpen={versions.open} + onToggleVersions={() => { closeAgent(); versions.toggle(); }} + dockRevealed={focusDockRevealed} + zoomPercent={viewport.k} + onToggleDock={() => setFocusDockRevealed((value) => !value)} + onExit={exitFocusMode} + onZoomIn={zoomCanvasIn} + onZoomOut={zoomCanvasOut} + onFit={fitCanvasContent} + /> + ) : null} + + + + {emptyCanvasState} + + {!focusMode || focusDockRevealed ? ( + createNode(CanvasNodeType.Image)} + onAddVideo={() => createNode(CanvasNodeType.Video)} + onAddAudio={() => createNode(CanvasNodeType.Audio)} + onAddText={() => createNode(CanvasNodeType.Text)} + onChooseStyle={() => setStylePickerOpen(true)} + onAddScript={() => createNode(CanvasNodeType.Script)} + onAddFrame={() => createNode(CanvasNodeType.Frame)} + onAddFolder={createFolder} + onAddDrawing={() => createNode(CanvasNodeType.Drawing)} + onAddExtensionNode={(type) => createNode(type)} + onAddWorkflow={() => createNode(CanvasNodeType.Config)} + onOpenDirector={() => setDirectorTemplateRequest({})} + onUndo={undoCanvas} + onRedo={redoCanvas} + onUpload={() => handleUploadRequest()} + onDelete={() => deleteNodes(new Set(selectedNodeIds))} + onClear={() => setClearConfirmOpen(true)} + onDeselect={deselectCanvas} + onAppearanceChange={applyCanvasAppearance} + onSaveAppearanceDefault={saveCanvasAppearanceDefault} + onBackgroundModeChange={setBackgroundMode} + onShowImageInfoChange={setShowImageInfo} + onOpenMyAssets={() => { + openCanvasAssetLibrary(); + }} + onOpenProjectCharacters={() => openProjectAssets("character")} + /> + ) : null} +
+ +
+ { + if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; } + focusCanvasNode(nodeId); + }} /> +
+
+ + {angleNode?.metadata?.content ? ( + + setAngleNodeId(null)} + onConfirm={(params) => { + void generateAngleNode(angleNode, params); + }} + /> + + ) : null} + + {lightingNode?.metadata?.content ? ( + setLightingNodeId(null)}> + setLightingNodeId(null)} + onConfirm={(options, prompt) => { + generateLightingNode(lightingNode, options, prompt); + }} + /> + + ) : null} + + {emotionNode?.metadata?.content && !isCanvasNodeMoving ? ( + setEmotionNodeId(null)} + onConfirm={(payload: CanvasImageEmotionPayload) => { + void generateEmotionNode(emotionNode, payload); + }} + /> + ) : null} + + {dialogNode && + !isCanvasImageSourceNode(dialogNode) && + !dialogNode.metadata?.fileUpload && + dialogNode.type !== CanvasNodeType.Script && + dialogNode.type !== CanvasNodeType.BatchTable && + dialogNode.type !== CanvasNodeType.Drawing && + dialogNode.type !== CanvasNodeType.Panorama && + !selectionBox && + !isCanvasNodeMoving ? ( + + {renderCanvasNodePanel(dialogNode)} + + ) : null} + + {pendingConnectionCreate ? ( + getConnectionCreateDisabledReason(type, pendingConnectionCreate)} + onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} + onClose={cancelPendingConnectionCreate} + /> + ) : null} + + {connectionReplaceHover ? ( +
+ + 松开替换 + @{connectionReplaceHover.referenceLabel} +
+ ) : null} + + {selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? ( + beginBatchConnectionMode(Array.from(selectedNodeIds))} + onMergeVideos={() => void mergeSelectedVideos()} + onSendSelectionToAgent={() => sendSelectionToAgent()} + /> + ) : null} + + {uploadStatus ? : null} + {mergeVideoProgress ? : null} + {lastAgentChange ? ( + { + undoAgentOps(); + }} + onClose={dismissLastAgentChange} + /> + ) : null} + + (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId ? openTextNodeEditor(node) : setInfoNodeId(node.id))} + onEditText={openTextNodeEditor} + onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))} + onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))} + onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))} + onGenerateImage={generateImageFromTextNode} + onUpload={(node) => handleUploadRequest(node.id)} + onDownload={downloadNodeImage} + onSaveAsset={(node) => void saveNodeAsset(node)} + onAnnotate={(node) => setAnnotationNodeId(node.id)} + onAnnotationEdit={openAnnotationEditNode} + onMaskEdit={(node) => setMaskEditNodeId(node.id)} + onImageEdit={openImageEditNode} + onRemoveBackground={openBackgroundRemoval} + onLayerDecomposition={openLayerDecomposition} + onTextEdit={openTextEditNode} + onEmotion={(node) => { + setDialogNodeId(null); + setEmotionNodeId((current) => (current === node.id ? null : node.id)); + }} + onPortraitTexture={openPortraitTextureEditor} + onCrop={(node) => setCropNodeId(node.id)} + onSplit={(node, params) => void splitImageNode(node, params)} + onUpscale={(node) => setUpscaleNodeId(node.id)} + onSuperResolve={(node) => setSuperResolveNodeId(node.id)} + onAngle={(node) => { + setDialogNodeId(null); + setAngleNodeId((current) => (current === node.id ? null : node.id)); + }} + onLighting={(node) => { + setDialogNodeId(null); + setLightingNodeId((current) => (current === node.id ? null : node.id)); + }} + onPanorama={openPanoramaConfig} + onViewImage={(node) => setPreviewNodeId(node.id)} + onExtractVideoFrames={openVideoFrameExtractor} + onExtractAudioFromVideo={(node) => void extractAudioFromVideo(node)} + onTrimVideoSegments={openVideoSegmentExtractor} + onSubtitles={(node) => setSubtitleNodeId(node.id)} + onTimeline={(node) => setTimelineNodeId(node.id)} + extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId} + extractingAudio={segmentRunningMode === "audio"} + trimmingVideo={segmentRunningMode === "video"} + onReversePrompt={createImageReversePromptNodes} + onRetry={retryCanvasNode} + onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)} + onToggleLocked={(node) => toggleNodeLocked(node.id)} + onDelete={(node) => deleteNodes(new Set([node.id]))} + /> + +{isMiniMapOpen && !focusMode ? : null} + + {!focusMode ? ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + setIsMiniMapOpen((value) => !value)} + onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)} + /> + void createImageAssetNode(asset)} + onFocusCanvasImage={focusCanvasImageNode} + /> + + ) : null} + + setContextMenu(null)} + onAddNode={(type, position) => createNode(type, position)} + onAddFolder={createFolder} + onChooseStyle={() => setStylePickerOpen(true)} + onOpenDirector={(position) => setDirectorTemplateRequest({ position })} + onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)} + onOpenAssets={openCanvasAssetLibrary} + onOpenProjectCharacters={(position) => openProjectAssets("character", position)} + onUndo={undoCanvas} + onRedo={redoCanvas} + onPaste={pasteAtPosition} + onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))} + onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")} + onDuplicate={duplicateNode} + onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))} + onDeleteConnection={deleteConnection} + onSaveAsset={(node) => { + void saveNodeAsset(node); + }} + onViewMedia={(node) => setPreviewNodeId(node.id)} + onEditText={openTextNodeEditor} + onOpenDrawing={openDrawingNode} + onGenerateImage={generateImageFromTextNode} + onCopyContent={(node) => { + void copyNodeContentToClipboard(node); + }} + onCopyMediaUrl={(node) => { + void copyNodeMediaUrlToClipboard(node); + }} + onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset} + onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })} + onToggleFrame={(node) => handleFrameToggle(node.id)} + onSpreadSelection={spreadSelectedNodes} + onCopySelection={copySelectedNodes} + onDeleteSelection={() => deleteNodes(selectedNodeIds)} + onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)} + /> + + + + + + setInfoNodeId(null)} onMetadataChange={handleConfigNodeChange} /> + + {subtitleNode ? ( + setSubtitleNodeId(null)} + onSave={(nodeId, patch) => { + handleConfigNodeChange(nodeId, patch); + const currentTimeline = currentProject?.timeline; + if (currentTimeline) { + const next = syncNodeSubtitlesToTimeline(currentTimeline, nodeId, patch.subtitleEntries || []); + if (next !== currentTimeline) updateProject(projectId, { timeline: next }); + } + }} + /> + ) : null} + + {frameNode ? void extractVideoFrames(frameNode, params)} /> : null} + + {segmentNode && segmentDialogMode ? ( + void handleSegmentConfirm(segmentNode, params)} + /> + ) : null} + + {timelineNode ? ( + setTimelineNodeId(null)} + onOpenSubtitleDialog={(subNodeId) => { + setTimelineNodeId(null); + setSubtitleNodeId(subNodeId); + }} + onSave={(next) => updateProject(projectId, { timeline: next })} + onSaveSubtitles={(subNodeId, entries) => + handleConfigNodeChange(subNodeId, { + subtitleEntries: entries, + ...(entries.length ? {} : { subtitleHighlights: [] }), + subtitleUpdatedAt: new Date().toISOString(), + }) + } + onOpenAssetLibrary={openTimelineAssetLibrary} + onOpenProjectAssets={() => openProjectAssets("all", undefined, "timeline")} + onUploadLocalFiles={uploadTimelineMedia} + addNodeToTimelineRef={timelineAddNodeRef} + addMediaToTimelineRef={timelineMediaAddRef} + onCreateAssembledNode={createVideoNodeFromBlob} + /> + ) : null} + + setCharacterReferenceNodeId(null)} /> + + setTextEditorNodeId(null)} + onSave={(nodeId, title, content, richText) => { + setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, title, metadata: { ...node.metadata, content, richText } } : node))); + }} + /> + + {drawingNode ? ( + + +
+ } + > + setDrawingNodeId(null)} + onSaved={(nodeId, summary) => { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + metadata: { + ...node.metadata, + drawingEngine: summary.engine, + drawingRevision: summary.revision, + drawingUpdatedAt: summary.updatedAt, + drawingShapeCount: summary.shapeCount, + drawingPageCount: summary.pageCount, + }, + } + : node, + ), + ); + message.success("绘图已保存"); + }} + /> + + ) : null} + + { + artCritiqueRunningRef.current = running; + }} + node={artCritiqueNode} + upstreamNodes={artCritiqueInputs} + open={Boolean(artCritiqueNode)} + onClose={() => setArtCritiqueNodeId(null)} + onUpdateState={(nodeId, state) => handleConfigNodeChange(nodeId, { artCritique: state })} + /> + + setPanoramaConfigNodeId(null)} + onConfirm={(composedPrompt, config) => { + const node = nodes.find((n) => n.id === panoramaConfigNodeId); + if (node) { + createPanoramaViewerWithConfig(node, composedPrompt, config); + } + }} + onCopyPrompt={(prompt) => { + void navigator.clipboard?.writeText(prompt).then(() => message.success("已复制全景提示词")); + }} + previewImageUrl={panoramaConfigNodeId ? nodes.find((n) => n.id === panoramaConfigNodeId)?.metadata?.content : undefined} + nodes={nodes} + /> + + setScriptEditorNodeId(null)} + onUpdateRows={(rows) => activeScriptNode && replaceScriptRows(activeScriptNode.id, rows)} + onVisibleColumnsChange={(visibleColumns: StoryboardColumn[]) => { + if (!activeScriptNode || !visibleColumns.length) return; + setNodes((prev) => + prev.map((node) => + node.id === activeScriptNode.id + ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } + : node, + ), + ); + }} + onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)} + onGenerateVideos={(rowIds) => { + if (!activeScriptNode) return; + if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds); + else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds); + }} + onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })} + /> + + {directorNodeId && activeDirectorScene ? ( + + + + } + > + node.type === CanvasNodeType.Image && Boolean(node.metadata?.content))} + onClose={() => setDirectorNodeId(null)} + onChange={saveDirectorScene} + onApply={applyDirectorOutput} + onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))} + onFlush={() => flushCanvasStorePersistence()} + onboardingScope={directorOnboardingScope} + /> + + ) : null} + + setVersionCompareRootId(null)} + onSetPrimary={setPrimaryVersion} + onFocus={(nodeId) => { + setVersionCompareRootId(null); + focusCanvasNode(nodeId); + }} + /> + + setCropNodeId(null)} + onCloseAnnotation={() => setAnnotationNodeId(null)} + onCloseAnnotationEdit={() => setAnnotationEditNodeId(null)} + onCloseMaskEdit={() => setMaskEditNodeId(null)} + onCloseImageEdit={() => { setImageEditNodeId(null); setImageEditPreset(null); }} + onCloseLayerDecomposition={() => setLayerDecompositionNodeId(null)} + onCloseTextEdit={() => setTextEditNodeId(null)} + onCloseUpscale={() => setUpscaleNodeId(null)} + onCrop={(node, crop) => void cropImageNode(node, crop)} + onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)} + onAnnotationEdit={(node, payload) => void editAnnotatedImageNode(node, payload)} + onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)} + onImageEdit={(node, payload) => void editImageNode(node, payload)} + onLayerDecomposition={(node, payload) => void decomposeImageLayers(node, payload)} + onDetectText={() => { + const node = textEditNodeId ? nodeById.get(textEditNodeId) : null; + return node ? detectImageText(node) : Promise.reject(new Error("图片节点已不存在")); + }} + onTextEdit={(node, payload) => void editTextImageNode(node, payload)} + onUpscale={(node, params) => void upscaleImageNode(node, params)} + config={effectiveConfig} + /> + + + + setTaskDetail(null)} + onCancelTask={cancelCanvasTask} + superResolveNode={superResolveNode} + onCloseSuperResolve={() => setSuperResolveNodeId(null)} + previewNode={previewNode} + onClosePreview={() => setPreviewNodeId(null)} + clearConfirmOpen={clearConfirmOpen} + onCancelClear={() => setClearConfirmOpen(false)} + onConfirmClear={clearCanvas} + /> + + + + + {versions.preview ? : null} + +
+ +
+ + ); +} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 89e3f36e6..ac209febe 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -1,584 +1,592 @@ -import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade"; -import type { MediaConversionNodeState } from "@/lib/media-conversion/contracts"; -import type { AssetCategory } from "@/lib/asset-category"; -import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture"; -import type { StyleExecutionPlan } from "@/lib/canvas/style-profile"; -import type { ArtCritiqueNodeState } from "@/lib/art-critique/contracts"; -import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; -import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline"; - -export type Position = { - x: number; - y: number; -}; - -export type ViewportTransform = { - x: number; - y: number; - k: number; -}; - -export enum CanvasNodeType { - Image = "image", - Text = "text", - Drawing = "drawing", - Script = "script", - Skill = "skill", - Config = "config", - Video = "video", - Audio = "audio", - Frame = "frame", - Markdown = "markdown", - Svg = "svg", - Html = "html", - Panorama = "panorama", - Compare = "compare", - Chart = "chart", - ColorGrade = "colorgrade", - MediaConversion = "media-conversion", - BatchTable = "batch-table", -} - -/** Runtime IDs contributed by plugins share the persisted node type field. */ -export type PluginCanvasNodeType = string & { readonly __pluginCanvasNodeType?: unique symbol }; -export type CanvasNodeTypeId = CanvasNodeType | PluginCanvasNodeType; - -export function isBuiltinCanvasNodeType(type: CanvasNodeTypeId): type is CanvasNodeType { - return Object.values(CanvasNodeType).includes(type as CanvasNodeType); -} - -export type CanvasNodeStatus = "idle" | "success" | "loading" | "error"; -export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance"; -export type CanvasWorkspaceMode = "simple" | "professional"; -export type CanvasToolMode = "move" | "box-select"; -export type CanvasFolderStyle = "glass" | "stacked" | "midnight" | "paper" | "cinema" | "compact"; -export type CanvasFolderTheme = "aurora" | "obsidian" | "ember" | "pearl"; -export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30"; -export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10"; -export type StoryboardVideoInputMode = "direct" | "keyframe"; -export type CanvasGenerationMode = "text" | "image" | "video" | "audio"; -export type CanvasGenerationBatchMode = "storyboard_image" | "storyboard_video" | "action_board" | "batch_image"; -export type CanvasGenerationBatchStatus = "queued" | "running" | "partial_failed" | "completed" | "cancelled"; -export type CanvasGenerationBatchItemStatus = "waiting" | "submitting" | "queued" | "running" | "succeeded" | "failed" | "cancelled"; -export type CanvasImageGenerationType = "generation" | "edit"; -export type CanvasWorkflowKind = "free" | "script" | "story_input" | "character" | "scene" | "storyboard" | "shot" | "final" | "styleboard" | "reference_set" | "reference_video" | "action_board"; -export type CanvasVideoEditOperation = "text_to_video" | "image_to_video" | "reference_to_video" | "extend" | "inpaint" | "replace_element" | "camera_motion" | "style_transfer" | "audio_to_video" | "compare_versions" | "concat"; -export type CanvasSkillCategory = "writing" | "storyboard" | "image" | "video" | "utility"; -export type CanvasSkillOutputMode = "text" | "json" | "image_prompt" | "workflow"; -export type StoryboardColumn = - | "shotNumber" - | "durationSeconds" - | "plotDescription" - | "dialogue" - | "narrativeIntent" - | "viewerPOV" - | "performanceBlocking" - | "shotSize" - | "emotion" - | "lightingAndAtmosphere" - | "audioEffects" - | "camera" - | "motion" - | "timeBeats" - | "imageGenerationPrompt" - | "videoMotionPrompt" - | "assets" - | "continuityOut" - | "negativePrompt"; - -export type StoryboardAssetRole = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "motion" | "audio"; - -export type StoryboardAssetBinding = { - nodeId: string; - role: StoryboardAssetRole; - priority: number; -}; - -export type StoryboardCharacterReference = { - characterName: string; - characterAssetId?: string; - characterVersionId?: string; - characterDescription?: string; - characterImageNodeId?: string; -}; - -export type StoryboardRow = { - id: string; - shotNumber: number; - durationSeconds: number; - plotDescription: string; - dialogue: string; - characters: StoryboardCharacterReference[]; - narrativeIntent: string; - viewerPOV: string; - performanceBlocking: string; - shotSize: string; - emotion: string; - lightingAndAtmosphere: string; - audioEffects: string; - camera: string; - motion: string; - timeBeats: string; - imageGenerationPrompt: string; - videoMotionPrompt: string; - imagePromptTemplateVariables?: Record; - videoPromptTemplateVariables?: Record; - mustHave: string[]; - optionalDetails: string[]; - continuityOut: string; - negativePrompt: string; - assetBindings: StoryboardAssetBinding[]; - imageNodeId?: string; - videoNodeId?: string; - status?: CanvasNodeStatus; - errorDetails?: string; -}; - -export type StoryboardData = { - rows: StoryboardRow[]; - visibleColumns: StoryboardColumn[]; - referenceNodeIds: string[]; -}; - -export type CanvasGenerationBatchItem = { - id: string; - rowId: string; - nodeId: string; - taskId?: string; - status: CanvasGenerationBatchItemStatus; - retryCount: number; - errorDetails?: string; - costUncertain?: boolean; -}; - -export type CanvasGenerationBatch = { - id: string; - projectId: string; - sourceNodeId: string; - mode: CanvasGenerationBatchMode; - status: CanvasGenerationBatchStatus; - items: CanvasGenerationBatchItem[]; - concurrency?: number; - createdAt: string; - updatedAt: string; -}; - -export type CanvasBatchOperation = "try_on" | "creative"; -export type CanvasBatchRow = { - id: string; - enabled: boolean; - inputNodeIds: string[]; - /** Text nodes selected for this row; their contents are appended to prompt. */ - textNodeIds?: string[]; - prompt: string; - outputNodeId?: string; -}; -export type CanvasBatchReferenceColumn = { - id: string; - label: string; -}; -export type CanvasBatchTableData = { - operation: CanvasBatchOperation; - concurrency: number; - /** Optional prompt override applied to every batch row while non-empty. */ - globalPrompt?: string; - referenceColumns?: CanvasBatchReferenceColumn[]; - textColumns?: CanvasBatchReferenceColumn[]; - rows: CanvasBatchRow[]; -}; - -export type CanvasSkillSnapshot = { - id: string; - name: string; - description: string; - category: CanvasSkillCategory; - template: string; - outputMode: CanvasSkillOutputMode; - outputContract: string; - version: number; - tags: string[]; -}; - -export type CanvasNodeMetadata = { - /** Namespaced extension ownership for nodes contributed by a unified plugin. */ - pluginId?: string; - pluginNodeId?: string; - pluginData?: Record; - importSource?: - | { - provider: "libtv"; - projectUuid: string; - nodeKey: string; - batchId: string; - sourceType?: string; - styleAssetUuid?: string; - styleVersionUuid?: string; - styleName?: string; - } - | { - provider: "tapnow"; - shareId: string; - nodeId: string; - batchId: string; - sourceType?: string; - }; - content?: string; - previewContent?: string; - videoPreview?: { - content: string; - storageKey?: string; - width?: number; - height?: number; - bytes?: number; - mimeType?: string; - }; - richText?: Record; - composerContent?: string; - prompt?: string; - promptTemplateOperation?: string; - promptTemplateVariables?: Record; - status?: CanvasNodeStatus; - /** 浏览器文件上传,与模型生成任务状态独立。 */ - fileUpload?: "uploading" | "error"; - fileUploadProgress?: number; - locked?: boolean; - errorDetails?: string; - generationErrorCode?: string; - resourceReloadAvailable?: boolean; - failedPromptFingerprint?: string; - lastGenerationRequestFingerprint?: string; - fontSize?: number; - generationMode?: CanvasGenerationMode; - generationType?: CanvasImageGenerationType; - model?: string; - workflowProvider?: "model" | "runninghub"; - runningHubWorkflowId?: string; - runningHubWorkflowKind?: "workflow" | "app"; - /** 当前画布节点覆盖的工作流动态字段,键为 source:* 或 field:nodeId:fieldName。 */ - workflowParameters?: Record; - size?: string; - quality?: string; - transparentBackground?: string; - count?: number; - textCount?: number; - seconds?: string; - vquality?: string; - generateAudio?: string; - watermark?: string; - audioVoice?: string; - audioFormat?: string; - audioSpeed?: string; - audioInstructions?: string; - references?: string[]; - naturalWidth?: number; - naturalHeight?: number; - freeResize?: boolean; - isBatchRoot?: boolean; - batchRootId?: string; - batchChildIds?: string[]; - batchFailedCount?: number; - batchUsesReferenceImages?: boolean; - primaryImageId?: string; - imageBatchExpanded?: boolean; - storageKey?: string; - mimeType?: string; - bytes?: number; - durationMs?: number; - /** Whether the video file contains an audio track when this is known. */ - hasAudio?: boolean; - assetId?: string; - assetTags?: string[]; - assetCategory?: AssetCategory; - workflowKind?: CanvasWorkflowKind; - workflowTitle?: string; - workflowDescription?: string; - stylePresetId?: string; - styleProfileJson?: string; - styleExecutionPlan?: StyleExecutionPlan; - skillIds?: string[]; - skillVersions?: Array<{ skillId: string; versionId: string; version: string }>; - skillFiles?: Array<{ skillId: string; path: string; sha256?: string }>; - chapterId?: string; - chapterTitle?: string; - shotIndex?: number; - sceneId?: string; - characterIds?: string[]; - referenceSetId?: string; - referenceAssetNodeIds?: string[]; - assetBindings?: StoryboardAssetBinding[]; - characterName?: string; - characterPrompt?: string; - characterAliases?: string[]; - characterDefinition?: Record; - characterAssetId?: string; - characterVersionId?: string; - characterVersionPolicy?: "current" | "pinned"; - characterVisualStatus?: string; - characterVoiceStatus?: string; - characterVoiceName?: string; - characterVoiceProfile?: { - name: string; - provider: string; - language: string; - timbre: string; - }; - characterVoiceInstructions?: string; - characterCoverUrl?: string; - characterView?: "front" | "side" | "back" | "multi"; - characterViewNodeIds?: { - front?: string; - side?: string; - back?: string; - }; - actionBoardRows?: number; - actionBoardColumns?: number; - taskId?: string; - taskClientOperationId?: string; - retryOf?: string; - attemptGroupId?: string; - taskStatus?: "queued" | "running" | "succeeded" | "failed" | "cancelled" | string; - taskProgress?: number; - taskStage?: string; - taskProvider?: string; - taskStartedAt?: string; - taskCompletedAt?: string; - taskDurationMs?: number; - taskErrorCode?: string; - taskOfficialStatus?: "pending" | "processing" | "completed" | "failed" | "cancelled"; - taskReceiptRecorded?: boolean; - taskCreatedAt?: string; - taskUpdatedAt?: string; - generationEffectKeys?: string[]; - agentGenerationContinuation?: { - id: string; - taskId: string; - conversationId?: string; - messageId?: string; - source?: "online" | "local"; - status: "pending" | "completed" | "failed"; - effectKey?: string; - }; - sessionId?: string; - videoEditOperation?: CanvasVideoEditOperation; - arkPrivateAssetUpload?: string; - videoCameraMoveId?: string; - videoCameraMovePrompt?: string; - videoStartFrameNodeId?: string; - videoEndFrameNodeId?: string; - videoFrameSourceNodeId?: string; - videoFrameTimeMs?: number; - versionOfNodeId?: string; - versionLabel?: string; - versionPrimary?: boolean; - copiedFromNodeId?: string; - generationResultPlacement?: "replace-node" | "new-version"; - directorSceneId?: string; - directorShotId?: string; - directorPreviewNodeId?: string; - directorDepthNodeId?: string; - directorNormalNodeId?: string; - directorClayVideoNodeId?: string; - subtitleEntries?: SrtEntry[]; - subtitleHighlights?: SubtitleHighlight[]; - subtitleStyle?: SubtitleStyle; - subtitleUpdatedAt?: string; - skillId?: string; - skillVersion?: number; - skillSnapshot?: CanvasSkillSnapshot; - /** 图表节点的图形类型,缺省柱状图。落盘字段——新增扩展节点的自有字段都要在这里声明。 */ - chartKind?: "bar" | "line"; - /** 调色节点的参数;缺省视为未调色。 */ - colorGrade?: CanvasColorGrade; - mediaConversion?: MediaConversionNodeState; - /** 用户手动拉伸过尺寸;图片按真实比例自动适配时避让它。 */ - manualSize?: boolean; - storyboard?: StoryboardData; - storyboardShotDuration?: StoryboardShotDuration; - storyboardShotCount?: StoryboardShotCount; - storyboardVideoInputMode?: StoryboardVideoInputMode; - storyboardComposerHeight?: number; - generationBatches?: CanvasGenerationBatch[]; - batchTable?: CanvasBatchTableData; - batchSourceNodeId?: string; - batchRowId?: string; - batchOperation?: CanvasBatchOperation; - batchInputNodeIds?: string[]; - frame?: { - collapsed: boolean; - expandedWidth: number; - expandedHeight: number; - }; - folder?: { - style: CanvasFolderStyle; - theme?: CanvasFolderTheme; - createdAt: string; - // 自定义主题资源覆盖预置主题;目录内容永远从 childNodes 读取。 - themeCover?: string; - assetFolderId?: string; - projectId?: string; - }; - drawingId?: string; - drawingEngine?: "tldraw" | "excalidraw"; - drawingRevision?: number; - drawingUpdatedAt?: string; - drawingPreviewStorageKey?: string; - drawingPreviewUrl?: string; - drawingShapeCount?: number; - drawingPageCount?: number; - emotionEdit?: { - sourceNodeId: string; - characterName: string; - presetId: string; - intimacy: number; - arousal: number; - label: string; - faceBox: { - id: string; - x: number; - y: number; - width: number; - height: number; - confidence?: number; - source: "detected" | "manual"; - }; - editRegion?: { - x: number; - y: number; - width: number; - height: number; - }; - sourceWidth?: number; - sourceHeight?: number; - providerSize?: string; - maskStorageKey?: string; - editMode?: "provider-mask" | "local-composite"; - }; - portraitTexture?: PortraitTextureSettings; - /** AI 审美批改节点只保存当前报告和输入指纹,不保存图片二进制。 */ - artCritique?: ArtCritiqueNodeState; - /** 摄像机控制选项,启用后生成时自动追加摄影机/镜头/焦距/光圈提示词。 */ - cameraControl?: CameraControlOptions; - /** 全景节点配置:投影方式、生成方式和比例兜底开关。 */ - panoramaConfig?: { - projection: "spherical" | "cylindrical"; - sourceMode: "ai" | "image"; - smartBase: boolean; - directImageUrl?: string | null; - }; -}; - -export type CanvasNodeData = { - id: string; - type: CanvasNodeTypeId; - title: string; - createdAt?: string; - updatedAt?: string; - position: Position; - width: number; - height: number; - parentId?: string; - metadata?: CanvasNodeMetadata; -}; - -export type CanvasConnection = { - id: string; - fromNodeId: string; - toNodeId: string; - fromHandleId?: string; - toHandleId?: string; - fromAnchorRatio?: number; - toAnchorRatio?: number; - relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output"; - storyboardRowId?: string; -}; - -export type CanvasDisplayConnection = { - connection: CanvasConnection; - from: CanvasNodeData; - to: CanvasNodeData; -}; - -export type CanvasAssistantReference = { - id: string; - type: CanvasNodeTypeId; - title: string; - dataUrl?: string; - storageKey?: string; - text?: string; -}; - -export type CanvasAssistantImage = { - id: string; - dataUrl: string; - storageKey?: string; - prompt: string; -}; - -export type CanvasAssistantMessage = { - id: string; - role: "user" | "assistant" | "system" | "tool" | "error"; - title?: string; - text: string; - meta?: string; - detail?: unknown; - references?: CanvasAssistantReference[]; -}; - -export type CanvasAssistantPendingBackendSession = { - id: string; - kind: "cinematic"; - messageId: string; - status: "pending"; - startedAt: string; -}; - -export type CanvasAssistantSession = { - id: string; - title: string; - messages: CanvasAssistantMessage[]; - pendingBackendSession?: CanvasAssistantPendingBackendSession; - generationEffectKeys?: string[]; - createdAt: string; - updatedAt: string; -}; - -export type ConnectionHandle = { - nodeId: string; - handleType: "source" | "target"; - handleId?: string; - anchorRatio?: number; -}; - -export type CanvasSelectionStrategy = "replace" | "add" | "toggle" | "subtract"; -export type CanvasSelectionHitMode = "contain" | "intersect"; - -export type SelectionBox = { - startWorldX: number; - startWorldY: number; - currentWorldX: number; - currentWorldY: number; - strategy: CanvasSelectionStrategy; - hitMode: CanvasSelectionHitMode; - initialSelectedNodeIds: string[]; -}; - -export type ContextMenuState = - | { - type: "canvas"; - x: number; - y: number; - position: Position; - createOpen?: boolean; - } - | { - type: "node"; - x: number; - y: number; - nodeId: string; - } - | { - type: "connection"; - x: number; - y: number; - connectionId: string; - }; +import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade"; +import type { MediaConversionNodeState } from "@/lib/media-conversion/contracts"; +import type { AssetCategory } from "@/lib/asset-category"; +import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture"; +import type { StyleExecutionPlan } from "@/lib/canvas/style-profile"; +import type { ArtCritiqueNodeState } from "@/lib/art-critique/contracts"; +import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; +import type { SrtEntry, SubtitleHighlight, SubtitleStyle } from "@/types/timeline"; + +export type Position = { + x: number; + y: number; +}; + +export type ViewportTransform = { + x: number; + y: number; + k: number; +}; + +export enum CanvasNodeType { + Image = "image", + Text = "text", + Drawing = "drawing", + Script = "script", + Skill = "skill", + Config = "config", + Video = "video", + Audio = "audio", + Frame = "frame", + Markdown = "markdown", + Svg = "svg", + Html = "html", + Panorama = "panorama", + Compare = "compare", + Chart = "chart", + ColorGrade = "colorgrade", + MediaConversion = "media-conversion", + BatchTable = "batch-table", +} + +/** Runtime IDs contributed by plugins share the persisted node type field. */ +export type PluginCanvasNodeType = string & { readonly __pluginCanvasNodeType?: unique symbol }; +export type CanvasNodeTypeId = CanvasNodeType | PluginCanvasNodeType; + +export function isBuiltinCanvasNodeType(type: CanvasNodeTypeId): type is CanvasNodeType { + return Object.values(CanvasNodeType).includes(type as CanvasNodeType); +} + +export type CanvasNodeStatus = "idle" | "success" | "loading" | "error"; +export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance"; +export type CanvasWorkspaceMode = "simple" | "professional"; +export type CanvasToolMode = "move" | "box-select"; +export type CanvasFolderStyle = "glass" | "stacked" | "midnight" | "paper" | "cinema" | "compact"; +export type CanvasFolderTheme = "aurora" | "obsidian" | "ember" | "pearl"; +export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30"; +export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10"; +export type StoryboardVideoInputMode = "direct" | "keyframe"; +export type CanvasGenerationMode = "text" | "image" | "video" | "audio"; +export type CanvasGenerationBatchMode = "storyboard_image" | "storyboard_video" | "action_board" | "batch_image"; +export type CanvasGenerationBatchStatus = "queued" | "running" | "partial_failed" | "completed" | "cancelled"; +export type CanvasGenerationBatchItemStatus = "waiting" | "submitting" | "queued" | "running" | "succeeded" | "failed" | "cancelled"; +export type CanvasImageGenerationType = "generation" | "edit"; +export type CanvasWorkflowKind = "free" | "script" | "story_input" | "character" | "scene" | "storyboard" | "shot" | "final" | "styleboard" | "reference_set" | "reference_video" | "action_board"; +export type CanvasVideoEditOperation = "text_to_video" | "image_to_video" | "reference_to_video" | "extend" | "inpaint" | "replace_element" | "camera_motion" | "style_transfer" | "audio_to_video" | "compare_versions" | "concat"; +export type CanvasSkillCategory = "writing" | "storyboard" | "image" | "video" | "utility"; +export type CanvasSkillOutputMode = "text" | "json" | "image_prompt" | "workflow"; +export type StoryboardColumn = + | "shotNumber" + | "durationSeconds" + | "plotDescription" + | "dialogue" + | "narrativeIntent" + | "viewerPOV" + | "performanceBlocking" + | "shotSize" + | "emotion" + | "lightingAndAtmosphere" + | "audioEffects" + | "camera" + | "motion" + | "timeBeats" + | "imageGenerationPrompt" + | "videoMotionPrompt" + | "assets" + | "continuityOut" + | "negativePrompt"; + +export type StoryboardAssetRole = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "motion" | "audio"; + +export type StoryboardAssetBinding = { + nodeId: string; + role: StoryboardAssetRole; + priority: number; +}; + +export type StoryboardCharacterReference = { + characterName: string; + characterAssetId?: string; + characterVersionId?: string; + characterDescription?: string; + characterImageNodeId?: string; +}; + +export type StoryboardRow = { + id: string; + shotNumber: number; + durationSeconds: number; + plotDescription: string; + dialogue: string; + characters: StoryboardCharacterReference[]; + narrativeIntent: string; + viewerPOV: string; + performanceBlocking: string; + shotSize: string; + emotion: string; + lightingAndAtmosphere: string; + audioEffects: string; + camera: string; + motion: string; + timeBeats: string; + imageGenerationPrompt: string; + videoMotionPrompt: string; + imagePromptTemplateVariables?: Record; + videoPromptTemplateVariables?: Record; + mustHave: string[]; + optionalDetails: string[]; + continuityOut: string; + negativePrompt: string; + assetBindings: StoryboardAssetBinding[]; + imageNodeId?: string; + videoNodeId?: string; + status?: CanvasNodeStatus; + errorDetails?: string; +}; + +export type StoryboardData = { + rows: StoryboardRow[]; + visibleColumns: StoryboardColumn[]; + referenceNodeIds: string[]; +}; + +export type CanvasGenerationBatchItem = { + id: string; + rowId: string; + nodeId: string; + taskId?: string; + status: CanvasGenerationBatchItemStatus; + retryCount: number; + errorDetails?: string; + costUncertain?: boolean; +}; + +export type CanvasGenerationBatch = { + id: string; + projectId: string; + sourceNodeId: string; + mode: CanvasGenerationBatchMode; + status: CanvasGenerationBatchStatus; + items: CanvasGenerationBatchItem[]; + concurrency?: number; + createdAt: string; + updatedAt: string; +}; + +export type CanvasBatchOperation = "try_on" | "creative"; +export type CanvasBatchRow = { + id: string; + enabled: boolean; + inputNodeIds: string[]; + /** Text nodes selected for this row; their contents are appended to prompt. */ + textNodeIds?: string[]; + prompt: string; + outputNodeId?: string; + /** AI-generated cell content keyed by column id (for aiGenerated tables). */ + cells?: Record; +}; +export type CanvasBatchColumnType = "image" | "text"; +export type CanvasBatchReferenceColumn = { + id: string; + label: string; + type?: CanvasBatchColumnType; +}; +export type CanvasBatchTableData = { + operation: CanvasBatchOperation; + concurrency: number; + /** Optional prompt override applied to every batch row while non-empty. */ + globalPrompt?: string; + referenceColumns?: CanvasBatchReferenceColumn[]; + textColumns?: CanvasBatchReferenceColumn[]; + rows: CanvasBatchRow[]; + /** When true, columns are AI-generated with dynamic headers. */ + aiGenerated?: boolean; +}; + +export type CanvasSkillSnapshot = { + id: string; + name: string; + description: string; + category: CanvasSkillCategory; + template: string; + outputMode: CanvasSkillOutputMode; + outputContract: string; + version: number; + tags: string[]; +}; + +export type CanvasNodeMetadata = { + /** Namespaced extension ownership for nodes contributed by a unified plugin. */ + pluginId?: string; + pluginNodeId?: string; + pluginData?: Record; + importSource?: + | { + provider: "libtv"; + projectUuid: string; + nodeKey: string; + batchId: string; + sourceType?: string; + styleAssetUuid?: string; + styleVersionUuid?: string; + styleName?: string; + } + | { + provider: "tapnow"; + shareId: string; + nodeId: string; + batchId: string; + sourceType?: string; + }; + content?: string; + previewContent?: string; + /** When true, text prompt panel uses list/table generation mode. */ + listMode?: boolean; + videoPreview?: { + content: string; + storageKey?: string; + width?: number; + height?: number; + bytes?: number; + mimeType?: string; + }; + richText?: Record; + composerContent?: string; + prompt?: string; + promptTemplateOperation?: string; + promptTemplateVariables?: Record; + status?: CanvasNodeStatus; + /** 浏览器文件上传,与模型生成任务状态独立。 */ + fileUpload?: "uploading" | "error"; + fileUploadProgress?: number; + locked?: boolean; + errorDetails?: string; + generationErrorCode?: string; + resourceReloadAvailable?: boolean; + failedPromptFingerprint?: string; + lastGenerationRequestFingerprint?: string; + fontSize?: number; + generationMode?: CanvasGenerationMode; + generationType?: CanvasImageGenerationType; + model?: string; + workflowProvider?: "model" | "runninghub"; + runningHubWorkflowId?: string; + runningHubWorkflowKind?: "workflow" | "app"; + /** 当前画布节点覆盖的工作流动态字段,键为 source:* 或 field:nodeId:fieldName。 */ + workflowParameters?: Record; + size?: string; + quality?: string; + transparentBackground?: string; + count?: number; + textCount?: number; + seconds?: string; + vquality?: string; + generateAudio?: string; + watermark?: string; + audioVoice?: string; + audioFormat?: string; + audioSpeed?: string; + audioInstructions?: string; + references?: string[]; + naturalWidth?: number; + naturalHeight?: number; + freeResize?: boolean; + isBatchRoot?: boolean; + batchRootId?: string; + batchChildIds?: string[]; + batchFailedCount?: number; + batchUsesReferenceImages?: boolean; + primaryImageId?: string; + imageBatchExpanded?: boolean; + storageKey?: string; + mimeType?: string; + bytes?: number; + durationMs?: number; + /** Whether the video file contains an audio track when this is known. */ + hasAudio?: boolean; + assetId?: string; + assetTags?: string[]; + assetCategory?: AssetCategory; + workflowKind?: CanvasWorkflowKind; + workflowTitle?: string; + workflowDescription?: string; + stylePresetId?: string; + styleProfileJson?: string; + styleExecutionPlan?: StyleExecutionPlan; + skillIds?: string[]; + skillVersions?: Array<{ skillId: string; versionId: string; version: string }>; + skillFiles?: Array<{ skillId: string; path: string; sha256?: string }>; + chapterId?: string; + chapterTitle?: string; + shotIndex?: number; + sceneId?: string; + characterIds?: string[]; + referenceSetId?: string; + referenceAssetNodeIds?: string[]; + assetBindings?: StoryboardAssetBinding[]; + characterName?: string; + characterPrompt?: string; + characterAliases?: string[]; + characterDefinition?: Record; + characterAssetId?: string; + characterVersionId?: string; + characterVersionPolicy?: "current" | "pinned"; + characterVisualStatus?: string; + characterVoiceStatus?: string; + characterVoiceName?: string; + characterVoiceProfile?: { + name: string; + provider: string; + language: string; + timbre: string; + }; + characterVoiceInstructions?: string; + characterCoverUrl?: string; + characterView?: "front" | "side" | "back" | "multi"; + characterViewNodeIds?: { + front?: string; + side?: string; + back?: string; + }; + actionBoardRows?: number; + actionBoardColumns?: number; + taskId?: string; + taskClientOperationId?: string; + retryOf?: string; + attemptGroupId?: string; + taskStatus?: "queued" | "running" | "succeeded" | "failed" | "cancelled" | string; + taskProgress?: number; + taskStage?: string; + taskProvider?: string; + taskStartedAt?: string; + taskCompletedAt?: string; + taskDurationMs?: number; + taskErrorCode?: string; + taskOfficialStatus?: "pending" | "processing" | "completed" | "failed" | "cancelled"; + taskReceiptRecorded?: boolean; + taskCreatedAt?: string; + taskUpdatedAt?: string; + generationEffectKeys?: string[]; + agentGenerationContinuation?: { + id: string; + taskId: string; + conversationId?: string; + messageId?: string; + source?: "online" | "local"; + status: "pending" | "completed" | "failed"; + effectKey?: string; + }; + sessionId?: string; + videoEditOperation?: CanvasVideoEditOperation; + arkPrivateAssetUpload?: string; + videoCameraMoveId?: string; + videoCameraMovePrompt?: string; + videoStartFrameNodeId?: string; + videoEndFrameNodeId?: string; + videoFrameSourceNodeId?: string; + videoFrameTimeMs?: number; + versionOfNodeId?: string; + versionLabel?: string; + versionPrimary?: boolean; + copiedFromNodeId?: string; + generationResultPlacement?: "replace-node" | "new-version"; + directorSceneId?: string; + directorShotId?: string; + directorPreviewNodeId?: string; + directorDepthNodeId?: string; + directorNormalNodeId?: string; + directorClayVideoNodeId?: string; + subtitleEntries?: SrtEntry[]; + subtitleHighlights?: SubtitleHighlight[]; + subtitleStyle?: SubtitleStyle; + subtitleUpdatedAt?: string; + skillId?: string; + skillVersion?: number; + skillSnapshot?: CanvasSkillSnapshot; + /** 图表节点的图形类型,缺省柱状图。落盘字段——新增扩展节点的自有字段都要在这里声明。 */ + chartKind?: "bar" | "line"; + /** 调色节点的参数;缺省视为未调色。 */ + colorGrade?: CanvasColorGrade; + mediaConversion?: MediaConversionNodeState; + /** 用户手动拉伸过尺寸;图片按真实比例自动适配时避让它。 */ + manualSize?: boolean; + storyboard?: StoryboardData; + storyboardShotDuration?: StoryboardShotDuration; + storyboardShotCount?: StoryboardShotCount; + storyboardVideoInputMode?: StoryboardVideoInputMode; + storyboardComposerHeight?: number; + generationBatches?: CanvasGenerationBatch[]; + batchTable?: CanvasBatchTableData; + batchSourceNodeId?: string; + batchRowId?: string; + batchOperation?: CanvasBatchOperation; + batchInputNodeIds?: string[]; + frame?: { + collapsed: boolean; + expandedWidth: number; + expandedHeight: number; + }; + folder?: { + style: CanvasFolderStyle; + theme?: CanvasFolderTheme; + createdAt: string; + // 自定义主题资源覆盖预置主题;目录内容永远从 childNodes 读取。 + themeCover?: string; + assetFolderId?: string; + projectId?: string; + }; + drawingId?: string; + drawingEngine?: "tldraw" | "excalidraw"; + drawingRevision?: number; + drawingUpdatedAt?: string; + drawingPreviewStorageKey?: string; + drawingPreviewUrl?: string; + drawingShapeCount?: number; + drawingPageCount?: number; + emotionEdit?: { + sourceNodeId: string; + characterName: string; + presetId: string; + intimacy: number; + arousal: number; + label: string; + faceBox: { + id: string; + x: number; + y: number; + width: number; + height: number; + confidence?: number; + source: "detected" | "manual"; + }; + editRegion?: { + x: number; + y: number; + width: number; + height: number; + }; + sourceWidth?: number; + sourceHeight?: number; + providerSize?: string; + maskStorageKey?: string; + editMode?: "provider-mask" | "local-composite"; + }; + portraitTexture?: PortraitTextureSettings; + /** AI 审美批改节点只保存当前报告和输入指纹,不保存图片二进制。 */ + artCritique?: ArtCritiqueNodeState; + /** 摄像机控制选项,启用后生成时自动追加摄影机/镜头/焦距/光圈提示词。 */ + cameraControl?: CameraControlOptions; + /** 全景节点配置:投影方式、生成方式和比例兜底开关。 */ + panoramaConfig?: { + projection: "spherical" | "cylindrical"; + sourceMode: "ai" | "image"; + smartBase: boolean; + directImageUrl?: string | null; + }; +}; + +export type CanvasNodeData = { + id: string; + type: CanvasNodeTypeId; + title: string; + createdAt?: string; + updatedAt?: string; + position: Position; + width: number; + height: number; + parentId?: string; + metadata?: CanvasNodeMetadata; +}; + +export type CanvasConnection = { + id: string; + fromNodeId: string; + toNodeId: string; + fromHandleId?: string; + toHandleId?: string; + fromAnchorRatio?: number; + toAnchorRatio?: number; + relation?: "storyboard-output" | "storyboard-asset-reference" | "batch-output" | "batch-input"; + storyboardRowId?: string; +}; + +export type CanvasDisplayConnection = { + connection: CanvasConnection; + from: CanvasNodeData; + to: CanvasNodeData; +}; + +export type CanvasAssistantReference = { + id: string; + type: CanvasNodeTypeId; + title: string; + dataUrl?: string; + storageKey?: string; + text?: string; +}; + +export type CanvasAssistantImage = { + id: string; + dataUrl: string; + storageKey?: string; + prompt: string; +}; + +export type CanvasAssistantMessage = { + id: string; + role: "user" | "assistant" | "system" | "tool" | "error"; + title?: string; + text: string; + meta?: string; + detail?: unknown; + references?: CanvasAssistantReference[]; +}; + +export type CanvasAssistantPendingBackendSession = { + id: string; + kind: "cinematic"; + messageId: string; + status: "pending"; + startedAt: string; +}; + +export type CanvasAssistantSession = { + id: string; + title: string; + messages: CanvasAssistantMessage[]; + pendingBackendSession?: CanvasAssistantPendingBackendSession; + generationEffectKeys?: string[]; + createdAt: string; + updatedAt: string; +}; + +export type ConnectionHandle = { + nodeId: string; + handleType: "source" | "target"; + handleId?: string; + anchorRatio?: number; +}; + +export type CanvasSelectionStrategy = "replace" | "add" | "toggle" | "subtract"; +export type CanvasSelectionHitMode = "contain" | "intersect"; + +export type SelectionBox = { + startWorldX: number; + startWorldY: number; + currentWorldX: number; + currentWorldY: number; + strategy: CanvasSelectionStrategy; + hitMode: CanvasSelectionHitMode; + initialSelectedNodeIds: string[]; +}; + +export type ContextMenuState = + | { + type: "canvas"; + x: number; + y: number; + position: Position; + createOpen?: boolean; + } + | { + type: "node"; + x: number; + y: number; + nodeId: string; + } + | { + type: "connection"; + x: number; + y: number; + connectionId: string; + }; From cf4d2ab10ca0e6941479d8903e6bf72d546ed329 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:36:20 +0800 Subject: [PATCH 06/20] feat: add right-click and keyboard delete for batch table reference thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Right-click on reference thumbnail shows context menu with 删除素材 - Delete/Backspace key removes the hovered reference cell - Both actions only work within the batch table, on the specific thumbnail --- fix_remove_ref.py | 20 + fix_shared.py | 10 + fix_thumbnail_delete.py | 168 +++++ .../canvas/canvas-batch-table-node.tsx | 62 +- web/src/pages/canvas/project.tsx | 9 + web/src/pages/canvas/shared.tsx | 660 +++++++++--------- 6 files changed, 596 insertions(+), 333 deletions(-) create mode 100644 fix_remove_ref.py create mode 100644 fix_shared.py create mode 100644 fix_thumbnail_delete.py diff --git a/fix_remove_ref.py b/fix_remove_ref.py new file mode 100644 index 000000000..3cdc4e395 --- /dev/null +++ b/fix_remove_ref.py @@ -0,0 +1,20 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\project.tsx" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +old_upload = ' onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}' +new_upload = ''' onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} + onRemoveReference={(rowId, columnIndex) => { + const table = nodesRef.current.find((n) => n.id === contentNode.id)?.metadata?.batchTable; + if (!table) return; + const row = table.rows.find((r) => r.id === rowId); + if (!row) return; + const newInputNodeIds = [...row.inputNodeIds]; + newInputNodeIds[columnIndex] = ""; + updateBatchRow(contentNode.id, rowId, { inputNodeIds: newInputNodeIds }); + }}''' +content = content.replace(old_upload, new_upload, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/fix_shared.py b/fix_shared.py new file mode 100644 index 000000000..4a5708727 --- /dev/null +++ b/fix_shared.py @@ -0,0 +1,10 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\shared.tsx" +with open(path, "r", encoding="utf-8") as f: + content = f.read() +content = content.replace( + 'onUploadReference={() => {}} onConnectStart={() => {}}', + 'onUploadReference={() => {}} onRemoveReference={() => {}} onConnectStart={() => {}}' +) +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/fix_thumbnail_delete.py b/fix_thumbnail_delete.py new file mode 100644 index 000000000..60171a418 --- /dev/null +++ b/fix_thumbnail_delete.py @@ -0,0 +1,168 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\components\canvas\canvas-batch-table-node.tsx" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# 1. Add onRemoveReference to Props +old_props = " onUploadReference: (rowId: string, columnIndex: number, file: File) => void;\n onConnectStart: (event: ReactPointerEvent, handleId: string) => void;" +new_props = " onUploadReference: (rowId: string, columnIndex: number, file: File) => void;\n onRemoveReference: (rowId: string, columnIndex: number) => void;\n onConnectStart: (event: ReactPointerEvent, handleId: string) => void;" +content = content.replace(old_props, new_props, 1) + +# 2. Add hoveredCell ref and context menu state after existing refs +old_refs = " const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null);\n const lastPointerRef = useRef({ x: 0, y: 0 });" +new_refs = """ const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const hoveredCellRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; cell: ReferenceCell } | null>(null);""" +content = content.replace(old_refs, new_refs, 1) + +# 3. Add keyboard delete handler effect after existing useEffect block +old_effect_end = " }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]);" +new_effect_end = """ }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + + // Delete / Backspace to remove hovered reference + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + const target = event.target as HTMLElement; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const cell = hoveredCellRef.current; + if (!cell || readOnly) return; + event.preventDefault(); + event.stopPropagation(); + onRemoveReference(cell.rowId, cell.columnIndex); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [readOnly, onRemoveReference]); + + // Hide context menu on outside click + useEffect(() => { + if (!contextMenu) return; + const hide = () => setContextMenu(null); + window.addEventListener("pointerdown", hide); + return () => window.removeEventListener("pointerdown", hide); + }, [contextMenu]);""" +content = content.replace(old_effect_end, new_effect_end, 1) + +# 4. Destructure onRemoveReference +old_destructure = "onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false" +new_destructure = "onReplaceReference, onUploadReference, onRemoveReference, onConnectStart, onConnectDrop, readOnly = false" +content = content.replace(old_destructure, new_destructure, 1) + +# 5. Pass hover tracking and onRemove to ReferenceThumbnail in the rows +old_thumb = """ { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + />""" +new_thumb = """ onRemoveReference(row.id, columnIndex)} + onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ x: event.clientX, y: event.clientY, cell: { rowId: row.id, columnIndex } }); }} + onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + />""" +content = content.replace(old_thumb, new_thumb, 1) + +# 6. Add context menu overlay before closing div +old_close = """ + + ); +} + +function ReferenceColumnHeader""" +new_close = """ + {contextMenu ? ( +
e.stopPropagation()} + > + +
+ ) : null} + + ); +} + +function ReferenceColumnHeader""" +content = content.replace(old_close, new_close, 1) + +# 7. Update ReferenceThumbnail props type +old_thumb_props = " suppressClickRef: MutableRefObject;\n onReplace: (node: CanvasNodeData) => void;\n onUpload: () => void;" +new_thumb_props = " suppressClickRef: MutableRefObject;\n hoveredCellRef: MutableRefObject;\n onReplace: (node: CanvasNodeData) => void;\n onRemove: () => void;\n onContextMenu: (event: ReactMouseEvent) => void;\n onUpload: () => void;" +content = content.replace(old_thumb_props, new_thumb_props, 1) + +# 8. Update ReferenceThumbnail function signature +old_thumb_sig = "function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {" +new_thumb_sig = "function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onContextMenu, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {" +content = content.replace(old_thumb_sig, new_thumb_sig, 1) + +# 9. Add hover tracking to both thumbnail buttons (empty and filled) +# For empty button - add onMouseEnter/Leave and onContextMenu +old_empty_btn = ''' onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} + aria-label={`${columnId},点击上传图片`}''' +new_empty_btn = ''' onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} + aria-label={`${columnId},点击上传图片`}''' +content = content.replace(old_empty_btn, new_empty_btn, 1) + +# For filled button - add onMouseEnter/Leave and onContextMenu +old_filled_btn = ''' onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} + {...cellData}''' +new_filled_btn = ''' onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} + {...cellData}''' +content = content.replace(old_filled_btn, new_filled_btn, 1) + +# 10. Need to import ReactMouseEvent type +old_import = 'import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";' +new_import = 'import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";' +content = content.replace(old_import, new_import, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index baaa29ce3..5286f6ed3 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Button, Input, Select, Switch, Tooltip } from "antd"; import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; @@ -26,6 +26,7 @@ type Props = { onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; onReplaceReference: (node: CanvasNodeData) => void; onUploadReference: (rowId: string, columnIndex: number, file: File) => void; + onRemoveReference: (rowId: string, columnIndex: number) => void; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; readOnly?: boolean; @@ -38,7 +39,7 @@ const OPERATION_OPTIONS = [ type ReferenceCell = { rowId: string; columnIndex: number }; -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { +export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onRemoveReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; const referenceColumns = batchReferenceColumns(table); const textColumns = batchTextColumns(table); @@ -57,6 +58,8 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t const dropCellRef = useRef(null); const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); const lastPointerRef = useRef({ x: 0, y: 0 }); + const hoveredCellRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; cell: ReferenceCell } | null>(null); const suppressClickRef = useRef(false); const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); const uploadInputRef = useRef(null); @@ -149,6 +152,30 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t }; }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + // Delete / Backspace to remove hovered reference + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + const target = event.target as HTMLElement; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const cell = hoveredCellRef.current; + if (!cell || readOnly) return; + event.preventDefault(); + event.stopPropagation(); + onRemoveReference(cell.rowId, cell.columnIndex); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [readOnly, onRemoveReference]); + + // Hide context menu on outside click + useEffect(() => { + if (!contextMenu) return; + const hide = () => setContextMenu(null); + window.addEventListener("pointerdown", hide); + return () => window.removeEventListener("pointerdown", hide); + }, [contextMenu]); + const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { if (readOnly || !hasMaterial) return; event.preventDefault(); @@ -293,7 +320,10 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t isDraggingCell={draggingCell?.rowId === row.id && draggingCell.columnIndex === columnIndex} isDropTarget={dropCell?.rowId === row.id && dropCell.columnIndex === columnIndex} suppressClickRef={suppressClickRef} + hoveredCellRef={hoveredCellRef} onReplace={onReplaceReference} + onRemove={() => onRemoveReference(row.id, columnIndex)} + onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ x: event.clientX, y: event.clientY, cell: { rowId: row.id, columnIndex } }); }} onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} onPointerDown={handleReferencePointerDown} onPointerMove={handleReferencePointerMove} @@ -351,6 +381,23 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
)} + {contextMenu ? ( +
e.stopPropagation()} + > + +
+ ) : null} ); } @@ -497,7 +544,7 @@ function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasThe return
{content || "未连接文字"}
; } -function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onContextMenu, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { node?: CanvasNodeData; theme: CanvasTheme; readOnly: boolean; @@ -508,7 +555,10 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI isDraggingCell: boolean; isDropTarget: boolean; suppressClickRef: MutableRefObject; + hoveredCellRef: MutableRefObject; onReplace: (node: CanvasNodeData) => void; + onRemove: () => void; + onContextMenu: (event: ReactMouseEvent) => void; onUpload: () => void; onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; onPointerMove: (event: ReactPointerEvent) => void; @@ -528,6 +578,9 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI style={{ borderColor: isDropTarget ? theme.accent.primary : theme.node.stroke, background: `${theme.node.panel}88`, ...dropStyle }} disabled={readOnly} onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} aria-label={`${columnId},点击上传图片`} {...cellData} onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, false)} @@ -553,6 +606,9 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI style={{ borderColor: isDropTarget || draggingColumnId === columnId ? theme.accent.primary : theme.node.stroke, opacity: isDraggingCell || draggingColumnId === columnId ? 0.55 : 1, ...dropStyle }} aria-label={readOnly ? node.title || "参考图" : `${node.title || "参考图"},点击替换素材`} onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} {...cellData} onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, true)} onPointerMove={onPointerMove} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 5ddc7b8db..79b236e92 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2174,6 +2174,15 @@ function InfiniteCanvasPage() { onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)} onReplaceReference={replaceCanvasNodeMedia} onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} + onRemoveReference={(rowId, columnIndex) => { + const table = nodesRef.current.find((n) => n.id === contentNode.id)?.metadata?.batchTable; + if (!table) return; + const row = table.rows.find((r) => r.id === rowId); + if (!row) return; + const newInputNodeIds = [...row.inputNodeIds]; + newInputNodeIds[columnIndex] = ""; + updateBatchRow(contentNode.id, rowId, { inputNodeIds: newInputNodeIds }); + }} onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)} onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)} /> diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index 77cfb9cbb..f0c3d869e 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -1,330 +1,330 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; -import { App, Button } from "antd"; -import { Clapperboard, Eye, FileText, Image as ImageIcon, LockKeyhole, LogIn, Send, Share2, Video } from "lucide-react"; -import { Link, useParams } from "react-router"; -import { nanoid } from "nanoid"; - -import { ConnectionPath } from "@/components/canvas/canvas-connections"; -import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar"; -import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node"; -import { CanvasNode } from "@/components/canvas/canvas-node"; -import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node"; -import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; -import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; -import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"; -import { WorkspaceState } from "@/components/layout/workspace-state"; -import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; -import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; -import { canvasThemes } from "@/lib/canvas-theme"; -import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame"; -import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size"; -import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references"; -import { getPublicCanvasShare } from "@/services/api/canvas-share"; -import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store"; -import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas"; - -type ContextMenu = { x: number; y: number; world: Position; nodeId?: string }; -type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: number; origins: Map; moved: boolean }; - -export default function SharedCanvasPage() { - useCanvasThemeScope(); - const { token = "" } = useParams(); - const { message } = App.useApp(); - const colorTheme = useCanvasThemeStore((state) => state.theme); - const theme = canvasThemes[colorTheme]; - const containerRef = useRef(null); - const viewportRef = useRef({ x: 0, y: 0, k: 1 }); - const dragRef = useRef(null); - const toolbarTimerRef = useRef | null>(null); - const [title, setTitle] = useState("共享画布"); - const [nodes, setNodes] = useState([]); - const [connections, setConnections] = useState>["project"]["connections"]>([]); - const [appearance, setAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); - const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines"); - const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); - const [selectedNodeId, setSelectedNodeId] = useState(null); - const [infoNodeId, setInfoNodeId] = useState(null); - const [toolbarNodeId, setToolbarNodeId] = useState(null); - const [dragOffset, setDragOffset] = useState(null); - const [contextMenu, setContextMenu] = useState(null); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(""); - - const unauthorized = useCallback(() => message.warning("未授权:分享画布仅供查看,该操作不会执行。"), [message]); - const infoNode = nodes.find((node) => node.id === infoNodeId) || null; - const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); - const frameChildrenById = useMemo(() => { - const result = new Map(); - nodes.forEach((node) => { - if (!node.parentId) return; - const children = result.get(node.parentId) || []; - children.push(node); - result.set(node.parentId, children); - }); - return result; - }, [nodes]); - const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]); - const visibleConnections = useMemo(() => connections.flatMap((connection) => { - const resolved = resolveFrameConnection(connection, nodes); - return resolved ? [{ connection, ...resolved }] : []; - }), [connections, nodes]); - const connectionBounds = useMemo(() => { - if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 }; - const padding = 320; - const left = Math.min(...nodes.map((node) => node.position.x)) - padding; - const top = Math.min(...nodes.map((node) => node.position.y)) - padding; - const right = Math.max(...nodes.map((node) => node.position.x + node.width)) + padding; - const bottom = Math.max(...nodes.map((node) => node.position.y + node.height)) + padding; - return { left, top, width: right - left, height: bottom - top }; - }, [nodes]); - - useEffect(() => { - let active = true; - const themeBeforeShare = useCanvasThemeStore.getState().theme; - let appliedShareTheme: typeof themeBeforeShare | null = null; - let themeChangedAfterApply = false; - const unsubscribeTheme = useCanvasThemeStore.subscribe((state, previous) => { - if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true; - }); - setLoading(true); - getPublicCanvasShare(token).then(({ project }) => { - if (!active) return; - setTitle(project.title || "共享画布"); - setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize)); - setConnections(project.connections || []); - const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare); - setAppearance(nextAppearance); - appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare); - useCanvasThemeStore.getState().setTheme(appliedShareTheme); - setBackgroundMode(project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE); - const initial = project.viewport || { x: 0, y: 0, k: 1 }; - viewportRef.current = initial; - setViewport(initial); - }).catch((error) => { - if (active) setLoadError(error instanceof Error ? error.message : "分享链接无效或已失效"); - }).finally(() => { - if (active) setLoading(false); - }); - return () => { - active = false; - unsubscribeTheme(); - if (appliedShareTheme && !themeChangedAfterApply && useCanvasThemeStore.getState().theme === appliedShareTheme) { - useCanvasThemeStore.getState().setTheme(themeBeforeShare); - } - }; - }, [token]); - - useEffect(() => { - const onMove = (event: MouseEvent) => { - const drag = dragRef.current; - if (!drag) return; - const next = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k }; - if (Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3) drag.moved = true; - setDragOffset(next); - }; - const onUp = (event: MouseEvent) => { - const drag = dragRef.current; - if (!drag) return; - const offset = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k }; - if (drag.moved) setNodes((current) => current.map((node) => { - const origin = drag.origins.get(node.id); - return origin ? { ...node, position: { x: origin.x + offset.x, y: origin.y + offset.y } } : node; - })); - else setInfoNodeId(drag.primaryId); - dragRef.current = null; - setDragOffset(null); - document.body.style.cursor = "default"; - }; - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - return () => { - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - document.body.style.cursor = "default"; - if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current); - }; - }, []); - - const keepToolbar = useCallback((nodeId: string) => { - if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current); - toolbarTimerRef.current = null; - setToolbarNodeId(nodeId); - }, []); - const hideToolbar = useCallback(() => { - if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current); - toolbarTimerRef.current = setTimeout(() => { - toolbarTimerRef.current = null; - setToolbarNodeId(null); - }, 160); - }, []); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { - event.preventDefault(); - unauthorized(); - } - if (event.key === "Escape") setContextMenu(null); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [unauthorized]); - - const onViewportChange = useCallback((next: ViewportTransform) => { - viewportRef.current = next; - setViewport(next); - setContextMenu(null); - }, []); - const setZoom = (scale: number) => { - const container = containerRef.current; - if (!container) return; - const rect = container.getBoundingClientRect(); - const current = viewportRef.current; - onViewportChange({ - x: rect.width / 2 - ((rect.width / 2 - current.x) / current.k) * scale, - y: rect.height / 2 - ((rect.height / 2 - current.y) / current.k) * scale, - k: scale, - }); - }; - const resetViewport = () => { - const container = containerRef.current; - if (!container || !nodes.length) return onViewportChange({ x: 0, y: 0, k: 1 }); - const rect = container.getBoundingClientRect(); - const left = Math.min(...nodes.map((node) => node.position.x)); - const top = Math.min(...nodes.map((node) => node.position.y)); - const right = Math.max(...nodes.map((node) => node.position.x + node.width)); - const bottom = Math.max(...nodes.map((node) => node.position.y + node.height)); - const scale = Math.min(1, Math.max(0.05, Math.min((rect.width - 120) / Math.max(right - left, 1), (rect.height - 140) / Math.max(bottom - top, 1)))); - onViewportChange({ x: rect.width / 2 - ((left + right) / 2) * scale, y: rect.height / 2 - ((top + bottom) / 2) * scale, k: scale }); - }; - const openContextMenu = (event: ReactMouseEvent, nodeId?: string) => { - event.preventDefault(); - event.stopPropagation(); - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - const current = viewportRef.current; - setContextMenu({ - x: event.clientX - rect.left, - y: event.clientY - rect.top, - world: { x: (event.clientX - rect.left - current.x) / current.k, y: (event.clientY - rect.top - current.y) / current.k }, - nodeId, - }); - }; - const addNode = (type: CanvasNodeType) => { - if (!contextMenu) return; - const size = NODE_DEFAULT_SIZE[type]; - setNodes((current) => [...current, { - id: `shared-${nanoid()}`, - type, - title: type === CanvasNodeType.Text ? "临时文本" : type === CanvasNodeType.Image ? "临时图片" : type === CanvasNodeType.Video ? "临时视频" : "临时分镜", - position: contextMenu.world, - width: size.width, - height: size.height, - metadata: type === CanvasNodeType.Text ? { content: "此节点只存在于当前浏览器页面,刷新后消失。" } : {}, - }]); - setContextMenu(null); - message.info("已添加临时节点,刷新页面后会消失"); - }; - const toggleFrame = (nodeId: string) => setNodes((current) => current.map((node) => { - if (node.id !== nodeId || !isFrameNode(node)) return node; - const collapsed = !node.metadata?.frame?.collapsed; - const frame = node.metadata?.frame; - return { - ...node, - width: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_WIDTH : 240) : frame?.expandedWidth || node.width, - height: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_HEIGHT : 144) : frame?.expandedHeight || node.height, - metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } }, - }; - })); - const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => { - if (node.type === CanvasNodeType.Script) return ; - if (node.type === CanvasNodeType.BatchTable) return {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onAddTextColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onReplaceReference={() => {}} onUploadReference={() => {}} onConnectStart={() => {}} />; - return ; - }, [connections, nodes, theme, unauthorized]); - const toolbarNodeKey = selectedNodeId; - const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null; - - if (loading) return ; - if (loadError) return
} />
; - - return ( -
-
-
- - {title} - 只读分享 -
- -
- - { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}> - - {visibleConnections.map(({ connection, from, to }) => setInfoNodeId(to.id)} />)} - - {visibleNodes.map((node) => isFrameNode(node) ? { - event.stopPropagation(); - if (event.button !== 0) return; - setSelectedNodeId(nodeId); - setContextMenu(null); - const dragged = [node, ...(frameChildrenById.get(nodeId) || [])]; - dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false }; - document.body.style.cursor = "grabbing"; - }} onResize={() => undefined} onToggleCollapsed={toggleFrame} onFolderStyleChange={() => undefined} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : { - event.stopPropagation(); - if (event.button !== 0) return; - const target = nodes.find((item) => item.id === nodeId); - if (!target) return; - setSelectedNodeId(nodeId); - setContextMenu(null); - dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false }; - document.body.style.cursor = "grabbing"; - }} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)} - - - setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onImageEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} /> - -
-
访客操作仅在当前页面临时生效
- - {contextMenu ? { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null} - setInfoNodeId(null)} readOnly onUnauthorized={unauthorized} /> -
- ); -} - -function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) { - const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; - return
event.stopPropagation()}> - {menu.nodeId ? <>} label="查看节点信息" onClick={onInfo} />} label="编辑或生成" onClick={onUnauthorized} /> : <> -
添加临时节点
- } label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} /> - } label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} /> - } label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} /> - } label="分镜脚本" onClick={() => onAdd(CanvasNodeType.Script)} /> - } -
; -} - -function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) { - return ; -} - -function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { - const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; - return
-
{node.title}
-
{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}
-
-
; -} - -function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { - const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; - const rows = node.metadata?.storyboard?.rows || []; - return
-
{node.title}{rows.length} 镜
-
event.stopPropagation()}>{rows.length ? rows.map((row) =>
#{row.shotNumber}{row.durationSeconds}s{row.plotDescription || "-"}{row.dialogue || "-"}
) :
暂无分镜
}
- {node.metadata?.composerContent ?
{node.metadata.composerContent}
: null} -
; -} +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { App, Button } from "antd"; +import { Clapperboard, Eye, FileText, Image as ImageIcon, LockKeyhole, LogIn, Send, Share2, Video } from "lucide-react"; +import { Link, useParams } from "react-router"; +import { nanoid } from "nanoid"; + +import { ConnectionPath } from "@/components/canvas/canvas-connections"; +import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar"; +import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node"; +import { CanvasNode } from "@/components/canvas/canvas-node"; +import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node"; +import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; +import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; +import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader"; +import { WorkspaceState } from "@/components/layout/workspace-state"; +import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame"; +import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size"; +import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references"; +import { getPublicCanvasShare } from "@/services/api/canvas-share"; +import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store"; +import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas"; + +type ContextMenu = { x: number; y: number; world: Position; nodeId?: string }; +type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: number; origins: Map; moved: boolean }; + +export default function SharedCanvasPage() { + useCanvasThemeScope(); + const { token = "" } = useParams(); + const { message } = App.useApp(); + const colorTheme = useCanvasThemeStore((state) => state.theme); + const theme = canvasThemes[colorTheme]; + const containerRef = useRef(null); + const viewportRef = useRef({ x: 0, y: 0, k: 1 }); + const dragRef = useRef(null); + const toolbarTimerRef = useRef | null>(null); + const [title, setTitle] = useState("共享画布"); + const [nodes, setNodes] = useState([]); + const [connections, setConnections] = useState>["project"]["connections"]>([]); + const [appearance, setAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); + const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines"); + const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [infoNodeId, setInfoNodeId] = useState(null); + const [toolbarNodeId, setToolbarNodeId] = useState(null); + const [dragOffset, setDragOffset] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + + const unauthorized = useCallback(() => message.warning("未授权:分享画布仅供查看,该操作不会执行。"), [message]); + const infoNode = nodes.find((node) => node.id === infoNodeId) || null; + const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]); + const frameChildrenById = useMemo(() => { + const result = new Map(); + nodes.forEach((node) => { + if (!node.parentId) return; + const children = result.get(node.parentId) || []; + children.push(node); + result.set(node.parentId, children); + }); + return result; + }, [nodes]); + const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]); + const visibleConnections = useMemo(() => connections.flatMap((connection) => { + const resolved = resolveFrameConnection(connection, nodes); + return resolved ? [{ connection, ...resolved }] : []; + }), [connections, nodes]); + const connectionBounds = useMemo(() => { + if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 }; + const padding = 320; + const left = Math.min(...nodes.map((node) => node.position.x)) - padding; + const top = Math.min(...nodes.map((node) => node.position.y)) - padding; + const right = Math.max(...nodes.map((node) => node.position.x + node.width)) + padding; + const bottom = Math.max(...nodes.map((node) => node.position.y + node.height)) + padding; + return { left, top, width: right - left, height: bottom - top }; + }, [nodes]); + + useEffect(() => { + let active = true; + const themeBeforeShare = useCanvasThemeStore.getState().theme; + let appliedShareTheme: typeof themeBeforeShare | null = null; + let themeChangedAfterApply = false; + const unsubscribeTheme = useCanvasThemeStore.subscribe((state, previous) => { + if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true; + }); + setLoading(true); + getPublicCanvasShare(token).then(({ project }) => { + if (!active) return; + setTitle(project.title || "共享画布"); + setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize)); + setConnections(project.connections || []); + const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare); + setAppearance(nextAppearance); + appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare); + useCanvasThemeStore.getState().setTheme(appliedShareTheme); + setBackgroundMode(project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE); + const initial = project.viewport || { x: 0, y: 0, k: 1 }; + viewportRef.current = initial; + setViewport(initial); + }).catch((error) => { + if (active) setLoadError(error instanceof Error ? error.message : "分享链接无效或已失效"); + }).finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + unsubscribeTheme(); + if (appliedShareTheme && !themeChangedAfterApply && useCanvasThemeStore.getState().theme === appliedShareTheme) { + useCanvasThemeStore.getState().setTheme(themeBeforeShare); + } + }; + }, [token]); + + useEffect(() => { + const onMove = (event: MouseEvent) => { + const drag = dragRef.current; + if (!drag) return; + const next = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k }; + if (Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3) drag.moved = true; + setDragOffset(next); + }; + const onUp = (event: MouseEvent) => { + const drag = dragRef.current; + if (!drag) return; + const offset = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k }; + if (drag.moved) setNodes((current) => current.map((node) => { + const origin = drag.origins.get(node.id); + return origin ? { ...node, position: { x: origin.x + offset.x, y: origin.y + offset.y } } : node; + })); + else setInfoNodeId(drag.primaryId); + dragRef.current = null; + setDragOffset(null); + document.body.style.cursor = "default"; + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.style.cursor = "default"; + if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current); + }; + }, []); + + const keepToolbar = useCallback((nodeId: string) => { + if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current); + toolbarTimerRef.current = null; + setToolbarNodeId(nodeId); + }, []); + const hideToolbar = useCallback(() => { + if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current); + toolbarTimerRef.current = setTimeout(() => { + toolbarTimerRef.current = null; + setToolbarNodeId(null); + }, 160); + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + unauthorized(); + } + if (event.key === "Escape") setContextMenu(null); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [unauthorized]); + + const onViewportChange = useCallback((next: ViewportTransform) => { + viewportRef.current = next; + setViewport(next); + setContextMenu(null); + }, []); + const setZoom = (scale: number) => { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const current = viewportRef.current; + onViewportChange({ + x: rect.width / 2 - ((rect.width / 2 - current.x) / current.k) * scale, + y: rect.height / 2 - ((rect.height / 2 - current.y) / current.k) * scale, + k: scale, + }); + }; + const resetViewport = () => { + const container = containerRef.current; + if (!container || !nodes.length) return onViewportChange({ x: 0, y: 0, k: 1 }); + const rect = container.getBoundingClientRect(); + const left = Math.min(...nodes.map((node) => node.position.x)); + const top = Math.min(...nodes.map((node) => node.position.y)); + const right = Math.max(...nodes.map((node) => node.position.x + node.width)); + const bottom = Math.max(...nodes.map((node) => node.position.y + node.height)); + const scale = Math.min(1, Math.max(0.05, Math.min((rect.width - 120) / Math.max(right - left, 1), (rect.height - 140) / Math.max(bottom - top, 1)))); + onViewportChange({ x: rect.width / 2 - ((left + right) / 2) * scale, y: rect.height / 2 - ((top + bottom) / 2) * scale, k: scale }); + }; + const openContextMenu = (event: ReactMouseEvent, nodeId?: string) => { + event.preventDefault(); + event.stopPropagation(); + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const current = viewportRef.current; + setContextMenu({ + x: event.clientX - rect.left, + y: event.clientY - rect.top, + world: { x: (event.clientX - rect.left - current.x) / current.k, y: (event.clientY - rect.top - current.y) / current.k }, + nodeId, + }); + }; + const addNode = (type: CanvasNodeType) => { + if (!contextMenu) return; + const size = NODE_DEFAULT_SIZE[type]; + setNodes((current) => [...current, { + id: `shared-${nanoid()}`, + type, + title: type === CanvasNodeType.Text ? "临时文本" : type === CanvasNodeType.Image ? "临时图片" : type === CanvasNodeType.Video ? "临时视频" : "临时分镜", + position: contextMenu.world, + width: size.width, + height: size.height, + metadata: type === CanvasNodeType.Text ? { content: "此节点只存在于当前浏览器页面,刷新后消失。" } : {}, + }]); + setContextMenu(null); + message.info("已添加临时节点,刷新页面后会消失"); + }; + const toggleFrame = (nodeId: string) => setNodes((current) => current.map((node) => { + if (node.id !== nodeId || !isFrameNode(node)) return node; + const collapsed = !node.metadata?.frame?.collapsed; + const frame = node.metadata?.frame; + return { + ...node, + width: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_WIDTH : 240) : frame?.expandedWidth || node.width, + height: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_HEIGHT : 144) : frame?.expandedHeight || node.height, + metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } }, + }; + })); + const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => { + if (node.type === CanvasNodeType.Script) return ; + if (node.type === CanvasNodeType.BatchTable) return {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onAddTextColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onReplaceReference={() => {}} onUploadReference={() => {}} onRemoveReference={() => {}} onConnectStart={() => {}} />; + return ; + }, [connections, nodes, theme, unauthorized]); + const toolbarNodeKey = selectedNodeId; + const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null; + + if (loading) return ; + if (loadError) return
} />
; + + return ( +
+
+
+ + {title} + 只读分享 +
+ +
+ + { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}> + + {visibleConnections.map(({ connection, from, to }) => setInfoNodeId(to.id)} />)} + + {visibleNodes.map((node) => isFrameNode(node) ? { + event.stopPropagation(); + if (event.button !== 0) return; + setSelectedNodeId(nodeId); + setContextMenu(null); + const dragged = [node, ...(frameChildrenById.get(nodeId) || [])]; + dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false }; + document.body.style.cursor = "grabbing"; + }} onResize={() => undefined} onToggleCollapsed={toggleFrame} onFolderStyleChange={() => undefined} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : { + event.stopPropagation(); + if (event.button !== 0) return; + const target = nodes.find((item) => item.id === nodeId); + if (!target) return; + setSelectedNodeId(nodeId); + setContextMenu(null); + dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false }; + document.body.style.cursor = "grabbing"; + }} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)} + + + setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onImageEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} /> + +
+
访客操作仅在当前页面临时生效
+ + {contextMenu ? { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null} + setInfoNodeId(null)} readOnly onUnauthorized={unauthorized} /> +
+ ); +} + +function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) { + const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; + return
event.stopPropagation()}> + {menu.nodeId ? <>} label="查看节点信息" onClick={onInfo} />} label="编辑或生成" onClick={onUnauthorized} /> : <> +
添加临时节点
+ } label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} /> + } label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} /> + } label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} /> + } label="分镜脚本" onClick={() => onAdd(CanvasNodeType.Script)} /> + } +
; +} + +function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) { + return ; +} + +function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { + const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; + return
+
{node.title}
+
{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}
+
+
; +} + +function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { + const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; + const rows = node.metadata?.storyboard?.rows || []; + return
+
{node.title}{rows.length} 镜
+
event.stopPropagation()}>{rows.length ? rows.map((row) =>
#{row.shotNumber}{row.durationSeconds}s{row.plotDescription || "-"}{row.dialogue || "-"}
) :
暂无分镜
}
+ {node.metadata?.composerContent ?
{node.metadata.composerContent}
: null} +
; +} From 937f2b7a3a7d2d5eeeccf1b6178b2f77cf19f514 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:39:09 +0800 Subject: [PATCH 07/20] fix: use buildGenerationConfig for list mode to resolve channel/model properly --- fix_list_config.py | 19 +++++++++++++++++++ web/src/pages/canvas/list-mode-generator.ts | 5 ++--- 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 fix_list_config.py diff --git a/fix_list_config.py b/fix_list_config.py new file mode 100644 index 000000000..6355326ac --- /dev/null +++ b/fix_list_config.py @@ -0,0 +1,19 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\list-mode-generator.ts" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# Fix import +content = content.replace( + 'import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";', + 'import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";' +) + +# Fix config building - use buildGenerationConfig instead of resolveCanvasGenerationModel +content = content.replace( + ' const model = resolveCanvasGenerationModel(config, undefined, "text");\n const listConfig = { ...config, model };', + ' const listConfig = buildGenerationConfig(config, sourceNode, "text");' +) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/web/src/pages/canvas/list-mode-generator.ts b/web/src/pages/canvas/list-mode-generator.ts index f279ed16d..15a21bc8e 100644 --- a/web/src/pages/canvas/list-mode-generator.ts +++ b/web/src/pages/canvas/list-mode-generator.ts @@ -2,7 +2,7 @@ import { nanoid } from "nanoid"; import { message } from "antd"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasBatchTableData, type CanvasBatchRow } from "@/types/canvas"; import { runBackendGenerationTask } from "@/services/api/generation-task"; -import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; +import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation"; import type { AiConfig } from "@/stores/use-config-store"; const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你多张产品图片和一个任务描述。 @@ -95,8 +95,7 @@ export async function handleListGenerate({ setRunningNodeId(sourceNodeId); try { - const model = resolveCanvasGenerationModel(config, undefined, "text"); - const listConfig = { ...config, model }; + const listConfig = buildGenerationConfig(config, sourceNode, "text"); const referenceImages = imageNodes.map((node) => ({ id: node.id, From 944a220939c645d17f5e5f6bd1ceb87e4e41a9ff Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:46:26 +0800 Subject: [PATCH 08/20] fix: force text model for list mode to avoid image model rejection --- fix_text_model.py | 17 +++++++++++++++++ web/src/pages/canvas/list-mode-generator.ts | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 fix_text_model.py diff --git a/fix_text_model.py b/fix_text_model.py new file mode 100644 index 000000000..647666b61 --- /dev/null +++ b/fix_text_model.py @@ -0,0 +1,17 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\list-mode-generator.ts" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +old = ''' const listConfig = buildGenerationConfig(config, sourceNode, "text");''' +new = ''' // Force text model: strip node.metadata.model so buildGenerationConfig uses config.textModel + const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: undefined } }; + let listConfig = buildGenerationConfig(config, sourceNodeForConfig, "text"); + // Explicitly set model to the configured text model + if (config.textModel) { + listConfig = { ...listConfig, model: config.textModel }; + }''' +content = content.replace(old, new, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/web/src/pages/canvas/list-mode-generator.ts b/web/src/pages/canvas/list-mode-generator.ts index 15a21bc8e..f5de40f12 100644 --- a/web/src/pages/canvas/list-mode-generator.ts +++ b/web/src/pages/canvas/list-mode-generator.ts @@ -95,7 +95,13 @@ export async function handleListGenerate({ setRunningNodeId(sourceNodeId); try { - const listConfig = buildGenerationConfig(config, sourceNode, "text"); + // Force text model: strip node.metadata.model so buildGenerationConfig uses config.textModel + const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: undefined } }; + let listConfig = buildGenerationConfig(config, sourceNodeForConfig, "text"); + // Explicitly set model to the configured text model + if (config.textModel) { + listConfig = { ...listConfig, model: config.textModel }; + } const referenceImages = imageNodes.map((node) => ({ id: node.id, From baddbc2f5e0fdf81e06f3eaddb938d31cf25a38b Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:51:07 +0800 Subject: [PATCH 09/20] fix: allow image inputs for multimodal text models in capability check --- backend/internal/app/model_router.go | 2819 +++++++++++++------------- fix_list_config.py | 19 - fix_remove_ref.py | 20 - fix_shared.py | 10 - fix_text_model.py | 17 - fix_thumbnail_delete.py | 168 -- 6 files changed, 1410 insertions(+), 1643 deletions(-) delete mode 100644 fix_list_config.py delete mode 100644 fix_remove_ref.py delete mode 100644 fix_shared.py delete mode 100644 fix_text_model.py delete mode 100644 fix_thumbnail_delete.py diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 4d2860c02..5a4e1b46f 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -1,1409 +1,1410 @@ -package app - -import ( - "context" - cryptorand "crypto/rand" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "log" - "math" - "sort" - "strconv" - "strings" - "time" - - "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/repository" -) - -type CapabilityImageSizePreset struct { - Size string `json:"size"` - Tier string `json:"tier"` - Ratio string `json:"ratio"` - Width int `json:"width"` - Height int `json:"height"` -} - -type CapabilityImageSize struct { - Parameter string `json:"parameter,omitempty"` - AllowCustom bool `json:"allowCustom,omitempty"` - Presets []CapabilityImageSizePreset `json:"presets,omitempty"` -} - -type CapabilitySpec struct { - Version int `json:"version"` - Capability string `json:"capability"` - Operations []string `json:"operations,omitempty"` - Inputs map[string]InputConstraint `json:"inputs,omitempty"` - Options map[string]OptionConstraint `json:"options,omitempty"` - ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` -} - -type InputConstraint struct { - Min int `json:"min"` - Max int `json:"max"` -} - -type OptionConstraint struct { - Values []any `json:"values,omitempty"` - Min *float64 `json:"min,omitempty"` - Max *float64 `json:"max,omitempty"` - Step *float64 `json:"step,omitempty"` -} - -type ModelRequestIntent struct { - Capability string `json:"capability"` - Operation string `json:"operation,omitempty"` - Inputs map[string]int `json:"inputs,omitempty"` - Options map[string]any `json:"options,omitempty"` -} - -// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。 -func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent { - capability := normalizeCapability(fmt.Sprint(input["mode"])) - if capability == "" { - capability = capabilityFromTaskType(taskType) - } - intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}} - for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { - if values, ok := input[key].([]any); ok { - intent.Inputs[inputType] = len(values) - } - } - if mask, exists := input["mask"]; exists && mask != nil { - intent.Inputs["mask"] = 1 - } - explicitOptions := false - if options, ok := input["capabilityOptions"].(map[string]any); ok { - explicitOptions = true - for key, value := range options { - name := canonicalCapabilityOptionName(key) - // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 - // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 - if name == "quality" { - if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { - continue - } - } - intent.Options[name] = normalizeModelRequestOption(name, value) - } - } - if config, ok := input["config"].(map[string]any); ok && !explicitOptions { - for key, value := range config { - switch key { - case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": - continue - default: - canonical := canonicalCapabilityOptionName(key) - if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { - intent.Options[canonical] = normalizeModelRequestOption(canonical, value) - } - } - } - } - return intent -} - -func normalizeModelRequestOption(name string, value any) any { - canonicalName := canonicalCapabilityOptionName(name) - if canonicalName == "quality" || canonicalName == "size" { - if text, ok := value.(string); ok { - return strings.ToLower(strings.TrimSpace(text)) - } - return value - } - if canonicalName != "vquality" { - return value - } - resolution, ok := value.(string) - if !ok { - return value - } - switch strings.ToLower(strings.TrimSpace(resolution)) { - case "low", "480", "480p": - return "480p" - case "720", "720p": - return "720p" - case "1080", "1080p": - return "1080p" - case "2k", "1440", "1440p": - return "1440p" - case "4k", "2160", "2160p": - return "2160p" - default: - return value - } -} - -type CapabilityMatch struct { - Matched bool `json:"matched"` - Reasons []string `json:"reasons,omitempty"` -} - -type cachedLogicalModel struct { - Model model.LogicalModel - Revision model.LogicalModelRevision - ProductSpec CapabilitySpec - Defaults map[string]any - Routes []cachedLogicalRoute -} - -type cachedLogicalRoute struct { - Route model.LogicalModelRoute - CapabilitySpec CapabilitySpec - ChannelModel model.ChannelModel -} - -type routeCatalogSnapshot struct { - LoadedAt time.Time - CatalogVersion int64 - Models map[string]cachedLogicalModel - Ordered []string -} - -type RoutedModel struct { - LogicalModel model.LogicalModel - Revision model.LogicalModelRevision - Route model.LogicalModelRoute - ChannelModel model.ChannelModel - PriceTier *model.ChannelModelPriceTier - Defaults map[string]any -} - -func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { - var spec CapabilitySpec - if err := json.Unmarshal([]byte(raw), &spec); err != nil { - return spec, BadAuthRequest("能力配置不是有效 JSON") - } - normalized, err := NormalizeCapabilitySpec(spec) - if err != nil { - return spec, err - } - return normalized, nil -} - -func ValidateCapabilitySpec(spec CapabilitySpec) error { - _, err := NormalizeCapabilitySpec(spec) - return err -} - -func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { - spec.Capability = normalizeCapability(spec.Capability) - if spec.Version != 1 { - return spec, BadAuthRequest("能力配置 version 必须为 1") - } - if spec.Capability == "" { - return spec, BadAuthRequest("能力配置必须声明 capability") - } - operations := make([]string, 0, len(spec.Operations)) - seenOperations := make(map[string]bool, len(spec.Operations)) - for _, operation := range spec.Operations { - normalized := normalizeCapabilityValue(operation) - if normalized != "" && !seenOperations[normalized] { - seenOperations[normalized] = true - operations = append(operations, normalized) - } - } - spec.Operations = operations - normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs)) - for rawName, constraint := range spec.Inputs { - name := normalizeCapabilityValue(rawName) - if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min { - return spec, BadAuthRequest("输入能力范围无效") - } - if _, exists := normalizedInputs[name]; exists { - return spec, BadAuthRequest("输入能力存在重复名称:" + name) - } - normalizedInputs[name] = constraint - } - spec.Inputs = normalizedInputs - normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) - for rawName, constraint := range spec.Options { - name := canonicalCapabilityOptionName(rawName) - if strings.TrimSpace(name) == "" { - return spec, BadAuthRequest("能力参数名称不能为空") - } - if _, exists := normalizedOptions[name]; exists { - return spec, BadAuthRequest("能力参数存在重复别名:" + name) - } - hasValues := len(constraint.Values) > 0 - hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil - if !hasValues && !hasRange { - return spec, BadAuthRequest("能力参数必须声明 values 或数值范围") - } - if hasValues && hasRange { - return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围") - } - if hasRange && (constraint.Min == nil || constraint.Max == nil) { - return spec, BadAuthRequest("数值范围必须同时声明 min 和 max") - } - if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min { - return spec, BadAuthRequest("能力参数数值范围无效") - } - if constraint.Step != nil && *constraint.Step <= 0 { - return spec, BadAuthRequest("能力参数 step 必须大于 0") - } - normalizedOptions[name] = constraint - } - spec.Options = normalizedOptions - return spec, nil -} - -func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) { - defaults := map[string]any{} - if err := json.Unmarshal([]byte(raw), &defaults); err != nil { - return nil, err - } - return normalizeLogicalDefaults(spec, defaults) -} - -func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { - reasons := make([]string, 0) - if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { - reasons = append(reasons, "能力类型不匹配") - } - if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { - reasons = append(reasons, "不支持操作 "+intent.Operation) - } - for inputType, count := range intent.Inputs { - if count < 0 { - reasons = append(reasons, "输入数量不能小于 0") - continue - } - constraint, declared := spec.Inputs[inputType] - if !declared { - if count > 0 { - reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") - } - continue - } - if count < constraint.Min || count > constraint.Max { - reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) - } - } - for inputType, constraint := range spec.Inputs { - if intent.Inputs[inputType] < constraint.Min { - reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) - } - } - for name, value := range intent.Options { - constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] - if !declared { - reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) - continue - } - if !matchOptionConstraint(name, constraint, value) { - reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") - } - } - return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} -} - -func capabilityInputLabel(name string) string { - switch normalizeCapabilityValue(name) { - case "image": - return "参考图片" - case "video": - return "参考视频" - case "audio": - return "参考音频" - case "mask": - return "蒙版" - default: - return name - } -} - -func capabilityOptionLabel(name string) string { - switch canonicalCapabilityOptionName(name) { - case "size": - return "画面尺寸" - case "quality": - return "生成质量" - case "transparentBackground": - return "透明背景" - case "count": - return "输出数量" - case "videoSeconds": - return "视频时长" - case "vquality": - return "输出分辨率" - case "videoGenerateAudio": - return "同步音频" - case "videoWatermark": - return "水印设置" - case "audioVoice": - return "音色" - case "audioFormat": - return "音频格式" - case "audioSpeed": - return "语速" - case "audioInstructions": - return "朗读指令" - default: - return name - } -} - -func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool { - if len(constraint.Values) > 0 { - for _, candidate := range constraint.Values { - if normalizedScalar(candidate) == "*" { - return true - } - if capabilityOptionValuesEqual(name, candidate, value) { - return true - } - } - return false - } - number, ok := numericScalar(value) - if !ok { - return false - } - if constraint.Min != nil && number < *constraint.Min { - return false - } - if constraint.Max != nil && number > *constraint.Max { - return false - } - if constraint.Step != nil && constraint.Min != nil { - steps := (number - *constraint.Min) / *constraint.Step - return math.Abs(steps-math.Round(steps)) < 1e-9 - } - return true -} - -func capabilityOptionValuesEqual(name string, candidate any, value any) bool { - left := normalizedScalar(candidate) - right := normalizedScalar(value) - if canonicalCapabilityOptionName(name) == "vquality" { - // Compare using the same aliases as request intents and price tiers. - left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") - right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") - } - return left == right -} - -func normalizedScalar(value any) string { - switch typed := value.(type) { - case string: - return normalizeCapabilityValue(typed) - case bool: - return strconv.FormatBool(typed) - case float64: - return strconv.FormatFloat(typed, 'f', -1, 64) - case float32: - return strconv.FormatFloat(float64(typed), 'f', -1, 64) - case int: - return strconv.Itoa(typed) - case int64: - return strconv.FormatInt(typed, 10) - default: - encoded, _ := json.Marshal(value) - return string(encoded) - } -} - -func numericScalar(value any) (float64, bool) { - switch typed := value.(type) { - case float64: - return typed, true - case float32: - return float64(typed), true - case int: - return float64(typed), true - case int64: - return float64(typed), true - case json.Number: - parsed, err := typed.Float64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - return parsed, err == nil - default: - return 0, false - } -} - -func containsNormalized(values []string, expected string) bool { - for _, value := range values { - if normalizeCapabilityValue(value) == expected { - return true - } - } - return false -} - -func normalizeCapabilityValue(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func canonicalCapabilityOptionName(value string) string { - name := strings.TrimSpace(value) - switch name { - case "duration": - return "videoSeconds" - case "aspectRatio": - return "size" - case "resolution": - return "vquality" - default: - return name - } -} - -func isCapabilityOptionFor(capability string, name string) bool { - switch normalizeCapability(capability) { - case "image": - return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" - case "video": - return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" - case "audio": - return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions" - case "text": - // systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。 - return false - default: - return false - } -} - -func isProviderCapabilityOption(name string) bool { - return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name) -} - -func (s *Service) invalidateRouteCatalog() { - s.routeCatalogRefreshMu.Lock() - s.routeCatalogMu.Lock() - s.routeCatalog = nil - s.routeCatalogVersion++ - s.routeCatalogRetryAt = time.Time{} - s.routeCatalogRefreshError = nil - s.routeCatalogMu.Unlock() - s.routeCatalogRefreshMu.Unlock() - if s.coordinator != nil { - ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) - defer cancel() - if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil { - log.Printf("logical model route catalog distributed invalidation failed: %v", err) - } - } - s.initReadCaches() - s.routeVersionReadCache.Clear() -} - -func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) { - now := time.Now() - version := s.currentRouteCatalogVersion() - s.routeCatalogMu.RLock() - snapshot := s.routeCatalog - if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { - s.routeCatalogMu.RUnlock() - return snapshot, nil - } - s.routeCatalogMu.RUnlock() - - s.routeCatalogRefreshMu.Lock() - defer s.routeCatalogRefreshMu.Unlock() - now = time.Now() - version = s.currentRouteCatalogVersion() - s.routeCatalogMu.RLock() - snapshot = s.routeCatalog - if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { - s.routeCatalogMu.RUnlock() - return snapshot, nil - } - s.routeCatalogMu.RUnlock() - - // 刷新锁只能防止并行回源,失败后还需要冷却,否则等待者会逐个重打数据库。 - if s.routeCatalogRefreshError != nil && now.Before(s.routeCatalogRetryAt) { - if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { - return snapshot, nil - } - return nil, s.routeCatalogRefreshError - } - loaded, err := s.loadRouteCatalog() - if err != nil { - s.routeCatalogRefreshError = err - s.routeCatalogRetryAt = time.Now().Add(2 * time.Second) - log.Printf("logical model route catalog refresh failed; retry cooled down: %v", err) - // 已有快照过期时允许短暂继续服务,数据库首次加载失败则明确失败。 - if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { - return snapshot, nil - } - return nil, err - } - s.routeCatalogRefreshError = nil - s.routeCatalogRetryAt = time.Time{} - s.routeCatalogMu.Lock() - s.routeCatalog = loaded - s.routeCatalogMu.Unlock() - return loaded, nil -} - -func (s *Service) currentRouteCatalogVersion() int64 { - s.routeCatalogMu.RLock() - localVersion := s.routeCatalogVersion - s.routeCatalogMu.RUnlock() - if s.coordinator == nil || !s.coordinator.HasRedis() { - return localVersion - } - s.initReadCaches() - ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) - defer cancel() - version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) { - value, err := s.coordinator.RouteCatalogVersion(ctx) - if err != nil { - log.Printf("logical model route catalog distributed version check failed: %v", err) - } - return value, 256, err - }) - if err != nil { - return localVersion - } - if version > localVersion { - return version - } - return localVersion -} - -func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - repo := s.repo.WithContext(ctx) - items, err := repo.LogicalModels(false) - if err != nil { - return nil, err - } - snapshot := &routeCatalogSnapshot{LoadedAt: time.Now(), CatalogVersion: s.currentRouteCatalogVersion(), Models: make(map[string]cachedLogicalModel), Ordered: make([]string, 0, len(items))} - graphs, err := repo.LogicalModelGraphs(items, false) - if err != nil { - return nil, err - } - systemChannelIDs := make([]string, 0) - for _, graph := range graphs { - if graph == nil { - continue - } - for _, channelModel := range graph.ChannelModels { - systemChannelIDs = append(systemChannelIDs, channelModel.ChannelID) - } - } - systemChannels, err := repo.SystemChannelsByIDs(systemChannelIDs, false) - if err != nil { - return nil, err - } - enabledSystemChannels := make(map[string]bool, len(systemChannels)) - for _, channel := range systemChannels { - enabledSystemChannels[channel.ID] = true - } - for _, item := range items { - graph := graphs[item.ID] - if graph == nil || graph.Revision == nil { - log.Printf("logical model omitted from route catalog id=%s: graph unavailable", item.ID) - continue - } - productSpec, decodeErr := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON) - if decodeErr != nil { - log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr) - continue - } - channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) - for _, channelModel := range graph.ChannelModels { - channelModelByID[channelModel.ID] = channelModel - } - cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}} - for _, route := range graph.Routes { - channelModel, ok := channelModelByID[route.ChannelModelID] - if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { - continue - } - if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { - continue - } - if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { - continue - } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) - if specErr != nil { - log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) - continue - } - cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) - } - productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes)) - defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) - if defaultsErr != nil { - log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr) - continue - } - cached.ProductSpec = productSpec - cached.Defaults = defaults - snapshot.Models[item.ID] = cached - snapshot.Ordered = append(snapshot.Ordered, item.ID) - } - return snapshot, nil -} - -// ResolveLogicalModel 将创作意图解析为一次可执行的路由快照。 -// 解析同时约束能力合同、启用状态、价格档和渠道协议;调用方不得在解析完成后自行替换其中任一供应链字段, -// 否则会出现“目录显示可用、任务实际走另一条线路”的配置漂移。 -func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequestIntent) (*RoutedModel, error) { - snapshot, err := s.routeCatalogSnapshot() - if err != nil { - return nil, err - } - cached, ok := snapshot.Models[strings.TrimSpace(logicalModelID)] - if !ok { - return nil, BadAuthRequest("所选模型不可用") - } - intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) - if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { - return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) - } - eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") - if len(eligible) == 0 { - return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") - } - selected := weightedRoute(eligible) - var priceTier *model.ChannelModelPriceTier - if cached.Model.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) - if priceTier == nil { - return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") - } - } - return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil -} - -func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { - eligible := make([]cachedLogicalRoute, 0, len(routes)) - maxPriority := math.MinInt - for _, route := range routes { - if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) { - continue - } - if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { - continue - } - if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { - continue - } - if route.Route.Priority > maxPriority { - eligible = eligible[:0] - maxPriority = route.Route.Priority - } - if route.Route.Priority == maxPriority { - eligible = append(eligible, route) - } - } - return eligible -} - -func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { - for _, tier := range channelModel.PriceTiers { - if tier.Enabled && tier.PriceConfigured { - return true - } - } - return false -} - -// channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 -// 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 -func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { - selector := skuSelectorForIntent(intent) - bestScore := -1 - var best *model.ChannelModelPriceTier - for index := range channelModel.PriceTiers { - tier := &channelModel.PriceTiers[index] - if !tier.Enabled || !tier.PriceConfigured { - continue - } - matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) - if !matched { - continue - } - if score > bestScore { - best, bestScore = tier, score - } - } - return best -} - -func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { - selector := map[string]string{} - if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { - selector["operation"] = operation - } - switch normalizeCapability(intent.Capability) { - case "video": - // 价格档按实际参考素材归类。供应商执行仍可使用 reference_to_video、extend - // 等细分操作;计价时视频参考优先归为视频生视频,其余图片参考无论数量 - // 都归为图生视频。 - if intent.Inputs["video"] > 0 { - selector["operation"] = "video_to_video" - } else if intent.Inputs["image"] > 0 { - selector["operation"] = "image_to_video" - } - if count := intent.Inputs["image"]; count > 0 { - selector["imageCount"] = strconv.Itoa(count) - } - if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { - selector["vquality"] = value - } - if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { - selector["videoSeconds"] = strconv.Itoa(seconds) - } - case "image": - if intent.Inputs["image"] > 0 { - selector["operation"] = "image_to_image" - } else { - selector["operation"] = "text_to_image" - } - rawQuality, _ := intent.Options["quality"].(string) - rawSize, _ := intent.Options["size"].(string) - if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" { - selector["quality"] = quality - } - for _, key := range []string{"quality", "size"} { - if key == "quality" && selector["quality"] != "" { - continue - } - text, _ := intent.Options[key].(string) - if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" { - selector[key] = value - } - } - } - return selector -} - -func normalizeImagePriceQuality(rawQuality string, rawSize string) string { - quality := strings.ToLower(strings.TrimSpace(rawQuality)) - if quality != "" && quality != "auto" && quality != "any" { - return quality - } - parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x") - if len(parts) != 2 { - return "" - } - width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) - height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height { - return "" - } - pixels := width * height - switch { - case pixels <= 2_000_000: - return "1k" - case pixels <= 4_300_000: - return "2k" - case pixels <= 8_294_400: - return "4k" - default: - return "" - } -} - -func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { - selector := model.DecodeSKUSelector(tier.SelectorJSON) - if len(selector) == 0 { - if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" { - selector["vquality"] = resolution - } - if tier.VideoSeconds > 0 { - selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) - } - } - return selector -} - -func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) { - score := 0 - for key, expected := range tier { - expected = strings.TrimSpace(expected) - if expected == "" || expected == "*" { - continue - } - if requested[key] != expected { - return false, 0 - } - score++ - } - return true, score -} - -func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { - now := time.Now() - keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} - // 这里会删除过期项,必须使用写锁;不要改成 RLock。 - s.routeHealthMu.Lock() - for _, key := range keys { - until, exists := s.routeHealthBlocked[key] - if !exists { - continue - } - if !until.After(now) { - delete(s.routeHealthBlocked, key) - continue - } - s.routeHealthMu.Unlock() - return true - } - s.routeHealthMu.Unlock() - - if s.coordinator == nil || !s.coordinator.HasRedis() { - return false - } - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - for _, key := range keys { - until, err := s.coordinator.RouteBlockedUntil(ctx, key) - if err != nil { - log.Printf("logical route distributed health check failed key=%s: %v", key, err) - return false - } - if until.After(now) { - return true - } - } - return false -} - -func mergeIntentDefaults(options map[string]any, defaults map[string]any) map[string]any { - result := make(map[string]any, len(defaults)+len(options)) - for key, value := range defaults { - result[key] = value - } - for key, value := range options { - result[key] = value - } - return result -} - -func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute { - if len(routes) == 1 { - return routes[0] - } - var total int64 - for _, route := range routes { - total += int64(route.Route.Weight) - } - if total <= 0 { - return routes[0] - } - var raw [8]byte - if _, err := cryptorand.Read(raw[:]); err != nil { - return routes[0] - } - totalUnsigned := uint64(total) - // 丢弃不能整除 total 的低概率尾部,避免取模造成轻微权重偏差。 - threshold := -totalUnsigned % totalUnsigned - randomValue := binary.LittleEndian.Uint64(raw[:]) - for randomValue < threshold { - if _, err := cryptorand.Read(raw[:]); err != nil { - return routes[0] - } - randomValue = binary.LittleEndian.Uint64(raw[:]) - } - pick := int64(randomValue % totalUnsigned) - for _, route := range routes { - if pick < int64(route.Route.Weight) { - return route - } - pick -= int64(route.Route.Weight) - } - return routes[len(routes)-1] -} - -func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent ModelRequestIntent) []RouteSimulationCandidate { - result := make([]RouteSimulationCandidate, 0, len(routes)) - poolPriority := math.MinInt - for _, route := range routes { - match := MatchCapability(route.CapabilitySpec, intent) - blocked := s.logicalRouteBlocked(route) - if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority { - poolPriority = route.Route.Priority - } - result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) - } - for index := range result { - result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority - } - sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority }) - return result -} - -func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, attemptNumber int) (*model.RouteAttempt, error) { - id, err := s.repo.NextPrefixedID("ATTEMPT") - if err != nil { - return nil, err - } - channelModel, err := s.repo.ChannelModelByID(routed.ChannelModel.ChannelID, routed.ChannelModel.ID) - if err != nil { - return nil, err - } - attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} - if err := s.repo.CreateRouteAttempt(attempt); err != nil { - return nil, err - } - return attempt, nil -} - -func (s *Service) beginTaskRouteAttempt(task *model.Task) (*model.RouteAttempt, error) { - if task == nil { - return nil, nil - } - attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) - if err != nil { - return nil, err - } - if len(attempts) > 0 { - existing := &attempts[len(attempts)-1] - switch existing.DispatchState { - case "not_sent": - return existing, nil - case "accepted": - if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { - if task.ProviderRequestID == "" { - task.ProviderRequestID = existing.ProviderRequestID - if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { - return nil, err - } - } - return existing, nil - } - return nil, routeDispatchUncertainError{"上游已接受请求,但没有可恢复的任务 ID"} - case "submission_unknown": - if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { - if task.ProviderRequestID == "" { - task.ProviderRequestID = existing.ProviderRequestID - if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { - return nil, err - } - } - existing.DispatchState = "accepted" - if err := s.repo.SaveRouteAttempt(existing); err != nil { - return nil, err - } - return existing, nil - } - return nil, routeDispatchUncertainError{"上一次提交结果不明确,为避免重复扣费已停止自动重发"} - case "rejected_no_job": - if task.LogicalModelID == "" { - return nil, errors.New("上游已拒绝本次请求,请检查渠道配置后再试") - } - return s.switchTaskToNextRoute(task, attempts) - } - } - if task.LogicalModelID == "" { - return s.createDirectTaskAttempt(task) - } - routed, routeErr := s.routedModelForTaskSelection(task) - if routeErr != nil { - return s.switchTaskToNextRoute(task, attempts) - } - return s.createRouteAttempt(task, routed, len(attempts)+1) -} - -func (s *Service) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { - if attempt == nil || attempt.DispatchState != "not_sent" { - return nil - } - // A stale worker must not dispatch the same selected attempt a second time. - if err := s.repo.MarkRouteAttemptDispatching(attempt.ID); err != nil { - return routeDispatchUncertainError{"提交状态未能独占确认,为避免重复扣费已停止自动重发"} - } - attempt.Status, attempt.DispatchState = "dispatching", "submission_unknown" - return nil -} - -type routeDispatchUncertainError struct{ message string } - -func (e routeDispatchUncertainError) Error() string { return e.message } - -func isRouteDispatchUncertain(err error) bool { - var target routeDispatchUncertainError - return errors.As(err, &target) -} - -func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, error) { - route, err := s.repo.LogicalModelRoute(task.RouteID) - if err != nil { - return nil, err - } - if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { - return nil, errors.New("任务使用的模型服务配置已失效") - } - if route.ChannelModelID != task.ChannelModelID { - return nil, errors.New("任务使用的模型服务配置已失效") - } - channelModel, err := s.repo.ChannelModel(task.ChannelModelID) - if err != nil { - return nil, err - } - if !channelModel.Enabled { - return nil, errors.New("任务使用的模型服务配置已失效") - } - if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { - return nil, err - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil { - return nil, err - } - if revision.LogicalModelID != logicalModel.ID { - return nil, errors.New("任务前台模型版本不一致") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, errors.New("当前模型服务暂不可用") - } - if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { - return nil, errors.New("任务使用的模型服务价格配置已失效") - } - if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { - return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") - } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} - return routed, nil -} - -func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.RouteAttempt) (*model.RouteAttempt, error) { - decrypted, err := s.decryptTaskInputJSON(task.InputJSON) - if err != nil { - return nil, err - } - var input map[string]any - if err := json.Unmarshal([]byte(decrypted), &input); err != nil { - return nil, BadAuthRequest("任务输入格式无效,无法恢复模型服务") - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil || revision.LogicalModelID != logicalModel.ID { - return nil, errors.New("任务前台模型版本不存在或归属不一致") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } - routes, err := s.repo.LogicalModelRoutes(revision.ID, false) - if err != nil { - return nil, err - } - channelModelIDs := make([]string, 0, len(routes)) - for _, route := range routes { - channelModelIDs = append(channelModelIDs, route.ChannelModelID) - } - channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) - if err != nil { - return nil, err - } - channelIDs := make([]string, 0, len(channelModels)) - for _, channelModel := range channelModels { - channelIDs = append(channelIDs, channelModel.ChannelID) - } - systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) - if err != nil { - return nil, err - } - enabledSystemChannels := make(map[string]bool, len(systemChannels)) - for _, channel := range systemChannels { - enabledSystemChannels[channel.ID] = true - } - channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) - for _, channelModel := range channelModels { - if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { - channelModelByID[channelModel.ID] = channelModel - } - } - candidates := make([]cachedLogicalRoute, 0, len(routes)) - for _, route := range routes { - channelModel, channelOK := channelModelByID[route.ChannelModelID] - if !channelOK { - continue - } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) - if specErr != nil { - continue - } - candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) - } - tried := make(map[string]bool, len(attempts)) - for _, attempt := range attempts { - tried[attempt.RouteID] = true - } - eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") - if len(eligible) == 0 { - return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") - } - selected := weightedRoute(eligible) - var priceTier *model.ChannelModelPriceTier - if logicalModel.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) - if priceTier == nil { - return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") - } - } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} - nextInput := applyRoutedProviderSelection(input, routed) - if err := s.ValidateTaskCapability(nextInput); err != nil { - return nil, err - } - if err := s.protectTaskSecrets(nextInput); err != nil { - return nil, err - } - encoded, err := json.Marshal(nextInput) - if err != nil { - return nil, err - } - var replacement *model.BillingOrder - if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { - config, _ := nextInput["config"].(map[string]any) - capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) - if capability == "" { - capability = capabilityFromTaskType(task.Type) - } - priceTierID, _ := config["priceTierId"].(string) - replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) - if err != nil { - return nil, err - } - replacement.Model = logicalModel.Code - } - previousRouteID := task.RouteID - if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement); err != nil { - if errors.Is(err, repository.ErrInsufficientCredits) { - return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") - } - return nil, err - } - task.RouteID = selected.Route.ID - task.ChannelModelID = selected.ChannelModel.ID - task.InputJSON = string(encoded) - task.ProviderRequestID = "" - return s.createRouteAttempt(task, routed, len(attempts)+1) -} - -func (s *Service) nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) { - if task == nil || task.LogicalModelID == "" || attempt == nil || attempt.DispatchState != "rejected_no_job" { - return nil, nil - } - if errors.Is(taskErr, context.Canceled) || errors.Is(taskErr, context.DeadlineExceeded) { - return nil, nil - } - s.blockLogicalRouteForFailure(attempt, taskErr) - attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) - if err != nil { - return nil, err - } - return s.switchTaskToNextRoute(task, attempts) -} - -func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskErr error) { - if attempt == nil { - return - } - key := "" - duration := time.Duration(0) - if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { - key, duration = "channel:"+attempt.ChannelID, 10*time.Minute - } else if attempt.FailureCode == "upstream_404" { - key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute - } else if attempt.FailureCode == "upstream_429" { - key, duration = "channel:"+attempt.ChannelID, 30*time.Second - var upstream providerHTTPError - if errors.As(taskErr, &upstream) && upstream.RetryAfter > 0 { - duration = upstream.RetryAfter - } - } - if key == "" || duration <= 0 { - return - } - until := time.Now().Add(duration) - s.routeHealthMu.Lock() - // 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。 - s.routeHealthBlocked[key] = until - s.routeHealthMu.Unlock() - if s.coordinator != nil && s.coordinator.HasRedis() { - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - if err := s.coordinator.BlockRoute(ctx, key, until); err != nil { - log.Printf("logical route distributed health update failed key=%s: %v", key, err) - } - } -} - -func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any) error { - if task == nil || task.LogicalModelID == "" { - return nil - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return err - } - var routed *RoutedModel - if logicalModel.ArchivedAt != nil { - // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 - routed, err = s.resolveArchivedTaskRoute(task, intent) - } else { - routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) - } - if err != nil { - return err - } - input = applyRoutedProviderSelection(input, routed) - if err := s.ValidateTaskCapability(input); err != nil { - return err - } - if err := s.protectTaskSecrets(input); err != nil { - return err - } - encoded, err := json.Marshal(input) - if err != nil { - return err - } - task.LogicalModelRevisionID = routed.Revision.ID - task.RouteID = routed.Route.ID - task.ChannelModelID = routed.ChannelModel.ID - task.Model = routed.LogicalModel.Code - task.Provider = "managed" - task.InputJSON = string(encoded) - return nil -} - -// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 -// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 -func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { - if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { - return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - if logicalModel.ArchivedAt == nil { - return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil || revision.LogicalModelID != logicalModel.ID { - return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") - } - route, err := s.repo.LogicalModelRoute(task.RouteID) - if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { - return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") - } - channelModel, err := s.repo.ChannelModel(task.ChannelModelID) - if err != nil || !channelModel.Enabled { - return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") - } - if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { - return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") - } - if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { - return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") - } - return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil -} - -func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { - if attempt == nil { - return - } - now := time.Now() - attempt.CompletedAt = &now - if task != nil { - attempt.ProviderRequestID = task.ProviderRequestID - } - if taskErr == nil { - attempt.Status = "succeeded" - attempt.DispatchState = "accepted" - } else { - attempt.Status = "failed" - attempt.FailureMessage = truncateRunes(taskFailureMessage(taskErr), 1000) - attempt.FailureCode = routeFailureCode(taskErr) - if attempt.ProviderRequestID != "" { - attempt.DispatchState = "accepted" - } else if safeRouteRejection(taskErr) { - attempt.DispatchState = "rejected_no_job" - } else { - attempt.DispatchState = "submission_unknown" - } - } - if err := s.repo.SaveRouteAttempt(attempt); err != nil { - log.Printf("route attempt terminal state save failed attempt_id=%s task_id=%s: %v", attempt.ID, attempt.TaskID, err) - } -} - -func routeFailureCode(err error) string { - if code, _ := ChannelSlotFailureDetails(err); code != "" { - return code - } - var upstream providerHTTPError - if errors.As(err, &upstream) { - return fmt.Sprintf("upstream_%d", upstream.StatusCode) - } - return "submission_unknown" -} - -func safeRouteRejection(err error) bool { - if err == nil { - return false - } - if code, _ := ChannelSlotFailureDetails(err); code != "" { - return true - } - var upstream providerHTTPError - if errors.As(err, &upstream) { - switch upstream.StatusCode { - case 401, 403, 404, 429: - return true - } - } - return false -} +package app + +import ( + "context" + cryptorand "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "sort" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +type CapabilityImageSizePreset struct { + Size string `json:"size"` + Tier string `json:"tier"` + Ratio string `json:"ratio"` + Width int `json:"width"` + Height int `json:"height"` +} + +type CapabilityImageSize struct { + Parameter string `json:"parameter,omitempty"` + AllowCustom bool `json:"allowCustom,omitempty"` + Presets []CapabilityImageSizePreset `json:"presets,omitempty"` +} + +type CapabilitySpec struct { + Version int `json:"version"` + Capability string `json:"capability"` + Operations []string `json:"operations,omitempty"` + Inputs map[string]InputConstraint `json:"inputs,omitempty"` + Options map[string]OptionConstraint `json:"options,omitempty"` + ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` +} + +type InputConstraint struct { + Min int `json:"min"` + Max int `json:"max"` +} + +type OptionConstraint struct { + Values []any `json:"values,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Step *float64 `json:"step,omitempty"` +} + +type ModelRequestIntent struct { + Capability string `json:"capability"` + Operation string `json:"operation,omitempty"` + Inputs map[string]int `json:"inputs,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。 +func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent { + capability := normalizeCapability(fmt.Sprint(input["mode"])) + if capability == "" { + capability = capabilityFromTaskType(taskType) + } + intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}} + for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { + if values, ok := input[key].([]any); ok { + intent.Inputs[inputType] = len(values) + } + } + if mask, exists := input["mask"]; exists && mask != nil { + intent.Inputs["mask"] = 1 + } + explicitOptions := false + if options, ok := input["capabilityOptions"].(map[string]any); ok { + explicitOptions = true + for key, value := range options { + name := canonicalCapabilityOptionName(key) + // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 + // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 + if name == "quality" { + if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { + continue + } + } + intent.Options[name] = normalizeModelRequestOption(name, value) + } + } + if config, ok := input["config"].(map[string]any); ok && !explicitOptions { + for key, value := range config { + switch key { + case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": + continue + default: + canonical := canonicalCapabilityOptionName(key) + if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + intent.Options[canonical] = normalizeModelRequestOption(canonical, value) + } + } + } + } + return intent +} + +func normalizeModelRequestOption(name string, value any) any { + canonicalName := canonicalCapabilityOptionName(name) + if canonicalName == "quality" || canonicalName == "size" { + if text, ok := value.(string); ok { + return strings.ToLower(strings.TrimSpace(text)) + } + return value + } + if canonicalName != "vquality" { + return value + } + resolution, ok := value.(string) + if !ok { + return value + } + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "low", "480", "480p": + return "480p" + case "720", "720p": + return "720p" + case "1080", "1080p": + return "1080p" + case "2k", "1440", "1440p": + return "1440p" + case "4k", "2160", "2160p": + return "2160p" + default: + return value + } +} + +type CapabilityMatch struct { + Matched bool `json:"matched"` + Reasons []string `json:"reasons,omitempty"` +} + +type cachedLogicalModel struct { + Model model.LogicalModel + Revision model.LogicalModelRevision + ProductSpec CapabilitySpec + Defaults map[string]any + Routes []cachedLogicalRoute +} + +type cachedLogicalRoute struct { + Route model.LogicalModelRoute + CapabilitySpec CapabilitySpec + ChannelModel model.ChannelModel +} + +type routeCatalogSnapshot struct { + LoadedAt time.Time + CatalogVersion int64 + Models map[string]cachedLogicalModel + Ordered []string +} + +type RoutedModel struct { + LogicalModel model.LogicalModel + Revision model.LogicalModelRevision + Route model.LogicalModelRoute + ChannelModel model.ChannelModel + PriceTier *model.ChannelModelPriceTier + Defaults map[string]any +} + +func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { + var spec CapabilitySpec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + return spec, BadAuthRequest("能力配置不是有效 JSON") + } + normalized, err := NormalizeCapabilitySpec(spec) + if err != nil { + return spec, err + } + return normalized, nil +} + +func ValidateCapabilitySpec(spec CapabilitySpec) error { + _, err := NormalizeCapabilitySpec(spec) + return err +} + +func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { + spec.Capability = normalizeCapability(spec.Capability) + if spec.Version != 1 { + return spec, BadAuthRequest("能力配置 version 必须为 1") + } + if spec.Capability == "" { + return spec, BadAuthRequest("能力配置必须声明 capability") + } + operations := make([]string, 0, len(spec.Operations)) + seenOperations := make(map[string]bool, len(spec.Operations)) + for _, operation := range spec.Operations { + normalized := normalizeCapabilityValue(operation) + if normalized != "" && !seenOperations[normalized] { + seenOperations[normalized] = true + operations = append(operations, normalized) + } + } + spec.Operations = operations + normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs)) + for rawName, constraint := range spec.Inputs { + name := normalizeCapabilityValue(rawName) + if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min { + return spec, BadAuthRequest("输入能力范围无效") + } + if _, exists := normalizedInputs[name]; exists { + return spec, BadAuthRequest("输入能力存在重复名称:" + name) + } + normalizedInputs[name] = constraint + } + spec.Inputs = normalizedInputs + normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) + for rawName, constraint := range spec.Options { + name := canonicalCapabilityOptionName(rawName) + if strings.TrimSpace(name) == "" { + return spec, BadAuthRequest("能力参数名称不能为空") + } + if _, exists := normalizedOptions[name]; exists { + return spec, BadAuthRequest("能力参数存在重复别名:" + name) + } + hasValues := len(constraint.Values) > 0 + hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil + if !hasValues && !hasRange { + return spec, BadAuthRequest("能力参数必须声明 values 或数值范围") + } + if hasValues && hasRange { + return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围") + } + if hasRange && (constraint.Min == nil || constraint.Max == nil) { + return spec, BadAuthRequest("数值范围必须同时声明 min 和 max") + } + if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min { + return spec, BadAuthRequest("能力参数数值范围无效") + } + if constraint.Step != nil && *constraint.Step <= 0 { + return spec, BadAuthRequest("能力参数 step 必须大于 0") + } + normalizedOptions[name] = constraint + } + spec.Options = normalizedOptions + return spec, nil +} + +func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) { + defaults := map[string]any{} + if err := json.Unmarshal([]byte(raw), &defaults); err != nil { + return nil, err + } + return normalizeLogicalDefaults(spec, defaults) +} + +func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { + reasons := make([]string, 0) + if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { + reasons = append(reasons, "能力类型不匹配") + } + if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { + reasons = append(reasons, "不支持操作 "+intent.Operation) + } + for inputType, count := range intent.Inputs { + if count < 0 { + reasons = append(reasons, "输入数量不能小于 0") + continue + } + constraint, declared := spec.Inputs[inputType] + if !declared { + // Multimodal text models may accept reference images even if not declared. + if count > 0 && !(normalizeCapability(spec.Capability) == "text" && inputType == "image") { + reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") + } + continue + } + if count < constraint.Min || count > constraint.Max { + reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) + } + } + for inputType, constraint := range spec.Inputs { + if intent.Inputs[inputType] < constraint.Min { + reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) + } + } + for name, value := range intent.Options { + constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] + if !declared { + reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) + continue + } + if !matchOptionConstraint(name, constraint, value) { + reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") + } + } + return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} +} + +func capabilityInputLabel(name string) string { + switch normalizeCapabilityValue(name) { + case "image": + return "参考图片" + case "video": + return "参考视频" + case "audio": + return "参考音频" + case "mask": + return "蒙版" + default: + return name + } +} + +func capabilityOptionLabel(name string) string { + switch canonicalCapabilityOptionName(name) { + case "size": + return "画面尺寸" + case "quality": + return "生成质量" + case "transparentBackground": + return "透明背景" + case "count": + return "输出数量" + case "videoSeconds": + return "视频时长" + case "vquality": + return "输出分辨率" + case "videoGenerateAudio": + return "同步音频" + case "videoWatermark": + return "水印设置" + case "audioVoice": + return "音色" + case "audioFormat": + return "音频格式" + case "audioSpeed": + return "语速" + case "audioInstructions": + return "朗读指令" + default: + return name + } +} + +func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool { + if len(constraint.Values) > 0 { + for _, candidate := range constraint.Values { + if normalizedScalar(candidate) == "*" { + return true + } + if capabilityOptionValuesEqual(name, candidate, value) { + return true + } + } + return false + } + number, ok := numericScalar(value) + if !ok { + return false + } + if constraint.Min != nil && number < *constraint.Min { + return false + } + if constraint.Max != nil && number > *constraint.Max { + return false + } + if constraint.Step != nil && constraint.Min != nil { + steps := (number - *constraint.Min) / *constraint.Step + return math.Abs(steps-math.Round(steps)) < 1e-9 + } + return true +} + +func capabilityOptionValuesEqual(name string, candidate any, value any) bool { + left := normalizedScalar(candidate) + right := normalizedScalar(value) + if canonicalCapabilityOptionName(name) == "vquality" { + // Compare using the same aliases as request intents and price tiers. + left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") + right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") + } + return left == right +} + +func normalizedScalar(value any) string { + switch typed := value.(type) { + case string: + return normalizeCapabilityValue(typed) + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + encoded, _ := json.Marshal(value) + return string(encoded) + } +} + +func numericScalar(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func containsNormalized(values []string, expected string) bool { + for _, value := range values { + if normalizeCapabilityValue(value) == expected { + return true + } + } + return false +} + +func normalizeCapabilityValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func canonicalCapabilityOptionName(value string) string { + name := strings.TrimSpace(value) + switch name { + case "duration": + return "videoSeconds" + case "aspectRatio": + return "size" + case "resolution": + return "vquality" + default: + return name + } +} + +func isCapabilityOptionFor(capability string, name string) bool { + switch normalizeCapability(capability) { + case "image": + return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" + case "video": + return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" + case "audio": + return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions" + case "text": + // systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。 + return false + default: + return false + } +} + +func isProviderCapabilityOption(name string) bool { + return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name) +} + +func (s *Service) invalidateRouteCatalog() { + s.routeCatalogRefreshMu.Lock() + s.routeCatalogMu.Lock() + s.routeCatalog = nil + s.routeCatalogVersion++ + s.routeCatalogRetryAt = time.Time{} + s.routeCatalogRefreshError = nil + s.routeCatalogMu.Unlock() + s.routeCatalogRefreshMu.Unlock() + if s.coordinator != nil { + ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) + defer cancel() + if err := s.coordinator.BumpRouteCatalogVersion(ctx); err != nil { + log.Printf("logical model route catalog distributed invalidation failed: %v", err) + } + } + s.initReadCaches() + s.routeVersionReadCache.Clear() +} + +func (s *Service) routeCatalogSnapshot() (*routeCatalogSnapshot, error) { + now := time.Now() + version := s.currentRouteCatalogVersion() + s.routeCatalogMu.RLock() + snapshot := s.routeCatalog + if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { + s.routeCatalogMu.RUnlock() + return snapshot, nil + } + s.routeCatalogMu.RUnlock() + + s.routeCatalogRefreshMu.Lock() + defer s.routeCatalogRefreshMu.Unlock() + now = time.Now() + version = s.currentRouteCatalogVersion() + s.routeCatalogMu.RLock() + snapshot = s.routeCatalog + if snapshot != nil && now.Sub(snapshot.LoadedAt) < s.routeCatalogTTL && snapshot.CatalogVersion == version { + s.routeCatalogMu.RUnlock() + return snapshot, nil + } + s.routeCatalogMu.RUnlock() + + // 刷新锁只能防止并行回源,失败后还需要冷却,否则等待者会逐个重打数据库。 + if s.routeCatalogRefreshError != nil && now.Before(s.routeCatalogRetryAt) { + if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { + return snapshot, nil + } + return nil, s.routeCatalogRefreshError + } + loaded, err := s.loadRouteCatalog() + if err != nil { + s.routeCatalogRefreshError = err + s.routeCatalogRetryAt = time.Now().Add(2 * time.Second) + log.Printf("logical model route catalog refresh failed; retry cooled down: %v", err) + // 已有快照过期时允许短暂继续服务,数据库首次加载失败则明确失败。 + if snapshot != nil && snapshot.CatalogVersion == version && now.Sub(snapshot.LoadedAt) <= s.routeCatalogMaxStale { + return snapshot, nil + } + return nil, err + } + s.routeCatalogRefreshError = nil + s.routeCatalogRetryAt = time.Time{} + s.routeCatalogMu.Lock() + s.routeCatalog = loaded + s.routeCatalogMu.Unlock() + return loaded, nil +} + +func (s *Service) currentRouteCatalogVersion() int64 { + s.routeCatalogMu.RLock() + localVersion := s.routeCatalogVersion + s.routeCatalogMu.RUnlock() + if s.coordinator == nil || !s.coordinator.HasRedis() { + return localVersion + } + s.initReadCaches() + ctx, cancel := context.WithTimeout(context.Background(), runtimeCoordinationTimeout) + defer cancel() + version, err := s.routeVersionReadCache.Get(ctx, routeCatalogVersionKey, func(ctx context.Context) (int64, int, error) { + value, err := s.coordinator.RouteCatalogVersion(ctx) + if err != nil { + log.Printf("logical model route catalog distributed version check failed: %v", err) + } + return value, 256, err + }) + if err != nil { + return localVersion + } + if version > localVersion { + return version + } + return localVersion +} + +func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + repo := s.repo.WithContext(ctx) + items, err := repo.LogicalModels(false) + if err != nil { + return nil, err + } + snapshot := &routeCatalogSnapshot{LoadedAt: time.Now(), CatalogVersion: s.currentRouteCatalogVersion(), Models: make(map[string]cachedLogicalModel), Ordered: make([]string, 0, len(items))} + graphs, err := repo.LogicalModelGraphs(items, false) + if err != nil { + return nil, err + } + systemChannelIDs := make([]string, 0) + for _, graph := range graphs { + if graph == nil { + continue + } + for _, channelModel := range graph.ChannelModels { + systemChannelIDs = append(systemChannelIDs, channelModel.ChannelID) + } + } + systemChannels, err := repo.SystemChannelsByIDs(systemChannelIDs, false) + if err != nil { + return nil, err + } + enabledSystemChannels := make(map[string]bool, len(systemChannels)) + for _, channel := range systemChannels { + enabledSystemChannels[channel.ID] = true + } + for _, item := range items { + graph := graphs[item.ID] + if graph == nil || graph.Revision == nil { + log.Printf("logical model omitted from route catalog id=%s: graph unavailable", item.ID) + continue + } + productSpec, decodeErr := DecodeCapabilitySpec(graph.Revision.CapabilitySpecJSON) + if decodeErr != nil { + log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr) + continue + } + channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) + for _, channelModel := range graph.ChannelModels { + channelModelByID[channelModel.ID] = channelModel + } + cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}} + for _, route := range graph.Routes { + channelModel, ok := channelModelByID[route.ChannelModelID] + if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { + continue + } + if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { + continue + } + if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { + continue + } + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + if specErr != nil { + log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) + continue + } + cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) + } + productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes)) + defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) + if defaultsErr != nil { + log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr) + continue + } + cached.ProductSpec = productSpec + cached.Defaults = defaults + snapshot.Models[item.ID] = cached + snapshot.Ordered = append(snapshot.Ordered, item.ID) + } + return snapshot, nil +} + +// ResolveLogicalModel 将创作意图解析为一次可执行的路由快照。 +// 解析同时约束能力合同、启用状态、价格档和渠道协议;调用方不得在解析完成后自行替换其中任一供应链字段, +// 否则会出现“目录显示可用、任务实际走另一条线路”的配置漂移。 +func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequestIntent) (*RoutedModel, error) { + snapshot, err := s.routeCatalogSnapshot() + if err != nil { + return nil, err + } + cached, ok := snapshot.Models[strings.TrimSpace(logicalModelID)] + if !ok { + return nil, BadAuthRequest("所选模型不可用") + } + intent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) + if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { + return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) + } + eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") + if len(eligible) == 0 { + return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") + } + selected := weightedRoute(eligible) + var priceTier *model.ChannelModelPriceTier + if cached.Model.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil +} + +func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { + eligible := make([]cachedLogicalRoute, 0, len(routes)) + maxPriority := math.MinInt + for _, route := range routes { + if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) { + continue + } + if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { + continue + } + if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { + continue + } + if route.Route.Priority > maxPriority { + eligible = eligible[:0] + maxPriority = route.Route.Priority + } + if route.Route.Priority == maxPriority { + eligible = append(eligible, route) + } + } + return eligible +} + +func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { + for _, tier := range channelModel.PriceTiers { + if tier.Enabled && tier.PriceConfigured { + return true + } + } + return false +} + +// channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 +// 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 +func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { + selector := skuSelectorForIntent(intent) + bestScore := -1 + var best *model.ChannelModelPriceTier + for index := range channelModel.PriceTiers { + tier := &channelModel.PriceTiers[index] + if !tier.Enabled || !tier.PriceConfigured { + continue + } + matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) + if !matched { + continue + } + if score > bestScore { + best, bestScore = tier, score + } + } + return best +} + +func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { + selector := map[string]string{} + if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { + selector["operation"] = operation + } + switch normalizeCapability(intent.Capability) { + case "video": + // 价格档按实际参考素材归类。供应商执行仍可使用 reference_to_video、extend + // 等细分操作;计价时视频参考优先归为视频生视频,其余图片参考无论数量 + // 都归为图生视频。 + if intent.Inputs["video"] > 0 { + selector["operation"] = "video_to_video" + } else if intent.Inputs["image"] > 0 { + selector["operation"] = "image_to_video" + } + if count := intent.Inputs["image"]; count > 0 { + selector["imageCount"] = strconv.Itoa(count) + } + if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { + selector["vquality"] = value + } + if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { + selector["videoSeconds"] = strconv.Itoa(seconds) + } + case "image": + if intent.Inputs["image"] > 0 { + selector["operation"] = "image_to_image" + } else { + selector["operation"] = "text_to_image" + } + rawQuality, _ := intent.Options["quality"].(string) + rawSize, _ := intent.Options["size"].(string) + if quality := normalizeImagePriceQuality(rawQuality, rawSize); quality != "" { + selector["quality"] = quality + } + for _, key := range []string{"quality", "size"} { + if key == "quality" && selector["quality"] != "" { + continue + } + text, _ := intent.Options[key].(string) + if value := strings.ToLower(strings.TrimSpace(text)); value != "" && value != "auto" && value != "any" { + selector[key] = value + } + } + } + return selector +} + +func normalizeImagePriceQuality(rawQuality string, rawSize string) string { + quality := strings.ToLower(strings.TrimSpace(rawQuality)) + if quality != "" && quality != "auto" && quality != "any" { + return quality + } + parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x") + if len(parts) != 2 { + return "" + } + width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height { + return "" + } + pixels := width * height + switch { + case pixels <= 2_000_000: + return "1k" + case pixels <= 4_300_000: + return "2k" + case pixels <= 8_294_400: + return "4k" + default: + return "" + } +} + +func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { + selector := model.DecodeSKUSelector(tier.SelectorJSON) + if len(selector) == 0 { + if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" { + selector["vquality"] = resolution + } + if tier.VideoSeconds > 0 { + selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) + } + } + return selector +} + +func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) { + score := 0 + for key, expected := range tier { + expected = strings.TrimSpace(expected) + if expected == "" || expected == "*" { + continue + } + if requested[key] != expected { + return false, 0 + } + score++ + } + return true, score +} + +func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { + now := time.Now() + keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} + // 这里会删除过期项,必须使用写锁;不要改成 RLock。 + s.routeHealthMu.Lock() + for _, key := range keys { + until, exists := s.routeHealthBlocked[key] + if !exists { + continue + } + if !until.After(now) { + delete(s.routeHealthBlocked, key) + continue + } + s.routeHealthMu.Unlock() + return true + } + s.routeHealthMu.Unlock() + + if s.coordinator == nil || !s.coordinator.HasRedis() { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + for _, key := range keys { + until, err := s.coordinator.RouteBlockedUntil(ctx, key) + if err != nil { + log.Printf("logical route distributed health check failed key=%s: %v", key, err) + return false + } + if until.After(now) { + return true + } + } + return false +} + +func mergeIntentDefaults(options map[string]any, defaults map[string]any) map[string]any { + result := make(map[string]any, len(defaults)+len(options)) + for key, value := range defaults { + result[key] = value + } + for key, value := range options { + result[key] = value + } + return result +} + +func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute { + if len(routes) == 1 { + return routes[0] + } + var total int64 + for _, route := range routes { + total += int64(route.Route.Weight) + } + if total <= 0 { + return routes[0] + } + var raw [8]byte + if _, err := cryptorand.Read(raw[:]); err != nil { + return routes[0] + } + totalUnsigned := uint64(total) + // 丢弃不能整除 total 的低概率尾部,避免取模造成轻微权重偏差。 + threshold := -totalUnsigned % totalUnsigned + randomValue := binary.LittleEndian.Uint64(raw[:]) + for randomValue < threshold { + if _, err := cryptorand.Read(raw[:]); err != nil { + return routes[0] + } + randomValue = binary.LittleEndian.Uint64(raw[:]) + } + pick := int64(randomValue % totalUnsigned) + for _, route := range routes { + if pick < int64(route.Route.Weight) { + return route + } + pick -= int64(route.Route.Weight) + } + return routes[len(routes)-1] +} + +func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent ModelRequestIntent) []RouteSimulationCandidate { + result := make([]RouteSimulationCandidate, 0, len(routes)) + poolPriority := math.MinInt + for _, route := range routes { + match := MatchCapability(route.CapabilitySpec, intent) + blocked := s.logicalRouteBlocked(route) + if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority { + poolPriority = route.Route.Priority + } + result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) + } + for index := range result { + result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority + } + sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority }) + return result +} + +func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, attemptNumber int) (*model.RouteAttempt, error) { + id, err := s.repo.NextPrefixedID("ATTEMPT") + if err != nil { + return nil, err + } + channelModel, err := s.repo.ChannelModelByID(routed.ChannelModel.ChannelID, routed.ChannelModel.ID) + if err != nil { + return nil, err + } + attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} + if err := s.repo.CreateRouteAttempt(attempt); err != nil { + return nil, err + } + return attempt, nil +} + +func (s *Service) beginTaskRouteAttempt(task *model.Task) (*model.RouteAttempt, error) { + if task == nil { + return nil, nil + } + attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) + if err != nil { + return nil, err + } + if len(attempts) > 0 { + existing := &attempts[len(attempts)-1] + switch existing.DispatchState { + case "not_sent": + return existing, nil + case "accepted": + if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { + if task.ProviderRequestID == "" { + task.ProviderRequestID = existing.ProviderRequestID + if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { + return nil, err + } + } + return existing, nil + } + return nil, routeDispatchUncertainError{"上游已接受请求,但没有可恢复的任务 ID"} + case "submission_unknown": + if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { + if task.ProviderRequestID == "" { + task.ProviderRequestID = existing.ProviderRequestID + if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { + return nil, err + } + } + existing.DispatchState = "accepted" + if err := s.repo.SaveRouteAttempt(existing); err != nil { + return nil, err + } + return existing, nil + } + return nil, routeDispatchUncertainError{"上一次提交结果不明确,为避免重复扣费已停止自动重发"} + case "rejected_no_job": + if task.LogicalModelID == "" { + return nil, errors.New("上游已拒绝本次请求,请检查渠道配置后再试") + } + return s.switchTaskToNextRoute(task, attempts) + } + } + if task.LogicalModelID == "" { + return s.createDirectTaskAttempt(task) + } + routed, routeErr := s.routedModelForTaskSelection(task) + if routeErr != nil { + return s.switchTaskToNextRoute(task, attempts) + } + return s.createRouteAttempt(task, routed, len(attempts)+1) +} + +func (s *Service) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { + if attempt == nil || attempt.DispatchState != "not_sent" { + return nil + } + // A stale worker must not dispatch the same selected attempt a second time. + if err := s.repo.MarkRouteAttemptDispatching(attempt.ID); err != nil { + return routeDispatchUncertainError{"提交状态未能独占确认,为避免重复扣费已停止自动重发"} + } + attempt.Status, attempt.DispatchState = "dispatching", "submission_unknown" + return nil +} + +type routeDispatchUncertainError struct{ message string } + +func (e routeDispatchUncertainError) Error() string { return e.message } + +func isRouteDispatchUncertain(err error) bool { + var target routeDispatchUncertainError + return errors.As(err, &target) +} + +func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, error) { + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil { + return nil, err + } + if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { + return nil, errors.New("任务使用的模型服务配置已失效") + } + if route.ChannelModelID != task.ChannelModelID { + return nil, errors.New("任务使用的模型服务配置已失效") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil { + return nil, err + } + if !channelModel.Enabled { + return nil, errors.New("任务使用的模型服务配置已失效") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, err + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil { + return nil, err + } + if revision.LogicalModelID != logicalModel.ID { + return nil, errors.New("任务前台模型版本不一致") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, errors.New("当前模型服务暂不可用") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, errors.New("任务使用的模型服务价格配置已失效") + } + if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { + return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} + return routed, nil +} + +func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.RouteAttempt) (*model.RouteAttempt, error) { + decrypted, err := s.decryptTaskInputJSON(task.InputJSON) + if err != nil { + return nil, err + } + var input map[string]any + if err := json.Unmarshal([]byte(decrypted), &input); err != nil { + return nil, BadAuthRequest("任务输入格式无效,无法恢复模型服务") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, errors.New("任务前台模型版本不存在或归属不一致") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + routes, err := s.repo.LogicalModelRoutes(revision.ID, false) + if err != nil { + return nil, err + } + channelModelIDs := make([]string, 0, len(routes)) + for _, route := range routes { + channelModelIDs = append(channelModelIDs, route.ChannelModelID) + } + channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) + if err != nil { + return nil, err + } + channelIDs := make([]string, 0, len(channelModels)) + for _, channelModel := range channelModels { + channelIDs = append(channelIDs, channelModel.ChannelID) + } + systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) + if err != nil { + return nil, err + } + enabledSystemChannels := make(map[string]bool, len(systemChannels)) + for _, channel := range systemChannels { + enabledSystemChannels[channel.ID] = true + } + channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) + for _, channelModel := range channelModels { + if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { + channelModelByID[channelModel.ID] = channelModel + } + } + candidates := make([]cachedLogicalRoute, 0, len(routes)) + for _, route := range routes { + channelModel, channelOK := channelModelByID[route.ChannelModelID] + if !channelOK { + continue + } + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + if specErr != nil { + continue + } + candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) + } + tried := make(map[string]bool, len(attempts)) + for _, attempt := range attempts { + tried[attempt.RouteID] = true + } + eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") + if len(eligible) == 0 { + return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") + } + selected := weightedRoute(eligible) + var priceTier *model.ChannelModelPriceTier + if logicalModel.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} + nextInput := applyRoutedProviderSelection(input, routed) + if err := s.ValidateTaskCapability(nextInput); err != nil { + return nil, err + } + if err := s.protectTaskSecrets(nextInput); err != nil { + return nil, err + } + encoded, err := json.Marshal(nextInput) + if err != nil { + return nil, err + } + var replacement *model.BillingOrder + if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { + config, _ := nextInput["config"].(map[string]any) + capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) + if capability == "" { + capability = capabilityFromTaskType(task.Type) + } + priceTierID, _ := config["priceTierId"].(string) + replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) + if err != nil { + return nil, err + } + replacement.Model = logicalModel.Code + } + previousRouteID := task.RouteID + if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement); err != nil { + if errors.Is(err, repository.ErrInsufficientCredits) { + return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") + } + return nil, err + } + task.RouteID = selected.Route.ID + task.ChannelModelID = selected.ChannelModel.ID + task.InputJSON = string(encoded) + task.ProviderRequestID = "" + return s.createRouteAttempt(task, routed, len(attempts)+1) +} + +func (s *Service) nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) { + if task == nil || task.LogicalModelID == "" || attempt == nil || attempt.DispatchState != "rejected_no_job" { + return nil, nil + } + if errors.Is(taskErr, context.Canceled) || errors.Is(taskErr, context.DeadlineExceeded) { + return nil, nil + } + s.blockLogicalRouteForFailure(attempt, taskErr) + attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) + if err != nil { + return nil, err + } + return s.switchTaskToNextRoute(task, attempts) +} + +func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskErr error) { + if attempt == nil { + return + } + key := "" + duration := time.Duration(0) + if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { + key, duration = "channel:"+attempt.ChannelID, 10*time.Minute + } else if attempt.FailureCode == "upstream_404" { + key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute + } else if attempt.FailureCode == "upstream_429" { + key, duration = "channel:"+attempt.ChannelID, 30*time.Second + var upstream providerHTTPError + if errors.As(taskErr, &upstream) && upstream.RetryAfter > 0 { + duration = upstream.RetryAfter + } + } + if key == "" || duration <= 0 { + return + } + until := time.Now().Add(duration) + s.routeHealthMu.Lock() + // 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。 + s.routeHealthBlocked[key] = until + s.routeHealthMu.Unlock() + if s.coordinator != nil && s.coordinator.HasRedis() { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := s.coordinator.BlockRoute(ctx, key, until); err != nil { + log.Printf("logical route distributed health update failed key=%s: %v", key, err) + } + } +} + +func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any) error { + if task == nil || task.LogicalModelID == "" { + return nil + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return err + } + var routed *RoutedModel + if logicalModel.ArchivedAt != nil { + // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 + routed, err = s.resolveArchivedTaskRoute(task, intent) + } else { + routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) + } + if err != nil { + return err + } + input = applyRoutedProviderSelection(input, routed) + if err := s.ValidateTaskCapability(input); err != nil { + return err + } + if err := s.protectTaskSecrets(input); err != nil { + return err + } + encoded, err := json.Marshal(input) + if err != nil { + return err + } + task.LogicalModelRevisionID = routed.Revision.ID + task.RouteID = routed.Route.ID + task.ChannelModelID = routed.ChannelModel.ID + task.Model = routed.LogicalModel.Code + task.Provider = "managed" + task.InputJSON = string(encoded) + return nil +} + +// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 +// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 +func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { + if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { + return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + if logicalModel.ArchivedAt == nil { + return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") + } + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { + return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil || !channelModel.Enabled { + return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") + } + return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil +} + +func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { + if attempt == nil { + return + } + now := time.Now() + attempt.CompletedAt = &now + if task != nil { + attempt.ProviderRequestID = task.ProviderRequestID + } + if taskErr == nil { + attempt.Status = "succeeded" + attempt.DispatchState = "accepted" + } else { + attempt.Status = "failed" + attempt.FailureMessage = truncateRunes(taskFailureMessage(taskErr), 1000) + attempt.FailureCode = routeFailureCode(taskErr) + if attempt.ProviderRequestID != "" { + attempt.DispatchState = "accepted" + } else if safeRouteRejection(taskErr) { + attempt.DispatchState = "rejected_no_job" + } else { + attempt.DispatchState = "submission_unknown" + } + } + if err := s.repo.SaveRouteAttempt(attempt); err != nil { + log.Printf("route attempt terminal state save failed attempt_id=%s task_id=%s: %v", attempt.ID, attempt.TaskID, err) + } +} + +func routeFailureCode(err error) string { + if code, _ := ChannelSlotFailureDetails(err); code != "" { + return code + } + var upstream providerHTTPError + if errors.As(err, &upstream) { + return fmt.Sprintf("upstream_%d", upstream.StatusCode) + } + return "submission_unknown" +} + +func safeRouteRejection(err error) bool { + if err == nil { + return false + } + if code, _ := ChannelSlotFailureDetails(err); code != "" { + return true + } + var upstream providerHTTPError + if errors.As(err, &upstream) { + switch upstream.StatusCode { + case 401, 403, 404, 429: + return true + } + } + return false +} diff --git a/fix_list_config.py b/fix_list_config.py deleted file mode 100644 index 6355326ac..000000000 --- a/fix_list_config.py +++ /dev/null @@ -1,19 +0,0 @@ -path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\list-mode-generator.ts" -with open(path, "r", encoding="utf-8") as f: - content = f.read() - -# Fix import -content = content.replace( - 'import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";', - 'import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";' -) - -# Fix config building - use buildGenerationConfig instead of resolveCanvasGenerationModel -content = content.replace( - ' const model = resolveCanvasGenerationModel(config, undefined, "text");\n const listConfig = { ...config, model };', - ' const listConfig = buildGenerationConfig(config, sourceNode, "text");' -) - -with open(path, "w", encoding="utf-8") as f: - f.write(content) -print("done") diff --git a/fix_remove_ref.py b/fix_remove_ref.py deleted file mode 100644 index 3cdc4e395..000000000 --- a/fix_remove_ref.py +++ /dev/null @@ -1,20 +0,0 @@ -path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\project.tsx" -with open(path, "r", encoding="utf-8") as f: - content = f.read() - -old_upload = ' onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}' -new_upload = ''' onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} - onRemoveReference={(rowId, columnIndex) => { - const table = nodesRef.current.find((n) => n.id === contentNode.id)?.metadata?.batchTable; - if (!table) return; - const row = table.rows.find((r) => r.id === rowId); - if (!row) return; - const newInputNodeIds = [...row.inputNodeIds]; - newInputNodeIds[columnIndex] = ""; - updateBatchRow(contentNode.id, rowId, { inputNodeIds: newInputNodeIds }); - }}''' -content = content.replace(old_upload, new_upload, 1) - -with open(path, "w", encoding="utf-8") as f: - f.write(content) -print("done") diff --git a/fix_shared.py b/fix_shared.py deleted file mode 100644 index 4a5708727..000000000 --- a/fix_shared.py +++ /dev/null @@ -1,10 +0,0 @@ -path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\shared.tsx" -with open(path, "r", encoding="utf-8") as f: - content = f.read() -content = content.replace( - 'onUploadReference={() => {}} onConnectStart={() => {}}', - 'onUploadReference={() => {}} onRemoveReference={() => {}} onConnectStart={() => {}}' -) -with open(path, "w", encoding="utf-8") as f: - f.write(content) -print("done") diff --git a/fix_text_model.py b/fix_text_model.py deleted file mode 100644 index 647666b61..000000000 --- a/fix_text_model.py +++ /dev/null @@ -1,17 +0,0 @@ -path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\list-mode-generator.ts" -with open(path, "r", encoding="utf-8") as f: - content = f.read() - -old = ''' const listConfig = buildGenerationConfig(config, sourceNode, "text");''' -new = ''' // Force text model: strip node.metadata.model so buildGenerationConfig uses config.textModel - const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: undefined } }; - let listConfig = buildGenerationConfig(config, sourceNodeForConfig, "text"); - // Explicitly set model to the configured text model - if (config.textModel) { - listConfig = { ...listConfig, model: config.textModel }; - }''' -content = content.replace(old, new, 1) - -with open(path, "w", encoding="utf-8") as f: - f.write(content) -print("done") diff --git a/fix_thumbnail_delete.py b/fix_thumbnail_delete.py deleted file mode 100644 index 60171a418..000000000 --- a/fix_thumbnail_delete.py +++ /dev/null @@ -1,168 +0,0 @@ -path = r"D:\yingce\open-ai-canvas-main\web\src\components\canvas\canvas-batch-table-node.tsx" -with open(path, "r", encoding="utf-8") as f: - content = f.read() - -# 1. Add onRemoveReference to Props -old_props = " onUploadReference: (rowId: string, columnIndex: number, file: File) => void;\n onConnectStart: (event: ReactPointerEvent, handleId: string) => void;" -new_props = " onUploadReference: (rowId: string, columnIndex: number, file: File) => void;\n onRemoveReference: (rowId: string, columnIndex: number) => void;\n onConnectStart: (event: ReactPointerEvent, handleId: string) => void;" -content = content.replace(old_props, new_props, 1) - -# 2. Add hoveredCell ref and context menu state after existing refs -old_refs = " const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null);\n const lastPointerRef = useRef({ x: 0, y: 0 });" -new_refs = """ const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); - const lastPointerRef = useRef({ x: 0, y: 0 }); - const hoveredCellRef = useRef(null); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; cell: ReferenceCell } | null>(null);""" -content = content.replace(old_refs, new_refs, 1) - -# 3. Add keyboard delete handler effect after existing useEffect block -old_effect_end = " }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]);" -new_effect_end = """ }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); - - // Delete / Backspace to remove hovered reference - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Delete" && event.key !== "Backspace") return; - const target = event.target as HTMLElement; - if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; - const cell = hoveredCellRef.current; - if (!cell || readOnly) return; - event.preventDefault(); - event.stopPropagation(); - onRemoveReference(cell.rowId, cell.columnIndex); - }; - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [readOnly, onRemoveReference]); - - // Hide context menu on outside click - useEffect(() => { - if (!contextMenu) return; - const hide = () => setContextMenu(null); - window.addEventListener("pointerdown", hide); - return () => window.removeEventListener("pointerdown", hide); - }, [contextMenu]);""" -content = content.replace(old_effect_end, new_effect_end, 1) - -# 4. Destructure onRemoveReference -old_destructure = "onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false" -new_destructure = "onReplaceReference, onUploadReference, onRemoveReference, onConnectStart, onConnectDrop, readOnly = false" -content = content.replace(old_destructure, new_destructure, 1) - -# 5. Pass hover tracking and onRemove to ReferenceThumbnail in the rows -old_thumb = """ { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} - onPointerDown={handleReferencePointerDown} - onPointerMove={handleReferencePointerMove} - onPointerUp={handleReferencePointerUp} - onPointerCancel={handleReferencePointerCancel} - onLostPointerCapture={handleReferencePointerCancel} - />""" -new_thumb = """ onRemoveReference(row.id, columnIndex)} - onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ x: event.clientX, y: event.clientY, cell: { rowId: row.id, columnIndex } }); }} - onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} - onPointerDown={handleReferencePointerDown} - onPointerMove={handleReferencePointerMove} - onPointerUp={handleReferencePointerUp} - onPointerCancel={handleReferencePointerCancel} - onLostPointerCapture={handleReferencePointerCancel} - />""" -content = content.replace(old_thumb, new_thumb, 1) - -# 6. Add context menu overlay before closing div -old_close = """ - - ); -} - -function ReferenceColumnHeader""" -new_close = """ - {contextMenu ? ( -
e.stopPropagation()} - > - -
- ) : null} - - ); -} - -function ReferenceColumnHeader""" -content = content.replace(old_close, new_close, 1) - -# 7. Update ReferenceThumbnail props type -old_thumb_props = " suppressClickRef: MutableRefObject;\n onReplace: (node: CanvasNodeData) => void;\n onUpload: () => void;" -new_thumb_props = " suppressClickRef: MutableRefObject;\n hoveredCellRef: MutableRefObject;\n onReplace: (node: CanvasNodeData) => void;\n onRemove: () => void;\n onContextMenu: (event: ReactMouseEvent) => void;\n onUpload: () => void;" -content = content.replace(old_thumb_props, new_thumb_props, 1) - -# 8. Update ReferenceThumbnail function signature -old_thumb_sig = "function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {" -new_thumb_sig = "function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onContextMenu, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {" -content = content.replace(old_thumb_sig, new_thumb_sig, 1) - -# 9. Add hover tracking to both thumbnail buttons (empty and filled) -# For empty button - add onMouseEnter/Leave and onContextMenu -old_empty_btn = ''' onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} - aria-label={`${columnId},点击上传图片`}''' -new_empty_btn = ''' onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} - onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} - onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} - onContextMenu={onContextMenu} - aria-label={`${columnId},点击上传图片`}''' -content = content.replace(old_empty_btn, new_empty_btn, 1) - -# For filled button - add onMouseEnter/Leave and onContextMenu -old_filled_btn = ''' onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} - {...cellData}''' -new_filled_btn = ''' onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} - onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} - onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} - onContextMenu={onContextMenu} - {...cellData}''' -content = content.replace(old_filled_btn, new_filled_btn, 1) - -# 10. Need to import ReactMouseEvent type -old_import = 'import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";' -new_import = 'import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";' -content = content.replace(old_import, new_import, 1) - -with open(path, "w", encoding="utf-8") as f: - f.write(content) -print("done") From e94eab883ebe9d979f209cea0155abb79046739d Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 14:55:41 +0800 Subject: [PATCH 10/20] fix: replace custom context menu with hover X button, restore native right-click --- .../canvas/canvas-batch-table-node.tsx | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index 5286f6ed3..3ffdf3c17 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Button, Input, Select, Switch, Tooltip } from "antd"; -import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; +import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload, X } from "lucide-react"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; @@ -59,7 +59,6 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); const lastPointerRef = useRef({ x: 0, y: 0 }); const hoveredCellRef = useRef(null); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; cell: ReferenceCell } | null>(null); const suppressClickRef = useRef(false); const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); const uploadInputRef = useRef(null); @@ -168,13 +167,6 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t return () => window.removeEventListener("keydown", handleKeyDown, true); }, [readOnly, onRemoveReference]); - // Hide context menu on outside click - useEffect(() => { - if (!contextMenu) return; - const hide = () => setContextMenu(null); - window.addEventListener("pointerdown", hide); - return () => window.removeEventListener("pointerdown", hide); - }, [contextMenu]); const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { if (readOnly || !hasMaterial) return; @@ -323,7 +315,6 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t hoveredCellRef={hoveredCellRef} onReplace={onReplaceReference} onRemove={() => onRemoveReference(row.id, columnIndex)} - onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ x: event.clientX, y: event.clientY, cell: { rowId: row.id, columnIndex } }); }} onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} onPointerDown={handleReferencePointerDown} onPointerMove={handleReferencePointerMove} @@ -381,23 +372,6 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
)} - {contextMenu ? ( -
e.stopPropagation()} - > - -
- ) : null} ); } @@ -544,7 +518,7 @@ function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasThe return
{content || "未连接文字"}
; } -function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onContextMenu, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { node?: CanvasNodeData; theme: CanvasTheme; readOnly: boolean; @@ -558,7 +532,6 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI hoveredCellRef: MutableRefObject; onReplace: (node: CanvasNodeData) => void; onRemove: () => void; - onContextMenu: (event: ReactMouseEvent) => void; onUpload: () => void; onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; onPointerMove: (event: ReactPointerEvent) => void; @@ -580,7 +553,6 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} - onContextMenu={onContextMenu} aria-label={`${columnId},点击上传图片`} {...cellData} onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, false)} @@ -608,7 +580,6 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} - onContextMenu={onContextMenu} {...cellData} onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, true)} onPointerMove={onPointerMove} @@ -619,6 +590,17 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI > {!readOnly ? 替换 : null} + {!readOnly ? ( + + ) : null} ); From af96824011dfdb2dbd2b7dd9ea6af2994cf6d9c0 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 15:35:11 +0800 Subject: [PATCH 11/20] fix: harden list mode and batch generation flow --- .../internal/app/cloud_agent_batch_table.go | 6 +- .../batch-generation-settings-dialog.tsx | 19 ++- web/src/pages/canvas/list-mode-generator.ts | 119 +++++++++++++----- .../pages/canvas/use-canvas-batch-table.ts | 5 +- web/test/canvas-batch-table.test.ts | 2 +- web/test/list-mode-generator.test.ts | 31 +++++ 6 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 web/test/list-mode-generator.test.ts diff --git a/backend/internal/app/cloud_agent_batch_table.go b/backend/internal/app/cloud_agent_batch_table.go index b0aa9c02c..ab84ba3b5 100644 --- a/backend/internal/app/cloud_agent_batch_table.go +++ b/backend/internal/app/cloud_agent_batch_table.go @@ -12,7 +12,7 @@ import ( const ( maxCloudAgentBatchRows = 500 - maxCloudAgentBatchReferences = 6 + maxCloudAgentBatchReferences = 10 maxCloudAgentBatchPromptRunes = 20000 ) @@ -96,7 +96,7 @@ func batchTableNodeFromDocument(doc map[string]any, nodeID string) (map[string]a } columns := creationMaps(table["referenceColumns"]) if len(columns) < 1 || len(columns) > maxCloudAgentBatchReferences { - return nil, nil, nil, nil, BadAuthRequest("批量创作表必须包含 1 到 6 个参考图列") + return nil, nil, nil, nil, BadAuthRequest("批量创作表必须包含 1 到 10 个参考图列") } columnIDs := map[string]bool{} for _, column := range columns { @@ -299,7 +299,7 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas fields = []string{"并发数"} case "add_reference_column": if len(columns) >= maxCloudAgentBatchReferences { - return nil, BadAuthRequest("批量创作表最多支持6组参考图") + return nil, BadAuthRequest("批量创作表最多支持10组参考图") } if args.RowID != "" || len(args.Patch) != 0 || args.Operation != "" || args.Concurrency != 0 { return nil, BadAuthRequest("新增参考图列不接受其他修改参数") diff --git a/web/src/components/canvas/batch-generation-settings-dialog.tsx b/web/src/components/canvas/batch-generation-settings-dialog.tsx index 49df76d5c..84aec2cb7 100644 --- a/web/src/components/canvas/batch-generation-settings-dialog.tsx +++ b/web/src/components/canvas/batch-generation-settings-dialog.tsx @@ -6,10 +6,12 @@ import { ImageSettingsPanel } from "@/components/image-settings-panel"; import { ModelPicker } from "@/components/model-picker"; import { canvasThemes } from "@/lib/canvas-theme"; import { defaultImageParamsForModel } from "@/lib/model-selection"; +import { CanvasCameraControlPopover } from "./canvas-camera-control-popover"; +import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import type { AiConfig } from "@/stores/use-config-store"; -export type BatchGenerationSettings = Pick; +export type BatchGenerationSettings = Pick & { cameraControl?: CameraControlOptions }; type BatchGenerationSettingsDialogProps = { open: boolean; @@ -23,9 +25,13 @@ type BatchGenerationSettingsDialogProps = { export function BatchGenerationSettingsDialog({ open, config, rowCount, concurrency, onClose, onConfirm }: BatchGenerationSettingsDialogProps) { const theme = canvasThemes[useActiveTheme()]; const [generationConfig, setGenerationConfig] = useState(config); + const [cameraControl, setCameraControl] = useState(undefined); useEffect(() => { - if (open) setGenerationConfig(config); + if (open) { + setGenerationConfig(config); + setCameraControl(undefined); + } }, [config, open]); const handleConfigChange = (key: "quality" | "size" | "transparentBackground" | "count", value: string) => { @@ -78,6 +84,14 @@ export function BatchGenerationSettingsDialog({ open, config, rowCount, concurre /> +
+
+
摄像机控制
+
为本批次统一应用镜头、焦段和光圈设置
+
+ +
+
- - = MAX_BATCH_REFERENCE_COLUMNS ? "最多支持 6 组参考图" : `新增参考图 ${referenceColumns.length + 1}`}> - - - - ) : null} -
- 已连 {connectedImageCount} · 完成 {completed}/{table.rows.length} +
event.stopPropagation()} + onMouseDown={(event) => { + // Only the top padding is a node drag surface; keep child controls isolated. + if (event.target !== event.currentTarget) event.stopPropagation(); + }} + > + { + const file = event.currentTarget.files?.[0]; + const target = uploadTargetRef.current; + uploadTargetRef.current = null; + event.currentTarget.value = ""; + if (file && target) onUploadReference(target.rowId, target.columnIndex, file); + }} + /> + {!readOnly ? : null} +
event.stopPropagation()} + > + ({ value, label: String(value) }))} + disabled={readOnly} + onChange={(concurrency) => onPatchTable({ concurrency })} + className="w-20" + aria-label="并发数" + /> + + {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} + + {!readOnly ? ( +
+ 全局提示词 + onPatchTable({ globalPrompt: event.target.value })} + /> + {hasGlobalPrompt ? 已覆盖全部 : null}
- {!readOnly ? ( -
event.stopPropagation()}> - - - - - -
- ) : null} -
-
event.stopPropagation()}> - 全局提示词 - onPatchTable({ globalPrompt: event.target.value })} - /> -
+ ) : null} + {!readOnly ? ( +
+ + + +
+ ) : null}
-
event.stopPropagation()}> -
- 任务 +
event.stopPropagation()}> +
+ # + 任务 {referenceColumns.map((column) => ( - setDraggingColumnId(column.id)} - onDragEnd={() => setDraggingColumnId(null)} - onDragOver={(event) => { event.preventDefault(); }} - onDrop={() => { if (draggingColumnId) onReorderReferenceColumns?.(draggingColumnId, column.id); setDraggingColumnId(null); }} - > - {column.label} - + setDraggingColumnId(column.id)} onDragEnd={() => setDraggingColumnId(null)} onDrop={(targetColumnId) => { if (draggingColumnId) onReorderReferenceColumns(draggingColumnId, targetColumnId); setDraggingColumnId(null); }} /> ))} - 任务提示词 - 生成结果 - 操作 + {textColumns.map((column, index) => ( + {column.label} + ))} + 任务提示词 + 生成结果 +
- {table.rows.length ? ( table.rows.map((row, index) => { const output = row.outputNodeId ? nodeById.get(row.outputNodeId) : undefined; const item = batchItemByRowId.get(row.id); - const status = row.enabled ? rowStatus(item, output) : { label: "已停用", tone: "idle" as const, loading: false, retryable: false }; - const ready = rowReady(row, table, nodeById); - const completedRow = hasNodeMedia(output); - const references = batchRowMentionReferences(row, referenceColumns, nodeById); - const effectivePrompt = batchPromptForRow(table, row); - const disabledReason = status.loading ? "当前任务正在生成" : !row.enabled ? "请先启用这一行" : !effectivePrompt.trim() ? "请填写任务提示词" : table.operation === "try_on" && row.inputNodeIds.filter(Boolean).length < 2 ? "批量换装至少需要两张参考图" : !ready ? "请补齐有效参考图" : ""; + const status = rowStatus(item, output); return ( -
-
- {!readOnly ? onUpdateRow(row.id, { enabled })} /> : null} - {index + 1} +
+ {index + 1} +
+ onUpdateRow(row.id, { enabled })} aria-label={`任务 ${index + 1}`} /> + {row.enabled === false ? "关" : "开"}
{referenceColumns.map((column, columnIndex) => ( -
- pickReferenceFile(row.id, columnIndex)} - onUploadFile={(file) => onUploadReference?.(row.id, columnIndex, file)} - onPointerDown={(event) => startCellDrag(event, row.id, columnIndex)} - /> + onRemoveReference(row.id, columnIndex)} + onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + /> + ))} + {textColumns.map((column, columnIndex) => ( +
+ {table.aiGenerated ? ( +
+ {row.cells?.[column.id] || ""} +
+ ) : ( + + )}
))} -
- onUpdateRow(row.id, { prompt })} - onSubmit={!readOnly && ready && !status.loading ? () => onGenerate([row.id]) : undefined} - onPointerDown={(event) => event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - /> -
- {readOnly ? "输入 @ 插入参考图" : "输入 @ 插入参考图 · ⌘/Ctrl + Enter 生成此行"} + onUpdateRow(row.id, { prompt: event.target.value })} /> +
+ {output?.metadata?.content || output?.metadata?.storageKey ? ( + } + /> + ) : ( + + )} +
+
{status.label}
+ {!readOnly && status.retryable && batch && item ? ( + + ) : null}
-
-
- { if (row.outputNodeId) onFocusOutput?.(row.outputNodeId); }} - /> + {status.loading ? : null}
{!readOnly ? ( -
- -
- ) : } + +
); }) ) : ( -
-
-
-
还没有批量任务
-

把图片连接到左侧参考图端口后同步连线,或先添加一行手工配置。

- {!readOnly ? : null} -
-
+
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
)}
); } -function BatchChoiceGroup({ ariaLabel, options, value, onChange, theme, compact = false, disabled = false }: { ariaLabel: string; options: Array<{ value: string | number; label: string }>; value: string | number; onChange: (value: string | number) => void; theme: CanvasTheme; compact?: boolean; disabled?: boolean }) { +function ReferenceColumnHeader({ column, theme, readOnly, dragging, onDragStart, onDragEnd, onDrop }: { column: { id: string; label: string }; theme: CanvasTheme; readOnly: boolean; dragging: boolean; onDragStart: () => void; onDragEnd: () => void; onDrop: (columnId: string) => void }) { return ( -
- {options.map((option) => { - const selected = option.value === value; - return ; - })} -
+ { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", column.id); + onDragStart(); + }} + onDragEnd={onDragEnd} + onDragOver={(event) => { if (!readOnly) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; } }} + onDrop={(event) => { event.preventDefault(); onDrop(column.id); }} + > + {!readOnly ? : null} + {column.label} + ); } -function BatchReferenceHandles({ columns, theme, onConnectStart, onConnectDrop }: { columns: ReturnType; theme: CanvasTheme; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) { - const commonStyle = { left: 0, width: 36, height: 36, transform: "translate(-50%, -50%)", transformOrigin: "center" }; - return <>{columns.map((column, index) => )}; +function BatchTextHandle(props: Omit, "column"> & { column: { id: string; label: string } }) { + return ; } -function BatchReferenceHandle({ column, index, theme, commonStyle, onConnectStart, onConnectDrop }: { column: { id: string; label: string }; index: number; theme: CanvasTheme; commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void }) { +function BatchReferenceHandles({ + columns, + textColumns, + theme, + onAdd, + onAddText, + onConnectStart, + onConnectDrop, +}: { + columns: ReturnType; + textColumns: ReturnType; + theme: CanvasTheme; + onAdd: () => void; + onAddText: () => void; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; +}) { + const commonStyle = { left: 0, width: 44, height: 44, transform: "translate(-50%, -50%)", transformOrigin: "center" }; + const referenceAddTop = BATCH_REFERENCE_HANDLE_TOP + columns.length * BATCH_REFERENCE_HANDLE_GAP; + const textTop = batchTextHandleTop(columns.length); + return ( + <> + {columns.map((column, index) => ( + + ))} + {textColumns.map((column, index) => ( + + ))} + {columns.length < MAX_BATCH_REFERENCE_COLUMNS ? ( + + + + ) : null} + {textColumns.length < MAX_BATCH_TEXT_COLUMNS ? ( + + + + ) : null} + + ); +} + +function BatchReferenceHandle({ + column, + top, + badge, + theme, + commonStyle, + onConnectStart, + onConnectDrop, + handlePrefix = "reference", +}: { + column: { id: string; label: string }; + top: number; + badge: string; + theme: CanvasTheme; + commonStyle: { left: number; width: number; height: number; transform: string; transformOrigin: string }; + onConnectStart: (event: ReactPointerEvent, handleId: string) => void; + onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; + handlePrefix?: "reference" | "text"; +}) { const [hovered, setHovered] = useState(false); const [offset, setOffset] = useState({ x: 0, y: 0 }); - const handleId = batchReferenceHandleId(column.id); - const reset = useCallback(() => { setHovered(false); setOffset({ x: 0, y: 0 }); }, []); + const handleId = handlePrefix === "text" ? batchTextHandleId(column.id) : batchReferenceHandleId(column.id); + const reset = useCallback(() => { + setHovered(false); + setOffset({ x: 0, y: 0 }); + }, []); const update = useCallback((event: ReactPointerEvent) => { const bounds = event.currentTarget.getBoundingClientRect(); const dx = event.clientX - (bounds.left + bounds.width / 2); const dy = event.clientY - (bounds.top + bounds.height / 2); - const limit = 10; + const limit = 16; setOffset({ x: Math.max(-limit, Math.min(limit, dx)), y: Math.max(-limit, Math.min(limit, dy)) }); }, []); return ( - - ); } - -function batchRowMentionReferences(row: CanvasBatchRow, columns: ReturnType, nodeById: Map): CanvasResourceReference[] { - return columns.flatMap((column, index) => { - const source = nodeById.get(row.inputNodeIds[index]); - if (!source) return []; - return [{ id: `${row.id}:${column.id}:${source.id}`, nodeId: source.id, kind: "image" as const, label: `参考图${index + 1}`, title: `${column.label} · ${source.title || "图片"}`, previewUrl: source.metadata?.previewContent || source.metadata?.content, storageKey: source.metadata?.storageKey, active: true, sourceType: source.type, mentionToken: batchReferenceMentionToken(index) }]; - }); +function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasTheme }) { + const content = node?.metadata?.content || node?.metadata?.prompt; + return
{content || "未连接文字"}
; } -function ReferenceThumbnail({ node, label, theme, readOnly, rowId, columnIndex, isDraggingCell, onPickFile, onUploadFile, onPointerDown }: { node?: CanvasNodeData; label: string; theme: CanvasTheme; readOnly: boolean; rowId: string; columnIndex: number; isDraggingCell: boolean; onPickFile: () => void; onUploadFile: (file: File) => void; onPointerDown: (event: ReactPointerEvent) => void }) { - const filled = node && hasNodeMedia(node); +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { + node?: CanvasNodeData; + theme: CanvasTheme; + readOnly: boolean; + rowId: string; + columnIndex: number; + columnId: string; + draggingColumnId: string | null; + isDraggingCell: boolean; + isDropTarget: boolean; + suppressClickRef: MutableRefObject; + hoveredCellRef: MutableRefObject; + onReplace: (node: CanvasNodeData) => void; + onRemove: () => void; + onUpload: () => void; + onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; + onLostPointerCapture: (event: ReactPointerEvent) => void; +}) { + const cellData = { "data-batch-reference-cell": true, "data-batch-row-id": rowId, "data-batch-column-index": String(columnIndex) }; + const dropStyle = isDropTarget ? { boxShadow: `0 0 0 2px ${theme.accent.primary}, 0 0 0 5px ${theme.accent.primary}33` } : undefined; + if (!node || (!node.metadata?.content && !node.metadata?.storageKey)) { + return ( + + + + ); + } const fallback = ; return ( - + + ) : null} ); } -function ResultThumbnail({ output, status, theme, onFocus }: { output?: CanvasNodeData; status: ReturnType; theme: CanvasTheme; onFocus: () => void }) { - const filled = hasNodeMedia(output); - const tone = statusColor(status.tone, theme.node.stroke); - const title = filled ? `${status.label} · 点击定位到画布节点` : status.label; +function EmptyThumbnail({ theme, sizeClass = "size-12" }: { theme: CanvasTheme; sizeClass?: string }): ReactNode { return ( - - - - ); -} - -function statusColor(tone: RowStatusTone, fallback: string) { - if (tone === "success") return "var(--status-success)"; - if (tone === "error") return "var(--status-error)"; - if (tone === "loading") return "var(--status-loading)"; - return fallback; -} - -function EmptyThumbnail({ theme, compact = false }: { theme: CanvasTheme; compact?: boolean }): ReactNode { - const sizeClass = "size-full"; - return ( -
- {compact ? : ( - - - 上传 - - )} +
+
); } -function rowReady(row: CanvasBatchRow, table: CanvasBatchTableData, nodeById: Map) { - if (!row.enabled || !batchPromptForRow(table, row).trim()) return false; - const inputNodeIds = row.inputNodeIds.filter(Boolean); - if (table.operation === "try_on" && inputNodeIds.length < 2) return false; - if (!inputNodeIds.length) return false; - return inputNodeIds.every((id) => hasNodeMedia(nodeById.get(id))); -} - -function hasNodeMedia(node?: CanvasNodeData) { - return Boolean(node?.metadata?.content || node?.metadata?.storageKey); -} - -type RowStatusTone = "success" | "error" | "loading" | "idle"; -type RowStatus = { label: string; tone: RowStatusTone; loading: boolean; retryable: boolean }; - -function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined): RowStatus { - if (hasNodeMedia(output)) return { label: "生成完成", tone: "success", loading: false, retryable: false }; - if (item?.status === "failed") return { label: item.errorDetails || "生成失败", tone: "error", loading: false, retryable: true }; - if (item?.status === "cancelled") return { label: "已停止", tone: "error", loading: false, retryable: false }; - if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", tone: "loading", loading: true, retryable: false }; - if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", tone: "error", loading: false, retryable: false }; - return { label: "待生成", tone: "idle", loading: false, retryable: false }; +function rowStatus(item: CanvasGenerationBatchItem | undefined, output: CanvasNodeData | undefined) { + if (output?.metadata?.content) return { label: "生成完成", loading: false, retryable: false }; + if (item?.status === "failed") return { label: item.errorDetails || "生成失败", loading: false, retryable: true }; + if (item?.status === "cancelled") return { label: "已停止", loading: false, retryable: false }; + if (item && ["waiting", "submitting", "queued", "running"].includes(item.status)) + return { label: item.status === "waiting" ? "等待中" : item.status === "submitting" ? "正在提交" : item.status === "queued" ? "已排队" : "生成中", loading: true, retryable: false }; + if (output?.metadata?.status === "error") return { label: output.metadata.errorDetails || "生成失败", loading: false, retryable: false }; + return { label: "待生成", loading: false, retryable: false }; } diff --git a/web/src/lib/canvas/canvas-batch-table.ts b/web/src/lib/canvas/canvas-batch-table.ts index 5ee32d398..559acaeba 100644 --- a/web/src/lib/canvas/canvas-batch-table.ts +++ b/web/src/lib/canvas/canvas-batch-table.ts @@ -5,19 +5,91 @@ import type { CanvasBatchOperation, CanvasBatchReferenceColumn, CanvasBatchRow, export const TRY_ON_BATCH_PROMPT = "参考图1是人物原图,参考图2是目标服装。保持人物身份、五官、姿态和背景不变,将人物服装替换为参考图2中的款式。准确还原服装版型、颜色、材质、纹理和装饰细节,穿着关系自然,光影与原图一致。"; export const CREATIVE_BATCH_PROMPT = "基于参考图创作一张新的商业图片,保留主体身份和关键产品细节,画面构图完整,光影自然。"; export const BATCH_REFERENCE_HANDLE_PREFIX = "batch-reference:"; -/** 两行工具栏(操作栏 44 + 全局提示词 36 + 分隔 1)之后,对准表头中线。 */ -export const BATCH_REFERENCE_HANDLE_TOP = 102; -export const BATCH_REFERENCE_HANDLE_GAP = 40; +export const BATCH_REFERENCE_HANDLE_TOP = 112; +export const BATCH_REFERENCE_HANDLE_GAP = 38; +export const MAX_BATCH_REFERENCE_COLUMNS = 10; +export const MAX_BATCH_TEXT_COLUMNS = 4; +export const BATCH_TEXT_HANDLE_PREFIX = "batch-text:"; export const MIN_BATCH_REFERENCE_COLUMNS = 1; -export const MAX_BATCH_REFERENCE_COLUMNS = 6; const LEGACY_BATCH_TABLE_WIDTH = 900; -/** 旧默认 900 宽的批量创作表升级到当前默认尺寸,已经手动改过宽度的节点保持原样。 */ export function promoteLegacyBatchTableSize(node: CanvasNodeData): CanvasNodeData { if (node.type !== "batch-table" || node.width !== LEGACY_BATCH_TABLE_WIDTH) return node; return { ...node, width: 1280, height: Math.max(node.height, 560) }; } +export function batchGridTemplateColumns(referenceCount: number, textCount: number) { + return [ + "36px", + "68px", + `repeat(${referenceCount}, 72px)`, + textCount > 0 ? `repeat(${textCount}, minmax(168px, 0.75fr))` : undefined, + "minmax(280px, 1fr)", + "148px", + "36px", + ].filter(Boolean).join(" "); +} + +export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData { + const columns = batchReferenceColumns(table); + const fromIndex = columns.findIndex((column) => column.id === fromColumnId); + const toIndex = columns.findIndex((column) => column.id === toColumnId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table; + + const nextColumns = [...columns]; + const [moved] = nextColumns.splice(fromIndex, 1); + nextColumns.splice(toIndex, 0, moved); + const nextRows = table.rows.map((row) => ({ + ...row, + inputNodeIds: nextColumns.map((column) => { + const oldIndex = columns.findIndex((item) => item.id === column.id); + return row.inputNodeIds[oldIndex] || ""; + }), + })); + + return { + ...table, + referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })), + rows: nextRows, + }; +} + +export function moveBatchReferenceCell( + table: CanvasBatchTableData, + sourceRowId: string, + sourceColumnIndex: number, + targetRowId: string, + targetColumnIndex: number, +): CanvasBatchTableData { + if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table; + if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table; + + const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1); + const sourceRow = table.rows.find((row) => row.id === sourceRowId); + const targetRow = table.rows.find((row) => row.id === targetRowId); + if (!sourceRow || !targetRow) return table; + + const nextRows = table.rows.map((row) => ({ + ...row, + inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || ""), + })); + const nextSourceRow = nextRows.find((row) => row.id === sourceRowId)!; + const nextTargetRow = nextRows.find((row) => row.id === targetRowId)!; + const sourceNodeId = nextSourceRow.inputNodeIds[sourceColumnIndex]; + if (!sourceNodeId) return table; + + const targetNodeId = nextTargetRow.inputNodeIds[targetColumnIndex]; + nextTargetRow.inputNodeIds[targetColumnIndex] = sourceNodeId; + nextSourceRow.inputNodeIds[sourceColumnIndex] = targetNodeId || ""; + + return { ...table, rows: nextRows }; +} + +// Reserve one slot for adding a reference and a separate gap before text inputs. +export function batchTextHandleTop(referenceCount: number) { + return BATCH_REFERENCE_HANDLE_TOP + (referenceCount + 1) * BATCH_REFERENCE_HANDLE_GAP + 24; +} + export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] { return [ { id: "reference-1", label: "参考图 1" }, @@ -29,45 +101,13 @@ export function defaultBatchReferenceColumns(): CanvasBatchReferenceColumn[] { export function batchReferenceColumns(table?: CanvasBatchTableData) { const columns = table?.referenceColumns; if (!columns?.length) return defaultBatchReferenceColumns(); + // Older tables were created before the second default slot existed. They + // never had a remove-column action, so extending this exact legacy shape is + // a safe migration and makes reference slot 2 immediately selectable. + if (columns.length === 1 && columns[0].id === "reference-1") return defaultBatchReferenceColumns(); return columns; } -export function batchReferenceHandleId(columnId: string) { - return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`; -} - -export function batchReferenceMentionToken(index: number) { - return `@参考图${index + 1}`; -} - -export function batchReferenceColumnId(handleId?: string) { - return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined; -} - -export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) { - if (node.type !== "batch-table") return undefined; - const columnId = batchReferenceColumnId(handleId); - const columns = batchReferenceColumns(node.metadata?.batchTable); - const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0; - if (index < 0) return undefined; - return node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP; -} - -export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) { - if (node.type !== "batch-table") return undefined; - const columns = batchReferenceColumns(node.metadata?.batchTable); - let nearestIndex = -1; - let nearestDistance = Number.POSITIVE_INFINITY; - columns.forEach((_, index) => { - const distance = Math.abs(worldY - (node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP)); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestIndex = index; - } - }); - return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchReferenceHandleId(columns[nearestIndex].id) : undefined; -} - export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): CanvasBatchTableData | null { const columns = batchReferenceColumns(table); if (columns.length <= MIN_BATCH_REFERENCE_COLUMNS) return null; @@ -79,47 +119,71 @@ export function removeLastBatchReferenceColumn(table: CanvasBatchTableData): Can }; } -export function reorderBatchReferenceColumns(table: CanvasBatchTableData, fromColumnId: string, toColumnId: string): CanvasBatchTableData { - const columns = batchReferenceColumns(table); - const fromIndex = columns.findIndex((column) => column.id === fromColumnId); - const toIndex = columns.findIndex((column) => column.id === toColumnId); - if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return table; - const nextColumns = [...columns]; - const [moved] = nextColumns.splice(fromIndex, 1); - nextColumns.splice(toIndex, 0, moved); - return { - ...table, - referenceColumns: nextColumns.map((column, index) => ({ ...column, label: `参考图 ${index + 1}` })), - rows: table.rows.map((row) => ({ - ...row, - inputNodeIds: nextColumns.map((column) => { - const oldIndex = columns.findIndex((item) => item.id === column.id); - return row.inputNodeIds[oldIndex] || ""; - }), - })), - }; +export function batchReferenceHandleId(columnId: string) { + return `${BATCH_REFERENCE_HANDLE_PREFIX}${columnId}`; } -export function moveBatchReferenceCell(table: CanvasBatchTableData, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number): CanvasBatchTableData { - if (sourceRowId === targetRowId && sourceColumnIndex === targetColumnIndex) return table; - if (sourceColumnIndex < 0 || targetColumnIndex < 0) return table; - const referenceCount = Math.max(batchReferenceColumns(table).length, sourceColumnIndex + 1, targetColumnIndex + 1); - const sourceRow = table.rows.find((row) => row.id === sourceRowId); - const targetRow = table.rows.find((row) => row.id === targetRowId); - if (!sourceRow || !targetRow) return table; - const nextRows = table.rows.map((row) => ({ ...row, inputNodeIds: Array.from({ length: referenceCount }, (_, index) => row.inputNodeIds[index] || "") })); - const nextSource = nextRows.find((row) => row.id === sourceRowId)!; - const nextTarget = nextRows.find((row) => row.id === targetRowId)!; - const sourceNodeId = nextSource.inputNodeIds[sourceColumnIndex]; - if (!sourceNodeId) return table; - const targetNodeId = nextTarget.inputNodeIds[targetColumnIndex]; - nextTarget.inputNodeIds[targetColumnIndex] = sourceNodeId; - nextSource.inputNodeIds[sourceColumnIndex] = targetNodeId || ""; - return { ...table, rows: nextRows }; +export function batchReferenceColumnId(handleId?: string) { + return handleId?.startsWith(BATCH_REFERENCE_HANDLE_PREFIX) ? handleId.slice(BATCH_REFERENCE_HANDLE_PREFIX.length) : undefined; } -export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) { - return table.globalPrompt?.trim() || row.prompt; +export function batchTextColumns(table?: CanvasBatchTableData) { + return table?.textColumns || []; +} + +export function batchTextHandleId(columnId: string) { + return `${BATCH_TEXT_HANDLE_PREFIX}${columnId}`; +} + +export function batchTextColumnId(handleId?: string) { + return handleId?.startsWith(BATCH_TEXT_HANDLE_PREFIX) ? handleId.slice(BATCH_TEXT_HANDLE_PREFIX.length) : undefined; +} + +export function batchTextHandleY(node: CanvasNodeData, handleId?: string) { + if (node.type !== "batch-table") return undefined; + const columnId = batchTextColumnId(handleId); + const columns = batchTextColumns(node.metadata?.batchTable); + const index = columnId ? columns.findIndex((column) => column.id === columnId) : -1; + if (index < 0) return undefined; + return node.position.y + batchTextHandleTop(batchReferenceColumns(node.metadata?.batchTable).length) + index * BATCH_REFERENCE_HANDLE_GAP; +} + +export function batchTextHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) { + if (node.type !== "batch-table") return undefined; + const columns = batchTextColumns(node.metadata?.batchTable); + const referenceCount = batchReferenceColumns(node.metadata?.batchTable).length; + let nearestIndex = -1; + let nearestDistance = Number.POSITIVE_INFINITY; + columns.forEach((_, index) => { + const distance = Math.abs(worldY - (node.position.y + batchTextHandleTop(referenceCount) + index * BATCH_REFERENCE_HANDLE_GAP)); + if (distance < nearestDistance) { nearestDistance = distance; nearestIndex = index; } + }); + return nearestIndex >= 0 && nearestDistance <= hitRadius ? batchTextHandleId(columns[nearestIndex].id) : undefined; +} +export function batchReferenceHandleY(node: CanvasNodeData, handleId?: string) { + if (node.type !== "batch-table") return undefined; + const textY = batchTextHandleY(node, handleId); + if (textY !== undefined) return textY; + const columnId = batchReferenceColumnId(handleId); + const columns = batchReferenceColumns(node.metadata?.batchTable); + const index = columnId ? columns.findIndex((column) => column.id === columnId) : 0; + if (index < 0) return undefined; + return node.position.y + BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP; +} + +export function batchReferenceHandleAtY(node: CanvasNodeData, worldY: number, hitRadius = 18) { + if (node.type !== "batch-table") return undefined; + const referenceColumns = batchReferenceColumns(node.metadata?.batchTable); + const textColumns = batchTextColumns(node.metadata?.batchTable); + const handles = [ + ...referenceColumns.map((column, index) => ({ id: batchReferenceHandleId(column.id), y: BATCH_REFERENCE_HANDLE_TOP + index * BATCH_REFERENCE_HANDLE_GAP })), + ...textColumns.map((column, index) => ({ id: batchTextHandleId(column.id), y: batchTextHandleTop(referenceColumns.length) + index * BATCH_REFERENCE_HANDLE_GAP })), + ]; + const nearest = handles.reduce<{ id: string; distance: number } | null>((current, handle) => { + const distance = Math.abs(worldY - (node.position.y + handle.y)); + return !current || distance < current.distance ? { id: handle.id, distance } : current; + }, null); + return nearest && nearest.distance <= hitRadius ? nearest.id : undefined; } export function batchInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) { @@ -135,20 +199,34 @@ export function batchInputColumns(node: CanvasNodeData, connections: CanvasConne return result; } +export function batchTextInputColumns(node: CanvasNodeData, connections: CanvasConnection[]) { + const columns = batchTextColumns(node.metadata?.batchTable); + const indexById = new Map(columns.map((column, index) => [column.id, index])); + const result = columns.map(() => [] as string[]); + connections.filter((connection) => connection.toNodeId === node.id && connection.relation !== "batch-output").forEach((connection) => { + const columnId = batchTextColumnId(connection.toHandleId); + const index = columnId ? indexById.get(columnId) : undefined; + if (index === undefined || result[index].includes(connection.fromNodeId)) return; + result[index].push(connection.fromNodeId); + }); + return result; +} + +/** A non-empty global prompt takes precedence without destroying row-specific prompts. */ +export function batchPromptForRow(table: CanvasBatchTableData, row: CanvasBatchRow) { + return table.globalPrompt?.trim() || row.prompt; +} + export function createBatchRow(operation: CanvasBatchOperation, inputNodeIds: string[] = []): CanvasBatchRow { return { id: `batch-row-${nanoid()}`, enabled: true, inputNodeIds, + textNodeIds: [], prompt: operation === "try_on" ? TRY_ON_BATCH_PROMPT : CREATIVE_BATCH_PROMPT, }; } -/** 手工追加任务时沿用上一行的参考图,但重新使用当前任务类型的默认提示词。 */ -export function createInheritedBatchRow(operation: CanvasBatchOperation, rows: CanvasBatchRow[]) { - return createBatchRow(operation, [...(rows.at(-1)?.inputNodeIds || [])]); -} - /** * Connected try-on inputs follow the reference layout: all model/person images first, * followed by one shared garment image. Users can still edit any row afterwards. @@ -160,34 +238,32 @@ export function createBatchRowsFromInputs(operation: CanvasBatchOperation, input return inputNodeIds.slice(0, -1).map((personId) => createBatchRow(operation, [personId, garmentId])); } -/** - * Zip equally sized columns and broadcast singleton columns across every row. - * - * 同步连线是增量操作:匹配到的行更新参考图,未被本次连线覆盖的手工行继续保留。 - * 这样用户在表格内追加或编辑的任务不会因为再次点击“同步连线”而被删除。 - */ +/** Zip equally sized columns and broadcast singleton columns across every row. */ export function createBatchRowsFromColumns(operation: CanvasBatchOperation, columns: string[][], previousRows: CanvasBatchRow[] = []) { const rowCount = Math.max(0, ...columns.map((column) => column.length)); - const unmatchedRows = [...previousRows]; - const synchronizedRows = Array.from({ length: rowCount }, (_, rowIndex) => { + const previousByPrimary = new Map(previousRows.flatMap((row) => row.inputNodeIds[0] ? [[row.inputNodeIds[0], row] as const] : [])); + return Array.from({ length: rowCount }, (_, rowIndex) => { const inputNodeIds = columns.flatMap((column) => { const input = column.length === 1 ? column[0] : column[rowIndex]; return input ? [input] : []; }); - let previousIndex = unmatchedRows.findIndex((row) => sameBatchInputs(row.inputNodeIds, inputNodeIds)); - if (previousIndex < 0 && inputNodeIds[0]) previousIndex = unmatchedRows.findIndex((row) => row.inputNodeIds[0] === inputNodeIds[0]); - const previous = previousIndex >= 0 ? unmatchedRows.splice(previousIndex, 1)[0] : undefined; + const previous = inputNodeIds[0] ? previousByPrimary.get(inputNodeIds[0]) : undefined; const inputsUnchanged = previous && previous.inputNodeIds.length === inputNodeIds.length && previous.inputNodeIds.every((id, index) => id === inputNodeIds[index]); return { ...createBatchRow(operation, inputNodeIds), - ...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt } : {}), + ...(previous ? { id: previous.id, enabled: previous.enabled, prompt: previous.prompt, textNodeIds: previous.textNodeIds } : {}), ...(inputsUnchanged && previous?.outputNodeId ? { outputNodeId: previous.outputNodeId } : {}), inputNodeIds, }; }); - return [...synchronizedRows, ...unmatchedRows]; } -function sameBatchInputs(left: string[], right: string[]) { - return left.length === right.length && left.every((id, index) => id === right[index]); +export function assignBatchTextRows(rows: CanvasBatchRow[], columns: string[][]) { + return rows.map((row, rowIndex) => ({ + ...row, + textNodeIds: columns.flatMap((column) => { + const input = column.length === 1 ? column[0] : column[rowIndex]; + return input ? [input] : []; + }), + })); } diff --git a/web/src/pages/canvas/use-canvas-batch-table.ts b/web/src/pages/canvas/use-canvas-batch-table.ts index 6a3c5c319..ef79ec187 100644 --- a/web/src/pages/canvas/use-canvas-batch-table.ts +++ b/web/src/pages/canvas/use-canvas-batch-table.ts @@ -1,58 +1,67 @@ -import { useCallback, type Dispatch, type SetStateAction } from "react"; -import { App } from "antd"; -import { nanoid } from "nanoid"; - -import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; -import { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, createBatchRowsFromColumns, createInheritedBatchRow, moveBatchReferenceCell, removeLastBatchReferenceColumn, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table"; -import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; -import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; -import { navigateToSettings } from "@/lib/settings-navigation"; -import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; -import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas"; - -type Options = { - nodesRef: { current: CanvasNodeData[] }; - connectionsRef: { current: CanvasConnection[] }; - setNodes: Dispatch>; - setConnections: Dispatch>; - setSelectedNodeIds: Dispatch>>; - enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined; -}; - -export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) { - const { message, modal } = App.useApp(); - const effectiveConfig = useEffectiveConfig(); - const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); - - const patchTable = useCallback((nodeId: string, patch: Partial) => { +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { App } from "antd"; +import { nanoid } from "nanoid"; + +import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; +import { MAX_BATCH_REFERENCE_COLUMNS, batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, removeLastBatchReferenceColumn, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table"; +import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; +import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; +import { navigateToSettings } from "@/lib/settings-navigation"; +import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; +import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas"; +import type { BatchGenerationSettings } from "@/components/canvas/batch-generation-settings-dialog"; + +type Options = { + nodesRef: { current: CanvasNodeData[] }; + connectionsRef: { current: CanvasConnection[] }; + setNodes: Dispatch>; + setConnections: Dispatch>; + setSelectedNodeIds: Dispatch>>; + enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined; +}; + +type PendingBatchGen = { + nodeId: string; + rows: CanvasBatchRow[]; + concurrency: number; +}; + +export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) { + const { message } = App.useApp(); + const effectiveConfig = useEffectiveConfig(); + const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); + + const [batchGenDialog, setBatchGenDialog] = useState<{ open: boolean; pending: PendingBatchGen | null }>({ open: false, pending: null }); + + const patchTable = useCallback((nodeId: string, patch: Partial) => { setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, rows: [], ...node.metadata?.batchTable, ...patch } } })); - }, [setNodes]); - - const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial) => { - const node = nodesRef.current.find((item) => item.id === nodeId); - if (!node?.metadata?.batchTable) return; - patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) }); - }, [nodesRef, patchTable]); - - const addRow = useCallback((nodeId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - patchTable(nodeId, { rows: [...table.rows, createInheritedBatchRow(table.operation, table.rows)] }); - }, [nodesRef, patchTable]); - - const removeRow = useCallback((nodeId: string, rowId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) }); - }, [nodesRef, patchTable]); - + }, [setNodes]); + + const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + if (!node?.metadata?.batchTable) return; + patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) }); + }, [nodesRef, patchTable]); + + const addRow = useCallback((nodeId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] }); + }, [nodesRef, patchTable]); + + const removeRow = useCallback((nodeId: string, rowId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) }); + }, [nodesRef, patchTable]); + const addReferenceColumn = useCallback((nodeId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const columns = batchReferenceColumns(table); - if (columns.length >= 6) return message.info("最多支持 6 组参考图"); - const nextIndex = columns.length + 1; - patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const columns = batchReferenceColumns(table); + if (columns.length >= MAX_BATCH_REFERENCE_COLUMNS) return message.info("最多支持 10 组参考图"); + const nextIndex = columns.length + 1; + patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] }); }, [message, nodesRef, patchTable]); const removeReferenceColumn = useCallback((nodeId: string) => { @@ -61,152 +70,191 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon const columns = batchReferenceColumns(table); const nextTable = removeLastBatchReferenceColumn(table); if (!nextTable) return message.info("至少保留 1 组参考图"); - const removed = columns.at(-1); patchTable(nodeId, nextTable); + const removed = columns.at(-1); if (removed) { const handleId = batchReferenceHandleId(removed.id); setConnections((current) => current.filter((connection) => !(connection.toNodeId === nodeId && connection.toHandleId === handleId))); } }, [message, nodesRef, patchTable, setConnections]); - - const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => { - const node = nodesRef.current.find((item) => item.id === nodeId); - const table = node?.metadata?.batchTable; - if (!node || !table) return false; - const nodeById = new Map(nodesRef.current.map((item) => [item.id, item])); - const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { - const input = nodeById.get(inputNodeId); - return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey); - })); - if (!columns.some((column) => column.length)) { - if (!silent) message.warning("请先把图片节点连接到批量创作表"); - return false; - } - const rows = createBatchRowsFromColumns(table.operation, columns, table.rows); - if (!rows.length) { - if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); - return false; - } - if (JSON.stringify(rows) === JSON.stringify(table.rows)) return true; - patchTable(nodeId, { rows }); - if (!silent) { - const added = Math.max(0, rows.length - table.rows.length); - message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留"); - } - return true; - }, [connectionsRef, message, nodesRef, patchTable]); - - const fillRowsFromConnections = useCallback((nodeId: string) => { - syncRowsFromConnections(nodeId); - }, [syncRowsFromConnections]); - - const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId); - if (nextTable !== table) patchTable(nodeId, nextTable); - }, [nodesRef, patchTable]); - - const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => { - const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; - if (!table) return; - const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex); - if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows }); - }, [nodesRef, patchTable]); - - const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => { - const sourceNode = nodesRef.current.find((item) => item.id === nodeId); - const table = sourceNode?.metadata?.batchTable; - if (!sourceNode || !table) return; - const imageModel = effectiveConfig.imageModel || effectiveConfig.model; - if (!isAiConfigReady(effectiveConfig, imageModel)) { - navigateToSettings({ continueCreation: true }); - return; - } - const generationConfig = buildGenerationConfig(effectiveConfig, undefined, "image"); - const imageResolution = /^(1k|2k|4k)$/i.test(generationConfig.quality) ? generationConfig.quality.toUpperCase() : "由尺寸决定"; - const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId))); - const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null; - const rows = table.rows.filter((row) => { - const inputNodeIds = row.inputNodeIds.filter(Boolean); - if (!row.enabled || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false; - if (table.operation === "try_on" && inputNodeIds.length < 2) return false; - if (!inputNodeIds.length || inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false; - const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined; - if (output && activeNodeIds.has(output.id)) return false; - // 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。 - if (requested) return true; - return !output?.metadata?.content; - }); - if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词"); - const confirmed = await new Promise((resolve) => modal.confirm({ - title: `确认提交 ${rows.length} 个批量图片任务`, - content: [ - `模型:${modelDisplayName(effectiveConfig, generationConfig.model) || generationConfig.model}`, - `出图数量:${rows.length} 张(每行 1 张)`, - `尺寸:${generationConfig.size || "默认"}`, - `质量:${generationConfig.quality || "默认"}`, - `分辨率:${imageResolution}`, - `并发:${table.concurrency}`, - "这些任务可能消耗积分或产生外部模型费用。", - ].join("\n"), - okText: "确认生成", - cancelText: "取消", - centered: true, - onOk: () => resolve(true), - onCancel: () => resolve(false), - })); - if (!confirmed) return; - - const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; - const nextNodes = [...nodesRef.current]; - let nextConnections = [...connectionsRef.current]; - const outputByRowId = new Map(); - const targets: Array<{ rowId: string; nodeId: string }> = []; - rows.forEach((row, index) => { - const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1; - const prompt = batchPromptForRow(table, row).trim(); - const metadata = { - ...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}), - prompt, - composerContent: prompt, - model: generationConfig.model, - size: generationConfig.size, - quality: generationConfig.quality, - transparentBackground: generationConfig.transparentBackground, - count: 1, - generationMode: "image" as const, - generationType: "edit" as const, - workflowKind: "final" as const, - workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`, - status: "idle" as const, - batchSourceNodeId: nodeId, - batchRowId: row.id, - batchOperation: table.operation, + + const addTextColumn = useCallback((nodeId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const columns = table.textColumns || []; + if (columns.length >= 4) return message.info("最多支持 4 组文字"); + patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] }); + }, [message, nodesRef, patchTable]); + + const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => { + const node = nodesRef.current.find((item) => item.id === nodeId); + const table = node?.metadata?.batchTable; + if (!node || !table) return false; + const nodeById = new Map(nodesRef.current.map((item) => [item.id, item])); + const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { + const input = nodeById.get(inputNodeId); + return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey); + })); + if (!columns.some((column) => column.length)) { + if (!silent) message.warning("请先把图片节点连接到批量创作表"); + return false; + } + const rows = createBatchRowsFromColumns(table.operation, columns, table.rows); + const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { + const input = nodeById.get(inputNodeId); + return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt); + })); + const rowsWithText = rows.map((row, index) => ({ + ...row, + textNodeIds: textColumns.flatMap((column) => { + const input = column.length === 1 ? column[0] : column[index]; + return input ? [input] : []; + }), + })); + if (!rows.length) { + if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图"); + return false; + } + const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText); + if (!rowsChanged) return false; + patchTable(nodeId, { rows: rowsWithText }); + if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`); + return true; + }, [connectionsRef, message, nodesRef, patchTable]); + + const fillRowsFromConnections = useCallback((nodeId: string) => { + syncRowsFromConnections(nodeId); + }, [syncRowsFromConnections]); + + const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId); + if (nextTable !== table) patchTable(nodeId, nextTable); + }, [nodesRef, patchTable]); + + const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => { + const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable; + if (!table) return; + const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex); + if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows }); + }, [nodesRef, patchTable]); + + const executeBatchGeneration = useCallback((pending: PendingBatchGen, settings: BatchGenerationSettings) => { + const { nodeId, rows, concurrency } = pending; + const sourceNode = nodesRef.current.find((item) => item.id === nodeId); + const table = sourceNode?.metadata?.batchTable; + if (!sourceNode || !table) return; + + const mergedConfig = { ...effectiveConfig, ...settings }; + + const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image]; + const nextNodes = [...nodesRef.current]; + let nextConnections = [...connectionsRef.current]; + const outputByRowId = new Map(); + const targets: Array<{ rowId: string; nodeId: string }> = []; + rows.forEach((row, index) => { + const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1; + const prompt = batchPromptForRow(table, row).trim(); + const metadata = { + ...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}), + prompt, + composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => { + const textNode = nodesRef.current.find((node) => node.id === textNodeId); + return textNode?.metadata?.content || textNode?.metadata?.prompt || ""; + })].filter(Boolean).join("\n\n"), + model: buildGenerationConfig(mergedConfig, undefined, "image").model, + size: mergedConfig.size, + quality: mergedConfig.quality, + transparentBackground: mergedConfig.transparentBackground, + count: Number(mergedConfig.count) || 1, + generationMode: "image" as const, + generationType: "edit" as const, + workflowKind: "final" as const, + workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`, + status: "idle" as const, + batchSourceNodeId: nodeId, + batchRowId: row.id, + batchOperation: table.operation, batchInputNodeIds: row.inputNodeIds, cameraControl: settings.cameraControl, }; - const output = existingIndex >= 0 - ? { ...nextNodes[existingIndex], metadata } - : createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata); - output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`; - if (existingIndex >= 0) nextNodes[existingIndex] = output; - else nextNodes.push(output); - nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id); + const output = existingIndex >= 0 + ? { ...nextNodes[existingIndex], metadata } + : createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata); + output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`; + if (existingIndex >= 0) nextNodes[existingIndex] = output; + else nextNodes.push(output); + nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id); row.inputNodeIds.filter(Boolean).forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id })); - nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id }); - outputByRowId.set(row.id, output.id); - targets.push({ rowId: row.id, nodeId: output.id }); - }); - const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id); - nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } }; - nodesRef.current = nextNodes; - connectionsRef.current = nextConnections; - setNodes(nextNodes); - setConnections(nextConnections); - setSelectedNodeIds(new Set(targets.map((target) => target.nodeId))); - if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`); - }, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]); - - return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow }; -} + nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id }); + outputByRowId.set(row.id, output.id); + targets.push({ rowId: row.id, nodeId: output.id }); + }); + const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id); + nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } }; + nodesRef.current = nextNodes; + connectionsRef.current = nextConnections; + setNodes(nextNodes); + setConnections(nextConnections); + setSelectedNodeIds(new Set(targets.map((target) => target.nodeId))); + if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency })) message.success(`${targets.length} 个任务已加入并发队列`); + }, [connectionsRef, effectiveConfig, enqueueGenerationBatch, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]); + + const generateRows = useCallback((nodeId: string, requestedRowIds?: string[]) => { + const sourceNode = nodesRef.current.find((item) => item.id === nodeId); + const table = sourceNode?.metadata?.batchTable; + if (!sourceNode || !table) return; + const imageModel = effectiveConfig.imageModel || effectiveConfig.model; + if (!isAiConfigReady(effectiveConfig, imageModel)) { + navigateToSettings({ continueCreation: true }); + return; + } + const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId))); + const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null; + const rows = table.rows.filter((row) => { + if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false; + if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false; + if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false; + const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined; + return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id)); + }); + if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词"); + + setBatchGenDialog({ open: true, pending: { nodeId, rows, concurrency: table.concurrency } }); + }, [effectiveConfig, isAiConfigReady, message, nodesRef]); + + const closeBatchGenDialog = useCallback(() => { + setBatchGenDialog({ open: false, pending: null }); + }, []); + + const confirmBatchGenDialog = useCallback((settings: BatchGenerationSettings) => { + if (batchGenDialog.pending) { + executeBatchGeneration(batchGenDialog.pending, settings); + } + setBatchGenDialog({ open: false, pending: null }); + }, [batchGenDialog.pending, executeBatchGeneration]); + + const dialogConfig = useMemo(() => effectiveConfig, [effectiveConfig]); + + return { + addReferenceColumn, + addTextColumn, + addRow, + fillRowsFromConnections, + generateRows, + moveReferenceCell, + patchTable, + removeReferenceColumn, + removeRow, + reorderReferenceColumns, + syncRowsFromConnections, + updateRow, + batchGenDialogOpen: batchGenDialog.open, + batchGenDialogRowCount: batchGenDialog.pending?.rows.length ?? 0, + batchGenDialogConcurrency: batchGenDialog.pending?.concurrency ?? 1, + batchGenDialogConfig: dialogConfig, + closeBatchGenDialog, + confirmBatchGenDialog, + }; +} diff --git a/web/test/canvas-batch-table.test.ts b/web/test/canvas-batch-table.test.ts index 8331eb070..dccf3e451 100644 --- a/web/test/canvas-batch-table.test.ts +++ b/web/test/canvas-batch-table.test.ts @@ -1,101 +1,95 @@ -import { describe, expect, test } from "bun:test"; - -import { - batchPromptForRow, - batchReferenceColumns, - batchReferenceHandleAtY, - batchReferenceHandleId, - batchReferenceMentionToken, - createBatchRowsFromColumns, - createBatchRowsFromInputs, - createInheritedBatchRow, - moveBatchReferenceCell, - promoteLegacyBatchTableSize, - removeLastBatchReferenceColumn, - reorderBatchReferenceColumns, - BATCH_REFERENCE_HANDLE_GAP, - BATCH_REFERENCE_HANDLE_TOP, - TRY_ON_BATCH_PROMPT, -} from "@/lib/canvas/canvas-batch-table"; -import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; -import { CanvasNodeType } from "@/types/canvas"; - -describe("batch creation table", () => { - test("creates one try-on row per person with the final image as the shared garment", () => { - const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]); - - expect(rows.map((row) => row.inputNodeIds)).toEqual([ - ["person-1", "garment"], - ["person-2", "garment"], - ["person-3", "garment"], - ]); - expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true); - }); - - test("creates one creative row per connected image", () => { - expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]); - }); - - test("broadcasts a singleton reference column across rows", () => { - expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([ - ["person-1", "garment"], - ["person-2", "garment"], - ]); - }); - - test("inherits the previous row references when adding a manual task", () => { - const row = createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]); - - expect(row.inputNodeIds).toEqual(["person-1", "garment"]); - expect(row.inputNodeIds).not.toBe(createInheritedBatchRow("try_on", [{ id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "自定义提示词" }]).inputNodeIds); - expect(row.prompt).toBe(TRY_ON_BATCH_PROMPT); - }); - - test("syncing connections preserves unmatched manual rows", () => { - const manual = { id: "manual-row", enabled: false, inputNodeIds: ["manual-image"], prompt: "保留手工任务" }; - const rows = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [manual]); - - expect(rows).toHaveLength(2); - expect(rows[1]).toEqual(manual); - }); - - test("syncing unchanged references keeps the row identity and linked output", () => { - const existing = { id: "row-1", enabled: true, inputNodeIds: ["person-1", "garment"], prompt: "保留提示词", outputNodeId: "output-1" }; - const [row] = createBatchRowsFromColumns("try_on", [["person-1"], ["garment"]], [existing]); - - expect(row).toEqual(existing); - }); - - test("uses stable prompt mention tokens for positional references", () => { - expect(batchReferenceMentionToken(0)).toBe("@参考图1"); - expect(batchReferenceMentionToken(1)).toBe("@参考图2"); - }); - - test("new canvas node has durable batch defaults", () => { +import { describe, expect, test } from "bun:test"; + +import { batchGridTemplateColumns, batchPromptForRow, batchReferenceHandleAtY, batchReferenceHandleId, batchTextHandleId, batchTextHandleTop, createBatchRowsFromColumns, createBatchRowsFromInputs, CREATIVE_BATCH_PROMPT, moveBatchReferenceCell, reorderBatchReferenceColumns, TRY_ON_BATCH_PROMPT } from "@/lib/canvas/canvas-batch-table"; +import { createCanvasNode } from "@/lib/canvas/canvas-project-domain"; +import { CanvasNodeType } from "@/types/canvas"; + +describe("batch creation table", () => { + test("creates one try-on row per person with the final image as the shared garment", () => { + const rows = createBatchRowsFromInputs("try_on", ["person-1", "person-2", "person-3", "garment"]); + + expect(rows.map((row) => row.inputNodeIds)).toEqual([ + ["person-1", "garment"], + ["person-2", "garment"], + ["person-3", "garment"], + ]); + expect(rows.every((row) => row.prompt === TRY_ON_BATCH_PROMPT)).toBe(true); + }); + + test("creates one creative row per connected image", () => { + expect(createBatchRowsFromInputs("creative", ["a", "b"]).map((row) => row.inputNodeIds)).toEqual([["a"], ["b"]]); + }); + + test("broadcasts a singleton reference column across rows", () => { + expect(createBatchRowsFromColumns("try_on", [["person-1", "person-2"], ["garment"]]).map((row) => row.inputNodeIds)).toEqual([ + ["person-1", "garment"], + ["person-2", "garment"], + ]); + }); + + test("new canvas node has durable batch defaults", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + + expect(node.title).toBe("批量创作表"); + expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] }); + }); + test("keeps the third reference handle addressable", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + + expect(batchReferenceHandleAtY(node, node.position.y + 112 + 2 * 38)).toBe(batchReferenceHandleId("reference-3")); + }); + test("keeps the second reference handle addressable", () => { + const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); + const referenceColumns = [ + { id: "reference-1", label: "参考图 1" }, + { id: "reference-2", label: "参考图 2" }, + ]; + node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } }; + + expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38)).toBe(batchReferenceHandleId("reference-2")); + }); + + test("chooses the nearest reference handle when magnetic hit areas overlap", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - expect(node.title).toBe("批量创作表"); - expect(node.width).toBe(1280); - expect(node.height).toBe(560); - expect(node.metadata?.batchTable).toEqual({ operation: "try_on", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }], rows: [] }); + expect(batchReferenceHandleAtY(node, node.position.y + 112 + 38, 90)).toBe(batchReferenceHandleId("reference-2")); }); - test("promotes the previous 900-wide default without touching resized tables", () => { + test("keeps text handles separated from reference controls", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - const legacy = { ...node, width: 900, height: 520 }; - const resized = { ...node, width: 1100, height: 480 }; + node.metadata = { ...node.metadata, batchTable: { ...node.metadata?.batchTable!, textColumns: [{ id: "text-1", label: "文字 1" }] } }; - expect(promoteLegacyBatchTableSize(legacy)).toEqual({ ...legacy, width: 1280, height: 560 }); - expect(promoteLegacyBatchTableSize(resized)).toBe(resized); + expect(batchReferenceHandleAtY(node, node.position.y + batchTextHandleTop(3))).toBe(batchTextHandleId("text-1")); + expect(batchTextHandleTop(3)).toBeGreaterThan(112 + 3 * 38); }); - test("keeps a single explicitly stored reference column", () => { + + test("global prompt overrides a row prompt without changing the row data", () => { const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - node.metadata = { ...node.metadata, batchTable: { operation: "creative", concurrency: 10, referenceColumns: [{ id: "reference-1", label: "参考图 1" }], rows: [] } }; + const table = node.metadata?.batchTable!; + const row = createBatchRowsFromInputs("creative", ["image-1"])[0]; - expect(batchReferenceColumns(node.metadata.batchTable).map((column) => column.id)).toEqual(["reference-1"]); + expect(batchPromptForRow(table, row)).toBe(row.prompt); + expect(batchPromptForRow({ ...table, globalPrompt: "统一风格:高级商业摄影" }, row)).toBe("统一风格:高级商业摄影"); + expect(row.prompt).toBe(CREATIVE_BATCH_PROMPT); }); - test("removes the last reference column down to one and trims row inputs", () => { + test("legacy rows without enabled stay enabled by default", () => { + const row = createBatchRowsFromInputs("creative", ["image-1"])[0]; + expect(row.enabled).toBe(true); + expect({ ...row, enabled: undefined }.enabled === false).toBe(false); + }); + + test("keeps the table grid valid when no text columns exist", () => { + const withoutText = batchGridTemplateColumns(3, 0); + const withText = batchGridTemplateColumns(3, 1); + + expect(withoutText).not.toContain("repeat(0,"); + expect(withoutText).toContain("68px"); + expect(withoutText).toContain("repeat(3, 72px)"); + expect(withText).toContain("repeat(1, minmax(168px, 0.75fr))"); + }); + + test("reorders reference columns and keeps row materials aligned", () => { const table = { operation: "creative" as const, concurrency: 10, @@ -104,49 +98,16 @@ describe("batch creation table", () => { { id: "reference-2", label: "参考图 2" }, { id: "reference-3", label: "参考图 3" }, ], - rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }], + rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "" }], }; + const reordered = reorderBatchReferenceColumns(table, "reference-1", "reference-3"); - const next = removeLastBatchReferenceColumn(table); - expect(next?.referenceColumns?.map((column) => column.id)).toEqual(["reference-1", "reference-2"]); - expect(next?.rows[0].inputNodeIds).toEqual(["a", "b"]); - expect(removeLastBatchReferenceColumn({ ...table, referenceColumns: [{ id: "reference-1", label: "参考图 1" }] })).toBeNull(); + expect(reordered.referenceColumns?.map((column) => column.label)).toEqual(["参考图 1", "参考图 2", "参考图 3"]); + expect(reordered.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]); + expect(reordered.rows[0].inputNodeIds).toEqual(["b", "c", "a"]); }); - test("keeps the second reference handle addressable", () => { - const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - const referenceColumns = [ - { id: "reference-1", label: "参考图 1" }, - { id: "reference-2", label: "参考图 2" }, - ]; - node.metadata = { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, referenceColumns, rows: [] } }; - - expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP)).toBe(batchReferenceHandleId("reference-2")); - }); - - test("chooses the nearest reference handle when magnetic hit areas overlap", () => { - const node = createCanvasNode(CanvasNodeType.BatchTable, { x: 500, y: 300 }); - - expect(batchReferenceHandleAtY(node, node.position.y + BATCH_REFERENCE_HANDLE_TOP + BATCH_REFERENCE_HANDLE_GAP, 90)).toBe(batchReferenceHandleId("reference-2")); - }); - - test("reorders reference columns and keeps row inputs aligned", () => { - const table = { - operation: "try_on" as const, - concurrency: 10, - referenceColumns: [ - { id: "reference-1", label: "参考图 1" }, - { id: "reference-2", label: "参考图 2" }, - { id: "reference-3", label: "参考图 3" }, - ], - rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b", "c"], prompt: "x" }], - }; - const next = reorderBatchReferenceColumns(table, "reference-1", "reference-3"); - expect(next.referenceColumns?.map((column) => column.id)).toEqual(["reference-2", "reference-3", "reference-1"]); - expect(next.rows[0].inputNodeIds).toEqual(["b", "c", "a"]); - }); - - test("swaps reference cells across rows and columns", () => { + test("moves a reference image into an empty slot across rows", () => { const table = { operation: "creative" as const, concurrency: 10, @@ -155,18 +116,26 @@ describe("batch creation table", () => { { id: "reference-2", label: "参考图 2" }, ], rows: [ - { id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "one" }, - { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "two" }, + { id: "row-1", enabled: true, inputNodeIds: ["a", ""], prompt: "" }, + { id: "row-2", enabled: true, inputNodeIds: ["b", ""], prompt: "" }, ], }; - const next = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); - expect(next.rows[0].inputNodeIds).toEqual(["d", "b"]); - expect(next.rows[1].inputNodeIds).toEqual(["c", "a"]); + + const moved = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); + + expect(moved.rows.map((row) => row.inputNodeIds)).toEqual([["", ""], ["b", "a"]]); }); - test("uses a non-empty global prompt instead of the row prompt", () => { - const table = { operation: "creative" as const, concurrency: 10, globalPrompt: " 全局覆盖 ", rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a"], prompt: "行提示词" }] }; - expect(batchPromptForRow(table, table.rows[0])).toBe("全局覆盖"); - expect(batchPromptForRow({ ...table, globalPrompt: " " }, table.rows[0])).toBe("行提示词"); + test("swaps reference images when the target slot is occupied", () => { + const table = { + operation: "creative" as const, + concurrency: 10, + referenceColumns: [{ id: "reference-1", label: "参考图 1" }, { id: "reference-2", label: "参考图 2" }], + rows: [{ id: "row-1", enabled: true, inputNodeIds: ["a", "b"], prompt: "" }, { id: "row-2", enabled: true, inputNodeIds: ["c", "d"], prompt: "" }], + }; + + const swapped = moveBatchReferenceCell(table, "row-1", 0, "row-2", 1); + + expect(swapped.rows.map((row) => row.inputNodeIds)).toEqual([["d", "b"], ["c", "a"]]); }); }); From cce2d2d399e540110e0a4f0adc477087a9b9414e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 18:41:34 +0800 Subject: [PATCH 13/20] fix: preserve legacy migration lineage --- backend/internal/database/migrations.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 8f91bb050..6ab630a9e 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 27 +const CurrentSchemaVersion int64 = 28 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -89,10 +89,11 @@ var schemaMigrations = []migration{ {version: 23, name: "canvas_revision_history", checksum: "sha256:canvas-revision-history-v23-20260918", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}) }}, - {version: 24, name: "channel_model_label", checksum: "sha256:channel-model-label-v24", apply: migrateChannelModelLabel}, + {version: 24, name: "channel_model_description", checksum: "sha256:channel-model-description-v24-20260918", apply: migrateChannelModelDescription}, {version: 25, name: "video_token_formula_snapshot", checksum: "sha256:video-token-formula-snapshot-v25", apply: migrateVideoTokenFormulaSnapshot}, {version: 26, name: "channel_model_description", checksum: "sha256:channel-model-description-v26", apply: migrateChannelModelDescription}, {version: 27, name: "channel_credit_cost", checksum: "sha256:channel-credit-cost-v27", apply: migrateChannelCreditCost}, + {version: 28, name: "channel_model_label", checksum: "sha256:channel-model-label-v28", apply: migrateChannelModelLabel}, } func migrateChannelCreditCost(tx *gorm.DB) error { From 3f5b0bd34712d6ab098b46f12e3e1a1aa9991a2a Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 20:04:26 +0800 Subject: [PATCH 14/20] fix: route list mode through multimodal text model --- web/src/lib/model-selection.ts | 6 +++++- web/src/pages/canvas/list-mode-generator.ts | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/web/src/lib/model-selection.ts b/web/src/lib/model-selection.ts index 729b744b4..112c81ce8 100644 --- a/web/src/lib/model-selection.ts +++ b/web/src/lib/model-selection.ts @@ -95,7 +95,11 @@ export function modelCompatibilityError(config: AiConfig, model: string, require if (!input) return ""; if (capability === "text") { - return input.audioCount > 0 ? "文本模型不支持参考音频" : ""; + const text = modelCapabilityConfigFor(config, model).text; + if (input.imageCount > (text?.references.maxImages ?? 0)) return `最多支持 ${(text?.references.maxImages ?? 0)} 张参考图`; + if (input.videoCount > (text?.references.maxVideos ?? 0)) return `最多支持 ${(text?.references.maxVideos ?? 0)} 个参考视频`; + if (input.audioCount > 0) return "文本模型不支持参考音频"; + return ""; } if (input.characterCount > 1) return "角色配音一次只能引用一个角色卡"; diff --git a/web/src/pages/canvas/list-mode-generator.ts b/web/src/pages/canvas/list-mode-generator.ts index 2b7d79c2d..e6e31cc9a 100644 --- a/web/src/pages/canvas/list-mode-generator.ts +++ b/web/src/pages/canvas/list-mode-generator.ts @@ -2,8 +2,8 @@ import { nanoid } from "nanoid"; import { message } from "antd"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasBatchTableData, type CanvasBatchRow } from "@/types/canvas"; import { runBackendGenerationTask } from "@/services/api/generation-task"; -import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation"; -import { modelRequestOptions, type ModelRequirements } from "@/lib/model-selection"; +import { buildGenerationConfig, resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; +import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import type { AiConfig } from "@/stores/use-config-store"; const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你多张产品图片和一个任务描述。 @@ -145,14 +145,22 @@ export async function handleListGenerate({ setRunningNodeId(sourceNodeId); try { - // Ask model selection for a text model that can receive all connected images. - const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: undefined } }; + // 列表模式必须沿用文本节点当前选中的模型。此前这里清空 node.metadata.model, + // 会退回全局 textModel;当全局模型是普通文本模型时,界面虽显示 Gemini, + // 实际请求却会发给不支持图片的旧模型。 const requirements: ModelRequirements = { capability: "text", input: { textCount: 1, imageCount: imageNodes.length, videoCount: 0, audioCount: 0, characterCount: 0 }, options: modelRequestOptions(config, "text"), }; - const listConfig = buildGenerationConfig(config, sourceNodeForConfig, "text", requirements); + const preferredModel = resolveCanvasGenerationModel(config, sourceNode.metadata?.model, "text") || resolveCanvasGenerationModel(config, config.textModel, "text"); + if (!preferredModel) throw new Error("当前没有可用的文本模型,请先在模型设置中选择模型"); + const compatibleModel = resolveCompatibleModel(config, preferredModel, requirements); + if (!compatibleModel || modelCompatibilityError(config, compatibleModel, requirements)) { + throw new Error("当前文本模型不支持图片理解,请在节点底部选择支持图片输入的 Gemini 或其他多模态模型"); + } + const sourceNodeForConfig = { ...sourceNode, metadata: { ...(sourceNode.metadata || {}), model: compatibleModel } }; + const listConfig = { ...buildGenerationConfig(config, sourceNodeForConfig, "text", requirements), model: compatibleModel }; const referenceImages = imageNodes.map((node) => ({ id: node.id, From 5843d489c68fde0c9f060f38f3ddd0415628ed35 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 20:25:53 +0800 Subject: [PATCH 15/20] fix: restore list mode entry in text composer --- .../canvas/canvas-node-prompt-panel.tsx | 48 +++++++++++++------ web/src/pages/canvas/project.tsx | 20 ++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 60ac2acee..4edf203ce 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -1,7 +1,7 @@ import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; -import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; +import { ArrowLeftRight, ArrowUp, AtSign, Boxes, Camera, ChevronDown, FileText, GripVertical, ImageIcon, ImagePlus, LayoutList, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; @@ -51,6 +51,7 @@ type CanvasNodePromptPanelProps = { onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; onImageSettingsOpenChange?: (open: boolean) => void; workspaceMode?: CanvasWorkspaceMode; + onListGenerate?: (nodeId: string, prompt: string) => void; }; type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; @@ -69,7 +70,7 @@ const PROMPT_EDITOR_MODAL_WIDTH = "min(1200px, 92vw)"; const PROMPT_EDITOR_MODAL_DEFAULT_WIDTH = 1200; const PROMPT_EDITOR_MODAL_DEFAULT_HEIGHT = 420; -export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { +export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onAddReference, onRemoveReference, onReorderReferences, onReplaceReference, onReplaceReferenceFiles, onClose, onNodeMouseDown, onImageSettingsOpenChange, workspaceMode = "professional", onListGenerate }: CanvasNodePromptPanelProps) { const globalConfig = useEffectiveConfig(); const themeName = useActiveTheme(); const theme = canvasThemes[themeName]; @@ -256,7 +257,8 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan const submit = () => { const text = prompt.trim(); if (!text || isRunning) return false; - onGenerate(node.id, mode, text); + if (mode === "text" && node.metadata?.listMode && onListGenerate) onListGenerate(node.id, text); + else onGenerate(node.id, mode, text); return true; }; @@ -411,17 +413,35 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan compact={!expanded} /> {mode === "text" ? ( - - onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} - aria-label="文本生成份数" - className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" - /> - + <> +
+ + +
+ {!node.metadata?.listMode ? ( + + + onConfigChange(node.id, { + textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value) || 1)))), + }) + } + aria-label="文本生成份数" + className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" + /> + + ) : 行数和列结构由模型判断} + ) : mode === "image" ? ( // 图片模式下,显示相机配置与镜头配置 <> diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 056630207..86ade9442 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -60,6 +60,7 @@ import { CanvasPanoramaConfigModal } from "@/components/canvas/canvas-panorama-c import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; import { Minimap } from "@/components/canvas/canvas-mini-map"; import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; +import { handleListGenerate } from "./list-mode-generator"; import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"; import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands"; import { AssetPickerModal } from "@/components/canvas/asset-picker-modal"; @@ -2118,11 +2119,26 @@ function InfiniteCanvasPage() { setNodeImageSettingsOpen(open); if (open) setToolbarNodeId(null); }} + onListGenerate={(nodeId, listPrompt) => { + void handleListGenerate({ + sourceNodeId: nodeId, + prompt: listPrompt, + nodes: nodesRef.current, + connections: connectionsRef.current, + config: effectiveConfig, + projectId, + setNodes, + setConnections, + setRunningNodeId, + setDialogNodeId, + }); + }} /> ); }, [ configInputsById, + effectiveConfig, handleConfigNodeChange, handleGenerateNode, handleNodePromptChange, @@ -2134,6 +2150,10 @@ function InfiniteCanvasPage() { message, projectId, runningNodeId, + setConnections, + setDialogNodeId, + setNodes, + setRunningNodeId, skillMentionReferences, workspaceMode, ], From 10b85962b60848a36280ea4fcbc904b3f69e90a6 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 20:40:55 +0800 Subject: [PATCH 16/20] fix: recognize common multimodal text models --- backend/internal/app/model_capability.go | 21 ++++++++++++++++-- backend/internal/app/model_capability_test.go | 17 ++++++++++++++ web/src/lib/model-capabilities.ts | 22 ++++++++++++++++--- web/test/model-capabilities.test.ts | 13 +++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/backend/internal/app/model_capability.go b/backend/internal/app/model_capability.go index 240ab5699..1a05e0ebb 100644 --- a/backend/internal/app/model_capability.go +++ b/backend/internal/app/model_capability.go @@ -207,9 +207,13 @@ func legacyImageSizeValues() []string { } func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { - // 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。 streaming := true - text := &TextCapabilityConfig{Streaming: &streaming, References: TextReferenceConfig{PromptMaxChars: 32000}} + textReferences := TextReferenceConfig{PromptMaxChars: 32000} + if isKnownMultimodalTextModel(protocol, modelName) { + textReferences.MaxImages = 16 + textReferences.MaxImageBytes = 30 * 1024 * 1024 + } + text := &TextCapabilityConfig{Streaming: &streaming, References: textReferences} video := &VideoCapabilityConfig{ References: VideoReferenceConfig{PromptMaxChars: DefaultVideoPromptMaxChars, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, @@ -275,6 +279,11 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video} } +func isKnownMultimodalTextModel(protocol string, modelName string) bool { + model := strings.ToLower(strings.TrimSpace(modelName)) + return strings.Contains(model, "gemini") || strings.Contains(model, "gpt") || strings.Contains(model, "doubao") || strings.Contains(model, "豆包") || strings.EqualFold(strings.TrimSpace(protocol), "gemini") +} + func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) { if strings.TrimSpace(raw) == "" { return nil, nil @@ -323,6 +332,14 @@ func NormalizeModelCapabilityConfigForModel(capability string, protocol string, return nil, BadAuthRequest("请配置文本模型能力参数") } text := *input.Text + if isKnownMultimodalTextModel(protocol, modelName) && text.References.MaxImages == 0 { + // 旧配置是在文本模型默认不接收图片时生成的;Gemini 文本模型 + // 通过兼容协议仍然会接收图片 parts,因此自动修复旧的 0 上限。 + text.References.MaxImages = 16 + if text.References.MaxImageBytes == 0 { + text.References.MaxImageBytes = 30 * 1024 * 1024 + } + } if text.Streaming == nil { streaming := true text.Streaming = &streaming diff --git a/backend/internal/app/model_capability_test.go b/backend/internal/app/model_capability_test.go index 2028dc406..8e15472b8 100644 --- a/backend/internal/app/model_capability_test.go +++ b/backend/internal/app/model_capability_test.go @@ -6,6 +6,23 @@ import ( "testing" ) +func TestDefaultModelCapabilityConfigRecognizesKnownTextVisionFamilies(t *testing.T) { + gemini := DefaultModelCapabilityConfigForModel("chat-completion", "gemini-3.8-flash-high") + gpt := DefaultModelCapabilityConfigForModel("chat-completion", "gpt-4o") + doubao := DefaultModelCapabilityConfigForModel("chat-completion", "doubao-seed-1.6-flash") + plain := DefaultModelCapabilityConfigForModel("chat-completion", "plain-text-model") + + if gemini.Text == nil || gemini.Text.References.MaxImages != 16 || gemini.Text.References.MaxImageBytes != 30*1024*1024 { + t.Fatalf("Gemini text vision defaults = %#v", gemini.Text) + } + if gpt.Text == nil || gpt.Text.References.MaxImages != 16 || doubao.Text == nil || doubao.Text.References.MaxImages != 16 { + t.Fatalf("known multimodal text vision defaults: gpt=%#v doubao=%#v", gpt.Text, doubao.Text) + } + if plain.Text == nil || plain.Text.References.MaxImages != 0 { + t.Fatalf("plain text vision defaults = %#v", plain.Text) + } +} + func TestValidateImageTaskRejectsOversizedGrokPromptByUTF8Bytes(t *testing.T) { prompt := strings.Repeat("中", 4001) input := canvasGenerationInput{ diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 3f2e93493..658a3d685 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -105,6 +105,14 @@ export const STANDARD_IMAGE_SIZE_VALUES = [ "1024x1536", ] as const; +const DEFAULT_TEXT_REFERENCE_MAX_IMAGES = 16; + +function isKnownMultimodalTextModel(protocol?: ModelProtocol, model = "") { + // These model families currently expose vision through their text/chat + // endpoint, including when they are wrapped by an OpenAI-compatible gateway. + return /gemini|gpt|doubao|豆包/i.test(model) || protocol === "gemini"; +} + export function normalizeCapabilityString(value: string) { const normalized = value.trim(); return normalized.startsWith("string:") ? normalized.slice("string:".length) : normalized; @@ -286,8 +294,13 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = " export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = ""): ModelCapabilityConfig { const text: TextCapabilityConfig = { streaming: true, - // 文本模型的视觉能力必须由管理员明确开启,不能根据模型名猜测。 - references: { promptMaxChars: 32000, maxImages: 0, maxImageBytes: 0, maxVideos: 0, maxVideoBytes: 0 }, + references: { + promptMaxChars: 32000, + maxImages: isKnownMultimodalTextModel(protocol, model) ? DEFAULT_TEXT_REFERENCE_MAX_IMAGES : 0, + maxImageBytes: isKnownMultimodalTextModel(protocol, model) ? 30 * 1024 * 1024 : 0, + maxVideos: 0, + maxVideoBytes: 0, + }, }; const video: VideoCapabilityConfig = { references: { @@ -398,7 +411,10 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName); if (!cost?.capabilityConfig) return fallback; const capabilityConfig = normalizeModelCapabilityConfig(cost.capabilityConfig); - const text = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text; + const mergedText = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text; + const text = mergedText && isKnownMultimodalTextModel(cost?.protocol, modelName) && mergedText.references.maxImages === 0 + ? { ...mergedText, references: { ...mergedText.references, maxImages: DEFAULT_TEXT_REFERENCE_MAX_IMAGES, maxImageBytes: mergedText.references.maxImageBytes || 30 * 1024 * 1024 } } + : mergedText; const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video; const configuredImage = capabilityConfig.image; const image = configuredImage diff --git a/web/test/model-capabilities.test.ts b/web/test/model-capabilities.test.ts index d320615ca..ba4bfe768 100644 --- a/web/test/model-capabilities.test.ts +++ b/web/test/model-capabilities.test.ts @@ -31,3 +31,16 @@ test("raising the video default leaves text and image limits untouched", () => { assert.equal(profile.text!.references.promptMaxChars, 32000); assert.equal(profile.image!.references.promptMaxChars, 32000); }); + +test("known multimodal text model families default to image input", () => { + const gemini = defaultModelCapabilityConfig("chat-completion", "gemini-3.8-flash-high").text!; + const gpt = defaultModelCapabilityConfig("chat-completion", "gpt-4o").text!; + const doubao = defaultModelCapabilityConfig("chat-completion", "doubao-seed-1.6-flash").text!; + const plain = defaultModelCapabilityConfig("chat-completion", "plain-text-model").text!; + + assert.equal(gemini.references.maxImages, 16); + assert.equal(gemini.references.maxImageBytes, 30 * 1024 * 1024); + assert.equal(gpt.references.maxImages, 16); + assert.equal(doubao.references.maxImages, 16); + assert.equal(plain.references.maxImages, 0); +}); From 0e61fb00ab0a5d83de597872a4408c76f983a3d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 20:51:55 +0800 Subject: [PATCH 17/20] fix: honor requested list rows and preserve ai table data --- web/src/pages/canvas/list-mode-generator.ts | 111 +++++++++++++----- .../pages/canvas/use-canvas-batch-table.ts | 11 +- web/test/list-mode-generator.test.ts | 8 +- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/web/src/pages/canvas/list-mode-generator.ts b/web/src/pages/canvas/list-mode-generator.ts index e6e31cc9a..d038abcb6 100644 --- a/web/src/pages/canvas/list-mode-generator.ts +++ b/web/src/pages/canvas/list-mode-generator.ts @@ -6,8 +6,8 @@ import { buildGenerationConfig, resolveCanvasGenerationModel } from "@/lib/canva import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import type { AiConfig } from "@/stores/use-config-store"; -const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你多张产品图片和一个任务描述。 -请分析每张图片,为每张图片生成一行数据。 +const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户会给你产品图片和一个任务描述。 +请严格根据用户要求的数量生成数据;如果用户说“生成5个卖点”,就必须返回5行,每行一个独立卖点。 输出严格为以下 JSON 格式(不要输出其他内容): { @@ -17,18 +17,37 @@ const LIST_MODE_SYSTEM_PROMPT = `你是一个电商内容分析助手。用户 ] } -规则: -1. 第一列不需要输出(它是图片本身),从第二列开始定义分析维度 -2. 列名根据任务需求自动决定(如:核心卖点、视觉细节、文案标题、搭配建议等) -3. 每张图片对应一行,rows 数量等于图片数量 -4. 每个单元格内容简洁有力,适合电商/社交媒体使用 -5. 列数建议 3-6 列,根据任务复杂度决定 -6. 只输出 JSON,不要有任何其他文字`; +规则: +1. 第一列不需要输出(它是图片本身),从第二列开始定义分析维度 +2. 列名根据任务需求自动决定(如:核心卖点、视觉细节、文案标题、搭配建议等) +3. 用户明确要求数量时,rows 数量必须严格等于目标数量;一行只表达一个独立任务或卖点 +4. 用户没有明确数量时,多张图片默认每张图片一行 +5. 每个单元格内容简洁有力,适合电商/社交媒体使用 +6. 不要把多个编号卖点塞进一个单元格;不要把整段分析只放进“任务提示词” +7. 列数建议 3-6 列,根据任务复杂度决定 +8. 只输出 JSON,不要有任何其他文字`; -type ListGenerationResult = { - columns: string[]; - rows: Record[]; -}; +type ListGenerationResult = { + columns: string[]; + rows: Record[]; +}; + +export function requestedListRowCount(prompt: string): number | undefined { + const match = prompt.match(/(?:生成|写|列出|提供|给我|帮我)?[^\n]{0,12}?(\d+|[零〇一二两三四五六七八九十百]+)\s*(?:个|条|项|行|款|种)(?:卖点|标题|文案|内容|任务|要点)?/i); + if (!match) return undefined; + const value = /^\d+$/.test(match[1]) ? Number(match[1]) : chineseNumber(match[1]); + return Number.isInteger(value) && value > 0 ? Math.min(value, 100) : undefined; +} + +function chineseNumber(value: string) { + const digits: Record = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 }; + if (value === "十") return 10; + if (value.startsWith("十")) return 10 + (digits[value[1]] || 0); + if (value.length === 2 && value[1] === "十") return (digits[value[0]] || 0) * 10; + if (value.length === 3 && value[1] === "十") return (digits[value[0]] || 0) * 10 + (digits[value[2]] || 0); + if (value.includes("百")) return (digits[value[0]] || 0) * 100 + (value[2] ? (digits[value[2]] || 0) * 10 : 0) + (value[3] ? digits[value[3]] || 0 : 0); + return value.split("").reduce((total, digit) => total * 10 + (digits[digit] ?? 0), 0); +} export function parseListModeJson(text: string): ListGenerationResult | null { const candidates = [ @@ -102,6 +121,38 @@ function listCellText(value: unknown): string { if (typeof value === "object") return JSON.stringify(value); return String(value); } + +function normalizedCellKey(value: string) { + return value.trim().toLocaleLowerCase().replace(/[\s\-_::,,。.!!??()()【】\[\]]/g, ""); +} + +function rowCellValue(row: Record, columnName: string, columnIndex: number) { + if (row[columnName]) return row[columnName]; + const target = normalizedCellKey(columnName); + const matchedKey = Object.keys(row).find((key) => normalizedCellKey(key) === target || normalizedCellKey(key).includes(target) || target.includes(normalizedCellKey(key))); + if (matchedKey && row[matchedKey]) return row[matchedKey]; + return Object.values(row)[columnIndex] || ""; +} + +function splitNumberedText(value: string) { + return value + .split(/\n+/) + .flatMap((line) => line.split(/(?=(?:^|\s)(?:\d{1,3}[.、))]|[一二三四五六七八九十百]+[、.))]))/)) + .map((item) => item.trim().replace(/^(?:\d{1,3}[.、))]|[一二三四五六七八九十百]+[、.))])\s*/, "")) + .filter(Boolean); +} + +function normalizeRequestedRows(parsed: ListGenerationResult, targetCount?: number) { + if (!targetCount || parsed.rows.length >= targetCount) return targetCount ? parsed.rows.slice(0, targetCount) : parsed.rows; + const firstColumn = parsed.columns[0]; + const expanded = parsed.rows.flatMap((row) => { + const first = rowCellValue(row, firstColumn, 0); + const parts = splitNumberedText(first); + if (parts.length <= 1) return [row]; + return parts.map((part) => ({ ...row, [firstColumn]: part })); + }); + return expanded.slice(0, targetCount); +} type HandleListGenerateOptions = { sourceNodeId: string; @@ -142,9 +193,10 @@ export async function handleListGenerate({ return; } - setRunningNodeId(sourceNodeId); - - try { + setRunningNodeId(sourceNodeId); + + try { + const targetRowCount = requestedListRowCount(prompt); // 列表模式必须沿用文本节点当前选中的模型。此前这里清空 node.metadata.model, // 会退回全局 textModel;当全局模型是普通文本模型时,界面虽显示 Gemini, // 实际请求却会发给不支持图片的旧模型。 @@ -170,7 +222,7 @@ export async function handleListGenerate({ storageKey: node.metadata?.storageKey, })); - const fullPrompt = `${LIST_MODE_SYSTEM_PROMPT}\n\n用户任务:${prompt}\n\n图片数量:${imageNodes.length} 张`; + const fullPrompt = `${LIST_MODE_SYSTEM_PROMPT}\n\n用户任务:${prompt}\n\n图片数量:${imageNodes.length} 张\n目标行数:${targetRowCount || "未指定,按任务判断"}`; const result = await runBackendGenerationTask({ projectId, @@ -189,7 +241,12 @@ export async function handleListGenerate({ return; } - // Build batch table data + const rowsForTable = normalizeRequestedRows(parsed, targetRowCount); + if (targetRowCount && rowsForTable.length !== targetRowCount) { + throw new Error(`模型只返回了 ${rowsForTable.length} 行,未满足你要求的 ${targetRowCount} 行,请重试`); + } + + // Build batch table data const textColumns = parsed.columns.map((name, i) => ({ id: `ai-col-${i}`, label: name, @@ -198,15 +255,15 @@ export async function handleListGenerate({ const referenceColumn = { id: "ai-ref", label: "输入", type: "image" as const }; - const rows: CanvasBatchRow[] = parsed.rows.map((row, rowIndex) => { - const imageNode = imageNodes[rowIndex % imageNodes.length]; - const cells: Record = {}; - parsed.columns.forEach((colName, colIndex) => { - cells[`ai-col-${colIndex}`] = row[colName] || ""; - }); - // Build prompt from all text cells - const cellPrompts = parsed.columns.map((colName, colIndex) => { - const val = row[colName] || ""; + const rows: CanvasBatchRow[] = rowsForTable.map((row, rowIndex) => { + const imageNode = imageNodes[rowIndex % imageNodes.length]; + const cells: Record = {}; + parsed.columns.forEach((colName, colIndex) => { + cells[`ai-col-${colIndex}`] = rowCellValue(row, colName, colIndex); + }); + // Build prompt from all text cells + const cellPrompts = parsed.columns.map((colName, colIndex) => { + const val = cells[`ai-col-${colIndex}`]; return val ? `${colName}:${val}` : ""; }).filter(Boolean); diff --git a/web/src/pages/canvas/use-canvas-batch-table.ts b/web/src/pages/canvas/use-canvas-batch-table.ts index ef79ec187..52e4d07aa 100644 --- a/web/src/pages/canvas/use-canvas-batch-table.ts +++ b/web/src/pages/canvas/use-canvas-batch-table.ts @@ -87,10 +87,13 @@ export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setCon }, [message, nodesRef, patchTable]); const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => { - const node = nodesRef.current.find((item) => item.id === nodeId); - const table = node?.metadata?.batchTable; - if (!node || !table) return false; - const nodeById = new Map(nodesRef.current.map((item) => [item.id, item])); + const node = nodesRef.current.find((item) => item.id === nodeId); + const table = node?.metadata?.batchTable; + if (!node || !table) return false; + // AI 列表模式已经根据用户要求生成了独立行;参考图连线只负责 + // 提供素材,不能在保存/连线刷新时把 N 行重置成“每张图一行”。 + if (table.aiGenerated) return false; + const nodeById = new Map(nodesRef.current.map((item) => [item.id, item])); const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => { const input = nodeById.get(inputNodeId); return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey); diff --git a/web/test/list-mode-generator.test.ts b/web/test/list-mode-generator.test.ts index 42dc40840..d8fe96a77 100644 --- a/web/test/list-mode-generator.test.ts +++ b/web/test/list-mode-generator.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { parseListModeJson } from "@/pages/canvas/list-mode-generator"; +import { parseListModeJson, requestedListRowCount } from "@/pages/canvas/list-mode-generator"; + +test("extracts the requested row count from natural language", () => { + expect(requestedListRowCount("帮我生成5个卖点")).toBe(5); + expect(requestedListRowCount("请写十条小红书标题")).toBe(10); + expect(requestedListRowCount("分析这张图")).toBeUndefined(); +}); describe("list mode response parsing", () => { test("parses plain JSON", () => { From 7985dbec48174ef96e0ab89c350ea84ea84ab04a Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 19 Sep 2026 21:21:07 +0800 Subject: [PATCH 18/20] fix: connect batch generation settings dialog --- web/src/pages/canvas/project.tsx | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 86ade9442..cd73d49cc 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -69,6 +69,7 @@ import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls"; import { CanvasShareModal } from "@/components/canvas/canvas-share-modal"; import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node"; import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node"; +import { BatchGenerationSettingsDialog } from "@/components/canvas/batch-generation-settings-dialog"; import { batchReferenceColumns, promoteLegacyBatchTableSize } from "@/lib/canvas/canvas-batch-table"; import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout"; import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel"; @@ -1910,7 +1911,25 @@ function InfiniteCanvasPage() { handleGenerateNode, }); - const { addReferenceColumn: addBatchReferenceColumn, addRow: addBatchRow, fillRowsFromConnections, generateRows: generateBatchRows, moveReferenceCell: moveBatchReferenceCell, patchTable: patchBatchTable, removeReferenceColumn: removeBatchReferenceColumn, removeRow: removeBatchRow, reorderReferenceColumns: reorderBatchReferenceColumns, syncRowsFromConnections, updateRow: updateBatchRow } = useCanvasBatchTable({ + const { + addReferenceColumn: addBatchReferenceColumn, + addRow: addBatchRow, + batchGenDialogConfig, + batchGenDialogConcurrency, + batchGenDialogOpen, + batchGenDialogRowCount, + closeBatchGenDialog, + confirmBatchGenDialog, + fillRowsFromConnections, + generateRows: generateBatchRows, + moveReferenceCell: moveBatchReferenceCell, + patchTable: patchBatchTable, + removeReferenceColumn: removeBatchReferenceColumn, + removeRow: removeBatchRow, + reorderReferenceColumns: reorderBatchReferenceColumns, + syncRowsFromConnections, + updateRow: updateBatchRow, + } = useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, @@ -3225,6 +3244,15 @@ function InfiniteCanvasPage() { onConfirmClear={clearCanvas} /> + + Date: Sun, 20 Sep 2026 12:59:23 +0800 Subject: [PATCH 19/20] feat(canvas): support multimodal video storyboard tables --- backend/internal/app/logical_models_test.go | 8 + backend/internal/app/model_router.go | 9 +- .../canvas/canvas-batch-table-node.tsx | 17 +- web/src/lib/canvas/canvas-batch-storyboard.ts | 94 +++++++ web/src/lib/model-capabilities.ts | 9 +- web/src/pages/canvas/list-mode-generator.ts | 240 ++++++++++++++++-- web/src/pages/canvas/project.tsx | 59 ++++- .../pages/canvas/use-canvas-media-tools.ts | 6 +- web/src/types/canvas.ts | 20 +- web/test/list-mode-generator.test.ts | 8 +- 10 files changed, 425 insertions(+), 45 deletions(-) create mode 100644 web/src/lib/canvas/canvas-batch-storyboard.ts diff --git a/backend/internal/app/logical_models_test.go b/backend/internal/app/logical_models_test.go index a43483a78..fefd2bc59 100644 --- a/backend/internal/app/logical_models_test.go +++ b/backend/internal/app/logical_models_test.go @@ -112,6 +112,14 @@ func TestMatchCapabilityRoutesTextImagesOnlyToDeclaredProfiles(t *testing.T) { } } +func TestMatchCapabilityRoutesUndeclaredVideoToTextMultimodalProfile(t *testing.T) { + profile := CapabilitySpec{Version: 1, Capability: "text"} + intent := ModelRequestIntent{Capability: "text", Inputs: map[string]int{"video": 1}} + if match := MatchCapability(profile, intent); !match.Matched { + t.Fatalf("multimodal text profile should accept legacy undeclared video input: %#v", match) + } +} + func TestMatchCapabilityAcceptsWildcardOptionValues(t *testing.T) { spec := CapabilitySpec{ Version: 1, diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 0e0e047e4..78a386e0c 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -274,9 +274,12 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM } constraint, declared := spec.Inputs[inputType] if !declared { - // Multimodal text models may accept reference images even when the - // provider catalog does not explicitly declare the image input. - if count > 0 && !(normalizeCapability(spec.Capability) == "text" && inputType == "image") { + // Multimodal text models may accept reference images or videos even + // when the provider catalog does not explicitly declare the input. + // The concrete model capability still controls the frontend limits; + // this fallback keeps older catalog entries routable for known + // multimodal text endpoints. + if count > 0 && !(normalizeCapability(spec.Capability) == "text" && (inputType == "image" || inputType == "video")) { reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") } continue diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index a89700ea1..096e9e5dc 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Button, Input, Select, Switch, Tooltip } from "antd"; -import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload, X } from "lucide-react"; +import { Film, GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload, X } from "lucide-react"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { BATCH_REFERENCE_HANDLE_GAP, BATCH_REFERENCE_HANDLE_TOP, MAX_BATCH_REFERENCE_COLUMNS, MAX_BATCH_TEXT_COLUMNS, batchGridTemplateColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, batchTextColumns, batchTextHandleId, batchTextHandleTop } from "@/lib/canvas/canvas-batch-table"; @@ -19,6 +19,7 @@ type Props = { onUpdateRow: (rowId: string, patch: Partial) => void; onFillRows: () => void; onGenerate: (rowIds?: string[]) => void; + onCreateStoryboard?: () => void; onRetryItem: (batchId: string, itemId: string) => void; onAddReferenceColumn: () => void; onRemoveReferenceColumn?: () => void; @@ -41,7 +42,7 @@ const OPERATION_OPTIONS = [ type ReferenceCell = { rowId: string; columnIndex: number }; -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn: _onRemoveReferenceColumn, onFocusOutput: _onFocusOutput, onAddTextColumn = () => {}, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference = () => {}, onUploadReference, onRemoveReference = () => {}, onConnectStart, onConnectDrop, readOnly = false }: Props) { +export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onCreateStoryboard, onRetryItem, onAddReferenceColumn, onRemoveReferenceColumn: _onRemoveReferenceColumn, onFocusOutput: _onFocusOutput, onAddTextColumn = () => {}, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference = () => {}, onUploadReference, onRemoveReference = () => {}, onConnectStart, onConnectDrop, readOnly = false }: Props) { const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; const referenceColumns = batchReferenceColumns(table); const textColumns = batchTextColumns(table); @@ -49,9 +50,10 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t const hasGlobalPrompt = Boolean(globalPrompt.trim()); const nodeById = useMemo(() => new Map(nodes.map((item) => [item.id, item])), [nodes]); const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]); - const connectedImageCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); + const connectedMediaCount = useMemo(() => new Set(connections.filter((connection) => connection.toNodeId === node.id).map((connection) => connection.fromNodeId)).size, [connections, node.id]); const completed = table.rows.filter((row) => Boolean(row.outputNodeId && nodeById.get(row.outputNodeId)?.metadata?.content)).length; const isAiTable = Boolean(table.aiGenerated); + const isStoryboardTable = table.contentKind === "storyboard" && Boolean(table.storyboardRows?.length); const gridTemplateColumns = batchGridTemplateColumns(referenceColumns.length, textColumns.length); const [draggingColumnId, setDraggingColumnId] = useState(null); const [draggingCell, setDraggingCell] = useState(null); @@ -244,7 +246,7 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t aria-label="并发数" /> - {referenceColumns.length} 组参考 · 已连 {connectedImageCount} 张图 · 完成 {completed}/{table.rows.length} + {referenceColumns.length} 组参考 · 已连 {connectedMediaCount} 个素材 · 完成 {completed}/{table.rows.length} {!readOnly ? (
@@ -268,6 +270,11 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t + {isStoryboardTable && onCreateStoryboard ? ( + + ) : null} @@ -590,7 +597,7 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI onLostPointerCapture={onLostPointerCapture} onDragStart={(event) => event.preventDefault()} > - + {!readOnly ? 替换 : null} {!readOnly ? (