diff --git a/AGENTS.md b/AGENTS.md index 79710404b..48621a5c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ - TanStack Query 使用稳定的业务 `queryKey`,写操作成功后主动 invalidate/refetch 相关 query;不要用页面级 `useEffect` 手工复制整个缓存。 - Zustand store 只保存跨页面状态和持久化配置;页面临时状态留在页面,媒体大对象不要塞进 localStorage。 - 生成任务、资源、画布、素材的本地缓存是后端不可用时的降级,不代表后端已保存;写路径必须区分“本地缓存成功”和“服务端持久化成功”。 -- 媒体删除不会自动清理 OSS 远端对象;涉及删除提示和文档时必须说明这个边界。 +- 素材删除会先校验项目、画布、任务、其他素材和相关业务记录的资源引用;无占用时同步清理服务器本地文件或 OSS/COS 对象,物理删除失败必须保留素材记录并返回明确错误。画布节点等其他业务记录的单独删除仍不自动回收资源。 ## 6. 前端 UI 与设计系统 @@ -208,6 +208,6 @@ ## 13. 当前边界 - 画布和素材支持登录后端同步;localForage 仍是缓存及后端/OSS 不可用时的降级存储。 -- 媒体资源支持私有 OSS 或后端文件存储;删除节点或素材不会自动清理 OSS 远端对象。 +- 媒体资源支持私有阿里云 OSS、腾讯云 COS 或后端文件存储;删除素材会在无其他业务引用时同步清理对应物理对象,删除画布节点等其他记录本身不主动回收资源。 - 用户 AI API Key 保存在浏览器本地,任务创建时可能提交到自部署后端;安全说明必须强调 HTTPS 和可信部署。 - Docker 静态资源和生产部署仍需按待测试清单验证,不得写成已全面生产验证。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6379c23..3fd85ead4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- 火山方舟 Seedream 图片生成显式关闭“AI 生成”水印,并由后端下载方舟 CDN 结果后写入服务器或对象存储,恢复素材库自动入库。 +- 项目开源协议由 AGPL-3.0 调整为 MIT,保留上游及项目贡献者版权声明。 +- 修复任务中心等资源入口仍由应用服务器转发对象存储媒体的问题:配置 CDN 加速域名后,登录资源、公开画布分享和管理员媒体预览统一重定向到 CDN;内部 Blob 读取保留同源代理兼容。 + +## v1.0.59 + +- 画布媒体工作流支持批量连线、失败重试与模型能力增强。 +- 修复 PostgreSQL 素材同步无法保存稳定生成 ID 的问题,素材主键及关联字段扩容至 80 个字符,并增加输入边界校验。 +- 项目贡献者列表新增 dyh 与 kyori 资料。 + ## v1.0.58 - 管理后台“功能开放”新增自定义渠道开关,关闭后保留用户已有配置,但隐藏配置入口、过滤自定义模型,并拦截模型目录拉取、渠道中转与新的异步生成任务。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c127fa7f1..3fbd732c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,4 +18,4 @@ - 涉及上传、外部请求或模型调用时,必须说明大小、频率、费用和 SSRF 边界。 - 用户可见文案使用中文;核心业务入口和安全边界使用简短中文注释说明原因。 -Pull Request 应包含改动摘要、风险、验证方式和必要截图。提交即表示你有权按本项目 AGPL-3.0 许可证贡献相关代码或素材,并保留已有上游署名和许可证通知。 +Pull Request 应包含改动摘要、风险、验证方式和必要截图。提交即表示你有权按本项目 MIT 许可证贡献相关代码或素材,并保留已有上游署名和许可证通知。 diff --git a/LICENSE b/LICENSE index 29ebfa545..77cd9dbea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,22 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. \ No newline at end of file +MIT License + +Copyright (c) 2026 basketikun +Copyright (c) 2026 ddcat and Open AI Canvas contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE index dff3963be..cf0666c83 100644 --- a/NOTICE +++ b/NOTICE @@ -1,7 +1,7 @@ Open AI Canvas Copyright (C) 2026 ddcat -This program is licensed under the GNU Affero General Public License v3.0. +This program is licensed under the MIT License. The complete license text is provided in LICENSE. This project is a derivative work based on: @@ -13,7 +13,10 @@ This project is a derivative work based on: The upstream project is maintained under the basketikun account. The base commit is authored by HouYunFei. Upstream authors and contributors retain -their copyright and attribution for their respective contributions. +their copyright and attribution for their respective contributions. The +upstream project was relicensed to MIT in commit +890ba95858bbb13496d23978003716656109abb2; its copyright and permission notice +are preserved in LICENSE. Material changes in this repository are maintained by ddcat. They include a multi-user Go backend, task processing and analytics, private resource diff --git a/README.md b/README.md index b8c2fb8fe..7bbeb744d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

GitHub stars Version - License + License

