diff --git a/README.md b/README.md index 7bdf6c4a..f9d1ff85 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [心屿 / Xinyu Island](plugins/yujiachen-y/xinyu-daily/) | A cozy daily-mood island that plays like an indie game: an inspiration card shaped by your answers, mood stickers, a joy jar, a decision slot machine, a Book of Answers, and 20 hidden secrets guarded by a grumpy crab. Optionally, Xiaoyu writes you a daily letter and chats with you using your model. Requires MiniMax Code 3.1.2 or later | [yujiachen-y](https://github.com/yujiachen-y) | | [MoMo Collab](plugins/amszuidas/momo-collab/) | A project steward that turns stray thoughts, screenshots, and links into ideas, writes clear ideas up as tasks, runs each task in its own Agent session with a full handoff, checks the result against your done criteria, and tracks everything on a board. Requires MiniMax Code 3.1.2 or later | [amszuidas](https://github.com/amszuidas) | | [Anatomy Gallery](plugins/zch0227/anatomy-gallery/) | A gallery for interactive isometric figures made with the anatomy skill: hang them in rooms you organize, view light and dark editions full screen, keep every version, and have an Agent create or edit figures from the gallery with a per-run process log. The anatomy skill is bundled. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [Page Designer](plugins/yujiachen-y/page-designer/) | Describe a page and your MiniMax Code Agent writes it as one self-contained HTML file: live sandboxed preview, every version kept, copy or download, your choice of model, six starting styles or your own `design.md`. A port of Vercel Labs' open-source fx0 (Apache-2.0). Requires MiniMax Code 3.1.2 or later | [yujiachen-y](https://github.com/yujiachen-y) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index dd0c744f..d71b016a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -76,6 +76,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [心屿](plugins/yujiachen-y/xinyu-daily/README.zh-CN.md) | 像独立小游戏的每日心情小岛:由你的回答决定的今日灵感卡、心情贴纸、小确幸罐子、纠结老虎机、答案之书,还有坏脾气螃蟹藏起来的 20 个秘密;可选开启小屿陪伴,借用你的模型每天写信、陪你聊天。需要 MiniMax Code 3.1.2 或更高版本 | [yujiachen-y](https://github.com/yujiachen-y) | | [协作 · MoMo](plugins/amszuidas/momo-collab/README.zh-CN.md) | 项目管家 MoMo:把随手的想法、截图和链接攒成想法,写清后变成任务,派 Agent 会话带着完整背景去执行,按验收标准核对结果后交你审核,进展在看板上一眼可见。需要 MiniMax Code 3.1.2 或更高版本 | [amszuidas](https://github.com/amszuidas) | | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [Page Designer](plugins/yujiachen-y/page-designer/README.zh-CN.md) | 描述一个页面,MiniMax Code 的 Agent 把它写成一个独立的 HTML 文件:实时沙箱预览、保留全部版本、可复制或下载,可选模型,内置六种起始风格或使用自己的 `design.md`。移植自 Vercel Labs 的开源项目 fx0(Apache-2.0)。需要 MiniMax Code 3.1.2 或更高版本 | [yujiachen-y](https://github.com/yujiachen-y) |
预览:Token 用量看板 diff --git a/plugins/yujiachen-y/page-designer/.minimax-plugin/plugin.json b/plugins/yujiachen-y/page-designer/.minimax-plugin/plugin.json new file mode 100644 index 00000000..dceb3ec0 --- /dev/null +++ b/plugins/yujiachen-y/page-designer/.minimax-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "name": "page-designer", + "displayName": "Page Designer", + "version": "1.0.0", + "description": "Describe a page and an Agent writes it as one self-contained HTML file, with a live sandboxed preview, every version kept, and your MiniMax Code model. A MiniMax Code port of fx0 by Vercel Labs.", + "author": "yujiachen-y", + "icon": "icon.png", + "category": "Design & Sites", + "exampleQueries": [ + "Open Page Designer", + "Open Page Designer and make a landing page for a neighborhood bakery", + "打开 Page Designer,做一个番茄钟小页面" + ], + "apps": [], + "mcpServers": [ + "servers.mcp.json" + ], + "skills": [] +} diff --git a/plugins/yujiachen-y/page-designer/LICENSE b/plugins/yujiachen-y/page-designer/LICENSE new file mode 100644 index 00000000..39fa4b31 --- /dev/null +++ b/plugins/yujiachen-y/page-designer/LICENSE @@ -0,0 +1,192 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Jiachen Yu (yujiachen-y) + Portions copyright 2026 Vercel, Inc. (fx0, https://github.com/vercel-labs/fx0) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/yujiachen-y/page-designer/NOTICE b/plugins/yujiachen-y/page-designer/NOTICE new file mode 100644 index 00000000..99f2e79e --- /dev/null +++ b/plugins/yujiachen-y/page-designer/NOTICE @@ -0,0 +1,23 @@ +Page Designer +Copyright 2026 Jiachen Yu (yujiachen-y) + +This product is a MiniMax Code port of fx0 by Vercel Labs +(https://github.com/vercel-labs/fx0, commit e584782), which is licensed under +the Apache License, Version 2.0. Copyright 2026 Vercel, Inc. + +The following parts are adapted from fx0, and each adapted file says so at +its top together with the changes made: + + miniapp/node/artifact.mjs from lib/design-artifact.ts (HTML validation, + the preview CSP and sandbox bootstrap) and the + edit_design handler in lib/agent.ts + miniapp/node/prompts.mjs from DESIGN_INSTRUCTIONS in lib/agent.ts + miniapp/node/server.mjs the publish_design, edit_design and read_design + tool descriptions, from lib/tools.ts + +Everything else, including the Mini App runtime, the page, the style presets +and the icon, was written for this package. + +Page Designer is an independent community project. It is not affiliated with, +sponsored by, or endorsed by Vercel. "fx0" and "Vercel" are used here only to +identify the original work. diff --git a/plugins/yujiachen-y/page-designer/README.md b/plugins/yujiachen-y/page-designer/README.md new file mode 100644 index 00000000..0057cd8a --- /dev/null +++ b/plugins/yujiachen-y/page-designer/README.md @@ -0,0 +1,174 @@ +# Page Designer (`page-designer`) + +English | [简体中文](README.zh-CN.md) + +Describe a page and an Agent writes it as one self-contained HTML file. The page shows up in a +live, sandboxed preview next to the chat, every version is kept, and you can copy or download the +HTML. The Agent is your own MiniMax Code Agent, running the model you pick. + +Page Designer is a MiniMax Code port of [fx0](https://github.com/vercel-labs/fx0), the +open-source design agent by Vercel Labs. See [Credits](#credits). + +![Page Designer](docs/preview.jpg) + +## What you can do + +- **Describe a page**: a landing page, a small tool, a game, an explainer, a résumé. The Agent + picks reasonable details and builds it right away, then you ask for changes in plain words. +- **Start from a style**: six built-in starting styles written for this package (quiet minimal, + editorial, soft and friendly, bold poster, night console, pocket arcade), or upload your own + `design.md`. +- **Pick the model**: any model your MiniMax Code can use, with its thinking level. The choice is + kept per page. +- **Every version**: each publish or edit is a new version. Open any of them from the chat or the + version menu, and copy or download its HTML. +- **Preview widths**: desktop, tablet and phone, plus reload. Links in the page open outside it. +- **Approvals and questions**: when the Agent asks to use a tool, the page shows a card with + Allow once and Deny, or you can decide in the conversation. Agent questions are answered in a form + above the composer, or skipped. +- **Pop out**: open the Mini App in its own window when the side panel feels narrow. +- English and Chinese interface, light and dark themes following the system. + +## Install + +Requires MiniMax Code 3.1.2 or later, because the package declares `agentAccess`, +`agentConcurrency` and `surface.window`; earlier versions cannot install it. + +Copy this directory, including the hidden `.minimax-plugin/`, into the MiniMax Code plugins +directory as `page-designer/` (`~/.minimax/plugins/page-designer/` by default; see the root README's +Install section). Restart MiniMax Code, then open "Page Designer" from the Mini App entry in the +right panel or ask the Agent to "Open Page Designer". + +## Tested environment + +- MiniMax Code 3.1.4-preview.4694 on macOS 27.0, an internal preview build of the desktop app + that ships the 3.1.2 Mini App Agent API. Installed through the Host publish flow, opened in the + right panel, and opened by link (`/studio?p=`). +- Real Agent runs with MiniMax-M3.1-Flash picked in the model menu (the turn reported that model): + a new page from a Chinese description with the "Soft & friendly" style (version 1, title set by + the Agent), two follow-up edits through `edit_design` (versions 2 and 3, the preview switched to + each new version by itself), a second page from an English description, and Stop pressed during + a run (the run ended with "Stopped." and the composer worked again). None of these runs raised a + permission request. After the loopback check was added, a rename and one more `edit_design` turn + on the English page (version 3) were repeated in the real Host and still worked. +- In the real page: version chips and the version menu, preview widths, Copy HTML (the clipboard + held the raw HTML of version 3), "Show conversation" (the call resolved), and a click on an + `https://` link inside the preview reaching `window.open`. No console errors or failed requests. +- In Google Chrome 155 (headless) against the same Node entry with a scripted Agent: the 420 px + layout with Chat and Preview tabs, the 900 px layout with the page drawer, light and dark themes, + the permission card, the questionnaire form, the About dialog, Download (file name and raw + content), a page that navigates itself away (restored), and a page that blocks for 1.5 s while + loading (loaded once, no reload loop). +- `npm test`: 18 `node:test` cases that run the Node entry with a scripted Agent, covering the MCP + tools and validation, approvals relaying only the user's decision, questionnaire continuation, + a lost session replaced once, Stop before and during a turn, the loopback and same-origin JSON + guards (a DNS-rebound hostname is refused on every route), and persistence across a restart. +- Not verified: a permission request or questionnaire raised by a real Agent run (covered with the + scripted Agent only), the pop-out window, where MiniMax Code opens a link clicked in the preview, + Download inside MiniMax Code, and Windows. + +## Data & access + +- **State**: everything is stored under the Host-provided `context.dataDir`, one folder per page: + `projects//project.json` (title, chat transcript, run records, chosen model and style, the + linked session id) and `projects//v.html` (each version exactly as published). Deleting a + page in Page Designer removes its folder; the linked Agent conversation stays in MiniMax Code. + The page keeps only the model choice and preview width in `localStorage`. +- **Files read outside `dataDir`**: none. Uploaded `design.md` files are read by the page and sent + to the Node process as text; they are stored inside the page's `project.json`. +- **Files written outside `dataDir`**: none by Page Designer. Download saves the HTML through the + browser's normal download flow. +- **Agent turns** (`agentAccess: ["session.run"]`): each page gets one hidden session created with + `context.agent.create()`, and every message you send is one turn in it, run from Node so it keeps + going when the panel closes. These sessions use your primary MiniMax Code Agent, so its own + settings and memory apply, and they work in this Mini App's own Agent directory. The first turn + carries the design brief (adapted from fx0), the chosen style and your message; later turns carry + a short reminder and your message. The brief asks + the Agent to work only through Page Designer's MCP tools, to stay away from files, the shell and + subagents, and allows web search and web fetch for reference material. Your message is passed as + marked user text; what the Agent sends back is shown as text and only the HTML it passes to the MCP + tools, after validation, becomes a version. If the linked session has disappeared, Page Designer + creates a new one once and sends the brief again. +- **Permissions**: every `permission.requested` in a Page Designer run is shown as a card with + Allow once, Deny and "Decide in conversation". Page Designer never answers a request by itself: + Allow once and Deny relay your click for that one request. "Decide in conversation" opens the + session in the chat side panel, where MiniMax Code's own card decides. +- **MCP**: the package declares an MCP server served by the Mini App itself at + `/mcp/page-designer`, with `publish_design`, `edit_design`, `read_design` and `list_designs`. + Agents can also call them from ordinary chats while the Mini App runs; `publish_design` without a + `project_id` starts a new page, and every result carries `open_path` (`/studio?p=`), the + route that opens that page. +- **Validation**: a version must be one complete HTML document of at most 512 KiB with inline + scripts and styles, embedded `data:` assets, no HTML comments, no forms, no nested frames, no + external `src`, and only `#fragment` or `https://` links (fx0's rules). Rejected HTML is returned + to the Agent with the reason and stored nowhere. +- **Preview sandbox**: versions render in an iframe with `sandbox="allow-scripts"` (opaque origin, + no access to the Mini App, its storage or the `window.mcode` bridge) and fx0's content security + policy, which blocks every network request, form, frame, worker and external asset. WebRTC is + switched off before the page runs. A page that navigates itself away is restored. A clicked + `https://` link is handed to the Mini App page, which opens it with `window.open`; MiniMax Code + decides where it opens. The raw-HTML route used for + copy and download is served with a `sandbox` CSP, so a direct visit never runs the page with the + Mini App's origin. +- **Loopback only**: every route, the MCP endpoint included, answers only when the Host header names + this machine (`127.0.0.1`, `localhost`, `::1`) and a browser Origin, when sent, is this listener, + so a web page whose hostname was re-pointed at 127.0.0.1 cannot reach it. Writes must also be + JSON (415 otherwise) and not marked cross-site by the browser. +- **Network**: the Node process makes no outbound requests. Web search and web fetch, when the + Agent uses them, run inside the Agent's own tools. +- **Processes**: none spawned. + +## Differences from fx0 + +- The Agent is MiniMax Code's, not libfx, and it uses the models configured in MiniMax Code instead + of Vercel AI Gateway. There is no sign-in, billing, free trial or BotID. +- Pages live on this computer in the Mini App's data folder instead of Redis and Neon. A run that is + interrupted by a restart is marked interrupted rather than resumed. +- The Agent writes through MCP tools, so the preview updates when a version is published or + edited, not while the HTML streams in. +- Messages sent while the Agent works are not steered into the running turn; stop it or wait. + Image attachments are not supported. +- The ten brand-inspired `design.md` styles bundled with fx0 are not included. The six built-in + styles are original, and you can still upload any `design.md` you like. + +## Credits + +Page Designer exists because Vercel Labs published [fx0](https://github.com/vercel-labs/fx0) under +the Apache License 2.0. These parts are adapted from fx0 at commit `e584782`, and each adapted file +names its source and the changes at its top: + +- `miniapp/node/artifact.mjs`: the HTML validation rules, the preview content security policy and + sandbox bootstrap (`lib/design-artifact.ts`), and the exact-replacement edits (`lib/agent.ts`). +- `miniapp/node/prompts.mjs`: the design instructions (`DESIGN_INSTRUCTIONS` in `lib/agent.ts`). +- `miniapp/node/server.mjs`: the descriptions of the page tools (`lib/tools.ts`). + +The Mini App runtime, the page, the style presets and the icon were written for this package. +Thank you to the fx0 authors. Page Designer is an independent community project and is not +affiliated with or endorsed by Vercel. See [NOTICE](./NOTICE). + +## Files + +```text +.minimax-plugin/plugin.json Plugin manifest (declares the MCP server) +package.json Mini App declaration +servers.mcp.json MCP server entry; its URL is a placeholder that the Host binds to the + Mini App's /mcp/page-designer route (miniapp.json mcpEndpoints) +miniapp/miniapp.json Payload roots, Node entry, page route, MCP endpoint, agentAccess +miniapp/client/ The page (index.html, app.js, app.css) +miniapp/node/server.mjs Node entry: HTTP routes, SSE, MCP tools +miniapp/node/runs.mjs Agent sessions and turns, approvals, questions +miniapp/node/store.mjs Projects and versions under dataDir +miniapp/node/artifact.mjs HTML validation, preview sandbox, exact edits (adapted from fx0) +miniapp/node/prompts.mjs The design brief (adapted from fx0) +miniapp/node/styles.mjs Built-in starting styles +miniapp/node/mcp.mjs Minimal MCP (JSON-RPC over HTTP) handler +miniapp/*/*.ts Type declarations for the runtime APIs; not loaded at runtime +tests/ node:test suites (`npm test`, Node 22 or later) +docs/preview.jpg README screenshot +LICENSE, NOTICE Apache License 2.0 and attribution +icon.png Plugin icon +``` + +## License + +[Apache License 2.0](./LICENSE), the license of fx0. See [NOTICE](./NOTICE) for attribution. diff --git a/plugins/yujiachen-y/page-designer/README.zh-CN.md b/plugins/yujiachen-y/page-designer/README.zh-CN.md new file mode 100644 index 00000000..7ef19f47 --- /dev/null +++ b/plugins/yujiachen-y/page-designer/README.zh-CN.md @@ -0,0 +1,102 @@ +# Page Designer(`page-designer`) + +[English](README.md) | 简体中文 + +描述你想要的页面,Agent 把它写成一个独立的 HTML 文件。页面显示在对话旁边的实时沙箱预览里,每个版本都会保留,可以复制或下载 HTML。写页面的是你自己的 MiniMax Code Agent,用的是你选择的模型。 + +Page Designer 是 Vercel Labs 开源设计 Agent [fx0](https://github.com/vercel-labs/fx0) 在 MiniMax Code 上的移植版,详见[致谢](#致谢)。 + +![Page Designer](docs/preview.jpg) + +## 能做什么 + +- **描述一个页面**:落地页、小工具、小游戏、交互讲解、简历都可以。Agent 会先自己定好合理的细节、直接做出来,之后你用大白话提修改。 +- **选择起始风格**:内置六种为本插件原创的风格(安静极简、杂志编辑、柔和友好、大胆海报、夜间控制台、口袋游戏),也可以上传自己的 `design.md`。 +- **选择模型**:MiniMax Code 里能用的模型都可以选,也能选思考档位;每个页面会记住自己的选择。 +- **保留每个版本**:每次发布或修改都是一个新版本。可以从对话或版本菜单打开任意版本,复制或下载它的 HTML。 +- **预览宽度**:桌面、平板、手机三档,支持重新载入。页面里的链接在预览之外打开。 +- **权限和提问**:Agent 想使用某个工具时,页面会显示卡片,可以「允许一次」「拒绝」,或者去对话里决定。Agent 的提问在输入框上方的表单里回答,也可以跳过。 +- **独立窗口**:侧边栏太窄时,可以把 Mini App 弹出到单独的窗口。 +- 界面支持中英文,明暗主题跟随系统。 + +## 安装 + +需要 MiniMax Code 3.1.2 或更高版本,因为包里声明了 `agentAccess`、`agentConcurrency` 和 `surface.window`,更早的版本无法安装。 + +把这个目录(包括隐藏的 `.minimax-plugin/`)复制到 MiniMax Code 的插件目录,命名为 `page-designer/`(默认是 `~/.minimax/plugins/page-designer/`,见根目录 README 的安装说明)。重启 MiniMax Code,然后从右侧面板的 Mini App 入口打开「Page Designer」,或者让 Agent「打开 Page Designer」。 + +## 测试环境 + +- macOS 27.0 上的 MiniMax Code 3.1.4-preview.4694。这是桌面端的内部预览版,已包含 3.1.2 的 Mini App Agent API。通过 Host 发布流程安装,在右侧面板打开,也用链接(`/studio?p=`)打开过。 +- 真实 Agent 运行,在模型菜单里选了 MiniMax-M3.1-Flash,运行回报的也是这个模型: + - 用中文描述加「柔和友好」风格新建页面,得到版本 1,标题由 Agent 设定; + - 两次追加修改走 `edit_design`,得到版本 2 和 3,预览自动切换到新版本; + - 用英文描述新建第二个页面; + - 运行中点停止,显示「已停止。」,之后输入框恢复可用。 + + 这几次运行都没有触发权限请求。加上本机请求校验后,又在真实 Host 里重做了一次重命名和一轮 `edit_design`(英文页面的版本 3),都正常。 +- 真实页面里:版本标签和版本菜单、预览宽度、复制 HTML(剪贴板里是版本 3 的原始 HTML)、「查看对话」(调用成功返回)、点击预览里的 `https://` 链接会走到 `window.open`。没有控制台报错或失败请求。 +- 在 Google Chrome 155(无头模式)里,用同一个 Node 入口配脚本化 Agent 测过: + - 420 px 下的「对话 / 预览」Tab 布局、900 px 下的页面抽屉、明暗主题; + - 权限卡片、问卷表单、关于对话框; + - 下载(文件名和原始内容); + - 页面自己跳走后会被恢复;加载时卡住 1.5 秒的页面只加载一次,不会循环重载。 +- `npm test`:18 个 `node:test` 用例,用脚本化 Agent 跑 Node 入口。覆盖 MCP 工具和校验、权限只转达用户的决定、问卷续答、会话丢失时重建一次、运行前和运行中的停止、本机与同源 JSON 校验(DNS 重绑定的域名在所有路由上都被拒绝),以及重启后的数据保留。 +- 未验证:真实 Agent 运行触发的权限请求或问卷(只用脚本化 Agent 覆盖过)、弹出窗口、预览里点击的链接最终在哪里打开、MiniMax Code 内的下载、Windows。 + +## 数据与权限 + +- **状态**:所有数据都存放在 Host 提供的 `context.dataDir` 下,每个页面一个文件夹: + - `projects//project.json`:标题、对话记录、运行记录、所选模型和风格、关联的会话 id; + - `projects//v.html`:每个版本,与发布时完全一致。 + + 在 Page Designer 里删除页面会删掉这个文件夹,关联的 Agent 对话仍保留在 MiniMax Code 里。浏览器 `localStorage` 里只保存模型选择和预览宽度。 +- **读取 `dataDir` 以外的文件**:无。上传的 `design.md` 由页面读取,以文本形式发给 Node 进程,存进该页面的 `project.json`。 +- **写入 `dataDir` 以外的文件**:Page Designer 本身不写。下载走浏览器的正常下载流程。 +- **Agent 运行**(`agentAccess: ["session.run"]`): + - 每个页面用 `context.agent.create()` 创建一个隐藏会话,你发的每条消息是其中一轮。运行由 Node 发起,所以关掉面板也会继续。 + - 这些会话使用你的主 MiniMax Code Agent,它自己的设置和记忆都会生效,工作目录是这个 Mini App 自己的 Agent 目录。 + - 第一轮会带上设计指令(改编自 fx0)、所选风格和你的消息,之后每轮只带一段简短提醒和你的消息。 + - 指令要求 Agent 只通过 Page Designer 的 MCP 工具工作,不碰文件、shell 和子 Agent,允许用网页搜索和抓取找参考。 + - 你的消息会作为标记好的用户文本传入。Agent 的回复只作为文本显示,只有它交给 MCP 工具、并通过校验的 HTML 才会成为版本。 + - 如果关联的会话不见了,Page Designer 会新建一个会话并重新发送一次指令。 +- **权限**:Page Designer 运行中的每个 `permission.requested` 都显示为卡片,提供「允许一次」「拒绝」和「去对话里决定」。Page Designer 不会自行答复任何请求:「允许一次」和「拒绝」只把你对这一个请求的点击转达过去。「去对话里决定」会在聊天侧栏打开这个会话,由 MiniMax Code 自己的卡片决定。 +- **MCP**:包里声明了一个由 Mini App 自己在 `/mcp/page-designer` 提供的 MCP 服务器,包含 `publish_design`、`edit_design`、`read_design` 和 `list_designs`。Mini App 运行期间,普通对话里的 Agent 也能调用: + - 不带 `project_id` 调用 `publish_design` 会新建页面; + - 每次返回都带 `open_path`(`/studio?p=`),也就是打开该页面的路由。 +- **校验**:沿用 fx0 的规则。一个版本必须是不超过 512 KiB 的完整 HTML 文档,满足以下条件: + - 脚本和样式内联,资源用 `data:` 嵌入,没有外部 `src`; + - 没有 HTML 注释、表单、嵌套框架; + - 链接只允许 `#片段` 或 `https://`。 + + 不合规的 HTML 会把原因返回给 Agent,任何地方都不会保存。 +- **预览沙箱**: + - 版本渲染在 `sandbox="allow-scripts"` 的 iframe 里,源是不透明的,无法访问 Mini App、它的存储或 `window.mcode` 桥。 + - 套用 fx0 的内容安全策略,屏蔽所有网络请求、表单、框架、worker 和外部资源。页面运行前会先关闭 WebRTC。 + - 页面自己跳走后会被恢复。点击的 `https://` 链接交给 Mini App 页面用 `window.open` 打开,具体在哪里打开由 MiniMax Code 决定。 + - 复制和下载用的原始 HTML 路由带 `sandbox` CSP,即使直接访问,也不会以 Mini App 的源运行页面。 +- **只接受本机请求**:所有路由(包括 MCP 端点)都要求 Host 头是本机(`127.0.0.1`、`localhost`、`::1`),浏览器带的 Origin 必须是这个监听地址本身。所以即使某个网页把自己的域名解析到 127.0.0.1(DNS 重绑定),也访问不到。写请求还必须是 JSON(否则 415),且不能被浏览器标记为跨站。 +- **网络**:Node 进程不发起任何外部请求。Agent 用到的网页搜索和抓取,在 Agent 自己的工具里运行。 +- **进程**:不启动任何进程。 + +## 与 fx0 的不同 + +- Agent 是 MiniMax Code 的,不是 libfx;模型来自 MiniMax Code 的配置,不走 Vercel AI Gateway。没有登录、计费、免费试用和 BotID。 +- 页面保存在本机 Mini App 的数据目录里,而不是 Redis 和 Neon。被重启打断的运行会标记为中断,不会续跑。 +- Agent 通过 MCP 工具写页面,所以预览在发布或修改完成时更新,不会在 HTML 生成过程中实时滚动出现。 +- Agent 工作时发的消息不会插入当前这一轮,需要先停止或等待它完成。不支持图片附件。 +- fx0 自带的十个品牌风格 `design.md` 没有收录。内置的六种风格是原创的,你仍然可以上传任意 `design.md`。 + +## 致谢 + +Page Designer 的存在,要感谢 Vercel Labs 以 Apache License 2.0 开源了 [fx0](https://github.com/vercel-labs/fx0)。以下部分改编自 fx0 的提交 `e584782`,每个改编的文件开头都写明了来源和改动: + +- `miniapp/node/artifact.mjs`:HTML 校验规则、预览内容安全策略和沙箱引导脚本(`lib/design-artifact.ts`),以及精确替换式修改(`lib/agent.ts`)。 +- `miniapp/node/prompts.mjs`:设计指令(`lib/agent.ts` 中的 `DESIGN_INSTRUCTIONS`)。 +- `miniapp/node/server.mjs`:页面工具的描述(`lib/tools.ts`)。 + +Mini App 运行时、页面、风格预设和图标是为本插件编写的。感谢 fx0 的作者们。Page Designer 是独立的社区项目,与 Vercel 没有隶属或背书关系,详见 [NOTICE](./NOTICE)。 + +## 许可证 + +[Apache License 2.0](./LICENSE),与 fx0 相同。署名见 [NOTICE](./NOTICE)。 diff --git a/plugins/yujiachen-y/page-designer/docs/preview.jpg b/plugins/yujiachen-y/page-designer/docs/preview.jpg new file mode 100644 index 00000000..813e55ce Binary files /dev/null and b/plugins/yujiachen-y/page-designer/docs/preview.jpg differ diff --git a/plugins/yujiachen-y/page-designer/icon.png b/plugins/yujiachen-y/page-designer/icon.png new file mode 100644 index 00000000..2386dbd4 Binary files /dev/null and b/plugins/yujiachen-y/page-designer/icon.png differ diff --git a/plugins/yujiachen-y/page-designer/miniapp/client/app.css b/plugins/yujiachen-y/page-designer/miniapp/client/app.css new file mode 100644 index 00000000..b73cac00 --- /dev/null +++ b/plugins/yujiachen-y/page-designer/miniapp/client/app.css @@ -0,0 +1,1232 @@ +/* Page Designer. Colors follow the MiniMax Code Mini App baseline through --mcode-* variables. */ + +:root, +[data-theme='light'] { + --mcode-bg: #ffffff; + --mcode-bg-secondary: #f5f5f5; + --mcode-surface: #ffffff; + --mcode-surface-muted: #fafafa; + --mcode-text: #171717; + --mcode-text-muted: #666666; + --mcode-text-subtle: #adadad; + --mcode-border: #0a0a0a14; + --mcode-border-strong: #0a0a0af2; + --mcode-accent: #0094fc; + --mcode-success: #04b54b; + --mcode-warning: #f56811; + --mcode-danger: #f73646; + --mcode-primary-action-bg: #171717; + --mcode-primary-action-text: #ffffff; + --pd-hover: #0a0a0a0a; + --pd-user-bubble: #f2f2f2; + --pd-stage: #f5f5f5; + --pd-shadow: 0 0 20px rgb(10 10 10 / 8%); + --pd-brand: #ff7a59; + color-scheme: light; +} + +[data-theme='dark'] { + --mcode-bg: #171717; + --mcode-bg-secondary: #1c1c1c; + --mcode-surface: #262626; + --mcode-surface-muted: #262626; + --mcode-text: #ededed; + --mcode-text-muted: #949494; + --mcode-text-subtle: #666666; + --mcode-border: #ffffff14; + --mcode-border-strong: #fffffff2; + --mcode-accent: #0077d9; + --mcode-success: #009c3d; + --mcode-warning: #e25507; + --mcode-danger: #e31937; + --mcode-primary-action-bg: #ffffff; + --mcode-primary-action-text: #171717; + --pd-hover: #ffffff0f; + --pd-user-bubble: #262626; + --pd-stage: #101010; + --pd-shadow: 0 0 20px rgb(0 0 0 / 40%); + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + background: var(--mcode-bg); + color: var(--mcode-text); + font-family: ui-sans-serif, -apple-system, system-ui, 'Segoe UI', 'PingFang SC', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 20px; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +code, +pre { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; +} + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.muted { + color: var(--mcode-text-muted); +} + +.small { + font-size: 12px; + line-height: 16px; +} + +.spacer { + flex: 1; +} + +.icon { + display: inline-flex; + width: 16px; + height: 16px; + flex: none; +} + +:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +/* App frame ---------------------------------------------------------------------------------- */ + +.app { + display: grid; + grid-template-columns: 232px minmax(0, 1fr); + height: 100%; +} + +.boot { + margin: 24px; + color: var(--mcode-text-muted); +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + padding: 16px 12px 12px; + background: var(--mcode-bg-secondary); + border-right: 1px solid var(--mcode-border); +} + +.brand { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + font-size: 15px; + font-weight: 600; +} + +.logo { + display: inline-flex; + flex: none; +} + +.project-nav { + flex: 1; + min-height: 0; + overflow-y: auto; + margin: 0 -4px; + padding: 0 4px; +} + +.project-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.empty-list { + padding: 8px; +} + +.project { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 8px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.project:hover { + background: var(--pd-hover); +} + +.project.current { + background: var(--mcode-surface); + box-shadow: inset 0 0 0 1px var(--mcode-border); +} + +.project-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.project-meta { + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + line-height: 18px; +} + +.pill.working { + color: var(--mcode-accent); + background: color-mix(in srgb, var(--mcode-accent) 12%, transparent); +} + +.pill.working::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.sidebar-foot { + border-top: 1px solid var(--mcode-border); + padding-top: 8px; +} + +.scrim { + display: none; +} + +.main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.tabs-bar { + display: none; +} + +.panes { + flex: 1; + display: grid; + grid-template-columns: minmax(340px, 400px) minmax(0, 1fr); + min-height: 0; +} + +/* Buttons and inputs ------------------------------------------------------------------------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + cursor: pointer; + white-space: nowrap; +} + +.btn:hover:not(:disabled) { + background: var(--mcode-surface-muted); + border-color: color-mix(in srgb, var(--mcode-border-strong) 25%, transparent); +} + +.btn:disabled { + opacity: 0.45; + cursor: default; +} + +.btn.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} + +.btn.primary:hover:not(:disabled) { + background: var(--mcode-primary-action-bg); + opacity: 0.86; +} + +.btn.danger { + background: var(--mcode-danger); + color: #fff; + border-color: transparent; +} + +.btn.danger:hover:not(:disabled) { + background: var(--mcode-danger); + opacity: 0.88; +} + +.btn.ghost { + background: transparent; + border-color: transparent; +} + +.btn.ghost:hover:not(:disabled) { + background: var(--pd-hover); + border-color: transparent; +} + +.btn.danger-hover:hover:not(:disabled) { + color: var(--mcode-danger); +} + +.btn.small { + height: 26px; + padding: 0 8px; + font-size: 12px; +} + +.btn.block { + width: 100%; + height: 36px; +} + +.btn.icon-only { + width: 32px; + padding: 0; +} + +.btn.send, +.btn.danger.icon-only { + width: 36px; + height: 36px; + border-radius: 10px; +} + +.link, +.link-button, +.md a { + color: var(--mcode-accent); + text-decoration: none; +} + +.link { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.link:hover, +.link-button:hover, +.md a:hover { + text-decoration: underline; +} + +.link-button { + border: 0; + padding: 0; + background: none; + cursor: pointer; + font-size: inherit; +} + +.input, +.select { + height: 32px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + padding: 0 10px; + min-width: 0; +} + +.input:focus, +.select:focus, +.composer:focus-within { + border-color: var(--mcode-border-strong); + outline: none; +} + +.select { + padding-right: 6px; + max-width: 100%; + cursor: pointer; +} + +/* Chat --------------------------------------------------------------------------------------- */ + +.chat { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-right: 1px solid var(--mcode-border); + background: var(--mcode-bg); +} + +.chat-head { + display: flex; + align-items: center; + gap: 4px; + min-height: 52px; + padding: 10px 12px 10px 16px; + border-bottom: 1px solid var(--mcode-border); +} + +.menu-button { + display: none; + margin-left: -6px; +} + +.title-wrap { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.title { + flex: 1; + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + line-height: 24px; + font-weight: 600; +} + +.title .input { + width: 100%; + font-weight: 500; +} + +.title-actions { + display: flex; + gap: 2px; +} + +.banner { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 16px 0; + padding: 10px 12px; + border-radius: 8px; + color: var(--mcode-danger); + background: color-mix(in srgb, var(--mcode-danger) 10%, transparent); +} + +.thread-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 16px 8px; + scroll-behavior: smooth; +} + +.thread, +.live { + display: flex; + flex-direction: column; + gap: 12px; +} + +.live:not(:empty) { + margin-top: 12px; +} + +.msg.user { + align-self: flex-end; + max-width: 88%; +} + +.bubble { + padding: 8px 12px; + border-radius: 12px 12px 4px 12px; + background: var(--pd-user-bubble); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.bubble-label { + display: block; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} + +.msg.assistant { + max-width: 100%; + overflow-wrap: anywhere; +} + +.md p, +.md ul, +.md ol, +.md pre { + margin: 0 0 8px; +} + +.md > :last-child { + margin-bottom: 0; +} + +.md ul, +.md ol { + padding-left: 20px; +} + +.md .md-heading { + font-weight: 600; +} + +.md code { + padding: 1px 4px; + border-radius: 4px; + background: var(--mcode-bg-secondary); +} + +.md pre { + padding: 10px 12px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + overflow-x: auto; +} + +.md pre code { + padding: 0; + background: none; +} + +.streaming .md > :last-child::after { + content: ''; + display: inline-block; + width: 6px; + height: 14px; + margin-left: 2px; + vertical-align: -2px; + background: var(--mcode-text-muted); + animation: blink 1s steps(2) infinite; +} + +.style-note { + margin: 0; + font-size: 12px; +} + +.activity { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} + +.activity.failed { + color: var(--mcode-warning); +} + +.version-chip { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 4px 6px 4px 12px; + border: 1px solid var(--mcode-border); + border-radius: 10px; + font-size: 13px; +} + +.version-chip > span:nth-child(2) { + flex: 1; + font-weight: 500; +} + +.version-chip.shown { + border-color: color-mix(in srgb, var(--mcode-accent) 45%, transparent); + background: color-mix(in srgb, var(--mcode-accent) 6%, transparent); +} + +.version-chip .muted.small { + padding-right: 6px; +} + +.version-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mcode-success); +} + +.notice { + margin: 0; + padding: 8px 12px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + font-size: 13px; +} + +.notice.error { + color: var(--mcode-danger); + background: color-mix(in srgb, var(--mcode-danger) 8%, transparent); +} + +.working-line { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--mcode-text-muted); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, var(--mcode-text-muted) 30%, transparent); + border-top-color: var(--mcode-text-muted); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex: none; +} + +.card { + padding: 14px 16px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + box-shadow: var(--pd-shadow); +} + +.card h3 { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; +} + +.card p { + margin: 0 0 8px; +} + +.card.permission { + border-color: color-mix(in srgb, var(--mcode-warning) 45%, transparent); +} + +.tool-input { + max-height: 140px; + margin: 0 0 8px; + padding: 8px 10px; + overflow: auto; + border-radius: 8px; + background: var(--mcode-bg-secondary); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +/* Questionnaire */ + +.question-slot:not(:empty) { + padding: 0 16px 12px; +} + +.question { + max-height: 50vh; + overflow-y: auto; +} + +.q-step { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 12px; + padding: 0; + border: 0; +} + +.q-step legend { + margin-bottom: 6px; + padding: 0; + font-weight: 500; +} + +.q-header { + display: block; + color: var(--mcode-text-muted); + font-size: 12px; + font-weight: 400; +} + +.q-option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; +} + +.q-option:hover { + background: var(--pd-hover); +} + +.q-option input { + margin: 3px 0 0; +} + +.q-desc { + display: block; +} + +/* Empty state */ + +.empty { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0 16px; +} + +.empty h2 { + margin: 8px 0 0; + font-size: 18px; + line-height: 28px; + font-weight: 600; +} + +.empty h3 { + margin: 16px 0 4px; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; + font-weight: 500; +} + +.empty > p { + margin: 0; +} + +.chips { + display: flex; + flex-direction: column; + gap: 6px; +} + +.chip { + padding: 8px 12px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + text-align: left; + cursor: pointer; +} + +.chip:hover { + background: var(--mcode-surface-muted); + border-color: color-mix(in srgb, var(--mcode-border-strong) 25%, transparent); +} + +.style-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; +} + +.style-card { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + text-align: left; + cursor: pointer; +} + +.style-card:hover { + border-color: color-mix(in srgb, var(--mcode-border-strong) 25%, transparent); +} + +.style-card.selected { + border-color: var(--mcode-accent); + box-shadow: inset 0 0 0 1px var(--mcode-accent); +} + +.swatches { + display: flex; + gap: 4px; + height: 18px; + align-items: center; + margin-bottom: 2px; +} + +.swatch { + width: 18px; + height: 18px; + border-radius: 50%; + box-shadow: inset 0 0 0 1px var(--mcode-border); +} + +.swatch.none { + background: repeating-linear-gradient(135deg, transparent 0 3px, var(--mcode-border) 3px 5px); +} + +.style-name { + font-weight: 500; +} + +.style-hint { + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} + +.credit { + margin-top: 16px !important; +} + +/* Composer */ + +.composer { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 16px 16px; + padding: 10px 10px 10px 12px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); +} + +.composer-input { + width: 100%; + min-height: 44px; + max-height: 220px; + padding: 0; + border: 0; + outline: none; + background: transparent; + resize: none; + line-height: 20px; +} + +.composer-input::placeholder { + color: var(--mcode-text-subtle); +} + +.composer-row { + display: flex; + align-items: center; + gap: 8px; +} + +.model-controls { + display: flex; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.model-controls .select { + height: 28px; + max-width: 180px; + font-size: 12px; + background: var(--mcode-surface-muted); +} + +/* Preview ------------------------------------------------------------------------------------ */ + +.preview { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--pd-stage); +} + +.preview-head { + display: flex; + align-items: center; + gap: 8px; + min-height: 52px; + padding: 10px 12px; + border-bottom: 1px solid var(--mcode-border); + background: var(--mcode-bg); +} + +.preview-head .select { + height: 30px; + font-size: 13px; +} + +.preview-actions { + display: flex; + gap: 2px; +} + +.segmented { + display: inline-flex; + padding: 2px; + border-radius: 8px; + background: var(--mcode-bg-secondary); +} + +.seg { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 26px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--mcode-text-muted); + cursor: pointer; +} + +.seg[aria-pressed='true'] { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 1px 2px rgb(10 10 10 / 12%); +} + +.preview-body { + position: relative; + flex: 1; + min-height: 0; + display: flex; +} + +.stage { + flex: 1; + display: flex; + justify-content: center; + min-width: 0; + min-height: 0; + overflow: auto; +} + +.device-frame { + position: relative; + width: 100%; + height: 100%; + background: #fff; +} + +.preview[data-device='tablet'] .stage, +.preview[data-device='phone'] .stage { + padding: 16px; +} + +.preview[data-device='tablet'] .device-frame { + width: min(820px, 100%); + border-radius: 12px; + box-shadow: var(--pd-shadow); + overflow: hidden; +} + +.preview[data-device='phone'] .device-frame { + width: min(390px, 100%); + border-radius: 16px; + box-shadow: var(--pd-shadow); + overflow: hidden; +} + +.preview-frame { + display: block; + width: 100%; + height: 100%; + border: 0; + background: #fff; +} + +.preview-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: var(--pd-stage); + color: var(--mcode-text-muted); +} + +.preview-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 24px; + text-align: center; +} + +.preview-empty .logo svg { + width: 40px; + height: 40px; + opacity: 0.9; +} + +/* Dialogs and toast ------------------------------------------------------------------------- */ + +.dialog { + width: min(460px, calc(100vw - 32px)); + padding: 20px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--pd-shadow); +} + +.dialog::backdrop { + background: rgb(10 10 10 / 35%); +} + +.dialog h2 { + margin: 0 0 8px; + font-size: 16px; + line-height: 24px; +} + +.dialog p { + margin: 0 0 10px; + line-height: 21px; +} + +.about-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; +} + +.about-head h2 { + margin: 0; +} + +.about-links { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 0 0 4px; + padding: 0; + list-style: none; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + z-index: 30; + max-width: calc(100vw - 32px); + padding: 8px 14px; + border-radius: 8px; + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + box-shadow: var(--pd-shadow); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 8px); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +.toast[data-tone='error'] { + background: var(--mcode-danger); + color: #fff; +} + +/* Responsive -------------------------------------------------------------------------------- */ + +@media (max-width: 1079px) { + .app { + grid-template-columns: minmax(0, 1fr); + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 20; + width: 260px; + transform: translateX(-100%); + transition: transform 0.18s ease; + box-shadow: var(--pd-shadow); + } + + .sidebar-open .sidebar { + transform: none; + } + + .sidebar-open .scrim { + display: block; + position: fixed; + inset: 0; + z-index: 15; + background: rgb(10 10 10 / 25%); + } + + .menu-button { + display: inline-flex; + } + + .panes { + grid-template-columns: minmax(320px, 360px) minmax(0, 1fr); + } +} + +@media (max-width: 759px) { + .tabs-bar { + display: block; + padding: 8px 12px 0; + border-bottom: 1px solid var(--mcode-border); + } + + .tabs { + display: flex; + gap: 4px; + } + + .tabs [role='tab'] { + flex: 1; + height: 32px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--mcode-text-muted); + cursor: pointer; + } + + .tabs [role='tab'][aria-selected='true'] { + color: var(--mcode-text); + border-bottom-color: var(--mcode-text); + font-weight: 500; + } + + .panes { + grid-template-columns: minmax(0, 1fr); + } + + .chat { + border-right: 0; + } + + .app[data-tab='chat'] .preview, + .app[data-tab='preview'] .chat { + display: none; + } + + .preview-head { + flex-wrap: wrap; + } + + .thread-scroll { + padding: 12px; + } + + .composer { + margin: 0 12px 12px; + } + + .question-slot:not(:empty) { + padding: 0 12px 12px; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes blink { + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/plugins/yujiachen-y/page-designer/miniapp/client/app.js b/plugins/yujiachen-y/page-designer/miniapp/client/app.js new file mode 100644 index 00000000..17ff82e7 --- /dev/null +++ b/plugins/yujiachen-y/page-designer/miniapp/client/app.js @@ -0,0 +1,1596 @@ +// @ts-check +// Page Designer page. Everything here talks only to this Mini App's own routes, plus the +// optional window.mcode.miniapp page APIs (opening a session in the chat side panel, popping +// the page out into its own window). + +/// + +const FX0_URL = 'https://github.com/vercel-labs/fx0'; +const LICENSE_URL = 'https://www.apache.org/licenses/LICENSE-2.0'; +const SOURCE_URL = 'https://github.com/MiniMax-AI/MiniMax-Code-MiniApps/tree/main/plugins/yujiachen-y/page-designer'; + +// --------------------------------------------------------------------------------------------- +// Strings + +const LANG = /^zh\b/i.test(navigator.language || '') ? 'zh' : 'en'; +document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en'; + +/** @type {Record<'en' | 'zh', Record>} */ +const STRINGS = { + en: { + appName: 'Page Designer', + newPage: 'New page', + pages: 'Pages', + noPages: 'No pages yet.', + versionsCount: '{n} versions', + versionsOne: '1 version', + versionsNone: 'No version yet', + working: 'Working', + about: 'About & credits', + menu: 'Pages', + chatTab: 'Chat', + previewTab: 'Preview', + rename: 'Rename', + renameLabel: 'Page title', + save: 'Save', + cancel: 'Cancel', + delete: 'Delete', + deleteTitle: 'Delete this page?', + deleteBody: '“{title}” and all its versions will be removed from Page Designer. The Agent conversation is kept in MiniMax Code.', + showConversation: 'Show conversation', + popOut: 'Open in window', + emptyTitle: 'What should we make?', + emptyBody: 'Describe a page. Your MiniMax Code Agent writes it as one self-contained HTML file, and it shows up in the preview.', + examplesLabel: 'Try one', + example1: 'A landing page for a neighborhood bakery with a weekly menu', + example2: 'A pomodoro timer with a calm animated background', + example3: 'An interactive explainer of how a bicycle gear works', + example4: 'A one-page résumé for a product designer', + styleLabel: 'Start from a style', + styleNone: 'No style', + styleNoneHint: 'Let the Agent choose.', + styleUpload: 'Your design.md', + styleUploadHint: 'Upload a design.md file.', + styleUploaded: 'Using {name}', + styleTooLarge: 'That file is too large (60,000 characters at most).', + styleUnreadable: 'That file could not be read as text.', + styleOnPage: 'Style: {name}', + credit: 'A MiniMax Code port of fx0 by Vercel Labs.', + creditLink: 'About', + placeholderNew: 'Describe the page you want…', + placeholderFollow: 'Ask for a change…', + send: 'Send', + stop: 'Stop', + model: 'Model', + modelDefault: 'Default ({name})', + effort: 'Thinking', + effortAuto: 'Auto', + modelsUnavailable: 'Models could not be loaded.', + agentUnavailable: 'This version of MiniMax Code does not let Page Designer run the Agent. Update to 3.1.2 or later.', + you: 'You', + agent: 'Agent', + answered: 'Answered', + versionChipPublish: 'Version {n}', + versionChipEdit: 'Version {n} · edited', + view: 'View', + viewing: 'Showing', + toolsUsed: 'Used {tools}', + toolFailed: '{tool} failed', + toolPublish: 'publish', + toolEdit: 'edit', + toolRead: 'read the page', + toolList: 'list pages', + toolSearch: 'web search', + toolFetch: 'fetch a page', + statusThinking: 'Thinking…', + statusTool: 'Using {tool}…', + statusPublish: 'Writing the page…', + statusEdit: 'Editing the page…', + permissionTitle: 'The Agent wants to use {tool}', + allowOnce: 'Allow once', + deny: 'Deny', + decideInChat: 'Decide in conversation', + questionTitle: 'The Agent has a question', + questionOther: 'Other', + questionOtherPlaceholder: 'Type your answer', + questionSubmit: 'Answer', + questionSkip: 'Skip', + questionUnsupported: 'Answer this in the conversation.', + previewEmpty: 'The page will appear here.', + previewLoading: 'Loading preview…', + previewFailed: 'The preview could not be loaded.', + latest: 'Latest (v{n})', + versionOption: 'Version {n}', + desktop: 'Desktop', + tablet: 'Tablet', + phone: 'Phone', + device: 'Preview width', + copy: 'Copy HTML', + copied: 'Copied the HTML.', + copyFailed: 'Could not copy. Try Download instead.', + download: 'Download', + downloaded: 'Downloaded {file}.', + retry: 'Retry', + reload: 'Reload preview', + disconnected: 'Page Designer restarted. Refresh the page to reconnect.', + refresh: 'Refresh', + aboutTitle: 'About Page Designer', + aboutP1: 'Page Designer is a MiniMax Code port of fx0, an open-source design agent by Vercel Labs: you describe a page and an agent writes it as one HTML file.', + aboutP2: 'The design instructions, the HTML safety rules, the exact-replacement edits and the sandboxed preview are adapted from fx0, under the Apache License 2.0. Thank you to the fx0 authors for building it in the open.', + aboutP3: 'What is different here: the pages are written by your own MiniMax Code Agent, with the model you pick, through this Mini App’s tools. Pages, versions and chats stay on this computer.', + aboutP4: 'Page Designer is an independent community project. It is not affiliated with or endorsed by Vercel.', + aboutFx0: 'fx0 on GitHub', + aboutLicense: 'Apache License 2.0', + aboutSource: 'Page Designer source', + close: 'Close', + errBUSY: 'The Agent is busy right now. Try again in a moment.', + errAGENT_UNAVAILABLE: 'The Agent is not available right now. Try again in a moment.', + errAGENT_OUTCOME_UNKNOWN: 'It is not clear whether that request ran. Check the conversation before trying again.', + errSESSION_NOT_FOUND: 'The linked conversation no longer exists.', + errINVALID_ARGUMENTS: 'The selected model cannot be used. Pick another one.', + errNOT_PERMITTED: 'Page Designer is not allowed to run the Agent.', + errREQUEST_NOT_FOUND: 'That request has already been handled.', + errTURN_ERROR: 'The Agent stopped with an error.', + errABORTED: 'Stopped.', + errREQUEST_CANCELLED: 'Stopped.', + errUNKNOWN: 'Something went wrong.', + errNetwork: 'Page Designer could not be reached.', + }, + zh: { + appName: 'Page Designer', + newPage: '新建页面', + pages: '页面', + noPages: '还没有页面。', + versionsCount: '{n} 个版本', + versionsOne: '1 个版本', + versionsNone: '还没有版本', + working: '进行中', + about: '关于与致谢', + menu: '页面列表', + chatTab: '对话', + previewTab: '预览', + rename: '重命名', + renameLabel: '页面标题', + save: '保存', + cancel: '取消', + delete: '删除', + deleteTitle: '删除这个页面?', + deleteBody: '「{title}」和它的所有版本会从 Page Designer 中移除,MiniMax Code 里的 Agent 对话会保留。', + showConversation: '查看对话', + popOut: '在窗口中打开', + emptyTitle: '想做一个什么页面?', + emptyBody: '描述你想要的页面,MiniMax Code 的 Agent 会把它写成一个独立的 HTML 文件,并显示在预览里。', + examplesLabel: '试试这些', + example1: '一家社区面包店的落地页,带每周菜单', + example2: '一个番茄钟,背景是舒缓的动画', + example3: '交互式讲解自行车变速器是怎么工作的', + example4: '一页纸的产品设计师简历', + styleLabel: '选择一个起始风格', + styleNone: '不指定', + styleNoneHint: '让 Agent 自己决定。', + styleUpload: '自己的 design.md', + styleUploadHint: '上传一个 design.md 文件。', + styleUploaded: '使用 {name}', + styleTooLarge: '文件太大了(最多 60,000 个字符)。', + styleUnreadable: '这个文件无法作为文本读取。', + styleOnPage: '风格:{name}', + credit: '移植自 Vercel Labs 的开源项目 fx0。', + creditLink: '了解更多', + placeholderNew: '描述你想要的页面…', + placeholderFollow: '想怎么改…', + send: '发送', + stop: '停止', + model: '模型', + modelDefault: '默认({name})', + effort: '思考', + effortAuto: '自动', + modelsUnavailable: '无法加载模型列表。', + agentUnavailable: '当前版本的 MiniMax Code 不支持 Page Designer 运行 Agent,请升级到 3.1.2 或更高版本。', + you: '你', + agent: 'Agent', + answered: '已回答', + versionChipPublish: '版本 {n}', + versionChipEdit: '版本 {n} · 修改', + view: '查看', + viewing: '预览中', + toolsUsed: '使用了 {tools}', + toolFailed: '{tool} 失败', + toolPublish: '发布', + toolEdit: '修改', + toolRead: '读取页面', + toolList: '列出页面', + toolSearch: '网页搜索', + toolFetch: '抓取网页', + statusThinking: '思考中…', + statusTool: '正在使用 {tool}…', + statusPublish: '正在写页面…', + statusEdit: '正在修改页面…', + permissionTitle: 'Agent 想使用 {tool}', + allowOnce: '允许一次', + deny: '拒绝', + decideInChat: '去对话里决定', + questionTitle: 'Agent 有个问题', + questionOther: '其他', + questionOtherPlaceholder: '输入你的回答', + questionSubmit: '回答', + questionSkip: '跳过', + questionUnsupported: '请在对话里回答这个问题。', + previewEmpty: '页面会显示在这里。', + previewLoading: '正在载入预览…', + previewFailed: '预览载入失败。', + latest: '最新(v{n})', + versionOption: '版本 {n}', + desktop: '桌面', + tablet: '平板', + phone: '手机', + device: '预览宽度', + copy: '复制 HTML', + copied: '已复制 HTML。', + copyFailed: '复制失败,可以改用下载。', + download: '下载', + downloaded: '已下载 {file}。', + retry: '重试', + reload: '重新载入预览', + disconnected: 'Page Designer 已重启,请刷新页面重新连接。', + refresh: '刷新', + aboutTitle: '关于 Page Designer', + aboutP1: 'Page Designer 是 Vercel Labs 开源设计 Agent fx0 在 MiniMax Code 上的移植版:你描述一个页面,Agent 把它写成一个 HTML 文件。', + aboutP2: '设计指令、HTML 安全规则、精确替换式修改和沙箱预览都改编自 fx0,遵循 Apache License 2.0。感谢 fx0 的作者们把它开源出来。', + aboutP3: '不同之处:页面由你自己的 MiniMax Code Agent 用你选择的模型,通过这个 Mini App 的工具写出;页面、版本和对话都保存在这台电脑上。', + aboutP4: 'Page Designer 是独立的社区项目,与 Vercel 没有隶属或背书关系。', + aboutFx0: 'GitHub 上的 fx0', + aboutLicense: 'Apache License 2.0', + aboutSource: 'Page Designer 源码', + close: '关闭', + errBUSY: 'Agent 正忙,请稍后再试。', + errAGENT_UNAVAILABLE: 'Agent 暂时不可用,请稍后再试。', + errAGENT_OUTCOME_UNKNOWN: '无法确认这次请求是否已执行,请先查看对话再决定是否重试。', + errSESSION_NOT_FOUND: '关联的对话已不存在。', + errINVALID_ARGUMENTS: '所选模型无法使用,请换一个。', + errNOT_PERMITTED: 'Page Designer 没有运行 Agent 的权限。', + errREQUEST_NOT_FOUND: '这个请求已经处理过了。', + errTURN_ERROR: 'Agent 出错停止了。', + errABORTED: '已停止。', + errREQUEST_CANCELLED: '已停止。', + errUNKNOWN: '出了点问题。', + errNetwork: '无法连接 Page Designer。', + }, +}; + +/** @param {string} key @param {Record} [vars] */ +function t(key, vars) { + const text = STRINGS[LANG][key] ?? STRINGS.en[key] ?? key; + return vars ? text.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? '')) : text; +} + +/** @param {string} code */ +function errorText(code) { + return STRINGS[LANG][`err${code}`] ?? STRINGS.en[`err${code}`] ?? t('errUNKNOWN'); +} + +// --------------------------------------------------------------------------------------------- +// DOM helpers + +/** + * @param {string} tag + * @param {Record | null} [props] + * @param {...(Node | string | null | undefined | false | (Node | string | null | undefined | false)[])} children + * @returns {HTMLElement} + */ +function h(tag, props, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props ?? {})) { + if (value === undefined || value === null || value === false) continue; + if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value); + else if (key === 'class') node.className = value; + else if (key === 'dataset') Object.assign(node.dataset, value); + else if (key in node && typeof value !== 'string') /** @type {any} */ (node)[key] = value; + else node.setAttribute(key, value === true ? '' : String(value)); + } + for (const child of children.flat()) { + if (child === null || child === undefined || child === false) continue; + node.append(typeof child === 'string' ? document.createTextNode(child) : child); + } + return node; +} + +const ICONS = { + plus: '', + menu: '', + send: '', + stop: '', + copy: '', + download: '', + trash: '', + pencil: '', + desktop: '', + tablet: '', + phone: '', + chat: '', + window: '', + reload: '', + info: '', + check: '', + external: '', + upload: '', +}; + +/** @param {keyof typeof ICONS} name */ +function icon(name) { + const span = document.createElement('span'); + span.className = 'icon'; + span.setAttribute('aria-hidden', 'true'); + span.innerHTML = `${ICONS[name]}`; + return span; +} + +const LOGO_SVG = + ''; + +/** @param {number} ms */ +function formatDate(ms) { + const date = new Date(ms); + const sameDay = new Date().toDateString() === date.toDateString(); + return new Intl.DateTimeFormat(LANG === 'zh' ? 'zh-CN' : 'en', sameDay ? { hour: '2-digit', minute: '2-digit' } : { month: 'short', day: 'numeric' }).format(date); +} + +/** @param {string} url */ +function openExternal(url) { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return; + // With noopener, window.open returns null even when it worked; MiniMax Code decides where it opens. + window.open(parsed.href, '_blank', 'noopener,noreferrer'); + } catch { + // not a URL: nothing to open + } +} + +/** @param {string} href @param {string} label */ +function externalLink(href, label) { + return h('a', { href, target: '_blank', rel: 'noopener noreferrer', class: 'link' }, label, icon('external')); +} + +// --------------------------------------------------------------------------------------------- +// Markdown (a safe subset: paragraphs, lists, headings, code, bold, italics, https links) + +/** @param {string} text */ +function inline(text) { + /** @type {(Node | string)[]} */ + const parts = []; + const pattern = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(\[[^\]\n]+\]\(https:\/\/[^\s)]+\))|(https:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g; + let last = 0; + for (const match of text.matchAll(pattern)) { + const index = match.index ?? 0; + if (index > last) parts.push(text.slice(last, index)); + const token = match[0]; + if (match[1]) parts.push(h('code', null, token.slice(1, -1))); + else if (match[2]) parts.push(h('strong', null, token.slice(2, -2))); + else if (match[3]) parts.push(h('em', null, token.slice(1, -1))); + else if (match[4]) { + const label = token.slice(1, token.indexOf('](')); + const href = token.slice(token.indexOf('](') + 2, -1); + parts.push(h('a', { href, target: '_blank', rel: 'noopener noreferrer' }, label)); + } else parts.push(h('a', { href: token, target: '_blank', rel: 'noopener noreferrer' }, token)); + last = index + token.length; + } + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + +/** @param {string} source */ +function markdown(source) { + const root = h('div', { class: 'md' }); + const chunks = source.replace(/\r\n?/g, '\n').split(/^```[^\n]*\n?/m); + chunks.forEach((chunk, index) => { + if (index % 2 === 1) { + root.append(h('pre', null, h('code', null, chunk.replace(/\n$/, '')))); + return; + } + for (const block of chunk.split(/\n{2,}/)) { + const lines = block.split('\n').filter((line) => line.trim() !== ''); + if (lines.length === 0) continue; + if (lines.every((line) => /^\s*[-*]\s+/.test(line))) { + root.append(h('ul', null, lines.map((line) => h('li', null, inline(line.replace(/^\s*[-*]\s+/, '')))))); + } else if (lines.every((line) => /^\s*\d+[.)]\s+/.test(line))) { + root.append(h('ol', null, lines.map((line) => h('li', null, inline(line.replace(/^\s*\d+[.)]\s+/, '')))))); + } else if (lines.length === 1 && /^#{1,6}\s+/.test(lines[0])) { + root.append(h('p', { class: 'md-heading' }, inline(lines[0].replace(/^#{1,6}\s+/, '')))); + } else { + const paragraph = h('p'); + lines.forEach((line, lineIndex) => { + if (lineIndex > 0) paragraph.append(h('br')); + paragraph.append(...inline(line)); + }); + root.append(paragraph); + } + } + }); + return root; +} + +// --------------------------------------------------------------------------------------------- +// API + +class ApiError extends Error { + /** @param {string} code @param {string} [message] @param {number} [status] */ + constructor(code, message, status) { + super(message || code); + this.code = code; + this.status = status; + } +} + +/** @param {string} method @param {string} path @param {unknown} [body] */ +async function api(method, path, body) { + /** @type {Response} */ + let response; + try { + response = await fetch(path, { + method, + credentials: 'same-origin', + headers: body === undefined && method === 'GET' ? {} : { 'content-type': 'application/json' }, + body: method === 'GET' ? undefined : JSON.stringify(body ?? {}), + }); + } catch { + throw new ApiError('network', t('errNetwork')); + } + if (response.status === 401) { + showDisconnected(); + throw new ApiError('unauthorized', t('disconnected'), 401); + } + const type = response.headers.get('content-type') ?? ''; + const data = type.includes('application/json') ? await response.json().catch(() => ({})) : await response.text(); + if (!response.ok) { + const code = typeof data === 'object' && data?.error ? String(data.error) : 'UNKNOWN'; + const message = typeof data === 'object' && data?.message ? String(data.message) : errorText(code); + throw new ApiError(code, /^[A-Z_]+$/.test(code) ? errorText(code) : message, response.status); + } + return data; +} + +// --------------------------------------------------------------------------------------------- +// State + +const MODEL_KEY = 'page-designer.model'; +const DEVICE_KEY = 'page-designer.device'; + +const state = { + agentAvailable: true, + /** @type {any[]} */ projects: [], + /** @type {any[]} */ styles: [], + /** @type {any} */ models: null, + modelsFailed: false, + /** model choice: '' means the runtime default, otherwise providerId + '\u0000' + modelId */ + modelKey: '', + effort: '', + /** @type {string | null} */ currentId: null, + /** @type {any} */ project: null, + /** @type {any} */ live: null, + /** @type {number | null} */ viewing: null, + device: localStorage.getItem(DEVICE_KEY) || 'desktop', + tab: 'chat', + /** @type {{ preset: string } | { name: string, text: string } | null} */ newStyle: null, + sending: false, + /** the next render of a project opens at its latest message */ + scrollToEnd: false, +}; + +try { + const saved = JSON.parse(localStorage.getItem(MODEL_KEY) || 'null'); + if (saved && typeof saved.modelKey === 'string') { + state.modelKey = saved.modelKey; + state.effort = typeof saved.effort === 'string' ? saved.effort : ''; + } +} catch { + // a broken preference is ignored +} + +/** @param {any} selection */ +function keyOf(selection) { + return selection && selection.providerId && selection.modelId ? `${selection.providerId}\u0000${selection.modelId}` : ''; +} + +function currentModelOption() { + if (!state.models || !state.modelKey) return null; + return state.models.models.find((/** @type {any} */ item) => keyOf(item) === state.modelKey) ?? null; +} + +/** The selection to send with a request, or null to keep the session's model. */ +function modelSelection() { + if (!state.models) return null; + if (!state.modelKey) return state.models.default ?? null; + const option = currentModelOption(); + if (!option) return state.models.default ?? null; + /** @type {Record} */ + const selection = { providerId: option.providerId, modelId: option.modelId }; + if (state.effort && option.thinking?.efforts?.includes(state.effort)) selection.thinking = { effort: state.effort }; + return selection; +} + +/** @param {any} model */ +function modelName(model) { + if (!model) return ''; + const option = state.models?.models?.find((/** @type {any} */ item) => item.providerId === model.providerId && item.modelId === model.modelId); + return option?.displayName ?? model.modelId ?? ''; +} + +// --------------------------------------------------------------------------------------------- +// Layout + +const root = /** @type {HTMLElement} */ (document.getElementById('app')); +const els = /** @type {Record} */ ({}); + +function buildShell() { + root.replaceChildren(); + root.removeAttribute('aria-busy'); + + els.projectList = h('ul', { class: 'project-list' }); + els.sidebar = h( + 'aside', + { class: 'sidebar', id: 'sidebar', 'aria-label': t('pages') }, + h('div', { class: 'brand' }, logo(), h('span', null, t('appName'))), + h('button', { type: 'button', class: 'btn primary block', onclick: () => selectProject(null) }, icon('plus'), t('newPage')), + h('nav', { class: 'project-nav', 'aria-label': t('pages') }, els.projectList), + h('div', { class: 'sidebar-foot' }, h('button', { type: 'button', class: 'btn ghost small', onclick: openAbout }, icon('info'), t('about'))), + ); + els.scrim = h('div', { class: 'scrim', onclick: () => toggleSidebar(false) }); + + els.title = h('h1', { class: 'title' }); + els.titleActions = h('div', { class: 'title-actions' }); + els.tabs = h( + 'div', + { class: 'tabs', role: 'tablist', 'aria-label': t('appName') }, + h('button', { type: 'button', role: 'tab', id: 'tab-chat', 'aria-controls': 'chat-panel', onclick: () => setTab('chat') }, t('chatTab')), + h('button', { type: 'button', role: 'tab', id: 'tab-preview', 'aria-controls': 'preview-panel', onclick: () => setTab('preview') }, t('previewTab')), + ); + els.menuButton = h('button', { type: 'button', class: 'btn icon-only ghost menu-button', 'aria-label': t('menu'), 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => toggleSidebar() }, icon('menu')); + els.banner = h('div', { class: 'banner', role: 'alert', hidden: true }); + els.thread = h('div', { class: 'thread', role: 'log', 'aria-live': 'polite', 'aria-relevant': 'additions' }); + els.live = h('div', { class: 'live' }); + els.question = h('div', { class: 'question-slot' }); + els.composer = buildComposer(); + + els.chat = h( + 'section', + { class: 'chat', id: 'chat-panel', role: 'tabpanel', 'aria-labelledby': 'tab-chat' }, + h('header', { class: 'chat-head' }, els.menuButton, h('div', { class: 'title-wrap' }, els.title, els.titleActions)), + els.banner, + h('div', { class: 'thread-scroll' }, els.thread, els.live), + els.question, + els.composer, + ); + + els.preview = buildPreview(); + els.toast = h('div', { class: 'toast', role: 'status', 'aria-live': 'polite' }); + + root.append( + els.sidebar, + els.scrim, + h('main', { class: 'main' }, h('div', { class: 'tabs-bar' }, els.tabs), h('div', { class: 'panes' }, els.chat, els.preview)), + els.toast, + ); + setTab(state.tab); +} + +function logo() { + const span = h('span', { class: 'logo' }); + span.innerHTML = LOGO_SVG; + return span; +} + +/** @param {boolean} [open] */ +function toggleSidebar(open) { + const next = open ?? !root.classList.contains('sidebar-open'); + root.classList.toggle('sidebar-open', next); + els.menuButton.setAttribute('aria-expanded', String(next)); +} + +/** @param {'chat' | 'preview'} tab */ +function setTab(tab) { + state.tab = tab; + root.dataset.tab = tab; + for (const button of els.tabs.querySelectorAll('[role="tab"]')) { + const selected = button.id === `tab-${tab}`; + button.setAttribute('aria-selected', String(selected)); + button.setAttribute('tabindex', selected ? '0' : '-1'); + } + if (tab === 'preview') preview.ensureShown(); +} + +let toastTimer = 0; +/** @param {string} text @param {'info' | 'error'} [tone] */ +function toast(text, tone = 'info') { + els.toast.textContent = text; + els.toast.dataset.tone = tone; + els.toast.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => els.toast.classList.remove('show'), 3200); +} + +function showDisconnected() { + els.banner.hidden = false; + els.banner.replaceChildren( + h('span', null, t('disconnected')), + h('button', { type: 'button', class: 'btn small', onclick: () => location.reload() }, t('refresh')), + ); +} + +// --------------------------------------------------------------------------------------------- +// Sidebar + +function renderProjects() { + if (state.projects.length === 0) { + els.projectList.replaceChildren(h('li', { class: 'muted empty-list' }, t('noPages'))); + return; + } + els.projectList.replaceChildren( + ...state.projects.map((project) => { + const current = project.id === state.currentId; + const meta = project.running + ? h('span', { class: 'pill working' }, t('working')) + : h('span', null, project.versions === 0 ? t('versionsNone') : project.versions === 1 ? t('versionsOne') : t('versionsCount', { n: project.versions }), ' · ', formatDate(project.updatedAt)); + return h( + 'li', + null, + h( + 'button', + { type: 'button', class: `project${current ? ' current' : ''}`, 'aria-current': current ? 'page' : undefined, onclick: () => selectProject(project.id) }, + h('span', { class: 'project-title' }, project.title), + h('span', { class: 'project-meta' }, meta), + ), + ); + }), + ); +} + +// --------------------------------------------------------------------------------------------- +// Header + +function renderHeader() { + const project = state.project; + els.titleActions.replaceChildren(); + if (!project) { + els.title.textContent = t('newPage'); + return; + } + els.title.textContent = project.title; + els.title.title = project.title; + els.titleActions.append(h('button', { type: 'button', class: 'btn icon-only ghost', 'aria-label': t('rename'), title: t('rename'), onclick: startRename }, icon('pencil'))); + const agentApi = window.mcode?.miniapp?.agent; + if (agentApi?.open && project.sessionId) { + els.titleActions.append( + h('button', { type: 'button', class: 'btn icon-only ghost', 'aria-label': t('showConversation'), title: t('showConversation'), onclick: () => openConversation() }, icon('chat')), + ); + } + els.titleActions.append( + h('button', { type: 'button', class: 'btn icon-only ghost danger-hover', 'aria-label': t('delete'), title: t('delete'), disabled: !!state.live, onclick: confirmDelete }, icon('trash')), + ); +} + +function startRename() { + const project = state.project; + if (!project) return; + const input = /** @type {HTMLInputElement} */ (h('input', { class: 'input', value: project.title, 'aria-label': t('renameLabel'), maxlength: '80' })); + // Enter or Escape removes the input, which also fires blur: only the first one counts, so + // Escape really cancels and Enter saves once. + let finished = false; + const finish = async (/** @type {boolean} */ save) => { + if (finished) return; + finished = true; + const title = input.value.trim(); + if (save && title && title !== project.title) { + try { + await api('PATCH', `/api/projects/${project.id}`, { title }); + } catch (error) { + toast(/** @type {Error} */ (error).message, 'error'); + } + } + renderHeader(); + }; + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.isComposing) void finish(true); + if (event.key === 'Escape') void finish(false); + }); + input.addEventListener('blur', () => void finish(true), { once: true }); + els.title.replaceChildren(input); + els.titleActions.replaceChildren(); + input.focus(); + input.select(); +} + +async function openConversation() { + const sessionId = state.project?.sessionId; + const agentApi = window.mcode?.miniapp?.agent; + if (!sessionId || !agentApi?.open) return; + try { + await agentApi.open(sessionId); + } catch (error) { + toast(errorText(String(/** @type {Error} */ (error)?.message || 'UNKNOWN').toUpperCase()), 'error'); + } +} + +/** @param {string} title @param {string} body @param {string} confirmLabel */ +function confirmDialog(title, body, confirmLabel) { + return new Promise((resolve) => { + const dialog = /** @type {HTMLDialogElement} */ ( + h( + 'dialog', + { class: 'dialog', 'aria-labelledby': 'confirm-title' }, + h('h2', { id: 'confirm-title' }, title), + h('p', null, body), + h( + 'div', + { class: 'dialog-actions' }, + h('button', { type: 'button', class: 'btn', onclick: () => dialog.close('cancel') }, t('cancel')), + h('button', { type: 'button', class: 'btn danger', onclick: () => dialog.close('confirm') }, confirmLabel), + ), + ) + ); + dialog.addEventListener('close', () => { + resolve(dialog.returnValue === 'confirm'); + dialog.remove(); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +async function confirmDelete() { + const project = state.project; + if (!project) return; + if (!(await confirmDialog(t('deleteTitle'), t('deleteBody', { title: project.title }), t('delete')))) return; + try { + await api('DELETE', `/api/projects/${project.id}`); + await refreshState(); + selectProject(null); + } catch (error) { + toast(/** @type {Error} */ (error).message, 'error'); + } +} + +function openAbout() { + const dialog = /** @type {HTMLDialogElement} */ ( + h( + 'dialog', + { class: 'dialog about', 'aria-labelledby': 'about-title' }, + h('div', { class: 'about-head' }, logo(), h('h2', { id: 'about-title' }, t('aboutTitle'))), + h('p', null, t('aboutP1')), + h('p', null, t('aboutP2')), + h('p', null, t('aboutP3')), + h('p', { class: 'muted' }, t('aboutP4')), + h('ul', { class: 'about-links' }, h('li', null, externalLink(FX0_URL, t('aboutFx0'))), h('li', null, externalLink(LICENSE_URL, t('aboutLicense'))), h('li', null, externalLink(SOURCE_URL, t('aboutSource')))), + h('div', { class: 'dialog-actions' }, h('button', { type: 'button', class: 'btn primary', onclick: () => dialog.close() }, t('close'))), + ) + ); + dialog.addEventListener('close', () => dialog.remove()); + document.body.append(dialog); + dialog.showModal(); +} + +// --------------------------------------------------------------------------------------------- +// Thread + +/** @param {string} name */ +function toolLabel(name) { + const n = name.toLowerCase(); + if (n.includes('publish_design')) return t('toolPublish'); + if (n.includes('edit_design')) return t('toolEdit'); + if (n.includes('read_design')) return t('toolRead'); + if (n.includes('list_designs')) return t('toolList'); + if (n.includes('search')) return t('toolSearch'); + if (n.includes('fetch')) return t('toolFetch'); + return name.replace(/^mcp__/, '').replace(/__/g, ' · '); +} + +/** Successful page writes already show as version chips. */ +function quietTool(/** @type {any} */ message) { + return message.ok && /publish_design|edit_design/i.test(message.name); +} + +function renderThread() { + const project = state.project; + const scroller = els.thread.parentElement; + const nearBottom = state.scrollToEnd || !scroller || scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80; + if (!project) { + if (scroller) scroller.scrollTop = 0; + els.thread.replaceChildren(emptyState()); + return; + } + /** @type {HTMLElement[]} */ + const items = []; + if (project.style) items.push(h('p', { class: 'style-note muted' }, t('styleOnPage', { name: project.style.name }))); + /** @type {any[]} */ + let tools = []; + const flushTools = () => { + const shown = tools.filter((item) => !quietTool(item)); + tools = []; + if (shown.length === 0) return; + const failed = shown.filter((item) => !item.ok); + const used = [...new Set(shown.filter((item) => item.ok).map((item) => toolLabel(item.name)))]; + if (used.length) items.push(h('p', { class: 'activity' }, icon('check'), t('toolsUsed', { tools: used.join(LANG === 'zh' ? '、' : ', ') }))); + for (const item of failed) items.push(h('p', { class: 'activity failed' }, icon('info'), t('toolFailed', { tool: toolLabel(item.name) }))); + }; + for (const message of project.messages) { + if (message.kind === 'tool') { + tools.push(message); + continue; + } + flushTools(); + items.push(renderMessage(message)); + } + flushTools(); + els.thread.replaceChildren(...items); + state.scrollToEnd = false; + if (nearBottom && scroller) scroller.scrollTop = scroller.scrollHeight; +} + +/** @param {any} message */ +function renderMessage(message) { + switch (message.kind) { + case 'user': + return h('div', { class: 'msg user' }, h('span', { class: 'sr-only' }, `${t('you')}: `), h('div', { class: 'bubble' }, message.text)); + case 'answer': + return h('div', { class: 'msg user' }, h('div', { class: 'bubble' }, h('span', { class: 'bubble-label' }, t('answered')), message.text || '')); + case 'assistant': + return h('div', { class: 'msg assistant' }, h('span', { class: 'sr-only' }, `${t('agent')}: `), markdown(message.text)); + case 'version': { + const shown = (state.viewing ?? latestVersion()) === message.n; + return h( + 'div', + { class: `version-chip${shown ? ' shown' : ''}` }, + h('span', { class: 'version-dot', 'aria-hidden': 'true' }), + h('span', null, t(message.via === 'edit' ? 'versionChipEdit' : 'versionChipPublish', { n: message.n })), + shown + ? h('span', { class: 'muted small' }, t('viewing')) + : h('button', { type: 'button', class: 'btn ghost small', onclick: () => showVersion(message.n) }, t('view')), + ); + } + case 'notice': { + const detail = message.text ? `${errorText(message.code)} ${message.text}` : errorText(message.code); + return h('p', { class: `notice ${message.tone === 'error' ? 'error' : ''}`, role: message.tone === 'error' ? 'alert' : undefined }, detail); + } + default: + return h('span'); + } +} + +function latestVersion() { + return state.project?.versions?.at(-1)?.n ?? 0; +} + +function emptyState() { + const examples = ['example1', 'example2', 'example3', 'example4'].map((key) => + h('button', { type: 'button', class: 'chip', onclick: () => fillComposer(t(key)) }, t(key)), + ); + const styleCards = [ + styleCard('none', t('styleNone'), t('styleNoneHint'), []), + ...state.styles.map((style) => styleCard(style.id, style.name[LANG] ?? style.name.en, style.description[LANG] ?? style.description.en, style.swatches)), + uploadCard(), + ]; + return h( + 'section', + { class: 'empty', 'aria-labelledby': 'empty-title' }, + h('h2', { id: 'empty-title' }, t('emptyTitle')), + h('p', { class: 'muted' }, t('emptyBody')), + state.agentAvailable ? null : h('p', { class: 'notice error', role: 'alert' }, t('agentUnavailable')), + h('h3', null, t('examplesLabel')), + h('div', { class: 'chips' }, examples), + h('h3', { id: 'style-label' }, t('styleLabel')), + h('div', { class: 'style-grid', role: 'radiogroup', 'aria-labelledby': 'style-label' }, styleCards), + h('p', { class: 'credit muted small' }, t('credit'), ' ', h('button', { type: 'button', class: 'link-button', onclick: openAbout }, t('creditLink'))), + ); +} + +function selectedStyleId() { + if (!state.newStyle) return 'none'; + return 'preset' in state.newStyle ? state.newStyle.preset : 'upload'; +} + +/** @param {string} id @param {string} name @param {string} hint @param {string[]} swatches */ +function styleCard(id, name, hint, swatches) { + const checked = selectedStyleId() === id; + return h( + 'button', + { + type: 'button', + role: 'radio', + 'aria-checked': String(checked), + class: `style-card${checked ? ' selected' : ''}`, + onclick: () => { + state.newStyle = id === 'none' ? null : { preset: id }; + renderThread(); + }, + }, + h('span', { class: 'swatches', 'aria-hidden': 'true' }, swatches.length ? swatches.map((color) => h('span', { class: 'swatch', style: `background:${color}` })) : h('span', { class: 'swatch none' })), + h('span', { class: 'style-name' }, name), + h('span', { class: 'style-hint' }, hint), + ); +} + +function uploadCard() { + const checked = selectedStyleId() === 'upload'; + const custom = state.newStyle && 'text' in state.newStyle ? state.newStyle : null; + const input = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: '.md,.markdown,.txt,text/markdown,text/plain', class: 'sr-only', tabindex: '-1', 'aria-hidden': 'true' })); + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return; + if (file.size > 400_000) return toast(t('styleTooLarge'), 'error'); + try { + const text = await file.text(); + if (text.includes('\u0000')) return toast(t('styleUnreadable'), 'error'); + if (text.length > 60_000) return toast(t('styleTooLarge'), 'error'); + state.newStyle = { name: file.name.slice(0, 80), text }; + renderThread(); + } catch { + toast(t('styleUnreadable'), 'error'); + } + }); + return h( + 'button', + { type: 'button', role: 'radio', 'aria-checked': String(checked), class: `style-card${checked ? ' selected' : ''}`, onclick: () => input.click() }, + h('span', { class: 'swatches', 'aria-hidden': 'true' }, icon('upload')), + h('span', { class: 'style-name' }, t('styleUpload')), + h('span', { class: 'style-hint' }, custom ? t('styleUploaded', { name: custom.name }) : t('styleUploadHint')), + input, + ); +} + +// --------------------------------------------------------------------------------------------- +// Live run + +function renderLive() { + const live = state.live; + els.live.replaceChildren(); + if (!live) return; + const scroller = els.live.parentElement; + const nearBottom = !scroller || scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 120; + if (live.text) els.live.append(h('div', { class: 'msg assistant streaming' }, markdown(live.text))); + let status = t('statusThinking'); + if (live.tool) { + if (/publish_design/i.test(live.tool)) status = t('statusPublish'); + else if (/edit_design/i.test(live.tool)) status = t('statusEdit'); + else status = t('statusTool', { tool: toolLabel(live.tool) }); + } + if (live.permissions.length === 0) { + els.live.append( + h('p', { class: 'working-line', role: 'status' }, h('span', { class: 'spinner', 'aria-hidden': 'true' }), status, live.model ? h('span', { class: 'muted small' }, ` · ${modelName(live.model)}`) : null), + ); + } + for (const permission of live.permissions) els.live.append(permissionCard(permission)); + if (nearBottom && scroller) scroller.scrollTop = scroller.scrollHeight; +} + +/** @param {any} permission */ +function permissionCard(permission) { + const agentApi = window.mcode?.miniapp?.agent; + const decide = async (/** @type {'allowOnce' | 'deny'} */ decision, /** @type {HTMLElement} */ card) => { + for (const button of card.querySelectorAll('button')) /** @type {HTMLButtonElement} */ (button).disabled = true; + try { + await api('POST', `/api/projects/${state.currentId}/permissions`, { requestId: permission.requestId, decision }); + } catch (error) { + toast(/** @type {Error} */ (error).message, 'error'); + for (const button of card.querySelectorAll('button')) /** @type {HTMLButtonElement} */ (button).disabled = false; + } + }; + const card = h( + 'section', + { class: 'card permission', 'aria-label': t('permissionTitle', { tool: permission.toolName }) }, + h('h3', null, t('permissionTitle', { tool: toolLabel(permission.toolName) })), + permission.reason ? h('p', null, permission.reason) : null, + permission.toolInput ? h('pre', { class: 'tool-input' }, permission.toolInput) : null, + ); + card.append( + h( + 'div', + { class: 'card-actions' }, + agentApi?.open && state.project?.sessionId ? h('button', { type: 'button', class: 'btn ghost', onclick: () => openConversation() }, t('decideInChat')) : null, + h('span', { class: 'spacer' }), + h('button', { type: 'button', class: 'btn', onclick: () => decide('deny', card) }, t('deny')), + h('button', { type: 'button', class: 'btn primary', onclick: () => decide('allowOnce', card) }, t('allowOnce')), + ), + ); + return card; +} + +// --------------------------------------------------------------------------------------------- +// Questionnaire + +function renderQuestion() { + const pending = state.project?.pendingQuestion; + els.question.replaceChildren(); + els.composer.hidden = !!pending; + if (!pending) return; + const request = pending.request ?? {}; + const steps = Array.isArray(request.steps) ? request.steps : []; + const form = h('form', { class: 'card question', 'aria-labelledby': 'question-title' }); + form.append(h('h3', { id: 'question-title' }, request.title || t('questionTitle'))); + if (pending.mode !== 'questionnaire' || steps.length === 0) { + form.append(h('p', null, t('questionUnsupported'))); + } + steps.forEach((/** @type {any} */ step, index) => { + const name = `step-${index}`; + const type = step.selectionMode === 'multiple' ? 'checkbox' : 'radio'; + const otherInput = /** @type {HTMLInputElement} */ (h('input', { class: 'input', type: 'text', placeholder: t('questionOtherPlaceholder'), 'aria-label': t('questionOther'), dataset: { otherFor: name } })); + form.append( + h( + 'fieldset', + { class: 'q-step', dataset: { stepId: step.id, name } }, + h('legend', null, step.header ? h('span', { class: 'q-header' }, step.header) : null, step.question), + step.description ? h('p', { class: 'muted small' }, step.description) : null, + ...(step.options ?? []).map((/** @type {any} */ option) => + h('label', { class: 'q-option' }, h('input', { type, name, value: option.id }), h('span', null, h('span', null, option.label), option.description ? h('span', { class: 'muted small q-desc' }, option.description) : null)), + ), + h('label', { class: 'q-option other' }, h('input', { type, name, value: '__other__' }), h('span', null, t('questionOther'))), + otherInput, + ), + ); + otherInput.addEventListener('input', () => { + const other = /** @type {HTMLInputElement | null} */ (form.querySelector(`input[name="${name}"][value="__other__"]`)); + if (other && otherInput.value.trim()) other.checked = true; + }); + }); + const agentApi = window.mcode?.miniapp?.agent; + form.append( + h( + 'div', + { class: 'card-actions' }, + agentApi?.open && state.project?.sessionId ? h('button', { type: 'button', class: 'btn ghost', onclick: () => openConversation() }, t('decideInChat')) : null, + h('span', { class: 'spacer' }), + h('button', { type: 'button', class: 'btn', onclick: () => void submitAnswers(steps, null) }, t('questionSkip')), + steps.length ? h('button', { type: 'submit', class: 'btn primary' }, t('questionSubmit')) : null, + ), + ); + form.addEventListener('submit', (event) => { + event.preventDefault(); + void submitAnswers(steps, form); + }); + els.question.append(form); +} + +/** @param {any[]} steps @param {HTMLFormElement | null} form */ +async function submitAnswers(steps, form) { + const answers = steps.map((step, index) => { + if (!form) return { stepId: step.id, selectedOptionIds: [], selectedOther: false, skipped: true }; + const name = `step-${index}`; + const checked = [...form.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => /** @type {HTMLInputElement} */ (input).value); + const otherText = /** @type {HTMLInputElement | null} */ (form.querySelector(`input[data-other-for="${name}"]`))?.value.trim() ?? ''; + const selectedOther = checked.includes('__other__') && !!otherText; + const selectedOptionIds = checked.filter((value) => value !== '__other__'); + if (selectedOptionIds.length === 0 && !selectedOther) return { stepId: step.id, selectedOptionIds: [], selectedOther: false, skipped: true }; + return { stepId: step.id, selectedOptionIds, selectedOther, ...(selectedOther ? { otherText } : {}) }; + }); + if (answers.length === 0) return; + try { + await api('POST', `/api/projects/${state.currentId}/answer`, { answers, model: modelSelection() }); + await loadProject(); + } catch (error) { + toast(/** @type {Error} */ (error).message, 'error'); + } +} + +// --------------------------------------------------------------------------------------------- +// Composer + +function buildComposer() { + const textarea = /** @type {HTMLTextAreaElement} */ (h('textarea', { class: 'composer-input', rows: '3', 'aria-label': t('placeholderNew'), maxlength: '20000' })); + const modelSelect = /** @type {HTMLSelectElement} */ (h('select', { class: 'select', 'aria-label': t('model') })); + const effortSelect = /** @type {HTMLSelectElement} */ (h('select', { class: 'select', 'aria-label': t('effort') })); + const sendButton = /** @type {HTMLButtonElement} */ (h('button', { type: 'submit', class: 'btn primary icon-only send', 'aria-label': t('send'), title: t('send') }, icon('send'))); + const stopButton = /** @type {HTMLButtonElement} */ (h('button', { type: 'button', class: 'btn danger icon-only', 'aria-label': t('stop'), title: t('stop'), hidden: true, onclick: stopRun }, icon('stop'))); + els.textarea = textarea; + els.modelSelect = modelSelect; + els.effortSelect = effortSelect; + els.sendButton = sendButton; + els.stopButton = stopButton; + + textarea.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) { + event.preventDefault(); + form.requestSubmit(); + } + }); + textarea.addEventListener('input', () => { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`; + }); + modelSelect.addEventListener('change', () => { + state.modelKey = modelSelect.value; + state.effort = ''; + saveModelPreference(); + renderModelControls(); + }); + effortSelect.addEventListener('change', () => { + state.effort = effortSelect.value; + saveModelPreference(); + }); + + const form = /** @type {HTMLFormElement} */ ( + h( + 'form', + { class: 'composer' }, + textarea, + h('div', { class: 'composer-row' }, h('div', { class: 'model-controls' }, modelSelect, effortSelect), h('span', { class: 'spacer' }), stopButton, sendButton), + ) + ); + form.addEventListener('submit', (event) => { + event.preventDefault(); + void send(); + }); + return form; +} + +function saveModelPreference() { + localStorage.setItem(MODEL_KEY, JSON.stringify({ modelKey: state.modelKey, effort: state.effort })); +} + +function renderModelControls() { + const select = /** @type {HTMLSelectElement} */ (els.modelSelect); + const effort = /** @type {HTMLSelectElement} */ (els.effortSelect); + if (!state.models) { + select.replaceChildren(h('option', { value: '' }, state.modelsFailed ? t('modelsUnavailable') : '…')); + select.disabled = true; + effort.hidden = true; + return; + } + select.disabled = false; + const defaultName = modelName(state.models.default) || state.models.default?.modelId || ''; + select.replaceChildren( + h('option', { value: '' }, t('modelDefault', { name: defaultName })), + ...state.models.models.map((/** @type {any} */ option) => h('option', { value: keyOf(option) }, option.displayName || option.modelId)), + ); + if (state.modelKey && !currentModelOption()) state.modelKey = ''; + select.value = state.modelKey; + const option = currentModelOption(); + const efforts = option?.thinking?.efforts ?? []; + effort.hidden = efforts.length === 0; + effort.replaceChildren(h('option', { value: '' }, t('effortAuto')), ...efforts.map((/** @type {string} */ value) => h('option', { value }, value))); + if (!efforts.includes(state.effort)) state.effort = ''; + effort.value = state.effort; +} + +function renderComposer() { + const busy = !!state.live || state.sending; + const textarea = /** @type {HTMLTextAreaElement} */ (els.textarea); + textarea.placeholder = state.project ? t('placeholderFollow') : t('placeholderNew'); + textarea.setAttribute('aria-label', textarea.placeholder); + /** @type {HTMLButtonElement} */ (els.sendButton).hidden = !!state.live; + /** @type {HTMLButtonElement} */ (els.sendButton).disabled = busy || !state.agentAvailable; + /** @type {HTMLButtonElement} */ (els.stopButton).hidden = !state.live; +} + +/** @param {string} text */ +function fillComposer(text) { + const textarea = /** @type {HTMLTextAreaElement} */ (els.textarea); + textarea.value = text; + textarea.dispatchEvent(new Event('input')); + textarea.focus(); +} + +async function send() { + const textarea = /** @type {HTMLTextAreaElement} */ (els.textarea); + const text = textarea.value.trim(); + if (!text || state.live || state.sending || !state.agentAvailable) return; + state.sending = true; + renderComposer(); + try { + if (!state.currentId) { + const style = state.newStyle ? ('preset' in state.newStyle ? { preset: state.newStyle.preset } : state.newStyle) : null; + const created = await api('POST', '/api/projects', { text, style, model: modelSelection() }); + textarea.value = ''; + textarea.dispatchEvent(new Event('input')); + state.newStyle = null; + await refreshState(); + await selectProject(created.id); + } else { + await api('POST', `/api/projects/${state.currentId}/messages`, { text, model: modelSelection() }); + textarea.value = ''; + textarea.dispatchEvent(new Event('input')); + await loadProject(); + } + } catch (error) { + toast(/** @type {Error} */ (error).message, 'error'); + } finally { + state.sending = false; + renderComposer(); + } +} + +async function stopRun() { + if (!state.currentId) return; + try { + await api('POST', `/api/projects/${state.currentId}/cancel`, {}); + } catch (error) { + toast(/** @type {Error} */ (error).message, 'error'); + } +} + +// --------------------------------------------------------------------------------------------- +// Preview + +function buildPreview() { + const frame = /** @type {HTMLIFrameElement} */ (h('iframe', { class: 'preview-frame', sandbox: 'allow-scripts', referrerpolicy: 'no-referrer', title: t('previewTab') })); + const overlay = h('div', { class: 'preview-overlay', role: 'status' }); + const empty = h('div', { class: 'preview-empty' }, logo(), h('p', { class: 'muted' }, t('previewEmpty'))); + const versionSelect = /** @type {HTMLSelectElement} */ (h('select', { class: 'select', 'aria-label': t('versionOption', { n: '' }).trim() })); + versionSelect.addEventListener('change', () => { + const value = Number(versionSelect.value); + showVersion(value === latestVersion() ? null : value); + }); + const deviceGroup = h( + 'div', + { class: 'segmented', role: 'group', 'aria-label': t('device') }, + ...(/** @type {const} */ (['desktop', 'tablet', 'phone'])).map((device) => + h('button', { type: 'button', class: 'seg', 'aria-label': t(device), title: t(device), dataset: { device }, onclick: () => setDevice(device) }, icon(device)), + ), + ); + const windowHandle = window.mcode?.miniapp?.window; + const popButton = windowHandle + ? h( + 'button', + { + type: 'button', + class: 'btn icon-only ghost', + 'aria-label': t('popOut'), + title: t('popOut'), + onclick: () => void windowHandle.update({ mode: 'window' }).catch(() => {}), + }, + icon('window'), + ) + : null; + els.popButton = popButton ?? h('span'); + els.versionSelect = versionSelect; + els.deviceGroup = deviceGroup; + els.frame = frame; + els.overlay = overlay; + els.previewEmpty = empty; + els.previewActions = h( + 'div', + { class: 'preview-actions' }, + h('button', { type: 'button', class: 'btn icon-only ghost', 'aria-label': t('reload'), title: t('reload'), onclick: () => preview.reload() }, icon('reload')), + h('button', { type: 'button', class: 'btn icon-only ghost', 'aria-label': t('copy'), title: t('copy'), onclick: copyHtml }, icon('copy')), + h('button', { type: 'button', class: 'btn icon-only ghost', 'aria-label': t('download'), title: t('download'), onclick: downloadHtml }, icon('download')), + popButton, + ); + els.stage = h('div', { class: 'stage' }, h('div', { class: 'device-frame' }, frame, overlay)); + return h( + 'section', + { class: 'preview', id: 'preview-panel', role: 'tabpanel', 'aria-labelledby': 'tab-preview' }, + h('header', { class: 'preview-head' }, deviceGroup, versionSelect, h('span', { class: 'spacer' }), els.previewActions), + h('div', { class: 'preview-body' }, els.stage, empty), + ); +} + +/** @param {string} device */ +function setDevice(device) { + state.device = ['desktop', 'tablet', 'phone'].includes(device) ? device : 'desktop'; + localStorage.setItem(DEVICE_KEY, state.device); + renderPreviewChrome(); +} + +function renderPreviewChrome() { + const project = state.project; + const latest = latestVersion(); + const shown = state.viewing ?? latest; + els.preview.dataset.device = state.device; + for (const button of els.deviceGroup.querySelectorAll('button')) { + button.setAttribute('aria-pressed', String(/** @type {HTMLElement} */ (button).dataset.device === state.device)); + } + const hasVersion = !!project && latest > 0; + els.previewEmpty.hidden = hasVersion; + els.stage.hidden = !hasVersion; + const select = /** @type {HTMLSelectElement} */ (els.versionSelect); + select.hidden = !hasVersion; + els.previewActions.hidden = !hasVersion; + if (hasVersion) { + const versions = [...project.versions].reverse(); + select.replaceChildren(...versions.map((/** @type {any} */ v) => h('option', { value: String(v.n) }, v.n === latest ? t('latest', { n: v.n }) : t('versionOption', { n: v.n })))); + select.value = String(shown); + } +} + +/** @param {number | null} n */ +function showVersion(n) { + state.viewing = n; + renderPreviewChrome(); + renderThread(); + preview.sync(); + if (root.dataset.tab === 'chat' && window.matchMedia('(max-width: 759px)').matches) setTab('preview'); +} + +const preview = (() => { + let loadedKey = ''; + let token = ''; + let html = ''; + let request = 0; + let readySignals = 0; + let consumedSignals = 0; + let restores = 0; + let watching = false; + + window.addEventListener('message', (event) => { + const frame = /** @type {HTMLIFrameElement} */ (els.frame); + if (!frame || event.source !== frame.contentWindow || !event.data || event.data.token !== token) return; + if (event.data.type === 'ready') readySignals += 1; + if (event.data.type === 'painted' || event.data.type === 'ready') els.overlay.hidden = true; + // The design cannot open popups; a clicked https link is opened here, during the click. + if (event.data.type === 'open' && typeof event.data.url === 'string') openExternal(event.data.url); + }); + + // Every document the design loads reports "ready" with this preview's token. A load that + // does not is the design navigating itself away, so the design is put back (as fx0 does). + // Timing alone is not used: a hidden panel throttles the design's timers. + function watchLoads() { + if (watching) return; + watching = true; + /** @type {HTMLIFrameElement} */ (els.frame).addEventListener('load', () => { + setTimeout(() => { + if (!html) return; + if (readySignals > consumedSignals) { + consumedSignals = readySignals; + restores = 0; + return; + } + // A design that is only slow to load must not reload forever. + if (restores >= 2) return; + restores += 1; + restore(); + }, 600); + }); + } + + function restore() { + /** @type {HTMLIFrameElement} */ (els.frame).srcdoc = html; + } + + async function sync() { + const project = state.project; + const n = state.viewing ?? latestVersion(); + if (!project || n === 0) { + loadedKey = ''; + html = ''; + return; + } + const key = `${project.id}:${n}`; + if (key === loadedKey) return; + loadedKey = key; + const mine = ++request; + els.overlay.hidden = false; + els.overlay.replaceChildren(h('span', { class: 'spinner', 'aria-hidden': 'true' }), t('previewLoading')); + try { + const data = await api('GET', `/api/projects/${project.id}/preview/${n}`); + if (mine !== request) return; + watchLoads(); + token = data.token; + html = data.html; + consumedSignals = readySignals; + restores = 0; + /** @type {HTMLIFrameElement} */ (els.frame).srcdoc = html; + // A document that never reports (scripts blocked by an error) still shows after a moment. + setTimeout(() => { + if (mine === request) els.overlay.hidden = true; + }, 1500); + } catch (error) { + if (mine !== request) return; + loadedKey = ''; + els.overlay.replaceChildren( + h('span', null, t('previewFailed')), + h('button', { type: 'button', class: 'btn small', onclick: () => void sync() }, t('retry')), + ); + } + } + + return { + sync, + reload() { + loadedKey = ''; + void sync(); + }, + ensureShown() { + void sync(); + }, + reset() { + loadedKey = ''; + html = ''; + token = ''; + /** @type {HTMLIFrameElement} */ (els.frame).removeAttribute('srcdoc'); + }, + }; +})(); + +async function currentHtml() { + const project = state.project; + const n = state.viewing ?? latestVersion(); + if (!project || n === 0) throw new Error('no version'); + const text = await api('GET', `/api/projects/${project.id}/versions/${n}`); + return { text: String(text), n, project }; +} + +async function copyHtml() { + try { + const { text } = await currentHtml(); + await navigator.clipboard.writeText(text); + toast(t('copied')); + } catch { + toast(t('copyFailed'), 'error'); + } +} + +/** @param {string} title */ +function fileSlug(title) { + const slug = title + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-') + .slice(0, 48); + return slug || 'page'; +} + +async function downloadHtml() { + try { + const { text, n, project } = await currentHtml(); + const file = `${fileSlug(project.title)}-v${n}.html`; + const url = URL.createObjectURL(new Blob([text], { type: 'text/html;charset=utf-8' })); + const link = h('a', { href: url, download: file, hidden: true }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10_000); + toast(t('downloaded', { file })); + } catch { + toast(t('errUNKNOWN'), 'error'); + } +} + +// --------------------------------------------------------------------------------------------- +// Data flow + +async function refreshState() { + const data = await api('GET', '/api/state'); + state.agentAvailable = data.agentAvailable; + state.projects = data.projects; + state.styles = data.styles; + renderProjects(); +} + +async function loadModels() { + if (!state.agentAvailable) return; + try { + state.models = await api('GET', '/api/models'); + state.modelsFailed = false; + } catch { + state.modelsFailed = true; + } + renderModelControls(); + renderLive(); +} + +async function loadProject() { + const id = state.currentId; + if (!id) return; + try { + const project = await api('GET', `/api/projects/${id}`); + if (state.currentId !== id) return; + const grew = (project.versions.at(-1)?.n ?? 0) !== latestVersion(); + state.project = project; + state.live = project.live; + if (state.viewing !== null && !project.versions.some((/** @type {any} */ v) => v.n === state.viewing)) state.viewing = null; + renderAll(); + if (grew && state.viewing === null) preview.sync(); + } catch (error) { + if (/** @type {ApiError} */ (error).code === 'not_found') { + await refreshState().catch(() => {}); + selectProject(null); + } + } +} + +/** @param {string | null} id */ +async function selectProject(id) { + if (state.currentId !== id) { + state.viewing = null; + preview.reset(); + } + state.currentId = id; + state.project = null; + state.live = null; + state.scrollToEnd = true; + history.replaceState(null, '', id ? `${location.pathname}?p=${id}` : location.pathname); + toggleSidebar(false); + renderAll(); + if (id) { + await loadProject(); + const saved = state.project?.model; + if (saved && state.models) { + const option = state.models.models.find((/** @type {any} */ item) => keyOf(item) === keyOf(saved)); + if (option) { + state.modelKey = keyOf(saved); + state.effort = saved.thinking?.effort ?? ''; + renderModelControls(); + } + } + } else { + setTab('chat'); + /** @type {HTMLTextAreaElement} */ (els.textarea).focus(); + } +} + +function renderAll() { + renderProjects(); + renderHeader(); + renderThread(); + renderLive(); + renderQuestion(); + renderComposer(); + renderPreviewChrome(); +} + +let projectTimer = 0; +let listTimer = 0; +function connectEvents() { + const source = new EventSource('/api/events'); + let dropped = false; + source.addEventListener('message', (event) => { + let data; + try { + data = JSON.parse(event.data); + } catch { + return; + } + if (data.type === 'live' && data.id === state.currentId) { + state.live = data.live; + renderLive(); + renderComposer(); + renderHeader(); + } else if (data.type === 'project' && data.id === state.currentId) { + clearTimeout(projectTimer); + projectTimer = window.setTimeout(() => void loadProject(), 80); + } else if (data.type === 'projects') { + clearTimeout(listTimer); + listTimer = window.setTimeout(() => void refreshState().catch(() => {}), 120); + } + }); + source.addEventListener('error', () => { + dropped = true; + }); + source.addEventListener('open', () => { + if (!dropped) return; + dropped = false; + void refreshState().catch(() => {}); + void loadProject(); + }); +} + +function watchTheme() { + const query = window.matchMedia('(prefers-color-scheme: dark)'); + const apply = () => { + document.documentElement.dataset.theme = query.matches ? 'dark' : 'light'; + }; + apply(); + query.addEventListener('change', apply); +} + +async function boot() { + watchTheme(); + buildShell(); + setDevice(state.device); + renderModelControls(); + try { + await refreshState(); + } catch (error) { + els.banner.hidden = false; + els.banner.textContent = /** @type {Error} */ (error).message; + } + connectEvents(); + void loadModels(); + // A page link is /studio?p=; MiniMax Code can open it directly. + const linked = new URLSearchParams(location.search).get('p') ?? ''; + await selectProject(/^p-[0-9a-f]{8}$/.test(linked) && state.projects.some((project) => project.id === linked) ? linked : null); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && root.classList.contains('sidebar-open')) toggleSidebar(false); + }); +} + +void boot(); diff --git a/plugins/yujiachen-y/page-designer/miniapp/client/client-api.ts b/plugins/yujiachen-y/page-designer/miniapp/client/client-api.ts new file mode 100644 index 00000000..2ce7ab69 --- /dev/null +++ b/plugins/yujiachen-y/page-designer/miniapp/client/client-api.ts @@ -0,0 +1,387 @@ +/** + * Copyable Mini App Client declarations for TypeScript and JavaScript/JSDoc authoring. + * No Host implementation or runtime import: Electron preload provides window.mcode. + * Keep the .ts filename because Electron dependency packaging filters out .d.ts assets. + * Node start(context) uses miniapp-api.ts instead. See docs/client.md for usage and errors. + */ +export interface MiniAppMessageAPI { + /** + * Ask the user to append plain text to this Mini App's conversation input. + * Accepts at most 100,000 UTF-16 code units and preserves spaces, tabs, and newlines. + * An empty string still requires confirmation and leaves the draft unchanged. + * Resolves after the confirmed append; rejects on cancellation or failure. + * Does not send a message or insert, edit, or delete sent chat history. + */ + append(text: string): Promise; +} + +export type MiniAppAgentQuestionMode = 'questionnaire' | 'feature-enable' | 'plan'; + +export interface MiniAppAgentQuestionOption { + readonly id: string; + readonly label: string; + readonly description?: string; +} + +export interface MiniAppAgentQuestionStep { + readonly id: string; + readonly header?: string; + readonly question: string; + readonly description?: string; + readonly selectionMode: 'single' | 'multiple'; + readonly options: readonly MiniAppAgentQuestionOption[]; + /** Free-text "other" is always accepted; see `MiniAppAgentQuestionAnswer.otherText`. */ + readonly allowOther: true; +} + +/** Same questionnaire the Host chat renders; render every step and answer each one. */ +export interface MiniAppAgentQuestionnaire { + readonly schemaVersion: 2; + readonly id: string; + readonly title?: string; + readonly steps: readonly MiniAppAgentQuestionStep[]; + readonly mode?: MiniAppAgentQuestionMode; + /** Plan review Markdown and other mode-specific data. */ + readonly modePayload?: { readonly [key: string]: unknown }; + /** Unix ms; the request is withdrawn after this. */ + readonly expiresAt?: number; +} + +export interface MiniAppAgentQuestionAnswer { + readonly stepId: string; + readonly selectedOptionIds: readonly string[]; + readonly selectedOther: boolean; + readonly otherText?: string; + readonly skipped?: boolean; +} + +/** A model as the Host's composer selects it; take the values from `models()`. */ +export interface MiniAppAgentModelSelection { + readonly providerId: string; + readonly modelId: string; + /** `''` means thinking is off; it is preserved, distinct from an absent `variant`. */ + readonly variant?: string; + readonly thinking?: { readonly effort: string }; +} + +/** One model this user can use. */ +export interface MiniAppAgentModelOption { + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly variants?: readonly string[]; + readonly thinking?: { readonly efforts: readonly string[]; readonly default?: string }; +} + +export interface MiniAppAgentModels { + readonly models: readonly MiniAppAgentModelOption[]; + /** What a session without a host chat model starts with. */ + readonly default: MiniAppAgentModelSelection; + /** The host chat's current model (the default when the chat has none); only when opened from a chat. */ + readonly host?: MiniAppAgentModelSelection; +} + +export interface MiniAppAgentAgentOption { + /** Canonical name; what `agent` takes. */ + readonly name: string; + readonly displayName: string; + /** The default agent. */ + readonly primary: boolean; +} + +export interface MiniAppAgentAgents { + readonly agents: readonly MiniAppAgentAgentOption[]; +} + +/** Final outcome of one Agent turn. */ +export interface MiniAppAgentRunResult { + /** Pass back in `run({ sessionId })` to continue the same conversation. */ + readonly sessionId: string; + /** Absent when no turn ran: an `answer` accepted without starting a continuation. */ + readonly turnId?: string; + /** The model the turn was dispatched with; absent when no turn ran. */ + readonly model?: MiniAppAgentModelSelection; + /** `aborted` only when the Host confirmed the abort; an unconfirmed cancel rejects with `result_unknown`. */ + readonly status: 'finished' | 'error' | 'aborted'; + /** Final assistant reply; absent when the turn produced none or did not finish. */ + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + /** Set when the turn ended waiting on a questionnaire; render `request` and continue with `agent.answer`. */ + readonly pendingQuestion?: { + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + }; +} + +/** + * Progress of one run. `turnId` matches the result's, except that a `permission.requested` raised + * by a delegated child session carries that child's own `sessionId` and `turnId`; `reply` with + * those, and do not filter events by the root turn. Ignore unknown `type` values: new event + * types may be added without notice. + * - `run.queued`: all run slots are taken and the call waits at 1-based `position`; sent once, + * before the turn starts, so it has no `turnId` and names a session only when continuing one. + * - `turn.started`: the Agent began the turn; `model` is the model it was dispatched with. + * - `text.delta`: a piece of the reply as it streams. + * - `message.committed`: one complete assistant message (a turn can have several). + * - `tool.started` / `tool.finished`: a tool call by name; `ok` is false when it failed. + * - `permission.requested`: a tool call waits for approval; decide it with `agent.reply`. + * - `question.requested`: the turn paused on a questionnaire; continue with `agent.answer`. + * - `turn.finished`: always last; same `status`, `text`, and `error` as the run result. + * Every event except `run.queued` names its `sessionId`, so `reply` works before the `run` that + * created the session has returned. `answer` does not: call it after the paused `run` resolves, + * or it rejects as busy. + */ +export type MiniAppAgentRunEvent = + | { readonly type: 'run.queued'; readonly sessionId?: string; readonly position: number } + | { + readonly type: 'turn.started'; + readonly sessionId: string; + readonly turnId: string; + readonly model: MiniAppAgentModelSelection; + } + | { + readonly type: 'text.delta'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'message.committed'; + readonly sessionId: string; + readonly turnId: string; + readonly text: string; + } + | { + readonly type: 'tool.started'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + } + | { + readonly type: 'tool.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly name: string; + readonly ok: boolean; + } + | { + readonly type: 'permission.requested'; + readonly sessionId: string; + /** Absent when the runtime did not attribute the ask to a turn. */ + readonly turnId?: string; + readonly requestId: string; + readonly toolName: string; + readonly toolInput?: string; + readonly reason: string; + } + | { + readonly type: 'question.requested'; + readonly sessionId: string; + readonly turnId?: string; + readonly requestId: string; + readonly mode: MiniAppAgentQuestionMode; + readonly request: MiniAppAgentQuestionnaire; + } + | { + readonly type: 'turn.finished'; + readonly sessionId: string; + readonly turnId: string; + readonly status: 'finished' | 'error' | 'aborted'; + readonly text?: string; + readonly error?: { readonly code: string; readonly message: string }; + }; + +export interface MiniAppAgentRunInput { + /** Plain text, at most 100,000 UTF-16 code units. */ + readonly text: string; + /** Continue a session this Mini App created earlier; omit to start a new one. */ + readonly sessionId?: string; + /** + * Switch the session to this model before the turn (validated like the composer validates a new + * session's model: it must be one `models()` lists; unusable is `invalid_request`, missing BYOK + * credentials end the turn with `status: 'error'`); the session keeps it. Omit to use the session's current model. + */ + readonly model?: MiniAppAgentModelSelection; + /** + * Only when starting a session (no `sessionId`): an agent `name` from `agents()`. A session + * never changes its agent, so passing it with `sessionId` is `invalid_request`. + */ + readonly agent?: string; + /** Your id for `cancel(requestId)`; 1-128 characters, unique among this page's active runs. */ + readonly requestId?: string; +} + +export interface MiniAppAgentReplyInput { + readonly sessionId: string; + /** `requestId` of the `permission.requested` event. */ + readonly requestId: string; + /** No `allowAlways`: a Mini App never writes the user's global rules. */ + readonly decision: 'allowOnce' | 'deny'; +} + +export interface MiniAppAgentAnswerInput { + readonly sessionId: string; + /** `requestId` of the `question.requested` event or of `result.pendingQuestion`. */ + readonly questionId: string; + /** One answer per questionnaire step. */ + readonly answers: readonly MiniAppAgentQuestionAnswer[]; + /** Same as `MiniAppAgentRunInput.model`, applied before the continuation turn. */ + readonly model?: MiniAppAgentModelSelection; + /** Your id for `cancel(requestId)`, as in `run`. */ + readonly requestId?: string; +} + +export interface MiniAppAgentRunOptions { + readonly onEvent?: (event: MiniAppAgentRunEvent) => void; +} + +/** + * Absent on older Hosts. Calls reject with `not_permitted` unless the manifest declares + * `agentAccess: ["session.run"]`. A new session uses the Agent of the chat this page is open in; + * a page without a host chat uses the primary Agent unless `run({ agent })` / `create({ agent })` + * names one from `agents()`. Continuing a session keeps its Agent. + * The Agent works in the host chat's project directory when it has one, otherwise in this Mini + * App's own Agent directory; a continued session keeps its directory. A new session takes the + * model and thinking level of the host chat (the runtime default when the chat has none) and keeps + * them; `run` / `answer` with `model` switch the session's model first. + * It runs one turn with that Agent's full capabilities (files, shell, Skills, MCP including this + * Mini App's own), and resolves when it ends. + * + * Interaction is yours to render: handle `permission.requested` with `reply` and + * `question.requested` with `answer`. A permission request waits only while this run passed + * `onEvent` or the session is open in a chat side panel through `open`; otherwise it is denied + * and the model is told why. A run with `onEvent` that does not handle the request waits until + * `cancel` or the page closes; there is no timeout. Up to `agentConcurrency` runs (default 4) per + * Mini App run at once, one per session; further `run` / `answer` calls wait in order (up to four + * times that many, see `run.queued`), and `cancel` on a waiting call rejects it with `cancelled`. + * `busy` means the session already has a run in progress or the wait queue is full. + * Failures reject with an `Error` whose `message` is `invalid_request`, `not_permitted`, `busy`, + * `session_not_found`, `runtime_unavailable`, `cancelled`, `result_unknown`, or + * `request_not_found`. `runtime_unavailable` means nothing was started; `result_unknown` means + * the turn may have run (or ended without the Host knowing whether a question is pending), so + * never repeat it automatically. + */ +export interface MiniAppAgentAPI { + run( + input: MiniAppAgentRunInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Decide a pending permission request; `request_not_found` when it is unknown or settled. */ + reply(input: MiniAppAgentReplyInput): Promise; + /** + * Answer a questionnaire; the continuation turn is observed like `run`. Rejects with + * `result_unknown` when the answer was recorded but the continuation turn was not observed. + */ + answer( + input: MiniAppAgentAnswerInput, + options?: MiniAppAgentRunOptions, + ): Promise; + /** Ask the Host to stop an active run or answer; unknown or finished ids are ignored. */ + cancel(requestId: string): void; + /** + * Show one of this Mini App's sessions as a tab in the side panel of the chat the + * Mini App is open in, where the user can read the conversation and answer + * permission and question cards themselves. Permission requests wait for the tab only + * while it stays open; once the user closes it, handle `permission.requested` yourself + * or call `open` again. Any session this Mini App owns can be opened, whichever chat + * created it; `session_not_found` otherwise. + */ + open(sessionId: string): Promise; + /** + * Returns the model catalog, runtime `default` and optional chat `host`. Pass `default` / `host` + * directly as `model` to `run` / `answer`. For a catalog entry, extract `providerId` / `modelId`, + * add a selected `variant` if needed and preserve `variant: ''`. If thinking is supported, + * select an effort from `thinking.efforts` and pass `thinking: { effort }`. + */ + models(): Promise; + /** The agents the user has configured; `agent` of `create` / a first `run` must be one of them. */ + agents(): Promise; + /** + * Create a session bound to this chat (agent, project directory, model) without running a + * turn, and return its `sessionId`; continue it with `run({ sessionId })` here or hand it to + * Node code. `agent` picks the session's agent from `agents()`; a session never changes it. + * Takes no run slot. If the Host's 60 s deadline passes it rejects with `runtime_unavailable` + * although the session may already exist. + */ + create(input?: { readonly agent?: string }): Promise<{ readonly sessionId: string }>; +} + +export interface MiniAppClientAPI { + readonly message: MiniAppMessageAPI; + readonly agent?: MiniAppAgentAPI; + /** + * Proposed: shape may still change. Feature-detect with + * `window.mcode?.miniapp?.window` before use. + */ + readonly window?: MiniAppWindowHandle; +} + +/** Live state of the popped-out window: current position, size, and flags — not the manifest declaration. */ +export interface MiniAppWindowOptions { + readonly width: number; + readonly height: number; + readonly minWidth: number; + readonly minHeight: number; + readonly x: number; + readonly y: number; + readonly visible: boolean; + readonly resizable: boolean; + readonly frame: boolean; + readonly transparent: boolean; + readonly alwaysOnTop: boolean; + readonly clickThrough: boolean; + readonly skipTaskbar: boolean; +} + +export type MiniAppWindowState = + | { readonly mode: 'inline' } + | { readonly mode: 'window'; readonly window: MiniAppWindowOptions }; + +/** Every field is optional; omitted fields are left unchanged. */ +export type MiniAppWindowPatch = Partial<{ + mode: 'inline' | 'window'; + width: number; + height: number; + x: number; + y: number; + visible: boolean; + resizable: boolean; + alwaysOnTop: boolean; + clickThrough: boolean; + skipTaskbar: boolean; +}>; + +export interface MiniAppWindowDisposable { + dispose(): void; +} + +/** + * Page-side handle for this Mini App's own window (proposed). Separate from + * `message`; the Host injects both under the same `mcode.miniapp` namespace. + */ +export interface MiniAppWindowHandle { + /** Opaque and stable across pop-out and return; never a Session or launch identifier. */ + readonly id: string; + getState(): Promise; + /** + * `update({ mode: ... })` or `focus()` require the call to originate from a + * user gesture (`navigator.userActivation.isActive`); otherwise they reject + * with `not_permitted` before any Host round trip. + */ + update(patch: MiniAppWindowPatch): Promise; + focus(): Promise; + /** Returns a disposable; call `dispose()` to stop receiving updates. */ + onDidChangeState(listener: (state: MiniAppWindowState) => void): MiniAppWindowDisposable; +} + +export interface McodeClientAPI { + readonly miniapp: MiniAppClientAPI; +} + +declare global { + interface Window { + /** Absent outside a supported managed Mini App main page. Check before use. */ + readonly mcode?: McodeClientAPI; + } +} diff --git a/plugins/yujiachen-y/page-designer/miniapp/client/index.html b/plugins/yujiachen-y/page-designer/miniapp/client/index.html new file mode 100644 index 00000000..6a1daf1b --- /dev/null +++ b/plugins/yujiachen-y/page-designer/miniapp/client/index.html @@ -0,0 +1,17 @@ + + + + + + + Page Designer + + + + +
+

Loading Page Designer…

+
+ + + diff --git a/plugins/yujiachen-y/page-designer/miniapp/miniapp.json b/plugins/yujiachen-y/page-designer/miniapp/miniapp.json new file mode 100644 index 00000000..f95b7b8f --- /dev/null +++ b/plugins/yujiachen-y/page-designer/miniapp/miniapp.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/studio", + "window": { + "width": 1280, + "height": 820, + "minWidth": 720, + "minHeight": 480 + } + }, + "mcpEndpoints": [{ "server": "page-designer", "path": "/mcp/page-designer" }], + "agentAccess": ["session.run"], + "agentConcurrency": 4 +} diff --git a/plugins/yujiachen-y/page-designer/miniapp/node/artifact.mjs b/plugins/yujiachen-y/page-designer/miniapp/node/artifact.mjs new file mode 100644 index 00000000..7e546bcb --- /dev/null +++ b/plugins/yujiachen-y/page-designer/miniapp/node/artifact.mjs @@ -0,0 +1,291 @@ +// @ts-check +// The design artifact: one self-contained HTML document, its safety checks and the sandbox +// bootstrap the preview runs it with. +// +// Adapted from fx0 by Vercel Labs, lib/design-artifact.ts and the edit_design handler in +// lib/agent.ts (https://github.com/vercel-labs/fx0, commit e584782, Apache-2.0). +// Changes from the original: ported from TypeScript to JavaScript; the bootstrap reports to the +// Mini App page instead of fx0's wrapper route and sends no heartbeat timer; the +// exact-replacement edit logic moved here from lib/agent.ts so the MCP tools and the tests +// share it. + +export const MAX_DESIGN_BYTES = 512 * 1024; +export const MAX_EDITS = 20; + +/** Applied inside the preview document, ahead of everything the design contains. */ +export const DESIGN_EMBEDDED_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + 'img-src data:', + 'font-src data:', + 'media-src data:', + "connect-src 'none'", + "child-src 'none'", + "frame-src 'none'", + "worker-src 'none'", + "object-src 'none'", + "manifest-src 'none'", + "base-uri 'none'", + "form-action 'none'", +].join('; '); + +/** + * WebRTC is switched off before the design runs (CSP does not cover it). With a token, the + * document also reports to the page when it has loaded and painted, and hands clicked https + * links to the page, because the sandbox grants no popups or top navigation. + * @param {string} [readyToken] + */ +function bootstrap(readyToken) { + const heartbeat = readyToken + ? `const token=${JSON.stringify(readyToken)};const send=type=>parent.postMessage({type,token},"*");addEventListener("load",()=>{send("ready");requestAnimationFrame(()=>requestAnimationFrame(()=>send("painted")))},{once:true});addEventListener("click",event=>{const link=event.target instanceof Element&&event.target.closest("a[href]");const url=link?link.getAttribute("href")||"":"";if(/^https:/i.test(url)){event.preventDefault();parent.postMessage({type:"open",token,url},"*")}},true);` + : ''; + return ``; +} + +/** @param {string} value */ +function escapeAttribute(value) { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); +} + +/** + * Insert the CSP meta tag and the bootstrap right after the doctype, before any design content. + * @param {string} html a document that passed validateDesignHtml + * @param {string} [readyToken] + */ +export function prepareDesignHtml(html, readyToken) { + const csp = ``; + return html.replace(/^(\s*)/i, `$1${csp}${bootstrap(readyToken)}`); +} + +const PROHIBITED_TAGS = new Map([ + ['base', ' elements'], + ['form', 'forms'], + ['iframe', ''), /' }); + assert.equal(outside.isError, true); + assert.match(outside.value, /must not contain