{skill.skillName}
+ {skill.version ? v{skill.version} : null} +{skill.description || "暂无技能说明"}
+x8*RVWI-#FAp7E*DDYHVyud3>ZQ?n4+fzs#IcwaNkd}YpG_wmp}fJ+OaTR@$oz;@ zw8svd-+|gp+?z$s!>8Tp^|*<}qchGd+SS$(ub1rBM{H#KI1*1!cy4a7I_#wTTb-h= z*O`~6A_G7DYWwtQx|=eBJLH_~qtygHC$FAFMwl$wt8a!wpZX2j#qk5%vWSBwIlkUoo)Th{X z?gQb#s)pz9Cto{>^c4;z$%p;MO`?Ogx1? duXCdIUE0 z0Wuzge~OBM*{JEcoL1o`6_CeDAX1I;OZnDzo9taaXe?UQ_JsHTGlm;g7aP5YDMkX> zT;;akKgPqfu$o1qd9jfireE+FY%MUo_i{p}V%a&B0re$I{O?nS®R(VTp(b;Y6I zspjXIl1GH1mkj}A&f6k)*FFAI<{Hddz;f)co`W=eN7(Nv0*Cy-zVm5p!*4My7dr1k z&^?3k@ZPp>n (58?1kVTxIbYU2cLqkgfPg4J zdX^heq^>xatOS=UOgk2saaIlT`X@}dxWN x0jZe z*v-#3W=j}TWUa^$<^$&L=?qQ}B9qg}Su+z^MQCP*wi?ZA1j8G6{dKonK_ -s6x3sj4 zesd)?Me5B|L~IRt?j3q!x>q=w*>f!{P$Y7%SK4O1lc} `MBBmjN(*`;6qK!*vQy$}c4KzT}0#EJaZ=Y79`?URP zN{wCnE#|AV#rQyN#Hl4e`?U!f$|yi%@e^jd1_86h`&kM8%Us;>UlHhJ{#0Z|m76l! zrEQjBF{B19wr>z>nGLE+s*i_J6c%ongKuMu1*4m;qh-^CA2xRI6>MQe_YGT|> !-wZ}VgzpbMnu`-PwuyKPQKb@C0RomrP{Yu&z;$LCZFu8n&2+KwCB^)DfB3C0u z`i~MwJFcH%ag{YCDhJl5A+udvJ)h?&b!yIY?yBK0^wm-G0Ka9e?8rR_g^OiQ-Ef>1 zE?Y|F=X>jHl=TkXFU;EB&7PGVJWjRCWm+Czp5o0=C@=2CFF<=jKML9 ;u|2|cmaypYZiVs4`_p2-OLsh?mi z(NXqDLonKGdkQO_cCtRn0P}>5jR1~MTdbbpjt?O`Ao-0b^TUbt1J0qMG1;JObv#&) zyf61B`A*z+su*}h2#?NKI{HephP0L*>Uvm~VXjNd?iGb@i0-aS&Q5<>`ZooB`&gXB zZd$MYy41uzZGn|cX=aEuR2pg_89{WH2om(QuN!&JSHn_?2mQ>PO|c@;5ck7QkFr*B z#HQR)AefK++h-h;ycv0jtlCDsJGH)WWQ+XIY)qpx`w(dH+to=8yQuV?ycrZ?V(bT; zq7Oz*mRymn{!kL4V&2ZOu)Y@qta1;k+fRtkkM1vf$E@e%OZ|<_ELY#06HL2XqGkxf z9Y5^f?^0kL5QIo48$Gxpu>{i+Z3plbyl(7H0^a#+4zKve1wl&=Rl{Y^4BHtP_+M;f z;o^Gi1#|72ebEIkg0oXTw%kTUDEAIKBcj;m-Y8f5-jisa* v6-E^%_*0{ z4Z{c)$VlG_@x8@7)uoO$DF!|GUrm+{zf$=Ke_ji#Ij8+%vU;2URHN x&2qV0 z8EC$)Uv1jspGLws_B%^|FxE||oWnA`@q0ABmL!O<7{5(zR+Z0HN7SS4sXog2vchV4 z(JwAhn;Uhl%XX%0CoQOUeha W3(BODN5j5F&(EBG+!@YngU-d)r0?m3I< z^0H}ujAKFYXr1|`LZVP1Mw?M-NlVAT>rz+I%>@67s;{sddh6#R$J@u84XX8TeC|27 z&6XoSqqw?JG5h2O7>X)6BU2YIKM4d2%Tiq(Myr21Lq%3jr8hQIN6N c!Tiv2?H|9D${RDc~;{h!s{@P>i{{WG=p#tzmN=B{omHm;5i z5ItR2T 9?~)^ zFmTu-8xxZVXcJ>bG`K x4^f zo=M85IXA+LJ8~{`7x|K1(fgZ=lkE`2$u?dC_ZAlK{%tJ>!S;Yj$?;Eo9+RdYaQcZW zqqZA!4D48637K>M9 Q`@VFrJF3k7P^?y z_c5xz_m-fyX!z|Q4Dz1I5gWwjuvO)i!f6u-NRAEW0oY`(oTnYGM+#Tev{eBGkJDXf z_}wLtZg!=KyOa6DH3W$D&yk^C48Wi}8`mK%wriemS50wMay(%sm IaumwFGQ*p>(ocrq;X+qkwl@A4%xT8^W>CHQqv+CLHXyL`H-OaG3Q8S?~d+>IHa zx4WI*{=2L(dQBY`2O@k_ZNDC|_MiBrh0gtLfIDsC-K78BgtFxuM%%-6eQ*8n+af=3 zOFU0p3M&UC6~TQ|7O)+o7_)Sh@qR1$u-pCb*92${4|918rwDVgIc8cYA5*zNL00e9 z@@V~>%Zz*}h=x#hmlto;h#)IUGdI9PMAFq9+Y-%T>pmBV6}X%(%Hq;wfn#D?f%ms( z?L=}gnBy3#)vUs=#6FmEh3drebPfV6>$zQ7w7f)$1s^C&queX`7hNV3Z)0?|j>fMm zrOP2jtu36ec=1V<`SxVw`HZiqr!hzcT;9XPjilu&HPOoJjCj`M?^o$}`+y~zm(!b+ z?W@$cYt&1X2RzcrvJ_^vdl%P(CSWI^+qd}EXx6YJJL56Prc;cF2AvDRRkB|5s!F>m zxdJC7zOd7KQMj2{59o3~cM*vGvs4l2nbgCa2g4I>`@T9xJ&8 {>wRdPSK8#6^k> bl;-cx1!plK1~(z(CNJ^wfQ_Yi;IN&k^i z Z5vIIH21~&T=izDyL_SmRyM6<_}$ZO-3Kf5k<&k^ zE9# mGt=ut12_4H-qO@lo*Du7cA^~z(sRbe>%GH#{P%h#Pcc9_%D*~gcN zv9-a1^T(}Xt!mKBRf_6dDE)^$8zMDUd&f@1T{uocvtnll$Q6QjMBF?))ND-mL5x`Z z^4lyix3T*;z?OweMQyROp9Wi{qLyE%#Qn8KyIE~2-N*apx?Re_x0tj@T${X{uI6po zb;AFld$*4mo#{6@c2J-~)@P^G21IEE_YvQSlS+0PO{vE7#gnaw* z%Q} PZO5K@SFNaU9lA#?j&s@Df;z?ok4@X|LOaEznp+By67x z22?AD@as9|7y7Ha4MKg8EedQymCobk01<&X#!ErJzI54^=8~SSS_FDvdSGG|=~jLT z(em;-4?$h~cn0fMZ9=eU+cx{lP9--uWDeQT39mOcsVdj*vG&`lo1)72#}G VQ(}S#dzcC9<&U>J{jL*&$F9cnR|959mJ*j~6|SRPomgzp$5$tBh80FK!QLY|kRRLB9MaP_t|tKiUu6N)8t zMf-N6X6}!GCHf%S%urpz<}tb`smg5!;CjK(cTrE C||Q)a%rJx_QE6@HviXK-bls*lf1WV76EiK}by30Hnvt|bKm zXq%sC^Q@~aWMiLJ=tzK5{biRZU=t|c3Efrf)v>epQl(DRU^-Fg1m}LYJhSaXlRdIh z*Q~xl3@9$ ~t zhtmqp 6mvukB9i)54XL&LG7Hr zzsvm&G;9wA@?uO0ev(dyfLDsQC`3;->94;j9Ny ;!pg67P zsAf@r;tcI?XgZSGJrm4GTwfr$*>?opTW#F-XsN46uW++8*i$JaHI~mCQ6Js ksMVcZ0dQI2zaapuB;YaLJMP)x+#UK=jqhZr*`MX+;mgfyr|DB7+l@lw_~|E zrsFT0A$P6x6xdd#r}gkqP~i>S-h K}$Lvi6{!@pFK-htJ38J&gRrF)IPG z1ZYQR5Q(J J@ ~d zOhz#-W(7;!g}sKfovJT#!PmQtxeX4RxhmS{+OQ2<$%qfKt?K+nNAOQD>>d4dLRJ=+ zbk-*g7taQj?7a}YDIJ)`Culi-=^V}2F^tnvTq@tkK?hjT6o>N;1g_!K)AdgEt@v4^ zpkLf*NQnIiZ4iNKpCyL;%9PPM8EEG=I4oSyneAhHz8f#j0@IRu-Y*w8H67Pm@2t?8 zApV)tN{%XN_nn~eWKB)1E+F=Z$y3H?Kk)UtrE!$5x1x6cwpNf8(H^}F?~TA8a=Ol( zZ_&L4Q3g=|hFs_>2cG%!C&D7#PYaHb=*xG&8!x&>#48xY-*=f^D^pj-dyV+Img_!+ z3gNW1xArRjk!Zx}y(~1V|L(cR$8Hc)!h^bRLz=11yY~Yf7QaHj4jCCb)MajZm5e(Q z5)Nr#?Mzj0km!pmggsTOQ=^umAuMMfu&V7)6bJ5FX6e7Ref8x_@~63r&esGF&esG% zQHf2Ns1kpnedb!|3J!x$*~C1@nI8q6kX^)(0|wg<$|DSlREJ;Kr+zhdTa~jgD~fHZ z^w(B4Ex6^w5V_l!7(-C+TZp7VqhCNwyk3k~6+LM%xhreP1$Jm4g;8l_Nf6=clIo&r zEFg51KoRc?hxoHH2;J6A!Ax@^LV1>UqHy1MV?Ix>^b8TFuQYDW10W z_|JZ*tlfkAW>L987#%lrGBKr|EIwZsk*bp#u8P?`lGnuR984o>?s2J21X(+xUqkmf z(Y-Qb2KF 7OuIm{T@&TRQt z-b4XhQ|IiO++Rcw2mQi>PP`@mV$BzX S5-r81m5ie zJsIZR47HA0Lj5WjExVMRhE_Yl8{N{X!+uBGycxSX%#%-plerV&XohM(^Z=BK9HaP{ zQ(F}#V=J%K1HP2C5v2-IEo}V=pDgAvWKlf)GbFgCCn+*pA2oy7zk*4JLM%{YHh#!$ z4}FxhmTTgAOVn9CZ4Tu5CIpBlPW@wdCKaJzz`K~)QN{Dbt#c(iNlisPaW}klNv@>d zfE5e0DHXD4HQJ$i!y#2c0 9-xmYSkAQXKG=Mi-%`A#&060KGuL4qarJLE$B z4emUA_LL01h482h5BNG6A{kmB_-%ZP+XFVrOm42@>AVjEY0 ap_3`DZxI|XY^;1(R1TW+W{m{Zrl MNI7V!mpGr#&70w;1dd}{u)b4vKlz5QA$Zd#DdbwUJ$XSP+`NUV!!AQ3jx~0 z9c8eq<$NW>O_7c^AXMgZwm=TSVIp-Q^(&~ZhfC9Ml&2a&OdapR<8WgN%JwJ>Myv1= zrItX^!DeuiM9m&JEi%ZX2h9vHCF5(wh+XK&EYZ1PepXMJspQj}lA9hmqli@soDt1G zTSVMMAz6mDORcDr4N@q@i$rU~i`ta2PnC<@ndg&R!oH~i_^x)?lbN-IFP~85+8lo3 z(6loU$%M1`DVjElJS9gK!I*tk`55n8^rH}%qnZ{Zu |L1#_V zIn=RW`}rk&U?tCNGz?|k*j_qpr=Odj!CU;QVV2&z<*OG@k`I+z0eydxg*BFl!cQ7{ z+87Y_J_zpxc$DzpprTD2b!FVW#~)Pmh?3=aUX;Q+i=WM+$u;CccKApu1!h7 )1Zp+Rr6AzL}@mNR8T=nhzqB_0-J*QCBhk! zgZ0@Na|58An`ct)l0%oe%<6B^xr7)-C6`4!g6dUw7F7pUcsFY2mAkX&rzPd^8?mPNHWI2DIc8&P^ z7#;}@;JtkR&@ zsUoB%B3qv#$d;JKzt`nZk>?zMw?cZMJ8vEI;xE2pl;{;OP)7)qR973Pz)y;-e)R$nW~*XX9@e`O6WaC3y{2VfH? zt-ni;%s9-yRmEc!JCr+i?`l7qRb4j5cg+n&+efyt8GiBZ9kpZFvQC-Iu;W&z3K2(6 z&HGf59C(9-{=<`rYE45gd+EdKCYehfU cpI3JkkFrv;Nc_iH=_15eaPet>^A)$EYB4Q(vIj zDmqSyI%>*}%df|`2qa0WTpezh0x%jjE9;ksTrO=dY183tD5bB^Fl4a*UBdzX)5Fns z@?wOn{>vzL{t5Y?{ ## 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/web/src/components/canvas/canvas-agent-skill-library-modal.tsx b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx new file mode 100644 index 000000000..e04874931 --- /dev/null +++ b/web/src/components/canvas/canvas-agent-skill-library-modal.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button, Input } from "antd"; +import { Check, LibraryBig, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react"; + +import { AppModal } from "@/components/ui/product/app-modal"; +import type { CanvasTheme } from "@/lib/canvas-theme"; +import type { Skill, SkillCategory } from "@/services/api/skills"; + +type SkillLibraryTab = "enabled" | "installed" | "market"; + +type CanvasAgentSkillLibraryModalProps = { + open: boolean; + theme: CanvasTheme; + installedSkills: Skill[]; + marketSkills: Skill[]; + selectedSkillIds: string[]; + categories: SkillCategory[]; + category: string; + search: string; + loading: boolean; + hasMore: boolean; + onClose: () => void; + onCategoryChange: (value: string) => void; + onSearch: (value: string) => void; + onToggle: (skillId: string) => void; + onInstall: (skill: Skill) => Promise ; + onLoadMore: () => Promise ; +}; + +export function CanvasAgentSkillLibraryModal({ + open, + theme, + installedSkills, + marketSkills, + selectedSkillIds, + categories, + category, + search, + loading, + hasMore, + onClose, + onCategoryChange, + onSearch, + onToggle, + onInstall, + onLoadMore, +}: CanvasAgentSkillLibraryModalProps) { + const [tab, setTab] = useState ("enabled"); + const listRef = useRef (null); + const loadMoreRef = useRef (null); + const wasOpenRef = useRef(false); + const selectedCount = selectedSkillIds.length; + + useEffect(() => { + if (open && !wasOpenRef.current) setTab(selectedCount > 0 ? "enabled" : "installed"); + wasOpenRef.current = open; + }, [open, selectedCount]); + + const visibleSkills = useMemo(() => { + const keyword = search.trim().toLocaleLowerCase("zh-CN"); + const source = tab === "enabled" + ? installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)) + : tab === "installed" + ? installedSkills + : marketSkills; + + return source.filter((skill) => { + if (category !== "all" && skill.tag !== category) return false; + if (!keyword) return true; + return `${skill.skillName} ${skill.description || ""} ${skill.effectiveUser?.name || ""}` + .toLocaleLowerCase("zh-CN") + .includes(keyword); + }); + }, [category, installedSkills, marketSkills, search, selectedSkillIds, tab]); + + useEffect(() => { + const target = loadMoreRef.current; + if (tab !== "market" || !target || !hasMore || loading || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) void onLoadMore(); + }, { root: listRef.current, rootMargin: "180px 0px" }); + observer.observe(target); + return () => observer.disconnect(); + }, [hasMore, loading, onLoadMore, tab, visibleSkills.length]); + + const categoryItems = useMemo(() => { + const unique = new Map (); + categories.forEach((item) => { + if (item.value && item.label) unique.set(item.value, item); + }); + return [{ value: "all", label: "全部" }, ...unique.values()]; + }, [categories]); + + const emptyText = tab === "enabled" + ? "本轮还没有启用 Skill,可在“已加入”中选择" + : tab === "installed" + ? "还没有匹配的已加入 Skill,可前往“发现 Skills”添加" + : "没有匹配的公开 Skill,换个关键词或分类试试"; + + return ( + + + ); +} + +function SkillTab({ active, label, count, onClick }: { active: boolean; label: string; count?: number; onClick: () => void }) { + return ( + + ); +} + +function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onToggle, onInstall }: { + skill: Skill; + theme: CanvasTheme; + categories: SkillCategory[]; + selected: boolean; + canSelect: boolean; + onToggle: () => void; + onInstall: () => Promise+ ++ + ++++ + Skills 技能库 ++按分类浏览和搜索技能,本轮最多启用 8 个,发送消息时固定版本。
++ 已启用 = 8 ? "var(--destructive)" : theme.accent.primary }}>{selectedCount}/8 ++++ + + +++ } + value={search} + onChange={(event) => onSearch(event.target.value)} + placeholder="搜索 Skill 名称、说明或作者" + aria-label="搜索 Skills" + className="canvas-agent-skill-library-search" + /> +setTab("enabled")} /> + setTab("installed")} /> + setTab("market")} /> + + {visibleSkills.map((skill) => ( ++ + +onToggle(skill.skillId)} + onInstall={() => onInstall(skill)} + /> + ))} + {loading && visibleSkills.length === 0 ? ( + + + 正在读取技能库… ++ ) : null} + {!loading && visibleSkills.length === 0 ? ( ++ + {emptyText} + {tab !== "market" ? : null} ++ ) : null} + {tab === "market" && hasMore ? : null} +; +}) { + const [installing, setInstalling] = useState(false); + const [coverFailed, setCoverFailed] = useState(false); + const coverUrl = skill.showcaseMedia?.find((item) => item.showcaseUrl)?.showcaseUrl; + const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他"; + const author = skill.effectiveUser?.name || "影策创作者"; + const addedCount = formatSkillCount(skill.addedCount || 0); + + useEffect(() => setCoverFailed(false), [coverUrl]); + + const install = async () => { + if (installing) return; + setInstalling(true); + try { + await onInstall(); + } finally { + setInstalling(false); + } + }; + + return ( + + + ); +} + +function formatSkillCount(value: number) { + return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value); +} diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 90f5b34f7..35a57e31b 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -570,8 +570,8 @@ export function AgentChatComposer({ ) : null} -+ {coverUrl && !coverFailed ? ( ++setCoverFailed(true)} /> + ) : ( + + )} + {categoryLabel} +
++++{skill.skillName}
+ {skill.version ? v{skill.version} : null} +{skill.description || "暂无技能说明"}
++ {author} + + {addedCount} +++ {skill.isAdded ? ( + :+} + disabled={!canSelect} + aria-pressed={selected} + title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined} + onClick={onToggle} + > + {selected ? "已启用" : "启用"} + + ) : ( + } loading={installing} disabled={installing} onClick={() => void install()}> + 加入 + + )} + -++{onAddFiles ? ( <> -+Enter 换行 · ⌘/Ctrl+Enter 发送); } -function AgentSkillPicker({ - theme, - installedSkills, - marketSkills, - selectedSkillIds, - search, - loading, - hasMore, - onSearch, - onToggle, - onInstall, - onLoadMore, -}: { - theme: CanvasTheme; - installedSkills: Skill[]; - marketSkills: Skill[]; - selectedSkillIds: string[]; - search: string; - loading: boolean; - hasMore: boolean; - onSearch: (value: string) => void; - onToggle: (skillId: string) => void; - onInstall: (skill: Skill) => Promisevoid; onCollapse: () => void; onFocusNode?: (nodeId: string) => void }; +type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void }; type ApprovalState = { approvalId: string; detail: Record ; reason: string }; type AgentPanelView = "chat" | "history" | "settings"; -export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { +export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); @@ -41,8 +42,12 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o const [connectionEpoch, setConnectionEpoch] = useState(0); const [messages, setMessages] = useState ([]); const [prompt, setPrompt] = useState(""); - const [thinking, setThinking] = useState(false); - const [personality, setPersonality] = useState ("务实"); + const [reasoningMode, setReasoningMode] = useState ("off"); + const [profileView, setProfileView] = useState (null); + const [profileLoading, setProfileLoading] = useState(false); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState (); + const profileRequestRef = useRef(0); const [skills, setSkills] = useState ([]); const [selectedSkillIds, setSelectedSkillIds] = useState ([]); const [marketSkills, setMarketSkills] = useState ([]); @@ -51,6 +56,8 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o const [skillsLoading, setSkillsLoading] = useState(false); const [skillHasMore, setSkillHasMore] = useState(false); const [skillPage, setSkillPage] = useState(1); + const [skillCategories, setSkillCategories] = useState ([]); + const [skillTag, setSkillTag] = useState("all"); const [skillsOpen, setSkillsOpen] = useState(false); const [busy, setBusy] = useState(false); const [approvalSubmitting, setApprovalSubmitting] = useState(false); @@ -88,8 +95,8 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o currentScope.current = conversationScope; const running = Boolean(run?.cleanupPending) || run?.status === "running" || run?.status === "queued" || run?.status === "waiting_approval"; const selectedModel = config.textModel || config.model || ""; - const thinkingSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking); - useEffect(() => { if (!thinkingSupported && thinking) setThinking(false); }, [thinkingSupported, thinking]); + const reasoningSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking); + useEffect(() => { if (!reasoningSupported && reasoningMode !== "off") setReasoningMode("off"); }, [reasoningSupported, reasoningMode]); const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]); const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]); const status = run?.status || "idle"; @@ -100,6 +107,37 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o if (!open || view !== "chat") setSkillsOpen(false); }, [open, view]); + const reloadProfile = useCallback(async () => { + const requestId = ++profileRequestRef.current; + setProfileLoading(true); + setProfileError(undefined); + setProfileView(null); + try { + const result = await getAgentProfile({ projectId: domainProjectId, canvasId }); + if (profileRequestRef.current === requestId) setProfileView(result); + } catch (cause) { + if (profileRequestRef.current === requestId) setProfileError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (profileRequestRef.current === requestId) setProfileLoading(false); + } + }, [canvasId, domainProjectId]); + + useEffect(() => { + void reloadProfile(); + }, [reloadProfile]); + + const saveProfile = async (input: { scope: AgentProfileScope; projectId?: string; canvasId?: string; content: string; revision: number }) => { + setProfileSaving(true); + try { + const result = await updateAgentProfile(input); + setProfileView(result); + setProfileError(undefined); + return result; + } finally { + setProfileSaving(false); + } + }; + useEffect(() => { const timer = window.setTimeout(() => setDebouncedSkillSearch(skillSearch.trim()), 250); return () => window.clearTimeout(timer); @@ -128,12 +166,19 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o if (view !== "settings" && !skillsOpen) return; let active = true; setSkillsLoading(true); - void listSkills({ scope: "public", search: debouncedSkillSearch || undefined, pageSize: 20, sort: "popular" }) + void listSkills({ + scope: "public", + search: debouncedSkillSearch || undefined, + tag: skillsOpen && skillTag !== "all" ? skillTag : undefined, + pageSize: 20, + sort: "popular", + }) .then((result) => { if (active) { setMarketSkills(result.skills); setSkillHasMore(result.hasMore); setSkillPage(result.page); + if (result.categories.length > 0) setSkillCategories(result.categories); } }) .catch(() => { @@ -145,14 +190,21 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o return () => { active = false; }; - }, [view, skillsOpen, debouncedSkillSearch]); + }, [view, skillsOpen, debouncedSkillSearch, skillTag]); const loadMoreSkills = async () => { if (skillsLoading || skillPageRequestRef.current || !skillHasMore || skillSearch.trim() !== debouncedSkillSearch) return; skillPageRequestRef.current = true; setSkillsLoading(true); try { - const result = await listSkills({ scope: "public", search: debouncedSkillSearch || undefined, page: skillPage + 1, pageSize: 20, sort: "popular" }); + const result = await listSkills({ + scope: "public", + search: debouncedSkillSearch || undefined, + tag: skillsOpen && skillTag !== "all" ? skillTag : undefined, + page: skillPage + 1, + pageSize: 20, + sort: "popular", + }); setMarketSkills((current) => [...current, ...result.skills.filter((skill) => !current.some((item) => item.skillId === skill.skillId))]); setSkillPage(result.page); setSkillHasMore(result.hasMore); @@ -288,6 +340,8 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o // Editing model settings or prompt must not silently create a new charge. if (pending?.request && pending.request.prompt !== value) throw new Error("上一条请求结果待确认,请先原样重试上一条消息,再发送新要求"); if (!pending?.request) { + if (profileLoading) throw new Error("正在确认长期偏好快照,请稍后再发送"); + if (!profileView || profileError) throw new Error("长期偏好快照尚未确认,请重新读取后再发送"); const capabilities = await getAgentCapabilities(); if (currentScope.current !== scope) return; if (!capabilities.permissionModes.includes(permissionMode)) throw new Error("当前后端不支持所选 Agent 权限,请更新后端"); @@ -298,7 +352,7 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o const requestConfig = resolveModelRequestConfig(agentConfig, selectedModel); const logicalModelId = logicalModelIDForConfig(agentConfig); const input = { - canvasId, prompt: value, thinking, personality, + canvasId, prompt: value, reasoningMode: reasoningSupported ? reasoningMode : "off", profileRevision: profileView.revision, model: modelOptionName(selectedModel) || undefined, ...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(selectedModel) || undefined } : {}), skillIds: [...new Set([...selectedSkillIds, ...resolveSkillMentions(value, installedSkills).map((skill) => skill.skillId)])], @@ -351,7 +405,7 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o } } } - setMessages((current) => appendAgentError(current, `submit-error-${activeConversationId}`, cause, accepted ? "运行已接收,但本地提交记录清理失败" : "请求未确认;重试将核对原请求,不重复创建")); + setMessages((current) => appendAgentError(current, `submit-error-${activeConversationId}`, cause, agentSubmissionErrorTitle(cause, accepted))); } finally { submissionRequestRef.current = false; if (currentScope.current === scope) setBusy(false); @@ -423,7 +477,7 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o const result = await addSkill(skill.skillId); setSkills((current) => [...current.filter((item) => item.skillId !== skill.skillId), result.skill]); setMarketSkills((current) => current.map((item) => (item.skillId === skill.skillId ? result.skill : item))); - setSelectedSkillIds((current) => (current.includes(skill.skillId) ? current : [...current, skill.skillId])); + setSelectedSkillIds((current) => (current.includes(skill.skillId) || current.length >= 8 ? current : [...current, skill.skillId])); } catch (cause) { setMessages((current) => appendAgentError(current, `skill-error-${Date.now()}`, cause, "添加 Skill 失败")); } @@ -536,8 +590,16 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o onBack={() => setView("chat")} onModelChange={setModel} onPermissionChange={setPermissionMode} - personality={personality} - onPersonalityChange={setPersonality} + reasoningMode={reasoningMode} + onReasoningModeChange={setReasoningMode} + profileView={profileView} + profileLoading={profileLoading} + profileSaving={profileSaving} + profileError={profileError} + projectId={domainProjectId} + canvasId={canvasId} + onReloadProfile={reloadProfile} + onSaveProfile={saveProfile} onContextToggle={(value) => setContextScope((current) => (current.includes(value) ? current.filter((item) => item !== value) : [...current, value]))} onSkillSearch={setSkillSearch} onSkillToggle={(id) => setSelectedSkillIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))} @@ -622,29 +684,17 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o includeAssetLibrary={false} left={ { if (thinkingSupported) setThinking(value); }} - thinkingSupported={thinkingSupported} + reasoningMode={reasoningSupported ? reasoningMode : "off"} + reasoningSupported={reasoningSupported} + onReasoningModeChange={(value) => { if (reasoningSupported) setReasoningMode(value); }} config={config} selectedModel={selectedModel} permissionMode={permissionMode} theme={theme} onModelChange={setModel} onPermissionChange={setPermissionMode} - personality={personality} - onPersonalityChange={setPersonality} skillsOpen={skillsOpen} onSkillsOpenChange={setSkillsOpen} - installedSkills={installedSkills} - marketSkills={marketSkills} - selectedSkillIds={selectedSkillIds} - skillSearch={skillSearch} - skillsLoading={skillsLoading} - skillHasMore={skillHasMore} - onSkillSearch={setSkillSearch} - onSkillToggle={(id) => setSelectedSkillIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))} - onSkillInstall={installSkill} - onLoadMoreSkills={loadMoreSkills} selectedSkillCount={selectedSkillIds.length} /> } @@ -655,6 +705,27 @@ export function CanvasCloudAgentPanel({ canvasId, nodeCount, references, open, o ) : null} + setSkillsOpen(false)} + onCategoryChange={setSkillTag} + onSearch={setSkillSearch} + onToggle={(id) => setSelectedSkillIds((current) => { + if (current.includes(id)) return current.filter((item) => item !== id); + return current.length < 8 ? [...current, id] : current; + })} + onInstall={installSkill} + onLoadMore={loadMoreSkills} + /> > ); } @@ -810,7 +881,7 @@ function AgentConversation({ {messages.map((item) => ( ))} - {approval ? : null} + {approval ? : null} {busy && !approval ? ( ) : null} @@ -832,11 +903,9 @@ function AgentEmptyState({ theme, nodeCount }: { theme: CanvasTheme; nodeCount: } function ComposerControls({ - thinking, - thinkingSupported, - personality, - onPersonalityChange, - onThinkingChange, + reasoningMode, + reasoningSupported, + onReasoningModeChange, config, selectedModel, permissionMode, @@ -845,23 +914,11 @@ function ComposerControls({ onPermissionChange, skillsOpen, onSkillsOpenChange, - installedSkills, - marketSkills, - selectedSkillIds, - skillSearch, - skillsLoading, - skillHasMore, - onSkillSearch, - onSkillToggle, - onSkillInstall, - onLoadMoreSkills, selectedSkillCount, }: { - thinking: boolean; - thinkingSupported: boolean; - personality: AgentPersonality; - onPersonalityChange: (value: AgentPersonality) => void; - onThinkingChange: (value: boolean) => void; + reasoningMode: AgentReasoningMode; + reasoningSupported: boolean; + onReasoningModeChange: (value: AgentReasoningMode) => void; config: ReturnType ; selectedModel: string; permissionMode: AgentPermissionMode; @@ -870,16 +927,6 @@ function ComposerControls({ onPermissionChange: (mode: AgentPermissionMode) => void; skillsOpen: boolean; onSkillsOpenChange: (open: boolean) => void; - installedSkills: Skill[]; - marketSkills: Skill[]; - selectedSkillIds: string[]; - skillSearch: string; - skillsLoading: boolean; - skillHasMore: boolean; - onSkillSearch: (value: string) => void; - onSkillToggle: (skillId: string) => void; - onSkillInstall: (skill: Skill) => Promise ; - onLoadMoreSkills: () => Promise ; selectedSkillCount: number; }) { const permissionVisual = agentPermissionVisual(permissionMode); @@ -896,9 +943,11 @@ function ComposerControls({ showOptionPrices placeholder="选择模型" /> - {thinkingSupported ? : null} + {reasoningSupported ? + + : null}- - } - > - - + ; - onLoadMore: () => Promise ; -}) { - const [tab, setTab] = useState<"installed" | "market">("installed"); - const listRef = useRef (null); - const loadMoreRef = useRef (null); - const normalizedSearch = search.trim().toLocaleLowerCase(); - const source = (tab === "installed" ? installedSkills : marketSkills).filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(normalizedSearch)); - const selectedCount = selectedSkillIds.length; - const allSelected = tab === "installed" && source.length > 0 && source.every((skill) => selectedSkillIds.includes(skill.skillId)); - const toggleAll = () => { - let added = 0; - source.forEach((skill) => { - const selected = selectedSkillIds.includes(skill.skillId); - if (allSelected ? selected : !selected && added < Math.max(0, 8 - selectedCount)) { - onToggle(skill.skillId); - added += 1; - } - }); - }; +const reasoningLabels: Record = { off: "直达", auto: "自动推理", deep: "深入推理" }; - useEffect(() => { - const target = loadMoreRef.current; - if (tab !== "market" || !target || !hasMore || loading || typeof IntersectionObserver === "undefined") return; - const observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) void onLoadMore(); - }, { root: listRef.current, rootMargin: "120px 0px" }); - observer.observe(target); - return () => observer.disconnect(); - }, [hasMore, loading, onLoadMore, tab]); +function reasoningModeLabel(mode: AgentReasoningMode) { return reasoningLabels[mode]; } - return ( - -- ); +function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentReasoningMode) => void) { + return (Object.keys(reasoningLabels) as AgentReasoningMode[]).map((value) => ({ + key: value, + label: reasoningLabels[value], + icon: value === mode ?-----选择 Skills 本轮启用 {selectedCount}/8 个,发送时固定版本-- {tab === "installed" && source.length > 0 ? : null} --- - -- } value={search} onChange={(event) => onSearch(event.target.value)} placeholder={tab === "market" ? "搜索技能市场" : "搜索已加入 Skills"} aria-label="搜索 Skills" className="canvas-agent-skill-picker-search !rounded-xl" /> -- {loading && source.length === 0 ?-: null} - {source.map((skill) =>正在读取技能… onToggle(skill.skillId)} onInstall={() => void onInstall(skill)} />)} - {!loading && source.length === 0 ? {tab === "market" ? "没有匹配的公开 Skill,换个关键词试试" : "还没有加入 Skill,可前往“发现 Skills”添加"}: null} - {tab === "market" && hasMore ? : null} -- {tab === "installed" ? `已选择 ${selectedCount} 个技能包` : `已加载 ${marketSkills.length} 个技能包`} - {tab === "market" && hasMore ? : {tab === "installed" ? "最多 8 个" : "已加载全部"}} --: undefined, + onClick: () => onChange(value), + })); } -function SkillPickerRow({ skill, theme, selected, canSelect, onToggle, onInstall }: { skill: Skill; theme: CanvasTheme; selected: boolean; canSelect: boolean; onToggle: () => void; onInstall: () => void }) { - const [expanded, setExpanded] = useState(false); - const description = skill.description || "暂无技能说明"; - const hasMore = description.length > 112 || description.includes("\n"); - return ( - - {skill.isAdded ? ( - - ) :- ); -} - -function ApprovalCard({ approval, theme, submitting, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onReasonChange: (value: string) => void; onApprove: () => void; onReject: () => void }) { +function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: () => void; onReject: () => void }) { const [showReason, setShowReason] = useState(Boolean(approval.reason)); const action = agentApprovalPresentation(approval.detail); return ( @@ -1077,7 +1000,11 @@ function ApprovalCard({ approval, theme, submitting, onReasonChange, onApprove, 等待你的确认} - -- {!skill.isAdded ? } onClick={onInstall}>加入 : null} - {hasMore ? ( - - ) : null} -{skill.skillName}{skill.version ? v{skill.version} : null}-{description}-{action.description}
- {action.items.length ?{action.items.map((item, index) => {item})}: null} + {action.items.length ? ( ++ {action.items.map((item, index) =>+ ) :)} + 无法确认具体目标,继续前请重新读取画布。} @@ -1093,6 +1020,37 @@ function ApprovalCard({ approval, theme, submitting, onReasonChange, onApprove, ); } +function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) { + const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : "生成"; + const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => { + if (!title) return null; + const content = <>{title}{typeLabel ? {typeLabel} : null}>; + return id && onFocusNode ? : {content}; + }; + return ( + + + ); +} + function positiveNumber(value: string) { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : undefined; diff --git a/web/src/components/canvas/canvas-cloud-agent-settings.tsx b/web/src/components/canvas/canvas-cloud-agent-settings.tsx index 53cf28ed7..458a8e865 100644 --- a/web/src/components/canvas/canvas-cloud-agent-settings.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-settings.tsx @@ -1,23 +1,31 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { Button, Checkbox, Input } from "antd"; import { ArrowLeft, Check, ChevronRight, Cpu, Gauge, LockKeyhole, PlugZap, Search, ShieldCheck, Sparkles, Wrench } from "lucide-react"; import { ModelPicker } from "@/components/model-picker"; import type { CanvasTheme } from "@/lib/canvas-theme"; -import type { AgentPermissionMode, AgentPersonality } from "@/services/api/agent"; +import type { AgentPermissionMode, AgentProfileLayer, AgentProfileScope, AgentProfileView, AgentReasoningMode } from "@/services/api/agent"; import type { Skill } from "@/services/api/skills"; import type { AiConfig } from "@/stores/use-config-store"; export type AgentContextKey = "canvas" | "resources" | "generation_history" | "skills" | "project"; -type SettingsSection = "home" | "skills" | "mcp" | "context" | "budget"; +type SettingsSection = "home" | "profile" | "skills" | "mcp" | "context" | "budget"; type AgentSettingsProps = { theme: CanvasTheme; config: AiConfig; selectedModel: string; permissionMode: AgentPermissionMode; - personality: AgentPersonality; - onPersonalityChange: (value: AgentPersonality) => void; + reasoningMode: AgentReasoningMode; + onReasoningModeChange: (value: AgentReasoningMode) => void; + profileView: AgentProfileView | null; + profileLoading: boolean; + profileSaving: boolean; + profileError?: string; + projectId?: string; + canvasId: string; + onReloadProfile: () => Promise+ {operationLabel} + {item.operation === "connect_nodes" ? ( ++ {item.fields?.length ?+ {renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "source")} + + {renderNode(item.targetNodeTitle, item.targetNodeId, undefined, "target")} ++ ) : ( ++ {renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "node")} + {item.resultTitle ? <>《{item.resultTitle}》> : null} ++ )} +修改:{item.fields.map((field) => {field})}: null} + {item.details?.length ?{item.details.map((detail) => {detail})}: null} +{item.summary}+; + onSaveProfile: (input: { scope: AgentProfileScope; projectId?: string; canvasId?: string; content: string; revision: number }) => Promise ; contextScope: AgentContextKey[]; nodeCount: number; installedSkills: Skill[]; @@ -52,6 +60,12 @@ const contextOptions: Array<{ value: AgentContextKey; label: string; description { value: "canvas", label: "已保存画布摘要", description: "最多 80 个节点的标题与文本片段,不含媒体正文或未同步修改" }, ]; +const reasoningOptions: Array<{ value: AgentReasoningMode; label: string; description: string }> = [ + { value: "off", label: "关闭", description: "直接执行" }, + { value: "auto", label: "自动", description: "按任务决定" }, + { value: "deep", label: "深入", description: "复杂任务优先规划" }, +]; + export function CanvasCloudAgentSettings(props: AgentSettingsProps) { const [section, setSection] = useState ("home"); const [skillTab, setSkillTab] = useState<"installed" | "market">("installed"); @@ -68,6 +82,7 @@ export function CanvasCloudAgentSettings(props: AgentSettingsProps) { {section === "home" ? : null} + {section === "profile" ? : null} {section === "skills" ? : null} {section === "mcp" ? : null} {section === "context" ? : null} @@ -80,8 +95,9 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the return ( - - {([['务实','先说结果,少铺垫'],['简洁','只保留必要信息'],['详细','补充步骤与依据'],['温和','更耐心地解释']] as const).map(([value, description]) => )}++ {reasoningOptions.map((option) => )}+推理只用于目标拆解、缺口判断和下一步工具选择,不会把内部思考过程展示给用户。
@@ -109,6 +125,7 @@ function SettingsHome({ props, theme, onOpen }: { props: AgentSettingsProps; the } title="上下文" summary={`${props.nodeCount} 个节点 · ${props.contextScope.length} 个范围`} onClick={() => onOpen("context")} /> + } title="长期偏好" summary={profileSummary(props.profileView)} onClick={() => onOpen("profile")} /> } title="Skills · 用户技能库" summary={`${props.installedSkills.length} 个已安装 · 本轮启用 ${props.selectedSkillIds.length} 个`} onClick={() => onOpen("skills")} /> } title="工具与连接" summary="画布、技能参考文件、生成任务" onClick={() => onOpen("mcp")} /> } title="预算" summary={`每轮最多 ${props.maxCredits || "未设置"} 积分 · 固定计价模型`} onClick={() => onOpen("budget")} /> @@ -146,6 +163,104 @@ function SkillsWorkspace({ props, theme, tab, onTabChange }: { props: AgentSetti ); } +type ProfileDraft = Pick & { dirty: boolean }; + +function ProfileWorkspace({ props, theme }: { props: AgentSettingsProps; theme: CanvasTheme }) { + const [scope, setScope] = useState ("user"); + const [drafts, setDrafts] = useState >({}); + const [error, setError] = useState(""); + const layer = profileLayer(props.profileView, scope, props.projectId, props.canvasId); + const draftKey = profileDraftKey(scope, props.projectId, props.canvasId); + const serverDraft: ProfileDraft = { content: layer.content, revision: layer.revision, hash: layer.hash, dirty: false }; + const draft = drafts[draftKey] || serverDraft; + const profileUnavailable = props.profileLoading || !props.profileView || Boolean(props.profileError); + + useEffect(() => { + setDrafts((current) => { + const existing = current[draftKey]; + if (existing?.dirty) return current; + if (existing && existing.revision === layer.revision && existing.hash === layer.hash && existing.content === layer.content) return current; + return { ...current, [draftKey]: { content: layer.content, revision: layer.revision, hash: layer.hash, dirty: false } }; + }); + setError(""); + }, [draftKey, layer.content, layer.hash, layer.revision]); + + const updateDraft = (content: string) => { + setDrafts((current) => { + const base = current[draftKey] || serverDraft; + return { ...current, [draftKey]: { ...base, content, dirty: true } }; + }); + }; + + const save = async () => { + setError(""); + try { + const result = await props.onSaveProfile({ + scope, + projectId: scope === "project" ? props.projectId : undefined, + canvasId: scope === "canvas" ? props.canvasId : undefined, + content: draft.content, + revision: draft.revision, + }); + const savedLayer = profileLayer(result, scope, props.projectId, props.canvasId); + setDrafts((current) => ({ + ...current, + [draftKey]: { content: savedLayer.content, revision: savedLayer.revision, hash: savedLayer.hash, dirty: false }, + })); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }; + + const disabled = profileUnavailable || props.profileSaving || (scope === "project" && !props.projectId); + return ( + ++ ); +} + +function profileDraftKey(scope: AgentProfileScope, projectId: string | undefined, canvasId: string) { + if (scope === "project") return `project:${projectId || "unbound"}`; + if (scope === "canvas") return `canvas:${canvasId}`; + return "user"; +} + +function profileLayer(view: AgentProfileView | null, scope: AgentProfileScope, projectId: string | undefined, canvasId: string): AgentProfileLayer { + return view?.layers.find((item) => item.scope === scope) || { scope, projectId: scope === "project" ? projectId : undefined, canvasId: scope === "canvas" ? canvasId : undefined, content: "", revision: 0, hash: "" }; +} + +function profileSummary(view: AgentProfileView | null) { + const count = view?.layers.filter((layer) => layer.content.trim()).length || 0; + return count ? `${count} 层已配置 · 仅影响行为偏好` : "未配置 · 仅影响行为偏好"; +} + +function scopeLabel(scope: AgentProfileScope) { return scope === "user" ? "用户偏好" : scope === "project" ? "项目偏好" : "当前画布偏好"; } + +function ProfileError({ message, onRetry, theme }: { message: string; onRetry: () => Promise++长期偏好文档+这里定义 Agent 的长期工作习惯、语气和输出结构。它不会增加工具、节点、预算或审批权限;安全契约始终由服务端代码控制。
+++ {scope === "project" && !props.projectId ?setScope("user")} /> + setScope("project")} /> + setScope("canvas")} /> + 当前画布尚未关联项目,项目偏好暂不可用。
: null} + {props.profileLoading ?正在读取偏好快照…
: null} + {props.profileError ?: null} + + + 保存后只影响下一次新运行。正在运行的 Agent 会固定原快照。 + ++ {error ?{error}: null} +; theme: CanvasTheme }) { + return ; +} + function SkillRow({ skill, theme, selected, installed, selectable, onToggle, onInstall }: { skill: Skill; theme: CanvasTheme; selected: boolean; installed: boolean; selectable: boolean; onToggle: () => void; onInstall: () => void }) { return ({message}@@ -187,8 +302,8 @@ function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasThe function TabButton({ active, label, onClick, theme }: { active: boolean; label: string; onClick: () => void; theme: CanvasTheme }) { return ; } function SettingLabel({ label, hint }: { label: string; hint?: string }) { return-{label}{hint ? {hint} : null}; } function BudgetInput({ label, hint, value, onChange, theme }: { label: string; hint: string; value: string; onChange: (value: string) => void; theme: CanvasTheme }) { return ; } -function sectionTitle(section: SettingsSection) { return section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; } -function sectionSubtitle(section: SettingsSection) { return section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; } +function sectionTitle(section: SettingsSection) { return section === "profile" ? "长期偏好" : section === "skills" ? "Skills" : section === "mcp" ? "MCP 与工具" : section === "context" ? "上下文" : "预算"; } +function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; } export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; } export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; } diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css index 4814c9492..ce6d78d21 100644 --- a/web/src/components/canvas/canvas-cloud-agent.css +++ b/web/src/components/canvas/canvas-cloud-agent.css @@ -131,6 +131,18 @@ outline: none; } +.agent-composer-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 8px; +} + +.agent-composer-submit { + min-width: 0; + justify-self: end; +} + .agent-composer-send-hint { color: color-mix(in srgb, currentColor 52%, transparent); font-size: var(--fs-tiny); @@ -138,7 +150,8 @@ white-space: nowrap; } -@container (max-width: 420px) { +/* 面板宽度包含内边距;默认 448px 面板的输入区已不足以同时容纳控制区和快捷键提示。 */ +@container (max-width: 520px) { .agent-composer-send-hint { display: none; } @@ -243,24 +256,137 @@ } .canvas-agent-approval-items { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 10px; + display: grid; + gap: 8px; + max-height: min(360px, 42vh); + margin-top: 12px; + overflow-y: auto; + padding-right: 2px; } -.canvas-agent-approval-items span { - max-width: 100%; - overflow: hidden; - border-radius: 6px; - background: color-mix(in srgb, var(--foreground) 7%, transparent); - padding: 4px 7px; - font-size: 11px; +.canvas-agent-approval-item { + display: grid; + gap: 6px; + min-width: 0; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--foreground) 3%, transparent); + padding: 9px 10px; +} + +.canvas-agent-approval-item-main, +.canvas-agent-approval-node-summary, +.canvas-agent-approval-connection { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; +} + +.canvas-agent-approval-operation { + flex: 0 0 auto; + border-radius: 5px; + padding: 2px 6px; + font-size: 10px; + font-weight: 700; line-height: 16px; +} + +.canvas-agent-approval-operation-add_node { background: #dcfce7; color: #166534; } +.canvas-agent-approval-operation-update_node { background: #dbeafe; color: #1d4ed8; } +.canvas-agent-approval-operation-connect_nodes { background: #fef3c7; color: #92400e; } +.canvas-agent-approval-operation-generate_media { background: #f3e8ff; color: #7e22ce; } + +.canvas-agent-approval-node, +.canvas-agent-approval-node-button { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; + border-radius: 6px; + color: var(--foreground); + font-size: 12px; + line-height: 18px; + text-align: left; +} + +.canvas-agent-approval-node-button { + cursor: pointer; + padding: 2px 5px; +} + +.canvas-agent-approval-node-button:hover { + background: color-mix(in srgb, var(--agent-approval-blue) 10%, transparent); + color: var(--agent-approval-blue); +} + +.canvas-agent-approval-node-title, +.canvas-agent-approval-result-title { + min-width: 0; + overflow: hidden; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.canvas-agent-approval-node-type { + flex: 0 0 auto; + border-radius: 4px; + background: color-mix(in srgb, var(--foreground) 8%, transparent); + padding: 1px 5px; + color: color-mix(in srgb, var(--foreground) 58%, transparent); + font-size: 10px; + line-height: 15px; +} + +.canvas-agent-approval-arrow, +.canvas-agent-approval-change-arrow { + flex: 0 0 auto; + color: color-mix(in srgb, var(--foreground) 48%, transparent); + font-size: 12px; +} + +.canvas-agent-approval-result-title { + color: var(--agent-approval-blue); + font-size: 12px; +} + +.canvas-agent-approval-field-list, +.canvas-agent-approval-detail-list, +.canvas-agent-approval-summary, +.canvas-agent-approval-empty { + color: color-mix(in srgb, var(--foreground) 62%, transparent); + font-size: 11px; + line-height: 17px; +} + +.canvas-agent-approval-field-list, +.canvas-agent-approval-detail-list { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.canvas-agent-approval-field-list span, +.canvas-agent-approval-detail-list span { + border-radius: 4px; + background: color-mix(in srgb, var(--foreground) 7%, transparent); + padding: 2px 5px; +} + +.canvas-agent-approval-summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.canvas-agent-approval-empty { + margin-top: 12px; + border-radius: 8px; + background: color-mix(in srgb, #f59e0b 10%, transparent); + padding: 8px 10px; +} + .canvas-agent-approval-reason-toggle { display: inline-flex; margin-top: 10px; @@ -356,82 +482,486 @@ gap: 8px; } -.canvas-agent-skill-popover .ant-popover-inner { - padding: 0; +.canvas-agent-skill-library-modal .ant-modal-container { overflow: hidden; border: 1px solid var(--border); - border-radius: 18px; + border-radius: var(--r-2xl); + padding: 0; + background: transparent; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18), var(--elevation-hairline) !important; } -.canvas-agent-skill-picker { +.canvas-agent-skill-library-modal .ant-modal-body { + padding: 0; +} + +.canvas-agent-skill-library-modal .ant-modal-close { + top: 16px; + right: 16px; + width: 34px; + height: 34px; + border-radius: var(--r-md); + color: var(--foreground); + background: color-mix(in srgb, var(--foreground) 7%, transparent); +} + +.canvas-agent-skill-library-modal .ant-modal-close:hover { + background: color-mix(in srgb, var(--foreground) 12%, transparent); +} + +.canvas-agent-skill-library-shell { + position: relative; + display: grid; + height: min(780px, calc(100dvh - 36px)); + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + overflow: hidden; + border-radius: inherit; + background: + radial-gradient(circle at 0 0, color-mix(in srgb, var(--workspace-accent) 7%, transparent), transparent 34%), + var(--cn-spatial-elevated, var(--background)); +} + +.canvas-agent-skill-library-header { display: flex; - width: min(520px, calc(100vw - 24px)); - max-height: calc(100vh - 24px); - flex-direction: column; - padding: 12px; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 18px 64px 14px 20px; } -.canvas-agent-skill-picker-tabs { - height: 48px; - margin-top: 10px; +.canvas-agent-skill-library-header p { + margin-bottom: 0; } -.canvas-agent-skill-picker-tabs button { - transition: background-color 140ms ease, color 140ms ease; +.canvas-agent-skill-library-count { + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: var(--r-lg); + background: color-mix(in srgb, var(--foreground) 4%, transparent); + padding: 7px 10px; + font-size: var(--fs-label); + font-variant-numeric: tabular-nums; } -.canvas-agent-skill-picker-search { - height: 44px; - margin-top: 10px; +.canvas-agent-skill-library-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 430px); + align-items: center; + gap: 14px; + padding: 0 20px 12px; } -.canvas-agent-skill-picker-list { +.canvas-agent-skill-library-tabs { + display: flex; + min-width: 0; + align-items: center; + gap: 4px; +} + +.canvas-agent-skill-library-tab { + display: inline-flex; + min-height: 38px; + flex: 0 0 auto; + align-items: center; + gap: 7px; + border-radius: var(--r-md); + padding: 0 12px; + color: color-mix(in srgb, var(--foreground) 56%, transparent); + font-size: var(--fs-caption); + font-weight: 560; + transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out); +} + +.canvas-agent-skill-library-tab span { + min-width: 20px; + border-radius: 999px; + background: color-mix(in srgb, currentColor 9%, transparent); + padding: 1px 6px; + font-size: var(--fs-tiny); + line-height: 18px; + text-align: center; +} + +.canvas-agent-skill-library-tab:hover { + background: color-mix(in srgb, var(--foreground) 4%, transparent); + color: color-mix(in srgb, var(--foreground) 76%, transparent); +} + +.canvas-agent-skill-library-tab.is-active { + background: color-mix(in srgb, var(--foreground) 5%, transparent); + color: color-mix(in srgb, var(--foreground) 78%, transparent); +} + +.canvas-agent-skill-library-search { + height: 40px; + border-radius: var(--r-lg); +} + +.canvas-agent-skill-library-categories { + display: flex; + min-width: 0; + gap: 8px; + overflow-x: auto; + border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); + padding: 10px 20px; + overscroll-behavior-x: contain; + scrollbar-width: none; +} + +.canvas-agent-skill-library-categories::-webkit-scrollbar { + display: none; +} + +.canvas-agent-skill-category { + min-height: 34px; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent); + border-radius: var(--r-md); + padding: 0 13px; + color: color-mix(in srgb, var(--foreground) 58%, transparent); + font-size: var(--fs-caption); + transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out); +} + +.canvas-agent-skill-category:hover { + border-color: color-mix(in srgb, var(--foreground) 20%, transparent); + color: var(--foreground); +} + +.canvas-agent-skill-category.is-active { + border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border)); + background: color-mix(in srgb, var(--workspace-accent) 11%, transparent); + color: var(--workspace-accent); +} + +.canvas-agent-skill-library-list { + display: grid; min-height: 0; - max-height: min(360px, 44vh); - margin-top: 10px; - padding-right: 5px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: max-content; + align-content: start; + gap: 12px; + overflow-y: auto; + padding: 16px 20px; + overscroll-behavior: contain; } -.canvas-agent-skill-picker-row { - padding: 8px 10px; - transition: background-color 140ms ease, border-color 140ms ease; +.canvas-agent-skill-library-state { + display: grid; + min-height: 260px; + grid-column: 1 / -1; + place-items: center; + align-content: center; + gap: 10px; + padding: 28px; + font-size: var(--fs-caption); + text-align: center; } -.canvas-agent-skill-picker-row:hover { - background: var(--library-surface-hover); +.canvas-agent-skill-card { + display: grid; + min-width: 0; + min-height: 136px; + grid-template-columns: 164px minmax(0, 1fr) auto; + gap: 14px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--r-xl); + background: color-mix(in srgb, var(--foreground) 3%, transparent); + padding: 11px; + transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out); } -.canvas-agent-skill-picker-row.is-selected { +.canvas-agent-skill-card:hover { + border-color: color-mix(in srgb, var(--foreground) 22%, transparent) !important; + background: color-mix(in srgb, var(--foreground) 5%, transparent); + transform: translateY(-1px); +} + +.canvas-agent-skill-card.is-selected { background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 24%, transparent); } -.canvas-agent-skill-picker-check { - width: 22px; - height: 22px; +.canvas-agent-skill-card-cover { + position: relative; + min-width: 0; + overflow: hidden; + border-radius: var(--r-lg); + aspect-ratio: 16 / 10; } -.canvas-agent-skill-picker-footer { - min-height: 36px; - margin-top: 8px; - padding-top: 8px; +.canvas-agent-skill-card-cover img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--motion-dur-base) var(--motion-ease-out); } -.canvas-agent-skill-picker-expand { - border-radius: 5px; - padding: 2px 4px; +.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img { + transform: scale(1.025); } -.canvas-agent-skill-picker-expand:hover { - background: color-mix(in srgb, var(--workspace-accent) 10%, transparent); +.canvas-agent-skill-card-placeholder { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: color-mix(in srgb, var(--workspace-accent) 64%, var(--foreground)); + background: + radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--workspace-accent) 28%, transparent), transparent 46%), + linear-gradient(145deg, color-mix(in srgb, var(--foreground) 8%, transparent), color-mix(in srgb, var(--foreground) 2%, transparent)); +} + +.canvas-agent-skill-card-category { + position: absolute; + top: 7px; + right: 7px; + max-width: calc(100% - 14px); + overflow: hidden; + border: 1px solid color-mix(in srgb, white 18%, transparent); + border-radius: var(--r-md); + background: color-mix(in srgb, black 62%, transparent); + padding: 2px 7px; + color: white; + font-size: var(--fs-tiny); + line-height: 19px; + text-overflow: ellipsis; + white-space: nowrap; + backdrop-filter: blur(8px); +} + +.canvas-agent-skill-card-body { + display: flex; + min-width: 0; + flex-direction: column; + padding: 4px 0 2px; +} + +.canvas-agent-skill-card-title-row { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; +} + +.canvas-agent-skill-card-title-row h3 { + min-width: 0; + overflow: hidden; + margin: 0; + color: var(--foreground); + font-size: 15px; + font-weight: 650; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.canvas-agent-skill-card-version { + flex: 0 0 auto; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + font-size: var(--fs-tiny); +} + +.canvas-agent-skill-card-body > p { + display: -webkit-box; + min-width: 0; + overflow: hidden; + margin: 7px 0 10px; + color: color-mix(in srgb, var(--foreground) 54%, transparent); + font-size: var(--fs-label); + line-height: 19px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.canvas-agent-skill-card-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-top: auto; + font-size: var(--fs-label); +} + +.canvas-agent-skill-card-action { + display: flex; + align-items: flex-start; + padding-top: 3px; +} + +.canvas-agent-skill-card-action .ant-btn { + min-width: 76px; + border-radius: var(--r-md); +} + +.canvas-agent-skill-card-action .ant-btn.is-selected { + border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--border)); + background: color-mix(in srgb, var(--workspace-accent) 13%, transparent); + color: var(--workspace-accent); + box-shadow: none; +} + +.canvas-agent-skill-library-footer { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid; + padding: 8px 20px; + font-size: var(--fs-label); +} + +.canvas-agent-skill-library-tab:focus-visible, +.canvas-agent-skill-category:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +@media (max-width: 920px) { + .canvas-agent-skill-library-list { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 680px) { + .canvas-agent-skill-library-shell { + height: calc(100dvh - 24px); + } + + .canvas-agent-skill-library-header { + align-items: flex-start; + gap: 10px; + padding: 16px 56px 12px 14px; + } + + .canvas-agent-skill-library-header p { + display: none; + } + + .canvas-agent-skill-library-count { + padding: 5px 8px; + } + + .canvas-agent-skill-library-toolbar { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + padding: 0 14px 10px; + } + + .canvas-agent-skill-library-tabs { + overflow-x: auto; + scrollbar-width: none; + } + + .canvas-agent-skill-library-tabs::-webkit-scrollbar { + display: none; + } + + .canvas-agent-skill-library-categories { + padding: 8px 14px; + } + + .canvas-agent-skill-library-list { + gap: 10px; + padding: 12px 14px; + } + + .canvas-agent-skill-library-footer { + min-height: 44px; + padding: 6px 14px; + } +} + +@media (max-width: 560px) { + .canvas-agent-skill-library-header { + flex-wrap: wrap; + } + + .canvas-agent-skill-library-count { + position: absolute; + top: 17px; + right: 56px; + } + + .canvas-agent-skill-library-tab { + min-height: 34px; + padding: 0 9px; + font-size: var(--fs-label); + } + + .canvas-agent-skill-card { + min-height: 122px; + grid-template-columns: 104px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + gap: 8px 10px; + padding: 9px; + } + + .canvas-agent-skill-card-cover { + grid-row: 1 / 3; + align-self: stretch; + aspect-ratio: auto; + } + + .canvas-agent-skill-card-body { + padding: 1px 0 0; + } + + .canvas-agent-skill-card-body > p { + margin: 4px 0 6px; + font-size: var(--fs-tiny); + line-height: 17px; + } + + .canvas-agent-skill-card-meta { + font-size: var(--fs-tiny); + } + + .canvas-agent-skill-card-action { + justify-content: flex-end; + padding: 0; + } + + .canvas-agent-skill-card-action .ant-btn { + min-width: 72px; + } + + .canvas-agent-skill-library-footer > span:last-child { + display: none; + } } @media (max-height: 560px) { - .canvas-agent-skill-picker { - max-height: calc(100vh - 16px); + .canvas-agent-skill-library-shell { + height: calc(100dvh - 16px); } - .canvas-agent-skill-picker-list { - max-height: min(280px, 38vh); + .canvas-agent-skill-library-header { + padding-top: 12px; + padding-bottom: 8px; + } + + .canvas-agent-skill-library-categories { + padding-top: 7px; + padding-bottom: 7px; + } +} + +@media (prefers-reduced-motion: reduce) { + .canvas-agent-skill-card, + .canvas-agent-skill-card-cover img, + .canvas-agent-skill-category, + .canvas-agent-skill-library-tab { + transition: none; + } + + .canvas-agent-skill-card:hover, + .canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img { + transform: none; } } diff --git a/web/src/lib/canvas/agent-approval-presentation.ts b/web/src/lib/canvas/agent-approval-presentation.ts index 0da43dc42..5e6047d96 100644 --- a/web/src/lib/canvas/agent-approval-presentation.ts +++ b/web/src/lib/canvas/agent-approval-presentation.ts @@ -1,11 +1,17 @@ +import type { AgentApprovalPreview, AgentApprovalPreviewItem, AgentApprovalPreviewOperation } from "@/services/api/agent"; + type RecordValue = Record; +export type AgentApprovalPresentation = AgentApprovalPreview & { source: "server" | "fallback" }; + function record(value: unknown): RecordValue | null { return value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : null; } -function text(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; +function text(value: unknown, max = 240): string { + if (typeof value !== "string") return ""; + const compact = value.replace(/[\u0000-\u001f\u007f]/gu, " ").trim(); + return compact.length > max ? `${compact.slice(0, max)}…` : compact; } function approvalArguments(value: unknown): RecordValue | null { @@ -17,35 +23,108 @@ function approvalArguments(value: unknown): RecordValue | null { } } -export function agentApprovalPresentation(detail: unknown) { +function shortReference(value: unknown): string { + const id = text(value, 80); + if (!id) return "未知目标"; + return id.length <= 16 ? id : `${id.slice(0, 8)}…${id.slice(-4)}`; +} + +function nodeTypeLabel(value: unknown): string { + const type = text(value, 32).toLowerCase(); + return ({ text: "文本", markdown: "Markdown", image: "图片", video: "视频", audio: "音频", frame: "画框", script: "脚本" } as Record )[type] || (type ? "节点" : ""); +} + +function fieldLabel(key: string, nodeType: string): string { + if (key === "title") return "节点名称"; + if (key === "content") return ["image", "video", "audio"].includes(nodeType.toLowerCase()) ? "下一版提示词" : "正文"; + return key === "composerContent" ? "下一版提示词" : "已配置字段"; +} + +function operation(value: unknown): AgentApprovalPreviewOperation | null { + return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" ? value : null; +} + +function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null { + const source = record(value); + if (!source || !Array.isArray(source.items)) return null; + const rawItems = source.items; + const items: AgentApprovalPreviewItem[] = []; + for (const raw of rawItems) { + const item = record(raw); + const op = operation(item?.operation); + const summary = text(item?.summary); + if (!item || !op || !summary) continue; + items.push({ + operation: op, + nodeId: text(item.nodeId, 120) || undefined, + nodeTitle: text(item.nodeTitle, 120) || undefined, + resultTitle: text(item.resultTitle, 120) || undefined, + nodeType: text(item.nodeType, 32) || undefined, + nodeTypeLabel: text(item.nodeTypeLabel, 48) || undefined, + targetNodeId: text(item.targetNodeId, 120) || undefined, + targetNodeTitle: text(item.targetNodeTitle, 120) || undefined, + targetNodeType: text(item.targetNodeType, 32) || undefined, + fields: Array.isArray(item.fields) ? item.fields.map((field) => text(field, 64)).filter(Boolean).slice(0, 12) : undefined, + details: Array.isArray(item.details) ? item.details.map((detail) => text(detail, 180)).filter(Boolean).slice(0, 12) : undefined, + summary, + }); + } + const title = text(source.title, 120); + const description = text(source.description, 500); + if (!title || !description || !items.length) return null; + return { kind: text(source.kind, 64) || "agent_approval", title, description, items, source: "server" }; +} + +function canvasFallback(args: RecordValue | null): AgentApprovalPresentation { + const rawOps = Array.isArray(args?.ops) ? args.ops : []; + const items: AgentApprovalPreviewItem[] = []; + for (const raw of rawOps) { + const op = record(raw); + const kind = operation(op?.type); + if (!op || !kind || kind === "generate_media") continue; + const type = text(op.nodeType, 32).toLowerCase(); + const label = nodeTypeLabel(type); + if (kind === "add_node") { + const title = text(op.title, 120) || `未命名${label || "节点"}`; + items.push({ operation: kind, nodeTitle: title, nodeType: type || undefined, nodeTypeLabel: label || undefined, fields: ["节点名称", label === "文本" || label === "Markdown" ? "正文" : "初始内容"], summary: `新增${label || "节点"}《${title}》` }); + continue; + } + if (kind === "update_node") { + const patch = record(op.patch); + const id = shortReference(op.id); + const fields = patch ? Object.keys(patch).map((key) => fieldLabel(key, type)).filter(Boolean) : []; + const resultTitle = text(patch?.title, 120); + const target = `目标节点(引用 ${id})`; + items.push({ operation: kind, nodeTitle: target, nodeType: type || undefined, nodeTypeLabel: label || undefined, resultTitle: resultTitle || undefined, fields: fields.length ? [...new Set(fields)] : ["已配置字段"], summary: `修改${label || "节点"}《${target}》的${fields.length ? [...new Set(fields)].join("、") : "已配置字段"}${resultTitle ? `,名称改为《${resultTitle}》` : ""}` }); + continue; + } + const from = shortReference(op.fromNodeId); + const to = shortReference(op.toNodeId); + items.push({ operation: kind, nodeTitle: `来源(引用 ${from})`, targetNodeTitle: `目标(引用 ${to})`, summary: `建立来源(引用 ${from})→目标(引用 ${to})的引用连线` }); + } + const counts = items.reduce >((out, item) => { out[item.operation] = (out[item.operation] || 0) + 1; return out; }, {}); + const parts = [counts.add_node ? `新增 ${counts.add_node} 个节点` : "", counts.update_node ? `修改 ${counts.update_node} 个节点` : "", counts.connect_nodes ? `建立 ${counts.connect_nodes} 条引用连线` : ""].filter(Boolean); + return { source: "fallback", kind: "canvas_mutation", title: "确认画布修改", description: parts.length ? `Agent 准备${parts.join(",")}。历史审批缺少画布快照,以下目标使用安全引用标识;无法确认具体目标时不会代替当前画布。` : "审批参数不完整,无法确认具体修改目标,请重新读取画布后再申请审批。", items, }; +} + +function mediaFallback(args: RecordValue | null, payload: RecordValue | null): AgentApprovalPresentation { + const mode = text(args?.mode, 32).toLowerCase(); + const type = mode === "video" ? "视频" : mode === "image" ? "图片" : mode === "audio" ? "音频" : "媒体"; + const references = Array.isArray(args?.referenceNodeIds) ? args.referenceNodeIds.filter((id) => typeof id === "string") : []; + const title = text(args?.title, 120) || `未命名${type}`; + const details = [text(payload?.modelName) ? `模型:${text(payload?.modelName)}` : text(args?.channelModelKey) || text(args?.logicalModelId) ? `模型:${text(args?.channelModelKey) || text(args?.logicalModelId)}` : "", references.length ? `引用 ${references.length} 个画布资产,并建立连线` : "不引用画布媒体资产", typeof args?.durationSeconds === "number" && args.durationSeconds > 0 ? `时长:${args.durationSeconds} 秒` : "", text(args?.size) ? `画幅:${text(args?.size, 40)}` : "", text(args?.quality) ? `质量:${text(args?.quality, 40)}` : "", typeof args?.videoGenerateAudio === "boolean" ? `音频:${args.videoGenerateAudio ? "开启" : "关闭"}` : ""].filter(Boolean); + return { source: "fallback", kind: "media_generation", title: `确认生成${type}`, description: `${type}草稿节点和引用连线已创建,尚未提交生成。确认规格后批准才会提交收费任务;拒绝则保留草稿,结果自动回写画布。`, items: [{ operation: "generate_media", nodeTitle: title, nodeType: mode || undefined, nodeTypeLabel: type, details, summary: `生成${type}《${title}》` }] }; +} + +export function agentApprovalPresentation(detail: unknown): AgentApprovalPresentation { const payload = record(detail); + const serverPreview = normalizeServerPreview(payload?.preview); + if (serverPreview) return serverPreview; const call = record(payload?.call); const fn = record(call?.function); const name = text(fn?.name) || text(payload?.toolName); const args = approvalArguments(fn?.arguments ?? payload?.arguments); - - if (name === "canvas_apply_ops") { - const ops = Array.isArray(args?.ops) ? args.ops.map(record).filter((op): op is RecordValue => Boolean(op)) : []; - const added = ops.filter((op) => op.type === "add_node").length; - const updated = ops.filter((op) => op.type === "update_node").length; - const connected = ops.filter((op) => op.type === "connect_nodes").length; - const action = [added ? `新增 ${added} 个节点` : "", updated ? `修改 ${updated} 个节点` : "", connected ? `建立 ${connected} 条引用连线` : ""].filter(Boolean).join(","); - const titles = ops.map((op) => text(op.title)).filter(Boolean).slice(0, 3).map((title) => title.slice(0, 64)); - return { title: "确认画布修改", description: action ? `Agent 准备${action},确认后才会写入画布。` : "Agent 准备修改当前画布,确认后才会写入。", items: titles }; - } - if (name === "generate_media") { - const type = args?.mode === "video" ? "视频" : args?.mode === "image" ? "图片" : "媒体"; - const references = Array.isArray(args?.referenceNodeIds) ? args.referenceNodeIds.filter((id) => typeof id === "string") : []; - const items = [ - text(payload?.modelName) ? `模型:${text(payload?.modelName)}` : (text(args?.channelModelKey) || text(args?.logicalModelId)) ? `模型:${text(args?.channelModelKey) || text(args?.logicalModelId)}` : "", - text(args?.title) ? `节点:${text(args?.title).slice(0, 64)}` : "", - references.length ? `引用 ${references.length} 个画布资产,并建立连线` : "不引用画布媒体资产", - typeof args?.durationSeconds === "number" && args.durationSeconds > 0 ? `时长:${args.durationSeconds} 秒` : "", - text(args?.size) ? `画幅:${text(args?.size)}` : "", - text(args?.quality) ? `质量:${text(args?.quality)}` : "", - typeof args?.videoGenerateAudio === "boolean" ? `音频:${args.videoGenerateAudio ? "开启" : "关闭"}` : "", - ].filter(Boolean); - return { title: `确认生成${type}`, description: `${type}草稿节点和引用连线已创建,尚未提交生成。确认模型与画幅后批准才会提交收费任务;拒绝则保留草稿,结果自动回写画布。`, items }; - } - return { title: "确认执行操作", description: "Agent 请求执行一项操作,确认后才会继续。", items: [] as string[] }; -} + if (name === "canvas_apply_ops") return canvasFallback(args); + if (name === "generate_media") return mediaFallback(args, payload); + return { source: "fallback", kind: "agent_approval", title: "确认执行操作", description: "Agent 请求执行一项操作,但审批参数无法识别;请重新读取画布后再申请审批。", items: [] }; +} \ No newline at end of file diff --git a/web/src/lib/canvas/agent-canvas-actions.ts b/web/src/lib/canvas/agent-canvas-actions.ts index f5e47710d..0e1482fc0 100644 --- a/web/src/lib/canvas/agent-canvas-actions.ts +++ b/web/src/lib/canvas/agent-canvas-actions.ts @@ -1,6 +1,6 @@ import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; -export type AgentCanvasAction = { nodeId: string; title: string; nodeType: string; action: string }; +export type AgentCanvasAction = { nodeId: string; title: string; nodeType: string; action: string; fields?: string[]; resultTitle?: string; targetNodeId?: string; targetTitle?: string; targetNodeType?: string }; export function agentRecord(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; @@ -17,15 +17,21 @@ export function agentCanvasActions(toolName: string, detail: unknown, references const result = agentRecord(payload.result); const args = agentToolArguments(detail); const actions: AgentCanvasAction[] = []; - const add = (id: unknown, title: unknown, kind: unknown, action: string) => { + const add = (id: unknown, title: unknown, kind: unknown, action: string, details: Partial = {}) => { if (typeof id !== "string" || !id || actions.some((item) => item.nodeId === id && item.action === action)) return; const reference = references.find((item) => item.nodeId === id); - actions.push({ nodeId: id, title: typeof title === "string" && title ? title : reference?.title || id, nodeType: typeof kind === "string" ? kind : reference?.kind || "", action }); + actions.push({ nodeId: id, title: typeof title === "string" && title ? title : reference?.title || id, nodeType: typeof kind === "string" ? kind : reference?.kind || "", action, ...details }); }; if (Array.isArray(payload.actions)) { for (const value of payload.actions) { const action = agentRecord(value); - add(action.nodeId, action.title, action.nodeType, String(action.action || "updated")); + add(action.nodeId, action.title, action.nodeType, String(action.action || "updated"), { + fields: Array.isArray(action.fields) ? action.fields.filter((field): field is string => typeof field === "string") : undefined, + resultTitle: typeof action.resultTitle === "string" ? action.resultTitle : undefined, + targetNodeId: typeof action.targetNodeId === "string" ? action.targetNodeId : undefined, + targetTitle: typeof action.targetTitle === "string" ? action.targetTitle : undefined, + targetNodeType: typeof action.targetNodeType === "string" ? action.targetNodeType : undefined, + }); } return actions; } @@ -53,7 +59,13 @@ export function agentCanvasActions(toolName: string, detail: unknown, references } export function agentCanvasActionLabel(action: AgentCanvasAction) { - const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", script: "分镜" } as Record )[action.nodeType] || ""; + const kind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record )[action.nodeType] || ""; + if (action.action === "referenced" && action.targetTitle) { + const targetKind = ({ image: "图片", video: "视频", audio: "音频", text: "文本", markdown: "Markdown", script: "分镜" } as Record )[action.targetNodeType || ""] || ""; + return `建立引用:${kind}节点《${action.title}》 → ${targetKind}节点《${action.targetTitle}》`; + } const verb = ({ created: "创建了", updated: "更新了", referenced: "引用了", read: "读取了", generating: "已提交生成:", generated: "已生成", failed: "生成未完成:" } as Record )[action.action] || "定位"; - return `${verb}${kind}节点《${action.title}》`; + const fieldText = action.fields?.length ? `(修改:${action.fields.join("、")})` : ""; + const titleText = action.resultTitle ? `,名称改为《${action.resultTitle}》` : ""; + return `${verb}${kind}节点《${action.title}》${fieldText}${titleText}`; } diff --git a/web/src/lib/canvas/agent-error-presentation.ts b/web/src/lib/canvas/agent-error-presentation.ts index 02688d966..548f1a5e2 100644 --- a/web/src/lib/canvas/agent-error-presentation.ts +++ b/web/src/lib/canvas/agent-error-presentation.ts @@ -19,3 +19,11 @@ export function agentErrorPresentation(cause: unknown, fallback = "Agent 执行 // 渠道、鉴权、配额等业务错误保留真实语义,不能按 message 猜测为服务未部署。 return { title: fallback, text: text || fallback }; } + +export function agentSubmissionErrorTitle(cause: unknown, accepted: boolean) { + if (accepted) return "运行已接收,但本地提交记录清理失败"; + const status = cause instanceof ApiError ? cause.status : undefined; + if (status && [400, 401, 403, 404, 422].includes(status)) return "请求已被服务端拒绝"; + if (status) return "服务端已返回错误;重试将核对原请求,不重复创建"; + return "未收到服务端确认;重试将核对原请求,不重复创建"; +} diff --git a/web/src/lib/canvas/canvas-node-prompt.test.ts b/web/src/lib/canvas/canvas-node-prompt.test.ts new file mode 100644 index 000000000..eeec54936 --- /dev/null +++ b/web/src/lib/canvas/canvas-node-prompt.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import "./node-registry/definitions/index.ts"; +// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。 +import { canvasNodeHasCommittedContent, writeCanvasNodePrompt } from "./canvas-node-prompt.ts"; +// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。 +import { CanvasNodeType, type CanvasNodeData } from "../../types/canvas.ts"; + +function node(type: CanvasNodeData["type"], metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData { + return { + id: `${type}-node`, + type, + title: "测试节点", + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata, + }; +} + +test("媒体节点已有结果时只更新下一版提示词草稿", () => { + for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio]) { + const current = node(type, { + content: `https://example.com/${type}`, + prompt: "已提交提示词", + composerContent: "旧草稿", + status: "success", + }); + + assert.equal(canvasNodeHasCommittedContent(current), true); + assert.deepEqual(writeCanvasNodePrompt(current, "下一版提示词").metadata, { + content: `https://example.com/${type}`, + prompt: "已提交提示词", + composerContent: "下一版提示词", + status: "success", + }); + } +}); + +test("空媒体草稿同时初始化提交提示词和编辑提示词", () => { + const current = node(CanvasNodeType.Video, { status: "idle" }); + + assert.equal(canvasNodeHasCommittedContent(current), false); + assert.deepEqual(writeCanvasNodePrompt(current, "第一版提示词").metadata, { + status: "idle", + prompt: "第一版提示词", + composerContent: "第一版提示词", + }); +}); + +test("图片仅保留 storageKey 时仍视为已有媒体结果", () => { + const current = node(CanvasNodeType.Image, { + storageKey: "canvas/image/result.png", + prompt: "已提交提示词", + }); + + assert.equal(canvasNodeHasCommittedContent(current), true); + assert.equal(writeCanvasNodePrompt(current, "新草稿").metadata?.prompt, "已提交提示词"); +}); + +test("文本提示词本身不构成已提交内容", () => { + const draft = node(CanvasNodeType.Text, { prompt: "文本草稿" }); + const committed = node(CanvasNodeType.Text, { content: "已生成文本", prompt: "已提交提示词" }); + + assert.equal(canvasNodeHasCommittedContent(draft), false); + assert.equal(canvasNodeHasCommittedContent(committed), true); + assert.equal(writeCanvasNodePrompt(draft, "新文本草稿").metadata?.prompt, "新文本草稿"); + assert.equal(writeCanvasNodePrompt(committed, "下一版文本草稿").metadata?.prompt, "已提交提示词"); +}); + +test("调用方可先清理失败任务元数据,再由统一写入器保存草稿", () => { + const current = node(CanvasNodeType.Video, { + content: "https://example.com/video.mp4", + prompt: "已提交提示词", + promptTemplateOperation: "storyboard-video", + promptTemplateVariables: { shot: "1" }, + taskId: "failed-task", + }); + const metadata = { ...current.metadata, taskId: undefined }; + + const updated = writeCanvasNodePrompt(current, "修订草稿", { + metadata, + clearPromptTemplate: true, + }); + + assert.equal(updated.metadata?.prompt, "已提交提示词"); + assert.equal(updated.metadata?.composerContent, "修订草稿"); + assert.equal(updated.metadata?.taskId, undefined); + assert.equal(updated.metadata?.promptTemplateOperation, undefined); + assert.equal(updated.metadata?.promptTemplateVariables, undefined); +}); diff --git a/web/src/lib/canvas/canvas-node-prompt.ts b/web/src/lib/canvas/canvas-node-prompt.ts new file mode 100644 index 000000000..454c9597e --- /dev/null +++ b/web/src/lib/canvas/canvas-node-prompt.ts @@ -0,0 +1,37 @@ +import { getNodeResourceKind } from "@/lib/canvas/node-registry"; +import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; + +export type WriteCanvasNodePromptOptions = { + metadata?: CanvasNodeMetadata; + clearPromptTemplate?: boolean; +}; + +/** + * A generated or authored result is committed once the node registry exposes it + * as a resource. Text-like resources additionally require persisted content so + * a draft prompt alone is not mistaken for a submitted result. + */ +export function canvasNodeHasCommittedContent(node: CanvasNodeData) { + const resourceKind = getNodeResourceKind(node); + if (resourceKind === "text") return Boolean(node.metadata?.content?.trim()); + return resourceKind !== null; +} + +/** + * Update the editable prompt draft without replacing the prompt snapshot that + * produced an existing result. Callers may provide sanitized task metadata, + * but prompt ownership remains centralized here. + */ +export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string, options: WriteCanvasNodePromptOptions = {}) { + const metadata = options.metadata ?? node.metadata; + const clearPromptTemplate = options.clearPromptTemplate ?? Boolean(metadata?.promptTemplateOperation); + const promptTemplateMetadata = clearPromptTemplate + ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } + : {}; + return { + ...node, + metadata: canvasNodeHasCommittedContent(node) + ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } + : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt }, + }; +} diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index 0d6cf21a7..fd6f0a53b 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -1,5 +1,6 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview"; +import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt"; import { getNodeResourceKind } from "@/lib/canvas/node-registry"; import { seedanceReferenceLabel } from "@/lib/seedance-video"; import type { Skill } from "@/services/api/skills"; @@ -215,19 +216,6 @@ function canvasReferenceIdentityChanged(previousReferences: CanvasResourceRefere return previousReferences.some((reference) => nextLabelByNodeId.get(reference.nodeId) !== reference.label); } -export function writeCanvasNodePrompt(node: CanvasNodeData, prompt: string) { - const hasExistingContent = (node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim())) || (node.type === CanvasNodeType.Image && Boolean(node.metadata?.content)); - const promptTemplateMetadata = node.metadata?.promptTemplateOperation - ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } - : {}; - return { - ...node, - metadata: hasExistingContent - ? { ...node.metadata, ...promptTemplateMetadata, composerContent: prompt } - : { ...node.metadata, ...promptTemplateMetadata, prompt, composerContent: prompt }, - }; -} - function removeCanvasMentionToken(value: string, token: string) { return replaceCanvasMentionToken(value, token, ""); } diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index a0f3afc26..03affccc9 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -72,6 +72,7 @@ import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-dir 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, @@ -81,7 +82,6 @@ import { normalizeCanvasNodeMentionTokens, reorderCanvasResourceConnections, replaceCanvasReferenceMentions, - writeCanvasNodePrompt, type CanvasResourceReference, } from "@/lib/canvas/canvas-resource-references"; import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay, type PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; @@ -2521,7 +2521,7 @@ function InfiniteCanvasPage() { ) : null} { + { if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; } focusCanvasNode(nodeId); }} /> diff --git a/web/src/pages/canvas/use-canvas-node-editor.ts b/web/src/pages/canvas/use-canvas-node-editor.ts index 56406762f..654bbdfb7 100644 --- a/web/src/pages/canvas/use-canvas-node-editor.ts +++ b/web/src/pages/canvas/use-canvas-node-editor.ts @@ -6,6 +6,7 @@ import { saveAs } from "file-saver"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame"; import { buildCanvasMediaDownloadFileName } from "@/lib/canvas/canvas-media-download"; +import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt"; import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain"; import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; @@ -162,16 +163,16 @@ export function useCanvasNodeEditor({ const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => { setNodes((current) => current.map((node) => { if (node.id !== nodeId) return node; - const hasExistingContent = (node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim())) || (node.type === CanvasNodeType.Image && Boolean(node.metadata?.content)); const previousPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; + const promptChanged = prompt !== previousPrompt; const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails); - const metadata = moderationFailure && prompt !== previousPrompt + const metadata = moderationFailure && promptChanged ? resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle") : node.metadata; - const promptTemplateMetadata = prompt !== previousPrompt && metadata?.promptTemplateOperation - ? { promptTemplateOperation: undefined, promptTemplateVariables: undefined } - : {}; - return { ...node, metadata: hasExistingContent ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt } }; + return writeCanvasNodePrompt(node, prompt, { + metadata, + clearPromptTemplate: promptChanged && Boolean(metadata?.promptTemplateOperation), + }); })); }, [setNodes]); diff --git a/web/src/services/api/agent.ts b/web/src/services/api/agent.ts index 16ee96fbb..ba6d13b29 100644 --- a/web/src/services/api/agent.ts +++ b/web/src/services/api/agent.ts @@ -2,12 +2,54 @@ import { http, apiBaseURL } from "@/services/api/request"; import { consumeTaskTextStream, createTaskTextStreamParser } from "@/services/api/task-text-stream"; export type AgentPermissionMode = "read_only" | "auto" | "request_approval"; -export type AgentPersonality = "务实" | "简洁" | "详细" | "温和"; +export type AgentReasoningMode = "off" | "auto" | "deep"; +export type AgentProfileScope = "user" | "project" | "canvas"; + +export type AgentProfileLayer = { + scope: AgentProfileScope; + projectId?: string; + canvasId?: string; + content: string; + revision: number; + hash: string; +}; + +export type AgentProfileView = { + revision: string; + hash: string; + layers: AgentProfileLayer[]; +}; + +export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "generate_media"; + +export type AgentApprovalPreviewItem = { + operation: AgentApprovalPreviewOperation; + nodeId?: string; + nodeTitle?: string; + resultTitle?: string; + nodeType?: string; + nodeTypeLabel?: string; + targetNodeId?: string; + targetNodeTitle?: string; + targetNodeType?: string; + fields?: string[]; + details?: string[]; + summary: string; +}; + +export type AgentApprovalPreview = { + kind: string; + title: string; + description: string; + items: AgentApprovalPreviewItem[]; +}; export type AgentApproval = { modelName?: string; approvalId: string; call: { id: string; function: { name: string; arguments: string } }; + callHash?: string; + preview?: AgentApprovalPreview; decision?: "approve" | "reject"; reason?: string; }; @@ -54,8 +96,8 @@ export class AgentStreamError extends Error { } export type CreateAgentRunInput = { - thinking?: boolean; - personality?: AgentPersonality; + reasoningMode?: AgentReasoningMode; + profileRevision?: string; canvasId: string; prompt: string; model?: string; @@ -97,7 +139,20 @@ export async function sendAgentMessage(runId: string, input: CreateAgentRunInput } export function getAgentCapabilities() { - return http.get<{ version: number; permissionModes: AgentPermissionMode[]; contextScopes: string[]; skills: boolean; writeTools: boolean }>("/agent/capabilities", { timeout: 15_000 }); + return http.get<{ version: number; permissionModes: AgentPermissionMode[]; contextScopes: string[]; skills: boolean; writeTools: boolean; capabilitySetVersion?: string; capabilitySetHash?: string; nodeTypes?: string[] }>("/agent/capabilities", { timeout: 15_000 }); +} + +export function getAgentProfile(options: { projectId?: string; canvasId?: string; scope?: AgentProfileScope } = {}) { + const params = new URLSearchParams(); + if (options.projectId) params.set("projectId", options.projectId); + if (options.canvasId) params.set("canvasId", options.canvasId); + if (options.scope) params.set("scope", options.scope); + const query = params.toString(); + return http.get (`/agent/profile${query ? `?${query}` : ""}`, { timeout: 15_000 }); +} + +export function updateAgentProfile(input: { scope: AgentProfileScope; projectId?: string; canvasId?: string; content: string; revision: number }) { + return http.patch ("/agent/profile", input, { timeout: 15_000 }); } export function getAgentRun(runId: string, signal?: AbortSignal) { diff --git a/web/test/agent-approval-presentation.test.ts b/web/test/agent-approval-presentation.test.ts index cfd3fd70c..f815881b8 100644 --- a/web/test/agent-approval-presentation.test.ts +++ b/web/test/agent-approval-presentation.test.ts @@ -4,26 +4,54 @@ import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentat describe("Agent approval presentation", () => { const args = JSON.stringify({ snapshotHash: "private-hash", ops: [ - { type: "add_node", title: "开场分镜", content: "private script" }, - { type: "update_node", title: "结尾台词", content: "private dialogue" }, + { type: "add_node", id: "image-node", nodeType: "image", title: "开场分镜", content: "private script" }, + { type: "update_node", id: "video-node-1789310237935", patch: { title: "结尾台词", content: "private dialogue" } }, ] }); - it("summarizes a persisted approval without exposing tool names or content", () => { + it("prefers the server-authored preview and exposes the target and changed fields", () => { + const view = agentApprovalPresentation({ + call: { function: { name: "canvas_apply_ops", arguments: args } }, + preview: { + kind: "canvas_mutation", + title: "确认画布修改", + description: "请确认目标节点和修改字段;批准后才会写入画布。", + items: [{ operation: "update_node", nodeId: "video-node-1789310237935", nodeTitle: "满月庆祝视频草稿", nodeType: "video", nodeTypeLabel: "视频", fields: ["节点名称", "下一版提示词"], resultTitle: "满月庆祝视频草稿(舒缓呼吸感)", summary: "修改视频《满月庆祝视频草稿》的节点名称、下一版提示词" }], + }, + }); + expect(view.source).toBe("server"); + expect(view.items[0]).toMatchObject({ nodeTitle: "满月庆祝视频草稿", nodeTypeLabel: "视频", fields: ["节点名称", "下一版提示词"] }); + expect(view.items[0].summary).toContain("满月庆祝视频草稿"); + expect(JSON.stringify(view)).not.toContain("private"); + }); + + it("summarizes a legacy persisted approval without exposing content or tool names", () => { const view = agentApprovalPresentation({ call: { function: { name: "canvas_apply_ops", arguments: args } } }); expect(view.title).toBe("确认画布修改"); expect(view.description).toContain("新增 1 个节点,修改 1 个节点"); - expect(view.items).toEqual(["开场分镜", "结尾台词"]); + expect(view.items.map((item) => item.summary).join(" ")).toContain("目标节点"); + expect(view.items.find((item) => item.operation === "update_node")?.fields).toEqual(["节点名称", "正文"]); expect(JSON.stringify(view)).not.toContain("private"); expect(JSON.stringify(view)).not.toContain("canvas_apply_ops"); + expect(JSON.stringify(view)).not.toContain("video-node-1789310237935"); + }); + + it("shows both endpoints for legacy reference connections", () => { + const view = agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: { ops: [ + { type: "connect_nodes", fromNodeId: "reference-image-node", toNodeId: "video-node" }, + ] } }); + expect(view.items[0].summary).toContain("来源"); + expect(view.items[0].summary).toContain("目标"); + expect(view.description).toContain("建立 1 条引用连线"); }); it("summarizes an event approval and media request", () => { - expect(agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: JSON.parse(args) }).items).toEqual(["开场分镜", "结尾台词"]); expect(agentApprovalPresentation({ toolName: "generate_media", arguments: { mode: "video" } }).title).toBe("确认生成视频"); }); - it("uses a safe fallback for malformed arguments", () => { - expect(agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: "{" }).description).toContain("修改当前画布"); + it("uses an explicit safe state when arguments are malformed", () => { + const view = agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: "{" }); + expect(view.description).toContain("无法确认具体修改目标"); + expect(view.description).not.toContain("修改当前画布"); }); it("shows approved media specifications and references without exposing prompts or IDs", () => { @@ -32,23 +60,13 @@ describe("Agent approval presentation", () => { videoGenerateAudio: false, referenceNodeIds: ["private-cat", "private-hero"], prompt: "private prompt", } }); expect(view.description).toContain("结果自动回写画布"); - expect(view.items).toEqual(["节点:镜头1", "引用 2 个画布资产,并建立连线", "时长:12 秒", "画幅:9:16", "质量:720p", "音频:关闭"]); + expect(view.items[0].details).toEqual(["引用 2 个画布资产,并建立连线", "时长:12 秒", "画幅:9:16", "质量:720p", "音频:关闭"]); expect(JSON.stringify(view)).not.toContain("private"); }); - it("summarizes reference connections as canvas edits without generation", () => { - const view = agentApprovalPresentation({ toolName: "canvas_apply_ops", arguments: { ops: [ - { type: "add_node", title: "视频节点" }, { type: "connect_nodes" }, { type: "connect_nodes" }, - ] } }); - expect(view.description).toContain("新增 1 个节点,建立 2 条引用连线"); - expect(view.description).not.toContain("提交任务"); + it("states when an unknown approval cannot identify a target", () => { + const view = agentApprovalPresentation({ toolName: "unknown_tool", arguments: {} }); + expect(view.items).toHaveLength(0); + expect(view.description).toContain("无法识别"); }); - - it("shows the real catalog model and states that a draft is not a submitted task", () => { - const view = agentApprovalPresentation({ modelName: "用户选择的模型", toolName: "generate_media", arguments: { mode: "video", channelModelKey: "internal-key", size: "16:9" } }); - expect(view.items).toContain("模型:用户选择的模型"); - expect(view.items).toContain("画幅:16:9"); - expect(view.description).toContain("尚未提交生成"); - expect(view.description).toContain("拒绝则保留草稿"); - }); -}); +}); \ No newline at end of file diff --git a/web/test/agent-canvas-actions.test.ts b/web/test/agent-canvas-actions.test.ts index 311d5d40c..199c12e5a 100644 --- a/web/test/agent-canvas-actions.test.ts +++ b/web/test/agent-canvas-actions.test.ts @@ -9,6 +9,32 @@ test("persisted node actions retain human-readable titles without a live canvas" expect(agentCanvasActions("canvas_apply_ops", detail).map(agentCanvasActionLabel)).toEqual(["创建了视频节点《满月动画》", "引用了图片节点《满月照片》"]); }); +test("server-authored canvas actions explain updated fields, renamed nodes, and both connection endpoints", () => { + const detail = JSON.parse(JSON.stringify({ actions: [ + { + action: "updated", + nodeId: "video", + title: "旧名", + nodeType: "video", + fields: ["节点名称", "下一版提示词"], + resultTitle: "新名", + }, + { + action: "referenced", + nodeId: "image", + title: "参考图", + nodeType: "image", + targetNodeId: "video", + targetTitle: "目标视频", + targetNodeType: "video", + }, + ] })); + expect(agentCanvasActions("canvas_apply_ops", detail).map(agentCanvasActionLabel)).toEqual([ + "更新了视频节点《旧名》(修改:节点名称、下一版提示词),名称改为《新名》", + "建立引用:图片节点《参考图》 → 视频节点《目标视频》", + ]); +}); + test("legacy generation failures retain node name and never claim creation or reference success", () => { const detail = { eventType: "tool_failed", arguments: JSON.stringify({ nodeId: "video", title: "满月动画", mode: "video", referenceNodeIds: ["image"] }), result: { phase: "admission", taskSubmitted: false } }; expect(agentCanvasActions("generate_media", detail).map(agentCanvasActionLabel)).toEqual(["生成未完成:视频节点《满月动画》"]); diff --git a/web/test/agent-panel-layout.test.ts b/web/test/agent-panel-layout.test.ts index e833ae2b1..4b0ffedec 100644 --- a/web/test/agent-panel-layout.test.ts +++ b/web/test/agent-panel-layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { changeAgentPanelLayout, clampAgentPanelLayout, restoreAgentPanelLayout } from "@/lib/canvas/agent-panel-layout"; -import { agentErrorPresentation } from "@/lib/canvas/agent-error-presentation"; +import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation"; import { ApiError } from "@/services/api/request"; const viewport = { width: 1280, height: 800 }; @@ -64,4 +64,10 @@ describe("Agent error semantics", () => { it("reports unimplemented endpoints without retry promises", () => { expect(agentErrorPresentation(new ApiError("Not implemented", { status: 501 })).title).toBe("当前 Agent 能力尚未开放"); }); + it("distinguishes confirmed server failures from missing responses", () => { + expect(agentSubmissionErrorTitle(new ApiError("系统处理失败", { status: 500 }), false)).toBe("服务端已返回错误;重试将核对原请求,不重复创建"); + expect(agentSubmissionErrorTitle(new ApiError("参数错误", { status: 400 }), false)).toBe("请求已被服务端拒绝"); + expect(agentSubmissionErrorTitle(new TypeError("network failed"), false)).toBe("未收到服务端确认;重试将核对原请求,不重复创建"); + expect(agentSubmissionErrorTitle(undefined, true)).toBe("运行已接收,但本地提交记录清理失败"); + }); });