一个故事也许始于一页小说、一个人物,或一句还没写完的对白。影策从章节中梳理角色与情节,让人物的外观、声音和气质成为可复用的角色资产,再把分镜、图片、视频和音频组织在同一张画布上。从最初的文字到可以被看见、被听见的镜头,创作者始终掌握故事的方向。 @@ -41,6 +41,10 @@ | delve-s | delve-s | [3013141136@qq.com](mailto:3013141136@qq.com) | 我亦无他,惟手熟尔 | | CyrusAuyeung | CyrusAuyeung | [cyrusauyeungho@gmail.com](mailto:cyrusauyeungho@gmail.com) | HKUST(GZ) UG | | 奶大佬 | 奶大佬 | [1304634970@qq.com](mailto:1304634970@qq.com) | 人生就是要不断的探索 | +| dyh | dyh | [1613203335@qq.com](mailto:1613203335@qq.com) | 无 | +| kyori | kyori | [1771634408@qq.com](mailto:1771634408@qq.com) | 励志成为未来最好用的画布仓库的贡献者 | +| Bowen | Bowen | [admin@bowen.games](mailto:admin@bowen.games) | 剑走偏峰,雷厉风行。 | +| ken | ken | [2506802@qq.com](mailto:2506802@qq.com) | 走自己的路 | ## 主要功能 @@ -256,7 +260,7 @@ docker compose -f docker-compose.local.yml up -d --build - 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。 - 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。 -- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权;删除业务记录不会自动清理远端对象。 +- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名。上传继续使用 Endpoint,配置 CDN 后登录资源、公开画布分享及管理后台媒体的下载与预览会在权限校验后统一重定向到 CDN;需要同源 Blob 的内部读取仍由后端代理,但后端同样通过 CDN 取源。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权,并为需要浏览器跨域读取的场景配置 CORS。用户删除素材时,后端会先检查项目、画布、任务、其他素材、工作流、风格和声音等引用;存在占用时保留素材并返回具体来源,不存在占用时同步删除对应的本地文件或 OSS/COS 对象及资源记录,物理对象删除失败也会阻止素材记录删除;删除画布、节点等其他业务记录仍不会自动清理远端对象。 - 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。 ## 公网部署安全 @@ -280,4 +284,4 @@ docker compose -f docker-compose.local.yml up -d --build 本项目基于 [basketikun/infinite-canvas](https://github.com/basketikun/infinite-canvas) `v0.5.0`(提交 `568f0f1838df8de31fe885a4e130e2f346dd14ab`)进行二次开发。上游项目由 `basketikun` 维护,该基线提交作者为 `HouYunFei`;上游作者和贡献者继续保留其对应代码的权利与署名。 -漏洞请按 [SECURITY.md](SECURITY.md) 提交。项目采用 [AGPL-3.0](LICENSE) 协议。 +漏洞请按 [SECURITY.md](SECURITY.md) 提交。项目采用 [MIT](LICENSE) 协议。 diff --git a/VERSION b/VERSION index ab18d35a6..1e440c0b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.58 +v1.0.59 diff --git a/assets/user-bowen.jpg b/assets/user-bowen.jpg new file mode 100644 index 000000000..89037e3ae Binary files /dev/null and b/assets/user-bowen.jpg differ diff --git a/assets/user-dyh.jpg b/assets/user-dyh.jpg new file mode 100644 index 000000000..6b8267779 Binary files /dev/null and b/assets/user-dyh.jpg differ diff --git a/assets/user-ken.jpg b/assets/user-ken.jpg new file mode 100644 index 000000000..ed147ea25 Binary files /dev/null and b/assets/user-ken.jpg differ diff --git a/assets/user-kyori.jpg b/assets/user-kyori.jpg new file mode 100644 index 000000000..cb89f87c8 Binary files /dev/null and b/assets/user-kyori.jpg differ diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 22faed045..e2d95e74e 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -1,6 +1,10 @@ package database import ( + "database/sql" + "errors" + "fmt" + "infinite-canvas/backend/internal/model" "gorm.io/gorm" @@ -72,6 +76,9 @@ func MigrateSchema(db *gorm.DB) error { return err } } + if err := widenPostgresAssetIDColumns(db); err != nil { + return err + } if err := db.AutoMigrate(Models()...); err != nil { return err } @@ -84,3 +91,44 @@ func MigrateSchema(db *gorm.DB) error { } return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error } + +type varcharColumnMigration struct { + table string + column string + statement string +} + +var assetIDColumnMigrations = []varcharColumnMigration{ + {table: "assets", column: "id", statement: `ALTER TABLE "assets" ALTER COLUMN "id" TYPE varchar(80)`}, + {table: "project_asset_links", column: "asset_id", statement: `ALTER TABLE "project_asset_links" ALTER COLUMN "asset_id" TYPE varchar(80)`}, + {table: "project_asset_candidates", column: "resolved_asset_id", statement: `ALTER TABLE "project_asset_candidates" ALTER COLUMN "resolved_asset_id" TYPE varchar(80)`}, + {table: "asset_versions", column: "asset_id", statement: `ALTER TABLE "asset_versions" ALTER COLUMN "asset_id" TYPE varchar(80)`}, +} + +// PostgreSQL Migrator 跳过主键列变更,素材 ID 扩容必须在 AutoMigrate 前显式执行。 +func widenPostgresAssetIDColumns(db *gorm.DB) error { + if db.Dialector.Name() != "postgres" { + return nil + } + for _, migration := range assetIDColumnMigrations { + var currentLength sql.NullInt64 + err := db.Raw( + "SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + migration.table, + migration.column, + ).Row().Scan(¤tLength) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return fmt.Errorf("检查 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err) + } + if !currentLength.Valid || currentLength.Int64 >= model.AssetIDMaxLength { + continue + } + if err := db.Exec(migration.statement).Error; err != nil { + return fmt.Errorf("扩容 PostgreSQL 素材 ID 列 %s.%s:%w", migration.table, migration.column, err) + } + } + return nil +} diff --git a/backend/internal/database/schema_postgres_test.go b/backend/internal/database/schema_postgres_test.go new file mode 100644 index 000000000..388f354b2 --- /dev/null +++ b/backend/internal/database/schema_postgres_test.go @@ -0,0 +1,106 @@ +package database + +import ( + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestPostgresAssetIDMigration(t *testing.T) { + dsn := strings.TrimSpace(os.Getenv("CANVAS_TEST_POSTGRES_DSN")) + if dsn == "" { + t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured") + } + + base, err := Open(Config{Driver: "postgres", DSN: dsn}) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + baseSQL, err := base.DB() + if err != nil { + t.Fatalf("postgres sql db: %v", err) + } + defer baseSQL.Close() + + schemaName := fmt.Sprintf("asset_id_migration_%d", time.Now().UnixNano()) + if err := base.Exec(`CREATE SCHEMA "` + schemaName + `"`).Error; err != nil { + t.Fatalf("create test schema: %v", err) + } + defer func() { + if err := base.Exec(`DROP SCHEMA IF EXISTS "` + schemaName + `" CASCADE`).Error; err != nil { + t.Errorf("drop test schema: %v", err) + } + }() + + testDSN, err := postgresDSNWithSearchPath(dsn, schemaName) + if err != nil { + t.Fatalf("test postgres dsn: %v", err) + } + db, err := Open(Config{Driver: "postgres", DSN: testDSN}) + if err != nil { + t.Fatalf("open test schema: %v", err) + } + dbSQL, err := db.DB() + if err != nil { + t.Fatalf("test schema sql db: %v", err) + } + defer dbSQL.Close() + + for _, statement := range []string{ + `CREATE TABLE assets (id varchar(36) PRIMARY KEY)`, + `CREATE TABLE project_asset_links (id varchar(36) PRIMARY KEY, asset_id varchar(36))`, + `CREATE TABLE project_asset_candidates (id varchar(36) PRIMARY KEY, resolved_asset_id varchar(36))`, + `CREATE TABLE asset_versions (id varchar(36) PRIMARY KEY, asset_id varchar(36))`, + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("create legacy table: %v", err) + } + } + if err := MigrateSchema(db); err != nil { + t.Fatalf("migrate schema: %v", err) + } + + for _, migration := range assetIDColumnMigrations { + var length int64 + if err := db.Raw( + "SELECT character_maximum_length FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", + migration.table, + migration.column, + ).Scan(&length).Error; err != nil { + t.Fatalf("read migrated column %s.%s: %v", migration.table, migration.column, err) + } + if length != model.AssetIDMaxLength { + t.Fatalf("%s.%s length = %d, want %d", migration.table, migration.column, length, model.AssetIDMaxLength) + } + } + + assetID := "generation_" + strings.Repeat("a", 64) + if err := db.Create(&model.Asset{ID: assetID, UserID: "user-1", Kind: "image"}).Error; err != nil { + t.Fatalf("insert deterministic generation asset: %v", err) + } + if err := db.Create(&model.ProjectAssetLink{ID: "link-1", ProjectID: "project-1", AssetID: assetID}).Error; err != nil { + t.Fatalf("insert project asset link: %v", err) + } + if err := db.Create(&model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: "project-1", ResolvedAssetID: assetID}).Error; err != nil { + t.Fatalf("insert project asset candidate: %v", err) + } + if err := db.Create(&model.AssetVersion{ID: "version-1", AssetID: assetID, Version: 1}).Error; err != nil { + t.Fatalf("insert asset version: %v", err) + } +} + +func postgresDSNWithSearchPath(dsn string, schemaName string) (string, error) { + parsed, err := url.Parse(dsn) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("search_path", schemaName) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} diff --git a/backend/internal/database/schema_test.go b/backend/internal/database/schema_test.go new file mode 100644 index 000000000..8945608b3 --- /dev/null +++ b/backend/internal/database/schema_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "fmt" + "strings" + "sync" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm/schema" +) + +func TestAssetIDColumnsUseSharedLimit(t *testing.T) { + tests := []struct { + value any + field string + }{ + {value: &model.Asset{}, field: "ID"}, + {value: &model.ProjectAssetLink{}, field: "AssetID"}, + {value: &model.ProjectAssetCandidate{}, field: "ResolvedAssetID"}, + {value: &model.AssetVersion{}, field: "AssetID"}, + } + + for _, test := range tests { + parsed, err := schema.Parse(test.value, &sync.Map{}, schema.NamingStrategy{}) + if err != nil { + t.Fatalf("parse schema: %v", err) + } + field := parsed.LookUpField(test.field) + if field == nil { + t.Fatalf("field %s not found in %s", test.field, parsed.Table) + } + if field.Size != model.AssetIDMaxLength { + t.Fatalf("%s.%s size = %d, want %d", parsed.Table, field.DBName, field.Size, model.AssetIDMaxLength) + } + } +} + +func TestPostgresAssetIDMigrationsCoverEveryAssetIDColumn(t *testing.T) { + want := map[string]bool{ + "assets.id": false, + "project_asset_links.asset_id": false, + "project_asset_candidates.resolved_asset_id": false, + "asset_versions.asset_id": false, + } + for _, migration := range assetIDColumnMigrations { + key := migration.table + "." + migration.column + if _, exists := want[key]; !exists { + t.Fatalf("unexpected asset ID migration %s", key) + } + if !strings.Contains(migration.statement, fmt.Sprintf("varchar(%d)", model.AssetIDMaxLength)) { + t.Fatalf("asset ID migration %s does not use limit %d", key, model.AssetIDMaxLength) + } + want[key] = true + } + for key, covered := range want { + if !covered { + t.Fatalf("missing asset ID migration %s", key) + } + } +} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 794cd1c48..3fc62eb24 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -590,11 +590,19 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - stream, err := svc.OpenAdminAPICallLogMediaRange(user, c.Param("id"), c.GetHeader("Range")) + delivery, err := svc.PrepareAdminAPICallLogMediaDelivery(user, c.Param("id"), c.GetHeader("Range")) if err != nil { failService(c, err) return } + if delivery.RedirectURL != "" { + c.Header("Cache-Control", "private, no-store") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) + return + } + stream := delivery.Stream defer stream.Body.Close() resource := stream.Resource mimeType := resource.MimeType @@ -707,8 +715,10 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, modelName := proxyRequestModelForPath(path, c.GetHeader("Content-Type"), body) protocol := model.ChannelInterfaceType("") capability := "text" + var channelModel *model.ChannelModel if !(c.Request.Method == http.MethodGet && path == "/models") { - channelModel, modelErr := svc.SystemChannelModel(channel.ID, modelName) + var modelErr error + channelModel, modelErr = svc.SystemChannelModel(channel.ID, modelName) if modelErr != nil || channelModel.Protocol == "" { fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置")) return @@ -720,6 +730,13 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, fail(c, http.StatusForbidden, err) return } + if channelModel != nil && channelModel.BillingMode == "token" && protocol == model.ChannelInterfaceChatCompletion { + body, err = service.EnsureChatCompletionStreamUsageRequest(body) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + } billingOrderID := "" query := c.Request.URL.Query() for _, key := range []string{"key", "api_key", "access_token", "token"} { diff --git a/backend/internal/handler/canvas_share.go b/backend/internal/handler/canvas_share.go index 832eb473b..23e6b38eb 100644 --- a/backend/internal/handler/canvas_share.go +++ b/backend/internal/handler/canvas_share.go @@ -78,11 +78,21 @@ func RegisterCanvasShareRoutes(r *gin.RouterGroup, svc *service.Service) { if !enforceRateLimit(c, "public-canvas-resource:"+c.ClientIP(), 300, time.Minute) { return } - stream, err := svc.OpenSharedCanvasResourceRange(c.Param("token"), c.Param("resourceId"), c.GetHeader("Range")) + delivery, err := svc.PrepareSharedCanvasResourceDelivery(c.Param("token"), c.Param("resourceId"), c.GetHeader("Range")) if err != nil { fail(c, http.StatusNotFound, errors.New("分享资源不存在")) return } + if delivery.RedirectURL != "" { + c.Header("Cache-Control", "no-store") + c.Header("Content-Security-Policy", "sandbox") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Robots-Tag", "noindex, nofollow") + c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) + return + } + stream := delivery.Stream defer stream.Body.Close() resource := stream.Resource mimeType := resource.MimeType diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 4a4818cef..295f270c5 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -214,24 +214,23 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - resource, err := svc.Resource(user.ID, c.Param("id")) + delivery, err := svc.PrepareResourceDelivery(user.ID, c.Param("id"), service.ResourceDeliveryOptions{ + ForceDirect: c.Query("direct") == "1", + ForceProxy: c.Query("proxy") == "1", + }) if err != nil { - fail(c, http.StatusNotFound, err) + failService(c, err) return } - if c.Query("direct") == "1" && resource.Provider != "local" { - directURL, err := svc.DirectResourceURL(user.ID, resource.ID) - if err != nil { - failService(c, err) - return - } - // 签名 URL 不进入应用、代理或浏览器缓存,也不作为后续请求的 Referer 泄露。 + if delivery.RedirectURL != "" { + // CDN 或对象存储直连地址不进入应用缓存,也不作为后续请求的 Referer 泄露。 c.Header("Cache-Control", "private, no-store") c.Header("Referrer-Policy", "no-referrer") c.Header("X-Content-Type-Options", "nosniff") - c.Redirect(http.StatusTemporaryRedirect, directURL) + c.Redirect(http.StatusTemporaryRedirect, delivery.RedirectURL) return } + resource := delivery.Resource etag := resourceResponseETag(resource) // 私有资源允许浏览器保存响应,但每次复用前必须重新鉴权;304 会在读取 OSS 前返回。 c.Header("Cache-Control", "private, no-cache") diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 82875dd30..9f3bd5431 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -2,6 +2,8 @@ package model import "time" +const AssetIDMaxLength = 80 + type Resource struct { ID string `json:"id" gorm:"primaryKey;size:36"` UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` @@ -10,7 +12,7 @@ type Resource struct { Provider string `json:"provider" gorm:"size:24"` Endpoint string `json:"endpoint"` Bucket string `json:"bucket" gorm:"size:160"` - // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。 + // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的存储与密钥;同一存储位置的 CDN 域名可跟随当前配置。 StorageSettingID string `json:"-" gorm:"index;size:36"` ObjectKey string `json:"objectKey" gorm:"index"` PublicURL string `json:"publicUrl"` @@ -26,7 +28,7 @@ type Resource struct { } type Asset struct { - ID string `json:"id" gorm:"primaryKey;size:36"` + ID string `json:"id" gorm:"primaryKey;size:80"` UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` Kind string `json:"kind" gorm:"index;size:24"` Category AssetCategory `json:"category" gorm:"index;size:32"` @@ -41,7 +43,7 @@ type Asset struct { type ProjectAssetLink struct { ID string `json:"id" gorm:"primaryKey;size:36"` ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` + AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_project_asset_links_unique,priority:2"` FolderID string `json:"folderId,omitempty" gorm:"index;size:36"` Position int `json:"position" gorm:"index"` CreatedAt time.Time `json:"createdAt"` @@ -55,6 +57,7 @@ type ProjectAssetFolder struct { Name string `json:"name" gorm:"size:240"` NameKey string `json:"-" gorm:"size:240;uniqueIndex:idx_project_asset_folders_sibling_name,priority:3"` Style string `json:"style" gorm:"size:24"` + Theme string `json:"theme" gorm:"size:24"` Position int `json:"position" gorm:"index"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -69,14 +72,14 @@ type ProjectAssetCandidate struct { Category AssetCategory `json:"category" gorm:"index;size:32"` Status string `json:"status" gorm:"index;size:32"` DetailsJSON string `json:"detailsJson" gorm:"type:text"` - ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` + ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:80"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type AssetVersion struct { ID string `json:"id" gorm:"primaryKey;size:36"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` + AssetID string `json:"assetId" gorm:"index;size:80;uniqueIndex:idx_asset_versions_number,priority:1"` Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` Status AssetVersionStatus `json:"status" gorm:"index;size:24"` DefinitionJSON string `json:"definitionJson" gorm:"type:text"` diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 6319bc127..bb4f048e7 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -854,19 +854,7 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error { } func (r *Repository) DeleteAsset(userID string, id string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", id) - if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil { - return err - } - if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil { - return err - } - if err := tx.Where("asset_id = ?", id).Delete(&model.AssetVersion{}).Error; err != nil { - return err - } - return tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", id, userID).Error - }) + return r.DeleteAssetAndResources(userID, id, nil) } func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error { @@ -1228,7 +1216,7 @@ func (r *Repository) UpdateProjectAssetFolder(folder *model.ProjectAssetFolder) return r.db.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.ProjectAssetFolder{}). Where("id = ? AND project_id = ?", folder.ID, folder.ProjectID). - Updates(map[string]any{"parent_id": folder.ParentID, "name": folder.Name, "name_key": folder.NameKey, "style": folder.Style, "position": folder.Position, "updated_at": folder.UpdatedAt}) + Updates(map[string]any{"parent_id": folder.ParentID, "name": folder.Name, "name_key": folder.NameKey, "style": folder.Style, "theme": folder.Theme, "position": folder.Position, "updated_at": folder.UpdatedAt}) if result.Error != nil { return result.Error } diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go new file mode 100644 index 000000000..36767dcb6 --- /dev/null +++ b/backend/internal/repository/resource_reference.go @@ -0,0 +1,273 @@ +package repository + +import ( + "strings" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +// ResourceReferenceDocument 是资源删除校验使用的只读业务文档快照。 +// JSON 中的精确资源 ID 识别由 service 完成,repository 只负责缩小候选记录。 +type ResourceReferenceDocument struct { + Kind string + ID string + Title string + PrimaryJSON string + SecondaryJSON string +} + +type ResourceDirectReference struct { + Kind string + ID string + Title string + ResourceID string +} + +type ResourceReferenceSnapshot struct { + Documents []ResourceReferenceDocument + Direct []ResourceDirectReference +} + +func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, []model.AssetRepresentation, error) { + var versions []model.AssetVersion + if err := r.db.Where("asset_id = ?", assetID).Find(&versions).Error; err != nil { + return nil, nil, err + } + if len(versions) == 0 { + return versions, nil, nil + } + versionIDs := make([]string, 0, len(versions)) + for _, version := range versions { + versionIDs = append(versionIDs, version.ID) + } + var representations []model.AssetRepresentation + if err := r.db.Where("asset_version_id IN ?", versionIDs).Find(&representations).Error; err != nil { + return nil, nil, err + } + return versions, representations, nil +} + +func (r *Repository) ResourcesForUserIDs(userID string, resourceIDs []string) ([]model.Resource, error) { + if len(resourceIDs) == 0 { + return []model.Resource{}, nil + } + var resources []model.Resource + err := r.db.Where("user_id = ? AND id IN ?", userID, resourceIDs).Find(&resources).Error + return resources, err +} + +// ResourceStorageReferenceCount 返回未包含在 excludedResourceIDs 中、但指向同一物理对象的资源记录数。 +// 这用于兼容历史数据中多个 Resource 行复用一个对象路径的情况。 +func (r *Repository) ResourceStorageReferenceCount(resource *model.Resource, excludedResourceIDs []string) (int64, error) { + if resource == nil { + return 0, nil + } + query := r.db.Model(&model.Resource{}). + Where("endpoint = ? AND bucket = ? AND object_key = ?", resource.Endpoint, resource.Bucket, resource.ObjectKey) + if strings.TrimSpace(resource.Provider) == "" || strings.EqualFold(strings.TrimSpace(resource.Provider), "local") { + query = query.Where("provider IN ?", []string{"", "local"}) + } else { + query = query.Where("provider = ?", resource.Provider) + } + if len(excludedResourceIDs) > 0 { + query = query.Where("id NOT IN ?", excludedResourceIDs) + } + var count int64 + err := query.Count(&count).Error + return count, err +} + +func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID string, resourceIDs []string) (ResourceReferenceSnapshot, error) { + snapshot := ResourceReferenceSnapshot{Documents: []ResourceReferenceDocument{}, Direct: []ResourceDirectReference{}} + if len(resourceIDs) == 0 { + return snapshot, nil + } + + var assets []model.Asset + assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID) + if err := resourceTextQuery(assetQuery, []string{"payload_json"}, resourceIDs).Find(&assets).Error; err != nil { + return snapshot, err + } + for _, asset := range assets { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: asset.ID, Title: asset.Title, PrimaryJSON: asset.PayloadJSON}) + } + + var canvases []model.CanvasProject + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"payload_json"}, resourceIDs).Find(&canvases).Error; err != nil { + return snapshot, err + } + for _, canvas := range canvases { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "画布", ID: canvas.ID, Title: canvas.Title, PrimaryJSON: canvas.PayloadJSON}) + } + + var tasks []model.Task + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"input_json", "result_json"}, resourceIDs).Find(&tasks).Error; err != nil { + return snapshot, err + } + for _, task := range tasks { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务", ID: task.ID, Title: task.Prompt, PrimaryJSON: task.InputJSON, SecondaryJSON: task.ResultJSON}) + } + + var projects []model.Project + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"style_profile_json"}, resourceIDs).Find(&projects).Error; err != nil { + return snapshot, err + } + for _, project := range projects { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "项目", ID: project.ID, Title: project.Name, PrimaryJSON: project.StyleProfileJSON}) + } + + var styles []model.StyleProfile + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"cover_url", "profile_json"}, resourceIDs).Find(&styles).Error; err != nil { + return snapshot, err + } + for _, style := range styles { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "风格", ID: style.ID, Title: style.Name, PrimaryJSON: style.CoverURL, SecondaryJSON: style.ProfileJSON}) + } + + type joinedDocument struct { + ID string + Title string + PrimaryJSON string + SecondaryJSON string + } + var versions []joinedDocument + versionQuery := r.db.Table("asset_versions"). + Select("asset_versions.id, assets.title, asset_versions.definition_json AS primary_json"). + Joins("JOIN assets ON assets.id = asset_versions.asset_id"). + Where("assets.user_id = ? AND assets.id <> ?", userID, excludingAssetID) + if err := resourceTextQuery(versionQuery, []string{"asset_versions.definition_json"}, resourceIDs).Scan(&versions).Error; err != nil { + return snapshot, err + } + for _, version := range versions { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: version.ID, Title: version.Title, PrimaryJSON: version.PrimaryJSON}) + } + + var candidates []joinedDocument + candidateQuery := r.db.Table("project_asset_candidates"). + Select("project_asset_candidates.id, projects.name AS title, project_asset_candidates.details_json AS primary_json"). + Joins("JOIN projects ON projects.id = project_asset_candidates.project_id"). + Where("projects.user_id = ?", userID) + if err := resourceTextQuery(candidateQuery, []string{"project_asset_candidates.details_json"}, resourceIDs).Scan(&candidates).Error; err != nil { + return snapshot, err + } + for _, candidate := range candidates { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "项目", ID: candidate.ID, Title: candidate.Title, PrimaryJSON: candidate.PrimaryJSON}) + } + + var steps []joinedDocument + stepQuery := r.db.Table("workflow_step_instances"). + Select("workflow_step_instances.id, workflow_step_instances.name AS title, workflow_step_instances.input_json AS primary_json, workflow_step_instances.output_json AS secondary_json"). + Joins("JOIN workflow_instances ON workflow_instances.id = workflow_step_instances.workflow_instance_id"). + Joins("JOIN projects ON projects.id = workflow_instances.project_id"). + Where("projects.user_id = ?", userID) + if err := resourceTextQuery(stepQuery, []string{"workflow_step_instances.input_json", "workflow_step_instances.output_json"}, resourceIDs).Scan(&steps).Error; err != nil { + return snapshot, err + } + for _, step := range steps { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工作流", ID: step.ID, Title: step.Title, PrimaryJSON: step.PrimaryJSON, SecondaryJSON: step.SecondaryJSON}) + } + + type joinedRepresentation struct { + ID string + Title string + ResourceID string + } + var representations []joinedRepresentation + if err := r.db.Table("asset_representations"). + Select("asset_representations.id, assets.title, asset_representations.resource_id"). + Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id"). + Joins("JOIN assets ON assets.id = asset_versions.asset_id"). + Where("assets.user_id = ? AND assets.id <> ? AND asset_representations.resource_id IN ?", userID, excludingAssetID, resourceIDs). + Scan(&representations).Error; err != nil { + return snapshot, err + } + for _, representation := range representations { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "素材", ID: representation.ID, Title: representation.Title, ResourceID: representation.ResourceID}) + } + + var voices []model.VoiceProfile + if err := r.db.Where("user_id = ? AND sample_resource_id IN ?", userID, resourceIDs).Find(&voices).Error; err != nil { + return snapshot, err + } + for _, voice := range voices { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "声音", ID: voice.ID, Title: voice.Name, ResourceID: voice.SampleResourceID}) + } + return snapshot, nil +} + +func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]ResourceDirectReference, error) { + type projectReference struct { + ID string + Title string + } + var projects []projectReference + if err := r.db.Table("project_asset_links"). + Distinct("projects.id, projects.name AS title"). + Joins("JOIN projects ON projects.id = project_asset_links.project_id"). + Where("projects.user_id = ? AND project_asset_links.asset_id = ?", userID, assetID). + Scan(&projects).Error; err != nil { + return nil, err + } + var shotProjects []projectReference + if err := r.db.Table("shot_asset_references"). + Distinct("projects.id, projects.name AS title"). + Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id"). + Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). + Joins("JOIN projects ON projects.id = shots.project_id"). + Where("projects.user_id = ? AND asset_versions.asset_id = ?", userID, assetID). + Scan(&shotProjects).Error; err != nil { + return nil, err + } + var candidateProjects []projectReference + if err := r.db.Table("project_asset_candidates"). + Distinct("projects.id, projects.name AS title"). + Joins("JOIN projects ON projects.id = project_asset_candidates.project_id"). + Where("projects.user_id = ? AND project_asset_candidates.resolved_asset_id = ?", userID, assetID). + Scan(&candidateProjects).Error; err != nil { + return nil, err + } + result := make([]ResourceDirectReference, 0, len(projects)+len(shotProjects)+len(candidateProjects)) + for _, project := range append(append(projects, shotProjects...), candidateProjects...) { + result = append(result, ResourceDirectReference{Kind: "项目", ID: project.ID, Title: project.Title}) + } + return result, nil +} + +func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID) + if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil { + return err + } + if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil { + return err + } + if err := tx.Where("asset_id = ?", assetID).Delete(&model.AssetVersion{}).Error; err != nil { + return err + } + if err := tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", assetID, userID).Error; err != nil { + return err + } + if len(resourceIDs) == 0 { + return nil + } + return tx.Where("user_id = ? AND id IN ?", userID, resourceIDs).Delete(&model.Resource{}).Error + }) +} + +func resourceTextQuery(query *gorm.DB, columns []string, resourceIDs []string) *gorm.DB { + conditions := make([]string, 0, len(columns)*len(resourceIDs)) + args := make([]any, 0, len(columns)*len(resourceIDs)) + for _, column := range columns { + for _, resourceID := range resourceIDs { + conditions = append(conditions, column+" LIKE ?") + args = append(args, "%"+resourceID+"%") + } + } + if len(conditions) == 0 { + return query + } + return query.Where("("+strings.Join(conditions, " OR ")+")", args...) +} diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index e3d4bb25d..dcda1bf62 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -1,6 +1,7 @@ package service import ( + "bufio" "bytes" "encoding/csv" "encoding/json" @@ -280,33 +281,58 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { // 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。 func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) { - if err := s.RequireAdmin(actor); err != nil { + userID, resource, err := s.adminAPICallLogMediaResource(actor, logID) + if err != nil { return nil, err } + return s.openResourceRange(userID, resource, rangeHeader) +} + +func (s *Service) PrepareAdminAPICallLogMediaDelivery(actor *model.User, logID string, rangeHeader string) (*ResourceDelivery, error) { + userID, resource, err := s.adminAPICallLogMediaResource(actor, logID) + if err != nil { + return nil, err + } + delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{}) + if err != nil || delivery.RedirectURL != "" { + return delivery, err + } + stream, err := s.openResourceRange(userID, resource, rangeHeader) + if err != nil { + return nil, err + } + delivery.Stream = stream + return delivery, nil +} + +func (s *Service) adminAPICallLogMediaResource(actor *model.User, logID string) (string, *model.Resource, error) { + if err := s.RequireAdmin(actor); err != nil { + return "", nil, err + } log, err := s.repo.APICallLog(strings.TrimSpace(logID)) if err != nil { - return nil, err + return "", nil, err } if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") { - return nil, BadAuthRequest("该请求没有可预览媒体") + return "", nil, BadAuthRequest("该请求没有可预览媒体") } task, err := s.repo.Task(log.TaskID) if err != nil { - return nil, err + return "", nil, err } if task.UserID != log.UserID { - return nil, BadAuthRequest("请求与媒体归属不一致") + return "", nil, BadAuthRequest("请求与媒体归属不一致") } previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type) resourceID := canvasResourceID(previewURL) if resourceID == "" { - return nil, BadAuthRequest("该请求没有已持久化媒体") + return "", nil, BadAuthRequest("该请求没有已持久化媒体") } resource, err := s.repo.ResourceForUser(log.UserID, resourceID) if err != nil { - return nil, err + return "", nil, err } - return s.openResourceRange(log.UserID, resource, rangeHeader) + return log.UserID, resource, nil } func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) { @@ -914,13 +940,16 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { if log.ProviderRequestID == "" { log.ProviderRequestID = providerRequestIDFromPath(log.Path) } - if len(responseBody) == 0 || !json.Valid(responseBody) { + payloads := providerResponsePayloads(responseBody) + if len(payloads) == 0 { return } - var payload map[string]any - if json.Unmarshal(responseBody, &payload) != nil { - return + for _, payload := range payloads { + s.enrichAPICallLogPayload(log, payload) } +} + +func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { if data, ok := payload["data"].(map[string]any); ok { for key, value := range data { if _, exists := payload[key]; !exists { @@ -928,6 +957,14 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } } } + // Responses API 的终态 SSE 把实际响应(包括 usage)放在 response 字段中。 + if response, ok := payload["response"].(map[string]any); ok { + for key, value := range response { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + } if log.Status == model.ApiCallStatusFailed { errorCode, errorMessage := providerFailureDetails(payload) log.ErrorCode = errorCode @@ -937,16 +974,24 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } usage, _ := payload["usage"].(map[string]any) if usage != nil { - log.UsageAvailable = true - log.InputTokens = firstInt64(usage, "input_tokens", "prompt_tokens") - log.OutputTokens = firstInt64(usage, "output_tokens", "completion_tokens") + inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens") + outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens") + if inputAvailable { + log.InputTokens = inputTokens + } + if outputAvailable { + log.OutputTokens = outputTokens + } + if inputAvailable || outputAvailable { + log.UsageAvailable = true + } // 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量, // 兼容只返回 total_tokens 的同协议中转实现。 - if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens == 0 { - log.OutputTokens = firstInt64(usage, "total_tokens") - } - if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens <= 0 { - log.UsageAvailable = false + if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") { + if log.OutputTokens == 0 { + log.OutputTokens = firstInt64(usage, "total_tokens") + } + log.UsageAvailable = log.OutputTokens > 0 } if details, ok := usage["input_tokens_details"].(map[string]any); ok { log.CachedTokens = firstInt64(details, "cached_tokens") @@ -956,9 +1001,17 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } } if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok { - log.UsageAvailable = true - log.InputTokens = firstInt64(usageMetadata, "promptTokenCount") - log.OutputTokens = firstInt64(usageMetadata, "candidatesTokenCount") + inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount") + outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount") + if inputAvailable { + log.InputTokens = inputTokens + } + if outputAvailable { + log.OutputTokens = outputTokens + } + if inputAvailable || outputAvailable { + log.UsageAvailable = true + } log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount") } log.ProviderRequestID = firstNonEmpty(stringField(payload, "task_id"), stringField(payload, "id"), stringField(payload, "request_id"), stringField(payload, "name"), log.ProviderRequestID) @@ -978,6 +1031,45 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { } } +func providerResponsePayloads(responseBody []byte) []map[string]any { + if len(responseBody) == 0 { + return nil + } + var payload map[string]any + if json.Unmarshal(responseBody, &payload) == nil { + return []map[string]any{payload} + } + + // 流式文本的用量只出现在最后一个 SSE data 事件中,不能把整段响应当作 JSON。 + result := make([]map[string]any, 0) + scanner := bufio.NewScanner(bytes.NewReader(responseBody)) + scanner.Buffer(make([]byte, 64<<10), max(len(responseBody)+1, 64<<10)) + dataLines := make([]string, 0, 1) + flush := func() { + raw := strings.TrimSpace(strings.Join(dataLines, "\n")) + dataLines = dataLines[:0] + if raw == "" || raw == "[DONE]" { + return + } + var event map[string]any + if json.Unmarshal([]byte(raw), &event) == nil { + result = append(result, event) + } + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + flush() + return result +} + func providerRequestIDFromPath(path string) string { parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/") for index := len(parts) - 1; index >= 0; index-- { @@ -994,18 +1086,23 @@ func providerRequestIDFromPath(path string) string { } func firstInt64(values map[string]any, keys ...string) int64 { + value, _ := firstInt64Value(values, keys...) + return value +} + +func firstInt64Value(values map[string]any, keys ...string) (int64, bool) { for _, key := range keys { switch value := values[key].(type) { case float64: - return int64(value) + return int64(value), true case int64: - return value + return value, true case json.Number: - parsed, _ := value.Int64() - return parsed + parsed, err := value.Int64() + return parsed, err == nil } } - return 0 + return 0, false } func (s *Service) recordActivity(userID string, event string, count int) { diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index f7b15d7e2..310f6779c 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -70,6 +70,10 @@ func BadAuthRequest(message string) *AuthError { return &AuthError{Status: 400, Message: message} } +func NotFound(message string) *AuthError { + return &AuthError{Status: 404, Message: message} +} + func Unauthorized(message string) *AuthError { return &AuthError{Status: 401, Message: message} } diff --git a/backend/internal/service/canvas_share.go b/backend/internal/service/canvas_share.go index 0fdc88ac7..d42d7ae6d 100644 --- a/backend/internal/service/canvas_share.go +++ b/backend/internal/service/canvas_share.go @@ -137,18 +137,47 @@ func (s *Service) OpenSharedCanvasResource(token string, resourceID string) (*mo } func (s *Service) OpenSharedCanvasResourceRange(token string, resourceID string, rangeHeader string) (*ResourceStream, error) { - share, project, err := s.sharedCanvasProject(token) + userID, resource, err := s.sharedCanvasResource(token, resourceID) if err != nil { return nil, err } + return s.openResourceRange(userID, resource, rangeHeader) +} + +func (s *Service) PrepareSharedCanvasResourceDelivery(token string, resourceID string, rangeHeader string) (*ResourceDelivery, error) { + userID, resource, err := s.sharedCanvasResource(token, resourceID) + if err != nil { + return nil, err + } + delivery, err := s.prepareResourceDelivery(userID, resource, ResourceDeliveryOptions{}) + if err != nil || delivery.RedirectURL != "" { + return delivery, err + } + stream, err := s.openResourceRange(userID, resource, rangeHeader) + if err != nil { + return nil, err + } + delivery.Stream = stream + return delivery, nil +} + +func (s *Service) sharedCanvasResource(token string, resourceID string) (string, *model.Resource, error) { + share, project, err := s.sharedCanvasProject(token) + if err != nil { + return "", nil, err + } _, allowedResources, err := publicCanvasProject(project, token) if err != nil { - return nil, err + return "", nil, err } if !allowedResources[resourceID] { - return nil, gorm.ErrRecordNotFound + return "", nil, gorm.ErrRecordNotFound } - return s.OpenResourceRange(share.UserID, resourceID, rangeHeader) + resource, err := s.repo.ResourceForUser(share.UserID, resourceID) + if err != nil { + return "", nil, err + } + return share.UserID, resource, nil } func (s *Service) sharedCanvasProject(token string) (*model.CanvasShare, *model.CanvasProject, error) { diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go index 6ac9ca5ab..31fad5b39 100644 --- a/backend/internal/service/channel_model_catalog.go +++ b/backend/internal/service/channel_model_catalog.go @@ -29,9 +29,28 @@ type channelModelsPayload struct { } type channelModelItem struct { - ID string `json:"id"` - Name string `json:"name"` - SupportedEndpointTypes []string `json:"supported_endpoint_types"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + ModelType string `json:"model_type"` + SupportedEndpointTypes []string `json:"supported_endpoint_types"` + DefaultParameters channelModelCatalogParameters `json:"default_parameters"` + Options channelModelCatalogOptions `json:"options"` + SupportsImages *bool `json:"supports_images"` + MinImages *int `json:"min_images"` + MaxImages *int `json:"max_images"` +} + +type channelModelCatalogParameters struct { + AspectRatio string `json:"aspect_ratio"` + DurationSeconds string `json:"duration_seconds"` + Resolution string `json:"resolution"` +} + +type channelModelCatalogOptions struct { + AspectRatio []ChannelModelCatalogOption `json:"aspect_ratio"` + DurationSeconds []ChannelModelCatalogOption `json:"duration_seconds"` + Resolution []ChannelModelCatalogOption `json:"resolution"` } func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) { @@ -54,10 +73,34 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp } // ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同; -// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。 +// 协议、能力和可选参数均来自上游公开元数据,不展开供应商内部兼容模型。 type ChannelModelCatalogItem struct { - ID string `json:"id"` - SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"` + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + ModelType string `json:"modelType,omitempty"` + SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"` + DefaultParameters ChannelModelCatalogDefaultParameters `json:"defaultParameters,omitempty"` + Options ChannelModelCatalogOptions `json:"options,omitempty"` + SupportsImages *bool `json:"supportsImages,omitempty"` + MinImages *int `json:"minImages,omitempty"` + MaxImages *int `json:"maxImages,omitempty"` +} + +type ChannelModelCatalogDefaultParameters struct { + AspectRatio string `json:"aspectRatio,omitempty"` + DurationSeconds string `json:"durationSeconds,omitempty"` + Resolution string `json:"resolution,omitempty"` +} + +type ChannelModelCatalogOptions struct { + AspectRatio []ChannelModelCatalogOption `json:"aspectRatio,omitempty"` + DurationSeconds []ChannelModelCatalogOption `json:"durationSeconds,omitempty"` + Resolution []ChannelModelCatalogOption `json:"resolution,omitempty"` +} + +type ChannelModelCatalogOption struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` } func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) { @@ -134,7 +177,25 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use continue } seen[name] = true - catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)}) + catalog = append(catalog, ChannelModelCatalogItem{ + ID: name, + DisplayName: strings.TrimSpace(item.DisplayName), + ModelType: normalizeCatalogModelType(item.ModelType), + SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes), + DefaultParameters: ChannelModelCatalogDefaultParameters{ + AspectRatio: strings.TrimSpace(item.DefaultParameters.AspectRatio), + DurationSeconds: strings.TrimSpace(item.DefaultParameters.DurationSeconds), + Resolution: strings.TrimSpace(item.DefaultParameters.Resolution), + }, + Options: ChannelModelCatalogOptions{ + AspectRatio: normalizeCatalogOptions(item.Options.AspectRatio), + DurationSeconds: normalizeCatalogOptions(item.Options.DurationSeconds), + Resolution: normalizeCatalogOptions(item.Options.Resolution), + }, + SupportsImages: item.SupportsImages, + MinImages: item.MinImages, + MaxImages: item.MaxImages, + }) } sort.Slice(catalog, func(left int, right int) bool { return catalog[left].ID < catalog[right].ID @@ -142,6 +203,30 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use return catalog, nil } +func normalizeCatalogModelType(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "text", "image", "video", "audio": + return normalized + default: + return "" + } +} + +func normalizeCatalogOptions(options []ChannelModelCatalogOption) []ChannelModelCatalogOption { + seen := make(map[string]bool, len(options)) + normalized := make([]ChannelModelCatalogOption, 0, len(options)) + for _, option := range options { + value := strings.TrimSpace(option.Value) + if value == "" || seen[value] { + continue + } + seen[value] = true + normalized = append(normalized, ChannelModelCatalogOption{Value: value, Label: strings.TrimSpace(option.Label)}) + } + return normalized +} + func normalizeCatalogEndpointTypes(values []string) []string { seen := make(map[string]bool, len(values)) normalized := make([]string, 0, len(values)) diff --git a/backend/internal/service/desktop_local_channel_flow_test.go b/backend/internal/service/desktop_local_channel_flow_test.go index 39468f1f9..b3d5b8f58 100644 --- a/backend/internal/service/desktop_local_channel_flow_test.go +++ b/backend/internal/service/desktop_local_channel_flow_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -101,6 +102,66 @@ func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testi } } +func TestCustomModelCatalogPreservesPublicCapabilityMetadataWithoutCompatibilityIDs(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[ + {"id":"image-fast","display_name":"Image Fast","model_type":"image"}, + {"id":"image-quality","display_name":"Image Quality","model_type":"image"}, + {"id":"omni","capability_id":"omni-flash","display_name":"Omni Flash","model_type":"video","supports_images":false,"min_images":0,"max_images":0,"default_parameters":{"aspect_ratio":"16:9","duration_seconds":"10"},"options":{"aspect_ratio":[{"value":"16:9"},{"value":"9:16"}],"duration_seconds":[{"value":"8"},{"value":"10"}]},"compatibility_map":[{"parameters":{"aspect_ratio":"9:16","duration_seconds":"10"},"model_id":"omni_portrait_10s"}]}, + {"id":"veo-lite","display_name":"Veo Lite","model_type":"video"}, + {"id":"veo-fast","display_name":"Veo Fast","model_type":"video"}, + {"id":"veo-quality","display_name":"Veo Quality","model_type":"video"} + ]}`)) + })) + defer upstream.Close() + + svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}} + catalog, err := svc.FetchChannelModelCatalog(context.Background(), &model.User{ID: "user-1"}, ChannelModelsRequest{ + BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true, + }) + if err != nil { + t.Fatalf("FetchChannelModelCatalog() error = %v", err) + } + if len(catalog) != 6 { + t.Fatalf("catalog length = %d; want 6", len(catalog)) + } + var omni *ChannelModelCatalogItem + for index := range catalog { + if catalog[index].ID == "omni" { + omni = &catalog[index] + break + } + } + if omni == nil { + t.Fatal("catalog is missing public omni capability") + } + if omni.DisplayName != "Omni Flash" || omni.ModelType != "video" { + t.Fatalf("omni metadata = %#v", omni) + } + if omni.DefaultParameters.AspectRatio != "16:9" || omni.DefaultParameters.DurationSeconds != "10" { + t.Fatalf("omni defaults = %#v", omni.DefaultParameters) + } + if got := optionValues(omni.Options.DurationSeconds); len(got) != 2 || got[0] != "8" || got[1] != "10" { + t.Fatalf("omni duration options = %#v", got) + } + encoded, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "omni_portrait_10s") || strings.Contains(string(encoded), "compatibility") { + t.Fatalf("ordinary catalog exposed compatibility IDs: %s", encoded) + } +} + +func optionValues(options []ChannelModelCatalogOption) []string { + values := make([]string, 0, len(options)) + for _, option := range options { + values = append(values, option.Value) + } + return values +} + func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { diff --git a/backend/internal/service/finance_token_test.go b/backend/internal/service/finance_token_test.go index a83bbab26..b222b4724 100644 --- a/backend/internal/service/finance_token_test.go +++ b/backend/internal/service/finance_token_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "testing" "infinite-canvas/backend/internal/model" @@ -83,3 +84,49 @@ func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) { t.Fatalf("EnrichAPICallLog() accepted empty Ark usage: %#v", missing) } } + +func TestEnrichAPICallLogReadsChatCompletionStreamUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "text", Path: "/v1/chat/completions"} + stream := []byte("data: {\"id\":\"chatcmpl-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n" + + "data: {\"id\":\"chatcmpl-test\",\"choices\":[],\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":7,\"prompt_tokens_details\":{\"cached_tokens\":5}}}\n\n" + + "data: [DONE]\n\n") + (&Service{}).EnrichAPICallLog(log, stream) + if !log.UsageAvailable || log.InputTokens != 23 || log.OutputTokens != 7 || log.CachedTokens != 5 { + t.Fatalf("EnrichAPICallLog() = %#v", log) + } +} + +func TestEnrichAPICallLogReadsResponsesStreamUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "text", Path: "/v1/responses"} + stream := []byte("event: response.output_text.delta\n" + + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n" + + "event: response.completed\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-test\",\"status\":\"completed\",\"usage\":{\"input_tokens\":31,\"output_tokens\":11,\"input_tokens_details\":{\"cached_tokens\":9}}}}\n\n") + (&Service{}).EnrichAPICallLog(log, stream) + if !log.UsageAvailable || log.InputTokens != 31 || log.OutputTokens != 11 || log.CachedTokens != 9 || log.ProviderRequestID != "resp-test" { + t.Fatalf("EnrichAPICallLog() = %#v", log) + } +} + +func TestEnrichAPICallLogRejectsEmptyTextUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "text", Path: "/v1/chat/completions"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"usage":{}}`)) + if log.UsageAvailable { + t.Fatalf("EnrichAPICallLog() accepted empty text usage: %#v", log) + } +} + +func TestEnsureChatCompletionStreamUsageRequest(t *testing.T) { + data, err := EnsureChatCompletionStreamUsageRequest([]byte(`{"model":"gpt-test","stream":true,"stream_options":{"custom":true}}`)) + if err != nil { + t.Fatalf("EnsureChatCompletionStreamUsageRequest() error = %v", err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + options, _ := payload["stream_options"].(map[string]any) + if options["include_usage"] != true || options["custom"] != true { + t.Fatalf("stream_options = %#v", options) + } +} diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 6488dcd0a..a45ade68a 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -67,6 +67,7 @@ type VideoCapabilityConfig struct { type VideoReferenceConfig struct { PromptMaxChars int `json:"promptMaxChars"` + MinImages int `json:"minImages"` MaxImages int `json:"maxImages"` MaxImageBytes int64 `json:"maxImageBytes"` MaxVideos int `json:"maxVideos"` @@ -154,11 +155,11 @@ func defaultImageSizeValues() []string { func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { video := &VideoCapabilityConfig{ - References: VideoReferenceConfig{PromptMaxChars: 1000, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, + References: VideoReferenceConfig{PromptMaxChars: 1000, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, DefaultRatio: "16:9", - Resolutions: []string{"480p", "720p", "1080p", "2160p"}, + Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"}, DefaultResolution: "720p", GenerateAudio: VideoBooleanConfig{Supported: false, Default: false}, Watermark: VideoBooleanConfig{Supported: false, Default: false}, @@ -178,11 +179,15 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} video.Watermark = VideoBooleanConfig{Supported: true, Default: false} + video.Resolutions = []string{"480p", "720p", "1080p"} case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2: video.References.MaxVideos, video.References.MaxAudios = 3, 3 video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 { + video.Resolutions = []string{"480p", "720p", "1080p"} + } case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} case model.ChannelInterfaceNovitaVideo: @@ -273,11 +278,14 @@ func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") } - for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { + for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { if number < 0 || number > 100 { return BadAuthRequest(name + "必须在 0-100 之间") } } + if value.References.MinImages > value.References.MaxImages { + return BadAuthRequest("最少图片引用数不能超过最大图片引用数") + } if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 { return BadAuthRequest("引用素材限制不能小于 0") } @@ -287,8 +295,12 @@ func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { if len(value.Ratios) == 0 || strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) { return BadAuthRequest("请至少配置一个画面比例,并选择默认比例") } - if len(value.Resolutions) == 0 || strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { - return BadAuthRequest("请至少配置一个输出分辨率,并选择默认分辨率") + if len(value.Resolutions) == 0 { + if strings.TrimSpace(value.DefaultResolution) != "" { + return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率") + } + } else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { + return BadAuthRequest("默认输出分辨率必须属于支持值") } if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) { return BadAuthRequest("请至少配置一个生成模式,并选择默认模式") @@ -373,6 +385,9 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { return BadAuthRequest("参考素材数量超过当前模型限制") } + if len(input.ReferenceImages) < profile.References.MinImages { + return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)) + } for _, media := range input.ReferenceImages { if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { return BadAuthRequest("参考图片文件超过当前模型大小限制") @@ -401,7 +416,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) { return BadAuthRequest("画面比例不在当前模型支持范围内") } - if input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(input.Config.VQuality)) { + if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" { return BadAuthRequest("输出分辨率不在当前模型支持范围内") } operation := metadataString(input.Metadata, "videoEditOperation") @@ -536,6 +551,9 @@ func ratioValue(value string) float64 { func normalizeResolution(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.TrimSuffix(value, "p") + if value == "2k" { + return "1440p" + } if value == "4k" { return "2160p" } diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 61b2983ef..852e047e0 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -62,3 +62,86 @@ func TestValidateImageTaskEnforcesGPTImage2CustomSizeLimits(t *testing.T) { }) } } + +func TestDefaultVideoCapabilityUsesProtocolSpecificResolutionTiers(t *testing.T) { + tests := map[string][]string{ + "newapi-channel-2": {"480p", "720p", "1080p", "1440p", "2160p"}, + "volcengine-ark-video": {"480p", "720p", "1080p"}, + "volcengine-jimeng-video": {"720p"}, + "gemini-veo": {"720p", "1080p"}, + } + for protocol, want := range tests { + t.Run(protocol, func(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(protocol, "") + if profile == nil || profile.Video == nil { + t.Fatalf("DefaultModelCapabilityConfigForModel(%q) video profile = nil", protocol) + } + if fmt.Sprint(profile.Video.Resolutions) != fmt.Sprint(want) { + t.Fatalf("resolutions = %v, want %v", profile.Video.Resolutions, want) + } + }) + } +} + +func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) { + tests := map[string]string{ + "1440": "1440p", + "1440p": "1440p", + "2K": "1440p", + "4K": "2160p", + "768P": "768p", + } + for input, want := range tests { + if got := normalizeResolution(input); got != want { + t.Fatalf("normalizeResolution(%q) = %q, want %q", input, got, want) + } + } +} + +func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi", "omni").Video + profile.Duration = VideoDurationConfig{Selection: "enum", Values: []int{8, 10}, Default: 10} + profile.Ratios = []string{"16:9", "9:16"} + profile.DefaultRatio = "16:9" + profile.Resolutions = nil + profile.DefaultResolution = "" + profile.References.MaxImages = 0 + profile.Operations = []string{"text_to_video"} + profile.DefaultOperation = "text_to_video" + + err := validateVideoTask(profile, canvasGenerationInput{ + Config: providerConfig{Model: "omni", VideoSeconds: "10", Size: "16:9", VQuality: "720"}, + }) + if err != nil { + t.Fatalf("validateVideoTask() error = %v", err) + } +} + +func TestNormalizeVideoCapabilityAllowsOmittedResolution(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "endpoint-video").Video + profile.Resolutions = nil + profile.DefaultResolution = "" + + result, err := NormalizeModelCapabilityConfig("video", "newapi-channel-2", &ModelCapabilityConfig{Version: 1, Video: profile}) + if err != nil { + t.Fatalf("NormalizeModelCapabilityConfig() error = %v", err) + } + if result.Video == nil || len(result.Video.Resolutions) != 0 || result.Video.DefaultResolution != "" { + t.Fatalf("normalized video resolution = %#v", result.Video) + } +} + +func TestValidateVideoTaskRequiresDeclaredMinimumImages(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "image-required-video").Video + profile.References.MinImages = 1 + profile.References.MaxImages = 2 + profile.Operations = []string{"image_to_video"} + profile.DefaultOperation = "image_to_video" + + err := validateVideoTask(profile, canvasGenerationInput{ + Config: providerConfig{Model: "image-required-video", VideoSeconds: "6", Size: "16:9", VQuality: "720"}, + }) + if err == nil || !strings.Contains(err.Error(), "至少需要 1 张参考图") { + t.Fatalf("validateVideoTask() error = %v", err) + } +} diff --git a/backend/internal/service/project_asset_folder.go b/backend/internal/service/project_asset_folder.go index 1a42b87ab..725c39ae0 100644 --- a/backend/internal/service/project_asset_folder.go +++ b/backend/internal/service/project_asset_folder.go @@ -15,12 +15,14 @@ type CreateProjectAssetFolderRequest struct { Name string `json:"name"` ParentID string `json:"parentId"` Style string `json:"style"` + Theme string `json:"theme"` } type UpdateProjectAssetFolderRequest struct { Name *string `json:"name"` ParentID *string `json:"parentId"` Style *string `json:"style"` + Theme *string `json:"theme"` } func (s *Service) ProjectAssetFolders(userID string, projectID string) ([]model.ProjectAssetFolder, error) { @@ -55,7 +57,11 @@ func (s *Service) CreateProjectAssetFolder(userID string, projectID string, req if err != nil { return model.ProjectAssetFolder{}, err } - folder := model.ProjectAssetFolder{ID: newID(), ProjectID: projectID, ParentID: parentID, Name: name, NameKey: projectAssetFolderNameKey(name), Style: style, Position: position, CreatedAt: now, UpdatedAt: now} + theme, err := validateProjectAssetFolderTheme(req.Theme) + if err != nil { + return model.ProjectAssetFolder{}, err + } + folder := model.ProjectAssetFolder{ID: newID(), ProjectID: projectID, ParentID: parentID, Name: name, NameKey: projectAssetFolderNameKey(name), Style: style, Theme: theme, Position: position, CreatedAt: now, UpdatedAt: now} if err := s.repo.CreateProjectAssetFolder(&folder); err != nil { if isProjectAssetFolderNameConflict(err) { return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹") @@ -102,6 +108,13 @@ func (s *Service) UpdateProjectAssetFolder(userID string, projectID string, fold } folder.Style = style } + if req.Theme != nil { + theme, themeErr := validateProjectAssetFolderTheme(*req.Theme) + if themeErr != nil { + return model.ProjectAssetFolder{}, themeErr + } + folder.Theme = theme + } if projectAssetFolderNameExists(folders, folder.ParentID, folder.Name, folder.ID) { return model.ProjectAssetFolder{}, BadAuthRequest("同级目录下已存在同名文件夹") } @@ -150,6 +163,19 @@ func validateProjectAssetFolderStyle(value string) (string, error) { } } +func validateProjectAssetFolderTheme(value string) (string, error) { + theme := strings.TrimSpace(value) + if theme == "" { + return "aurora", nil + } + switch theme { + case "aurora", "obsidian", "ember", "pearl": + return theme, nil + default: + return "", BadAuthRequest("不支持的文件夹主题") + } +} + func validateProjectAssetFolderName(value string) (string, error) { name := strings.TrimSpace(value) if name == "" { diff --git a/backend/internal/service/project_asset_folder_test.go b/backend/internal/service/project_asset_folder_test.go index f85e573bb..5f1239368 100644 --- a/backend/internal/service/project_asset_folder_test.go +++ b/backend/internal/service/project_asset_folder_test.go @@ -48,3 +48,15 @@ func TestProjectAssetFolderNameIsUniqueWithinSiblingOnly(t *testing.T) { t.Fatal("did not expect a duplicate across different parents") } } + +func TestValidateProjectAssetFolderTheme(t *testing.T) { + if theme, err := validateProjectAssetFolderTheme(""); err != nil || theme != "aurora" { + t.Fatalf("expected empty theme to use aurora, got %q: %v", theme, err) + } + if theme, err := validateProjectAssetFolderTheme(" pearl "); err != nil || theme != "pearl" { + t.Fatalf("expected pearl theme, got %q: %v", theme, err) + } + if _, err := validateProjectAssetFolderTheme("content-image"); err == nil { + t.Fatal("expected arbitrary content image theme to fail") + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 152743be2..46b9cd6cb 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -113,6 +113,7 @@ type providerAnalyticsContext struct { UserID string TaskID string BillingOrderID string + BillingMode string Capability string Operation string ChannelID string @@ -125,6 +126,12 @@ type providerAnalyticsContext struct { func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context { metadata := providerAnalyticsContext{Service: service, UserID: task.UserID, TaskID: task.ID, BillingOrderID: task.BillingOrderID, Capability: capabilityFromTaskType(task.Type), Operation: task.Operation, Model: task.Model, ProviderRequestID: task.ProviderRequestID} + // 账单模式随请求上下文传递,流式协议据此只为 Token 计费开启 usage 终态块。 + if service != nil && task.BillingOrderID != "" { + if order, err := service.repo.BillingOrder(task.BillingOrderID); err == nil { + metadata.BillingMode = order.BillingMode + } + } var input struct { Mode string `json:"mode"` Config providerConfig `json:"config"` @@ -874,18 +881,49 @@ func runVolcengineArkImageTask(ctx context.Context, input canvasGenerationInput) if err := postJSON(ctx, input.Config, "/images/generations", body, &payload); err != nil { return nil, err } - images, err := imageDataURLs(payload) + images, err := volcengineArkImageDataURLs(ctx, input.Config, payload) if err != nil { return nil, err } return map[string]interface{}{"mode": "image", "images": images}, nil } +func volcengineArkImageDataURLs(ctx context.Context, config providerConfig, payload imageResponse) ([]map[string]string, error) { + images, err := imageDataURLs(payload) + if err != nil { + return nil, err + } + for _, image := range images { + value := strings.TrimSpace(image["dataUrl"]) + if strings.HasPrefix(value, "data:image/") { + continue + } + if !isPublicMediaURL(value) { + return nil, errors.New("火山方舟图片接口没有返回可下载的图片") + } + // 方舟默认返回临时 CDN 地址。必须由后端下载成内联结果,后续资源持久化才能 + // 原子地写入服务器或用户配置的对象存储,且不依赖浏览器跨域访问方舟 CDN。 + data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value) + if err != nil { + return nil, fmt.Errorf("火山方舟图片结果下载失败:%w", err) + } + detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) + mimeType = strings.ToLower(normalizedMediaMimeType(mimeType, data)) + if len(data) == 0 || strings.Contains(detected, "json") || strings.HasPrefix(detected, "text/") || !strings.HasPrefix(mimeType, "image/") { + return nil, fmt.Errorf("火山方舟图片结果无效:%s", defaultString(detected, mimeType)) + } + image["dataUrl"] = dataURL(mimeType, data) + image["mimeType"] = mimeType + } + return images, nil +} + func volcengineArkImageBody(input canvasGenerationInput) (map[string]interface{}, error) { body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": withSystemPrompt(input.Config, input.Prompt), - "n": 1, + "model": input.Config.Model, + "prompt": withSystemPrompt(input.Config, input.Prompt), + "n": 1, + "watermark": false, } if key, value := imageSizeParameter(input.ImageCapability, input.Config.Size); value != "" { if key == "size" { @@ -1413,7 +1451,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] if size := normalizeVideoSize(input.Config.Size); size != "" { writeField(writer, "size", size) } - writeField(writer, "resolution_name", normalizeVideoResolution(input.Config.VQuality)) + if resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality); resolution != "" { + writeField(writer, "resolution_name", resolution) + } writeField(writer, "preset", "normal") if shouldSendNewAPIVideoImages(input) { for _, image := range input.ReferenceImages { @@ -1933,7 +1973,10 @@ func newAPIChannel2VideoRequestBody(input canvasGenerationInput) (newAPIVideoReq seconds = 6 } ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName) - resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName) + resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality) + if modelName == "grok-video-1.5-1080p" { + resolution = "1080p" + } body := newAPIVideoRequest{ Model: input.Config.Model, Prompt: strings.TrimSpace(input.Prompt), @@ -2025,16 +2068,22 @@ func normalizeNewAPIChannel2Resolution(value string, modelName string) string { if modelName == "grok-video-1.5-1080p" { return "1080p" } - switch strings.ToLower(strings.TrimSpace(value)) { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { case "480", "480p", "low": return "480p" case "1080", "1080p": return "1080p" + case "1440", "1440p", "2k": + return "1440p" case "2160", "2160p", "4k": return "2160p" - default: - return "720p" } + numeric := strings.TrimSuffix(normalized, "p") + if resolution, err := strconv.Atoi(numeric); err == nil && resolution > 0 { + return strconv.Itoa(resolution) + "p" + } + return "720p" } func runNewAPIChannel1VideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { @@ -2411,6 +2460,12 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool) (string, error) { if stream { + metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if protocol == "chat-completion" && metadata.BillingMode == "token" { + if err := ensureChatCompletionStreamUsage(body); err != nil { + return "", err + } + } return postStreamingText(ctx, config, path, body, protocol) } var payload map[string]interface{} diff --git a/backend/internal/service/provider_request_types.go b/backend/internal/service/provider_request_types.go index 91e4e996d..a646018a0 100644 --- a/backend/internal/service/provider_request_types.go +++ b/backend/internal/service/provider_request_types.go @@ -19,7 +19,7 @@ type newAPIVideoRequest struct { Prompt string `json:"prompt"` Seconds string `json:"seconds"` AspectRatio string `json:"aspect_ratio"` - Resolution string `json:"resolution"` + Resolution string `json:"resolution,omitempty"` GenerateAudio *bool `json:"generate_audio,omitempty"` ImageURLs []string `json:"image_urls,omitempty"` VideoURLs []string `json:"video_urls,omitempty"` diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 6a54587bc..981843ea8 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -116,6 +116,22 @@ func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) { } } +func TestNormalizeNewAPIChannel2ResolutionPreservesDeclaredTiers(t *testing.T) { + tests := map[string]string{ + "1440": "1440p", + "2k": "1440p", + "4K": "2160p", + "768p": "768p", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + if got := normalizeNewAPIChannel2Resolution(input, "custom-video-model"); got != want { + t.Fatalf("normalizeNewAPIChannel2Resolution(%q) = %q, want %q", input, got, want) + } + }) + } +} + func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) { body, err := volcengineArkImageBody(canvasGenerationInput{ Prompt: "combine the references", @@ -135,6 +151,9 @@ func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) if body["prompt"] != "keep the subject\n\ncombine the references" { t.Fatalf("prompt = %q", body["prompt"]) } + if watermark, ok := body["watermark"].(bool); !ok || watermark { + t.Fatalf("watermark = %#v, want false", body["watermark"]) + } size, _ := body["size"].(string) parts := strings.Split(size, "x") if len(parts) != 2 { @@ -168,6 +187,40 @@ func TestVolcengineArkImageBodyUpscalesPresetBelowMinimumPixels(t *testing.T) { } } +func TestVolcengineArkImageDataURLsDownloadsRemoteResult(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + imageBytes := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + })) + defer server.Close() + + images, err := volcengineArkImageDataURLs(context.Background(), providerConfig{}, imageResponse{ + Data: []map[string]interface{}{{"url": server.URL + "/generated.png"}}, + }) + if err != nil { + t.Fatalf("volcengineArkImageDataURLs() error = %v", err) + } + if len(images) != 1 || !strings.HasPrefix(images[0]["dataUrl"], "data:image/png;base64,") { + t.Fatalf("images = %#v", images) + } + + svc := newResourceTestService(t) + stored, err := svc.persistGeneratedMediaResult("user-1", map[string]interface{}{"mode": "image", "images": images}) + if err != nil { + t.Fatalf("persistGeneratedMediaResult() error = %v", err) + } + storedImages, ok := stored["images"].([]interface{}) + if !ok || len(storedImages) != 1 { + t.Fatalf("stored images = %#v", stored["images"]) + } + storedImage, ok := storedImages[0].(map[string]interface{}) + if !ok || !strings.HasPrefix(stringField(storedImage, "storageKey"), "resource:") || !strings.HasPrefix(stringField(storedImage, "dataUrl"), "/api/resources/") { + t.Fatalf("stored image = %#v", storedImages[0]) + } +} + func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) { _, err := runImageTask(context.Background(), canvasGenerationInput{ Prompt: "edit only the masked area", @@ -624,6 +677,64 @@ func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) { } } +func TestRunVideoTaskSendsOnlyDeclaredResolutionName(t *testing.T) { + tests := []struct { + name string + resolutions []string + quality string + withoutProfile bool + want string + }{ + {name: "catalog omits resolution capability", quality: "720"}, + {name: "auto never invents 720p", resolutions: []string{"720p", "1080p"}, quality: "auto"}, + {name: "legacy auto never invents 720p", quality: "auto", withoutProfile: true}, + {name: "missing profile explicit 720 never invents resolution", quality: "720", withoutProfile: true}, + {name: "declared HD resolution", resolutions: []string{"1080p"}, quality: "1080", want: "1080p"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + var got string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "POST /v1/videos": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm() error = %v", err) + } + got = r.FormValue("resolution_name") + _, _ = w.Write([]byte(`{"id":"video-resolution","status":"queued"}`)) + case "GET /v1/videos/video-resolution": + _, _ = w.Write([]byte(`{"id":"video-resolution","status":"completed"}`)) + case "GET /v1/videos/video-resolution/content": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("video")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + profile := DefaultModelCapabilityConfigForModel("newapi", "public-video").Video + profile.Resolutions = test.resolutions + profile.DefaultResolution = "" + if test.withoutProfile { + profile = nil + } + _, err := runVideoTask(context.Background(), canvasGenerationInput{ + Prompt: "synthetic prompt", + Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "public-video", VideoSeconds: "8", Size: "16:9", VQuality: test.quality}, + VideoCapability: profile, + }) + if err != nil { + t.Fatalf("runVideoTask() error = %v", err) + } + if got != test.want { + t.Fatalf("resolution_name = %q; want %q", got, test.want) + } + }) + } +} + func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) @@ -1167,9 +1278,12 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) { })) defer server.Close() + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", "grok-image-video").Video + profile.Resolutions = []string{"720p"} + profile.DefaultResolution = "720p" result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", - Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "high"}, + Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "720"}, ReferenceImages: []providerMedia{ {ID: "image-1", DataURL: testReferenceImageDataURL}, {ID: "image-2", DataURL: testReferenceImageDataURL}, @@ -1177,6 +1291,7 @@ func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) { ReferenceVideos: []providerMedia{{ID: "video-1", URL: server.URL + "/reference.mp4"}}, ReferenceAudios: []providerMedia{{ID: "audio-1", URL: server.URL + "/reference.mp3"}}, Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, + VideoCapability: profile, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) @@ -1243,6 +1358,50 @@ func TestNewAPIChannel2SingleImageModelsRequireOneReference(t *testing.T) { } } +func TestNewAPIChannel2SendsOnlyDeclaredResolution(t *testing.T) { + tests := []struct { + name string + model string + quality string + resolutions []string + want string + }{ + {name: "catalog omits resolution", model: "endpoint-video", quality: "720"}, + {name: "declared 2K alias", model: "declared-video", quality: "2K", resolutions: []string{"1440p"}, want: "1440p"}, + {name: "fixed grok 1080p model", model: "grok-video-1.5-1080p", quality: "720", want: "1080p"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel("newapi-channel-2", test.model).Video + profile.Resolutions = test.resolutions + profile.DefaultResolution = "" + input := canvasGenerationInput{ + Config: providerConfig{Model: test.model, VideoSeconds: "6", Size: "16:9", VQuality: test.quality}, + VideoCapability: profile, + } + if strings.HasPrefix(test.model, "grok-video-1.5") { + input.ReferenceImages = []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}} + } + + body, err := newAPIChannel2VideoRequestBody(input) + if err != nil { + t.Fatalf("newAPIChannel2VideoRequestBody() error = %v", err) + } + if body.Resolution != test.want { + t.Fatalf("resolution = %q, want %q", body.Resolution, test.want) + } + mapped, err := requestAsMap(body) + if err != nil { + t.Fatalf("requestAsMap() error = %v", err) + } + _, hasResolution := mapped["resolution"] + if hasResolution != (test.want != "") { + t.Fatalf("resolution presence = %v, want %v; body = %#v", hasResolution, test.want != "", mapped) + } + }) + } +} + func TestNewAPIChannel2RejectsAudioWithoutReferenceVideo(t *testing.T) { _, err := newAPIChannel2VideoRequestBody(canvasGenerationInput{ Config: providerConfig{Model: "grok-image-video", VideoSeconds: "6"}, diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go index b7cf41c14..057cb31dc 100644 --- a/backend/internal/service/provider_video_options.go +++ b/backend/internal/service/provider_video_options.go @@ -160,12 +160,52 @@ func normalizeVideoResolution(value string) string { if strings.EqualFold(value, "4k") { return "2160p" } + if strings.EqualFold(value, "2k") { + return "1440p" + } if strings.HasSuffix(value, "p") { return value } return value + "p" } +func videoResolutionNameRequest(profile *VideoCapabilityConfig, value string) string { + requested := strings.ToLower(strings.TrimSpace(value)) + if requested == "" || requested == "auto" || requested == "default" || requested == "medium" || requested == "high" { + return "" + } + if profile == nil { + return "" + } + if len(profile.Resolutions) == 0 { + return "" + } + candidates := []string{requested, strings.ToLower(normalizeVideoResolution(requested))} + if requested == "4k" { + candidates = append(candidates, "2160p") + } + if requested == "2160" || requested == "2160p" { + candidates = append(candidates, "4k") + } + for _, supported := range profile.Resolutions { + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(supported), candidate) { + return strings.TrimSpace(supported) + } + } + } + return "" +} + +func isAutomaticVideoResolution(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "auto", "default", "medium", "high": + return true + default: + return false + } +} + func normalizeXAIVideoDuration(value string) int { duration, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || duration <= 0 { @@ -180,6 +220,8 @@ func normalizeXAIVideoResolution(value string) string { return "480p" case "1080", "1080p": return "1080p" + case "1440", "1440p", "2k": + return "1440p" case "2160", "2160p", "4k": return "2160p" default: diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index ce7af6a95..4552b3bab 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -29,6 +29,7 @@ import ( "infinite-canvas/backend/internal/model" cos "github.com/tencentyun/cos-go-sdk-v5" + "gorm.io/gorm" ) const providerResourceURLTTL = 4 * time.Hour @@ -45,6 +46,17 @@ type ResourceStream struct { AcceptRanges string } +type ResourceDeliveryOptions struct { + ForceDirect bool + ForceProxy bool +} + +type ResourceDelivery struct { + Resource *model.Resource + Stream *ResourceStream + RedirectURL string +} + func (s *Service) Resources(userID string, limit int) ([]model.Resource, error) { resources, err := s.repo.Resources(userID, limit) for index := range resources { @@ -90,6 +102,48 @@ func (s *Service) directResourceURL(resource *model.Resource, expiresAt time.Tim return signedOSSObjectURL(setting, resource.ObjectKey, expiresAt) } +// PrepareResourceDelivery 统一决定浏览器资源出口:配置 CDN 时默认直连 CDN,显式代理仅用于需要同源 Blob 的内部读取。 +func (s *Service) PrepareResourceDelivery(userID string, id string, options ResourceDeliveryOptions) (*ResourceDelivery, error) { + resource, err := s.repo.ResourceForUser(userID, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, NotFound("资源不存在") + } + return nil, err + } + return s.prepareResourceDelivery(userID, resource, options) +} + +func (s *Service) prepareResourceDelivery(userID string, resource *model.Resource, options ResourceDeliveryOptions) (*ResourceDelivery, error) { + if resource == nil { + return nil, errors.New("资源不存在") + } + if resource.Status != model.ResourceStatusReady { + return nil, BadAuthRequest("资源尚未上传完成") + } + if resource.Provider != "local" && !options.ForceProxy { + setting, err := s.ossSettingForResource(userID, resource) + if err != nil { + return nil, err + } + if setting.CDNBaseURL != "" { + redirectURL, err := ossCDNObjectURL(setting.CDNBaseURL, resource.ObjectKey) + if err != nil { + return nil, err + } + return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil + } + if options.ForceDirect { + redirectURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL)) + if err != nil { + return nil, err + } + return &ResourceDelivery{Resource: resource, RedirectURL: redirectURL}, nil + } + } + return &ResourceDelivery{Resource: resource}, nil +} + func (s *Service) signedPublicResourceURL(resourceID string, expiresAt time.Time) (string, error) { baseURL, err := s.publicResourceBaseURL() if err != nil { @@ -634,6 +688,16 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource) var err error if resource.StorageSettingID != "" { _, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID) + if err == nil { + _, current, currentErr := s.readUserOSSSetting(userID) + if currentErr != nil { + return ossSettingValue{}, currentErr + } + // 密钥继续固定在历史版本;同一存储位置的 CDN 域名跟随当前配置,使已有资源也立即切换。 + if resourceStorageMatches(current, resource) { + setting.CDNBaseURL = current.CDNBaseURL + } + } } else { _, setting, err = s.readOSSSetting() } @@ -652,6 +716,16 @@ func (s *Service) ossSettingForResource(userID string, resource *model.Resource) return setting, nil } +func resourceStorageMatches(setting ossSettingValue, resource *model.Resource) bool { + if resource == nil { + return false + } + setting = normalizeOSSSetting(setting) + return setting.Provider == strings.ToLower(strings.TrimSpace(resource.Provider)) && + setting.Endpoint == strings.TrimRight(strings.TrimSpace(resource.Endpoint), "/") && + setting.Bucket == strings.TrimSpace(resource.Bucket) +} + func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) { setting = normalizeOSSSetting(setting) provider = strings.ToLower(strings.TrimSpace(provider)) diff --git a/backend/internal/service/resource_delete.go b/backend/internal/service/resource_delete.go new file mode 100644 index 000000000..4703e7e94 --- /dev/null +++ b/backend/internal/service/resource_delete.go @@ -0,0 +1,369 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) deleteUserAssetWithResources(userID string, assetID string) error { + asset, err := s.repo.AssetForUser(userID, assetID) + if err != nil { + return err + } + assetReferences, err := s.repo.AssetBusinessReferences(userID, assetID) + if err != nil { + return err + } + versions, representations, err := s.repo.AssetResourceRecords(assetID) + if err != nil { + return err + } + + resourceIDs := map[string]struct{}{} + originTaskIDs := map[string]struct{}{} + if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs, originTaskIDs); err != nil { + return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") + } + for _, version := range versions { + if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs, originTaskIDs); err != nil { + return BadAuthRequest("素材版本数据无法解析,已停止删除以避免误删文件") + } + } + for _, representation := range representations { + if resourceID := validCanvasResourceID(representation.ResourceID); resourceID != "" { + resourceIDs[resourceID] = struct{}{} + } + if taskID := validCanvasResourceID(representation.TaskID); taskID != "" { + originTaskIDs[taskID] = struct{}{} + } + if err := collectOwnedAssetDocumentReferences(representation.MetadataJSON, resourceIDs, originTaskIDs); err != nil { + return BadAuthRequest("素材表现数据无法解析,已停止删除以避免误删文件") + } + } + + candidateIDs := sortedReferenceIDs(resourceIDs) + resources, err := s.repo.ResourcesForUserIDs(userID, candidateIDs) + if err != nil { + return err + } + ownedIDs := make([]string, 0, len(resources)) + ownedIDSet := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + ownedIDs = append(ownedIDs, resource.ID) + ownedIDSet[resource.ID] = struct{}{} + } + + usages := make([]resourceUsage, 0, len(assetReferences)) + for _, reference := range assetReferences { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } + if len(ownedIDs) > 0 { + snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, assetID, ownedIDs) + if snapshotErr != nil { + return snapshotErr + } + for _, reference := range snapshot.Direct { + if _, exists := ownedIDSet[reference.ResourceID]; exists { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } + } + for _, document := range snapshot.Documents { + primaryReferenced := documentReferencesResources(document.PrimaryJSON, ownedIDSet) + secondaryReferenced := documentReferencesResources(document.SecondaryJSON, ownedIDSet) + if document.Kind == "任务" { + _, originTask := originTaskIDs[document.ID] + secondaryReferenced = secondaryReferenced && !originTask + } + if primaryReferenced || secondaryReferenced { + usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + } + } + } + if message := resourceOccupiedMessage(usages); message != "" { + return BadAuthRequest(message) + } + + // 所有引用校验必须先完成;任何资源仍被占用时,一个物理文件都不会开始删除。 + physicalObjects := map[string]*model.Resource{} + for index := range resources { + resource := &resources[index] + sharedCount, countErr := s.repo.ResourceStorageReferenceCount(resource, ownedIDs) + if countErr != nil { + return countErr + } + if sharedCount > 0 { + continue + } + physicalObjects[resourceStorageIdentity(resource)] = resource + } + physicalKeys := make([]string, 0, len(physicalObjects)) + for key := range physicalObjects { + physicalKeys = append(physicalKeys, key) + } + sort.Strings(physicalKeys) + for _, key := range physicalKeys { + resource := physicalObjects[key] + if err := s.deleteStoredResourceObject(userID, resource); err != nil { + return fmt.Errorf("素材文件删除失败,素材记录已保留:%w", err) + } + } + if err := s.repo.DeleteAssetAndResources(userID, assetID, ownedIDs); err != nil { + return fmt.Errorf("素材记录删除失败,请重试:%w", err) + } + return nil +} + +type resourceUsage struct { + Kind string + ID string + Title string +} + +func resourceOccupiedMessage(usages []resourceUsage) string { + seen := map[string]struct{}{} + labels := make([]string, 0, len(usages)) + for _, usage := range usages { + key := usage.Kind + "\x00" + usage.ID + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + title := strings.TrimSpace(usage.Title) + if title == "" { + title = usage.ID + } + title = truncateRunes(title, 32) + labels = append(labels, usage.Kind+"「"+title+"」") + } + if len(labels) == 0 { + return "" + } + sort.Strings(labels) + visible := labels + if len(visible) > 3 { + visible = append(append([]string{}, visible[:3]...), fmt.Sprintf("等 %d 处", len(labels))) + } + return "素材仍被" + strings.Join(visible, "、") + "引用,请先在对应画布、任务或业务记录中解除引用后再删除" +} + +func collectOwnedAssetDocumentReferences(raw string, resourceIDs map[string]struct{}, taskIDs map[string]struct{}) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return err + } + walkReferenceDocument(value, "", resourceIDs, taskIDs) + return nil +} + +func walkReferenceDocument(value any, parentKey string, resourceIDs map[string]struct{}, taskIDs map[string]struct{}) { + switch item := value.(type) { + case map[string]any: + for key, child := range item { + walkReferenceDocument(child, key, resourceIDs, taskIDs) + } + case []any: + for _, child := range item { + walkReferenceDocument(child, parentKey, resourceIDs, taskIDs) + } + case string: + key := normalizeReferenceKey(parentKey) + if isResourceLocatorKey(key) { + if resourceID := canvasResourceID(item); resourceID != "" { + resourceIDs[resourceID] = struct{}{} + } + } + if isBareResourceIDKey(key) { + if resourceID := validCanvasResourceID(item); resourceID != "" { + resourceIDs[resourceID] = struct{}{} + } + } + if key == "taskid" || key == "taskids" { + if taskID := validCanvasResourceID(item); taskID != "" { + taskIDs[taskID] = struct{}{} + } + } + } +} + +func normalizeReferenceKey(key string) string { + key = strings.ToLower(strings.TrimSpace(key)) + return strings.NewReplacer("_", "", "-", "").Replace(key) +} + +func isBareResourceIDKey(key string) bool { + return key == "resourceid" || key == "resourceids" || key == "sampleresourceid" || key == "referenceresourceid" || key == "referenceresourceids" +} + +func isResourceLocatorKey(key string) bool { + if key == "" || strings.HasSuffix(key, "url") || strings.HasSuffix(key, "urls") || strings.HasSuffix(key, "storagekey") { + return true + } + switch key { + case "content", "data", "image", "images", "video", "videos", "audio", "audios", "media", "references", "result", "output", "artifactref", "providerartifactref": + return true + default: + return false + } +} + +func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bool { + raw = strings.TrimSpace(raw) + if raw == "" || len(resourceIDs) == 0 { + return false + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err == nil { + found := map[string]struct{}{} + walkReferenceDocument(value, "", found, map[string]struct{}{}) + for resourceID := range found { + if _, exists := resourceIDs[resourceID]; exists { + return true + } + } + return false + } + // cover_url 等字段可以直接保存资源 URL,而不是 JSON。候选记录已经包含完整 ID,保守阻止删除。 + for resourceID := range resourceIDs { + if strings.Contains(raw, resourceID) { + return true + } + } + return false +} + +func sortedReferenceIDs(values map[string]struct{}) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func resourceStorageIdentity(resource *model.Resource) string { + if resource == nil { + return "" + } + provider := strings.ToLower(strings.TrimSpace(resource.Provider)) + if provider == "" { + provider = "local" + } + return strings.Join([]string{provider, resource.Endpoint, resource.Bucket, resource.ObjectKey}, "\x00") +} + +func (s *Service) deleteStoredResourceObject(userID string, resource *model.Resource) error { + if resource == nil { + return errors.New("资源记录为空") + } + if strings.TrimSpace(resource.ObjectKey) == "" { + return fmt.Errorf("资源 %s 的存储路径为空", resource.ID) + } + switch strings.ToLower(strings.TrimSpace(resource.Provider)) { + case "", "local": + return s.deleteLocalResourceObject(resource.ObjectKey) + case aliyunOSSProvider: + setting, err := s.ossSettingForResource(userID, resource) + if err != nil { + return fmt.Errorf("无法读取阿里云 OSS 配置:%w", err) + } + return deleteAliyunOSSObject(setting, resource.ObjectKey) + case tencentCOSProvider: + setting, err := s.ossSettingForResource(userID, resource) + if err != nil { + return fmt.Errorf("无法读取腾讯云 COS 配置:%w", err) + } + return deleteTencentCOSObject(setting, resource.ObjectKey) + default: + return fmt.Errorf("资源 %s 使用了不支持的存储类型 %q", resource.ID, resource.Provider) + } +} + +func (s *Service) deleteLocalResourceObject(objectKey string) error { + root, err := filepath.Abs(filepath.Join(s.dataDir, "resources")) + if err != nil { + return fmt.Errorf("解析本地资源目录失败:%w", err) + } + target, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(strings.TrimLeft(objectKey, "/\\")))) + if err != nil { + return fmt.Errorf("解析本地资源路径失败:%w", err) + } + relative, err := filepath.Rel(root, target) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("本地资源路径超出允许目录") + } + fileInfo, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("检查服务器本地文件失败:%w", err) + } + if fileInfo.IsDir() { + return errors.New("本地资源路径指向目录,已停止删除") + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return fmt.Errorf("检查本地资源目录失败:%w", err) + } + resolvedTarget, err := filepath.EvalSymlinks(target) + if err != nil { + return fmt.Errorf("检查本地资源路径失败:%w", err) + } + resolvedRelative, err := filepath.Rel(resolvedRoot, resolvedTarget) + if err != nil || resolvedRelative == "." || resolvedRelative == ".." || strings.HasPrefix(resolvedRelative, ".."+string(filepath.Separator)) { + return errors.New("本地资源真实路径超出允许目录") + } + if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("删除服务器本地文件失败:%w", err) + } + return nil +} + +func deleteAliyunOSSObject(setting ossSettingValue, objectKey string) error { + req, err := newOSSRequest(http.MethodDelete, setting, objectKey, "", nil) + if err != nil { + return err + } + resp, err := OutboundHTTPClient(2 * time.Minute).Do(req) + if err != nil { + return fmt.Errorf("删除阿里云 OSS 对象失败:%w", err) + } + defer resp.Body.Close() + if (resp.StatusCode < 200 || resp.StatusCode >= 300) && resp.StatusCode != http.StatusNotFound { + detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("删除阿里云 OSS 对象失败:%s %s", resp.Status, strings.TrimSpace(string(detail))) + } + return nil +} + +func deleteTencentCOSObject(setting ossSettingValue, objectKey string) error { + client, err := newCOSClient(setting, 2*time.Minute) + if err != nil { + return err + } + resp, err := client.Object.Delete(context.Background(), objectKey) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + return fmt.Errorf("删除腾讯云 COS 对象失败:%w", err) + } + return nil +} diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go new file mode 100644 index 000000000..7f223384f --- /dev/null +++ b/backend/internal/service/resource_delete_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCollectOwnedAssetDocumentReferences(t *testing.T) { + resources := map[string]struct{}{} + tasks := map[string]struct{}{} + raw := `{ + "data":{"storageKey":"resource:resource-1","url":"/api/resources/resource-2/file"}, + "metadata":{"taskId":"task-1","referenceResourceIds":["resource-3"],"errorDetails":"failed near /api/resources/not-a-reference/file"} + }` + if err := collectOwnedAssetDocumentReferences(raw, resources, tasks); err != nil { + t.Fatal(err) + } + for _, resourceID := range []string{"resource-1", "resource-2", "resource-3"} { + if _, exists := resources[resourceID]; !exists { + t.Fatalf("resource %q was not collected: %#v", resourceID, resources) + } + } + if _, exists := tasks["task-1"]; !exists { + t.Fatalf("origin task was not collected: %#v", tasks) + } + if _, exists := resources["not-a-reference"]; exists { + t.Fatalf("diagnostic text must not be treated as an actual resource reference: %#v", resources) + } +} + +func TestDocumentReferencesResourcesUsesExactResourceID(t *testing.T) { + candidates := map[string]struct{}{"resource-1": {}} + if documentReferencesResources(`{"storageKey":"resource:resource-10"}`, candidates) { + t.Fatal("resource-1 must not match resource-10") + } + if !documentReferencesResources(`{"storageKey":"resource:resource-1"}`, candidates) { + t.Fatal("exact resource ID should match") + } +} + +func TestResourceOccupiedMessageNamesBusinessRecord(t *testing.T) { + message := resourceOccupiedMessage([]resourceUsage{ + {Kind: "画布", ID: "canvas-1", Title: "广告分镜"}, + {Kind: "画布", ID: "canvas-1", Title: "广告分镜"}, + }) + if !strings.Contains(message, "画布「广告分镜」") || !strings.Contains(message, "解除引用") { + t.Fatalf("message = %q", message) + } +} + +func TestDeleteLocalResourceObjectRemovesOnlyResourceDirectoryFile(t *testing.T) { + dataDir := t.TempDir() + resourcePath := filepath.Join(dataDir, "resources", "users", "user-1", "image", "asset.png") + if err := os.MkdirAll(filepath.Dir(resourcePath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(resourcePath, []byte("image"), 0o640); err != nil { + t.Fatal(err) + } + service := &Service{dataDir: dataDir} + if err := service.deleteLocalResourceObject("users/user-1/image/asset.png"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(resourcePath); !os.IsNotExist(err) { + t.Fatalf("resource file still exists or stat failed unexpectedly: %v", err) + } + + outsidePath := filepath.Join(dataDir, "outside.txt") + if err := os.WriteFile(outsidePath, []byte("keep"), 0o640); err != nil { + t.Fatal(err) + } + if err := service.deleteLocalResourceObject("../outside.txt"); err == nil { + t.Fatal("path traversal should be rejected") + } + if _, err := os.Stat(outsidePath); err != nil { + t.Fatalf("outside file was changed: %v", err) + } +} diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index 771d902d7..8532bcc25 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -358,6 +358,123 @@ func TestDirectResourceURLChecksOwnershipAndSignsOSSResource(t *testing.T) { } } +func TestPrepareResourceDeliveryPrefersConfiguredCDN(t *testing.T) { + svc := newResourceTestService(t) + settingJSON, _ := json.Marshal(ossSettingValue{ + Enabled: true, Provider: tencentCOSProvider, Endpoint: "https://cos.ap-shanghai.myqcloud.com", CDNBaseURL: "https://media.example.com", + Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key", + }) + if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil { + t.Fatal(err) + } + resource := model.Resource{ + ID: "resource-cdn-delivery", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, + Provider: tencentCOSProvider, Endpoint: "https://cos.ap-shanghai.myqcloud.com", Bucket: "private-bucket-1250000000", + ObjectKey: "users/user-1/image/test image.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&resource); err != nil { + t.Fatal(err) + } + delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{}) + if err != nil { + t.Fatal(err) + } + if delivery.Resource == nil || delivery.Resource.ID != resource.ID || delivery.RedirectURL != "https://media.example.com/users/user-1/image/test%20image.png" { + t.Fatalf("PrepareResourceDelivery() = %#v", delivery) + } +} + +func TestPrepareResourceDeliveryAllowsExplicitProxyWithCDN(t *testing.T) { + svc := newResourceTestService(t) + settingJSON, _ := json.Marshal(ossSettingValue{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com", + Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }) + if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil { + t.Fatal(err) + } + resource := model.Resource{ + ID: "resource-cdn-proxy", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, + Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket", + ObjectKey: "users/user-1/image/proxy.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&resource); err != nil { + t.Fatal(err) + } + delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{ForceProxy: true}) + if err != nil { + t.Fatal(err) + } + if delivery.Resource == nil || delivery.Resource.ID != resource.ID || delivery.RedirectURL != "" { + t.Fatalf("PrepareResourceDelivery(force proxy) = %#v", delivery) + } +} + +func TestCurrentUserCDNSettingAppliesToHistoricalResourcesInSameStorage(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + svc := newResourceTestService(t) + actor := &model.User{ID: "user-1"} + if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, Bucket: "private-bucket", + AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }); err != nil { + t.Fatal(err) + } + historical, _, err := svc.readUserOSSSetting(actor.ID) + if err != nil { + t.Fatal(err) + } + resource := model.Resource{ + ID: "resource-user-cdn", UserID: actor.ID, Kind: "image", Status: model.ResourceStatusReady, + Provider: aliyunOSSProvider, Endpoint: server.URL, Bucket: "private-bucket", StorageSettingID: historical.ID, + ObjectKey: "users/user-1/image/historical.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&resource); err != nil { + t.Fatal(err) + } + if _, err := svc.UpdateUserOSSSetting(actor, OSSSettingRequest{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, CDNBaseURL: server.URL, Bucket: "private-bucket", + AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }); err != nil { + t.Fatal(err) + } + delivery, err := svc.PrepareResourceDelivery(actor.ID, resource.ID, ResourceDeliveryOptions{}) + if err != nil { + t.Fatal(err) + } + if delivery.RedirectURL != server.URL+"/users/user-1/image/historical.png" { + t.Fatalf("PrepareResourceDelivery(historical user resource) = %#v", delivery) + } +} + +func TestPrepareResourceDeliveryKeepsForcedOriginDirectWithoutCDN(t *testing.T) { + svc := newResourceTestService(t) + settingJSON, _ := json.Marshal(ossSettingValue{ + Enabled: true, Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket", + AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }) + if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil { + t.Fatal(err) + } + resource := model.Resource{ + ID: "resource-origin-direct", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, + Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket", + ObjectKey: "users/user-1/image/direct.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&resource); err != nil { + t.Fatal(err) + } + delivery, err := svc.PrepareResourceDelivery("user-1", resource.ID, ResourceDeliveryOptions{ForceDirect: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(delivery.RedirectURL, "private-bucket.oss-cn-test.aliyuncs.com/users/user-1/image/direct.png") || !strings.Contains(delivery.RedirectURL, "Signature=") { + t.Fatalf("PrepareResourceDelivery(force direct) = %#v", delivery) + } +} + func TestNormalizeSingleByteRange(t *testing.T) { tests := map[string]string{ "bytes=0-1023": "bytes=0-1023", diff --git a/backend/internal/service/text_stream_usage.go b/backend/internal/service/text_stream_usage.go new file mode 100644 index 000000000..501f9027d --- /dev/null +++ b/backend/internal/service/text_stream_usage.go @@ -0,0 +1,37 @@ +package service + +import ( + "encoding/json" + "errors" +) + +// EnsureChatCompletionStreamUsageRequest 为 Token 计费的流式 Chat Completions +// 请求开启最终 usage 块;非流式请求保持原样,避免改变普通接口合同。 +func EnsureChatCompletionStreamUsageRequest(data []byte) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + stream, _ := payload["stream"].(bool) + if !stream { + return data, nil + } + if err := ensureChatCompletionStreamUsage(payload); err != nil { + return nil, err + } + return json.Marshal(payload) +} + +func ensureChatCompletionStreamUsage(payload map[string]any) error { + options := map[string]any{} + if value, exists := payload["stream_options"]; exists { + var ok bool + options, ok = value.(map[string]any) + if !ok { + return errors.New("stream_options 必须是 JSON 对象") + } + } + options["include_usage"] = true + payload["stream_options"] = options + return nil +} diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 17b22facd..8c2ca7008 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "time" + "unicode/utf8" "infinite-canvas/backend/internal/model" @@ -102,17 +103,9 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS } func (s *Service) DeleteUserAsset(userID string, id string) error { - if _, err := s.repo.AssetForUser(userID, id); err != nil { - return err - } - references, err := s.repo.AssetReferenceCount(id) - if err != nil { - return err - } - if references > 0 { - return BadAuthRequest("素材仍被项目或镜头引用,请先解除引用") - } - return s.repo.DeleteAsset(userID, id) + s.storageMu.Lock() + defer s.storageMu.Unlock() + return s.deleteUserAssetWithResources(userID, id) } func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) { @@ -295,6 +288,13 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { if id == "" { id = newID() } + if utf8.RuneCountInString(id) > model.AssetIDMaxLength { + return model.Asset{}, BadAuthRequest("素材 ID 不能超过 80 个字符") + } + primaryVersionID := strings.TrimSpace(payload.PrimaryVersionID) + if utf8.RuneCountInString(primaryVersionID) > 36 { + return model.Asset{}, BadAuthRequest("素材主版本 ID 不能超过 36 个字符") + } category := model.AssetCategory(strings.TrimSpace(payload.Category)) if category == "" { category = model.AssetCategoryOther @@ -309,7 +309,7 @@ func assetFromJSON(userID string, raw json.RawMessage) (model.Asset, error) { Kind: strings.TrimSpace(payload.Kind), Category: category, Status: status, - PrimaryVersionID: strings.TrimSpace(payload.PrimaryVersionID), + PrimaryVersionID: primaryVersionID, Title: strings.TrimSpace(payload.Title), PayloadJSON: string(raw), CreatedAt: createdAt, diff --git a/backend/internal/service/user_data_test.go b/backend/internal/service/user_data_test.go index 95cd1d6ac..76445545c 100644 --- a/backend/internal/service/user_data_test.go +++ b/backend/internal/service/user_data_test.go @@ -2,9 +2,55 @@ package service import ( "encoding/json" + "strings" "testing" + + "infinite-canvas/backend/internal/model" ) +func TestAssetFromJSONAcceptsDeterministicGenerationID(t *testing.T) { + id := "generation_" + strings.Repeat("a", 64) + raw, err := json.Marshal(map[string]any{"id": id, "kind": "image", "title": "生成图片"}) + if err != nil { + t.Fatalf("marshal asset: %v", err) + } + + asset, err := assetFromJSON("user-1", raw) + if err != nil { + t.Fatalf("assetFromJSON: %v", err) + } + if asset.ID != id { + t.Fatalf("asset ID = %q, want %q", asset.ID, id) + } + if len([]rune(asset.ID)) > model.AssetIDMaxLength { + t.Fatalf("generation asset ID length = %d, limit %d", len([]rune(asset.ID)), model.AssetIDMaxLength) + } +} + +func TestAssetFromJSONRejectsIDOverLimit(t *testing.T) { + raw, err := json.Marshal(map[string]any{"id": strings.Repeat("a", model.AssetIDMaxLength+1), "kind": "image"}) + if err != nil { + t.Fatalf("marshal asset: %v", err) + } + + _, err = assetFromJSON("user-1", raw) + if err == nil || !strings.Contains(err.Error(), "素材 ID 不能超过 80 个字符") { + t.Fatalf("assetFromJSON error = %v", err) + } +} + +func TestAssetFromJSONRejectsPrimaryVersionIDOverLimit(t *testing.T) { + raw, err := json.Marshal(map[string]any{"id": "asset-1", "primaryVersionId": strings.Repeat("v", 37)}) + if err != nil { + t.Fatalf("marshal asset: %v", err) + } + + _, err = assetFromJSON("user-1", raw) + if err == nil || !strings.Contains(err.Error(), "素材主版本 ID 不能超过 36 个字符") { + t.Fatalf("assetFromJSON error = %v", err) + } +} + func TestValidateSyncedPayloadAllowsDataURLMentionInErrorMessage(t *testing.T) { raw, err := json.Marshal(map[string]interface{}{ "nodes": []interface{}{ diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 84165c6cc..8f1ddb090 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -43,7 +43,7 @@ "url": "https://github.com/ddcat-ai/open-ai-canvas.git", "directory": "canvas-agent" }, - "license": "AGPL-3.0", + "license": "MIT", "publishConfig": { "access": "public" } diff --git a/plugins/infinite-canvas/.codex-plugin/plugin.json b/plugins/infinite-canvas/.codex-plugin/plugin.json index 53d21a94b..5b138660e 100644 --- a/plugins/infinite-canvas/.codex-plugin/plugin.json +++ b/plugins/infinite-canvas/.codex-plugin/plugin.json @@ -6,7 +6,7 @@ "name": "ddcat" }, "repository": "https://github.com/ddcat-ai/open-ai-canvas", - "license": "AGPL-3.0", + "license": "MIT", "keywords": ["infinite-canvas", "canvas", "mcp", "agent"], "skills": "./skills/", "mcpServers": "./.mcp.json", diff --git a/web/package.json b/web/package.json index 5002c685a..e337828d1 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/public/images/canvas/folder-theme-ember.png b/web/public/images/canvas/folder-theme-ember.png new file mode 100644 index 000000000..62121e9e4 Binary files /dev/null and b/web/public/images/canvas/folder-theme-ember.png differ diff --git a/web/public/images/canvas/folder-theme-obsidian.png b/web/public/images/canvas/folder-theme-obsidian.png new file mode 100644 index 000000000..dc6bed0ee Binary files /dev/null and b/web/public/images/canvas/folder-theme-obsidian.png differ diff --git a/web/public/images/canvas/folder-theme-pearl.png b/web/public/images/canvas/folder-theme-pearl.png new file mode 100644 index 000000000..f43b034d6 Binary files /dev/null and b/web/public/images/canvas/folder-theme-pearl.png differ diff --git a/web/src/components/ai/generation-tool-card.tsx b/web/src/components/ai/generation-tool-card.tsx new file mode 100644 index 000000000..a165ff9da --- /dev/null +++ b/web/src/components/ai/generation-tool-card.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; + +export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled"; + +export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) { + const [open, setOpen] = useState(status !== "completed" || !isBulk); + + useEffect(() => { + if (status !== "completed") setOpen(true); + }, [status]); + + return <> +
+ {heading} + +
+ {open ? children : null} + ; +} diff --git a/web/src/components/ai/message-reasoning.tsx b/web/src/components/ai/message-reasoning.tsx new file mode 100644 index 000000000..3f74c923e --- /dev/null +++ b/web/src/components/ai/message-reasoning.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, Sparkles } from "lucide-react"; + +import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; + +export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) { + const [open, setOpen] = useState(isStreaming); + const [durationSeconds, setDurationSeconds] = useState(); + const startedAtRef = useRef(undefined); + const autoClosedRef = useRef(false); + + useEffect(() => { + if (!isStreaming) return; + startedAtRef.current ??= Date.now(); + setOpen(true); + }, [isStreaming]); + + useEffect(() => { + if (isStreaming || !startedAtRef.current) return; + setDurationSeconds(Math.max(1, Math.ceil((Date.now() - startedAtRef.current) / 1000))); + startedAtRef.current = undefined; + }, [isStreaming]); + + useEffect(() => { + if (isStreaming || !open || autoClosedRef.current || durationSeconds === undefined) return; + const timer = window.setTimeout(() => { + setOpen(false); + autoClosedRef.current = true; + }, 1000); + return () => window.clearTimeout(timer); + }, [durationSeconds, isStreaming, open]); + + return
+ + {open ?
{reasoning}
: null} +
; +} diff --git a/web/src/components/canvas/canvas-folder-preview.tsx b/web/src/components/canvas/canvas-folder-preview.tsx index 36cf6d6e5..ab1630e14 100644 --- a/web/src/components/canvas/canvas-folder-preview.tsx +++ b/web/src/components/canvas/canvas-folder-preview.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useState } from "react"; import { Dropdown } from "antd"; import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react"; -import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas"; +import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme"; +import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType } from "@/types/canvas"; -const DEFAULT_FOLDER_COVER = "/images/canvas/folder-default-cover.png"; - const FOLDER_STYLE_OPTIONS: Array<{ key: CanvasFolderStyle; label: string }> = [ { key: "glass", label: "流光玻璃" }, { key: "stacked", label: "内容陈列" }, @@ -25,6 +24,7 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({ onToggleCollapsed, onTitleChange, onStyleChange, + onThemeChange, }: { data: CanvasNodeData; childNodes: CanvasNodeData[]; @@ -34,13 +34,34 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({ onToggleCollapsed: (nodeId: string) => void; onTitleChange: (nodeId: string, title: string) => void; onStyleChange: (nodeId: string, style: CanvasFolderStyle) => void; + onThemeChange: (nodeId: string, theme: CanvasFolderTheme) => void; }) { const style = data.metadata?.folder?.style || "glass"; + const theme = resolveCanvasFolderTheme(data.metadata?.folder?.theme); const [editing, setEditing] = useState(false); const [title, setTitle] = useState(data.title); const previewNodes = useMemo(() => childNodes.filter(canPreviewFolderNode).slice(0, 3), [childNodes]); - const themeCover = data.metadata?.folder?.themeCover || DEFAULT_FOLDER_COVER; + const themeCover = resolveCanvasFolderThemeCover(theme, data.metadata?.folder?.themeCover); const showAdd = style === "glass" || childNodes.length === 0; + const folderMenu = { + selectedKeys: [`style:${style}`, `theme:${theme}`], + items: [ + { + key: "styles", + label: "文件夹样式", + children: FOLDER_STYLE_OPTIONS.map((item) => ({ key: `style:${item.key}`, label: item.label })), + }, + { + key: "themes", + label: "主题皮肤", + children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })), + }, + ], + onClick: ({ key }: { key: string }) => { + if (key.startsWith("style:")) onStyleChange(data.id, key.slice(6) as CanvasFolderStyle); + if (key.startsWith("theme:")) onThemeChange(data.id, key.slice(6) as CanvasFolderTheme); + }, + }; useEffect(() => setTitle(data.title), [data.title]); @@ -130,17 +151,13 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({ ) : ( ({ key: item.key, label: item.label })), - onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle), - }} + menu={folderMenu} > + ; })} :
{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}
} @@ -811,6 +905,76 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec ; } +function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) { + return
+ + +
; +} + +function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) { + return
+ +
+ + +
+
; +} + +function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { + if (item.role === "user") return ; + const mode = item.mode || "text"; + const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; + const heading = <>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}{mode !== "text" ? {item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}; + const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed"; + return
+ {mode === "text" ? <>
{heading}
{item.reasoning ? : null}
{item.content ? {item.content} : 正在生成…}
: 1} heading={heading}>} + {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null} +
; +} + +function CreationUserMessage({ item }: { item: CreationMessage }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const copyText = useCopyText(); + const visiblePrompt = displayCreationPrompt(item.content, item.references || []); + return
+
你{item.createdAt ? : null}
+

{visiblePrompt}

+ {item.references?.length ? : null} + {item.attachments?.length ?
{item.attachments.map((attachment) => { + const kind = creationAttachmentKind(attachment); + const previewable = kind === "image" || kind === "video"; + const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; + return ; + })}
: null} + setPreviewUrl("")} /> +
; +} + +function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { + const [previewUrl, setPreviewUrl] = useState(""); + const [previewType, setPreviewType] = useState<"image" | "video">("image"); + const assets = useAssetStore((state) => state.assets); + const resultUrls = item.resultUrls || []; + const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : []; + const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas"; + if (item.status === "pending") return ; + if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return
{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}
; + if (!resultUrls.length) return
没有返回可预览结果
; + const isVideo = item.mode === "video"; + return
+ {isVideo ? :
{resultUrls.map((url) => )}
} +
{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}{resultAssetIds.length ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
+ setPreviewUrl("")} /> +
; +} + +function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) { + return
影策正在生成{mode === "video" ? "视频" : "图像"}{onCancel ? : null}
; +} + function CreationMessageReferences({ references }: { references: CreationReference[] }) { return
{references.map((reference) => { const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText; @@ -822,6 +986,27 @@ function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: return {url ? type === "video" ? ; } +function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false, onPreview, onRemove, onAdd }: { + item: CreationAttachment; + primary?: boolean; + canAddMore?: boolean; + onPreview: (type: "image" | "video", url: string) => void; + onRemove: (id: string) => void; + onAdd?: () => void; +}) { + const kind = creationAttachmentKind(item); + const previewable = kind === "image" || kind === "video"; + const url = (kind === "video" ? item.url : item.previewUrl) || ""; + return
+ + + {primary && canAddMore && onAdd ? : null} +
; +} + type ComposerProps = { variant: "empty" | "thread"; mode: CreationMode; @@ -829,6 +1014,7 @@ type ComposerProps = { setPrompt: (value: string) => void; busy: boolean; attachments: CreationAttachment[]; + maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onOpenLibrary: () => void; @@ -861,6 +1047,18 @@ function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); + const priceChannel = resolveModelChannel(props.config, props.model); + const credits = requestCreditCost({ + channelMode: priceChannel.scope === "system" ? "remote" : "local", + modelCosts: priceChannel.modelCosts, + model: modelOptionName(props.model), + count: props.mode === "image" ? props.count : 1, + seconds: props.mode === "video" ? props.seconds : 1, + }); + const showCost = creditsEnabled && credits !== null; + const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); + const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" @@ -869,24 +1067,20 @@ function CreationComposer(props: ComposerProps) { const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video"); + const [primaryAttachment, ...secondaryAttachments] = props.attachments; + const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; - const primaryAttachment = props.attachments[0]; - const remainingAttachments = props.attachments.slice(1); + const previewAttachment = (type: "image" | "video", url: string) => { + setPreviewType(type); + setPreviewUrl(url); + }; return
- - {primaryAttachment ?
- - - -
: } + + {primaryAttachment ? : }
- {remainingAttachments.length ?
{remainingAttachments.map((item) => { - const isVideo = isVideoAttachment(item); - const url = isVideo ? item.url : item.previewUrl; - return
; - })}
: null} + {secondaryAttachments.length ?
{secondaryAttachments.map((item) => )}
: null}
@@ -896,12 +1090,29 @@ function CreationComposer(props: ComposerProps) { onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> + {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
- {props.busy ? : } + setPreviewUrl("")} /> ; @@ -925,17 +1136,43 @@ function GenerationSettingsMenu(props: ComposerProps) { const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; - const ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size); + const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : []; + const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined; + const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1"); + const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto"; + const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : []; + const imageRatios = usesImageResolutionPicker + ? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio))) + : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); + const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios; const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; + const selectImageRatio = (nextRatio: string) => { + if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") { + props.setRatio(nextRatio); + return; + } + props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio); + }; + const selectImageResolution = (choice: ImageResolutionChoice) => { + if (choice === "auto") { + props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio); + return; + } + const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; + if (nextSize) props.setRatio(nextSize); + }; + const videoResolutionSupported = props.mode === "video" && resolutions.length > 0; const imageSummary = [ - ...(props.imageProfile.size.parameter !== "none" ? [props.ratio] : []), + ...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); - const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary; + const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary; const panel =
- {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => )}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} - {props.mode === "video" ?
{resolutions.map((option) => )}
: <> + {props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} + {props.mode === "video" ? (videoResolutionSupported ?
{resolutions.map((option) => )}
: null) : <> + {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: null} } @@ -1011,12 +1248,12 @@ function thinkingFor(mode: CreationMode): CreationThinking { } function directorNoteFor(mode: CreationMode, settings: CreationSettings): string { - if (mode === "video") return `已按 ${settings.seconds}s · ${videoResolutionLabel(settings.videoQuality)} · ${settings.ratio} 渲染这一镜,等待你的下一句指令。`; + if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`; if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`; return ""; } -function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void }) { +function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) { const [railOpen, setRailOpen] = useState(false); const nextShotNumber = shots.length + 1; const closeRail = () => setRailOpen(false); @@ -1049,6 +1286,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
: null}
+ @@ -1088,9 +1326,8 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
{briefVisible && user ?
-
{shotScriptLabels[mode]}{user.createdAt ? : null}
+
{shotScriptLabels[mode]}{user.createdAt ? : null}
-

{visiblePrompt}

{user.references?.length ? : null} {user.attachments?.length ? : null} @@ -1138,9 +1375,10 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); return <>
{attachments.map((attachment) => { - const isVideo = isVideoAttachment(attachment); + const kind = creationAttachmentKind(attachment); + const previewable = kind === "image" || kind === "video"; const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; - return ; + return ; })}
setPreviewUrl("")} />; } @@ -1196,23 +1434,50 @@ function conversationPreviewMessage(conversation: CreationConversation) { return fallback; } -function buildTextMessageContent(item: CreationMessage) { +async function buildTextMessageContent(item: CreationMessage) { const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []); - const images = (item.attachments || []).filter(isImageAttachment); - if (!images.length) return content; - return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))]; + const attachments = item.attachments || []; + if (!attachments.length) return content; + const parts: AiTextContentPart[] = [{ type: "text", text: content }]; + for (const attachment of attachments) { + if (isImageAttachment(attachment)) { + parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } }); + continue; + } + const url = await creationAttachmentDataUrl(attachment); + parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } }); + } + return parts; +} + +async function creationAttachmentDataUrl(attachment: CreationAttachment) { + if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); + const attachmentUrl = attachment.url || ""; + if (attachmentUrl.startsWith("data:")) return attachmentUrl; + const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null; + if (blob) { + if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); + return blobToDataUrl(blob); + } + if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl; + throw new Error(`${attachment.name} 无法读取,请重新上传后再试`); +} + +function blobToDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("附件读取失败")); + reader.readAsDataURL(blob); + }); } function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } { - return attachment.type.startsWith("video/"); + return creationAttachmentKind(attachment) === "video"; } function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } { - return !isVideoAttachment(attachment); -} - -function removeReferenceTokens(value: string, references: CreationReference[]) { - return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); + return creationAttachmentKind(attachment) === "image"; } type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string }; diff --git a/web/src/pages/dev/folder-preview-lab.css b/web/src/pages/dev/folder-preview-lab.css index c567d1395..265471e08 100644 --- a/web/src/pages/dev/folder-preview-lab.css +++ b/web/src/pages/dev/folder-preview-lab.css @@ -105,6 +105,40 @@ margin-top: var(--space-16); } +.folder-preview-lab-theme-catalog { + display: grid; + max-width: 1280px; + margin: var(--space-16) auto 0; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); +} + +.folder-preview-lab-theme-catalog div { + position: relative; + overflow: hidden; + min-height: 88px; + border-radius: var(--r-2xl); + background: var(--library-surface); +} + +.folder-preview-lab-theme-catalog img { + width: 100%; + height: 100%; + min-height: 88px; + object-fit: cover; +} + +.folder-preview-lab-theme-catalog span { + position: absolute; + left: var(--space-3); + bottom: var(--space-3); + border-radius: var(--r-full); + background: color-mix(in srgb, var(--color-neutral-950) 72%, transparent); + padding: var(--space-1) var(--space-2); + color: var(--color-neutral-0); + font-size: var(--fs-caption); +} + .folder-preview-lab-section-heading { display: flex; margin-bottom: var(--space-6); @@ -286,6 +320,7 @@ .folder-preview-lab-controls, .folder-preview-lab-section-heading { align-items: flex-start; flex-direction: column; } .folder-preview-lab-grid { grid-template-columns: minmax(0, 1fr); } + .folder-preview-lab-theme-catalog { grid-template-columns: repeat(2, minmax(0, 1fr)); } .folder-preview-lab-folder { transform: scale(.86); transform-origin: top center; margin-bottom: -40px; } .folder-preview-lab-drawer { inset: var(--space-2); padding: var(--space-4); } .folder-preview-lab-drawer-header { grid-template-columns: 1fr 1fr; } diff --git a/web/src/pages/dev/folder-preview-lab.tsx b/web/src/pages/dev/folder-preview-lab.tsx index 2a25ec90b..2412acef4 100644 --- a/web/src/pages/dev/folder-preview-lab.tsx +++ b/web/src/pages/dev/folder-preview-lab.tsx @@ -2,8 +2,9 @@ import { useMemo, useState } from "react"; import { ChevronLeft, FolderOpen, ImagePlus, Moon, MousePointer2, Sun } from "lucide-react"; import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview"; +import { CANVAS_FOLDER_THEME_OPTIONS } from "@/lib/canvas/canvas-folder-theme"; import { useThemeStore } from "@/stores/use-theme-store"; -import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas"; +import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas"; import { CanvasNodeType } from "@/types/canvas"; import "./folder-preview-lab.css"; @@ -27,12 +28,12 @@ const PRESET_GROUPS = [ { key: "derived", title: "风格衍生", - description: "沿用同一主题皮肤与交互合同的工作流变体。", + description: "外形与主题正交组合,六种结构都可切换四套皮肤。", presets: STYLE_PRESETS.slice(3), }, ]; -const FOLDER_THEME_COVER = "/images/canvas/folder-default-cover.png"; +const THEME_SEQUENCE: CanvasFolderTheme[] = ["aurora", "pearl", "ember", "obsidian", "aurora", "pearl"]; const SAMPLE_MEDIA = [ "/short-drama-styles/fantasy-3d.jpg", @@ -63,7 +64,7 @@ function makeFolderNode(style: CanvasFolderStyle, index: number): CanvasNodeData width: 360, height: 280, metadata: { - folder: { style, createdAt: new Date(2026, 7, 19 + index).toISOString(), themeCover: FOLDER_THEME_COVER }, + folder: { style, theme: THEME_SEQUENCE[index], createdAt: new Date(2026, 7, 19 + index).toISOString() }, frame: { collapsed: true, expandedWidth: 760, expandedHeight: 520 }, }, }; @@ -115,7 +116,7 @@ export default function FolderPreviewLab() {
DEV · INTERACTIVE FOLDER LAB

通用素材文件夹

-

红蓝抽象图只作为文件夹主题皮肤;打开文件夹后,内部素材以独立内容卡片展示。悬停、单击和双击都有对应反馈。

+

文件夹外形与主题皮肤独立:四套主题都不是内部素材。打开后,真实内容以独立卡片展示;悬停、单击、双击与菜单均可交互。

@@ -128,7 +129,7 @@ export default function FolderPreviewLab() {
- 试试 hover、双击与样式切换 + 试试 hover、双击、样式与主题切换
@@ -180,6 +181,15 @@ export default function FolderPreviewLab() { }, })) } + onThemeChange={(nodeId, nextTheme) => + updateFolder(nodeId, (current) => ({ + ...current, + metadata: { + ...current.metadata, + folder: { ...current.metadata!.folder!, theme: nextTheme, themeCover: undefined }, + }, + })) + } />
: null}
- {childFolders.length ?
{childFolders.map((folder) => { setFolderId(folder.id); setCategory("all"); }} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id)} />)}
: null} + {childFolders.length ?
{childFolders.map((folder) => { setFolderId(folder.id); setCategory("all"); }} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onTheme={(theme) => themeFolderMutation.mutate({ id: folder.id, theme })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id) || (themeFolderMutation.isPending && themeFolderMutation.variables?.id === folder.id)} />)}
: null} {folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? (
@@ -368,7 +374,7 @@ function ProjectAssetFolderTree({ folders, assets, selectedId, onSelect }: { fol return
{renderLevel("", 0, new Set())}
; } -function ProjectAssetFolderCard({ folder, folders, assets, personalAssets, onOpen, onRename, onMove, onStyle, onDelete, deleting }: { folder: ProjectAssetFolder; folders: ProjectAssetFolder[]; assets: ProjectAsset[]; personalAssets: Asset[]; onOpen: () => void; onRename: () => void; onMove: (parentId: string) => void; onStyle: (style: CanvasFolderStyle) => void; onDelete: () => void; deleting: boolean }) { +function ProjectAssetFolderCard({ folder, folders, assets, personalAssets, onOpen, onRename, onMove, onStyle, onTheme, onDelete, deleting }: { folder: ProjectAssetFolder; folders: ProjectAssetFolder[]; assets: ProjectAsset[]; personalAssets: Asset[]; onOpen: () => void; onRename: () => void; onMove: (parentId: string) => void; onStyle: (style: CanvasFolderStyle) => void; onTheme: (theme: CanvasFolderTheme) => void; onDelete: () => void; deleting: boolean }) { const directAssets = assets.filter((asset) => (asset.folderId || "") === folder.id); const totalCount = projectAssetFolderDescendantAssetCount(folders, assets, folder.id); const childFolderCount = folders.filter((item) => item.parentId === folder.id).length; @@ -382,14 +388,14 @@ function ProjectAssetFolderCard({ folder, folders, assets, personalAssets, onOpe height: 280, metadata: { frame: { collapsed: true, expandedWidth: 760, expandedHeight: 520 }, - folder: { style: projectAssetFolderStyle(folder.style), createdAt: folder.createdAt }, + folder: { style: projectAssetFolderStyle(folder.style), theme: resolveCanvasFolderTheme(folder.theme), createdAt: folder.createdAt }, }, }; const childNodes = directAssets.slice(0, 3).map((asset, index) => projectAssetCanvasPreviewNode(asset, personalAssets.find((item) => item.id === asset.id), index)); return
+
undefined} onStyleChange={() => undefined} onThemeChange={() => undefined} />
+
{directAssets.length} 项内容{childFolderCount ? ` · ${childFolderCount} 个子文件夹` : ""} }, { key: "move", label: "移动到", icon: , children: moveItems }, { key: "style", label: "切换样式", children: [{ key: "style:glass", label: "流光玻璃" }, { key: "style:stacked", label: "内容陈列" }, { key: "style:midnight", label: "午夜封面" }, { key: "style:paper", label: "纸感收藏" }, { key: "style:cinema", label: "电影胶片" }, { key: "style:compact", label: "紧凑资料" }] }, { key: "theme", label: "切换主题", children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })) }, { key: "delete", label: "删除空文件夹", icon: , danger: true }], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); if (key === "rename") onRename(); else if (key.startsWith("move:")) onMove(key === "move:root" ? "" : key.slice(5)); else if (key.startsWith("style:")) onStyle(key.slice(6) as CanvasFolderStyle); else if (key.startsWith("theme:")) onTheme(key.slice(6) as CanvasFolderTheme); else if (key === "delete") onDelete(); } }}>
; } diff --git a/web/src/pages/settings/channel-settings-pane.tsx b/web/src/pages/settings/channel-settings-pane.tsx index 2825cde79..0f8f24cc0 100644 --- a/web/src/pages/settings/channel-settings-pane.tsx +++ b/web/src/pages/settings/channel-settings-pane.tsx @@ -4,10 +4,9 @@ import { useState } from "react"; import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor"; import { WorkspaceState } from "@/components/layout/workspace-state"; -import { defaultModelCapabilityConfig } from "@/lib/model-capabilities"; -import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols"; +import { mergeFetchedChannelModelCosts } from "@/lib/channel-model-catalog"; import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel"; -import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image"; +import { fetchChannelModels } from "@/services/api/image"; import { createModelChannel, defaultBaseUrlForApiFormat, @@ -21,8 +20,6 @@ import { ChannelModelSettings } from "./channel-video-pricing"; import { useUserStore } from "@/stores/use-user-store"; type UserChannelConnection = "openai" | "gemini"; -type ChannelModelCost = NonNullable[number]; - export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) { const { message } = App.useApp(); const config = useConfigStore((state) => state.config); @@ -108,7 +105,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void return; } updateChannels( - latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)), + latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedChannelModelCosts(item, result.catalog) } : item)), latestConfig, ); message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`); @@ -155,7 +152,7 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void updateChannels( latestConfig.channels.map((channel) => { const fetched = resultMap.get(channel.id); - return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel; + return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedChannelModelCosts(channel, fetched.catalog) } : channel; }), latestConfig, ); @@ -310,25 +307,6 @@ function normalizeDefaultModel(value: string, options: string[]) { return options.includes(value) ? value : options[0] || ""; } -function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] { - const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost])); - const next: ChannelModelCost[] = []; - for (const item of catalog) { - const existing = existingByModel.get(item.id); - const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes); - if (existing) { - const inferredCapability = modelProtocolCapability(inferredProtocol); - next.push(inferredProtocol && existing.protocol !== inferredProtocol ? { ...existing, protocol: inferredProtocol, capability: inferredCapability || existing.capability, capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined } : existing); - continue; - } - const catalogProtocol = inferredProtocol || channel.interfaceType; - const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined; - if (!catalogProtocol || !catalogCapability) continue; - next.push({ model: item.id, capability: catalogCapability, protocol: catalogProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined }); - } - return next; -} - function uniqueModels(models: string[]) { return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean))); } diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 39086fe15..ea835247c 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -64,14 +64,15 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan const protocol = cost?.protocol || defaultProtocolForModel(channel, model); const capability = cost?.capability || modelProtocolCapability(protocol) || "text"; const billingMode = cost?.billingMode || "fixed_request"; + const displayName = cost?.displayName?.trim() || model; return (
-
- {model} +
+ {displayName}
diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 6fbdbbbe1..2870f2414 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store"; +import { sanitizeChannelModelCatalogItem, type ChannelModelCatalogItem } from "@/lib/channel-model-catalog"; import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; import { buildImageReferencePromptText } from "@/lib/image-reference-prompt"; @@ -14,9 +15,14 @@ import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type I export type AiTextMessage = { role: "system" | "user" | "assistant"; - content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; + content: string | AiTextContentPart[]; }; +export type AiTextContentPart = + | { type: "text"; text: string } + | { type: "image_url"; image_url: { url: string } } + | { type: "file_url"; file_url: { url: string; name: string; mimeType: string } }; + export type ResponseToolCall = { id: string; type: "function"; @@ -39,11 +45,12 @@ export type ResponseFunctionTool = { export type ToolResponseResult = { content: string; toolCalls: ResponseToolCall[]; + reasoning?: string; }; type ToolChoice = "auto" | "required" | { type: "function"; name: string }; type ResponseMessageContent = AiTextMessage["content"] | string; -type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string }; +type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string }; type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string }; type ResponseApiToolDefinition = { type: "function"; @@ -61,16 +68,16 @@ type ResponseApiPayload = { code?: number; msg?: string; }; -type ResponseStreamState = { buffer: string; text: string; payload?: ResponseApiPayload; error?: string }; +type ResponseStreamState = { buffer: string; text: string; reasoning: string; payload?: ResponseApiPayload; error?: string }; type ChatCompletionToolCall = { id?: string; type?: "function"; function?: { name?: string; arguments?: string } }; type ChatCompletionPayload = { - choices?: Array<{ message?: { content?: string | null; tool_calls?: ChatCompletionToolCall[] } }>; + choices?: Array<{ message?: { content?: string | null; reasoning_content?: string | null; tool_calls?: ChatCompletionToolCall[] } }>; error?: { message?: string }; code?: number; msg?: string; }; type ChatCompletionStreamToolCall = { id: string; name: string; arguments: string }; -type ChatCompletionStreamState = { buffer: string; text: string; toolCalls: Map; error?: string }; +type ChatCompletionStreamState = { buffer: string; text: string; reasoning: string; toolCalls: Map; error?: string }; type ImageApiResponse = { data?: Array>; @@ -80,6 +87,7 @@ type ImageApiResponse = { }; type GeminiPart = { text?: string; + thought?: boolean; inlineData?: { mimeType?: string; data?: string }; inline_data?: { mime_type?: string; mimeType?: string; data?: string }; fileData?: { mimeType?: string; fileUri?: string }; @@ -95,8 +103,8 @@ type GeminiPayload = { error?: { message?: string }; promptFeedback?: { blockReason?: string }; }; -type GeminiStreamState = { buffer: string; text: string; toolCalls: ResponseToolCall[]; error?: string }; -type RequestOptions = { signal?: AbortSignal; promptCacheKey?: string }; +type GeminiStreamState = { buffer: string; text: string; reasoning: string; toolCalls: ResponseToolCall[]; error?: string }; +type RequestOptions = { signal?: AbortSignal; promptCacheKey?: string; onReasoning?: (text: string) => void }; const QUALITY_BASE: Record = { low: 1024, @@ -347,7 +355,13 @@ function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[] function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] { if (!Array.isArray(content)) return String(content || ""); - return content.map((item) => (item.type === "text" ? { type: "input_text" as const, text: item.text } : { type: "input_image" as const, image_url: item.image_url.url })); + return content.map((item) => { + if (item.type === "text") return { type: "input_text" as const, text: item.text }; + if (item.type === "image_url") return { type: "input_image" as const, image_url: item.image_url.url }; + return item.file_url.url.startsWith("data:") + ? { type: "input_file" as const, filename: item.file_url.name, file_data: item.file_url.url } + : { type: "input_file" as const, filename: item.file_url.name, file_url: item.file_url.url }; + }); } function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition { @@ -377,13 +391,24 @@ function toChatCompletionMessages(messages: ResponseInputMessage[]) { if (message.role === "tool") { result.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content }); } else { - result.push({ role: message.role, content: message.content }); + result.push({ role: message.role, content: toChatCompletionContent(message.content) }); } index += 1; } return result; } +function toChatCompletionContent(content: ResponseMessageContent) { + if (!Array.isArray(content)) return content; + return content.map((item) => { + if (item.type !== "file_url") return item; + const file = item.file_url.url.startsWith("data:") + ? { filename: item.file_url.name, file_data: item.file_url.url } + : { filename: item.file_url.name, file_url: item.file_url.url }; + return { type: "file", file }; + }); +} + function toChatCompletionToolChoice(toolChoice: ToolChoice) { return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice; } @@ -404,7 +429,7 @@ function parseChatCompletionPayload(payload: ChatCompletionPayload): ToolRespons function: { name: call.function?.name || "", arguments: call.function?.arguments || "{}" }, })) .filter((call) => call.id && call.function.name); - return { content: message?.content || "", toolCalls }; + return { content: message?.content || "", toolCalls, ...(message?.reasoning_content ? { reasoning: message.reasoning_content } : {}) }; } function parseToolResponse(payload: ResponseApiPayload): ToolResponseResult { @@ -472,7 +497,7 @@ async function readJsonPayload(response: Response, fallback: string): Promise } } -function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void) { +function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) { const data = block .split(/\r?\n/) .filter((line) => line.startsWith("data:")) @@ -492,6 +517,14 @@ function consumeResponseStreamBlock(block: string, state: ResponseStreamState, o state.text = event.text; onDelta?.(state.text); } + if (["response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta"].includes(type) && typeof event.delta === "string") { + state.reasoning += event.delta; + onReasoning?.(state.reasoning); + } + if (["response.reasoning.done", "response.reasoning_text.done", "response.reasoning_summary_text.done"].includes(type) && !state.reasoning && typeof event.text === "string") { + state.reasoning = event.text; + onReasoning?.(state.reasoning); + } if (type === "response.completed" && isRecord(event.response)) { state.payload = event.response as ResponseApiPayload; } else if (Array.isArray(event.output)) { @@ -499,17 +532,17 @@ function consumeResponseStreamBlock(block: string, state: ResponseStreamState, o } } -function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, flush = false) { +function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) { state.buffer += text; for (;;) { const match = state.buffer.match(/\r?\n\r?\n/); if (!match) break; const index = match.index ?? 0; - consumeResponseStreamBlock(state.buffer.slice(0, index), state, onDelta); + consumeResponseStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning); state.buffer = state.buffer.slice(index + match[0].length); } if (flush && state.buffer.trim()) { - consumeResponseStreamBlock(state.buffer, state, onDelta); + consumeResponseStreamBlock(state.buffer, state, onDelta, onReasoning); state.buffer = ""; } } @@ -532,22 +565,22 @@ async function requestStreamingResponse(config: AiConfig, body: Record void) { +function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) { const data = block .split(/\r?\n/) .filter((line) => line.startsWith("data:")) @@ -566,6 +599,11 @@ function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionSt state.text += delta.content; onDelta?.(state.text); } + const reasoningDelta = stringValue(delta.reasoning_content) || stringValue(delta.reasoning) || stringValue(delta.reasoning_text); + if (reasoningDelta) { + state.reasoning += reasoningDelta; + onReasoning?.(state.reasoning); + } const chunks = Array.isArray(delta.tool_calls) ? delta.tool_calls : []; chunks.forEach((value, fallbackIndex) => { if (!isRecord(value)) return; @@ -580,17 +618,17 @@ function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionSt }); } -function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text: string, onDelta?: (text: string) => void, flush = false) { +function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) { state.buffer += text; for (;;) { const match = state.buffer.match(/\r?\n\r?\n/); if (!match) break; const index = match.index ?? 0; - consumeChatCompletionStreamBlock(state.buffer.slice(0, index), state, onDelta); + consumeChatCompletionStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning); state.buffer = state.buffer.slice(index + match[0].length); } if (flush && state.buffer.trim()) { - consumeChatCompletionStreamBlock(state.buffer, state, onDelta); + consumeChatCompletionStreamBlock(state.buffer, state, onDelta, onReasoning); state.buffer = ""; } } @@ -606,24 +644,28 @@ async function requestStreamingChatCompletion(config: AiConfig, body: Record(response, "请求失败")); + if (!response.body || !contentType.includes("text/event-stream")) { + const result = parseChatCompletionPayload(await readJsonPayload(response, "请求失败")); + if (result.reasoning) options?.onReasoning?.(result.reasoning); + return result; + } const reader = response.body.getReader(); const decoder = new TextDecoder(); - const state: ChatCompletionStreamState = { buffer: "", text: "", toolCalls: new Map() }; + const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() }; for (;;) { const { done, value } = await reader.read(); if (done) break; - consumeChatCompletionStreamText(state, decoder.decode(value, { stream: true }), onDelta); + consumeChatCompletionStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning); if (state.error) throw new Error(state.error); } - consumeChatCompletionStreamText(state, decoder.decode(), onDelta, true); + consumeChatCompletionStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true); if (state.error) throw new Error(state.error); const toolCalls = Array.from(state.toolCalls.entries()) .sort(([left], [right]) => left - right) .map(([, call]) => ({ id: call.id, type: "function" as const, function: { name: call.name, arguments: call.arguments || "{}" } })) .filter((call) => call.id && call.function.name); - return { content: state.text, toolCalls }; + return { content: state.text, toolCalls, ...(state.reasoning ? { reasoning: state.reasoning } : {}) }; } function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record) { @@ -653,18 +695,22 @@ function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] { function toGeminiParts(content: ResponseMessageContent): GeminiPart[] { if (!Array.isArray(content)) return [{ text: String(content || "") }]; - return content.map((item) => (item.type === "text" ? { text: item.text } : toGeminiImagePart(item.image_url.url))); + return content.map((item) => { + if (item.type === "text") return { text: item.text }; + if (item.type === "image_url") return toGeminiFilePart(item.image_url.url, "image/png"); + return toGeminiFilePart(item.file_url.url, item.file_url.mimeType); + }); } -function toGeminiImagePart(url: string): GeminiPart { +function toGeminiFilePart(url: string, mimeType: string): GeminiPart { const match = url.match(/^data:([^;,]+);base64,(.+)$/); if (match) return { inlineData: { mimeType: match[1], data: match[2] } }; - return { fileData: { fileUri: url, mimeType: "image/png" } }; + return { fileData: { fileUri: url, mimeType } }; } function geminiTextContent(content: ResponseMessageContent) { if (!Array.isArray(content)) return String(content || ""); - return content.map((item) => (item.type === "text" ? item.text : item.image_url.url)).join("\n"); + return content.map((item) => item.type === "text" ? item.text : item.type === "image_url" ? item.image_url.url : `${item.file_url.name}: ${item.file_url.url}`).join("\n"); } function jsonObject(value: string): Record { @@ -706,39 +752,41 @@ async function requestGeminiStreamingResponse(config: AiConfig, body: Record void, flush = false) { +function consumeGeminiStreamText(state: GeminiStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) { state.buffer += text; for (;;) { const match = state.buffer.match(/\r?\n\r?\n/); if (!match) break; const index = match.index ?? 0; - consumeGeminiStreamBlock(state.buffer.slice(0, index), state, onDelta); + consumeGeminiStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning); state.buffer = state.buffer.slice(index + match[0].length); } if (flush && state.buffer.trim()) { - consumeGeminiStreamBlock(state.buffer, state, onDelta); + consumeGeminiStreamBlock(state.buffer, state, onDelta, onReasoning); state.buffer = ""; } } -function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDelta?: (text: string) => void) { +function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) { const data = block .split(/\r?\n/) .filter((line) => line.startsWith("data:")) @@ -747,6 +795,10 @@ function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDel .trim(); if (!data || data === "[DONE]") return; const result = parseGeminiToolResponse(JSON.parse(data) as GeminiPayload); + if (result.reasoning) { + state.reasoning += result.reasoning; + onReasoning?.(state.reasoning); + } if (result.content) { state.text += result.content; onDelta?.(state.text); @@ -757,7 +809,8 @@ function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDel function parseGeminiToolResponse(payload: GeminiPayload): ToolResponseResult { validateGeminiPayload(payload); const parts = payload.candidates?.flatMap((candidate) => candidate.content?.parts || []) || []; - const content = parts.map((part) => part.text || "").join(""); + const content = parts.filter((part) => !part.thought).map((part) => part.text || "").join(""); + const reasoning = parts.filter((part) => part.thought).map((part) => part.text || "").join(""); const toolCalls = parts .map((part) => part.functionCall) .filter((call): call is NonNullable => Boolean(call?.name)) @@ -771,7 +824,7 @@ function parseGeminiToolResponse(payload: GeminiPayload): ToolResponseResult { ...(thoughtSignature ? { thoughtSignature } : {}), }; }); - return { content, toolCalls }; + return { content, toolCalls, ...(reasoning ? { reasoning } : {}) }; } async function requestGeminiImages(config: AiConfig, prompt: string, references: ReferenceImage[], count: number, options?: RequestOptions) { @@ -782,7 +835,7 @@ async function requestGeminiImages(config: AiConfig, prompt: string, references: async function requestGeminiImagesOnce(config: AiConfig, prompt: string, references: ReferenceImage[], options?: RequestOptions) { const parts: GeminiPart[] = [{ text: prompt }]; for (const image of references) { - parts.push(toGeminiImagePart(await imageToDataUrl(image))); + parts.push(toGeminiFilePart(await imageToDataUrl(image), image.type || "image/png")); } const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config)); const response = await axios.post( @@ -860,6 +913,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option model: requestConfig.model, prompt: withSystemPrompt(requestConfig, prompt), n, + watermark: false, ...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}), } : { @@ -954,6 +1008,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: model: requestConfig.model, prompt: withSystemPrompt(requestConfig, requestPrompt), n, + watermark: false, ...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}), ...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}), }, @@ -1113,8 +1168,6 @@ export async function fetchImageModels(config: Pick { @@ -1141,8 +1194,8 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal } const catalog = new Map(); for (const item of response.data.data?.models || []) { - const entry = typeof item === "string" ? { id: item.trim() } : { id: String(item.id || "").trim(), supportedEndpointTypes: Array.isArray(item.supportedEndpointTypes) ? item.supportedEndpointTypes : undefined }; - if (!entry.id) continue; + const entry = typeof item === "string" ? sanitizeChannelModelCatalogItem({ id: item }) : sanitizeChannelModelCatalogItem(item); + if (!entry) continue; const existing = catalog.get(entry.id); catalog.set(entry.id, existing || entry); } diff --git a/web/src/services/api/projects.ts b/web/src/services/api/projects.ts index aeb54b5e5..2c413aafb 100644 --- a/web/src/services/api/projects.ts +++ b/web/src/services/api/projects.ts @@ -70,6 +70,7 @@ export type ProjectAssetFolder = { parentId?: string; name: string; style: "glass" | "stacked" | "midnight" | "paper" | "cinema" | "compact" | string; + theme: "aurora" | "obsidian" | "ember" | "pearl" | string; position: number; createdAt: string; updatedAt: string; @@ -258,11 +259,11 @@ export function listProjectAssetFolders(projectId: string, signal?: AbortSignal) return request<{ folders: ProjectAssetFolder[] }>(api.get(`/projects/${encodeURIComponent(projectId)}/asset-folders`, { signal })); } -export function createProjectAssetFolder(projectId: string, input: { name: string; parentId?: string; style?: ProjectAssetFolder["style"] }) { +export function createProjectAssetFolder(projectId: string, input: { name: string; parentId?: string; style?: ProjectAssetFolder["style"]; theme?: ProjectAssetFolder["theme"] }) { return request<{ folder: ProjectAssetFolder }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-folders`, input)); } -export function updateProjectAssetFolder(projectId: string, folderId: string, input: { name?: string; parentId?: string; style?: ProjectAssetFolder["style"] }) { +export function updateProjectAssetFolder(projectId: string, folderId: string, input: { name?: string; parentId?: string; style?: ProjectAssetFolder["style"]; theme?: ProjectAssetFolder["theme"] }) { return request<{ folder: ProjectAssetFolder }>(api.patch(`/projects/${encodeURIComponent(projectId)}/asset-folders/${encodeURIComponent(folderId)}`, input)); } diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 88f307b96..5dc8d73c2 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -142,7 +142,7 @@ export function resourceFileUrl(id: string) { function resourceProxyFileUrl(id: string) { const base = String(apiBaseURL).replace(/\/+$/, ""); - return `${base}/resources/${encodeURIComponent(id)}/file`; + return `${base}/resources/${encodeURIComponent(id)}/file?proxy=1`; } export async function resolveResourceUrl(storageKey?: string, fallback = "") { diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 4d5186915..2cd0d3053 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -6,7 +6,7 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil import { getResourceOSSUrl } from "@/services/api/resources"; import { channelRequest } from "@/services/api/custom-channel-relay"; import { imageToDataUrl } from "@/services/image-storage"; -import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities"; +import { modelCapabilityConfigFor, videoDurationAllowed, videoResolutionRequest } from "@/lib/model-capabilities"; import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; @@ -85,6 +85,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string function assertVideoCapability(profile: NonNullable["video"]>, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], seconds: string) { if (references.length > profile.references.maxImages || videoReferences.length > profile.references.maxVideos || audioReferences.length > profile.references.maxAudios) throw new Error("参考素材数量超过当前模型限制"); + if (references.length < profile.references.minImages) throw new Error(`当前视频模型至少需要 ${profile.references.minImages} 张参考图`); if (!videoDurationAllowed(profile, Number(seconds))) throw new Error("视频时长不在当前模型支持范围内"); if (profile.references.maxImageBytes > 0 && references.some((image) => (image.bytes || 0) > profile.references.maxImageBytes)) throw new Error("参考图片文件超过当前模型大小限制"); for (const video of videoReferences) { @@ -116,12 +117,13 @@ async function createVideoGenerationsTask(config: ResolvedAiConfig, model: strin Promise.all(audioReferences.map((item) => resolveVideoGenerationsUrl(item.url, item.storageKey))), ]); const profile = modelCapabilityConfigFor(config, model).video!; + const resolution = newAPIVideoResolutionRequest(profile, config.vquality, modelOptionName(model)); const payload = { model: modelOptionName(model), prompt: prompt.trim(), seconds: normalizeVideoSeconds(config.videoSeconds), aspect_ratio: normalizeVideoSize(config.size) || "16:9", - resolution: normalizeVideoResolution(config.vquality), + ...(resolution ? { resolution } : {}), ...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}), ...(imageUrls.length ? { image_urls: imageUrls } : {}), ...(videoUrls.length ? { video_urls: videoUrls } : {}), @@ -137,6 +139,11 @@ async function createVideoGenerationsTask(config: ResolvedAiConfig, model: strin } } +function newAPIVideoResolutionRequest(profile: NonNullable["video"]>, value: string, model: string) { + if (model.trim().toLowerCase() === "grok-video-1.5-1080p") return "1080p"; + return videoResolutionRequest(profile, value); +} + async function pollVideoGenerationsTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise { try { const raw = await channelGet>>(config, aiApiUrl(config, `/video/generations/${encodeURIComponent(task.id)}`), options); @@ -356,7 +363,8 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr body.append("prompt", prompt); body.append("seconds", normalizeVideoSeconds(config.videoSeconds)); if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!); - body.append("resolution_name", normalizeVideoResolution(config.vquality)); + const resolution = videoResolutionRequest(modelCapabilityConfigFor(config, model).video!, config.vquality); + if (resolution) body.append("resolution_name", resolution); body.append("preset", "normal"); const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) }))); files.forEach((file) => body.append("input_reference[]", file)); @@ -623,6 +631,7 @@ function normalizeVideoSize(value: string) { function normalizeVideoResolution(value: string) { if (value === "low") return "480p"; if (value === "auto" || value === "high" || value === "medium") return "720p"; + if (value.toLowerCase() === "2k") return "1440p"; if (value.toLowerCase() === "4k") return "2160p"; const resolution = value.replace(/p$/i, "") || "720"; return `${resolution}p`; diff --git a/web/src/services/creation-conversation-store.ts b/web/src/services/creation-conversation-store.ts index 4162e01b1..8295e3b4a 100644 --- a/web/src/services/creation-conversation-store.ts +++ b/web/src/services/creation-conversation-store.ts @@ -20,6 +20,14 @@ export function updateCreationConversationSnapshot(con return conversations.map((conversation) => (conversation.id === conversationId ? updater(conversation) : conversation)); } +// 对话、生成任务与素材是独立持久状态;删除历史记录不能在这里级联清理任务或资源。 +export function removeCreationConversationSnapshot(conversations: T[], conversationId: string) { + if (!conversationId) throw new Error("缺少要删除的创作对话 ID"); + const next = conversations.filter((conversation) => conversation.id !== conversationId); + if (next.length === conversations.length) throw new Error("要删除的创作对话不存在"); + return next; +} + export function pendingCreationMediaKey(conversations: StoredCreationConversation[]) { return conversations .flatMap((conversation) => conversation.messages.flatMap((message) => (message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : []))) diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 973769d51..3173353be 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -783,6 +783,7 @@ --prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */ --settings-model-option-min-height: 76px; --prompt-panel-heading-fs: var(--fs-heading); /* 16px 区标题 */ + --creation-media-pending-max-width: 320px; /* 进度条(对应 #98 决策3 任务进度)*/ --progress-bar-height: 6px; @@ -3201,23 +3202,6 @@ html:not(.dark) .creation-chat-control:hover, html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { border-color: rgba(20, 26, 35, .2) !important; background: #e9edf1 !important; } - .creation-chat-submit { - display: grid; - width: 38px; - height: 38px; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 50%; - background: #303540; - color: #f1f3f5; - transition: background-color 140ms ease, color 140ms ease, transform 140ms var(--motion-ease-out); - } - .creation-chat-submit:hover:not(:disabled) { background: #f3f4f6; color: #121417; transform: translateY(-1px); } - .creation-chat-submit:active:not(:disabled) { transform: scale(.96); } - .creation-chat-submit:disabled { cursor: not-allowed; background: #282c33; color: #5c6370; } - .creation-chat-submit.is-stopping { background: #bd4949; color: white; } - .creation-control-popover-surface { border: 0 !important; border-radius: 12px !important; @@ -3317,7 +3301,6 @@ .creation-chat-controls { max-width: calc(100% - 42px); gap: 5px; } .creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 34px !important; padding: 0 8px !important; font-size: 11px; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(152px, 48vw) !important; } - .creation-chat-submit { width: 36px; height: 36px; } .creation-thread-stage { padding-top: 28px; } .creation-assistant-message { margin-bottom: 32px; } .creation-message-content, .creation-message-error { padding-left: 0; } @@ -4075,8 +4058,9 @@ .task-record-dot.is-queued { background: var(--palette-status-loading); } .task-record-dot.is-failed { background: var(--palette-status-error); } .task-record-dot.is-idle { background: color-mix(in srgb, var(--foreground) 30%, transparent); } - .task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 28%, transparent); } - .task-record-thumb img, .task-record-thumb video { width: 100%; height: 100%; object-fit: cover; } + .task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); padding: 0; color: color-mix(in srgb, var(--foreground) 28%, transparent); } + /* 媒体脱离 Grid 固有尺寸计算,避免竖图按原始宽高比把固定缩略图撑出任务行。 */ + .task-record-thumb img, .task-record-thumb video { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; } .task-record-meta { display: flex; min-width: 0; align-items: center; gap: 8px; margin-top: 7px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); white-space: nowrap; } .task-record-meta-canvas { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; text-overflow: ellipsis; } .task-record-meta-canvas svg { flex: 0 0 auto; opacity: .7; } @@ -5098,16 +5082,17 @@ box-shadow: 0 24px 72px rgba(15, 23, 42, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.16); } + /* Dock 命令按钮:BottomMenu 式干净状态——透明默认 + hover/active 仅换底色, + 去掉描边/内阴影/发光阴影的多层反馈,hover 与 active 用同一套视觉语言, + 保持 dock 整体性(参考 uselayouts BottomMenu 的 bg-muted/bg-accent 切换)。*/ .aceternity-dock-command { - border-color: color-mix(in srgb, currentColor 11%, transparent); - background: var(--dock-command-bg); + border-color: transparent; + background: transparent; color: inherit; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 5px 15px rgba(15, 23, 42, 0.08); + box-shadow: none; transition: - border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, - color var(--motion-instant) ease-out, - box-shadow var(--motion-instant) ease-out; + color var(--motion-instant) ease-out; } .aceternity-dock-command > span:first-child svg { @@ -5117,33 +5102,12 @@ } .aceternity-dock-command:hover:not(:disabled) { - border-color: color-mix(in srgb, currentColor 20%, transparent); background: var(--dock-command-hover); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 12px 30px rgba(15, 23, 42, 0.14); - } - - .aceternity-dock-command.is-labeled { - border-color: transparent; - background: transparent; - box-shadow: none; - } - - .aceternity-dock-command.is-labeled:hover:not(:disabled) { - border-color: transparent; - background: var(--dock-command-hover); - box-shadow: none; - } - - .aceternity-dock-command.is-labeled.is-active { - border-color: transparent; - box-shadow: none; } .aceternity-dock-command.is-active { - border-color: color-mix(in srgb, var(--dock-command-active) 28%, transparent); background: var(--dock-command-active); color: var(--dock-command-active-text); - box-shadow: 0 12px 30px color-mix(in srgb, var(--dock-command-active) 22%, transparent); } .aceternity-dock-command.is-active > span:first-child, @@ -5153,19 +5117,6 @@ opacity: 1; } - .aceternity-dock-command.is-quiet { - border-color: transparent; - background: transparent; - box-shadow: none; - } - - .aceternity-dock-command.is-quiet:hover:not(:disabled), - .aceternity-dock-command.is-quiet.is-active { - border-color: transparent; - background: var(--dock-command-hover); - box-shadow: none; - } - .aceternity-dock-command.is-danger { color: var(--dock-command-danger); } @@ -7218,6 +7169,7 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba .creation-chat-controls { display: flex; min-width: 0; + flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 2px; @@ -7267,26 +7219,6 @@ html:not(.dark) .creation-chat-control.is-mode, html:not(.dark) .creation-chat-control:hover, html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; } -.creation-chat-submit { - display: grid; - width: 32px; - height: 32px; - flex: 0 0 auto; - place-items: center; - border: 0; - border-radius: 9px; - background: #f3f4f5; - color: #1b1d21; - cursor: pointer; - transition: transform 140ms var(--motion-ease-out), background-color 140ms ease; -} - -.creation-chat-submit:hover:not(:disabled) { background: #fff; transform: translateY(-1px); } -.creation-chat-submit:disabled { background: #292d34; color: #606771; cursor: not-allowed; } -.creation-chat-submit.is-stopping { background: #d06060; color: #fff; } -html:not(.dark) .creation-chat-submit { background: #202328; color: #fff; } -html:not(.dark) .creation-chat-submit:hover:not(:disabled) { background: #2d3139; } - .creation-control-popover-surface, .creation-model-picker-surface { overflow: hidden; @@ -7396,7 +7328,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f .creation-empty-workspace { width: calc(100% - 28px); padding-bottom: 8vh; } .creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; } .creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; } - .creation-chat-controls { max-width: calc(100% - 38px); } + .creation-chat-controls { max-width: none; } .creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; } .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; } .creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); } @@ -7405,8 +7337,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f @media (prefers-reduced-motion: reduce) { .creation-empty-composer, - .creation-chat-reference, - .creation-chat-submit { animation: none; transition: none; } + .creation-chat-reference { animation: none; transition: none; } } /* 创作入口与空间导航在同一块画布上,避免视觉断层。 */ @@ -7839,44 +7770,15 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage .creation-chat-reference.is-paper:hover:not(:disabled) { background: #2b2e37; color: #f1f3f5; transform: rotate(-4deg) translateY(-2px); } .creation-chat-reference.is-paper:active:not(:disabled) { transform: rotate(-4deg) translateY(0) scale(.97); } .creation-chat-reference.is-paper:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 3px; } -.creation-chat-reference-slot { - position: relative; - width: 58px; - height: 74px; - align-self: start; -} -.creation-chat-reference-preview { - display: block; - width: 100%; - height: 100%; - overflow: hidden; - border: 1px solid var(--creation-border); - border-radius: var(--r-sm); - background: var(--creation-surface); - padding: 0; - cursor: zoom-in; -} -.creation-chat-reference-preview img, -.creation-chat-reference-preview video { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform var(--motion-instant) var(--motion-ease-out); } -.creation-chat-reference-preview > span { position: absolute; bottom: var(--space-1); left: var(--space-1); display: grid; width: var(--space-5); height: var(--space-5); place-items: center; border-radius: 50%; background: rgba(0, 0, 0, .68); color: white; opacity: 0; transition: opacity var(--motion-instant) ease; } -.creation-chat-reference-preview > span svg { width: var(--space-3); height: var(--space-3); } -.creation-chat-reference-preview:hover img, -.creation-chat-reference-preview:hover video { transform: scale(1.035); } -.creation-chat-reference-preview:hover > span, -.creation-chat-reference-preview:focus-visible > span { opacity: 1; } -.creation-chat-reference-preview:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: var(--space-half); } -.creation-chat-reference-remove, -.creation-chat-reference-add { position: absolute; z-index: 1; display: grid; place-items: center; border: 0; border-radius: 50%; color: white; transition: transform var(--motion-instant) var(--motion-ease-out), opacity var(--motion-instant) ease; } -.creation-chat-reference-remove { top: calc(var(--space-1) * -1); right: calc(var(--space-1) * -1); width: var(--space-5); height: var(--space-5); background: rgba(0, 0, 0, .78); opacity: 0; } -.creation-chat-reference-add { right: calc(var(--space-1) * -1); bottom: calc(var(--space-1) * -1); width: var(--space-6); height: var(--space-6); background: var(--btn-solid-bg); color: var(--btn-solid-fg); box-shadow: var(--elevation-card); } -.creation-chat-reference-remove svg, -.creation-chat-reference-add svg { width: var(--space-3); height: var(--space-3); } -.creation-chat-reference-slot:hover .creation-chat-reference-remove, -.creation-chat-reference-remove:focus-visible { opacity: 1; } -.creation-chat-reference-remove:hover, -.creation-chat-reference-add:hover:not(:disabled) { transform: scale(1.06); } -.creation-chat-reference-add:disabled, -.creation-chat-reference-remove:disabled { opacity: .42; } +.creation-chat-reference-media { position: relative; overflow: hidden; padding: 0; } +.creation-chat-reference-media .creation-chat-attachment-preview > span { display: grid; } +.creation-chat-reference-media:hover .creation-chat-attachment-remove, +.creation-chat-reference-media .creation-chat-attachment-remove:focus-visible { opacity: 1; } +.creation-chat-reference-media button svg { width: var(--space-3); height: var(--space-3); } +.creation-chat-reference-add { position: absolute; bottom: var(--space-1); left: var(--space-1); display: grid; width: var(--space-5); height: var(--space-5); place-items: center; border: var(--stroke-1) solid color-mix(in srgb, var(--color-neutral-0) 18%, transparent); border-radius: var(--r-xs); background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent); color: var(--color-neutral-0); transition: background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out); } +.creation-chat-reference-add:hover { background: color-mix(in srgb, var(--color-neutral-950) 90%, transparent); transform: translateY(-1px); } +.creation-chat-reference-add:active { transform: scale(.96); } +.creation-chat-reference-add:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); } .creation-chat-composer .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; } .creation-custom-value { @@ -8503,11 +8405,15 @@ html:not(.dark) .creation-history-drawer-root { .creation-history-search input::placeholder { color: var(--creation-history-muted); } .creation-history-list { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } -.creation-history-list > li { min-width: 0; } -.creation-history-list button { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 10px 13px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; } -.creation-history-list button:hover { background: var(--creation-history-hover); } -.creation-history-list button:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; } -.creation-history-list > li.is-active button { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); } +.creation-history-list > li { position: relative; min-width: 0; } +.creation-history-item-main { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 50px 13px 10px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; } +.creation-history-item-main:hover { background: var(--creation-history-hover); } +.creation-history-item-main:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; } +.creation-history-list > li.is-active .creation-history-item-main { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); } +.creation-history-delete { position: absolute; z-index: 1; top: 50%; right: 9px; display: grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--creation-history-muted); transform: translateY(-50%); transition: background-color 150ms ease, color 150ms ease; } +.creation-history-delete svg { width: 16px; height: 16px; } +.creation-history-delete:hover { background: color-mix(in srgb, var(--destructive) 12%, transparent); color: var(--destructive); } +.creation-history-delete:focus-visible { outline: 2px solid color-mix(in srgb, var(--destructive) 60%, transparent); outline-offset: 1px; } .creation-history-time { display: flex; width: 100%; align-items: center; justify-content: space-between; color: var(--creation-history-muted); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; line-height: 1.3; } .creation-history-time em { border: 1px solid var(--creation-history-line); border-radius: var(--r-xs); padding: 2px 5px; color: var(--creation-history-muted); font-size: var(--fs-micro); font-style: normal; } .creation-history-item-heading { width: 100%; min-width: 0; overflow: hidden; font-size: var(--fs-label); font-weight: 650; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } @@ -10908,3 +10814,322 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou vertical-align: -0.08em; accent-color: var(--primary); } + +/* ===== 创作页 · 连续对话与媒体状态 ===== */ +.creation-home .creation-thread-workbench { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.creation-home .creation-thread-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); +} + +.creation-home .creation-thread-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.creation-home .creation-view-switch { + display: inline-flex; + align-items: center; + gap: var(--space-1); + border-radius: var(--r-md); + background: var(--creation-surface-muted); + padding: var(--space-1); +} + +.creation-home .creation-view-switch button { + display: inline-flex; + min-height: var(--space-8); + align-items: center; + gap: var(--space-1); + border: 0; + border-radius: var(--r-sm); + background: transparent; + color: var(--creation-muted); + padding: 0 var(--space-3); + font-size: var(--fs-caption); + cursor: pointer; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease; +} + +.creation-home .creation-view-switch button[aria-pressed="true"] { + background: var(--creation-surface-selected); + color: var(--creation-text); +} + +.creation-home .creation-view-switch button:focus-visible { + outline: 2px solid var(--creation-accent); + outline-offset: 1px; +} + +.creation-home .creation-view-switch svg { width: var(--space-4); height: var(--space-4); } + +.creation-home .storyboard-workbench-bar-actions .creation-view-switch { margin-right: var(--space-2); } + +.creation-home .creation-thread-stage { + width: min(900px, calc(100% - 56px)); + min-height: 100%; + margin: 0 auto; + padding: var(--space-8) 0 var(--space-6); +} + +.creation-home .creation-thread-composer { + position: relative; + z-index: 20; + bottom: auto; + width: min(900px, calc(100% - 56px)); + margin: 0 auto; + padding: var(--space-2) 0 var(--space-5); + background: var(--creation-bg); +} + +.creation-home .creation-user-message { + display: flex; + width: min(680px, 86%); + max-width: none; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); + margin: 0 0 var(--space-10) auto; + border-radius: 0; + background: transparent; + padding: 0; + color: var(--creation-text); + white-space: normal; +} + +html:not(.dark) .creation-home .creation-user-message { background: transparent; } + +.creation-home .creation-user-message-meta { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--creation-faint); + font-size: var(--fs-label); +} + +.creation-home .creation-user-message-meta time, +.creation-home .creation-message-heading time { color: var(--creation-faint); font-size: var(--fs-micro); } + +.creation-home .creation-user-message-copy { + display: grid; + width: var(--space-5); + height: var(--space-5); + flex: 0 0 var(--space-5); + place-items: center; + border: 0; + border-radius: var(--r-xs); + background: transparent; + color: var(--creation-faint); + padding: 0; + cursor: pointer; + opacity: .66; + transition: background-color var(--motion-state) ease, color var(--motion-state) ease, opacity var(--motion-state) ease; +} + +.creation-home .creation-user-message-copy:hover { + background: var(--creation-surface-hover); + color: var(--creation-text); + opacity: 1; +} + +.creation-home .creation-user-message-copy:focus-visible { outline: 2px solid var(--creation-accent); outline-offset: 1px; } +.creation-home .creation-user-message-copy svg { width: var(--space-3); height: var(--space-3); } + +.creation-home .creation-user-message-copy-wrap { + max-width: 100%; +} + +.creation-home .creation-user-message-copy-wrap p { + margin: 0; + color: var(--creation-text); + font-size: var(--fs-body); + line-height: 1.75; + text-align: right; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.creation-home .creation-assistant-message { + width: min(760px, 100%); + max-width: none; + margin: 0 0 var(--space-10); + background: transparent; +} + +.creation-home .creation-message-heading { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.creation-home .creation-message-heading strong { color: var(--creation-text); font-size: var(--fs-body); font-weight: 620; } +.creation-home .creation-message-progress-copy { color: var(--creation-muted); font-size: var(--fs-body); } + +.creation-home .creation-message-content { + max-width: 720px; + padding: var(--space-3) 0 0 var(--space-8); +} + +.creation-home .creation-media-pending { + position: relative; + display: grid; + width: min(var(--creation-media-pending-max-width), calc(100% - var(--space-8))); + min-height: 0; + isolation: isolate; + place-items: center; + overflow: hidden; + margin: var(--space-3) 0 0 var(--space-8); + border: 0; + border-radius: var(--r-lg); + background: linear-gradient(115deg, color-mix(in srgb, var(--status-loading) 16%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 7%, var(--creation-surface)), color-mix(in srgb, var(--status-loading) 28%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 10%, var(--creation-surface))); + background-size: 280% 280%; + padding: 0; + animation: creation-media-breathe 2.4s ease-in-out infinite; +} + +.creation-home .creation-media-pending::before { + position: absolute; + z-index: -1; + inset: 0; + background: linear-gradient(105deg, transparent 12%, color-mix(in srgb, var(--status-loading) 24%, transparent) 48%, color-mix(in srgb, var(--creation-text) 12%, transparent) 58%, transparent 88%); + content: ""; + opacity: .38; + transform: translateX(-38%); + animation: creation-media-breathe-layer 2.4s ease-in-out infinite; +} + +.creation-home .creation-media-pending-icon { + display: grid; + width: var(--space-12); + height: var(--space-12); + place-items: center; + border-radius: var(--r-md); + background: color-mix(in srgb, var(--creation-surface) 72%, transparent); + color: var(--creation-text); + animation: creation-media-icon-breathe 1.8s ease-in-out infinite; +} + +.creation-home .creation-media-pending-icon svg { width: var(--space-5); height: var(--space-5); } + +.creation-home .creation-media-pending > button { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + min-height: var(--space-8); + border: 0; + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--creation-surface) 78%, transparent); + color: var(--creation-muted); + padding: 0 var(--space-3); + font-size: var(--fs-label); + cursor: pointer; +} + +.creation-home .creation-media-result { max-width: 680px; padding: var(--space-3) 0 0 var(--space-8); } +.creation-home .creation-image-result-grid { display: flex; max-width: 680px; flex-wrap: wrap; gap: var(--space-2); } +.creation-home .creation-image-result { width: min(320px, 100%); aspect-ratio: auto; background: var(--creation-surface-muted); } +.creation-home .creation-image-result img { display: block; width: 100%; height: auto; max-height: 360px; object-fit: contain; } +.creation-home .creation-video-result { width: min(680px, 100%); } +.creation-home .creation-video-result video { display: block; width: 100%; height: 100%; object-fit: contain; } + +.creation-home .creation-media-error { + display: flex; + width: min(680px, calc(100% - var(--space-8))); + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + margin: var(--space-3) 0 0 var(--space-8); + color: var(--status-error); + font-size: var(--fs-caption); +} + +.creation-home .creation-media-error button { + display: inline-flex; + min-height: var(--space-8); + align-items: center; + gap: var(--space-1); + border: 0; + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--status-error) 10%, transparent); + color: inherit; + padding: 0 var(--space-3); + cursor: pointer; +} + +.creation-home .creation-media-error svg { width: var(--space-4); height: var(--space-4); } + +.creation-home .creation-chat-attachment-preview.is-file { + width: min(220px, 100%); + cursor: default; +} + +.creation-home .creation-chat-file-icon { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: var(--space-2); + color: var(--creation-muted); + padding: 0 var(--space-2); +} + +.creation-home .creation-chat-file-icon > svg { width: var(--space-4); height: var(--space-4); flex: 0 0 auto; } +.creation-home .creation-chat-file-icon em { overflow: hidden; font-size: var(--fs-label); font-style: normal; text-overflow: ellipsis; white-space: nowrap; } + +.creation-home .creation-user-message-attachments button.is-file { + display: grid; + place-items: center; + background: var(--creation-surface-muted); + color: var(--creation-muted); + cursor: default; +} + +.creation-home .creation-user-message-attachments button.is-file > svg { width: var(--space-4); height: var(--space-4); } + +@keyframes creation-media-breathe { + 0%, 100% { background-position: 0% 50%; filter: brightness(.9) saturate(.82); } + 50% { background-position: 100% 50%; filter: brightness(1.12) saturate(1.18); } +} + +@keyframes creation-media-breathe-layer { + 0%, 100% { opacity: .24; transform: translateX(-38%) scale(1); } + 50% { opacity: .72; transform: translateX(38%) scale(1.04); } +} + +@keyframes creation-media-icon-breathe { + 0%, 100% { opacity: .58; transform: scale(.94); } + 50% { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .creation-home .creation-media-pending, + .creation-home .creation-media-pending::before, + .creation-home .creation-media-pending-icon { animation: none; } +} + +@media (max-width: 800px) { + .creation-home .creation-thread-toolbar { align-items: flex-end; padding: var(--space-3); } + .creation-home .creation-view-switch button { padding: 0 var(--space-2); } + .creation-home .creation-thread-stage, + .creation-home .creation-thread-composer { width: calc(100% - 28px); } + .creation-home .creation-thread-stage { padding-top: var(--space-5); } + .creation-home .creation-user-message { width: 92%; max-width: none; } + .creation-home .creation-message-content, + .creation-home .creation-media-result { padding-left: 0; } + .creation-home .creation-media-pending { width: min(var(--creation-media-pending-max-width), 100%); margin-left: 0; } + .creation-home .creation-media-error { width: 100%; margin-left: 0; } +} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 14ed7f85f..01be55874 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -30,6 +30,7 @@ export type CanvasMediaPerformanceMode = "auto" | "quality" | "performance"; export type CanvasWorkspaceMode = "simple" | "professional"; export type CanvasToolMode = "move" | "box-select"; export type CanvasFolderStyle = "glass" | "stacked" | "midnight" | "paper" | "cinema" | "compact"; +export type CanvasFolderTheme = "aurora" | "obsidian" | "ember" | "pearl"; export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30"; export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10"; export type StoryboardVideoInputMode = "direct" | "keyframe"; @@ -286,7 +287,9 @@ export type CanvasNodeMetadata = { }; folder?: { style: CanvasFolderStyle; + theme?: CanvasFolderTheme; createdAt: string; + // 自定义主题资源覆盖预置主题;目录内容永远从 childNodes 读取。 themeCover?: string; assetFolderId?: string; projectId?: string; diff --git a/web/test/canvas-folder.test.ts b/web/test/canvas-folder.test.ts index 9c771992e..28ca1c985 100644 --- a/web/test/canvas-folder.test.ts +++ b/web/test/canvas-folder.test.ts @@ -7,6 +7,7 @@ import { findFrameDropTarget, isCanvasFolderNode, } from "@/lib/canvas/canvas-frame"; +import { resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; function folder(id: string, linked = false): CanvasNodeData { @@ -41,6 +42,13 @@ function image(id: string, content = "resource:image"): CanvasNodeData { } describe("canvas folders", () => { + test("文件夹样式与主题皮肤独立解析", () => { + expect(resolveCanvasFolderTheme("ember")).toBe("ember"); + expect(resolveCanvasFolderTheme("unknown")).toBe("aurora"); + expect(resolveCanvasFolderThemeCover("obsidian")).toContain("folder-theme-obsidian.png"); + expect(resolveCanvasFolderThemeCover("pearl", "resource:custom-cover")).toBe("resource:custom-cover"); + }); + test("识别文件夹并允许非容器节点进入本地文件夹", () => { const target = folder("folder"); expect(isCanvasFolderNode(target)).toBe(true); diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts index 84e1557a8..8faa96862 100644 --- a/web/test/canvas-model-policy.test.ts +++ b/web/test/canvas-model-policy.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy"; -import { buildGenerationConfig, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation"; +import { assertCanvasImageReferenceLimit, buildGenerationConfig, canvasImageReferenceLimitError, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel } from "../src/lib/model-selection"; import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store"; @@ -124,3 +124,20 @@ describe("画布连线能力", () => { expect(canvasConnectionError(config, nodes, [], { fromNodeId: "character", toNodeId: "target" })).toBe(""); }); }); + +describe("图片参考图上限", () => { + test("超出当前模型上限时保留全部输入并返回明确错误", () => { + const config = policyConfig(); + const modelCost = config.channels[0]?.modelCosts?.[0]; + if (!modelCost?.capabilityConfig?.image) throw new Error("缺少图片能力配置"); + modelCost.capabilityConfig.image.references.maxImages = 1; + const references = [ + { id: "image-a", name: "image-a.png", type: "image/png", dataUrl: "data:image/png;base64,YQ==" }, + { id: "image-b", name: "image-b.png", type: "image/png", dataUrl: "data:image/png;base64,Yg==" }, + ]; + + expect(canvasImageReferenceLimitError(config, references)).toContain("最多支持 1 张参考图,当前已连接 2 张"); + expect(() => assertCanvasImageReferenceLimit(config, references)).toThrow("请移除多余连线后重试"); + expect(references).toHaveLength(2); + }); +}); diff --git a/web/test/channel-model-catalog.test.ts b/web/test/channel-model-catalog.test.ts new file mode 100644 index 000000000..9c6a833ca --- /dev/null +++ b/web/test/channel-model-catalog.test.ts @@ -0,0 +1,320 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { App } from "antd"; +import axios from "axios"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CanvasVideoSettingsPopover } from "../src/components/canvas/canvas-video-settings-popover"; +import { mergeFetchedChannelModelCosts, type ChannelModelCatalogItem } from "../src/lib/channel-model-catalog"; +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { ChannelModelSettings } from "../src/pages/settings/channel-video-pricing"; +import { fetchChannelModels } from "../src/services/api/image"; +import { createVideoGenerationTask } from "../src/services/api/video"; +import { createModelChannel, defaultConfig, modelDisplayName, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "../src/stores/use-config-store"; + +const originalAxiosPost = axios.post; + +afterEach(() => { + axios.post = originalAxiosPost; +}); + +const omniCatalog: ChannelModelCatalogItem = { + id: "omni", + displayName: "Omni Flash", + modelType: "video", + supportsImages: false, + minImages: 0, + maxImages: 0, + defaultParameters: { aspectRatio: "16:9", durationSeconds: "10" }, + options: { + aspectRatio: [{ value: "16:9" }, { value: "9:16" }], + durationSeconds: [{ value: "8" }, { value: "10" }], + }, +}; + +function configForCatalog(catalog: ChannelModelCatalogItem[], input: Partial = {}) { + const channel = createModelChannel({ + id: "flow", + name: "Flow2API", + baseUrl: "https://flow.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + models: catalog.map((item) => item.id), + }); + const configured = { ...channel, modelCosts: mergeFetchedChannelModelCosts(channel, catalog) }; + const normalized = normalizeConfigSnapshot({ + config: { + ...defaultConfig, + ...input, + channels: [configured], + model: `flow::${catalog[0]!.id}`, + videoModel: `flow::${catalog[0]!.id}`, + }, + }).config; + return input.vquality ? { ...normalized, vquality: input.vquality } : normalized; +} + +function formEntries(body: unknown) { + expect(body).toBeInstanceOf(FormData); + return Object.fromEntries(Array.from((body as FormData).entries()).filter((entry): entry is [string, string] => typeof entry[1] === "string")); +} + +describe("public channel model catalog", () => { + test("preserves six public capabilities without expanding compatibility IDs", async () => { + axios.post = (async () => ({ + data: { + code: 0, + data: { + models: [ + { id: "image-fast", displayName: "Image Fast", modelType: "image" }, + { id: "image-quality", displayName: "Image Quality", modelType: "image" }, + { ...omniCatalog, compatibilityMap: [{ modelId: "omni_portrait_10s" }] }, + { id: "veo-lite", displayName: "Veo Lite", modelType: "video" }, + { id: "veo-fast", displayName: "Veo Fast", modelType: "video" }, + { id: "veo-quality", displayName: "Veo Quality", modelType: "video" }, + ], + }, + }, + })) as typeof axios.post; + const channel = createModelChannel({ baseUrl: "https://flow.example", apiKey: "synthetic-test-key", models: [] }); + + const result = await fetchChannelModels(channel, true); + + expect(result.models).toHaveLength(6); + expect(result.models).toContain("omni"); + expect(result.models).not.toContain("omni_portrait_10s"); + expect(result.catalog.find((item) => item.id === "omni")).toEqual(omniCatalog); + expect(result.catalog.find((item) => item.id === "omni")).not.toHaveProperty("compatibilityMap"); + }); + + test("maps Omni metadata to friendly video/NewAPI configuration with 8/10 seconds and both ratios", () => { + const config = configForCatalog([omniCatalog]); + const model = "flow::omni"; + const cost = config.channels[0]!.modelCosts![0]!; + + expect(modelDisplayName(config, model)).toBe("Omni Flash"); + expect(selectableModelsByCapability(config, "video")).toEqual([model]); + expect(selectableModelsByCapability(config, "text")).toEqual([]); + expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi"); + expect(cost.capabilityConfig?.video).toMatchObject({ + duration: { selection: "enum", values: [8, 10], default: 10 }, + ratios: ["16:9", "9:16"], + defaultRatio: "16:9", + resolutions: [], + defaultResolution: "", + }); + }); + + test("treats endpoint-only video metadata as authoritative without inventing 720P", async () => { + const catalog: ChannelModelCatalogItem = { + id: "endpoint-video", + supportedEndpointTypes: ["openai-video"], + }; + const config = configForCatalog([catalog], { videoSeconds: "6", size: "16:9", vquality: "720" }); + const model = "flow::endpoint-video"; + const cost = config.channels[0]!.modelCosts![0]!; + + expect(cost.capability).toBe("video"); + expect(cost.protocol).toBe("newapi-channel-2"); + expect(cost.capabilityConfig?.video?.resolutions).toEqual([]); + expect(cost.capabilityConfig?.video?.defaultResolution).toBe(""); + expect(selectableModelsByCapability(config, "video")).toEqual([model]); + expect(resolveModelRequestConfig(config, model).interfaceType).toBe("newapi-channel-2"); + + const canvasHtml = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined })); + expect(canvasHtml).not.toContain("720P"); + + const createSource = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(); + expect(createSource).toContain('const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;'); + expect(createSource).toContain("...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])"); + + let requestBody: Record = {}; + axios.post = (async (_url: string, body: Record) => { + requestBody = body; + return { data: { id: "endpoint-video-task" } }; + }) as typeof axios.post; + await createVideoGenerationTask(config, "synthetic prompt"); + expect(requestBody).not.toHaveProperty("resolution"); + }); + + test("preserves a manually configured capability profile when a catalog only returns an ID", () => { + const channel = createModelChannel({ + id: "manual", + name: "Manual", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + models: ["manual-video"], + }); + const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + channel.modelCosts = [{ model: "manual-video", displayName: "Manual Video", capability: "video", protocol: "newapi", billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig }]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video" }]); + + expect(costs[0]?.capabilityConfig).toEqual(capabilityConfig); + expect(costs[0]?.displayName).toBe("Manual Video"); + }); + + test("preserves an ID-only manual model cost when the per-model protocol is missing", () => { + const channel = createModelChannel({ + id: "manual-text-channel", + name: "Manual Text Channel", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + interfaceType: "chat-completion", + models: ["manual-video"], + }); + const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + const existing = { + model: "manual-video", + displayName: "Manual Video", + capability: "video" as const, + billingMode: "per_second" as const, + unitPriceMicrocredits: 4321, + inputTokenPriceMicrocredits: 11, + outputTokenPriceMicrocredits: 22, + cachedTokenPriceMicrocredits: 3, + capabilityConfig, + }; + channel.modelCosts = [existing]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]); + + expect(costs[0]).toEqual({ ...existing, displayName: "Catalog Video" }); + }); + + test("uses modelType only for capability and preserves an existing manual protocol and profile", () => { + const channel = createModelChannel({ + id: "manual-partial", + name: "Manual Partial", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + interfaceType: "chat-completion", + models: ["video-x"], + }); + const capabilityConfig = defaultModelCapabilityConfig("gemini-veo", "video-x"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + channel.modelCosts = [{ model: "video-x", capability: "video", protocol: "gemini-veo", billingMode: "per_second", unitPriceMicrocredits: 7654, capabilityConfig }]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "video-x", modelType: "video" }]); + + expect(costs).toEqual(channel.modelCosts); + }); + + test("requires the declared minimum image count and selects image-to-video", async () => { + const catalog: ChannelModelCatalogItem = { + id: "image-required-video", + modelType: "video", + minImages: 1, + maxImages: 2, + }; + const config = configForCatalog([catalog]); + const profile = config.channels[0]!.modelCosts![0]!.capabilityConfig!.video!; + + expect(profile.references).toMatchObject({ minImages: 1, maxImages: 2 }); + expect(profile.operations).toEqual(["image_to_video"]); + expect(profile.defaultOperation).toBe("image_to_video"); + expect(createVideoGenerationTask(config, "synthetic prompt")).rejects.toThrow("至少需要 1 张参考图"); + }); + + test("preserves an ID-only manual video model cost when both protocol sources are missing", () => { + const channel = createModelChannel({ + id: "manual-no-protocol", + name: "Manual No Protocol", + baseUrl: "https://manual.example", + apiKey: "synthetic-test-key", + apiFormat: "openai", + models: ["manual-video"], + }); + const capabilityConfig = defaultModelCapabilityConfig("newapi", "manual-video"); + capabilityConfig.video!.resolutions = ["1440p"]; + capabilityConfig.video!.defaultResolution = "1440p"; + const existing = { + model: "manual-video", + displayName: "Manual Video", + capability: "video" as const, + billingMode: "per_second" as const, + unitPriceMicrocredits: 8765, + inputTokenPriceMicrocredits: 31, + outputTokenPriceMicrocredits: 47, + cachedTokenPriceMicrocredits: 5, + capabilityConfig, + }; + channel.modelCosts = [existing]; + + const costs = mergeFetchedChannelModelCosts(channel, [{ id: "manual-video", displayName: "Catalog Video" }]); + + expect(costs).toEqual([{ ...existing, displayName: "Catalog Video" }]); + }); + + test("does not show a synthetic 720P label in the Canvas summary when the model declares no resolutions", () => { + const config = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" }); + + const html = renderToStaticMarkup(React.createElement(CanvasVideoSettingsPopover, { config, onConfigChange: () => undefined })); + + expect(html).toContain("横屏 · 10s"); + expect(html).not.toContain("720P"); + }); + + test("shows the public display name in channel model settings instead of only the internal ID", () => { + const config = configForCatalog([omniCatalog]); + + const html = renderToStaticMarkup(React.createElement(App, null, React.createElement(ChannelModelSettings, { channel: config.channels[0]!, onChange: () => undefined }))); + + expect(html).toContain("Omni Flash"); + }); + + test("omits resolution_name for Omni and for auto instead of inventing 720p", async () => { + const bodies: Record[] = []; + axios.post = (async (_url: string, body: unknown) => { + bodies.push(formEntries(body)); + return { data: { id: `synthetic-${bodies.length}` } }; + }) as typeof axios.post; + + const omniConfig = configForCatalog([omniCatalog], { videoSeconds: "10", size: "16:9", vquality: "720" }); + await createVideoGenerationTask(omniConfig, "synthetic prompt"); + + const declaredResolution: ChannelModelCatalogItem = { + ...omniCatalog, + id: "veo-public", + displayName: "Veo Public", + defaultParameters: { ...omniCatalog.defaultParameters, durationSeconds: "8", resolution: "1080p" }, + options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "720p" }, { value: "1080p" }] }, + }; + const autoConfig = configForCatalog([declaredResolution], { videoSeconds: "8", size: "16:9", vquality: "auto" }); + await createVideoGenerationTask(autoConfig, "synthetic prompt"); + + expect(bodies[0]).not.toHaveProperty("resolution_name"); + expect(bodies[1]).not.toHaveProperty("resolution_name"); + }); + + test("sends a declared compatible HD resolution for a video capability", async () => { + let body: Record = {}; + axios.post = (async (_url: string, requestBody: unknown) => { + body = formEntries(requestBody); + return { data: { id: "synthetic-hd" } }; + }) as typeof axios.post; + const catalog: ChannelModelCatalogItem = { + ...omniCatalog, + id: "veo-public", + displayName: "Veo Public", + defaultParameters: { aspectRatio: "16:9", durationSeconds: "8", resolution: "1080p" }, + options: { ...omniCatalog.options, durationSeconds: [{ value: "8" }], resolution: [{ value: "1080p" }] }, + }; + const config = configForCatalog([catalog], { videoSeconds: "8", size: "16:9", vquality: "1080" }); + + await createVideoGenerationTask(config, "synthetic prompt"); + + expect(body.resolution_name).toBe("1080p"); + expect(body.model).toBe("veo-public"); + expect(body.seconds).toBe("8"); + expect(body.size).toBe("1280x720"); + }); +}); diff --git a/web/test/create-canvas-handoff.test.ts b/web/test/create-canvas-handoff.test.ts index 9d20ab33c..4529c6a57 100644 --- a/web/test/create-canvas-handoff.test.ts +++ b/web/test/create-canvas-handoff.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createGenerationTaskSubscriptionService, type GenerationTask } from "../src/services/api/task-center"; -import { updateCreationConversationSnapshot } from "../src/services/creation-conversation-store"; +import { removeCreationConversationSnapshot, updateCreationConversationSnapshot } from "../src/services/creation-conversation-store"; test("Create exposes one accessible copy action beside each displayed user prompt", async () => { const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(); @@ -35,6 +35,17 @@ test("Create keeps optimistic messages when the first local task binds immediate expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["local:dreamina-cli:task-0001"] }); }); +test("Create history deletion removes only the selected conversation snapshot", () => { + const assets = [{ id: "asset-0001", storageKey: "resource:asset-0001" }]; + const conversations = [ + { id: "conversation-0001", messages: [{ id: "message-0001", resultUrls: ["resource:asset-0001"] }] }, + { id: "conversation-0002", messages: [{ id: "message-0002", attachments: [{ storageKey: "resource:asset-0001" }] }] }, + ]; + + expect(removeCreationConversationSnapshot(conversations, "conversation-0001")).toEqual([conversations[1]]); + expect(assets).toEqual([{ id: "asset-0001", storageKey: "resource:asset-0001" }]); +}); + test("Create refresh subscriptions share one durable scheduler observation without page polling", async () => { let queryCalls = 0; let waitCalls = 0; diff --git a/web/test/create-library-button.test.ts b/web/test/create-library-button.test.ts index 90baddac2..347a98346 100644 --- a/web/test/create-library-button.test.ts +++ b/web/test/create-library-button.test.ts @@ -14,9 +14,16 @@ describe("creation library button", () => { expect(dockStart).toBeGreaterThanOrEqual(0); expect(dockEnd).toBeGreaterThan(dockStart); - expect(compactSource(source.slice(dockStart, dockEnd))).toMatch( - / ]*>\s*]*className="creation-chat-control")(?=[^>]*onClick=\{props\.onOpenLibrary\})(?=[^>]*disabled=\{props\.busy \|\| !referencesSupported\})(?=[^>]*aria-label="打开素材库选择参考内容")[^>]*>\s*\s*素材库<\/span>\s*<\/button>\s*<\/Tooltip>/, - ); + const dockSource = compactSource(source.slice(dockStart, dockEnd)); + const modePickerIndex = dockSource.indexOf(" { @@ -39,10 +46,20 @@ describe("creation library button", () => { const createSource = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); const canvasSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node-prompt-panel.tsx"), "utf8"); - expect(createSource).toContain('className="creation-chat-attachment-preview"'); + expect(createSource).toContain('className="creation-user-message-attachments"'); + expect(createSource).toContain('setPreviewType(kind === "video" ? "video" : "image")'); expect(createSource).toContain(" onInsert(reference)}"); }); + + test("promotes the first reference into the primary reference slot", () => { + const source = readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"); + + expect(source).toContain("const [primaryAttachment, ...secondaryAttachments] = props.attachments"); + expect(source).toContain(" { + test("removes attachments and prompt tokens beyond the current model limit", () => { + const attachments = [imageAttachment("first"), imageAttachment("second"), imageAttachment("third")]; + const references = buildCreationMentionReferences([], attachments); + const result = reconcileCreationAttachmentLimit(attachments, references, 1); + const prompt = references.map(canvasResourceMentionToken).join(" "); + const nextPrompt = removeCreationReferenceTokens(prompt, result.removedReferences); + + expect(result.attachments).toEqual([attachments[0]]); + expect(result.removedReferences.map((reference) => reference.attachmentId)).toEqual(["second", "third"]); + expect(nextPrompt).toContain(canvasResourceMentionToken(references[0])); + expect(nextPrompt).not.toContain(canvasResourceMentionToken(references[1])); + expect(nextPrompt).not.toContain(canvasResourceMentionToken(references[2])); + }); + + test("returns the original attachment list when it is already within the limit", () => { + const attachments = [imageAttachment("first")]; + const result = reconcileCreationAttachmentLimit(attachments, buildCreationMentionReferences([], attachments), 1); + + expect(result.attachments).toBe(attachments); + expect(result.removedReferences).toEqual([]); + }); + + test("文本创作允许媒体和常用文档,图片创作仍只接受图片", () => { + expect(creationFileAccepted("text", { name: "story.pdf", type: "application/pdf" })).toBe(true); + expect(creationFileAccepted("text", { name: "clip.mp4", type: "video/mp4" })).toBe(true); + expect(creationFileAccepted("image", { name: "story.pdf", type: "application/pdf" })).toBe(false); + expect(creationUploadAccept("text")).toContain(".docx"); + }); + + test("文档附件会作为文本资源参与引用", () => { + const attachment: CreationAttachment = { id: "document", name: "script.pdf", type: "application/pdf", url: "https://example.com/script.pdf", storageKey: "resource:document", bytes: 1024, previewUrl: "" }; + const [reference] = buildCreationMentionReferences([], [attachment]); + + expect(creationAttachmentKind(attachment)).toBe("file"); + expect(reference.kind).toBe("text"); + expect(reference.label).toBe("文件1"); + }); + + test("媒体占位按本次选择的画幅展示并为异常值提供模式回退", () => { + expect(creationMediaAspectRatio("16:9", "video")).toBe("16 / 9"); + expect(creationMediaAspectRatio("1:1", "image")).toBe("1 / 1"); + expect(creationMediaAspectRatio("1920x1080", "image")).toBe("1920 / 1080"); + expect(creationMediaAspectRatio("auto", "video")).toBe("16 / 9"); + expect(creationMediaAspectRatio("auto", "image")).toBe("1 / 1"); + }); +}); diff --git a/web/test/text-reasoning-stream.test.ts b/web/test/text-reasoning-stream.test.ts new file mode 100644 index 000000000..20111434c --- /dev/null +++ b/web/test/text-reasoning-stream.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { GenerationToolCard } from "../src/components/ai/generation-tool-card"; +import type { ModelProtocol } from "../src/lib/model-protocols"; +import { requestImageQuestion } from "../src/services/api/image"; +import { createModelChannel, defaultConfig, type AiConfig } from "../src/stores/use-config-store"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function reasoningConfig(protocol: ModelProtocol | undefined, apiFormat: "openai" | "gemini" = "openai"): AiConfig { + const channel = createModelChannel({ + id: "reasoning", + name: "Reasoning", + baseUrl: "https://reasoning.example/v1", + apiKey: "synthetic-test-key", + apiFormat, + interfaceType: apiFormat === "gemini" ? undefined : protocol, + models: ["reasoner"], + modelCosts: [{ model: "reasoner", capability: "text", ...(protocol ? { protocol } : {}), billingMode: "fixed_request", unitPriceMicrocredits: 0 }], + }); + return { ...defaultConfig, channels: [channel], model: "reasoning::reasoner", textModel: "reasoning::reasoner" }; +} + +function streamResponse(events: unknown[]) { + const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`; + return new Response(body, { headers: { "Content-Type": "text/event-stream" } }); +} + +async function requestWithReasoning(config: AiConfig, events: unknown[]) { + globalThis.fetch = (async () => streamResponse(events)) as typeof fetch; + let content = ""; + let reasoning = ""; + const result = await requestImageQuestion(config, [{ role: "user", content: "请回答" }], (value) => { content = value; }, { onReasoning: (value) => { reasoning = value; } }); + return { result, content, reasoning }; +} + +describe("text reasoning streams", () => { + test("separates Responses reasoning summaries from answer text", async () => { + const output = await requestWithReasoning(reasoningConfig("openai-response"), [ + { type: "response.reasoning_summary_text.delta", delta: "先分析" }, + { type: "response.output_text.delta", delta: "最终答案" }, + ]); + + expect(output).toEqual({ result: "最终答案", content: "最终答案", reasoning: "先分析" }); + }); + + test("separates Chat Completions reasoning_content from answer text", async () => { + const output = await requestWithReasoning(reasoningConfig("chat-completion"), [ + { choices: [{ delta: { reasoning_content: "推理过程" } }] }, + { choices: [{ delta: { content: "回答内容" } }] }, + ]); + + expect(output).toEqual({ result: "回答内容", content: "回答内容", reasoning: "推理过程" }); + }); + + test("does not duplicate Gemini thought parts into visible content", async () => { + const output = await requestWithReasoning(reasoningConfig(undefined, "gemini"), [ + { candidates: [{ content: { parts: [{ text: "内部思考", thought: true }, { text: "可见回答" }] } }] }, + ]); + + expect(output).toEqual({ result: "可见回答", content: "可见回答", reasoning: "内部思考" }); + }); + + test("keeps single results open and collapses completed bulk results on restore", () => { + const single = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", heading: "图像生成" }, React.createElement("span", null, "单图结果"))); + const bulk = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", isBulk: true, heading: "图像生成" }, React.createElement("span", null, "批量结果"))); + + expect(single).toContain("单图结果"); + expect(bulk).not.toContain("批量结果"); + }); +}); diff --git a/web/test/video-generation-options.test.ts b/web/test/video-generation-options.test.ts new file mode 100644 index 000000000..c76564d07 --- /dev/null +++ b/web/test/video-generation-options.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; + +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { normalizeVideoResolution, VIDEO_RESOLUTION_CAPABILITY_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "../src/lib/video-generation-options"; + +describe("video generation resolution options", () => { + test("统一档位包含 1440P 与 4K,并识别常见别名", () => { + expect(VIDEO_RESOLUTION_OPTIONS).toEqual([480, 720, 1080, 1440, 2160]); + expect(VIDEO_RESOLUTION_CAPABILITY_OPTIONS).toEqual(["480p", "720p", "1080p", "1440p", "2160p"]); + expect(normalizeVideoResolution("2k")).toBe("1440"); + expect(normalizeVideoResolution("1440p")).toBe("1440"); + expect(normalizeVideoResolution("4K")).toBe("2160"); + }); + + test("保留模型声明的非标准档位而不是静默降级", () => { + expect(normalizeVideoResolution("768p")).toBe("768"); + }); + + test("按协议限制实际可选档位", () => { + expect(defaultModelCapabilityConfig("newapi-channel-2").video?.resolutions).toEqual(["480p", "720p", "1080p", "1440p", "2160p"]); + expect(defaultModelCapabilityConfig("volcengine-ark-video").video?.resolutions).toEqual(["480p", "720p", "1080p"]); + expect(defaultModelCapabilityConfig("volcengine-jimeng-video").video?.resolutions).toEqual(["720p"]); + expect(defaultModelCapabilityConfig("gemini-veo").video?.resolutions).toEqual(["720p", "1080p"]); + }); +});