diff --git a/AGENTS.md b/AGENTS.md index e4855fb..04050a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,9 @@ update one package at `plugins///`. their shape only, and the Host validates them at install time. This repository documents the Mini App payload, the page APIs, and the Agent API; treat `context.hostConnector` and the other capabilities as outside its scope. -- `agentAccess` gives an Agent with files, shell, and MCP. Declare it only when needed, and never - answer a `permission.requested` with `allowOnce` unless the user chose it. +- `agentAccess: ["session.run"]` gives an Agent with files, shell, and MCP. `session.read` is a + separate read-only Host extension; it does not authorize Agent turns. Declare only the grants + the app needs, and never answer a `permission.requested` with `allowOnce` unless the user chose it. - The runtime payload is exactly `miniapp/client` and `miniapp/node`. Keep tests and docs outside `miniapp/`. Vendor third-party code inside the payload; `node_modules` is never committed. - Paths are portable: ASCII, no symlinks, no `..`. `npm run check` enforces the full rule set. diff --git a/README.md b/README.md index 9298aab..21fa451 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [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) | | [仓库透视 / Repo X-Ray](plugins/yujiachen-y/repo-xray/) | Understand a codebase from its Git history: a code map you can drill into, hotspots and bug magnets, high-risk files, owners and knowledge loss, commit rhythm and firefighting, and the tech stack. Works with GitHub, GitLab, and local repositories; analysis runs on your machine | [yujiachen-y](https://github.com/yujiachen-y) | | [Disk Insight](plugins/zch0227/disk-insight/) | See where disk space went on macOS and Windows with a sunburst space map, find junk files sorted into three safety tiers, large and duplicate files, and how much code repositories and Git worktrees take; ask an Agent about any item, and permanently delete what you select after a confirmation that lists every item. Requires MiniMax Code 3.1.2 or later | [zch0227](https://github.com/zch0227) | +| [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders and a transparent desktop pet. Requires a Host build with the read/observe APIs documented in its README | [MiniMax](https://github.com/MiniMax-AI) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index 1794685..fb250d1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -75,6 +75,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [Anatomy 画廊](plugins/zch0227/anatomy-gallery/README.zh-CN.md) | 集中展示 anatomy 技能做出的交互式等距图解:按自建展区挂墙,全屏查看明暗两版,保留全部历史版本;在画廊里让 Agent 新建或修改作品,每次运行都有过程记录。自带 anatomy 技能。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | | [仓库透视](plugins/yujiachen-y/repo-xray/README.zh-CN.md) | 从 Git 历史看懂一个代码仓库:可逐层下钻的代码地图、热点与 Bug 磁铁、高风险文件、负责人与知识流失、提交节奏与救火记录、技术栈;支持 GitHub、GitLab 和本机仓库,全部在本机分析 | [yujiachen-y](https://github.com/yujiachen-y) | | [磁盘透视](plugins/zch0227/disk-insight/README.zh-CN.md) | 查看 macOS 和 Windows 的磁盘空间去向:旭日图空间地图,按三档风险分类的垃圾文件,大文件和重复文件,代码仓库和 Git worktree 的占用;任何条目都可以问 Agent,选中的条目在确认框里逐一列出,确认后直接永久删除。需要 MiniMax Code 3.1.2 或更高版本 | [zch0227](https://github.com/zch0227) | +| [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需使用具备 README 所列读取/观察接口的 Host 版本 | [MiniMax](https://github.com/MiniMax-AI) |
预览:Token 用量看板 diff --git a/docs/agent.md b/docs/agent.md index 061699d..bec0b98 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,6 +1,8 @@ # Mini App Agent API -Verified against MiniMax Code 3.1.2. Earlier clients reject packages that declare `agentAccess`. +Agent-turn APIs verified against MiniMax Code 3.1.2. Earlier clients reject packages that declare +`agentAccess`. Read access requires 3.1.4 or later (observed on 3.1.4-preview). The observation +extension below needs additional Host support; its minimum released client version is unverified. A Mini App can run Agent turns from its Node entry (`context.agent`) or its page (`window.mcode.miniapp.agent`). The Agent keeps its full capabilities: files, shell, Skills, and MCP, @@ -32,6 +34,44 @@ at runtime; a page that only prepares text for the user to send should use - State in the README `## Data & access` that the Mini App runs Agent turns and what it asks them to do (see `docs/security.md`). +### Read-only session extension + +`agentAccess: ["session.read"]` declares read access without permission to create or run Agent +turns, answer questions, decide approvals, or stop the user's work. `session.run` does not imply +read access to user conversations. A package that needs both must declare both, once each. + +The `sessions` and `session` reads require 3.1.4 or later, as above. The observation methods used +by the desktop pet additionally require a supporting Host build; their minimum released version +has not been verified. Check the page's `capabilities.has` and method availability before use +(see `docs/client.md`). + +| Page call | Read behavior | +| --- | --- | +| `sessions()` | Returns `{ sessions }` containing up to the 50 most recently updated visible root user conversations. It excludes Mini App-owned, scheduled and sub-Agent sessions. | +| `session()` | Returns the conversation this page belongs to. The observed 3.1.4-preview build rejects with `not_permitted` outside a conversation; callers should also handle an absent result from other builds. This is not a promise to identify the currently focused main-window conversation. | +| `session(sessionId)` | Returns a visible conversation's summary, current model when available, and latest assistant reply when available. It does not expose arbitrary Host files. | +| `observe({ all: true, requestId }, { onEvent })` | Tails lifecycle events for visible user conversations. Use it together with `sessions()`; it is not a complete historical snapshot. | +| `observe({ sessionId, requestId }, { onEvent })` | Tails progress events for one readable conversation. It does not replay earlier events or deliver actionable approval/question requests to the observer. | +| `cancel(requestId)` | Cancels this Mini App's observation request, not the observed user's Agent turn. | + +Summary statuses are `idle`, `running`, `error`, or `aborted`; timestamps use Unix milliseconds. +Global observation provides `turn.started`, `message.committed`, and `turn.finished`; a single +session also exposes progress such as text and tool events. Ignore unknown event types. Inferring +an ask reminder from a tool event does not authorize answering the question, and missing events +do not prove there is no pending ask. + +Exactly one observation target is required. Each Mini App has at most four concurrent observers; +observers consume no Agent-run slots. An observation's promise settles when the subscription +ends. When replacing one, cancel it and wait for that promise to settle before admitting a new +observer. Observers are tail-only, so interrupted or late subscriptions can miss events. Permission +revocation, page/process shutdown or deletion can end them; clear stale UI state and reconnect +only while permission is available. These are observation limits, separate from `agentConcurrency`. + +Keep user conversation data transient unless the app explicitly documents persistence. Do not +forward it to a network service or include it in diagnostics without the user's authorization. +The version check for the read grant does not prove that the observation methods are available +or that an installed Host has granted access. + ## Sessions Each Mini App owns its sessions and can continue only those. They are hidden from the user's diff --git a/docs/client.md b/docs/client.md index dbfc61e..0cd0513 100644 --- a/docs/client.md +++ b/docs/client.md @@ -2,6 +2,10 @@ Verified against MiniMax Code 3.1.2. +Session reads require 3.1.4 or later (observed on 3.1.4-preview). Observation and speech additionally +require a Host build that implements them; their minimum released client version is unverified. +These capabilities are not part of the 3.1.2 verification claim. + MiniMax Code injects `window.mcode.miniapp` into the Mini App's own page. Older clients, ordinary Browser tabs, and other browsers do not provide it, so check that a method exists before calling it. No SDK is needed. For type checking, copy `examples/hello-miniapp/miniapp/client/client-api.ts` @@ -16,7 +20,7 @@ scripts out of that document: anything running there shares the page's access to Asks the user to add plain text to the chat input of the conversation the Mini App is open in. The Host shows its own confirmation with the plugin, the target conversation, and the text. After the user confirms, the text is appended to the unsent draft; the user can still edit it and must press -Send. The page cannot send messages, read chat history, or change sent messages. +Send. This method cannot send messages, read chat history, or change sent messages. ```js async function addToChat(text, status) { @@ -44,9 +48,40 @@ async function addToChat(text, status) { ## `miniapp.agent` -Runs Agent turns from the page. Present only on MiniMax Code 3.1.2 or later, and calls reject with -`not_permitted` unless `miniapp.json` declares `agentAccess`. The calls, sessions, events, and -errors are in `docs/agent.md`. +Runs Agent turns from the page with `agentAccess: ["session.run"]` on MiniMax Code 3.1.2 or later. +Hosts with the read extension also offer `sessions`, `session` and `observe` with +`agentAccess: ["session.read"]`; this does not authorize running or answering a turn. The calls, +sessions, events, and errors are in `docs/agent.md`. + +## Capability checks for Host extensions + +Some Host builds expose `window.mcode.miniapp.capabilities.has(name)`. It reports whether the Host +offers an API, independently of the Mini App's manifest and the user's permission switches. +Older Hosts may omit `capabilities` altogether. Check both the capability and the method before +showing an extension-dependent control, and still handle permission and runtime errors. + +For read-only session features, the capability names are `agent.sessions`, `agent.session`, and +`agent.observe`. For speech, check `speech.transcribe`. Neither a capability probe nor a passing +repository validator guarantees that the user granted access. + +## `miniapp.speech` (Host extension) + +Requires a Host build with `capabilities?.has('speech.transcribe') === true`, a callable +`speech.transcribe`, `deviceAccess: ["microphone"]`, and microphone permission. A released minimum +client version has not been verified. Feature-detect it and keep text input available when absent. + +The page captures an utterance only after the user starts recording. Stop the microphone tracks +when recording ends or the page closes. `transcribe({ audio, format }, { onPartial? })` accepts raw +signed 16-bit mono PCM at 16,000 Hz (`format: { sampleRate: 16000, channelCount: 1, +sampleFormat: 's16' }`), not an encoded WAV/WebM/MP3 file. It returns `{ text }`; optional +`onPartial(text)` receives transcription progress. The supported build accepts up to 16,000,000 +audio bytes and one transcription per Mini App at a time. Handle rejection and preserve the user's +existing text; do not automatically retry a result whose outcome is unknown. + +The Host performs transcription and may contact its speech service. Disclose that audio leaves +the page through the Host; do not claim an offline-only flow unless that environment was tested. +Transcription fills the Mini App's draft. Appending to the chat still uses `message.append`, with +its separate Host confirmation, and the user still presses Send. ## `miniapp.window` (proposed) diff --git a/docs/package-contract.md b/docs/package-contract.md index 5f43cf6..541996e 100644 --- a/docs/package-contract.md +++ b/docs/package-contract.md @@ -2,6 +2,11 @@ Verified against MiniMax Code 3.1.2. +The `session.read` grant requires MiniMax Code 3.1.4 or later (observed on 3.1.4-preview). +Observation and `deviceAccess` below additionally require a Host build that implements them; +their minimum released client version has not been verified. Declaration checks do not prove +runtime availability. + A Mini App is a MiniMax Plugin whose `package.json` declares a Mini App payload. MiniMax Code enforces the manifest, payload, and path rules below when the package is installed. `npm run check` enforces the same rules here, plus this repository's own requirements: `README.md` and `LICENSE`, @@ -115,13 +120,23 @@ No other keys are allowed inside `mcode`. Other top-level keys (`name`, `type`, - `hostConnectorAccess` is optional: `{ "providers": string[] }`, each matching `^[a-z0-9_-]{1,64}$` and unique. Declared providers are granted by the Host at install time; this repository does not document their use. -- `agentAccess` is optional: an array of `"session.run"` (run Agent turns) and `"session.read"` - (read sessions: the page's `agent.session()` returns the chat it is open in, see `docs/agent.md`). - Absent or `[]` means no Agent access. See `docs/agent.md` before declaring it. Requires MiniMax - Code 3.1.2 or later; `"session.read"` requires 3.1.4 or later. +- `agentAccess` is optional: a unique string array containing `"session.run"`, `"session.read"`, + or both. `session.run` lets the Mini App run its own Agent turns (MiniMax Code 3.1.2 or later). + `session.read` reads visible conversations, including the chat the page is open in, and + requires 3.1.4 or later. Neither grant implies the other. Absent or `[]` means no Agent access. + Observation requires additional Host support; see `docs/agent.md` before declaring it. - `agentConcurrency` is optional: an integer from 1 to 32, default 4. It caps how many Agent turns of this Mini App run at once; it has no effect without `agentAccess`. Requires MiniMax Code 3.1.2 or later. +- `deviceAccess` is optional: a unique string array containing only `"microphone"`. It declares + microphone use for capture and the optional Host speech API; it does not grant Agent runs. + Absent or `[]` declares no device access. The Host and operating system must still authorize + microphone use. Its minimum released client version is unverified; see `docs/client.md`. + +`npm run check` rejects unknown capabilities and duplicate grants. It reports +`HOST_EXTENSION_UNVERIFIED` for microphone declarations so contributors explain +the tested Host build and unverified release compatibility in the README and pull request. The +check validates manifest shape, not whether the installed Host has or grants these capabilities. ### `surface.window` @@ -172,7 +187,7 @@ the package cannot be installed there at all. | Feature | Declared in | Minimum MiniMax Code | | --- | --- | --- | | `agents` | `plugin.json` | 3.1.2 | -| `agentAccess` | `miniapp/miniapp.json` | 3.1.2 | +| `agentAccess: ["session.run"]` | `miniapp/miniapp.json` | 3.1.2 | | `agentAccess` with `"session.read"` | `miniapp/miniapp.json` | 3.1.4 | | `agentConcurrency` | `miniapp/miniapp.json` | 3.1.2 | | `surface.window` | `miniapp/miniapp.json` | 3.1.2 | @@ -181,6 +196,11 @@ When a package declares any of these, name a MiniMax Code version at or above th `## Tested environment` in its README, for example `- MiniMax Code 3.1.2 on macOS 15`. `npm run check` warns until it finds one there. +The table does not establish observation or `deviceAccess` availability. Packages using these +extensions must name the Host build they actually tested and state that their minimum released +version is unverified. Older clients may reject the entire manifest. Do not remove a required +grant or replace read access with run access merely to make a package install. + ## Portable paths Every path inside the package, and every path written in a manifest, must be portable: diff --git a/docs/security.md b/docs/security.md index 1b517ce..2c96ab6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,7 +37,7 @@ Disclose every outbound host in the README. The default is none. ## Agent turns -An Agent run by a Mini App (`agentAccess`, see `docs/agent.md`) can read and write files, run +An Agent run by a Mini App (`agentAccess: ["session.run"]`, see `docs/agent.md`) can read and write files, run shell commands, and call MCP tools. - Declare `agentAccess` only when the Mini App needs it, and state in the README `## Data & access` @@ -47,3 +47,19 @@ shell commands, and call MCP tools. answer `allowOnce` automatically. - Text from the page, files, or the network is untrusted. Build prompts from your own instructions plus that text clearly marked as data, and validate anything the Agent returns before acting on it. + +## Read-only sessions and microphone extensions + +`session.read` grants access to user conversation status and content on supporting Host builds; +it does not authorize Agent turns or user-conversation mutations. Request it only when needed, +explain the data shown and retained, and treat titles, replies and tool input as untrusted data. +Do not insert them as HTML or executable code. Do not persist or send private conversation data +elsewhere without disclosing that behavior and obtaining authorization. + +`deviceAccess: ["microphone"]` separately declares microphone use. Record only after a user action, +show recording state, and release audio capture on stop, cancel or page disposal. Host speech +transcription may send audio to the Host's speech service; state this in the Mini App README and +preserve text input when the feature or permission is unavailable. Read access requires 3.1.4 +or later (observed on 3.1.4-preview); the minimum released version for observation and microphone/ +transcription remains unverified. Declaration validation is not a permission grant or a runtime +compatibility guarantee. diff --git a/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json new file mode 100644 index 0000000..3b64abb --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "name": "minimax-pet", + "displayName": "MiniMax Pet", + "version": "0.5.8", + "description": "轻量桌宠,聚合主窗口可见会话的任务状态、需要回答的提醒和最新结果,支持外观切换及图集导入。", + "author": "MiniMax", + "icon": "icon.png", + "category": "Other", + "exampleQueries": [ + "打开 MiniMax Pet,查看任务提醒", + "制作一只原创桌宠,生成可导入的图集" + ], + "apps": [], + "mcpServers": [], + "skills": [ + "skills/minimax-pet/SKILL.md" + ] +} diff --git a/plugins/minimax-ai/minimax-pet/ARTWORK.md b/plugins/minimax-ai/minimax-pet/ARTWORK.md new file mode 100644 index 0000000..889d90f --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/ARTWORK.md @@ -0,0 +1,13 @@ +# MiniMax Pet artwork + +The bundled `minimax-original` pet and `icon.png` are original geometric artwork created for this plugin. Copyright (c) 2026 MiniMax. They are distributed under the plugin's [MIT License](LICENSE). + +The editable source is `scripts/generate-pet-art.mjs` in `sources/minimax-ai/minimax-pet`. It draws the character and encodes its PNGs using Node built-ins, without loading external images, fonts, or models. To regenerate from that source: + +```sh +npm run generate:art +``` + +The generated atlas is 1536 × 2288 pixels: eight 192 × 208 cells per row, nine animation rows and two rows of look directions. It is compatible with the existing player, including idle, running, waving, jumping, failure, waiting, work and review poses. Tests compare decoded pixels with a fresh deterministic generation, so PNG compression differences between Node releases do not change the artwork contract. The source/distribution package check still compares every shipped file byte. + +No third-party character art or externally sourced pet spritesheet is included in the current package. User-imported artwork is not part of this distribution and keeps its own license. diff --git a/plugins/minimax-ai/minimax-pet/LICENSE b/plugins/minimax-ai/minimax-pet/LICENSE new file mode 100644 index 0000000..132dd20 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/minimax-ai/minimax-pet/PROVENANCE.md b/plugins/minimax-ai/minimax-pet/PROVENANCE.md new file mode 100644 index 0000000..6656ebe --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/PROVENANCE.md @@ -0,0 +1,36 @@ +# Source and distribution + +Author: MiniMax ([MiniMax-AI](https://github.com/MiniMax-AI)). This is the 0.5.8 open-source edition of the MiniMax Pet MiniApp. It uses public MiniApp page APIs and contains no Host implementation. Public documentation, dependency URLs, and license notices have been prepared for this repository; private development references and acceptance records are not part of the package. + +## Layout + +- `sources/minimax-ai/minimax-pet/`: complete editable source, synthetic tests, development tools, and the dependency lockfile. +- `plugins/minimax-ai/minimax-pet/`: self-contained ready-to-install files. + +Change the source first, stage the package, and copy the complete staged tree into the distribution directory. The installed plugin does not depend on the source directory or on `node_modules`. + +## Reproduce + +With Node.js 22 or later, from the source directory: + +```sh +npm ci --ignore-scripts +npm test +npm run test:smoke +npm run package +npm run check:package +``` + +`package` stages an explicit runtime/documentation allowlist under `dist/package`. Synchronize that complete directory to `plugins/minimax-ai/minimax-pet` before `check:package`. The comparison covers every path and file byte, including images, code, metadata and licenses, and rejects links. Tests, the development lockfile, dependencies, tools, and local state are not staged. + +The root `npm run check` validates package contracts through the repository's existing workflow. Run the source tests, HTTP smoke, staging and exact distribution comparison locally with the commands above; this package does not add or change repository CI jobs. Tests use synthetic data and temporary data directories; they do not need the user's real Host profile. + +## Dependencies and artwork + +Production JavaScript uses relative modules and Node built-ins. `jsdom` 24.1.3 is used only for DOM tests; the lockfile pins public npm registry URLs and integrity values. No dependency install or build step is required by the installed plugin. + +The bundled pet atlas and icon are original geometric artwork generated by `scripts/generate-pet-art.mjs` from Node built-ins. `npm run generate:art` reproduces them without external images or fonts. They use the same MiniMax [MIT License](LICENSE) as the original code and documentation; see [ARTWORK.md](ARTWORK.md). No third-party character art or spritesheet is bundled. + +## Verification boundaries + +See `README.md` for tested and unverified environments. Static validation and a loopback smoke test do not prove released-client API availability, GUI behavior on every platform, microphone recording, transcription, or official-market installation. diff --git a/plugins/minimax-ai/minimax-pet/README.md b/plugins/minimax-ai/minimax-pet/README.md new file mode 100644 index 0000000..2b47aed --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/README.md @@ -0,0 +1,61 @@ +# MiniMax Pet + +English | [简体中文](README.zh-CN.md) + +A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visible conversation activity, answer-needed reminders and recent results without creating another chat. The interface is currently in Chinese. + +## Features + +- **Appearance:** preview and select pets, play short interactions, import compatible pet JSON and spritesheets, and remove imported pets. +- **Tasks:** recent reminders, visible user conversations, and a scrollable latest reply. Desktop cards preview up to five lines. Clicking a card keeps the pet visible; observed `ask_user` calls show “需要你回答” (needs your answer). +- **Settings:** pet size, page-local reminder dismissal and mute, connection status, and reconnect. +- **Desktop window:** a draggable transparent pet with compact notification cards. Return it to the main panel to use the three tabs. +- **Original-chat draft:** explicitly open the draft tool, then ask the Host to confirm appending text to the chat that opened the pet. The user still sends it. Voice-to-text is available only when the Host provides it and microphone permission is granted. + +The pet does not run Agent turns, create independent chats, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; it does not navigate the Host to an arbitrary conversation. + +## Install and open + +1. Copy the entire `plugins/minimax-ai/minimax-pet/` directory, including `.minimax-plugin`, into `/plugins/minimax-pet/`. The default data directory is `~/.minimax`; use your configured directory if different. The `minimax-ai` author directory is not part of the installed path. +2. Restart MiniMax Code and confirm the plugin is enabled. +3. Open “MiniMax Pet” from the MiniApp list, or ask the Agent “打开 MiniMax Pet”. +4. Choose **放到桌面** to show the desktop pet. + +To update, close the app and exit the client before replacing the complete package. Keep backups outside the client's plugins directory. Imported pets and preferences are stored separately in the Host-assigned plugin data directory. + +Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` its own data directory; importing the new package does not automatically move an older plugin's pets or preferences. Keep any existing installation/data until you have exported what you need. + +## Tested environment + +- MiniMax Code **3.1.4 or later** is the repository's minimum installation requirement for this package's `session.read` declaration (upstream reads were observed on 3.1.4-preview). **This is not released-client GUI acceptance of this plugin or a guarantee of the observation and speech APIs below.** +- Task monitoring additionally requires a Host build exposing `agent.observe` and `agent.cancel`, alongside `agent.sessions` and `agent.session`. A minimum released version for complete task monitoring has not been established for this plugin. Missing APIs are reported as unsupported. +- Voice input additionally requires `deviceAccess: ["microphone"]`, `speech.transcribe`, and microphone permission; otherwise it remains unavailable. +- The preceding UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, draft confirmation, and desktop round trips. This open-source revision has Node/DOM tests and an isolated HTTP smoke test. +- The new original atlas and icon are checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The earlier native UI acceptance predates this artwork. +- No Windows GUI, released-client installation, or real microphone recording/transcription has been verified for this revision. Automated tests use synthetic data and do not prove those paths. + +## Data & access + +- **Conversation data:** requests only `session.read`. Reads visible user-conversation metadata, status and a selected conversation's latest reply through the public Host API. It does not read the Host database, logs, configuration, credentials or private files. Conversation state and reminders stay in page memory. +- **Observations:** one global observer aggregates visible task lifecycle events. Up to three detailed observers follow active tasks and detect new ask tool events. A completed/removed task releases its detailed slot; another task enters only after the previous subscription settles. The initial snapshot contains up to 50 recent visible conversations; observed tasks remain while the Host confirms visibility. +- **Coverage limits:** observers are tail-only. They cannot reconstruct questionnaires opened before subscribing or during a disconnect. More than three active tasks means only three receive detailed ask coverage. No observed ask does not prove no pending question. Historical idle tasks are not presented as newly completed. +- **Local files:** the Node service serves packaged assets and stores preferences and user-imported JSON/WebP/PNG spritesheets under opaque `context.dataDir`. Imports, replacement recovery and deletion are limited to that plugin data. Browser storage is used only to migrate old view preferences. +- **Drafts:** `message.append(text)` goes through Host confirmation and targets the original chat. Monitoring or selecting another task does not change that target or send a message. +- **Microphone and network:** recording is user-initiated in the draft tool. Audio stays in memory and is passed to Host `speech.transcribe`; leaving the tool cancels capture/transcription. The Host transcription service may use its configured network services. The plugin has no hard-coded external runtime endpoint; its own HTTP requests use its assigned loopback origin. +- **Processes and Agent turns:** the Host starts one Node process. The plugin spawns no additional runtime process, launches no shell command, offers no MCP server, and never calls `agent.run`. + +## Agent Skill + +The `minimax-pet` Skill opens the app and guides the current Agent through creating custom pet artwork. It includes exact v1/v2 dimensions, animation and look layouts, alpha and metadata requirements, and a self-contained Node PNG validation tool. Files stay in the user workspace until import is confirmed; this does not add automatic Agent runs to the MiniApp. + +## Bundled artwork + +The default is an original MiniMax Pet character, with all nine animation states and sixteen look directions. The atlas and icon can be regenerated with Node.js from the source directory. See [ARTWORK.md](ARTWORK.md). + +## Development + +The complete editable source is at `sources/minimax-ai/minimax-pet/`. The installable package is self-contained at `plugins/minimax-ai/minimax-pet/`. See [PROVENANCE.md](PROVENANCE.md) for build and byte-comparison steps; developer notes and tests stay outside the distributed runtime payload. + +## License + +Original code, documentation, the bundled pet and icon: [MIT](LICENSE), Copyright 2026 MiniMax. The default artwork is generated from editable geometric drawing code; see [ARTWORK.md](ARTWORK.md). No third-party pet artwork is bundled. User-imported artwork keeps its own license. diff --git a/plugins/minimax-ai/minimax-pet/README.zh-CN.md b/plugins/minimax-ai/minimax-pet/README.zh-CN.md new file mode 100644 index 0000000..4a650da --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/README.zh-CN.md @@ -0,0 +1,37 @@ +# MiniMax Pet + +[English](README.md) | 简体中文 + +作者:**MiniMax**。围绕主窗口可见会话提供轻量陪伴、任务状态与通知,界面提供“外观/任务/设置”三个页签。 + +## 使用 + +将仓库 `plugins/minimax-ai/minimax-pet/` 完整复制到 `/plugins/minimax-pet/`,包括隐藏的 `.minimax-plugin`;默认 `` 为 `~/.minimax`,自定义数据目录时采用实际配置。不要复制外层作者目录。重启 MiniMax Code、确认插件启用后,从 MiniApp 列表打开“MiniMax Pet”,或对 Agent 说“打开 MiniMax Pet”。点击“放到桌面”显示透明桌宠窗口。 + +外观页支持选择、互动、导入预览和删除已导入宠物;任务页显示提醒、全部可见任务和最近回复;设置页提供大小、提醒管理和重连。桌面通知最多预览五行,点击后可滚动读完整内容,桌宠保持可见。观察到 ask 时显示“需要你回答”。原属聊天草稿通过明确入口打开,经 Host 确认后追加到原聊天输入框,仍由用户发送。 + +旧的本地原型使用不同插件 ID。`minimax-pet` 会有独立的 Host 数据目录,安装本包不会自动搬迁旧插件的宠物和偏好;需要的素材先导出,保留旧安装和数据。 + +## 环境与兼容 + +本包声明 `session.read`,按仓库契约至少要求 MiniMax Code **3.1.4**;上游读取接口的已观察环境为 3.1.4-preview,这不代表本插件已完成正式客户端 GUI 验收,也不保证观察和语音接口可用。完整任务监控还要求 Host 提供 `agent.observe/cancel`,以及 `agent.sessions/session`,其最低正式发行版本尚未确认。缺少接口时显示不支持;语音还需要 `speech.transcribe`、麦克风声明和权限。 + +此前 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除、草稿确认和桌面切换;当前开源修订运行 Node/DOM 测试与隔离 HTTP smoke。新原创图集和图标通过 PNG 解码、动画帧覆盖与确定性重建检查;此前原生 UI 验收早于本次素材替换。Windows GUI、正式客户端安装、真实录音/ASR 未在当前修订中验收。 + +## 数据与能力边界 + +只申请 `session.read`,读取 Host 对本插件可见的用户会话状态和所选会话最新回复,不直接读取 Host 数据库、日志、配置或凭据。状态和提醒只在页面内存中,关闭或静音不修改 Host 的任务或未读事实。 + +全局观察聚合可见任务,初始快照至多 50 条;本连接已观察到、Host 仍确认可见的任务会继续保留。另三个详细观察名额监听活跃会话的 ask,任务结束或移除后取消,等待旧订阅真正结束再补位。详细观察是 tail-only,不能补回订阅前或断线期间的问卷;超过三个活跃任务时,只覆盖其中三个的详细事件。没有观察到 ask 不等于没有待答问卷。 + +偏好和用户导入素材写入独立的 `context.dataDir`;导入替换有中断恢复,删除限于本插件数据。不创建独立聊天、不逐会话点评、不自动调用模型、不发送或停止用户会话、不代答问卷,也不能跳转任意普通会话。 + +原属草稿调用 `message.append(text)`,选择其他任务不会改变草稿目标。语音只在用户进入草稿后明确开始;离开会取消录音或转写,音频仅在内存中交给 Host 的转写服务,该服务可能使用其配置的网络。插件本身没有硬编码的外部服务地址,只使用分配的 loopback 源,不启动额外子进程或 shell。 + +## Agent Skill + +随包的 `minimax-pet` Skill 既能打开插件,也能指导当前 Agent 制作、校验和导入自定义宠物。格式文档列出 v1/v2 的精确尺寸、逐帧布局、透明背景、视线方向和 `pet.json`,并附带无外部依赖的 PNG 校验脚本。生成文件先放在用户工作区,再通过界面确认导入;MiniApp 不自行启动模型。 + +## 开发与许可 + +保留源码/分发双目录,构建与完整字节对比步骤见 [PROVENANCE.md](PROVENANCE.md)。原创代码、文档、默认宠物和图标均按 [MIT](LICENSE) 开源,作者为 MiniMax。默认图集和图标由可编辑的几何绘图代码生成,见[原创素材说明](ARTWORK.md);当前包不含第三方宠物素材,用户自行导入的素材保留其各自许可。 diff --git a/plugins/minimax-ai/minimax-pet/icon.png b/plugins/minimax-ai/minimax-pet/icon.png new file mode 100644 index 0000000..46ffdfe Binary files /dev/null and b/plugins/minimax-ai/minimax-pet/icon.png differ diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/activity-reminders.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/activity-reminders.mjs new file mode 100644 index 0000000..cfdb719 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/activity-reminders.mjs @@ -0,0 +1,111 @@ +// Page-local reminders are independent of task facts and Host read/unread state. +const HOUR_MS = 60 * 60 * 1000; +const WEEK_MS = 7 * 24 * HOUR_MS; +const STATUSES = new Set(['idle', 'running', 'error', 'aborted']); +const compareId = (a, b) => a < b ? -1 : a > b ? 1 : 0; + +function identity(row) { + let parts; + try { parts = JSON.parse(row.animationKey); } catch { /* Older adapters need not expose an animation key. */ } + const valid = Array.isArray(parts) && parts.length >= 5 && parts[1] === row.sessionId; + return { + epoch: valid ? parts[0] : null, + turnId: valid && typeof parts[2] === 'string' && parts[2] ? parts[2] : null, + revision: Number.isSafeInteger(row.activityRevision) ? row.activityRevision : valid && Number.isSafeInteger(parts[3]) ? parts[3] : 0, + }; +} + +function normalize(row) { + if (!row || typeof row !== 'object' || typeof row.sessionId !== 'string' || !row.sessionId || !STATUSES.has(row.status) || !Number.isSafeInteger(row.updatedAtMs) || row.updatedAtMs < 0) return null; + const displayStatus = row.liveCompleted === true && row.status === 'idle' ? 'completed' : row.status; + return { ...row, displayStatus }; +} + +export function createReminderStore({ now = Date.now } = {}) { + const history = new Map(); + const dismissed = new Set(); + const muted = new Set(); + let visible = new Map(); + let sequence = 0; + + function newRound(previous, row, next) { + if (!previous) return true; + // A known new turn is the authoritative boundary. Revisions are local to the + // observer connection, so comparing them alone would replay after reconnect. + if (next.turnId && previous.turnId) return next.turnId !== previous.turnId; + if (row.displayStatus === 'running' && previous.status !== 'running') return true; + // A running snapshot can acquire its turn ID when the observer catches up. + // Treat that as the same round rather than reviving a dismissed reminder. + if (next.turnId) return false; + if (next.epoch !== previous.epoch) return false; + // A snapshot-only error has no turn ID. On a stable connection a later + // persisted timestamp is evidence of new activity; repeated 5s snapshots are + // identical. Across reconnect this is ambiguous and deliberately suppressed. + return row.displayStatus === 'error' && previous.status === 'error' + && !previous.turnId && row.updatedAtMs > previous.updatedAtMs; + } + + function getState() { + const timestamp = now(); + const items = []; + for (const [id, value] of visible) { + if (muted.has(id) || dismissed.has(value.reminderKey)) continue; + if (!['running', 'error', 'completed'].includes(value.row.displayStatus)) continue; + const expiresAtMs = value.row.displayStatus === 'running' ? null : value.row.updatedAtMs + (value.row.displayStatus === 'error' ? HOUR_MS : WEEK_MS); + if (expiresAtMs !== null && expiresAtMs <= timestamp) continue; + items.push({ ...value.row, reminderKey: value.reminderKey, expiresAtMs }); + } + items.sort((a, b) => Number(b.needsAnswer === true) - Number(a.needsAnswer === true) || b.updatedAtMs - a.updatedAtMs || compareId(a.sessionId, b.sessionId)); + const primary = items[0] ? { ...items[0] } : null; + return { + items, + primary, + // Status changes within a round can change pose; epoch/refresh changes + // cannot. Removing the primary has one stable idle animation identity. + animationKey: primary ? JSON.stringify([primary.reminderKey, primary.displayStatus, primary.needsAnswer === true]) : JSON.stringify(['idle']), + mutedIds: [...muted].sort(compareId), + }; + } + + return { + update(sessions) { + const nextVisible = new Map(); + for (const raw of Array.isArray(sessions) ? sessions : []) { + const row = normalize(raw); + if (!row) continue; + const next = identity(row); + const previous = history.get(row.sessionId); + const fresh = newRound(previous, row, next); + const reminderKey = fresh ? JSON.stringify([row.sessionId, ++sequence]) : previous.reminderKey; + const value = { + ...next, + // Keep a known turn across snapshot-only reconnects. Losing it would + // let a late observation of the same turn bypass a local dismissal. + turnId: next.turnId || (!fresh ? previous?.turnId : null), + status: row.displayStatus, + updatedAtMs: row.updatedAtMs, + reminderKey, + }; + history.set(row.sessionId, value); + nextVisible.set(row.sessionId, { row, reminderKey }); + } + visible = nextVisible; + return getState(); + }, + getState, + dismiss(sessionId) { + const current = visible.get(sessionId); + if (current) dismissed.add(current.reminderKey); + return getState(); + }, + dismissAll() { + for (const item of getState().items) dismissed.add(item.reminderKey); + return getState(); + }, + mute(sessionId) { + if (typeof sessionId === 'string' && sessionId) muted.add(sessionId); + return getState(); + }, + unmute(sessionId) { muted.delete(sessionId); return getState(); }, + }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/activity.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/activity.mjs new file mode 100644 index 0000000..53c91ef --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/activity.mjs @@ -0,0 +1,302 @@ +// Public Page Agent adapter. No Host activity projection, private IPC or inferred asks/unread state. +export const STATUS_ANIM = Object.freeze({ idle: 'idle', running: 'running', error: 'failed', aborted: 'idle', completed: 'review' }); +const RAW_STATUSES = new Set(['idle', 'running', 'error', 'aborted']); +const PRIORITY = ['error', 'completed', 'running', 'aborted', 'idle']; +const codeOf = (error) => error?.code || error?.message || 'runtime_unavailable'; +const invalid = () => Object.assign(new Error('invalid_request'), { code: 'invalid_request' }); +const displayStatus = (row) => row.liveCompleted ? 'completed' : row.status; +const emptyCounts = () => ({ total: 0, idle: 0, running: 0, error: 0, aborted: 0, completed: 0 }); +const idOf = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null; + +export function normalizeSession(raw, { detail = false } = {}) { + if (!raw || typeof raw !== 'object' || !idOf(raw.sessionId) || !RAW_STATUSES.has(raw.status) || !Number.isSafeInteger(raw.updatedAtMs) || raw.updatedAtMs < 0 || (raw.title !== undefined && typeof raw.title !== 'string')) throw invalid(); + const session = { sessionId: raw.sessionId, title: (raw.title || '未命名对话').slice(0, 1000), status: raw.status, updatedAtMs: raw.updatedAtMs }; + if (detail) { + if (typeof raw.error?.message === 'string') session.error = raw.error.message; + if (typeof raw.lastAssistantText === 'string') session.lastAssistantText = raw.lastAssistantText; + } + return session; +} + +export function createActivitySource({ agent, onChange, pollIntervalMs = 5000, now = Date.now, makeRequestId = () => `pet-observe-${globalThis.crypto.randomUUID()}` }) { + let generation = 0; + let sequence = 0; + let detailSequence = 0; + let inFlight = null; + let observer = null; + let started = false; + let disposed = false; + let timer = null; + let desiredId = null; + let rows = new Map(); + const activity = new Map(); + const removedAt = new Map(); + let snapshotIds = new Set(); + let state = { availability: 'connecting', sessions: [], originSession: null, selectedSessionId: null, detail: null, detailLoading: false, detailError: null, error: null }; + const releaseWaiters = new Set(); + const current = (epoch) => !disposed && generation === epoch; + const eventSequence = (id) => activity.get(id)?.eventSequence ?? 0; + const order = (a, b) => b.updatedAtMs - a.updatedAtMs || eventSequence(b.sessionId) - eventSequence(a.sessionId) || a.sessionId.localeCompare(b.sessionId); + + function meta(id) { + if (!activity.has(id)) activity.set(id, { revision: 0, eventSequence: 0, turnId: null, outcome: null, retiredTurns: [], retainOutsideList: false }); + return activity.get(id); + } + function withIdentity(row) { + const value = meta(row.sessionId); + return { ...row, observedTurnId: value.turnId, activityRevision: value.revision, animationKey: JSON.stringify([generation, row.sessionId, value.turnId, value.revision, displayStatus(row)]) }; + } + function aggregate(sessions) { + const counts = emptyCounts(); + for (const row of sessions) { counts.total++; counts[displayStatus(row)]++; } + const status = state.availability === 'ready' ? PRIORITY.find((name) => counts[name] > 0) ?? 'idle' : null; + const primarySession = status ? sessions.find((row) => displayStatus(row) === status) ?? null : null; + return { status, primarySession, counts, animationKey: primarySession?.animationKey ?? JSON.stringify([generation, status]) }; + } + function emit(patch) { + if (disposed) return; + state = { ...state, ...patch, sessions: [...rows.values()].sort(order).map(withIdentity) }; + const combined = aggregate(state.sessions); + onChange({ ...state, sessions: state.sessions.map((s) => ({ ...s })), originSession: state.originSession ? { ...state.originSession } : null, detail: state.detail ? { ...state.detail } : null, aggregate: { ...combined, counts: { ...combined.counts }, primarySession: combined.primarySession ? { ...combined.primarySession } : null } }); + } + function advanceGeneration() { + generation++; + for (const stop of [...releaseWaiters]) stop(); + return generation; + } + function cancelObserver() { + const previous = observer; + if (previous && (!previous.cancelled || previous.cancelFailed)) { + previous.cancelled = true; + previous.cancelFailed = false; + try { Promise.resolve(agent.cancel(previous.requestId)).catch(() => { previous.cancelFailed = true; }); } + catch { previous.cancelFailed = true; /* Keep its slot occupied until observe settles. */ } + } + return previous; + } + function waitForObserver(previous, epoch) { + return new Promise((resolve) => { + const finish = (released) => { releaseWaiters.delete(stop); resolve(released && current(epoch)); }; + const stop = () => finish(false); + releaseWaiters.add(stop); + previous.done.then(() => finish(true)); + }); + } + function clear(availability, error = null) { + rows.clear(); activity.clear(); removedAt.clear(); snapshotIds.clear(); detailSequence++; + emit({ availability, originSession: null, selectedSessionId: null, detail: null, detailLoading: false, detailError: null, error }); + } + function fail(error, epoch) { + if (!current(epoch)) return; + advanceGeneration(); + cancelObserver(); + clear(codeOf(error) === 'not_permitted' ? 'not_permitted' : 'unavailable', codeOf(error)); + } + function removeRow(id) { rows.delete(id); activity.delete(id); } + function mergeSnapshot(row) { + const previous = rows.get(row.sessionId); + const liveCompleted = row.status === 'idle' && previous?.liveCompleted === true; + if (previous && (previous.status !== row.status || previous.liveCompleted !== liveCompleted)) meta(row.sessionId).revision++; + return { ...row, liveCompleted }; + } + async function readDetail(epoch = generation) { + const id = state.selectedSessionId; + const ticket = ++detailSequence; + if (!id || !current(epoch)) return; + // A background refresh of the same visible task must not replace its long + // reply with a short loading placeholder, which collapses native scrollTop. + // Selection changes and visibility/error paths still clear the old detail. + const previous = rows.has(id) && state.detail?.sessionId === id ? state.detail : null; + emit({ detail: previous, detailLoading: true, detailError: null }); + try { + const raw = await agent.session(id); + if (!current(epoch) || ticket !== detailSequence || state.selectedSessionId !== id) return; + if (!raw) throw Object.assign(new Error('session_not_found'), { code: 'session_not_found' }); + const detail = normalizeSession(raw, { detail: true }); + if (detail.sessionId !== id) throw invalid(); + const row = rows.get(id); + if (!row) return; + emit({ detail: { ...detail, title: row.title, status: row.status, updatedAtMs: row.updatedAtMs }, detailLoading: false, detailError: null }); + } catch (error) { + if (!current(epoch) || ticket !== detailSequence) return; + if (codeOf(error) === 'session_not_found') { + removeRow(id); + removedAt.set(id, ++sequence); + if (desiredId === id) desiredId = null; + emit({ selectedSessionId: null, detail: null, detailLoading: false, detailError: 'session_not_found', ...(state.originSession?.sessionId === id ? { originSession: null } : {}) }); + } else if (['not_permitted', 'runtime_unavailable'].includes(codeOf(error))) fail(error, epoch); + else emit({ detail: null, detailLoading: false, detailError: codeOf(error) }); + } + } + function onEvent(event, epoch) { + if (!current(epoch) || !event || !idOf(event.sessionId)) return; + if (!['turn.started', 'turn.finished'].includes(event.type)) return; // Never retain message text from observer events. + if (event.type === 'turn.finished' && !['finished', 'error', 'aborted'].includes(event.status)) return; + const value = meta(event.sessionId); + const turnId = idOf(event.turnId); + const outcome = event.type === 'turn.started' ? 'running' : event.status; + if (turnId && value.retiredTurns.includes(turnId)) return; + if (turnId && value.turnId === turnId && value.outcome && value.outcome !== 'running') return; + // A delayed terminal event from another turn cannot finish the current running turn. + if (event.type === 'turn.finished' && turnId && value.turnId && value.turnId !== turnId && value.outcome === 'running') return; + if (value.turnId === turnId && value.outcome === outcome) return; + if (turnId !== value.turnId && value.turnId) { + value.retiredTurns.push(value.turnId); + if (value.retiredTurns.length > 8) value.retiredTurns.shift(); + } + value.turnId = turnId; + value.outcome = outcome; + value.revision++; + value.eventSequence = ++sequence; + value.retainOutsideList = true; + removedAt.delete(event.sessionId); + const previous = rows.get(event.sessionId); + const status = outcome === 'finished' ? 'idle' : outcome; + rows.set(event.sessionId, { sessionId: event.sessionId, title: typeof event.title === 'string' && event.title ? event.title.slice(0, 1000) : previous?.title || '未命名对话', status, updatedAtMs: now(), liveCompleted: outcome === 'finished' }); + emit({}); + if (state.selectedSessionId === event.sessionId) void readDetail(epoch); + } + function startObserver(epoch) { + const next = { requestId: makeRequestId(), cancelled: false, done: null }; + observer = next; + let subscription; + try { subscription = agent.observe({ all: true, requestId: next.requestId }, { onEvent: (event) => onEvent(event, epoch) }); } + catch (error) { if (observer === next) observer = null; fail(error, epoch); return false; } + const ended = (error) => { + if (observer === next) observer = null; + if (!next.cancelled) fail(error, epoch); + }; + next.done = Promise.resolve(subscription).then( + () => ended({ code: 'runtime_unavailable' }), + (error) => ended(error), + ); + return true; + } + // The SDK list is only the latest 50. Revalidate previously observed off-list roots with + // session(id), which checks current visibility/deletion. Never retain its reply text here. + async function readOutsideList(ids, epoch) { + const results = new Map(); + let cursor = 0; + async function worker() { + while (current(epoch) && cursor < ids.length) { + const id = ids[cursor++]; + const before = eventSequence(id); + try { + const raw = await agent.session(id); + if (!current(epoch)) return; + if (!raw) { results.set(id, { row: null, before }); continue; } + const row = normalizeSession(raw); + if (row.sessionId !== id) throw invalid(); + results.set(id, { row, before }); + } catch (error) { + if (!current(epoch)) return; + if (codeOf(error) === 'session_not_found') results.set(id, { row: null, before }); + else throw error; + } + } + } + await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker)); + return results; + } + async function readSnapshot(epoch) { + const before = sequence; + try { + const [result, originRaw] = await Promise.all([agent.sessions(), agent.session().catch((error) => { + if (codeOf(error) === 'session_not_found') return null; + throw error; + })]); + if (!current(epoch)) return; + if (!result || !Array.isArray(result.sessions)) throw invalid(); + const incoming = result.sessions.map((raw) => normalizeSession(raw)); + if (new Set(incoming.map((s) => s.sessionId)).size !== incoming.length) throw invalid(); + const origin = originRaw ? normalizeSession(originRaw) : null; + if (origin && !incoming.some((s) => s.sessionId === origin.sessionId)) incoming.push(origin); + const incomingIds = new Set(incoming.map((row) => row.sessionId)); + const outsideIds = [...rows.keys()].filter((id) => !incomingIds.has(id) && activity.get(id)?.retainOutsideList); + const outside = await readOutsideList(outsideIds, epoch); + if (!current(epoch)) return; + const next = new Map(); + for (const row of incoming) { + if ((removedAt.get(row.sessionId) ?? 0) > before) continue; + const previous = rows.get(row.sessionId); + next.set(row.sessionId, eventSequence(row.sessionId) > before && previous ? previous : mergeSnapshot(row)); + } + for (const [id, checked] of outside) { + if (!rows.has(id)) continue; + if (eventSequence(id) > checked.before) next.set(id, rows.get(id)); + else if (checked.row && activity.get(id)?.retainOutsideList) next.set(id, mergeSnapshot(checked.row)); + } + // Events that arrived after the list was requested may introduce entirely new roots. + for (const [id, row] of rows) if (eventSequence(id) > before && !incomingIds.has(id) && !outside.has(id)) next.set(id, row); + rows = next; + snapshotIds = incomingIds; + for (const id of activity.keys()) if (!rows.has(id)) activity.delete(id); + for (const [id, serial] of removedAt) if (serial <= before) removedAt.delete(id); + if (desiredId && !rows.has(desiredId)) desiredId = null; + const selected = desiredId ?? (origin && rows.has(origin.sessionId) ? origin.sessionId : null); + const matchingDetail = selected && state.detail?.sessionId === selected; + emit({ availability: 'ready', originSession: origin && rows.has(origin.sessionId) ? rows.get(origin.sessionId) : null, selectedSessionId: selected, error: null, + ...(!matchingDetail ? { detail: null, detailLoading: false, detailError: null } : {}) }); + if (selected) void readDetail(epoch); + else { detailSequence++; emit({ detail: null, detailLoading: false, detailError: null }); } + } catch (error) { fail(error, epoch); } + } + function refresh() { + if (disposed) return Promise.resolve(); + if (!agent || ['sessions', 'session', 'observe', 'cancel'].some((name) => typeof agent[name] !== 'function')) { + advanceGeneration(); cancelObserver(); clear('unavailable', 'unsupported'); return Promise.resolve(); + } + if (inFlight?.epoch === generation) { + // A later refresh can retry a failed cancel, while preserving the release barrier. + if (observer?.cancelled && observer.cancelFailed) cancelObserver(); + return inFlight.promise; + } + const reconnect = state.availability !== 'ready'; + let previous = null; + if (reconnect) { + advanceGeneration(); + previous = cancelObserver(); + clear('connecting'); + } + const epoch = generation; + const operation = { epoch, promise: null }; + operation.promise = (async () => { + // cancel() acknowledges a request, not release of the Host observer slot. + // Admit the global observer before publishing ready to detailed followers. + if (previous && !await waitForObserver(previous, epoch)) return; + if (!current(epoch) || (reconnect && !startObserver(epoch))) return; + // A synchronously failing observer must not be followed by a reviving snapshot. + if (!observer) return; + await readSnapshot(epoch); + })().finally(() => { if (inFlight === operation) inFlight = null; }); + inFlight = operation; + return operation.promise; + } + return { + start() { if (started || disposed) return inFlight?.promise ?? Promise.resolve(); started = true; emit({}); if (pollIntervalMs > 0) timer = setInterval(() => { void refresh(); }, pollIntervalMs); return refresh(); }, + refresh, + selectSession(id) { + if (state.availability !== 'ready' || !rows.has(id)) throw Object.assign(new Error('session_not_found'), { code: 'session_not_found' }); + desiredId = id; + emit({ selectedSessionId: id, detail: null, detailLoading: false, detailError: null }); + return readDetail(); + }, + clearCompleted() { + if (disposed) return; + for (const [id, row] of rows) { + if (!row.liveCompleted) continue; + const value = meta(id); + value.revision++; + value.retainOutsideList = false; + if (snapshotIds.has(id)) rows.set(id, { ...row, liveCompleted: false }); + else removeRow(id); + } + if (state.selectedSessionId && !rows.has(state.selectedSessionId)) { + desiredId = null; + detailSequence++; + emit({ selectedSessionId: null, detail: null, detailLoading: false, detailError: null }); + } else emit({}); + }, + dispose() { if (disposed) return; disposed = true; advanceGeneration(); detailSequence++; clearInterval(timer); cancelObserver(); rows.clear(); activity.clear(); removedAt.clear(); snapshotIds.clear(); }, + }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/ask-observer.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/ask-observer.mjs new file mode 100644 index 0000000..a4e7747 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/ask-observer.mjs @@ -0,0 +1,168 @@ +// Public single-session observers supply live tool lifecycle; all:true supplies none. +// This intentionally cannot recover an ask raised before this tail-only subscription. +let requestSequence = 0; +const idOf = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null; +const turnOf = (row) => idOf(row?.observedTurnId); + +export function createAskObserver({ agent, onChange, maxSubscriptions = 3, askDelayMs = 120 }) { + // The fourth Host observer slot belongs to the global lifecycle subscription. + const limit = Number.isSafeInteger(maxSubscriptions) ? Math.min(3, Math.max(0, maxSubscriptions)) : 3; + const delay = Number.isFinite(askDelayMs) ? Math.max(0, askDelayMs) : 120; + const watches = new Map(), retiring = new Set(), failed = new Map(); + let disposed = false, ready = false, running = new Map(), selectedId = null; + let queued = false, emitted = null; + + function getState() { + return { + needsAnswerIds: [...watches.values()].filter((watch) => watch.needsAnswer).map((watch) => watch.id), + watchingIds: [...watches.keys()], + runningCount: running.size, + }; + } + function notify() { + if (disposed || queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + if (disposed) return; + const state = getState(), key = JSON.stringify(state); + if (key === emitted) return; + emitted = key; + onChange?.(state); + }); + } + const current = (watch) => !disposed && ready && watches.get(watch.id) === watch; + function clearAsk(watch) { + clearTimeout(watch.timer); watch.timer = null; + watch.askCount = 0; watch.needsAnswer = false; + } + function enterTurn(watch, turnId) { + if (watch.turnId === turnId) return; + if (watch.turnId) { + watch.retiredTurns.add(watch.turnId); + if (watch.retiredTurns.size > 16) watch.retiredTurns.delete(watch.retiredTurns.values().next().value); + } + clearAsk(watch); watch.turnId = turnId; watch.terminal = false; + } + function retire(watch) { + watches.delete(watch.id); clearAsk(watch); retiring.add(watch); + // Admission of a replacement waits for the old observe promise to settle. + // cancel() is best effort and its acknowledgement need not free the Host slot yet. + try { Promise.resolve(agent.cancel(watch.requestId)).catch(() => {}); } catch { /* Keep the occupied slot fenced. */ } + } + function failedFor(row) { + const previous = failed.get(row.sessionId); + if (!previous) return false; + const turnId = turnOf(row); + return !turnId || previous.turnIds.has(turnId); + } + function reconcile() { + if (disposed || !ready) return; + const desired = []; + const add = (id) => { + const row = running.get(id); + if (row && desired.length < limit && !desired.includes(id) && !failedFor(row)) desired.push(id); + }; + // Never evict a question just because another session became selected. + for (const watch of watches.values()) if (watch.askCount > 0) add(watch.id); + add(selectedId); + for (const id of watches.keys()) add(id); + const recent = [...running.values()].sort((a, b) => (b.updatedAtMs || 0) - (a.updatedAtMs || 0) || a.sessionId.localeCompare(b.sessionId)); + for (const row of recent) add(row.sessionId); + for (const watch of [...watches.values()]) if (!desired.includes(watch.id)) retire(watch); + for (const id of desired) { + if (watches.has(id) || watches.size + retiring.size >= limit) continue; + start(running.get(id)); + } + notify(); + } + function start(row) { + const watch = { + id: row.sessionId, requestId: `pet-ask-observer-${Date.now().toString(36)}-${++requestSequence}`, + turnId: turnOf(row), retiredTurns: new Set(), terminal: false, + askCount: 0, needsAnswer: false, timer: null, + }; + watches.set(watch.id, watch); + const onEvent = (event) => { + if (!current(watch) || event?.sessionId !== watch.id) return; + const turnId = idOf(event.turnId); + if (!turnId || watch.retiredTurns.has(turnId)) return; + if (event.type === 'turn.started') { + if (watch.turnId === turnId && watch.terminal) return; + enterTurn(watch, turnId); notify(); return; + } + if (!['tool.started', 'tool.finished', 'turn.finished'].includes(event.type)) return; + if (!watch.turnId) enterTurn(watch, turnId); + if (watch.turnId !== turnId || watch.terminal) return; + if (event.type === 'turn.finished') { + watch.terminal = true; clearAsk(watch); notify(); return; + } + if (event.name !== 'ask_user') return; + if (event.type === 'tool.started') { + watch.askCount++; + if (watch.askCount === 1) { + watch.timer = setTimeout(() => { + watch.timer = null; + if (!current(watch) || !watch.askCount || watch.terminal) return; + watch.needsAnswer = true; notify(); + }, delay); + } + } else { + watch.askCount = Math.max(0, watch.askCount - 1); + if (!watch.askCount) clearAsk(watch); + } + notify(); + }; + let operation; + try { operation = agent.observe({ sessionId: watch.id, requestId: watch.requestId }, { onEvent }); } + catch (error) { operation = Promise.reject(error); } + void Promise.resolve(operation).then(() => ended(watch), () => ended(watch)); + } + function ended(watch) { + const wasCurrent = watches.get(watch.id) === watch; + retiring.delete(watch); + if (wasCurrent) { + watches.delete(watch.id); clearAsk(watch); + // Do not retry a failing stream on every 5s snapshot of the same turn. + const turnIds = new Set([watch.turnId, turnOf(running.get(watch.id))].filter(Boolean)); + failed.set(watch.id, { turnIds }); + } + if (!disposed) reconcile(); + } + + return { + update(snapshot = {}) { + if (disposed) return getState(); + const nextReady = snapshot.availability === 'ready' + && typeof agent?.observe === 'function' && typeof agent?.cancel === 'function'; + selectedId = idOf(snapshot.selectedSessionId); + running = new Map(); + if (nextReady) for (const row of Array.isArray(snapshot.sessions) ? snapshot.sessions : []) { + if (idOf(row?.sessionId) && row.status === 'running') running.set(row.sessionId, row); + } + if (!nextReady) { + ready = false; failed.clear(); + for (const watch of [...watches.values()]) retire(watch); + notify(); return getState(); + } + ready = true; + for (const [id, entry] of failed) { + const row = running.get(id), turnId = turnOf(row); + if (!row || (turnId && !entry.turnIds.has(turnId))) failed.delete(id); + } + for (const watch of [...watches.values()]) { + const row = running.get(watch.id); + if (!row) { retire(watch); continue; } + const turnId = turnOf(row); + if (turnId && !watch.retiredTurns.has(turnId)) enterTurn(watch, turnId); + } + reconcile(); return getState(); + }, + getState, + dispose() { + if (disposed) return; + disposed = true; ready = false; running.clear(); failed.clear(); + for (const watch of [...watches.values()]) retire(watch); + }, + }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs new file mode 100644 index 0000000..29c93ed --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs @@ -0,0 +1,50 @@ +// Original-chat drafts only. The observed task never changes the Host's bound destination. +const codeOf = (error) => error?.code || error?.message || 'result_unknown'; +export function createDraftController({ message, onChange = () => {} }) { + const drafts = new Map(); + let pending = false; + let disposed = false; + let origin = null; + const draft = (entry) => { if (!drafts.has(entry)) drafts.set(entry, { text: '', revision: 0 }); return drafts.get(entry); }; + const emit = () => { if (!disposed) onChange({ pending, origin: origin ? { ...origin } : null }); }; + return { + getState: () => ({ pending, origin: origin ? { ...origin } : null }), + getDraft: (entry) => ({ ...draft(entry) }), + setOrigin(value) { origin = value ? { sessionId: value.sessionId, title: value.title } : null; emit(); }, + edit(entry, text) { const current = draft(entry); current.text = String(text); current.revision++; }, + syncText(entry, text) { const current = draft(entry); if (current.text !== text) { current.text = String(text); current.revision++; } }, + appendText(entry, text) { const current = draft(entry); current.text += String(text); current.revision++; return current.text; }, + submit(entry) { + const fail = (code) => ({ submission: null, result: Promise.resolve({ ok: false, clear: false, code, submission: null, entry }) }); + if (disposed) return fail('target_unavailable'); + if (pending) return fail('busy'); + if (typeof message?.append !== 'function') return fail('unsupported'); + const current = draft(entry); + if (!current.text.trim()) return fail('empty'); + const submission = Object.freeze({ entry, text: current.text, title: origin?.title || '打开桌宠的原属对话', editRevision: current.revision }); + pending = true; + let request; + // No await and exactly one argument: this must remain in the real submit gesture. + try { request = message.append(submission.text); } catch (error) { request = Promise.reject(error); } + emit(); + const result = Promise.resolve(request).then( + () => { const value = draft(entry); const clear = !disposed && value.revision === submission.editRevision; if (clear) { value.text = ''; value.revision++; } return { ok: true, clear, submission, entry }; }, + (error) => ({ ok: false, clear: false, code: codeOf(error), submission, entry }), + ).finally(() => { pending = false; emit(); }); + return { submission, result }; + }, + dispose() { disposed = true; origin = null; drafts.clear(); }, + }; +} +export function bindDraftEntry({ controller, entry, form, input, onPending, onResult }) { + let active = true; + const onInput = () => controller.edit(entry, input.value); + const onSubmit = (event) => { + event.preventDefault(); controller.syncText(entry, input.value); + const attempt = controller.submit(entry); + if (attempt.submission) onPending?.(attempt.submission); + void attempt.result.then((result) => { if (!active) return; if (result.clear) input.value = controller.getDraft(entry).text; onResult(result); }); + }; + input.addEventListener('input', onInput); form.addEventListener('submit', onSubmit); + return () => { active = false; input.removeEventListener('input', onInput); form.removeEventListener('submit', onSubmit); }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs new file mode 100644 index 0000000..b0eecee --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs @@ -0,0 +1,167 @@ +const STATUS = { running: '进行中', error: '异常', completed: '已完成', aborted: '已停止', idle: '空闲', needs_answer: '需要你回答' }; +const textOf = (value) => typeof value === 'string' ? value : ''; +const failureCode = (error) => textOf(error?.code) || textOf(error?.message) || textOf(error) || 'result_unknown'; +// A completed live turn returns to raw idle. Only that observed completion +// promotes idle; newer running/error/aborted facts must retain their status. +export const taskStatus = (row) => row?.needsAnswer === true ? 'needs_answer' + : row?.status === 'idle' && row.liveCompleted === true ? 'completed' + : textOf(row?.status) || textOf(row?.displayStatus); + +/** Task notifications and their complete replies stay beside the pet. */ +export function createCompactPetView({ + container, onClose, onSelectTask, +}) { + if (!container?.ownerDocument) throw new TypeError('A compact pet container is required.'); + const doc = container.ownerDocument; + const hadRootClass = container.classList.contains('compact-pet-card'); + container.classList.add('compact-pet-card'); + let disposed = false, opened = false, view = 'tasks', reminders = { items: [] }; + let allSessions = null, taskSelection = {}, detailBackView = 'tasks'; + let paintedDetailId = null; + const rows = new Map(); + const setText = (node, value) => { const next = String(value ?? ''); if (node.textContent !== next) node.textContent = next; }; + const element = (tag, className, text) => { + const node = doc.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + const callbacks = []; + const bind = (node, event, listener) => { node.addEventListener(event, listener); callbacks.push(() => node.removeEventListener(event, listener)); }; + const button = (label, className, handler) => { + const node = element('button', `compact-button ${className || ''}`.trim(), label); + node.type = 'button'; bind(node, 'click', handler); + return node; + }; + const selection = () => { + const id = textOf(taskSelection.selectedSessionId); + return { id, + session: id && taskSelection.session?.sessionId === id ? taskSelection.session : null, + detail: id && taskSelection.detail?.sessionId === id ? taskSelection.detail : null, + available: taskSelection.availability === 'ready', + }; + }; + const call = async (callback, ...args) => { + if (disposed) return { ok: false, code: 'disposed' }; + try { return await callback?.(...args); } + catch (error) { return { ok: false, code: failureCode(error) }; } + }; + + const header = element('div', 'compact-header'); + const state = element('span', 'compact-state'); state.setAttribute('role', 'status'); + const closeButton = button('×', 'compact-close', () => { + if (disposed) return; + api.close(); void call(onClose); + }); + closeButton.setAttribute('aria-label', '关闭桌宠快捷面板'); + header.append(state, closeButton); + + const tasksPane = element('div', 'compact-tasks compact-body'); + const taskList = element('ul', 'compact-task-list'); + const empty = element('li', 'compact-empty', '暂无新任务提醒'); + const tasksFooter = element('div', 'compact-footer'); + const allTasks = button('全部任务', 'compact-link', () => api.open('allTasks')); + tasksFooter.append(allTasks); tasksPane.append(taskList, tasksFooter); + + const detailPane = element('div', 'compact-detail compact-body'); + const detailTitle = element('strong', 'compact-detail-title'); + const detailStatus = element('span', 'compact-detail-status'); detailStatus.setAttribute('role', 'status'); + const detailReply = element('p', 'compact-detail-reply'); + detailReply.setAttribute('aria-label', '任务最近回复'); detailReply.tabIndex = 0; + const detailFooter = element('div', 'compact-footer'); + const back = button('返回任务', 'compact-detail-back compact-link', () => api.open(detailBackView)); + detailFooter.append(back); detailPane.append(detailTitle, detailStatus, detailReply, detailFooter); + container.append(header, tasksPane, detailPane); + + function paintTasks() { + const reminderItems = Array.isArray(reminders?.items) ? reminders.items : Array.isArray(reminders) ? reminders : []; + const items = view === 'allTasks' && Array.isArray(allSessions) ? allSessions : reminderItems; + const visible = [], seen = new Set(); + for (const item of items) { + if (!textOf(item?.sessionId) || seen.has(item.sessionId)) continue; + seen.add(item.sessionId); visible.push(item); + if (view !== 'allTasks' && visible.length === 3) break; + } + const keep = new Set(visible.map((item) => item.sessionId)); + for (const [id, row] of rows) if (!keep.has(id)) { row.node.remove(); rows.delete(id); } + if (visible.length) empty.remove(); + for (const [index, item] of visible.entries()) { + let row = rows.get(item.sessionId); + if (!row) { + row = { item, node: element('li', 'compact-task') }; + row.title = button('', 'compact-task-title', () => { void call(onSelectTask, row.item.sessionId); }); + row.status = element('span', 'compact-task-status'); + row.preview = element('p', 'compact-task-preview'); + row.node.append(row.title, row.status, row.preview); rows.set(item.sessionId, row); + } + row.item = item; + setText(row.title, textOf(item.title) || '未命名任务'); + row.title.setAttribute('aria-label', `查看任务:${textOf(item.title) || '未命名任务'}`); + setText(row.preview, item.needsAnswer === true ? '需要你回答' : textOf(item.previewText)); row.preview.hidden = !row.preview.textContent; + const status = taskStatus(item); + setText(row.status, STATUS[status] || '待查看'); row.status.dataset.status = status; + row.title.disabled = typeof onSelectTask !== 'function'; + if (taskList.children[index] !== row.node) taskList.insertBefore(row.node, taskList.children[index] || null); + } + setText(empty, view === 'allTasks' ? '暂无可见任务' : '暂无新任务提醒'); + allTasks.hidden = view === 'allTasks'; + if (!visible.length && empty.parentNode !== taskList) taskList.append(empty); + } + + function paintDetail() { + const { id, session, detail, available } = selection(); + const needsAnswer = available && session?.needsAnswer === true; + const status = taskStatus(session) || taskStatus(detail); + setText(detailTitle, textOf(session?.title) || textOf(detail?.title) || '任务回复'); + setText(detailStatus, !available ? '暂不可用' : STATUS[status] || '待查看'); + detailStatus.dataset.status = !available ? 'unavailable' : status; + let text; + if (!available) text = '任务信息暂不可用,重新连接后继续查看。'; + else if (needsAnswer) text = '需要你回答'; + // A background refresh keeps the matching reply mounted; a short loading + // placeholder would shrink its scroll range and reset a reader's position. + else if (taskSelection.detailLoading && !detail) text = '正在读取任务…'; + else if (taskSelection.detailError) text = `无法读取任务(${failureCode(taskSelection.detailError)})。`; + else if (!id) text = '选择任务后查看回复。'; + else if (!detail) text = '正在读取任务…'; + else text = (detail.status === 'error' && textOf(detail.error)) || textOf(detail.lastAssistantText) || '该任务暂时没有可读取的最后回复。'; + setText(detailReply, text); + detailPane.setAttribute('aria-busy', String(available && !needsAnswer && taskSelection.detailLoading === true)); + if (paintedDetailId !== id) { detailReply.scrollTop = 0; detailPane.scrollTop = 0; paintedDetailId = id; } + } + + function paint() { + if (disposed) return; + container.hidden = !opened; + tasksPane.hidden = !['tasks', 'allTasks'].includes(view); detailPane.hidden = view !== 'detail'; + setText(state, view === 'tasks' ? '最近任务' : view === 'allTasks' ? '全部可见任务' : '任务回复'); + paintTasks(); paintDetail(); + } + + const api = { + get opened() { return opened && !disposed; }, + get view() { return view; }, + update(next = {}) { + if (disposed) return; + reminders = next.reminders || { items: [] }; + allSessions = Array.isArray(next.allSessions) ? next.allSessions : null; + taskSelection = next.taskSelection || {}; + paint(); + }, + open(next = 'tasks') { + if (disposed || !['tasks', 'allTasks', 'detail'].includes(next)) return false; + if (next === 'detail' && ['tasks', 'allTasks'].includes(view)) detailBackView = view; + view = next; opened = true; paint(); return true; + }, + close() { if (!disposed) { opened = false; paint(); } }, + dispose() { + if (disposed) return; + disposed = true; opened = false; + for (const unbind of callbacks) unbind(); + header.remove(); tasksPane.remove(); detailPane.remove(); rows.clear(); + container.hidden = true; + if (!hadRootClass) container.classList.remove('compact-pet-card'); + }, + }; + paint(); return api; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/index.html b/plugins/minimax-ai/minimax-pet/miniapp/client/index.html new file mode 100644 index 0000000..3486d6a --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/index.html @@ -0,0 +1,240 @@ + + + + + + + MiniMax Pet + + + + +
+
+
+

MiniMax Pet

+

陪伴工作,留意任务的新动静。

+
+
+ +

+
+
+ + + + + + +
+
+
+
+

动画预览

+
+ +
+

正在加载宠物…

+

+

+
+ +
+ 更多 +
+
+
+
+ +
+ + +
+ + + + +
+
+ + + + diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/notification-previews.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/notification-previews.mjs new file mode 100644 index 0000000..adf4424 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/notification-previews.mjs @@ -0,0 +1,64 @@ +// Notification copy is an optional, bounded detail read; activity facts stay in the observer. +export function createNotificationPreviews({ agent, onChange = () => {} }) { + let entries = new Map(); + let disposed = false; + let notificationPending = false; + + function getState() { + return { previews: Object.fromEntries([...entries].filter(([, entry]) => entry.text).map(([id, entry]) => [id, entry.text])) }; + } + function notify() { + if (notificationPending || disposed) return; + notificationPending = true; + queueMicrotask(() => { + notificationPending = false; + if (!disposed) onChange(getState()); + }); + } + function snippet(raw, status) { + const reply = typeof raw.lastAssistantText === 'string' ? raw.lastAssistantText : ''; + const error = typeof raw.error?.message === 'string' ? raw.error.message : ''; + // An errored turn may still expose the preceding turn's reply. Prefer its actual error. + const text = status === 'error' ? (error.trim() ? error : reply) : reply; + return text.trim() ? text.slice(0, 4000) : ''; + } + function load(id, entry) { + Promise.resolve().then(async () => { + if (disposed || entries.get(id) !== entry) return; + const raw = await agent.session(id); + if (disposed || entries.get(id) !== entry || raw?.sessionId !== id) return; + const text = snippet(raw, entry.status); + if (text && text !== entry.text) { entry.text = text; notify(); } + }).catch(() => { /* A failed optional read must not change the task's observed state. */ }); + } + function update({ availability, items } = {}) { + if (disposed) return; + const previous = getState().previews; + const next = new Map(); + const pending = []; + if (availability === 'ready' && typeof agent?.session === 'function' && Array.isArray(items)) { + for (const item of items.slice(0, 3)) { + const id = item?.sessionId; + const status = item?.displayStatus ?? (item?.status === 'error' ? 'error' : null); + if (typeof id !== 'string' || !id || id.length > 256 || !['completed', 'error'].includes(status) || next.has(id)) continue; + const key = item.reminderKey ?? item.animationKey; + // The reminder controller supplies a per-turn identity. Missing identity must not + // accidentally reuse a preceding turn's reply or turn polling into repeated reads. + if (typeof key !== 'string' || !key) continue; + const old = entries.get(id); + const entry = old?.key === key && old.status === status ? old : { key, status, text: '' }; + next.set(id, entry); + if (entry !== old) pending.push([id, entry]); + } + } + entries = next; + const current = getState().previews; + if (Object.keys(previous).length !== Object.keys(current).length || Object.keys(previous).some((id) => previous[id] !== current[id])) notify(); + for (const [id, entry] of pending) load(id, entry); + } + return { + update, + getState, + dispose() { if (disposed) return; disposed = true; entries.clear(); }, + }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs new file mode 100644 index 0000000..14b8725 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs @@ -0,0 +1,127 @@ +const TABS = ['appearance', 'tasks', 'settings']; +const FILTERS = ['recent', 'all']; +const CONNECTION = { ready: '已连接', connecting: '连接中', not_permitted: '未授权', unavailable: '连接中断' }; + +/** Owns the inline panel only; task updates never navigate or replace its content. */ +export function createPanelNavigation({ panel, beforeCloseImport = () => true, onLeaveDraft = () => {} }) { + if (!panel?.ownerDocument) throw new TypeError('A panel element is required.'); + const find = (selector) => panel.querySelector(selector); + const tabs = new Map(TABS.map((name) => [name, find(`[data-panel-tab="${name}"]`)])); + const pages = new Map(TABS.map((name) => [name, find(`[data-panel-page="${name}"]`)])); + if ([...tabs.values(), ...pages.values()].some((node) => !node)) throw new TypeError('Three panel tabs and pages are required.'); + let state = { tab: 'appearance', appearanceView: 'pets', settingsView: 'main', taskFilter: 'recent', taskDetail: false }; + let disposed = false; + let taskReturnFocus = null, taskReturnId = null; + const listeners = []; + const listen = (element, event, callback) => { + if (!element) return; + element.addEventListener(event, callback); + listeners.push(() => element.removeEventListener(event, callback)); + }; + const hidden = (selector, value) => { const node = find(selector); if (node) node.hidden = value; }; + const focus = (selector) => find(selector)?.focus({ preventScroll: true }); + function paint() { + for (const name of TABS) { + tabs.get(name).setAttribute('aria-selected', String(name === state.tab)); + tabs.get(name).tabIndex = name === state.tab ? 0 : -1; + pages.get(name).hidden = name !== state.tab; + } + hidden('#appearance-main', state.appearanceView !== 'pets'); + hidden('#import-panel', state.appearanceView !== 'import'); + hidden('#settings-main', state.settingsView !== 'main'); + hidden('#draft-card', state.settingsView !== 'draft'); + hidden('#recent-tasks', state.taskFilter !== 'recent'); + hidden('#all-sessions', state.taskFilter !== 'all'); + for (const filter of FILTERS) find(`#task-filter-${filter}`)?.setAttribute('aria-pressed', String(state.taskFilter === filter)); + panel.dataset.taskDetail = state.taskDetail ? 'open' : 'closed'; + } + function leaveDraft() { + if (state.settingsView !== 'draft') return; + state.settingsView = 'main'; + onLeaveDraft(); + } + function showTab(tab, { focus: moveFocus = false } = {}) { + if (disposed || !TABS.includes(tab)) return false; + if (tab !== 'settings') leaveDraft(); + state.tab = tab; + paint(); + if (moveFocus) tabs.get(tab).focus({ preventScroll: true }); + return true; + } + const api = { + showTab, + showImport() { + if (disposed) return false; + showTab('appearance'); state.appearanceView = 'import'; paint(); focus('#import-pick'); return true; + }, + closeImport() { + if (disposed || state.appearanceView !== 'import' || beforeCloseImport() === false) return false; + state.appearanceView = 'pets'; paint(); focus('#open-import'); return true; + }, + completeImport() { + if (disposed) return false; + state.appearanceView = 'pets'; paint(); return true; + }, + showDraft() { + if (disposed) return false; + showTab('settings'); state.settingsView = 'draft'; paint(); focus('#talk-text'); return true; + }, + showTaskDetail() { + if (disposed) return false; + const active = panel.ownerDocument.activeElement; + if (find('#task-list-pane')?.contains(active)) { + taskReturnFocus = active; + taskReturnId = active.closest('[data-session-id]')?.dataset.sessionId || null; + } + showTab('tasks'); state.taskDetail = true; paint(); focus('#detail-title'); return true; + }, + showTaskList() { + if (disposed) return false; + state.taskDetail = false; paint(); + const target = taskReturnFocus?.isConnected ? taskReturnFocus : taskReturnId + ? [...panel.querySelectorAll('#task-list-pane [data-session-id]')].find((node) => node.dataset.sessionId === taskReturnId) : null; + if (target) (target.matches('button') ? target : target.querySelector('button'))?.focus({ preventScroll: true }); + else focus(`#task-filter-${state.taskFilter}`); + return true; + }, + setTaskFilter(filter) { + if (disposed || !FILTERS.includes(filter)) return false; + state.taskFilter = filter; state.taskDetail = false; paint(); return true; + }, + update({ reminderCount = 0, availability = 'unavailable' } = {}) { + if (disposed) return; + const count = Number.isSafeInteger(reminderCount) && reminderCount > 0 ? reminderCount : 0; + const badge = find('#panel-task-count'); + if (badge) { badge.hidden = count === 0; badge.textContent = count > 99 ? '99+' : String(count); } + const connection = find('#connection-status'); + if (connection) { + connection.textContent = CONNECTION[availability] || CONNECTION.unavailable; + connection.dataset.status = availability in CONNECTION ? availability : 'unavailable'; + } + }, + getState: () => ({ ...state }), + dispose() { + if (disposed) return; + disposed = true; + for (const remove of listeners.splice(0)) remove(); + }, + }; + for (const [name, tab] of tabs) { + listen(tab, 'click', () => showTab(name)); + listen(tab, 'keydown', (event) => { + const index = TABS.indexOf(name); + const next = event.key === 'ArrowRight' ? TABS[(index + 1) % TABS.length] + : event.key === 'ArrowLeft' ? TABS[(index + TABS.length - 1) % TABS.length] + : event.key === 'Home' ? TABS[0] : event.key === 'End' ? TABS.at(-1) : null; + if (next) { event.preventDefault(); showTab(next, { focus: true }); } + }); + } + listen(find('#open-import'), 'click', api.showImport); + listen(find('#import-back'), 'click', api.closeImport); + listen(find('#open-draft'), 'click', api.showDraft); + listen(find('#draft-back'), 'click', () => { if (!disposed) { leaveDraft(); paint(); focus('#open-draft'); } }); + listen(find('#task-detail-back'), 'click', api.showTaskList); + for (const filter of FILTERS) listen(find(`#task-filter-${filter}`), 'click', () => api.setTaskFilter(filter)); + paint(); + return api; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js new file mode 100644 index 0000000..a334227 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -0,0 +1,2310 @@ +// Desktop pet page: a three-tab inline panel and a compact desktop companion. +import { STATUS_ANIM, createActivitySource } from './activity.mjs'; +import { DEFAULT_PREFERENCES, loadPreferences, preferenceRequest } from './preferences.mjs'; +import { bindDraftEntry, createDraftController } from './chat-draft.mjs'; +import { createSpeechInput } from './speech-input.mjs'; +import { restoreWindowPosition, fitWindowToScreen } from './window-position.mjs'; +import { aggregateText, notificationItems, notificationText, createAnimationGate } from './pet-presentation.mjs'; +import { createReminderStore } from './activity-reminders.mjs'; +import { createCompactPetView, taskStatus } from './compact-pet-view.mjs'; +import { createWorkbenchWindow } from './workbench-window.mjs'; +import { createNotificationPreviews } from './notification-previews.mjs'; +import { createAskObserver } from './ask-observer.mjs'; +import { createPanelNavigation } from './panel-navigation.mjs'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STATUS_TEXT = { idle: '', running: '正在工作…', error: '任务出现异常', aborted: '任务已停止', completed: '任务已完成' }; +const STATUS_LABEL = { idle: '空闲', running: '工作中', error: '异常', aborted: '已停止', completed: '已完成' }; +const DEMO_ORDER = ['idle', 'running', 'completed', 'error', 'aborted']; +const SCALES = [ + { value: 0.5, label: '小' }, + { value: 0.75, label: '中' }, + { value: 1, label: '大' }, +]; +const ID_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const IMPORT_BODY_LIMIT = 12 * 1024 * 1024; +const THUMB_SCALE = 0.375; +const DRAG_THRESHOLD = 4; +const DIRECTION_HYSTERESIS = 12; +const LOOK_DEAD_ZONE = 1; + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +const $ = (id) => document.getElementById(id); +const root = document.documentElement; + +function errCode(error) { + if (error && typeof error === 'object') return error.code || error.message || 'unknown_error'; + return String(error ?? 'unknown_error'); +} +function logError(tag) { + return (error) => console.error(`[pet] ${tag}:`, errCode(error), error); +} +function setMsg(el, text, kind = 'info') { + el.textContent = text || ''; + el.dataset.kind = kind; +} +function pointInRect(rect, x, y) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} +function basename(path) { + return String(path).split(/[\\/]/).pop(); +} +function callBridge(fn) { + // Bridge calls must start synchronously inside the gesture handler; this only normalizes throws. + try { + return Promise.resolve(fn()); + } catch (error) { + return Promise.reject(error); + } +} + +// --------------------------------------------------------------------------- +// Public Mini App bridge. Resource preview must be explicitly requested. +const params = new URLSearchParams(location.search); +const host = window.mcode?.miniapp ?? null; +const win = host?.window ?? null; +const agent = host?.agent ?? null; +const canAppend = typeof host?.message?.append === 'function'; +const explicitDemo = params.get('demo') === '1'; +const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const prefs = { ...DEFAULT_PREFERENCES }; +let preferenceWrites = Promise.resolve(); +let preferenceWriteVersion = 0; +let activitySource = null; +let disposed = false; +let panelNavigation = null; +let panelDetailId = null; + +const app = { + mode: 'standalone', // 'standalone' | 'inline' | 'window' + modeSwitching: false, + pets: [], + spriteUrls: new Map(), + pet: null, // LoadedPet from the player module + petSummary: null, + loadError: null, + selectToken: 0, + source: explicitDemo ? 'demo' : 'real', + availability: explicitDemo ? 'ready' : 'connecting', + aggregate: null, + hovered: false, + workbenchOpen: false, + workbenchView: 'tasks', + workbenchRevision: 0, + sessionsError: null, + sessions: [], + rawSessions: [], + originSession: null, + selectedSessionId: null, + detail: null, + detailLoading: false, + detailError: null, + restoringPosition: false, + awaitingPointer: false, + positionRestoreToken: 0, + positionTimer: null, + demoStatus: 'idle', + autoTimer: null, + // window mode + winPos: null, + winBounds: null, + compacting: false, + clickThrough: null, // last value we believe the Host has; null = unknown + ctRetryAt: 0, + // Forwarded-mousemove watchdog: 'unknown' | 'verified' | 'broken'. Until a forwarded move is seen + // with click-through applied, never stay click-through long enough to strand an unclickable pet. + ctForwarding: 'unknown', + ctAppliedAt: 0, + ctWatchdog: null, + ctSafeHold: false, + ctRevertedAt: 0, + ctRevertPointer: null, + ctFailures: 0, + pointer: null, + drag: null, + dragging: false, + menuOpen: false, + menuView: 'root', + menuAnchor: { x: 0, y: 0 }, + menuCloseTimer: null, + composerOpen: false, + composerFocused: false, + quickbarTimer: null, + transient: null, + transientTimer: null, + bubbleSessionId: null, + oneShot: false, + oneShotToken: 0, + looking: false, +}; + +const importState = { + petJson: null, + file: null, + blobUrl: null, + loaded: null, + player: null, + cycleTimer: null, + errors: [], + busy: false, + generation: 0, +}; + +let scaleIntent = 0; +let scaleUpdates = Promise.resolve(); + +const disposers = []; +const menuItemByButton = new WeakMap(); +function listen(target, type, listener, options) { + target.addEventListener(type, listener, options); + disposers.push(() => target.removeEventListener(type, listener, options)); +} + +const els = { + panel: $('panel'), + petName: $('pet-name'), + petDesc: $('pet-desc'), + versionBadge: $('version-badge'), + sourceBadge: $('source-badge'), + popoutBtn: $('popout-btn'), + popoutMsg: $('popout-msg'), + hostNote: $('host-note'), + loadError: $('load-error'), + panelStage: $('panel-stage'), + sprite: $('pet-sprite'), + panelStatus: $('panel-status'), + waveBtn: $('wave-btn'), + jumpBtn: $('jump-btn'), + activityNote: $('activity-note'), + taskConnection: $('task-connection'), + aggregateCounts: $('aggregate-counts'), + clearCompleted: $('clear-completed'), + speechSection: $('speech-section'), + draftSpeech: $('draft-speech'), + reminderList: $('reminder-list'), + reminderNote: $('reminder-note'), + mutedReminders: $('muted-reminders'), + mutedList: $('muted-list'), + workbench: $('workbench'), + activityCard: $('activity-card'), + detailCard: $('detail-card'), + draftCard: $('draft-card'), + demoControls: $('demo-controls'), + demoStatus: $('demo-status'), + sessionList: $('session-list'), + sessionMsg: $('session-msg'), + detailTitle: $('detail-title'), + detailStatus: $('detail-status'), + detailTime: $('detail-time'), + detailError: $('detail-error'), + detailReply: $('detail-reply'), + detailNote: $('detail-note'), + speechControls: $('speech-controls'), + speechStart: $('speech-start'), + speechStop: $('speech-stop'), + speechCancel: $('speech-cancel'), + speechMsg: $('speech-msg'), + speechPartial: $('speech-partial'), + activityRetry: $('activity-retry'), + preferenceMsg: $('preference-msg'), + talkForm: $('talk-form'), + talkText: $('talk-text'), + talkBtn: $('talk-btn'), + talkMsg: $('talk-msg'), + scaleGroup: $('scale-group'), + gallery: $('gallery'), + galleryMsg: $('gallery-msg'), + dropzone: $('dropzone'), + importPick: $('import-pick'), + importBack: $('import-back'), + importInput: $('import-input'), + importPreview: $('import-preview'), + importSprite: $('import-sprite'), + importName: $('import-name'), + importMeta: $('import-meta'), + importStateText: $('import-state'), + importErrors: $('import-errors'), + importCancel: $('import-cancel'), + importConfirm: $('import-confirm'), + importMsg: $('import-msg'), + desk: $('desk'), + deskPetSlot: $('desk-pet-slot'), + bubble: $('bubble'), + bubbleTitle: $('bubble-title'), + bubbleText: $('bubble-text'), + notificationStack: $('notification-stack'), + quickbar: $('quickbar'), + quickTasks: $('quick-tasks'), + quickMenu: $('quick-menu'), + composer: $('composer'), + composerInput: $('composer-input'), + composerSend: $('composer-send'), + composerClose: $('composer-close'), + composerMsg: $('composer-msg'), + draftOrigin: $('draft-origin'), + composerOrigin: $('composer-origin'), + menu: $('menu'), +}; +const draftController = createDraftController({ message: host?.message, onChange: renderDraftControls }); +let speechInput = null; +let speechState = 'idle'; +let animationGate = null; +let workbenchView = null; +let workbenchWindow = null; +const reminders = createReminderStore(); +const compactBubbleSize = { width: 300, height: 340 }; +let notificationPreviews = null; +let askObserver = null; + +// Player module is loaded dynamically so a missing/broken module surfaces in the UI instead of a blank page. +let STATES = []; +let SpritePlayer = null; +let loadPet = null; +let loadPetFromParts = null; +let player = null; + +// --------------------------------------------------------------------------- +// Activity (REAL sessions bridge or DEMO) +// --------------------------------------------------------------------------- + +function activity() { + if (app.source === 'real') { + if (app.availability !== 'ready') return { status: null, session: null, key: `unavailable:${app.availability}` }; + const notice = reminders.getState(); + return { status: notice.primary?.displayStatus ?? 'idle', session: notice.primary, key: notice.animationKey }; + } + return { status: app.demoStatus, session: null, key: `demo:${app.demoStatus}` }; +} + +function activityAnim() { + return activity().session?.needsAnswer ? 'waiting' : STATUS_ANIM[activity().status] ?? 'idle'; +} + +function desiredAnimation() { + if (activity().session?.needsAnswer) return { state: 'waiting', key: activity().key }; + const base = { state: activityAnim(), key: activity().key }; + if (app.hovered && app.mode === 'window' && !app.menuOpen && !app.composerOpen && !app.workbenchOpen) return { state: 'jumping', key: 'hover' }; + return base; +} + +function availabilityText() { + if (app.availability === 'not_permitted') return '请先在插件详情中允许读取会话,再重试。'; + if (app.availability === 'connecting') return '正在连接本机任务…'; + return `暂时无法读取本机任务${app.sessionsError ? `(${app.sessionsError})` : ''}。可重新连接。`; +} + +function applyActivity() { + renderActivityUI(); + renderBubble(); + renderMenuIfOpen(); + if (!app.pet || app.oneShot || app.dragging) return; + const desired = desiredAnimation(); + if (!['idle', 'running', 'waving'].includes(desired.state)) stopLook(); + animationGate?.apply(desired.state, desired.key, { loop: app.source === 'demo' }); +} + +function playOneShot(state) { + if (!app.pet) return; + stopLook(); + const token = ++app.oneShotToken; + app.oneShot = true; + player.playTransient(state, { then: activityAnim(), repeat: 3 }).then(() => { + if (disposed || token !== app.oneShotToken) return; + app.oneShot = false; + animationGate?.invalidate(); + applyActivity(); + }, logError('play_one_shot')); +} + +async function initActivitySource() { + if (explicitDemo) return; + askObserver = createAskObserver({ agent, maxSubscriptions: 3, onChange: () => { + if (disposed) return; + syncObservedAsks(); + applyActivity(); + } }); + disposers.push(() => askObserver.dispose()); + activitySource = createActivitySource({ + agent, + onChange(snapshot) { + Object.assign(app, { + availability: snapshot.availability, sessionsError: snapshot.error, + rawSessions: snapshot.sessions, originSession: snapshot.originSession, aggregate: snapshot.aggregate, + selectedSessionId: snapshot.selectedSessionId, detail: snapshot.detail, + detailLoading: snapshot.detailLoading, detailError: snapshot.detailError, + }); + askObserver.update({ availability: snapshot.availability, sessions: snapshot.sessions, selectedSessionId: snapshot.selectedSessionId }); + syncObservedAsks(); + draftController.setOrigin(snapshot.originSession); + renderHeader(); + applyActivity(); + }, + }); + disposers.push(() => activitySource.dispose()); + void activitySource.start(); +} + +function syncObservedAsks() { + const ids = new Set(askObserver?.getState().needsAnswerIds ?? []); + app.sessions = app.rawSessions.map((row) => ({ ...row, needsAnswer: row.status === 'running' && ids.has(row.sessionId) })); + reminders.update(app.sessions); +} + +function setDemoStatus(status) { + if (!Object.hasOwn(STATUS_ANIM, status)) return; + app.demoStatus = status; + applyActivity(); +} + +async function initPreferences() { + try { + let storage = null; + try { storage = localStorage; } catch { /* The stable API still works without browser storage. */ } + const result = await loadPreferences(fetch, storage); + Object.assign(prefs, result.preferences); + setMsg(els.preferenceMsg, result.persisted ? '偏好已保存到本机。' : '调整后会保存到本机。'); + } catch (error) { + console.warn('[pet] preferences_load_failed:', errCode(error), error); + setMsg(els.preferenceMsg, `偏好读取失败(${errCode(error)}),当前使用临时设置。`, 'error'); + } +} + +function savePreferences(patch) { + const version = ++preferenceWriteVersion; + setMsg(els.preferenceMsg, '正在保存偏好…'); + preferenceWrites = preferenceWrites.catch(() => {}).then(() => preferenceRequest(fetch, patch)).then((result) => { + if (!result.persisted) throw new Error('preferences_not_persisted'); + if (version === preferenceWriteVersion && !disposed) setMsg(els.preferenceMsg, '偏好已保存到本机。', 'ok'); + }).catch((error) => { + console.warn('[pet] preferences_save_failed:', errCode(error), error); + if (disposed) return; + const text = `偏好未保存(${errCode(error)}),本次调整仅临时生效。`; + setMsg(els.preferenceMsg, text, 'error'); + if (app.mode === 'window') showTransient(text, 'error'); + }); + return preferenceWrites; +} + +function selectSession(sessionId, report = (text, kind) => setMsg(els.sessionMsg, text, kind)) { + if (app.mode === 'window') { openTaskPreview(sessionId); return; } + if (!activitySource || app.availability !== 'ready') return; + panelNavigation?.showTaskDetail(); + callBridge(() => activitySource.selectSession(sessionId)).then(() => { + if (app.availability !== 'ready') report('任务信息暂不可用', 'error'); + else report('已切换任务详情;桌宠继续聚合全部任务。', 'ok'); + }, (error) => report(`无法关注此任务(${errCode(error)})`, 'error')); +} + +// Host message.append +// --------------------------------------------------------------------------- + +function describeAppendError(code) { + switch (code) { + case 'user_cancelled': + return '已取消'; + case 'target_unavailable': + return '打开桌宠的原属对话不可用,文字已保留。请从可用的聊天重新打开桌宠。'; + case 'not_permitted': + return '无法追加到原属对话。请直接点击重试;文字已保留。'; + case 'unsupported': + return '当前环境不能加到对话输入框。'; + case 'empty': + return '先写点什么吧'; + case 'busy': + return '正在等待上一次确认,文字已保留。'; + case 'result_unknown': + return '请先检查输入框,文本可能已经加进去了'; + default: + return `没能加到输入框(${code}),请检查对话后再试`; + } +} + +function draftResultText(result) { + if (!result.ok) return describeAppendError(result.code); + return `已加到「${result.submission.title}」的输入框,仍需你在对话里按发送。${result.clear ? '' : '新编辑的草稿已保留。'}`; +} + +function renderDraftControls() { + const state = draftController.getState(); + els.talkBtn.disabled = !canAppend || state.pending; + els.composerSend.disabled = !canAppend || state.pending; + const target = state.origin ? `原属对话:${state.origin.title}` : '投递到打开桌宠的原属对话,由 Host 确认。'; + els.draftOrigin.textContent = target; + els.composerOrigin.textContent = target; + els.composerOrigin.title = target; +} + +function bindDraftForms() { + disposers.push(() => draftController.dispose()); + for (const [entry, form, input, message] of [ + ['panel', els.talkForm, els.talkText, els.talkMsg], + ['desktop', els.composer, els.composerInput, els.composerMsg], + ]) { + disposers.push(bindDraftEntry({ + controller: draftController, entry, form, input, + onPending(submission) { setMsg(message, `请在 Host 确认框核对「${submission.title}」及文字…`); }, + onResult(result) { + const text = draftResultText(result); + setMsg(message, text, result.ok ? 'ok' : result.code === 'user_cancelled' || result.code === 'empty' ? 'info' : 'error'); + if (result.ok) playOneShot('waving'); + if (entry === 'desktop' && result.ok && result.clear) { closeComposer(); showTransient(text, 'ok'); } + }, + })); + } +} + +// --------------------------------------------------------------------------- +// Window mode switching +// --------------------------------------------------------------------------- + +function describeModeError(code, target) { + const where = target === 'window' ? '桌面上是否已经出现宠物' : '宠物是否已经回到面板'; + switch (code) { + case 'not_permitted': + return '需要直接点击才能切换窗口(not_permitted)'; + case 'busy': + return '窗口正在切换,请稍候再试(busy)'; + case 'result_unknown': + return `没收到切换结果,请看看${where}(result_unknown)`; + default: + return `切换失败(${code})`; + } +} + +async function initMode() { + if (!host) { + applyMode('standalone'); + return; + } + if (!win) { + applyMode('inline'); + return; + } + try { + const sub = win.onDidChangeState(onWindowState); + disposers.push(() => sub?.dispose?.()); + onWindowState(await win.getState()); + } catch (error) { + const code = errCode(error); + console.error('[pet] window_state_unavailable:', code, error); + applyMode('inline'); + setMsg(els.popoutMsg, `读不到窗口状态(${code})`, 'error'); + } +} + +function onWindowState(state) { + if (!state || (state.mode !== 'inline' && state.mode !== 'window')) { + console.warn('[pet] unexpected_window_state:', state); + return; + } + if (state.mode === 'window' && state.window) { + const entering = app.mode !== 'window'; + app.winPos = { x: state.window.x, y: state.window.y }; + app.winBounds = state.window; + if (app.mode !== 'window') app.clickThrough = state.window.clickThrough ?? null; + if (entering) { + const saved = prefs.windowPosition; + const token = ++app.positionRestoreToken; + app.restoringPosition = true; + app.awaitingPointer = true; + applyMode('window'); + const isCurrent = () => !disposed && token === app.positionRestoreToken && app.mode === 'window'; + void restoreWindowPosition(win, saved, { isCurrent }).then(async (result) => { + if (disposed || token !== app.positionRestoreToken || app.mode !== 'window') return; + let restoredState = result.state; + if (result.interactive && restoredState?.window && (restoredState.window.width !== 280 || restoredState.window.height !== 340)) { + try { restoredState = await win.update({ width: 280, height: 340 }); } + catch (error) { if (!isCurrent()) return; result.error = errCode(error); } + if (!isCurrent()) return; + } + if (restoredState) onWindowState(restoredState); + if (!isCurrent()) return; + app.clickThrough = result.interactive ? false : null; + app.restoringPosition = false; + if (result.error) showTransient(result.interactive + ? `位置未恢复(${result.error}),当前窗口仍可交互。` + : `窗口交互状态未确认(${result.error}),请收回面板重试。`, 'error'); + rememberPosition(); + }); + return; + } + if (!app.restoringPosition) rememberPosition(); + } + if (state.mode !== app.mode) applyMode(state.mode); +} + +function rememberPosition() { + if (!app.winPos || disposed || app.compacting || app.mode !== 'window') return; + const bounds = app.winBounds; + const position = app.workbenchOpen && bounds + ? { x: Math.round(bounds.x + bounds.width - 280), y: Math.round(bounds.y + bounds.height - 340) } + : { x: Math.round(app.winPos.x), y: Math.round(app.winPos.y) }; + if (prefs.windowPosition?.x === position.x && prefs.windowPosition?.y === position.y) return; + prefs.windowPosition = position; + clearTimeout(app.positionTimer); + app.positionTimer = setTimeout(() => { app.positionTimer = null; void savePreferences({ windowPosition: prefs.windowPosition }); }, 250); +} + +function applyMode(mode) { + const leavingWindow = app.mode === 'window' && mode !== 'window'; + if (leavingWindow) { app.hovered = false; animationGate?.invalidate(); } + app.mode = mode; + app.workbenchOpen = false; + app.compacting = false; + app.workbenchRevision++; + workbenchView?.close(); + root.classList.remove('workbench-open'); + if (mode !== 'window') { app.positionRestoreToken++; app.restoringPosition = false; app.awaitingPointer = false; } + if (mode === 'window' && !['idle', 'error'].includes(speechState)) { + speechInput?.cancel(); + showTransient('已取消面板录音。请回到面板重新录制。'); + } + root.classList.toggle('mode-window', mode === 'window'); + root.classList.toggle('mode-inline', mode !== 'window'); + root.dataset.mode = mode; + els.panel.hidden = mode === 'window'; + els.desk.hidden = mode !== 'window'; + + closeMenu(); + closeComposer(); + hideQuickbar(); + cancelDrag(); + stopLook(); + app.pointer = null; + clearForwardingWatchdog(); + app.ctSafeHold = false; + if (leavingWindow) app.clickThrough = null; + + if (mode === 'window') { + els.deskPetSlot.append(els.sprite); + els.sprite.tabIndex = 0; + els.sprite.setAttribute('role', 'button'); + player?.setScale(prefs.scale); + // The manifest opens the window click-through. Until forwarded moves are proven to reach this + // page, start interactive so the pet can never come up unclickable and undraggable. + if (!app.restoringPosition && app.ctForwarding !== 'verified') setClickThrough(false); + } else { + els.panelStage.append(els.sprite); + els.sprite.removeAttribute('tabindex'); + els.sprite.setAttribute('role', 'img'); + player?.setScale(1); + } + renderAll(); +} + +function popOut() { + if (!win) return; + if (!['idle', 'error'].includes(speechState)) { setMsg(els.popoutMsg, '请先停止录音并等待转写完成。'); return; } + els.popoutBtn.disabled = true; + setMsg(els.popoutMsg, '正在弹出…'); + app.modeSwitching = true; + // Synchronous inside the click handler: mode changes need a user gesture. + callBridge(() => win.update({ mode: 'window', width: 280, height: 340 })) + .then( + (state) => { + setMsg(els.popoutMsg, ''); + onWindowState(state); + }, + (error) => { + const code = errCode(error); + console.warn('[pet] pop_out_failed:', code, error); + setMsg(els.popoutMsg, describeModeError(code, 'window'), 'error'); + }, + ) + .finally(() => { + app.modeSwitching = false; + els.popoutBtn.disabled = false; + }); +} + +function returnToPanel() { + if (!win) return Promise.resolve(false); + app.modeSwitching = true; + return callBridge(() => win.update({ mode: 'inline' })) + .then((state) => { onWindowState(state); return state?.mode === 'inline'; }, (error) => { + const code = errCode(error); + console.warn('[pet] return_to_panel_failed:', code, error); + showTransient(describeModeError(code, 'inline'), 'error'); + return false; + }) + .finally(() => { + app.modeSwitching = false; + refreshInteractivity(); + }); +} + +// --------------------------------------------------------------------------- +// Click-through + pointer tracking (window mode) +// --------------------------------------------------------------------------- + +let opaqueFallbackLogged = false; +function isOverPet(x, y) { + if (!app.pet) return false; + try { + return player.isOpaqueAt(x, y); + } catch (error) { + if (!opaqueFallbackLogged) { + opaqueFallbackLogged = true; + console.warn('[pet] is_opaque_failed (falling back to bounding box):', errCode(error), error); + } + return pointInRect(els.sprite.getBoundingClientRect(), x, y); + } +} + +function isOverOverlay(x, y) { + return [els.bubble, els.notificationStack, els.quickbar, els.composer, els.menu, els.workbench].some( + (el) => !el.hidden && pointInRect(el.getBoundingClientRect(), x, y), + ); +} + +function refreshInteractivity() { + if (app.mode !== 'window' || !win || app.modeSwitching || app.restoringPosition || app.awaitingPointer) return; + const p = app.pointer; + const interactive = + app.workbenchOpen || + app.dragging || + app.menuOpen || + app.composerFocused || + (p ? isOverPet(p.x, p.y) || isOverOverlay(p.x, p.y) : false); + setClickThrough(!interactive); +} + +function setClickThrough(value) { + if (value && (app.ctForwarding === 'broken' || app.ctSafeHold)) value = false; + if (value === app.clickThrough) return; + if (app.clickThrough === null && Date.now() < app.ctRetryAt) return; + app.clickThrough = value; + clearForwardingWatchdog(); + app.ctAppliedAt = 0; + callBridge(() => win.update({ clickThrough: value })).then( + () => { + if (!value || app.clickThrough !== true || app.mode !== 'window') return; + app.ctAppliedAt = performance.now(); + if (app.ctForwarding === 'unknown') armForwardingWatchdog(); + }, + (error) => { + const code = errCode(error); + console.warn('[pet] click_through_update_failed:', code, error); + if (app.mode === 'window') { + app.clickThrough = null; + app.ctRetryAt = Date.now() + 1000; + } + }, + ); +} + +function armForwardingWatchdog() { + const armPointer = app.pointer; + app.ctWatchdog = setTimeout(() => { + app.ctWatchdog = null; + if (app.mode !== 'window' || app.clickThrough !== true) return; + console.warn('[pet] click_through_unverified_reverted'); + app.ctSafeHold = true; + app.ctRevertedAt = performance.now(); + app.ctRevertPointer = armPointer; + setClickThrough(false); + }, 1500); +} + +function clearForwardingWatchdog() { + if (app.ctWatchdog) clearTimeout(app.ctWatchdog); + app.ctWatchdog = null; +} + +/** Called for every document mousemove in window mode, before interactivity is recomputed. */ +function noteForwardedMove(e) { + if ( + app.ctForwarding === 'unknown' && + app.clickThrough === true && + app.ctAppliedAt > 0 && + e.timeStamp > app.ctAppliedAt + ) { + app.ctForwarding = 'verified'; + clearForwardingWatchdog(); + console.info('[pet] click_through_forwarding_verified'); + } + if (!app.ctSafeHold) return; + // First move after a watchdog revert. A quick move far from where the pointer was when + // click-through turned on means the user kept moving and we saw none of it: forwarding failed. + const p = app.ctRevertPointer; + const quick = performance.now() - app.ctRevertedAt < 150; + const far = p ? Math.hypot(e.clientX - p.x, e.clientY - p.y) > 8 : true; + app.ctSafeHold = false; + if (!(quick && far)) return; + app.ctFailures += 1; + if (app.ctFailures >= 3) { + app.ctForwarding = 'broken'; + console.warn('[pet] click_through_forwarding_unavailable'); + showTransient('这里收不到穿透时的鼠标移动,透明区域会挡住下面的点击', 'error'); + } +} + +function insideDesk(x, y) { + return pointInRect(els.desk.getBoundingClientRect(), x, y); +} + +function onDocumentMouseMove(e) { + if (app.mode !== 'window') return; + if (app.restoringPosition) return; + app.awaitingPointer = false; + noteForwardedMove(e); + if (!app.dragging && !insideDesk(e.clientX, e.clientY)) { + onPointerLeave(); + return; + } + app.pointer = { x: e.clientX, y: e.clientY }; + refreshInteractivity(); + trackMenuHover(e.clientX, e.clientY); + updateLook(e.clientX, e.clientY); +} + +function onPointerLeave() { + if (app.mode !== 'window') return; + app.pointer = null; + stopLook(); + refreshInteractivity(); // stays interactive while menu/composer/drag force it + if (app.menuOpen) armMenuClose(); +} + +// --------------------------------------------------------------------------- +// Look direction (v2) + idle liveliness +// --------------------------------------------------------------------------- + +function updateLook(x, y) { + if (!app.pet || !player.supportsLook) return; + if ( + app.mode !== 'window' || + app.hovered || + app.dragging || + app.menuOpen || + app.composerOpen || + app.oneShot || + app.workbenchOpen || + reducedMotion.matches || + !['idle', 'running', 'waving'].includes(activityAnim()) + ) { + stopLook(); + return; + } + const r = els.sprite.getBoundingClientRect(); + const dx = x - (r.left + r.width / 2); + const dy = y - (r.top + r.height / 2); + if (Math.hypot(dx, dy) <= LOOK_DEAD_ZONE) { + stopLook(); + return; + } + let angle = (Math.atan2(dx, -dy) * 180) / Math.PI; // 0° = up, clockwise + if (angle < 0) angle += 360; + player.look(angle); + app.looking = true; +} + +function stopLook() { + if (!app.looking) return; + app.looking = false; + player?.look(null); +} + +function noteInteraction() { + // Deliberate interactions own transient animations; no random idle interruptions. +} + +// --------------------------------------------------------------------------- +// Dragging +// --------------------------------------------------------------------------- + +function onSpritePointerDown(e) { + if (app.mode !== 'window' || e.button !== 0 || !app.pet) return; + noteInteraction(); + if (app.menuOpen) closeMenu(); + e.preventDefault(); + try { + els.sprite.setPointerCapture(e.pointerId); + } catch (error) { + console.warn('[pet] pointer_capture_failed:', errCode(error), error); + } + app.drag = { + pointerId: e.pointerId, + startX: e.screenX, + startY: e.screenY, + win: app.winPos ? { ...app.winPos } : null, + active: false, + dir: null, + extreme: e.screenX, + }; +} + +function onSpritePointerMove(e) { + const d = app.drag; + if (!d || e.pointerId !== d.pointerId) return; + const dx = e.screenX - d.startX; + const dy = e.screenY - d.startY; + if (!d.active) { + if (Math.hypot(dx, dy) <= DRAG_THRESHOLD) return; + d.active = true; + app.dragging = true; + app.oneShotToken++; + app.oneShot = false; + stopLook(); + hideQuickbar(); + els.sprite.classList.add('is-dragging'); + refreshInteractivity(); + if (!d.win) console.warn('[pet] drag_without_window_position: 还没拿到窗口位置,只播放动画'); + } + updateDragDirection(d, e.screenX); + if (!d.win) return; + callBridge(() => win.update({ x: Math.round(d.win.x + dx), y: Math.round(d.win.y + dy) })).catch((error) => { + const code = errCode(error); + if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted(目标位置不在任何屏幕上)'); + else console.warn('[pet] drag_move_failed:', code, error); + }); +} + +function updateDragDirection(d, sx) { + const before = d.dir; + if (d.dir === 'right') { + if (sx > d.extreme) d.extreme = sx; + else if (d.extreme - sx > DIRECTION_HYSTERESIS) { + d.dir = 'left'; + d.extreme = sx; + } + } else if (d.dir === 'left') { + if (sx < d.extreme) d.extreme = sx; + else if (sx - d.extreme > DIRECTION_HYSTERESIS) { + d.dir = 'right'; + d.extreme = sx; + } + } else if (Math.abs(sx - d.startX) > DIRECTION_HYSTERESIS / 2) { + d.dir = sx > d.startX ? 'right' : 'left'; + d.extreme = sx; + } + if (d.dir !== before) { + player.play(d.dir === 'right' ? 'running-right' : 'running-left').catch(logError('play_drag')); + } +} + +function finishDrag(e, cancelled) { + const d = app.drag; + if (!d || e.pointerId !== d.pointerId) return; + app.drag = null; + try { + if (els.sprite.hasPointerCapture(e.pointerId)) els.sprite.releasePointerCapture(e.pointerId); + } catch (error) { + console.warn('[pet] pointer_release_failed:', errCode(error), error); + } + els.sprite.classList.remove('is-dragging'); + if (d.active) { + app.dragging = false; + if (cancelled || desiredAnimation().state === 'jumping') { + // Hover already owns the same three-cycle jump; release must not queue it twice. + animationGate?.invalidate(); applyActivity(); + } else playOneShot('jumping'); + callBridge(() => win.getState()).then(onWindowState, logError('get_state_after_drag')); + } else if (!cancelled) { + void openWorkbench('tasks'); + } + refreshInteractivity(); +} + +function cancelDrag() { + if (!app.drag && !app.dragging) return; + app.drag = null; + app.dragging = false; + els.sprite.classList.remove('is-dragging'); +} + +// --------------------------------------------------------------------------- +// Bubble / quick bar / composer (window mode) +// --------------------------------------------------------------------------- + +function showTransient(text, kind = 'info', sticky = false) { + clearTimeout(app.transientTimer); + app.transient = text ? { text, kind } : null; + if (text && !sticky) { + app.transientTimer = setTimeout(() => { + app.transient = null; + renderBubble(); + }, 4000); + } + renderBubble(); +} + +function renderBubble() { + renderNotificationStack(); + if (app.composerOpen || app.workbenchOpen) { + els.bubble.hidden = true; + app.bubbleSessionId = null; + refreshInteractivity(); + return; + } + const a = activity(); + let title = ''; + let text = ''; + let kind = 'info'; + let clickable = false; + if (app.transient) { + ({ text, kind } = app.transient); + } else if (!app.pet && app.loadError) { + text = app.loadError; + kind = 'error'; + } else if (app.source === 'real' && app.availability !== 'ready') { + text = availabilityText(); + kind = app.availability === 'connecting' ? 'info' : 'error'; + } else { + if (a.session) { + text = a.session.needsAnswer ? '需要你回答' : a.status === 'running' ? '' : STATUS_TEXT[a.status]; + title = a.session.title; + clickable = !!text; + } + + } + if (app.source === 'demo') { + title = '状态演示'; + text = STATUS_TEXT[app.demoStatus] ? `演示:${STATUS_TEXT[app.demoStatus]}` : ''; + clickable = false; + } + els.bubbleTitle.textContent = title; + els.bubbleTitle.hidden = !title; + els.bubbleText.textContent = text; + els.bubble.dataset.kind = kind; + els.bubble.classList.toggle('is-clickable', clickable); + els.bubble.tabIndex = text ? 0 : -1; + els.bubble.setAttribute('aria-label', text ? `${title ? `${title},` : ''}${text},打开桌宠工作面板` : '任务提示'); + els.bubble.hidden = !text || (!app.transient && !els.notificationStack.hidden); + app.bubbleSessionId = clickable && a.session ? a.session.sessionId : null; + refreshInteractivity(); +} + +function renderNotificationStack() { + const container = els.notificationStack; + const items = notificationItems(reminders.getState().items); + const visible = app.mode === 'window' && app.availability === 'ready' && items.length + && !app.workbenchOpen && !app.composerOpen && !app.transient; + container.hidden = !visible; + if (!visible) return; + const previews = notificationPreviews?.getState().previews ?? {}; + const existing = new Map([...container.children].map((node) => [node.dataset.sessionId, node])); + const nodes = items.map((item) => { + let node = existing.get(item.sessionId); + if (!node) { + node = document.createElement('div'); node.className = 'pet-notice'; node.dataset.sessionId = item.sessionId; + const open = document.createElement('button'); open.type = 'button'; open.className = 'pet-notice-open'; + const title = document.createElement('strong'), preview = document.createElement('span'); preview.className = 'pet-notice-preview'; + open.append(title, preview); + open.addEventListener('click', () => { openTaskPreview(node.dataset.sessionId); }); + const dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'pet-notice-dismiss'; dismiss.textContent = '×'; + dismiss.addEventListener('click', () => { reminders.dismiss(node.dataset.sessionId); applyActivity(); }); + node.append(open, dismiss); + } + const open = node.children[0]; + open.children[0].textContent = item.title; + open.children[1].textContent = notificationText(item, previews); + open.setAttribute('aria-label', `查看通知:${item.title}`); + node.children[1].setAttribute('aria-label', `关闭通知:${item.title}`); + return node; + }); + if (nodes.length !== container.children.length || nodes.some((node, i) => node !== container.children[i])) container.replaceChildren(...nodes); +} + +function toggleQuickbar() { + if (els.quickbar.hidden) showQuickbar(); + else hideQuickbar(); +} +function showQuickbar() { + if (app.mode !== 'window') return; + els.quickbar.hidden = false; + armQuickbarHide(); + refreshInteractivity(); +} +function hideQuickbar() { + clearTimeout(app.quickbarTimer); + if (els.quickbar.hidden) return; + els.quickbar.hidden = true; + refreshInteractivity(); +} +function armQuickbarHide() { + clearTimeout(app.quickbarTimer); + app.quickbarTimer = setTimeout(() => { + const p = app.pointer; + if (p && pointInRect(els.quickbar.getBoundingClientRect(), p.x, p.y)) armQuickbarHide(); + else hideQuickbar(); + }, 6000); +} + +function openComposer() { + if (!canAppend) { + showTransient('当前环境不能加到对话输入框', 'error'); + return; + } + hideQuickbar(); + closeMenu(); + stopLook(); + app.composerOpen = true; + els.composer.hidden = false; + // Called from a click/keyboard handler: focus() needs a user gesture. Lets the pet window take keyboard input. + if (typeof win?.focus === 'function') { + callBridge(() => win.focus()).catch((error) => console.warn('[pet] window_focus_failed:', errCode(error), error)); + } + els.composerInput.focus(); + renderBubble(); + refreshInteractivity(); +} + +function closeComposer({ restoreFocus = false } = {}) { + if (!app.composerOpen) return; + app.composerOpen = false; + app.composerFocused = false; + els.composer.hidden = true; + if (restoreFocus) els.sprite.focus({ preventScroll: true }); + renderBubble(); + refreshInteractivity(); +} + +// --------------------------------------------------------------------------- +// Context menu (window mode) +// --------------------------------------------------------------------------- + +const MENU_TITLES = { + root: '宠物菜单', + demo: '动作预览(演示)', + sessions: '全部任务', + pets: '换宠物', + scale: '大小', +}; + +function menuModel(view) { + if (view === 'demo') { + return [ + ...DEMO_ORDER.map((s) => ({ + label: STATUS_LABEL[s], + role: 'menuitemradio', + checked: app.demoStatus === s, + run: () => setDemoStatus(s), + })), + ]; + } + if (view === 'sessions') { + if (!app.sessions.length) return [{ label: '还没有会话', disabled: true }]; + return app.sessions.map((s) => ({ + label: s.title, + detail: STATUS_LABEL[s.status] ?? s.status, + disabled: false, + run: () => selectSession(s.sessionId, showTransient), + })); + } + if (view === 'pets') { + if (!app.pets.length) return [{ label: '没有可用的宠物', disabled: true }]; + return app.pets.map((p) => ({ + label: p.displayName, + detail: `v${p.version}`, + role: 'menuitemradio', + checked: app.pet?.id === p.id, + run: () => selectPet(p.id), + })); + } + if (view === 'scale') { + return SCALES.map((s) => ({ + label: s.label, + role: 'menuitemradio', + checked: prefs.scale === s.value, + run: () => setScale(s.value), + })); + } + return [ + app.source === 'demo' && { label: '动作预览', submenu: 'demo' }, + app.source === 'real' && app.availability === 'ready' && { label: '全部任务', run: () => { void openWorkbench('tasks'); } }, + app.source === 'real' && app.availability !== 'ready' && { label: '重新连接会话', run: () => { void activitySource?.refresh(); } }, + { label: '打招呼', run: () => playOneShot('waving') }, + { label: '跳一下', run: () => playOneShot('jumping') }, + canAppend && { label: '追加到原属聊天草稿…', run: () => { void openWorkbench('draft'); } }, + { label: '换宠物', submenu: 'pets' }, + { label: '大小', submenu: 'scale' }, + win && { label: '收回到面板', run: returnToPanel }, + ].filter(Boolean); +} + +function menuButton(item) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'menu-item'; + b.tabIndex = -1; + b.setAttribute('role', item.role ?? 'menuitem'); + if (item.role === 'menuitemradio' || item.role === 'menuitemcheckbox') { + b.setAttribute('aria-checked', String(Boolean(item.checked))); + } + if (item.submenu) b.setAttribute('aria-haspopup', 'menu'); + if (item.disabled) b.setAttribute('aria-disabled', 'true'); + + const mark = document.createElement('span'); + mark.className = 'mi-mark'; + mark.setAttribute('aria-hidden', 'true'); + mark.textContent = item.back ? '‹' : item.checked ? '✓' : ''; + const label = document.createElement('span'); + label.className = 'mi-label'; + label.textContent = item.label; + b.append(mark, label); + if (item.detail) { + const detail = document.createElement('span'); + detail.className = 'mi-detail'; + detail.textContent = item.detail; + b.append(detail); + } + if (item.submenu) { + const arrow = document.createElement('span'); + arrow.className = 'mi-arrow'; + arrow.setAttribute('aria-hidden', 'true'); + arrow.textContent = '›'; + b.append(arrow); + } + b.addEventListener('click', () => activateMenuItem(item)); + menuItemByButton.set(b, item); + return b; +} + +function activateMenuItem(item) { + if (item.disabled) return; + if (item.back) { + goMenu('root'); + return; + } + if (item.submenu) { + goMenu(item.submenu); + return; + } + // Runs synchronously inside the click/keydown, so gesture-gated bridge calls stay valid. + // Run before closing: returnToPanel sets modeSwitching first, so closing does not queue a + // clickThrough patch that would race the mode switch (inline_mode). + item.run(); + closeMenu(); +} + +function goMenu(view) { + app.menuView = view; + renderMenu(0); +} + +function renderMenu(focusIndex = 0) { + const nodes = []; + if (app.menuView !== 'root') nodes.push(menuButton({ label: '返回', back: true })); + for (const item of menuModel(app.menuView)) nodes.push(menuButton(item)); + els.menu.replaceChildren(...nodes); + els.menu.setAttribute('aria-label', MENU_TITLES[app.menuView] ?? '菜单'); + els.menu.hidden = false; + positionMenu(); + const buttons = menuButtons(); + buttons[clamp(focusIndex, 0, buttons.length - 1)]?.focus({ preventScroll: true }); + refreshInteractivity(); +} + +function renderMenuIfOpen() { + if (!app.menuOpen) return; + const buttons = menuButtons(); + const index = Math.max(0, buttons.indexOf(document.activeElement)); + if (app.menuView === 'demo' && app.source !== 'demo') app.menuView = 'root'; + if (app.menuView === 'sessions' && app.source !== 'real') app.menuView = 'root'; + renderMenu(index); +} + +function menuButtons() { + return [...els.menu.querySelectorAll('.menu-item')]; +} + +function positionMenu() { + const desk = els.desk.getBoundingClientRect(); + els.menu.style.left = '0px'; + els.menu.style.top = '0px'; + const m = els.menu.getBoundingClientRect(); + const left = clamp(app.menuAnchor.x - desk.left - 8, 4, Math.max(4, desk.width - m.width - 4)); + const top = clamp(app.menuAnchor.y - desk.top - 8, 4, Math.max(4, desk.height - m.height - 4)); + els.menu.style.left = `${Math.round(left)}px`; + els.menu.style.top = `${Math.round(top)}px`; +} + +function openMenu(x, y) { + if (app.mode !== 'window') return; + noteInteraction(); + hideQuickbar(); + closeComposer(); + stopLook(); + disarmMenuClose(); + app.menuOpen = true; + app.menuView = 'root'; + app.menuAnchor = { x, y }; + renderMenu(0); +} + +function closeMenu({ restoreFocus = false } = {}) { + if (!app.menuOpen) return; + app.menuOpen = false; + disarmMenuClose(); + els.menu.hidden = true; + els.menu.replaceChildren(); + if (restoreFocus) els.sprite.focus({ preventScroll: true }); + refreshInteractivity(); +} + +function trackMenuHover(x, y) { + if (!app.menuOpen) return; + if (pointInRect(els.menu.getBoundingClientRect(), x, y)) disarmMenuClose(); + else armMenuClose(); +} +function armMenuClose() { + if (!app.menuCloseTimer) app.menuCloseTimer = setTimeout(() => closeMenu(), 1500); +} +function disarmMenuClose() { + clearTimeout(app.menuCloseTimer); + app.menuCloseTimer = null; +} + +function onMenuKeyDown(e) { + const buttons = menuButtons(); + const i = buttons.indexOf(document.activeElement); + const focusAt = (n) => buttons[(n + buttons.length) % buttons.length]?.focus({ preventScroll: false }); + const item = menuItemByButton.get(document.activeElement); + switch (e.key) { + case 'ArrowDown': + focusAt(i + 1); + break; + case 'ArrowUp': + focusAt(i < 0 ? -1 : i - 1); + break; + case 'Home': + focusAt(0); + break; + case 'End': + focusAt(-1); + break; + case 'ArrowRight': + if (item?.submenu) goMenu(item.submenu); + break; + case 'ArrowLeft': + case 'Backspace': + if (app.menuView !== 'root') goMenu('root'); + break; + case 'Escape': + case 'Tab': + closeMenu({ restoreFocus: true }); + break; + default: + return; + } + e.preventDefault(); + e.stopPropagation(); +} + +// --------------------------------------------------------------------------- +// Pets: list, select, delete +// --------------------------------------------------------------------------- + +async function readErrorBody(res) { + try { + const body = await res.json(); + return { code: body?.error || `http_${res.status}`, message: body?.message || '' }; + } catch (error) { + console.warn('[pet] error_body_not_json:', res.status, error); + return { code: `http_${res.status}`, message: '' }; + } +} + +async function refreshPets() { + let res; + try { + res = await fetch('/api/pets', { cache: 'no-store' }); + } catch (error) { + throw Object.assign(new Error('读不到宠物列表'), { code: 'pets_list_unreachable', cause: error }); + } + if (!res.ok) { + const { code, message } = await readErrorBody(res); + throw Object.assign(new Error(message || '读不到宠物列表'), { code }); + } + const body = await res.json(); + app.pets = Array.isArray(body?.pets) ? body.pets : []; + app.spriteUrls.clear(); + renderGallery(); + renderMenuIfOpen(); + resolveThumbs(); +} + +async function resolveThumbs() { + const results = await Promise.allSettled( + app.pets.map(async (pet) => { + const jsonUrl = new URL(pet.petJsonUrl, location.href); + const res = await fetch(jsonUrl, { cache: 'no-store' }); + if (!res.ok) throw Object.assign(new Error(`pet.json HTTP ${res.status}`), { code: 'thumb_pet_json_unreadable' }); + const json = await res.json(); + app.spriteUrls.set(pet.id, new URL(json.spritesheetPath, jsonUrl).href); + }), + ); + results.forEach((r, i) => { + if (r.status === 'rejected') console.warn('[pet] thumb_failed:', app.pets[i]?.id, errCode(r.reason), r.reason); + }); + renderGallery(); +} + +async function selectPet(id) { + const summary = app.pets.find((p) => p.id === id); + if (!summary) { + showLoadError({ code: 'pet_not_found', message: `没有 id 为 ${id} 的宠物` }); + return; + } + const token = ++app.selectToken; + setMsg(els.galleryMsg, `正在加载「${summary.displayName}」…`); + try { + const loaded = await loadPet(new URL(summary.petJsonUrl, location.href).href); + if (token !== app.selectToken) return; + stopLook(); + app.pet = loaded; + app.petSummary = summary; + player.setPet(loaded); + app.oneShot = false; + app.oneShotToken++; + animationGate?.invalidate(); applyActivity(); + prefs.petId = id; + savePreferences({ petId: id }); + showLoadError(null); + setMsg(els.galleryMsg, ''); + } catch (error) { + if (token !== app.selectToken) return; + showLoadError(error, summary.displayName); + setMsg(els.galleryMsg, ''); + } + renderAll(); +} + +function showLoadError(error, name) { + if (!error) { + app.loadError = null; + els.loadError.hidden = true; + els.loadError.textContent = ''; + renderBubble(); + return; + } + const code = errCode(error); + const detail = error.message && error.message !== code ? `:${error.message}` : ''; + const text = `${name ? `「${name}」` : ''}加载失败(${code})${detail}`; + console.error('[pet] load_failed:', code, error); + app.loadError = text; + els.loadError.textContent = text; + els.loadError.hidden = false; + if (app.mode === 'window' && app.pet) showTransient(text, 'error'); + renderBubble(); +} + +async function deletePet(pet) { + if (!window.confirm(`删除导入的宠物「${pet.displayName}」?删除后不能恢复。`)) { + setMsg(els.galleryMsg, '已取消删除'); + return; + } + setMsg(els.galleryMsg, `正在删除「${pet.displayName}」…`); + try { + const res = await fetch(`/api/pets/${encodeURIComponent(pet.id)}`, { method: 'DELETE' }); + if (!res.ok) { + const { code, message } = await readErrorBody(res); + setMsg(els.galleryMsg, `删除失败(${code})${message ? `:${message}` : ''}`, 'error'); + return; + } + await refreshPets(); + setMsg(els.galleryMsg, `已删除「${pet.displayName}」`, 'ok'); + if (app.pet?.id === pet.id || prefs.petId === pet.id) { + const next = app.pets[0]; + if (next) await selectPet(next.id); + } + } catch (error) { + const code = errCode(error); + console.error('[pet] delete_failed:', code, error); + setMsg(els.galleryMsg, `删除失败(${code})`, 'error'); + } +} + +function setScale(value) { + const intent = ++scaleIntent; + const current = () => !disposed && intent === scaleIntent; + scaleUpdates = scaleUpdates.then(async () => { + if (!current()) return; + if (app.workbenchOpen) await closeWorkbench(); + if (!current()) return; + if (app.mode === 'window') { + // The Host only requires the transparent window to overlap a display. + // Fit its whole content area before shrinking so opaque pet pixels remain reachable. + const result = await fitWindowToScreen(win, { + screenRect: () => ({ x: screen.availLeft, y: screen.availTop, width: screen.availWidth, height: screen.availHeight }), + isCurrent: () => current() && app.mode === 'window', + }); + if (!current() || result.cancelled) return; + if (result.error) { showTransient(`尺寸未调整(${result.error}),请稍后重试。`, 'error'); return; } + if (result.state) onWindowState(result.state); + player.setScale(value); + if (app.menuOpen) positionMenu(); + refreshInteractivity(); + } + prefs.scale = value; + savePreferences({ scale: value }); + renderScale(); + }).catch(logError('scale_update')); + return scaleUpdates; +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +function resetImport() { + importState.generation++; + clearInterval(importState.cycleTimer); + importState.player?.destroy(); + if (importState.blobUrl) URL.revokeObjectURL(importState.blobUrl); + Object.assign(importState, { + petJson: null, + file: null, + blobUrl: null, + loaded: null, + player: null, + cycleTimer: null, + errors: [], + busy: false, + }); + els.importSprite.removeAttribute('style'); + els.importPreview.hidden = true; + els.importErrors.replaceChildren(); + els.importStateText.textContent = ''; +} + +function renderImportErrors() { + els.importErrors.replaceChildren( + ...importState.errors.map(({ code, message }) => { + const li = document.createElement('li'); + const c = document.createElement('code'); + c.textContent = code; + li.append(c, ` ${message}`); + return li; + }), + ); + els.importConfirm.disabled = importState.busy || importState.errors.length > 0 || !importState.loaded; + els.importPick.disabled = importState.busy; + els.importInput.disabled = importState.busy; + els.importCancel.disabled = importState.busy; + if (els.importBack) els.importBack.disabled = importState.busy; +} + +function precheckImport(petJson, file) { + const errs = []; + if (typeof petJson.id !== 'string' || !ID_RE.test(petJson.id)) { + errs.push({ code: 'invalid_id', message: 'id 只能用小写字母、数字、点、下划线、连字符,最长 80 个字符' }); + } else if (app.pets.some((p) => p.id === petJson.id && p.source === 'bundled')) { + errs.push({ code: 'bundled_conflict', message: '和内置宠物的 id 重复了' }); + } + if (typeof petJson.displayName !== 'string' || petJson.displayName.length < 1 || petJson.displayName.length > 80) { + errs.push({ code: 'invalid_pet_json', message: 'displayName 需要 1–80 个字符' }); + } + if (petJson.description != null && (typeof petJson.description !== 'string' || petJson.description.length > 300)) { + errs.push({ code: 'invalid_pet_json', message: 'description 最多 300 个字符' }); + } + if (!/^[^/\\]+\.(webp|png)$/.test(petJson.spritesheetPath)) { + errs.push({ code: 'invalid_sprite_name', message: 'spritesheetPath 只能是同目录下的 .webp 或 .png 文件名' }); + } + if (Math.ceil(file.size / 3) * 4 + 4096 > IMPORT_BODY_LIMIT) { + errs.push({ code: 'payload_too_large', message: '图集太大,上传内容会超过 12 MB' }); + } + return errs; +} + +async function handleImportFiles(fileList) { + if (importState.busy) return; + resetImport(); + const generation = importState.generation; + const current = () => !disposed && generation === importState.generation; + setMsg(els.importMsg, ''); + const files = [...fileList]; + const jsons = files.filter((f) => /\.json$/i.test(f.name)); + if (jsons.length !== 1) { + setMsg( + els.importMsg, + jsons.length === 0 + ? '缺少 pet.json,请和图集一起选择(import_pet_json_missing)' + : '一次只能导入一个宠物,选了多个 .json(import_pet_json_ambiguous)', + 'error', + ); + return; + } + let petJson; + try { + petJson = JSON.parse(await jsons[0].text()); + } catch (error) { + if (!current()) return; + console.warn('[pet] import_pet_json_unreadable:', error); + setMsg(els.importMsg, `pet.json 不是有效的 JSON(pet_json_unreadable)`, 'error'); + return; + } + if (!current()) return; + if (!petJson || typeof petJson !== 'object' || Array.isArray(petJson) || typeof petJson.spritesheetPath !== 'string') { + setMsg(els.importMsg, 'pet.json 缺少 spritesheetPath(pet_json_invalid)', 'error'); + return; + } + const spriteName = basename(petJson.spritesheetPath); + const file = files.find((f) => f.name === spriteName); + if (!file) { + setMsg(els.importMsg, `没找到 ${spriteName},请和 pet.json 一起选择(import_sprite_missing)`, 'error'); + return; + } + + importState.petJson = petJson; + importState.file = file; + importState.blobUrl = URL.createObjectURL(file); + importState.errors = precheckImport(petJson, file); + els.importName.textContent = petJson.displayName || petJson.id || '未命名宠物'; + els.importMeta.textContent = `id:${petJson.id ?? '—'} · 图集:${file.name}(${Math.round(file.size / 1024)} KB)`; + els.importPreview.hidden = false; + els.importStateText.textContent = '正在检查图集…'; + renderImportErrors(); + + const blobUrl = importState.blobUrl; + try { + const loaded = await loadPetFromParts(petJson, blobUrl); + if (!current() || importState.blobUrl !== blobUrl) return; + importState.loaded = loaded; + els.importMeta.textContent += ` · v${loaded.version}${loaded.version === 2 ? '(支持看向鼠标)' : ''}`; + startImportPreview(loaded); + } catch (error) { + if (!current() || importState.blobUrl !== blobUrl) return; + const code = errCode(error); + console.warn('[pet] import_preview_failed:', code, error); + importState.errors.push({ code, message: error?.message && error.message !== code ? error.message : '图集无法预览' }); + els.importStateText.textContent = ''; + } + renderImportErrors(); +} + +function startImportPreview(loaded) { + const preview = new SpritePlayer(els.importSprite, { scale: 0.75 }); + preview.setReducedMotion(reducedMotion.matches); + preview.setPet(loaded); + importState.player = preview; + let i = 0; + const step = () => { + const state = STATES[i % STATES.length]; + i++; + preview.play(state, { loop: true }).catch(logError('import_preview_play')); + els.importStateText.textContent = `预览动作:${state}(${((i - 1) % STATES.length) + 1}/${STATES.length})`; + }; + step(); + importState.cycleTimer = setInterval(step, 1200); +} + +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result); + resolve(result.slice(result.indexOf(',') + 1)); + }; + reader.onerror = () => reject(Object.assign(new Error('读不到图集文件'), { code: 'sprite_unreadable', cause: reader.error })); + reader.readAsDataURL(file); + }); +} + +async function postPet(body) { + const res = await fetch('/api/pets', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + let data = null; + try { + data = await res.json(); + } catch (error) { + console.warn('[pet] import_response_not_json:', res.status, error); + } + return { res, data }; +} + +async function confirmImport() { + const { petJson, file } = importState; + if (!petJson || !file || !importState.loaded || importState.errors.length || importState.busy) return; + let generation = importState.generation; + const current = () => !disposed && generation === importState.generation; + importState.busy = true; + renderImportErrors(); + setMsg(els.importMsg, '正在导入…'); + try { + const base64 = await fileToBase64(file); + if (!current()) return; + const payload = { petJson, spritesheet: { fileName: file.name, base64 } }; + let { res, data } = await postPet(payload); + if (!current()) return; + if (res.status === 409 && data?.error === 'pet_exists') { + if (!window.confirm(`已经导入过 id 为「${petJson.id}」的宠物,要用这次的文件替换它吗?`)) { + setMsg(els.importMsg, '已取消导入,原来的宠物没有变化'); + return; + } + ({ res, data } = await postPet({ ...payload, replace: true })); + if (!current()) return; + } + if (!res.ok) { + const code = data?.error || `http_${res.status}`; + setMsg(els.importMsg, `导入失败(${code})${data?.message ? `:${data.message}` : ''}`, 'error'); + return; + } + const newId = data?.pet?.id ?? petJson.id; + resetImport(); + generation = importState.generation; + importState.busy = true; + renderImportErrors(); + await refreshPets(); + if (!current()) return; + await selectPet(newId); + if (!current()) return; + panelNavigation?.completeImport(); + const message = `导入成功,已切换到「${data?.pet?.displayName ?? newId}」`; + setMsg(els.importMsg, message, 'ok'); + setMsg(els.galleryMsg, message, 'ok'); + } catch (error) { + if (!current()) return; + const code = errCode(error); + console.error('[pet] import_failed:', code, error); + setMsg(els.importMsg, `导入失败(${code})`, 'error'); + } finally { + if (current()) { importState.busy = false; renderImportErrors(); } + } +} + +// --------------------------------------------------------------------------- +// Rendering (inline panel) +// --------------------------------------------------------------------------- + +function renderAll() { + renderHeader(); + renderActivityUI(); + renderTalk(); + renderScale(); + renderGallery(); + renderBubble(); +} + +function renderHeader() { + const pet = app.pet; + els.petName.textContent = pet?.displayName ?? (app.loadError ? '宠物没加载出来' : '正在加载宠物…'); + els.petDesc.textContent = pet?.description ?? ''; + els.versionBadge.hidden = !pet; + els.versionBadge.textContent = pet?.version === 2 ? 'v2·可看向鼠标' : 'v1'; + const sourceLabel = app.source === 'demo' ? '资源预览' : app.availability === 'ready' ? '真实会话' : app.availability === 'connecting' ? '连接中' : app.availability === 'not_permitted' ? '未授权' : '不可用'; + els.sourceBadge.textContent = sourceLabel; + els.sourceBadge.dataset.source = app.source; + els.popoutBtn.hidden = !win || app.mode === 'window'; + + let note = ''; + if (app.mode === 'standalone') { + note = '普通浏览器没有任务和窗口能力;可用 ?demo=1 手动预览资源动作。'; + } else if (!win) { + note = '当前 MiniMax Code 版本不支持弹出窗口,只能在面板里使用。'; + } + els.hostNote.textContent = note; + els.hostNote.hidden = !note; +} + +function renderActivityUI() { + const a = activity(); + const real = app.source === 'real'; + els.demoControls.hidden = real; + const available = real && app.availability === 'ready'; + panelNavigation?.update({ reminderCount: reminders.getState().items.length, availability: app.availability }); + els.taskConnection.hidden = available || !real; + if (!available && real) setMsg(els.taskConnection, `${availabilityText()} 可在设置中重新连接。`, app.availability === 'connecting' ? 'info' : 'error'); + els.sessionList.hidden = !available; + els.activityRetry.hidden = !real || available; + if (available) { + const coverage = askObserver?.getState(); + els.activityNote.textContent = `聚合最近 50 个用户主会话及本次可见活动。新出现的 ask 实时跟随 ${coverage?.watchingIds.length ?? 0}/${coverage?.runningCount ?? 0} 个运行任务;订阅前的问题无法恢复。选择条目只查看详情。`; + renderSessionList(a.session); + } else if (real) { + els.activityNote.textContent = availabilityText(); + els.sessionList.replaceChildren(); + } else { + els.activityNote.textContent = '手动资源动作预览(演示),不代表真实任务。'; + syncSegmented(els.demoStatus, app.demoStatus); + } + els.aggregateCounts.textContent = available ? aggregateText(app.aggregate?.counts) : ''; + els.clearCompleted.hidden = !available || !app.aggregate?.counts.completed; + renderReminders(); + const text = real && !available ? availabilityText() : a.session?.needsAnswer ? '需要你回答' : a.session || !real ? STATUS_TEXT[a.status] || '空闲中' : '暂无当前提醒'; + els.panelStatus.textContent = a.session?.needsAnswer ? `「${a.session.title}」需要你回答` : a.session && STATUS_TEXT[a.status] ? `「${a.session.title}」${text}` : text; + renderDetail(); + const name = app.pet?.displayName ?? '宠物'; + const stateLabel = a.session?.needsAnswer ? '需要你回答' : STATUS_LABEL[a.status] || sourceLabelForActivity(); + els.sprite.setAttribute('aria-label', app.mode === 'window' + ? `${name},${stateLabel}。点击或回车查看任务,可拖动` : `${name},${stateLabel}`); + +} + +function sourceLabelForActivity() { return app.availability === 'connecting' ? '连接中' : app.availability === 'not_permitted' ? '未授权读取会话' : '会话状态不可用'; } + +function renderSessionList(current) { + const focusedId = els.sessionList.contains(document.activeElement) ? document.activeElement?.dataset?.sessionId : null; + const scrollTop = els.sessionList.scrollTop; + const items = app.sessions.map((s) => { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'session-row'; + btn.dataset.sessionId = s.sessionId; + btn.disabled = false; + const title = document.createElement('span'); + title.className = 'session-title'; + title.textContent = s.title; + title.title = s.title; + const pill = document.createElement('span'); + pill.className = 'pill'; + const displayStatus = taskStatus(s); + pill.dataset.status = displayStatus === 'needs_answer' ? 'needs_input' : displayStatus; + pill.textContent = s.needsAnswer ? '需要你回答' : STATUS_LABEL[displayStatus] ?? displayStatus; + btn.append(title); + if (current?.sessionId === s.sessionId) { + const tag = document.createElement('span'); + tag.className = 'session-current'; + tag.textContent = '当前提示'; + btn.append(tag); + } + btn.append(pill); + if (app.selectedSessionId === s.sessionId) btn.setAttribute('aria-current', 'true'); + btn.setAttribute('aria-label', `${s.title},${pill.textContent},查看详情`); + btn.addEventListener('click', () => selectSession(s.sessionId, (t, k) => setMsg(els.sessionMsg, t, k))); + li.append(btn); + return li; + }); + if (!items.length) { + const li = document.createElement('li'); + li.className = 'muted'; + li.textContent = '还没有会话'; + items.push(li); + } + els.sessionList.replaceChildren(...items); + els.sessionList.scrollTop = scrollTop; + if (focusedId) els.sessionList.querySelector(`[data-session-id="${CSS.escape(focusedId)}"]`)?.focus(); +} + +function renderTalk() { + renderDraftControls(); + const entry = $('open-draft'); + if (entry) { entry.hidden = !canAppend; if (entry.closest('section')) entry.closest('section').hidden = !canAppend; } + els.talkText.disabled = !canAppend; + if (!canAppend && !els.talkMsg.textContent) setMsg(els.talkMsg, '当前环境不能加到对话输入框。'); +} + +function renderDetail() { + const detail = app.detail; + const session = app.sessions.find((row) => row.sessionId === app.selectedSessionId); + const needsAnswer = session?.needsAnswer; + els.detailTitle.textContent = detail?.title || '任务概览'; + const status = taskStatus(session) || taskStatus(detail); + els.detailStatus.textContent = needsAnswer ? '需要你回答' : detail ? STATUS_LABEL[status] || status : '—'; + els.detailTime.textContent = detail ? new Date(detail.updatedAtMs).toLocaleString('zh-CN') : '—'; + els.detailError.textContent = detail?.error || app.detailError || ''; + els.detailError.hidden = !els.detailError.textContent; + const reply = needsAnswer ? '需要你回答' : detail?.lastAssistantText || ''; + if (els.detailReply.textContent !== reply) els.detailReply.textContent = reply; + if (panelDetailId !== app.selectedSessionId) { els.detailReply.scrollTop = 0; panelDetailId = app.selectedSessionId; } + els.detailNote.textContent = app.source === 'demo' ? '资源动作预览,不提供真实任务概览。' + : app.availability !== 'ready' ? availabilityText() + : app.detailLoading ? (detail ? '正在更新…' : '正在读取任务…') : !detail ? '选择任务后查看最近回复。' + : needsAnswer ? '请回到 MiniMax Code 的对应聊天回答。' + : detail.lastAssistantText ? '显示任务的最近回复。后续处理请回到对应聊天。' : '这项任务暂时没有可查看的回复。'; +} + +function initWorkbench() { + workbenchView = createCompactPetView({ container: els.workbench, + onClose: () => { void closeWorkbench(); }, + onSelectTask: openTaskPreview, + }); + notificationPreviews = createNotificationPreviews({ agent, onChange: () => { renderBubble(); renderCompactView(); } }); + if (win) workbenchWindow = createWorkbenchWindow({ windowAPI: win, + screenRect: () => ({ x: screen.availLeft, y: screen.availTop, width: screen.availWidth, height: screen.availHeight }), + isCurrent: () => !disposed && app.mode === 'window', expandedSize: compactBubbleSize, + }); + disposers.push(() => { notificationPreviews.dispose(); workbenchWindow?.dispose(); workbenchView.dispose(); }); +} + +function renderCompactView() { + const notice = reminders.getState(); + notificationPreviews?.update({ availability: app.availability, items: notificationItems(notice.items) }); + const previews = notificationPreviews?.getState().previews ?? {}; + workbenchView?.update({ reminders: { ...notice, items: notice.items.map((item) => ({ ...item, previewText: item.needsAnswer ? '需要你回答' : previews[item.sessionId] || '' })) }, allSessions: app.sessions, + taskSelection: { availability: app.availability, selectedSessionId: app.selectedSessionId, session: app.sessions.find((row) => row.sessionId === app.selectedSessionId), detail: app.detail, detailLoading: app.detailLoading, detailError: app.detailError } }); +} + +function openTaskPreview(sessionId) { + if (!activitySource || app.availability !== 'ready') { showTransient(availabilityText(), 'error'); return; } + try { void activitySource.selectSession(sessionId).catch((error) => showTransient(`无法读取任务(${errCode(error)})。`, 'error')); } + catch (error) { showTransient(`无法读取任务(${errCode(error)})。`, 'error'); return; } + void openWorkbench('detail'); +} + +function focusFullPanel(view) { + if (view === 'draft') { if (canAppend) panelNavigation?.showDraft(); } + else if (view === 'detail') panelNavigation?.showTaskDetail(); + else { panelNavigation?.showTab('tasks'); panelNavigation?.showTaskList(); } +} + +function openFullPanel(view = 'tasks') { + const result = app.mode === 'window' ? returnToPanel() : Promise.resolve(true); + return result.then((ok) => { if (ok && !disposed && app.mode !== 'window') focusFullPanel(view); }); +} + +async function openWorkbench(view = 'tasks') { + if (disposed) return; + app.workbenchView = view; + if (app.mode !== 'window') { + focusFullPanel(view); + return; + } + if (view === 'draft') return openFullPanel('draft'); + const token = ++app.workbenchRevision; + app.compacting = false; + const alreadyOpen = workbenchView.opened; + app.workbenchOpen = true; + closeComposer(); closeMenu(); hideQuickbar(); stopLook(); + setClickThrough(false); + const waitingAt = Date.now(); + while (app.restoringPosition && token === app.workbenchRevision && !disposed && Date.now() - waitingAt < 3000) await new Promise((resolve) => setTimeout(resolve, 20)); + if (disposed || token !== app.workbenchRevision || app.mode !== 'window' || !app.workbenchOpen) return; + const result = app.restoringPosition ? { ok: false, code: 'window_restoring' } : alreadyOpen ? { ok: true } : await workbenchWindow.setExpanded(true); + if (disposed || token !== app.workbenchRevision || app.mode !== 'window' || !app.workbenchOpen) return; + if (!result.ok) { + app.workbenchOpen = false; + showTransient(`暂时无法展开(${result.code}),可收回面板继续使用。`, 'error'); + refreshInteractivity(); return; + } + workbenchView.open(view); + root.classList.add('workbench-open'); + applyActivity(); +} + +async function closeWorkbench() { + const token = ++app.workbenchRevision; + app.compacting = true; + app.workbenchOpen = false; + workbenchView.close(); + root.classList.remove('workbench-open'); + applyActivity(); + const result = await workbenchWindow?.setExpanded(false); + if (token === app.workbenchRevision) app.compacting = false; + if (!disposed && token === app.workbenchRevision && app.mode === 'window' && !app.workbenchOpen) { + if (result && !result.ok && !['stale', 'superseded', 'cancelled'].includes(result.code)) showTransient(`窗口大小暂未恢复(${result.code}),仍可继续操作。`, 'error'); + const current = () => !disposed && token === app.workbenchRevision && app.mode === 'window' && !app.workbenchOpen; + callBridge(() => win.getState()).then((state) => { if (current()) onWindowState(state); }, (error) => { if (current()) logError('get_state_after_collapse')(error); }); + refreshInteractivity(); + } +} + +function renderReminders() { + const state = reminders.getState(); + renderCompactView(); + els.reminderNote.textContent = state.items.length ? '按最新活动显示;静音只影响桌宠本次提示。' : '当前没有需要提示的活动。历史状态仍在全部会话中。'; + const existing = new Map([...els.reminderList.children].map((node) => [node.dataset.sessionId, node])); + const nodes = state.items.slice(0, 10).map((item) => { + let li = existing.get(item.sessionId); + if (!li) { + li = document.createElement('li'); li.className = 'reminder-item'; li.dataset.sessionId = item.sessionId; + const title = document.createElement('button'); title.type = 'button'; title.className = 'reminder-title'; + title.addEventListener('click', () => { selectSession(item.sessionId); }); + const status = document.createElement('span'); status.className = 'pill'; + const actions = document.createElement('div'); actions.className = 'row'; + const mute = document.createElement('button'); mute.type = 'button'; mute.className = 'btn small'; mute.textContent = '本次静音'; + mute.addEventListener('click', () => { reminders.mute(item.sessionId); applyActivity(); }); + actions.append(mute); li.append(title, status, actions); + } + li.children[0].textContent = item.title; + li.children[0].setAttribute('aria-label', `查看提醒:${item.title}`); + li.children[1].textContent = item.needsAnswer ? '需要你回答' : STATUS_LABEL[item.displayStatus]; li.children[1].dataset.status = item.needsAnswer ? 'needs_input' : item.displayStatus; + li.children[2].children[0].setAttribute('aria-label', `本次静音:${item.title}`); + return li; + }); + if (!nodes.length) { const empty = document.createElement('li'); empty.className = 'muted'; empty.textContent = '暂无当前提醒'; nodes.push(empty); } + const focused = document.activeElement; + if (nodes.length !== els.reminderList.children.length || nodes.some((node, i) => node !== els.reminderList.children[i])) els.reminderList.replaceChildren(...nodes); + if (focused?.isConnected && els.reminderList.contains(focused)) focused.focus({ preventScroll: true }); + els.mutedReminders.hidden = !state.mutedIds.length; + const mutedButtons = new Map([...els.mutedList.children].map((button) => [button.dataset.sessionId, button])); + const mutedFocus = document.activeElement; + const mutedNodes = state.mutedIds.map((id) => { + let button = mutedButtons.get(id); + if (!button) { + button = document.createElement('button'); button.type = 'button'; button.className = 'btn small'; button.dataset.sessionId = id; + button.addEventListener('click', () => { reminders.unmute(id); applyActivity(); }); + } + button.textContent = `恢复提醒:${app.sessions.find((row) => row.sessionId === id)?.title || '该会话'}`; + return button; + }); + if (mutedNodes.length !== els.mutedList.children.length || mutedNodes.some((node, i) => node !== els.mutedList.children[i])) els.mutedList.replaceChildren(...mutedNodes); + if (mutedFocus?.isConnected && els.mutedList.contains(mutedFocus)) mutedFocus.focus({ preventScroll: true }); +} + +function initSpeech() { + const labels = { requesting: '正在请求麦克风…', recording: '正在录音,请点击停止。', transcribing: '正在转写…' }; + speechInput = createSpeechInput({ + getMiniApp: () => window.mcode?.miniapp, + onState(value) { + speechState = value.state; + const busy = !['idle', 'error'].includes(value.state); + els.speechStart.disabled = busy; + els.speechStop.disabled = value.state !== 'recording'; + els.speechCancel.disabled = !busy; + els.draftSpeech.disabled = busy || !speechInput?.isAvailable(); + els.popoutBtn.disabled = busy || app.modeSwitching; + setMsg(els.speechMsg, value.state === 'error' ? `语音未完成(${value.errorCode || 'unknown'}),已有文字保持不变。` : labels[value.state] || '转写只填入原属草稿区,不会自动追加或发送。', value.state === 'error' ? 'error' : 'info'); + if (value.state !== 'transcribing') els.speechPartial.textContent = ''; + }, + onPartial(text) { els.speechPartial.textContent = `转写处理中:${text}`; }, + onText(text) { + if (disposed || !text) return; + draftController.syncText('panel', els.talkText.value); + els.talkText.value = draftController.appendText('panel', `${els.talkText.value ? '\n' : ''}${text}`); + setMsg(els.talkMsg, '转写已填入原聊天草稿区。确认后可添加到原聊天输入框。', 'ok'); + }, + }); + const available = speechInput.isAvailable(); + els.speechControls.hidden = !available; + els.draftSpeech.hidden = !available; + setMsg(els.speechMsg, available ? '转写只填入原属草稿区,不会自动追加或发送。' : '当前环境不支持语音转写,可直接输入文字。'); + listen(els.speechStart, 'click', () => { startSpeech(); }); + listen(els.draftSpeech, 'click', () => startSpeech()); + listen(els.speechStop, 'click', () => { void speechInput.stop(); }); + listen(els.speechCancel, 'click', () => { speechInput.cancel(); }); + disposers.push(() => speechInput.dispose()); +} + +function startSpeech() { + if (!['idle', 'error'].includes(speechState)) return; + void speechInput.start(); +} + +function renderScale() { + root.style.setProperty('--minimax-pet-height', `${208 * prefs.scale}px`); + compactBubbleSize.height = Math.max(340, Math.ceil(208 * prefs.scale) + 232); + syncSegmented(els.scaleGroup, prefs.scale); +} + +function renderGallery() { + const focusedPetId = document.activeElement?.dataset?.petId; + const items = app.pets.map((pet) => { + const selected = app.pet?.id === pet.id; + const li = document.createElement('li'); + li.className = 'pet-card'; + li.dataset.selected = String(selected); + + const pick = document.createElement('button'); + pick.type = 'button'; + pick.className = 'pet-pick'; + pick.dataset.petId = pet.id; + pick.setAttribute('aria-pressed', String(selected)); + pick.setAttribute('aria-label', `使用 ${pet.displayName}`); + const thumb = document.createElement('span'); + thumb.className = 'thumb'; + thumb.setAttribute('aria-hidden', 'true'); + const url = app.spriteUrls.get(pet.id); + if (url) { + thumb.style.backgroundImage = `url(${JSON.stringify(url)})`; + thumb.style.backgroundSize = `${1536 * THUMB_SCALE}px auto`; + } + const info = document.createElement('span'); + info.className = 'pet-info'; + const name = document.createElement('span'); + name.className = 'pet-card-name'; + name.textContent = pet.displayName; + name.title = pet.description || pet.displayName; + const meta = document.createElement('span'); + meta.className = 'pet-card-meta'; + const ver = document.createElement('span'); + ver.className = 'badge'; + ver.textContent = `v${pet.version}`; + const src = document.createElement('span'); + src.className = 'badge'; + src.textContent = pet.source === 'imported' ? '导入' : '内置'; + meta.append(ver, src); + if (selected) { + const on = document.createElement('span'); + on.className = 'badge is-selected'; + on.textContent = '使用中'; + meta.append(on); + } + info.append(name, meta); + pick.append(thumb, info); + pick.addEventListener('click', () => selectPet(pet.id)); + li.append(pick); + + if (pet.source === 'imported') { + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'btn small danger'; + del.textContent = '删除'; + del.setAttribute('aria-label', `删除 ${pet.displayName}`); + del.addEventListener('click', () => deletePet(pet)); + li.append(del); + } + return li; + }); + if (!items.length) { + const li = document.createElement('li'); + li.className = 'muted'; + li.textContent = '还没有可用的宠物'; + items.push(li); + } + els.gallery.replaceChildren(...items); + if (focusedPetId) els.gallery.querySelector(`[data-pet-id="${CSS.escape(focusedPetId)}"]`)?.focus(); +} + +function buildSegmented(fieldset, name, options, onChange) { + for (const opt of options) { + const label = document.createElement('label'); + label.className = 'seg-option'; + const input = document.createElement('input'); + input.type = 'radio'; + input.name = name; + input.value = String(opt.value); + input.addEventListener('change', () => { + if (input.checked) onChange(opt.value); + }); + const span = document.createElement('span'); + span.textContent = opt.label; + label.append(input, span); + fieldset.append(label); + } +} + +function syncSegmented(fieldset, value) { + for (const input of fieldset.querySelectorAll('input')) input.checked = input.value === String(value); +} + +// --------------------------------------------------------------------------- +// Event wiring +// --------------------------------------------------------------------------- + +function bindPanel() { + buildSegmented( + els.demoStatus, + 'demo-status', + DEMO_ORDER.map((s) => ({ value: s, label: STATUS_LABEL[s] })), + setDemoStatus, + ); + buildSegmented(els.scaleGroup, 'scale', SCALES, setScale); + listen(els.activityRetry, 'click', () => { void activitySource?.refresh(); }); + listen(els.clearCompleted, 'click', () => { activitySource?.clearCompleted(); }); + listen(els.popoutBtn, 'click', popOut); + listen(els.waveBtn, 'click', () => playOneShot('waving')); + listen(els.jumpBtn, 'click', () => playOneShot('jumping')); + + listen(els.importPick, 'click', () => els.importInput.click()); + listen(els.importInput, 'change', () => { + const files = els.importInput.files; + if (files?.length) handleImportFiles(files).catch(logError('import_files')); + els.importInput.value = ''; + }); + listen(els.dropzone, 'dragover', (e) => { + e.preventDefault(); + els.dropzone.classList.add('is-over'); + }); + listen(els.dropzone, 'dragleave', (e) => { + if (!els.dropzone.contains(e.relatedTarget)) els.dropzone.classList.remove('is-over'); + }); + listen(els.dropzone, 'drop', (e) => { + e.preventDefault(); + els.dropzone.classList.remove('is-over'); + const files = e.dataTransfer?.files; + if (files?.length) handleImportFiles(files).catch(logError('import_drop')); + }); + // A stray drop elsewhere must not navigate the managed page to the file. + listen(document, 'dragover', (e) => e.preventDefault()); + listen(document, 'drop', (e) => e.preventDefault()); + listen(els.importCancel, 'click', () => { + panelNavigation?.closeImport(); + }); + listen(els.importConfirm, 'click', () => confirmImport()); +} + +function bindDesk() { + listen(document, 'mousemove', onDocumentMouseMove); + listen(document, 'mouseout', (e) => { + if (!e.relatedTarget) onPointerLeave(); + }); + listen(root, 'mouseleave', onPointerLeave); + listen(window, 'blur', () => { + if (app.mode === 'window') closeMenu(); + }); + + listen(els.sprite, 'pointerenter', () => { if (app.mode !== 'window') return; app.hovered = true; applyActivity(); }); + listen(els.sprite, 'pointerleave', () => { app.hovered = false; applyActivity(); }); + listen(els.sprite, 'pointerdown', onSpritePointerDown); + listen(els.sprite, 'pointermove', onSpritePointerMove); + listen(els.sprite, 'pointerup', (e) => finishDrag(e, false)); + listen(els.sprite, 'pointercancel', (e) => finishDrag(e, true)); + listen(els.sprite, 'keydown', (e) => { + if (app.mode !== 'window') return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + noteInteraction(); + void openWorkbench('tasks'); + } else if (e.key === 'F10' && e.shiftKey) { + e.preventDefault(); + const r = els.sprite.getBoundingClientRect(); + openMenu(r.left + r.width / 2, r.top + r.height / 2); + } + }); + + listen(document, 'contextmenu', (e) => { + if (app.mode !== 'window') return; + if (els.workbench.contains(e.target)) return; + e.preventDefault(); + if (!els.desk.contains(e.target) || els.menu.contains(e.target)) return; + let { clientX: x, clientY: y } = e; + if (x === 0 && y === 0) { + // Keyboard ContextMenu key: anchor to the sprite. + const r = els.sprite.getBoundingClientRect(); + x = r.left + r.width / 2; + y = r.top + r.height / 2; + } + openMenu(x, y); + }); + + listen(document, 'pointerdown', (e) => { + if (app.mode !== 'window') return; + noteInteraction(); + if (app.menuOpen && !els.menu.contains(e.target)) closeMenu(); + }); + + listen(document, 'keydown', (e) => { + if (app.mode !== 'window' || e.key !== 'Escape') return; + if (app.workbenchOpen) { void closeWorkbench(); } + else if (app.composerOpen) closeComposer({ restoreFocus: true }); + else hideQuickbar(); + }); + + listen(els.menu, 'keydown', onMenuKeyDown); + + listen(els.quickTasks, 'click', () => { void openWorkbench('tasks'); }); + listen(els.bubble, 'click', () => { void openWorkbench('tasks'); }); + listen(els.bubble, 'keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void openWorkbench('tasks'); } + }); + listen(els.quickMenu, 'click', () => { + const r = els.quickMenu.getBoundingClientRect(); + openMenu(r.left + r.width / 2, r.bottom); + }); + listen(els.quickbar, 'pointerenter', armQuickbarHide); + + listen(els.composerClose, 'click', () => closeComposer({ restoreFocus: true })); + listen(els.composer, 'focusin', () => { + app.composerFocused = true; + refreshInteractivity(); + }); + listen(els.composer, 'focusout', (e) => { + if (els.composer.contains(e.relatedTarget)) return; + app.composerFocused = false; + refreshInteractivity(); + }); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +function showFatal(text) { + root.classList.remove('booting'); + els.loadError.textContent = text; + els.loadError.hidden = false; +} + +async function boot() { + player = new SpritePlayer(els.sprite, { scale: 1 }); + animationGate = createAnimationGate(player, logError('play_activity')); + player.setReducedMotion(reducedMotion.matches); + panelNavigation = createPanelNavigation({ + panel: els.panel, + beforeCloseImport() { + if (importState.busy) return false; + resetImport(); setMsg(els.importMsg, '已取消导入'); return true; + }, + onLeaveDraft() { + if (!['idle', 'error'].includes(speechState)) speechInput?.cancel(); + }, + }); + disposers.push(() => panelNavigation.dispose()); + listen(reducedMotion, 'change', (e) => { + player.setReducedMotion(e.matches); + importState.player?.setReducedMotion(e.matches); + if (e.matches) stopLook(); + }); + + bindPanel(); + initWorkbench(); + bindDesk(); + bindDraftForms(); + initSpeech(); + listen(window, 'pagehide', disposeApp); + await initPreferences(); + if (disposed) return; + await initMode(); + if (disposed) return; + root.classList.remove('booting'); + await initActivitySource(); + renderAll(); + + try { + await refreshPets(); + } catch (error) { + showLoadError(error); + } + const initial = app.pets.find((p) => p.id === prefs.petId) ?? app.pets[0]; + if (initial) await selectPet(initial.id); + else if (!app.loadError) showLoadError({ code: 'no_pets', message: '没有可用的宠物' }); + renderAll(); + noteInteraction(); + +} + +function disposeApp() { + if (disposed) return; + disposed = true; + for (const dispose of disposers.splice(0).reverse()) { + try { dispose(); } catch (error) { console.warn('[pet] dispose_failed:', errCode(error), error); } + } + for (const key of ['positionTimer', 'ctWatchdog', 'menuCloseTimer', 'quickbarTimer', 'transientTimer']) { + clearTimeout(app[key]); + clearInterval(app[key]); + app[key] = null; + } + clearInterval(importState.cycleTimer); + importState.player?.destroy(); + if (importState.blobUrl) URL.revokeObjectURL(importState.blobUrl); + player?.destroy(); +} + +try { + const mod = await import('/assets/pet-player.js'); + ({ STATES, SpritePlayer, loadPet, loadPetFromParts } = mod); + if (typeof SpritePlayer !== 'function' || typeof loadPet !== 'function' || typeof loadPetFromParts !== 'function') { + throw Object.assign(new Error('pet-player.js 缺少约定的导出'), { code: 'player_module_invalid' }); + } +} catch (error) { + console.error('[pet] player_module_unavailable:', errCode(error), error); + SpritePlayer = null; + showFatal(`播放器加载失败(${error?.code ?? 'player_module_unavailable'})`); +} + +if (SpritePlayer) { + boot().catch((error) => { + console.error('[pet] boot_failed:', errCode(error), error); + showFatal(`启动失败(${errCode(error)})`); + }); +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-player.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-player.js new file mode 100644 index 0000000..cfaa102 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-player.js @@ -0,0 +1,383 @@ +// Codex-compatible atlas player. Zero dependencies; no DOM access at import time. +// Desktop playback: three action cycles, then slow idle; explicit loop is for previews. + +export const CELL = { width: 192, height: 208, columns: 8 }; + +export const STATES = ['idle', 'running-right', 'running-left', 'waving', 'jumping', 'failed', 'waiting', 'running', 'review']; + +const frames = (count, ms, last) => [...Array(count - 1).fill(ms), last]; + +/** @type {Record} */ +export const ANIMATIONS = { + idle: { row: 0, durations: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, durations: frames(8, 120, 220) }, + 'running-left': { row: 2, durations: frames(8, 120, 220) }, + waving: { row: 3, durations: frames(4, 140, 280) }, + jumping: { row: 4, durations: frames(5, 140, 280) }, + failed: { row: 5, durations: frames(8, 140, 240) }, + waiting: { row: 6, durations: frames(6, 150, 260) }, + running: { row: 7, durations: frames(6, 120, 220) }, + review: { row: 8, durations: frames(6, 150, 280) }, +}; + +const ATLAS_WIDTH = CELL.width * CELL.columns; // 1536 +const ROWS = { 1: 9, 2: 11 }; +const LOOK_ROW = 9; +const ALPHA_THRESHOLD = 24; + +/** 1536×1872 → 1, 1536×2288 → 2, otherwise null. */ +export function atlasVersionFromSize(width, height) { + if (width !== ATLAS_WIDTH) return null; + if (height === ROWS[1] * CELL.height) return 1; + if (height === ROWS[2] * CELL.height) return 2; + return null; +} + +function petError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0; + +function validatePetJson(petJson) { + if (!petJson || typeof petJson !== 'object' || Array.isArray(petJson)) { + throw petError('pet_json_invalid', 'pet.json must be a JSON object'); + } + for (const key of ['id', 'displayName', 'spritesheetPath']) { + if (!isNonEmptyString(petJson[key])) throw petError('pet_json_invalid', `pet.json "${key}" must be a non-empty string`); + } + if (petJson.description !== undefined && typeof petJson.description !== 'string') { + throw petError('pet_json_invalid', 'pet.json "description" must be a string'); + } + const v = petJson.spriteVersionNumber; + if (v !== undefined && v !== 1 && v !== 2) { + throw petError('pet_json_invalid', `pet.json "spriteVersionNumber" must be 1 or 2 (got ${JSON.stringify(v)})`); + } + return v === 2 ? 2 : 1; +} + +function loadImage(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(petError('sprite_unreadable', `Could not load spritesheet: ${url}`)); + image.src = url; + }); +} + +/** + * @typedef {{ id: string, displayName: string, description: string, spriteUrl: string, version: 1|2, image: HTMLImageElement }} LoadedPet + * @param {any} petJson parsed pet.json object + * @param {string} spriteUrl directly loadable image URL (http(s):, blob:, data:) + * @returns {Promise} + */ +export async function loadPetFromParts(petJson, spriteUrl) { + const declared = validatePetJson(petJson); + const image = await loadImage(spriteUrl); + const { naturalWidth: w, naturalHeight: h } = image; + const actual = atlasVersionFromSize(w, h); + if (actual === null) { + throw petError('sprite_size_invalid', `Spritesheet is ${w}×${h}; expected 1536×1872 (v1) or 1536×2288 (v2)`); + } + if (actual !== declared) { + throw petError('sprite_version_mismatch', `pet.json declares v${declared} but spritesheet size ${w}×${h} is v${actual}`); + } + return { + id: petJson.id, + displayName: petJson.displayName, + description: petJson.description ?? '', + spriteUrl, + version: actual, + image, + }; +} + +/** @param {string} petJsonUrl @returns {Promise} */ +export async function loadPet(petJsonUrl) { + const absolute = new URL(petJsonUrl, globalThis.document?.baseURI ?? globalThis.location?.href).href; + let text; + try { + const res = await fetch(absolute); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + text = await res.text(); + } catch (cause) { + throw petError('pet_json_unreadable', `Could not fetch ${absolute}: ${cause.message}`); + } + let petJson; + try { + petJson = JSON.parse(text); + } catch (cause) { + throw petError('pet_json_invalid', `pet.json is not valid JSON: ${cause.message}`); + } + validatePetJson(petJson); + return loadPetFromParts(petJson, new URL(petJson.spritesheetPath, absolute).href); +} + +// One ImageData per sprite image, shared across players. Value null = sampling unavailable (tainted etc.). +const pixelCache = new WeakMap(); + +function pixelsFor(image) { + if (!pixelCache.has(image)) { + let pixels = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, 0, 0); + pixels = ctx.getImageData(0, 0, canvas.width, canvas.height); + } catch { + pixels = null; + } + pixelCache.set(image, pixels); + } + return pixelCache.get(image); +} + +const assertState = (state) => { + if (!STATES.includes(state)) throw new RangeError(`Unknown pet state: ${state}`); +}; + +export class SpritePlayer { + /** @param {HTMLElement} element empty element; player owns its width/height/background-* */ + constructor(element, { scale = 1 } = {}) { + this._el = element; + this._pet = null; + this._scale = clampScale(scale); + this._reduced = false; + this._lookIndex = null; + this._state = 'idle'; // requested semantic state, independent of the rendered pose + this._pose = 'idle'; + this._loop = false; + this._repeat = 3; + this._round = 0; + this._transientThen = null; + this._col = 0; + this._resolve = null; // pending transient resolver + this._timer = null; + this._due = 0; + this._remaining = null; // ms left on the current frame while paused (hidden) + this._destroyed = false; + this._onVisibility = () => this._handleVisibility(); + document.addEventListener('visibilitychange', this._onVisibility); + } + + /** @param {LoadedPet} loadedPet */ + setPet(loadedPet) { + if (this._destroyed) return; + this._pet = loadedPet; + if (loadedPet.version !== 2) this._lookIndex = null; + this._restart(); + } + + get state() { return this._state; } + + /** Actual rendered pose; 'look' denotes the v2 static direction override. */ + get pose() { + if (this._reduced) return this._state; + return this._lookIndex === null ? this._pose : 'look'; + } + + get frame() { + if (this._reduced) return { row: ANIMATIONS[this._state].row, col: 0 }; + if (this._lookIndex !== null) return { row: LOOK_ROW + Math.floor(this._lookIndex / 8), col: this._lookIndex % 8 }; + return { row: ANIMATIONS[this._pose].row, col: this._col }; + } + + get supportsLook() { return this._pet?.version === 2; } + + /** + * Request a normal playback track. Non-idle actions play three cycles, then + * render slow idle while `state` retains the request. Callers deduplicate + * their task/event identity; an explicit repeat call intentionally restarts. + * @returns {Promise} resolves immediately + */ + play(state, { loop = false } = {}) { + assertState(state); + if (this._destroyed) return Promise.resolve(); + this._settle(); + this._setNormal(state, loop); + this._restart(); + return Promise.resolve(); + } + + /** + * Temporary action, then a normal track for `then`. Resolves when the action + * finishes or is interrupted. Reduced motion / no loaded pet finishes it + * immediately so callers never wait for a non-existent animation timer. + * @returns {Promise} + */ + playTransient(state, { then = 'idle', repeat = 3 } = {}) { + assertState(state); + assertState(then); + if (!Number.isSafeInteger(repeat) || repeat < 1) throw new RangeError('repeat must be a positive integer'); + if (this._destroyed) return Promise.resolve(); + this._settle(); + this._state = state; + this._loop = false; + this._repeat = repeat; + this._transientThen = then; + const done = new Promise((resolve) => { this._resolve = resolve; }); + this._restart(); + return done; + } + + look(angleDegOrNull) { + if (this._destroyed || !this.supportsLook) return; + if (angleDegOrNull === null || angleDegOrNull === undefined || !Number.isFinite(angleDegOrNull)) { + if (this._lookIndex === null) return; + this._lookIndex = null; + this._restart(); + return; + } + const index = ((Math.round(angleDegOrNull / 22.5) % 16) + 16) % 16; + const wasLooking = this._lookIndex !== null; + this._lookIndex = index; + if (!wasLooking) { + this._stopTimer(); + if (this._transientThen !== null) this._finishTransient(); + } + this._render(); + } + + setScale(scale) { + this._scale = clampScale(scale); + this._render(); + } + + setReducedMotion(enabled) { + if (this._destroyed || this._reduced === !!enabled) return; + this._reduced = !!enabled; + this._restart(); + } + + isOpaqueAt(clientX, clientY) { + if (!this._pet || this._destroyed) return false; + const rect = this._el.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) return false; + const pixels = pixelsFor(this._pet.image); + if (!pixels) return true; // bounding-box fallback + const { row, col } = this.frame; + const px = col * CELL.width + Math.min(CELL.width - 1, Math.floor((x * CELL.width) / rect.width)); + const py = row * CELL.height + Math.min(CELL.height - 1, Math.floor((y * CELL.height) / rect.height)); + return pixels.data[(py * pixels.width + px) * 4 + 3] > ALPHA_THRESHOLD; + } + + destroy() { + if (this._destroyed) return; + this._stopTimer(); + this._settle(); + document.removeEventListener('visibilitychange', this._onVisibility); + const s = this._el.style; + for (const prop of ['width', 'height', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat']) s[prop] = ''; + this._destroyed = true; + this._pet = null; + } + + // --- internals --- + + _settle() { + const resolve = this._resolve; + this._resolve = null; + resolve?.(); + } + + _stopTimer() { + clearTimeout(this._timer); + this._timer = null; + this._remaining = null; + } + + _setNormal(state, loop = false) { + this._state = state; + this._pose = state; + this._loop = !!loop; + this._repeat = 3; + this._round = 0; + this._transientThen = null; + this._col = 0; + } + + _finishTransient() { + const then = this._transientThen; + if (then === null) return; + this._setNormal(then); + this._settle(); + } + + /** Rebuild the requested track from its first frame (also used on look exit). */ + _restart() { + this._stopTimer(); + this._pose = this._state; + this._round = 0; + this._col = 0; + if (!this._pet || this._reduced || this._lookIndex !== null) { + if (this._transientThen !== null) this._finishTransient(); + this._render(); + return; + } + this._render(); + this._schedule(ANIMATIONS[this._pose].durations[0]); + } + + _schedule(ms) { + if (document.hidden) { + this._remaining = ms; + return; + } + this._due = performance.now() + ms; + this._timer = setTimeout(() => this._tick(), ms); + } + + _tick() { + this._timer = null; + if (this._destroyed) return; + this._col += 1; + if (this._col >= ANIMATIONS[this._pose].durations.length) { + this._col = 0; + this._round += 1; + if (!this._loop && this._round >= this._repeat) { + if (this._transientThen !== null) this._finishTransient(); + else this._pose = 'idle'; + } + } + this._render(); + this._schedule(ANIMATIONS[this._pose].durations[this._col]); + } + + _handleVisibility() { + if (this._destroyed) return; + if (document.hidden) { + if (this._timer === null) return; + clearTimeout(this._timer); + this._timer = null; + this._remaining = Math.max(0, this._due - performance.now()); + } else if (this._remaining !== null) { + const ms = this._remaining; + this._remaining = null; + this._schedule(ms); + } + } + + _render() { + if (!this._pet || this._destroyed) return; + const k = this._scale; + const { row, col } = this.frame; + const s = this._el.style; + s.width = `${CELL.width * k}px`; + s.height = `${CELL.height * k}px`; + s.backgroundImage = `url("${this._pet.spriteUrl}")`; + s.backgroundRepeat = 'no-repeat'; + s.backgroundSize = `${ATLAS_WIDTH * k}px ${ROWS[this._pet.version] * CELL.height * k}px`; + s.backgroundPosition = `-${col * CELL.width * k}px -${row * CELL.height * k}px`; + } +} + +function clampScale(scale) { + const n = Number(scale); + return Number.isFinite(n) ? Math.min(1.5, Math.max(0.5, n)) : 1; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-presentation.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-presentation.mjs new file mode 100644 index 0000000..015d63c --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-presentation.mjs @@ -0,0 +1,29 @@ +export function aggregateText(counts) { + if (!counts) return ''; + return `${counts.total} 个可见任务 · ${counts.running} 运行中 · ${counts.completed} 本次完成 · ${counts.error} 异常 · ${counts.aborted} 已停止 · ${counts.idle} 空闲`; +} + +export function notificationItems(items) { + return items.filter((item) => item.needsAnswer === true || ['completed', 'error'].includes(item.displayStatus)) + .sort((a, b) => Number(b.needsAnswer === true) - Number(a.needsAnswer === true)).slice(0, 3); +} + +export function notificationText(item, previews = {}) { + if (item.needsAnswer === true) return '需要你回答'; + return previews[item.sessionId] || (item.displayStatus === 'error' ? '任务出现异常' : '本次观察到任务完成'); +} + +// A task snapshot may refresh while its animation has already settled into calm idle. +export function createAnimationGate(player, onError = () => {}) { + let previous = null; + return { + apply(state, key, { loop = false } = {}) { + const identity = `${key}:${state}:${loop}`; + if (previous === identity && player.state === state) return false; + previous = identity; + void player.play(state, { loop }).catch(onError); + return true; + }, + invalidate() { previous = null; }, + }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css b/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css new file mode 100644 index 0000000..2b24b95 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css @@ -0,0 +1,1680 @@ +/* MiniMax Pet — desktop pet: styles for the Codex Pet Mini App page (inline panel + window mode). */ + +: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; + --pet-shadow: 0 0 20px rgb(10 10 10 / 8%); + --pet-bubble-shadow: 0 1px 8px rgb(10 10 10 / 14%); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --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; + --pet-shadow: 0 0 20px rgb(0 0 0 / 30%); + --pet-bubble-shadow: 0 1px 8px rgb(0 0 0 / 40%); + color-scheme: dark; + } +} + +[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; + --pet-shadow: 0 0 20px rgb(0 0 0 / 30%); + --pet-bubble-shadow: 0 1px 8px rgb(0 0 0 / 40%); + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + font-family: ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 22px; + color: var(--mcode-text); + background: var(--mcode-bg); +} + +html.booting body { + visibility: hidden; + background: transparent; +} + +code { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.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; +} + +/* ------------------------------------------------------------------ */ +/* Inline panel */ +/* ------------------------------------------------------------------ */ + +.panel { + max-width: 1040px; + margin: 0 auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.panel-title { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.header-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + max-width: 320px; +} + +h1 { + margin: 0; + font-size: 18px; + line-height: 28px; + font-weight: 600; +} + +h2 { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 500; +} + +.muted { + margin: 0; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +.msg { + margin: 0; + min-height: 0; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); +} +.msg:empty { + display: none; +} +.msg[data-kind='error'] { + color: var(--mcode-danger); +} +.msg[data-kind='ok'] { + color: var(--mcode-success); +} + +.note { + margin: 0; + padding: 8px 12px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +.alert { + padding: 8px 12px; + border: 1px solid var(--mcode-danger); + border-radius: 8px; + background: var(--mcode-surface); + color: var(--mcode-danger); + font-size: 13px; + line-height: 20px; +} + +.badge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px solid var(--mcode-border); + border-radius: 999px; + background: var(--mcode-surface-muted); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; + white-space: nowrap; +} +.badge[data-source='real'] { + color: var(--mcode-success); + border-color: currentColor; +} +.badge[data-source='demo'] { + color: var(--mcode-warning); + border-color: currentColor; +} +.badge.is-selected { + color: var(--mcode-accent); + border-color: currentColor; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; + white-space: nowrap; + cursor: pointer; + transition: + background-color 150ms ease-out, + opacity 150ms ease-out; +} +.btn:hover { + background: var(--mcode-bg-secondary); +} +.btn.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.btn.primary:hover { + opacity: 0.86; +} +.btn.danger { + color: var(--mcode-danger); +} +.btn.small { + height: 28px; + padding: 0 10px; + font-size: 12px; +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +.row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} +.row.end { + justify-content: flex-end; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 4fr); + gap: 16px; + align-items: start; +} + +.side { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + min-width: 0; +} + +.stage { + display: flex; + align-items: center; + justify-content: center; + min-height: 248px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + overflow: hidden; +} + +.stage-status { + margin: 0; + text-align: center; + color: var(--mcode-text-muted); +} + +.stage-card .row { + justify-content: center; +} + +/* Segmented control (radio group) */ +.segmented { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + margin: 0; + padding: 2px; + border: 0; + border-radius: 8px; + background: var(--mcode-bg-secondary); + align-self: flex-start; +} +.seg-option { + position: relative; +} +.seg-option input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; + cursor: pointer; +} +.seg-option span { + display: block; + padding: 4px 12px; + border-radius: 6px; + color: var(--mcode-text-muted); + font-size: 13px; + line-height: 22px; + transition: background-color 150ms ease-out; +} +.seg-option input:checked + span { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 0 0 1px var(--mcode-border); +} +.seg-option input:focus-visible + span { + outline: 2px solid var(--mcode-accent); + outline-offset: 1px; +} + +.demo-controls { + display: flex; + flex-direction: column; + gap: 8px; +} + +.check { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--mcode-text-muted); +} + +.session-list { + max-height: 250px; + overflow: auto; + overscroll-behavior: contain; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.session-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 38px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--mcode-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.session-row:hover { + background: var(--mcode-bg-secondary); +} +.session-row:disabled { + cursor: default; +} +.session-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-current { + font-size: 12px; + color: var(--mcode-accent); +} + +.pill { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid currentColor; + font-size: 12px; + white-space: nowrap; + color: var(--mcode-text-muted); +} +.pill[data-status='running'] { + color: var(--mcode-accent); +} +.pill[data-status='needs_input'] { + color: var(--mcode-warning); +} +.pill[data-status='ready'] { + color: var(--mcode-success); +} +.pill[data-status='blocked'] { + color: var(--mcode-danger); +} + +.talk-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.draft-target-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.target-select { + width: 100%; + min-width: 0; + height: 30px; + padding: 0 6px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + color: var(--mcode-text); + font: inherit; +} + +textarea, +.composer input { + width: 100%; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; +} +textarea { + padding: 8px 12px; + resize: vertical; + min-height: 72px; +} +textarea::placeholder, +.composer input::placeholder { + color: var(--mcode-text-subtle); +} +textarea:focus, +.composer input:focus { + outline: none; + border-color: var(--mcode-accent); +} + +/* Gallery */ +.gallery { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; +} +.pet-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); +} +.pet-card[data-selected='true'] { + border-color: var(--mcode-accent); +} +.pet-pick { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.thumb { + flex: none; + width: calc(192px * 0.375); + height: calc(208px * 0.375); + border-radius: 8px; + background-color: var(--mcode-bg-secondary); + background-repeat: no-repeat; + background-position: 0 0; +} +.pet-info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.pet-card-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} +.pet-card-meta { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +/* Import */ +.dropzone { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + min-height: 72px; + padding: 12px; + border: 1px dashed var(--mcode-border-strong); + border-radius: 12px; + color: var(--mcode-text-muted); + opacity: 0.9; + transition: background-color 150ms ease-out; +} +.dropzone.is-over { + border-color: var(--mcode-accent); + background: var(--mcode-bg-secondary); +} +.import-preview { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: flex-start; +} +.import-stage { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 168px; + height: 180px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + overflow: hidden; +} +.import-info { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 6px; +} +.import-errors { + margin: 0; + padding-left: 18px; + color: var(--mcode-danger); + font-size: 13px; + line-height: 20px; +} +.import-errors:empty { + display: none; +} + +@media (max-width: 768px) { + .panel { + padding: 16px; + } + .layout { + grid-template-columns: 1fr; + } + .header-actions { + align-items: flex-start; + } +} + +/* ------------------------------------------------------------------ */ +/* Window mode (popped out: transparent, frameless, 280×340) */ +/* ------------------------------------------------------------------ */ + +html.mode-window, +html.mode-window body { + height: 100%; + overflow: hidden; + background: transparent; + user-select: none; + -webkit-user-select: none; +} + +.desk { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 8px 8px 4px; + overflow: hidden; +} + +.desk-overlays { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + max-width: 100%; + min-height: 0; + max-height: calc(100% - var(--minimax-pet-height, 104px) - 12px); + overflow-y: auto; +} + +.desk-pet-slot { + flex: none; + display: flex; + justify-content: center; +} + +html.mode-window .pet-sprite { + cursor: grab; + touch-action: none; + border-radius: 8px; +} +html.mode-window .pet-sprite.is-dragging { + cursor: grabbing; +} +html.mode-window .pet-sprite:focus-visible { + outline-offset: -2px; +} + +.bubble { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + max-width: 248px; + min-height: 0; + overflow-y: auto; + padding: 6px 12px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--pet-bubble-shadow); + font-size: 13px; + line-height: 18px; + text-align: center; + overflow-wrap: anywhere; +} +.bubble[data-kind='error'] { + color: var(--mcode-danger); +} +.bubble[data-kind='ok'] { + color: var(--mcode-success); +} +.bubble.is-clickable { + cursor: pointer; +} +.bubble-title { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} +.bubble-link { + padding: 0; + border: 0; + background: transparent; + color: var(--mcode-accent); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.quickbar { + display: flex; + gap: 6px; +} + +.chip { + height: 28px; + padding: 0 10px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--pet-bubble-shadow); + font: inherit; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + transition: background-color 150ms ease-out; +} +.chip:hover { + background: var(--mcode-bg-secondary); +} +.chip.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.composer { + position: absolute; + z-index: 4; + top: 8px; + left: 50%; + transform: translateX(-50%); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + flex: none; + align-items: center; + gap: 4px; + width: 256px; + max-width: 100%; + padding: 4px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + box-shadow: var(--pet-bubble-shadow); +} +.composer input { + grid-column: 1 / -1; + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + font-size: 13px; + user-select: text; + -webkit-user-select: text; +} +.composer .target-select, +#composer-msg { + grid-column: 1 / -1; +} +#composer-origin { + grid-column: 1 / -1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0 4px; +} +#composer-msg { + max-height: 72px; + overflow-y: auto; + margin: 0; + padding: 0 4px; +} +.composer .chip { + box-shadow: none; +} +#composer-send { + justify-self: end; +} + +.menu { + position: absolute; + z-index: 10; + min-width: 160px; + max-width: calc(100% - 8px); + max-height: calc(100% - 8px); + overflow-y: auto; + padding: 4px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + box-shadow: var(--pet-bubble-shadow); +} +.menu-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 32px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--mcode-text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} +.menu-item:hover, +.menu-item:focus { + outline: none; + background: var(--mcode-bg-secondary); +} +.menu-item:focus-visible { + box-shadow: inset 0 0 0 2px var(--mcode-accent); +} +.menu-item[aria-disabled='true'] { + color: var(--mcode-text-subtle); + cursor: default; +} +.mi-mark { + flex: none; + width: 14px; + color: var(--mcode-accent); +} +.mi-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mi-detail { + flex: none; + color: var(--mcode-text-muted); + font-size: 12px; +} +.mi-arrow { + flex: none; + color: var(--mcode-text-muted); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; + } +} + +.last-reply { + max-height: 20rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; + user-select: text; + -webkit-user-select: text; +} + +.aggregate-counts { font-weight: 600; line-height: 1.7; } + +/* Small companion bubble; detailed workflows stay in the main panel. */ +.workbench { + position: absolute; + z-index: 5; + left: 8px; + right: 8px; + bottom: calc(var(--minimax-pet-height, 104px) + 12px); + max-height: 216px; + display: flex; + flex-direction: column; + border: 1px solid var(--mcode-border); + border-radius: 12px; + overflow: hidden; + background: var(--mcode-surface); + box-shadow: var(--pet-bubble-shadow); + color: var(--mcode-text); + text-align: left; + user-select: text; + -webkit-user-select: text; + font-size: 12px; + line-height: 18px; +} +.compact-header { display: flex; align-items: center; justify-content: space-between; min-height: 26px; padding: 2px 8px 0 10px; gap: 6px; } +.compact-state { color: var(--mcode-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.compact-button { border: 0; border-radius: 6px; background: transparent; color: var(--mcode-text); font: inherit; cursor: pointer; padding: 3px 6px; } +.compact-button:hover { background: var(--mcode-bg-secondary); } +.compact-button:disabled { opacity: .4; cursor: default; } +.compact-close { color: var(--mcode-text-muted); margin-left: auto; font-size: 17px; padding: 0 5px; } +.compact-body { min-height: 0; overflow: auto; padding: 2px 10px 8px; } +.compact-hint { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--mcode-text-muted); margin: 5px 0; font-size: 11px; line-height: 16px; } +.compact-hint[data-kind='error'] { color: var(--mcode-danger); } +.compact-footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; } +.compact-link { color: var(--mcode-text-muted); font-size: 11px; } +.compact-task-list { list-style: none; margin: 0; padding: 0; } +.compact-task { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0 4px; padding: 3px 0; } +.compact-task + .compact-task { border-top: 1px solid var(--mcode-border); } +.compact-task-title { text-align: left; padding-left: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compact-task-status { justify-self: end; padding-inline: 6px; text-align: right; white-space: nowrap; color: var(--mcode-text-muted); font-size: 10px; } +.compact-task-preview { grid-column: 1 / -1; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; margin: 0; color: var(--mcode-text-muted); font-size: 11px; line-height: 16px; } +.compact-empty { color: var(--mcode-text-muted); padding: 8px 0; } +.notification-stack { display: flex; flex-direction: column-reverse; gap: 5px; width: 248px; max-height: 216px; overflow-y: auto; } +.pet-notice { display: flex; align-items: start; border: 1px solid var(--mcode-border); border-radius: 10px; background: var(--mcode-surface); box-shadow: var(--pet-bubble-shadow); padding: 6px 7px 6px 10px; flex: none; } +.pet-notice-open { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--mcode-text); text-align: left; font: inherit; font-size: 12px; line-height: 17px; padding: 0; cursor: pointer; } +.pet-notice-open strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.pet-notice-preview { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; color: var(--mcode-text-muted); overflow-wrap: anywhere; } +.compact-detail-title { margin: 0; font-size: 12px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compact-detail-status { margin: 2px 0 2px 6px; font-size: 11px; line-height: 16px; color: var(--mcode-text-muted); } +.compact-detail-reply { margin: 5px 0; max-height: 126px; overflow: auto; overscroll-behavior: contain; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; } +.compact-detail-back { color: var(--mcode-text-muted); font-size: 11px; } +.pet-notice-dismiss { flex: none; border: 0; background: transparent; color: var(--mcode-text-muted); cursor: pointer; font-size: 15px; padding: 0 2px 0 6px; } +.reminder-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.reminder-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 6px; padding: 8px; border-radius: 8px; background: var(--mcode-bg-secondary); } +.reminder-item .row { grid-column: 1 / -1; } +.reminder-title { text-align: left; padding: 0; border: 0; color: var(--mcode-text); background: transparent; font: inherit; font-weight: 500; cursor: pointer; overflow-wrap: anywhere; } +.reminder-heading { justify-content: space-between; } +summary { cursor: pointer; color: var(--mcode-text-muted); font-size: 12px; } +.pill[data-status='error'] { color: var(--mcode-danger); } +.pill[data-status='completed'] { color: var(--mcode-success); } +.pill[data-status='running'] { color: var(--mcode-accent); } +#speech-section { display: flex; flex-direction: column; gap: 6px; } + + + +/* Main panel: appearance, task reading, and settings share one quiet surface. + All selectors are panel-scoped so the desktop companion keeps its layout. */ +html.mode-inline, +html.mode-inline body { + height: 100%; + overflow: hidden; +} + +#panel { + width: 100%; + max-width: 1040px; + height: 100dvh; + min-height: 0; + margin: 0 auto; + padding: 0; + gap: 0; + overflow: hidden; + background: var(--mcode-bg); +} + +#panel .panel-header { + flex: none; + flex-wrap: nowrap; + align-items: center; + padding: 24px 28px 18px; +} + +#panel .panel-title { + gap: 2px; +} + +#panel .header-actions { + flex: none; + max-width: 46%; + align-items: flex-end; +} + +#panel .header-actions .msg { + overflow-wrap: anywhere; + text-align: right; +} + +#panel .panel-tabs { + display: flex; + flex: none; + gap: 24px; + padding: 0 28px; + border-bottom: 1px solid var(--mcode-border); +} + +#panel .panel-tabs [role='tab'] { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 42px; + padding: 0 2px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--mcode-text-muted); + font: inherit; + cursor: pointer; +} + +#panel .panel-tabs [aria-selected='true'] { + border-bottom-color: var(--mcode-text); + color: var(--mcode-text); + font-weight: 600; +} + +#panel .tab-count { + min-width: 18px; + padding: 0 5px; + border-radius: 6px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +#panel > .note, +#panel > .alert { + flex: none; + margin: 12px 28px 0; +} + +#panel .panel-main { + display: block; + flex: 1; + min-height: 0; + overflow: hidden; +} + +#panel .panel-page { + height: 100%; + min-width: 0; + min-height: 0; + padding: 28px; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +#panel .panel-page:focus { + outline-offset: -3px; +} + +#panel h2 { + font-weight: 600; +} + +#panel .appearance-layout { + display: grid; + grid-template-columns: minmax(200px, .85fr) minmax(0, 1.15fr); + align-items: start; + gap: 32px; +} + +#panel .appearance-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + min-width: 0; + padding: 16px 16px 24px; + border-radius: 16px; + background: var(--mcode-bg-secondary); + text-align: center; +} + +#panel #panel-stage { + width: 100%; + min-height: 240px; + background: transparent; +} + +#panel #pet-name { + max-width: 100%; + font-size: 18px; + line-height: 28px; + overflow-wrap: anywhere; +} + +#panel #pet-desc { + max-width: 32ch; + overflow-wrap: anywhere; +} + +#panel .stage-status { + max-width: 100%; + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +#panel .appearance-actions { + justify-content: center; + margin-top: 8px; +} + +#panel .pet-more { + position: relative; +} + +#panel .pet-more > summary { + padding: 7px 6px; + list-style: none; +} + +#panel .pet-more > summary::-webkit-details-marker { + display: none; +} + +#panel .pet-more-content { + position: absolute; + z-index: 2; + top: calc(100% + 4px); + right: 0; + padding: 6px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); +} + +#panel .appearance-gallery { + min-width: 0; +} + +#panel .section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 2px 0 18px; +} + +#panel .gallery { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +#panel .pet-card { + flex-direction: column; + min-width: 0; + padding: 14px 10px; + border-radius: 10px; +} + +#panel .pet-pick { + flex-direction: column; + align-self: stretch; + gap: 8px; + text-align: center; +} + +#panel .pet-info { + align-items: center; + width: 100%; +} + +#panel .pet-card-name { + max-width: 100%; +} + +#panel .pet-card-meta { + justify-content: center; +} + +#panel .pet-card-meta .badge:first-child { + display: none; +} + +#panel .pet-card-meta .badge { + height: 18px; + padding: 0 5px; + border: 0; + border-radius: 4px; + background: transparent; +} + +#panel .pet-card-meta .badge.is-selected { + color: var(--mcode-accent); +} + +#panel #gallery-msg { + margin-top: 12px; +} + +#panel .inline-subview { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 720px; + margin: 0 auto; +} + +#panel .subview-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +#panel .back-button { + align-self: flex-start; + padding-inline: 0; + border: 0; + background: transparent; + color: var(--mcode-text-muted); +} + +#panel .dropzone { + min-height: 132px; + border-color: var(--mcode-border); + opacity: 1; +} + +#panel .import-preview { + gap: 24px; +} + +#panel .import-info { + min-width: min(240px, 100%); + gap: 10px; + overflow-wrap: anywhere; +} + +#panel .task-page { + display: flex; + overflow: hidden; + padding: 20px 28px 0; +} + +#panel .activity-layout { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + min-height: 0; +} + +#panel .task-toolbar { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding-bottom: 16px; + border-bottom: 1px solid var(--mcode-border); +} + +#panel .task-filters { + display: inline-flex; + gap: 2px; + padding: 3px; + border-radius: 8px; + background: var(--mcode-bg-secondary); +} + +#panel .task-filters .btn { + height: 28px; + padding-inline: 10px; + border: 0; + background: transparent; + color: var(--mcode-text-muted); +} + +#panel .task-filters [aria-pressed='true'] { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 0 0 1px var(--mcode-border); +} + +#panel .aggregate-counts { + margin: 0; + font-size: 12px; + font-weight: 400; + line-height: 18px; +} + +#panel .task-panes { + display: grid; + grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); + flex: 1; + min-width: 0; + min-height: 0; +} + +#panel .task-list-pane { + min-width: 0; + min-height: 0; + padding: 18px 20px 24px 0; + border-right: 1px solid var(--mcode-border); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +#panel .task-list-note { + margin-bottom: 12px; +} + +#panel .session-list { + max-height: none; + overflow: visible; + gap: 0; +} + +#panel .session-list > li + li, +#panel .reminder-item + .reminder-item { + border-top: 1px solid var(--mcode-border); +} + +#panel .session-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 3px 12px; + min-height: 52px; + padding: 12px 8px; +} + +#panel .session-row[aria-current='true'] { + background: var(--mcode-bg-secondary); +} + +#panel .session-title { + grid-column: 1; + grid-row: 1; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; + overflow-wrap: anywhere; +} + +#panel .session-current { + grid-column: 1; + grid-row: 2; +} + +#panel .session-row .pill { + grid-column: 2; + grid-row: 1; + justify-self: end; +} + +#panel .pill { + height: auto; + padding: 0; + border: 0; + border-radius: 0; + line-height: 22px; + text-align: right; +} + +#panel .pill[data-status='completed'] { + color: var(--mcode-text-muted); +} + +#panel .reminder-list { + gap: 0; +} + +#panel .reminder-item { + gap: 6px 12px; + padding: 12px 8px; + border-radius: 0; + background: transparent; +} + +#panel .reminder-title { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +#panel .reminder-item > .pill { + justify-self: end; +} + +#panel .reminder-item > .row { + justify-content: flex-end; +} + +#panel .reminder-item .btn { + height: 24px; + padding-inline: 0; + border: 0; + background: transparent; + color: var(--mcode-text-muted); +} + +#panel .task-detail { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + min-height: 0; + padding: 20px 0 24px 24px; + overflow: hidden; +} + +#panel #task-detail-back { + display: none; +} + +#panel #detail-title { + overflow-wrap: anywhere; +} + +#panel .detail-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +#panel #detail-note, +#panel #detail-error { + flex: none; + overflow-wrap: anywhere; +} + +#panel #detail-reply { + flex: 1; + min-height: 0; + max-height: none; + margin: 4px 0 0; + padding-right: 8px; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + line-height: 1.7; +} + +#panel #session-msg { + margin-top: 12px; +} + +#panel .settings-content { + max-width: 720px; + margin: 0 auto; +} + +#panel .setting-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px 24px; + padding: 24px 0; + border-bottom: 1px solid var(--mcode-border); +} + +#panel .setting-row:first-child { + padding-top: 0; +} + +#panel .setting-copy { + min-width: 0; +} + +#panel .setting-copy h2 { + margin-bottom: 4px; + font-size: 14px; + line-height: 22px; +} + +#panel .setting-copy p { + overflow-wrap: anywhere; +} + +#panel .setting-message { + grid-column: 1 / -1; + min-width: 0; +} + +#panel #muted-list { + flex-direction: column; + align-items: flex-start; + margin: 10px 0; +} + +#panel #muted-list .btn { + max-width: 100%; + height: auto; + min-height: 28px; + white-space: normal; + overflow-wrap: anywhere; + text-align: left; +} + +#panel #clear-completed { + margin-top: 4px; +} + +#panel .panel-help { + padding-top: 20px; +} + +#panel .panel-help > summary { + padding-block: 4px; +} + +#panel .help-content { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 16px; + overflow-wrap: anywhere; +} + +#panel .talk-form { + gap: 12px; +} + +@media (max-width: 640px) { + #panel .panel-header { + gap: 12px; + padding: 18px 20px 14px; + } + + #panel .panel-header .panel-title > .muted { + display: none; + } + + #panel .header-actions { + max-width: 50%; + } + + #panel .panel-tabs { + gap: 28px; + padding-inline: 20px; + } + + #panel > .note, + #panel > .alert { + margin-inline: 20px; + } + + #panel .panel-page { + padding: 20px; + } + + #panel .appearance-layout { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } + + #panel .appearance-preview { + gap: 5px; + padding: 6px 12px 18px; + } + + #panel #panel-stage { + min-height: 210px; + } + + #panel .appearance-actions { + margin-top: 4px; + } + + #panel .gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + #panel .pet-card { + padding-inline: 8px; + } + + #panel .task-page { + padding-bottom: 0; + overflow: hidden; + } + + #panel .task-toolbar { + align-items: flex-start; + gap: 8px; + padding-bottom: 12px; + } + + #panel .aggregate-counts { + flex-basis: 100%; + } + + #panel .task-panes { + display: flex; + flex-direction: column; + } + + #panel .task-list-pane { + flex: 1; + padding: 16px 0 20px; + border-right: 0; + } + + #panel .task-detail { + flex: 1; + padding: 12px 0 20px; + } + + #panel #task-detail-back { + display: inline-flex; + } + + #panel[data-task-detail='closed'] #detail-card { + display: none; + } + + #panel[data-task-detail='open'] #task-list-pane { + display: none; + } + + #panel .setting-row { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding-block: 20px; + } + + #panel .setting-row > .btn, + #panel .setting-row .segmented { + justify-self: start; + } + + #panel .import-preview { + gap: 16px; + } + + #panel .import-stage { + margin-inline: auto; + } + + #panel .import-info { + flex-basis: 100%; + min-width: 0; + } +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/pet.json b/plugins/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/pet.json new file mode 100644 index 0000000..713e856 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/pet.json @@ -0,0 +1,9 @@ +{ + "id": "minimax-original", + "displayName": "MiniMax Pet", + "description": "MiniMax 原创薄荷色机器人。图集由随包源代码独立绘制,采用 MIT 许可。", + "spritesheetPath": "spritesheet.png", + "spriteVersionNumber": 2, + "author": "MiniMax", + "license": "MIT" +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/spritesheet.png b/plugins/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/spritesheet.png new file mode 100644 index 0000000..1207168 Binary files /dev/null and b/plugins/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/spritesheet.png differ diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/preferences.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/preferences.mjs new file mode 100644 index 0000000..7c64eda --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/preferences.mjs @@ -0,0 +1,57 @@ +export const DEFAULT_PREFERENCES = Object.freeze({ petId: null, scale: 0.75, autoCycle: false, windowPosition: null }); +const KEYS = Object.keys(DEFAULT_PREFERENCES); +// Read-once compatibility keys from earlier local builds; these are not plugin IDs. +export const LEGACY_KEYS = Object.freeze({ petId: 'codexPetSpike.selectedPetId', scale: 'codexPetSpike.scale', autoCycle: 'codexPetSpike.demoAutoCycle' }); + +export function validatePreferences(raw, { partial = false } = {}) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).some((key) => !KEYS.includes(key)) || (!partial && KEYS.some((key) => key !== 'windowPosition' && !Object.hasOwn(raw, key)))) throw new Error('invalid_preferences'); + const result = partial ? {} : { windowPosition: null }; + for (const key of KEYS) { + if (!Object.hasOwn(raw, key)) continue; + const value = raw[key]; + if (key === 'petId' && value !== null && (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(value))) throw new Error('invalid_preferences'); + if (key === 'scale' && ![0.5, 0.75, 1].includes(value)) throw new Error('invalid_preferences'); + if (key === 'autoCycle' && typeof value !== 'boolean') throw new Error('invalid_preferences'); + if (key === 'windowPosition' && value !== null && (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((name) => !['x', 'y'].includes(name)) || !['x', 'y'].every((name) => Number.isInteger(value[name]) && Math.abs(value[name]) <= 100000))) throw new Error('invalid_preferences'); + result[key] = value; + } + if (partial && !Object.keys(result).length) throw new Error('invalid_preferences'); + return result; +} + +export async function preferenceRequest(fetchImpl, patch, { onlyIfUnpersisted = false } = {}) { + const response = await fetchImpl('/api/preferences', patch === undefined ? { cache: 'no-store' } : { method: 'PATCH', headers: { 'content-type': 'application/json', ...(onlyIfUnpersisted ? { 'if-none-match': '*' } : {}) }, body: JSON.stringify(validatePreferences(patch, { partial: true })) }); + let body; + try { body = await response.json(); } catch { throw new Error('preferences_response_invalid'); } + if (onlyIfUnpersisted && response.status === 412 && body?.error === 'preferences_exists') return preferenceRequest(fetchImpl); + if (!response.ok) throw new Error(body?.error || `http_${response.status}`); + const preferences = validatePreferences(body.preferences); + if (typeof body.persisted !== 'boolean') throw new Error('preferences_response_invalid'); + return { preferences, persisted: body.persisted }; +} + +export function readLegacyPreferences(storage) { + const patch = {}; + for (const [key, storageKey] of Object.entries(LEGACY_KEYS)) { + const raw = storage.getItem(storageKey); + if (raw === null) continue; + const value = key === 'scale' ? Number(raw) : key === 'autoCycle' ? raw === '1' : raw; + try { Object.assign(patch, validatePreferences({ [key]: value }, { partial: true })); } catch { /* Ignore malformed legacy values, never persist them. */ } + } + return patch; +} + +export async function loadPreferences(fetchImpl, storage) { + const current = await preferenceRequest(fetchImpl); + if (current.persisted || !storage) return current; + let legacy; + try { legacy = readLegacyPreferences(storage); } catch { return current; } + if (!Object.keys(legacy).length) return current; + const migrated = await preferenceRequest(fetchImpl, legacy, { onlyIfUnpersisted: true }); + if (!migrated.persisted) throw new Error('preferences_not_persisted'); + // Keep the migration source if persistence failed or its result is uncertain. + if (migrated.persisted) { + for (const key of Object.values(LEGACY_KEYS)) { try { storage.removeItem(key); } catch { /* Persisted values already won. */ } } + } + return migrated; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs new file mode 100644 index 0000000..381d5f4 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs @@ -0,0 +1,280 @@ +// The page records one utterance; the public Host API only transcribes its PCM. +const FORMAT = Object.freeze({ sampleRate: 16000, channelCount: 1, sampleFormat: 's16' }); +const MAX_DURATION_MS = 60000; +const BUFFER_SIZE = 4096; +const HOST_ERRORS = new Set([ + 'invalid_request', 'not_permitted', 'audio_too_large', 'busy', + 'runtime_unavailable', 'transcription_failed', 'result_unknown', +]); + +function errorCode(error, phase) { + const code = error?.code || error?.message; + if (HOST_ERRORS.has(code)) return code; + if (['NotAllowedError', 'PermissionDeniedError', 'SecurityError'].includes(error?.name)) + return 'not_permitted'; + if (['NotFoundError', 'DevicesNotFoundError', 'NotReadableError', 'TrackStartError', 'OverconstrainedError'].includes(error?.name)) + return 'microphone_unavailable'; + return phase === 'transcribing' ? 'result_unknown' : 'capture_failed'; +} + +function stopTracks(stream) { + for (const track of stream?.getTracks?.() ?? []) { + try { track.stop(); } catch { /* Release the other tracks too. */ } + } +} + +function toPcm(chunks, length, sampleRate) { + const samples = new Float32Array(length); + let offset = 0; + for (const chunk of chunks) { + samples.set(chunk, offset); + offset += chunk.length; + } + const ratio = sampleRate / FORMAT.sampleRate; + const pcm = new Int16Array(Math.floor(length / ratio)); + for (let i = 0; i < pcm.length; i++) { + const start = i * ratio; + let sample; + if (ratio >= 1) { + // Average each source interval, including fractional edges, rather than + // mislabelling 44.1/48 kHz samples as 16 kHz or just dropping samples. + const end = Math.min(length, (i + 1) * ratio); + let sum = 0; + for (let j = Math.floor(start); j < Math.ceil(end); j++) { + const weight = Math.min(end, j + 1) - Math.max(start, j); + sum += (Number.isFinite(samples[j]) ? samples[j] : 0) * weight; + } + sample = sum / (end - start); + } else { + const left = Math.floor(start); + const right = Math.min(length - 1, left + 1); + const fraction = start - left; + sample = samples[left] * (1 - fraction) + samples[right] * fraction; + } + sample = Number.isFinite(sample) ? Math.max(-1, Math.min(1, sample)) : 0; + pcm[i] = Math.round(sample < 0 ? sample * 32768 : sample * 32767); + } + return pcm; +} + +/** + * Owns one microphone recording and one final-text delivery at a time. + * Expected failures resolve start/stop and are reported through onState. + * `dependencies` only replaces browser/timer boundaries for focused Node tests. + */ +export function createSpeechInput({ + getMiniApp = () => globalThis.window?.mcode?.miniapp, + onState = () => {}, + onText = () => {}, + onPartial = () => {}, + dependencies = {}, +} = {}) { + const getUserMedia = dependencies.getUserMedia ?? ((constraints) => + globalThis.navigator.mediaDevices.getUserMedia(constraints)); + const createAudioContext = dependencies.createAudioContext ?? (() => { + const AudioContext = globalThis.AudioContext ?? globalThis.webkitAudioContext; + return new AudioContext({ sampleRate: FORMAT.sampleRate }); + }); + const schedule = dependencies.setTimeout ?? globalThis.setTimeout.bind(globalThis); + const unschedule = dependencies.clearTimeout ?? globalThis.clearTimeout.bind(globalThis); + let state = 'idle'; + let generation = 0; + let disposed = false; + let capture = null; + let stopping = null; + + function emit(callback, value) { + try { callback(value); } catch { /* A view callback must not leak the microphone. */ } + } + function setState(next, code) { + if (disposed) return; + state = next; + emit(onState, { state: next, ...(code ? { errorCode: code } : {}) }); + } + function speech() { + try { + const miniapp = getMiniApp(); + return miniapp?.capabilities?.has?.('speech.transcribe') === true && + typeof miniapp?.speech?.transcribe === 'function' ? miniapp.speech : null; + } catch { return null; } + } + function isAvailable() { + return !disposed && !!speech() && + (!!dependencies.getUserMedia || typeof globalThis.navigator?.mediaDevices?.getUserMedia === 'function') && + (!!dependencies.createAudioContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function'); + } + const current = (token) => !disposed && generation === token; + + function release(recording) { + if (!recording || recording.released) return Promise.resolve(); + recording.released = true; + recording.cancelReady(); + if (recording.timer !== null) unschedule(recording.timer); + recording.processor && (recording.processor.onaudioprocess = null); + for (const stop of recording.trackListeners) stop(); + for (const node of [recording.source, recording.processor, recording.mute]) { + try { node?.disconnect(); } catch { /* Continue releasing remaining resources. */ } + } + stopTracks(recording.stream); + recording.chunks.length = 0; + try { return Promise.resolve(recording.context.close()).catch(() => {}); } + catch { return Promise.resolve(); } + } + + function fail(token, code) { + if (!current(token)) return; + generation++; + const recording = capture; + capture = null; + void release(recording); + setState('error', code); + } + + async function start() { + if (disposed || ['requesting', 'recording', 'transcribing'].includes(state)) return; + if (!isAvailable()) { setState('error', 'speech_unavailable'); return; } + const token = ++generation; + setState('requesting'); + // A callback may have synchronously cancelled or disposed the request. + if (!current(token)) return; + let recording; + try { + const context = createAudioContext(); + let cancelReady; + const cancelled = new Promise((resolve) => { cancelReady = resolve; }); + recording = { + context, stream: null, source: null, processor: null, mute: null, + chunks: [], frames: 0, timer: null, released: false, + trackListeners: [], cancelReady, + }; + capture = recording; + if (!Number.isFinite(context.sampleRate) || context.sampleRate <= 0) + throw new Error('Invalid microphone sample rate'); + // Start/resume inside start's user gesture, before awaiting the permission prompt. + const resumed = Promise.resolve(context.resume()); + let media; + try { + media = getUserMedia({ audio: { + channelCount: 1, echoCancellation: true, autoGainControl: true, noiseSuppression: true, + } }); + } catch (error) { media = Promise.reject(error); } + const acquired = Promise.resolve(media).then((stream) => { + if (!current(token) || capture !== recording || recording.released) { + stopTracks(stream); + return; + } + recording.stream = stream; + }); + await Promise.race([Promise.all([resumed, acquired]), cancelled]); + if (!current(token) || capture !== recording || recording.released) return; + const tracks = recording.stream?.getAudioTracks?.() ?? recording.stream?.getTracks?.() ?? []; + if (!tracks.length || tracks.every((track) => track.readyState === 'ended')) + throw Object.assign(new Error('No active microphone'), { name: 'NotFoundError' }); + for (const track of tracks) { + const ended = () => fail(token, 'microphone_unavailable'); + track.addEventListener?.('ended', ended); + recording.trackListeners.push(() => track.removeEventListener?.('ended', ended)); + } + recording.source = context.createMediaStreamSource(recording.stream); + // ScriptProcessor is the same zero-build capture boundary used by the Host's + // public copyable helper. No worklet/runtime package or encoded audio is needed. + recording.processor = context.createScriptProcessor(BUFFER_SIZE, 1, 1); + recording.mute = context.createGain(); + recording.mute.gain.value = 0; + recording.processor.onaudioprocess = (event) => { + if (!current(token) || capture !== recording || recording.released) return; + try { + const input = event.inputBuffer; + const remaining = Math.max(0, Math.floor(context.sampleRate * MAX_DURATION_MS / 1000) - recording.frames); + const length = Math.min(input.length, remaining); + if (length > 0) { + const mono = new Float32Array(length); + const channels = input.numberOfChannels; + for (let channel = 0; channel < channels; channel++) { + const values = input.getChannelData(channel); + for (let i = 0; i < length; i++) mono[i] += (Number.isFinite(values[i]) ? values[i] : 0) / channels; + } + recording.chunks.push(mono); + recording.frames += length; + } + if (!remaining || length === remaining) void stop(); + } catch { fail(token, 'capture_failed'); } + }; + recording.source.connect(recording.processor); + recording.processor.connect(recording.mute); + recording.mute.connect(context.destination); + recording.timer = schedule(() => { if (current(token)) void stop(); }, MAX_DURATION_MS); + setState('recording'); + } catch (error) { + if (current(token)) fail(token, errorCode(error, 'capture')); + else void release(recording); + } + } + + function stop() { + if (disposed) return Promise.resolve(); + if (stopping) return stopping; + if (state === 'requesting') { cancel(); return Promise.resolve(); } + if (state !== 'recording' || !capture) return Promise.resolve(); + const token = generation; + const recording = capture; + capture = null; + let pcm; + try { pcm = toPcm(recording.chunks, recording.frames, recording.context.sampleRate); } + catch { void release(recording); fail(token, 'capture_failed'); return Promise.resolve(); } + const released = release(recording); + if (!pcm.length) { fail(token, 'no_audio'); return released; } + setState('transcribing'); + if (!current(token)) return released; + const pending = (async () => { + try { + await released; + if (!current(token)) return; + const api = speech(); + if (!api) { fail(token, 'speech_unavailable'); return; } + const transcription = api.transcribe({ audio: pcm, format: { ...FORMAT } }, { + onPartial: (text) => { + if (current(token) && state === 'transcribing' && typeof text === 'string') emit(onPartial, text); + }, + }); + // The bridge owns the dispatched audio; do not retain another recording + // reference while waiting, particularly after cancel/dispose. + pcm = null; + const result = await transcription; + if (!current(token)) return; + if (!result || typeof result.text !== 'string') { fail(token, 'result_unknown'); return; } + emit(onText, result.text); + if (current(token)) setState('idle'); + } catch (error) { + fail(token, errorCode(error, 'transcribing')); + } finally { + pcm = null; + if (stopping === pending) stopping = null; + } + })(); + stopping = pending; + return pending; + } + + function cancel() { + if (disposed) return; + generation++; + const recording = capture; + capture = null; + stopping = null; + void release(recording); + setState('idle'); + } + + function dispose() { + if (disposed) return; + disposed = true; + generation++; + const recording = capture; + capture = null; + stopping = null; + void release(recording); + } + + return { isAvailable, start, stop, cancel, dispose }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/window-position.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/window-position.mjs new file mode 100644 index 0000000..c259e09 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/window-position.mjs @@ -0,0 +1,47 @@ +/** + * Called only after manual pop-out. The safe interaction patch must settle in its own frame: + * an invalid geometry patch is atomically rejected along with every field merged into that frame. + */ +export async function restoreWindowPosition(handle, position, { isCurrent = () => true } = {}) { + const result = { restored: false, interactive: false, cancelled: false, error: null, state: null }; + if (!isCurrent()) return { ...result, cancelled: true }; + try { + const safeState = await handle.update({ clickThrough: false }); + if (!isCurrent()) return { ...result, interactive: true, cancelled: true }; + result.interactive = true; + result.state = safeState; + if (!position) return result; + const state = await handle.update({ x: position.x, y: position.y }); + if (!isCurrent()) return { ...result, cancelled: true }; + return { ...result, restored: true, state }; + } catch (error) { + if (!isCurrent()) return { ...result, cancelled: true }; + return { ...result, error: error?.code || error?.message || 'result_unknown' }; + } +} + +/** Fit the complete transparent content area, not only the Host's minimum overlap. */ +export async function fitWindowToScreen(handle, { screenRect, isCurrent = () => true } = {}) { + const cancelled = () => ({ cancelled: true, error: null, state: null }); + if (!isCurrent()) return cancelled(); + try { + const state = await handle.getState(); + if (!isCurrent()) return cancelled(); + const bounds = state?.window, rect = screenRect?.(); + if (state?.mode !== 'window') return cancelled(); + const finite = (value) => Number.isFinite(value); + if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(finite) + || bounds.width <= 0 || bounds.height <= 0) return { error: 'invalid_state' }; + if (!rect || ![rect.x, rect.y, rect.width, rect.height].every(finite) + || rect.width <= 0 || rect.height <= 0) return { error: 'screen_unavailable' }; + if (bounds.width > rect.width || bounds.height > rect.height) return { error: 'screen_too_small' }; + const x = Math.ceil(Math.max(rect.x, Math.min(bounds.x, rect.x + rect.width - bounds.width))); + const y = Math.ceil(Math.max(rect.y, Math.min(bounds.y, rect.y + rect.height - bounds.height))); + if (x === bounds.x && y === bounds.y) return { state, error: null }; + const updated = await handle.update({ x, y }); + if (!isCurrent() || updated?.mode !== 'window') return cancelled(); + return { state: updated, error: null }; + } catch (error) { + return isCurrent() ? { error: error?.code || error?.message || 'result_unknown' } : cancelled(); + } +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/workbench-window.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/workbench-window.mjs new file mode 100644 index 0000000..1f7f728 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/workbench-window.mjs @@ -0,0 +1,104 @@ +const fail = (code) => ({ ok: false, code }); +const positive = (value) => Number.isFinite(value) && value > 0; +const errorCode = (error) => typeof error?.code === 'string' ? error.code + : typeof error?.message === 'string' ? error.message : 'result_unknown'; + +function boundsOf(state) { + const bounds = state?.window; + return state?.mode === 'window' && bounds && positive(bounds.width) && positive(bounds.height) + && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) ? bounds : null; +} + +function resizePatch(bounds, size, rect) { + if (!rect || !Number.isFinite(rect.x) || !Number.isFinite(rect.y) + || !positive(rect.width) || !positive(rect.height)) return fail('screen_unavailable'); + if (!positive(size?.width) || !positive(size?.height)) return fail('invalid_size'); + const minWidth = positive(bounds.minWidth) ? Math.ceil(bounds.minWidth) : 1; + const minHeight = positive(bounds.minHeight) ? Math.ceil(bounds.minHeight) : 1; + const width = Math.max(minWidth, Math.min(Math.round(size.width), Math.floor(rect.width))); + const height = Math.max(minHeight, Math.min(Math.round(size.height), Math.floor(rect.height))); + if (width > rect.width || height > rect.height) return fail('screen_too_small'); + // Work areas can start left of or above the primary display. Clamp to that + // rectangle, retaining the current bottom-right anchor whenever it fits. + const x = Math.round(Math.max(rect.x, Math.min(bounds.x + bounds.width - width, rect.x + rect.width - width))); + const y = Math.round(Math.max(rect.y, Math.min(bounds.y + bounds.height - height, rect.y + rect.height - height))); + return { ok: true, patch: { x, y, width, height } }; +} + +/** + * Geometry only: callers own panel visibility, focus and click-through. A + * rejected resize leaves the remembered compact size and expansion unchanged. + * screenRect must describe the current display's available area; when unknown + * no geometry is guessed or written. + */ +export function createWorkbenchWindow({ + windowAPI, + screenRect, + isCurrent = () => true, + expandedSize = { width: 300, height: 340 }, +} = {}) { + let disposed = false, expanded = false, compactSize = null; + let intent = 0, lifetime = 0, queue = Promise.resolve(); + const active = (generation) => !disposed && generation === lifetime && isCurrent(); + const invalidateWindow = () => { + lifetime += 1; + intent += 1; + expanded = false; + compactSize = null; + }; + const subscription = windowAPI?.onDidChangeState?.((state) => { + if (state?.mode === 'inline') invalidateWindow(); + }); + + async function apply(target, request, generation) { + try { + if (!active(generation)) return fail('cancelled'); + if (request !== intent) return fail('superseded'); + if (typeof windowAPI?.getState !== 'function' || typeof windowAPI?.update !== 'function') return fail('unsupported'); + const state = await windowAPI.getState(); + if (!active(generation)) return fail('cancelled'); + if (request !== intent) return fail('superseded'); + if (state?.mode === 'inline') { invalidateWindow(); return fail('inline_mode'); } + const bounds = boundsOf(state); + if (!bounds) return fail('invalid_state'); + if (expanded === target) return { ok: true }; + if (bounds.resizable === false) return fail('not_permitted'); + const originalSize = compactSize || { width: bounds.width, height: bounds.height }; + const geometry = resizePatch(bounds, target ? expandedSize : originalSize, screenRect?.()); + if (!geometry.ok) return geometry; + if (!active(generation)) return fail('cancelled'); + if (request !== intent) return fail('superseded'); + const updated = await windowAPI.update(geometry.patch); + if (!active(generation)) return fail('cancelled'); + if (updated?.mode === 'inline') { invalidateWindow(); return fail('inline_mode'); } + if (!boundsOf(updated)) return fail('invalid_state'); + // An update already sent to the Host cannot be recalled. Remember its + // accepted size even if a newer close arrived, so the serialized close + // restores the compact size from the actual new bottom-right position. + compactSize = originalSize; + expanded = target; + return request === intent ? { ok: true } : fail('superseded'); + } catch (error) { + return disposed || generation !== lifetime ? fail('cancelled') : fail(errorCode(error)); + } + } + + return { + setExpanded(target) { + if (disposed) return Promise.resolve(fail('cancelled')); + if (typeof target !== 'boolean') return Promise.resolve(fail('invalid_request')); + const request = ++intent, generation = lifetime; + const pending = queue.then(() => apply(target, request, generation)); + // All API/callback failures are explicit results, so the next deliberate + // intent can run without an automatic retry or a hidden rejected chain. + queue = pending.then(() => undefined); + return pending; + }, + dispose() { + if (disposed) return; + disposed = true; + invalidateWindow(); + subscription?.dispose(); + }, + }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/miniapp.json b/plugins/minimax-ai/minimax-pet/miniapp/miniapp.json new file mode 100644 index 0000000..f823575 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/miniapp.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "agentAccess": [ + "session.read" + ], + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/pet", + "window": { + "width": 280, + "height": 340, + "minWidth": 200, + "minHeight": 200, + "resizable": true, + "frame": false, + "transparent": true, + "alwaysOnTop": true, + "skipTaskbar": true, + "clickThrough": true + } + }, + "mcpEndpoints": [], + "deviceAccess": [ + "microphone" + ] +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/node/image-header.mjs b/plugins/minimax-ai/minimax-pet/miniapp/node/image-header.mjs new file mode 100644 index 0000000..ff38d5a --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/node/image-header.mjs @@ -0,0 +1,93 @@ +// @ts-check +// MiniMax Pet — magic-byte format detection and width/height parsing from image file headers. +// Pure functions over a Buffer; no I/O. + +/** Bytes needed to parse any supported header. */ +export const HEADER_BYTES = 32; + +export const ATLAS_SIZES = Object.freeze({ + 1: Object.freeze({ width: 1536, height: 1872 }), + 2: Object.freeze({ width: 1536, height: 2288 }), +}); + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * @param {Buffer} buf + * @returns {'png' | 'webp' | null} + */ +export function detectImageFormat(buf) { + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIGNATURE)) return 'png'; + if ( + buf.length >= 12 && + buf.toString('latin1', 0, 4) === 'RIFF' && + buf.toString('latin1', 8, 12) === 'WEBP' + ) { + return 'webp'; + } + return null; +} + +/** + * @param {Buffer} buf + * @returns {{ format: 'png' | 'webp', width: number, height: number, variant?: string } | null} + */ +export function readImageHeader(buf) { + const format = detectImageFormat(buf); + if (format === 'png') return readPngHeader(buf); + if (format === 'webp') return readWebpHeader(buf); + return null; +} + +/** @param {Buffer} buf */ +function readPngHeader(buf) { + if (buf.length < 24) return null; + if (buf.readUInt32BE(8) !== 13 || buf.toString('latin1', 12, 16) !== 'IHDR') return null; + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + if (width === 0 || height === 0) return null; + return { format: /** @type {const} */ ('png'), width, height }; +} + +/** @param {Buffer} buf */ +function readWebpHeader(buf) { + if (buf.length < 20) return null; + const fourcc = buf.toString('latin1', 12, 16); + if (fourcc === 'VP8 ') { + if (buf.length < 30) return null; + // Keyframe bit (bit 0 of frame tag) must be 0, followed by start code 9d 01 2a. + if ((buf[20] & 0x01) !== 0) return null; + if (buf[23] !== 0x9d || buf[24] !== 0x01 || buf[25] !== 0x2a) return null; + const width = buf.readUInt16LE(26) & 0x3fff; + const height = buf.readUInt16LE(28) & 0x3fff; + if (width === 0 || height === 0) return null; + return { format: /** @type {const} */ ('webp'), variant: 'VP8', width, height }; + } + if (fourcc === 'VP8L') { + if (buf.length < 25) return null; + if (buf[20] !== 0x2f) return null; + const bits = buf.readUInt32LE(21); + if (bits >>> 29 !== 0) return null; // version must be 0 + const width = (bits & 0x3fff) + 1; + const height = ((bits >>> 14) & 0x3fff) + 1; + return { format: /** @type {const} */ ('webp'), variant: 'VP8L', width, height }; + } + if (fourcc === 'VP8X') { + if (buf.length < 30) return null; + const width = buf.readUIntLE(24, 3) + 1; + const height = buf.readUIntLE(27, 3) + 1; + return { format: /** @type {const} */ ('webp'), variant: 'VP8X', width, height }; + } + return null; +} + +/** + * @param {number} width + * @param {number} height + * @returns {1 | 2 | null} + */ +export function atlasVersionFromSize(width, height) { + if (width === ATLAS_SIZES[1].width && height === ATLAS_SIZES[1].height) return 1; + if (width === ATLAS_SIZES[2].width && height === ATLAS_SIZES[2].height) return 2; + return null; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/node/pet-store.mjs b/plugins/minimax-ai/minimax-pet/miniapp/node/pet-store.mjs new file mode 100644 index 0000000..a94dd93 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/node/pet-store.mjs @@ -0,0 +1,505 @@ +// @ts-check +// MiniMax Pet — bundled + imported pet catalog, import, delete, normalization. + +import { randomBytes } from 'node:crypto'; +import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { join, sep } from 'node:path'; + +import { HEADER_BYTES, atlasVersionFromSize, readImageHeader } from './image-header.mjs'; + +export const PET_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const SPRITE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(webp|png)$/; +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/; +const DISPLAY_NAME_MAX = 80; +const DESCRIPTION_MAX = 300; +const STAGING_DIR = '.staging'; +const REPLACEMENT_NAME_PATTERN = /^replace-([a-z0-9][a-z0-9._-]{0,79})-\d+-[a-f0-9]{12}$/; +const DISPOSABLE_STAGE_PATTERN = /^(import|delete)-\d+-[a-f0-9]{12}$/; + +export class PetError extends Error { + /** + * @param {number} status + * @param {string} code + * @param {string} message + */ + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +/** @param {unknown} value @returns {value is Record} */ +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** @param {string} s */ +function charLength(s) { + return [...s].length; +} + +/** + * @typedef {{ id: string, displayName: string, description: string, spritesheetPath: string, version: 1 | 2 }} NormalizedPet + * @typedef {{ id: string, displayName: string, description: string, version: 1 | 2, source: 'bundled' | 'imported', petJsonUrl: string }} PetSummary + */ + +/** + * Validates pet.json shape (not the image). Throws PetError. + * @param {unknown} raw + * @returns {NormalizedPet} + */ +export function normalizePetJson(raw) { + if (!isPlainObject(raw)) throw new PetError(400, 'invalid_pet_json', 'pet.json must be an object'); + const { id, displayName, description, spritesheetPath, spriteVersionNumber } = raw; + if (typeof id !== 'string' || !PET_ID_PATTERN.test(id)) { + throw new PetError(400, 'invalid_id', 'id must match ^[a-z0-9][a-z0-9._-]{0,79}$'); + } + if (typeof displayName !== 'string') { + throw new PetError(400, 'invalid_pet_json', 'displayName must be a string'); + } + const name = displayName.trim(); + if (charLength(name) < 1 || charLength(name) > DISPLAY_NAME_MAX) { + throw new PetError(400, 'invalid_pet_json', `displayName must be 1-${DISPLAY_NAME_MAX} characters`); + } + if (description !== undefined && typeof description !== 'string') { + throw new PetError(400, 'invalid_pet_json', 'description must be a string'); + } + const desc = (description ?? '').trim(); + if (charLength(desc) > DESCRIPTION_MAX) { + throw new PetError(400, 'invalid_pet_json', `description must be at most ${DESCRIPTION_MAX} characters`); + } + if (spriteVersionNumber !== undefined && spriteVersionNumber !== 1 && spriteVersionNumber !== 2) { + throw new PetError(400, 'invalid_pet_json', 'spriteVersionNumber must be 1 or 2 when present'); + } + if (typeof spritesheetPath !== 'string') { + throw new PetError(400, 'invalid_pet_json', 'spritesheetPath must be a string'); + } + if (!SPRITE_NAME_PATTERN.test(spritesheetPath)) { + throw new PetError( + 400, + 'invalid_sprite_name', + 'spritesheetPath must be a plain *.webp or *.png file name without directories', + ); + } + return { + id, + displayName: name, + description: desc, + spritesheetPath, + version: spriteVersionNumber === 2 ? 2 : 1, + }; +} + +/** @param {NormalizedPet} pet */ +function toStoredJson(pet) { + /** @type {Record} */ + const out = { + id: pet.id, + displayName: pet.displayName, + description: pet.description, + spritesheetPath: pet.spritesheetPath, + }; + if (pet.version === 2) out.spriteVersionNumber = 2; + return out; +} + +/** + * Validates decoded image header against the pet's extension and declared version. Throws PetError. + * @param {Buffer} header + * @param {NormalizedPet} pet + */ +function checkSprite(header, pet) { + const info = readImageHeader(header); + if (!info) throw new PetError(400, 'sprite_format_invalid', 'spritesheet is not a PNG or WebP image'); + const ext = pet.spritesheetPath.slice(pet.spritesheetPath.lastIndexOf('.') + 1); + if (info.format !== ext) { + throw new PetError(400, 'sprite_format_invalid', `spritesheet content is ${info.format}, extension is .${ext}`); + } + const version = atlasVersionFromSize(info.width, info.height); + if (version === null) { + throw new PetError( + 400, + 'sprite_size_invalid', + `spritesheet is ${info.width}x${info.height}; expected 1536x1872 (v1) or 1536x2288 (v2)`, + ); + } + if (version !== pet.version) { + throw new PetError( + 400, + 'sprite_version_mismatch', + `spritesheet size matches v${version} but pet.json declares v${pet.version}`, + ); + } +} + +/** @param {string} value */ +function decodeBase64Strict(value) { + if (value.length === 0 || value.length % 4 !== 0 || !BASE64_PATTERN.test(value)) { + throw new PetError(400, 'sprite_format_invalid', 'spritesheet.base64 is not valid base64'); + } + const buf = Buffer.from(value, 'base64'); + if (buf.toString('base64') !== value) { + throw new PetError(400, 'sprite_format_invalid', 'spritesheet.base64 is not canonical base64'); + } + return buf; +} + +/** @param {string} path */ +async function readHeaderBytes(path) { + const handle = await open(path, 'r'); + try { + const buf = Buffer.alloc(HEADER_BYTES); + const { bytesRead } = await handle.read(buf, 0, HEADER_BYTES, 0); + return buf.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} + +/** @param {unknown} error */ +function errnoCode(error) { + return error && typeof error === 'object' && 'code' in error ? String(error.code) : 'unknown'; +} + +/** + * Reads and fully validates a pet directory (pet.json + sprite header). Throws PetError or fs errors. + * @param {string} dir + * @param {string} expectedId + * @returns {Promise} + */ +async function loadPetDir(dir, expectedId) { + const petJsonPath = join(dir, 'pet.json'); + const st = await lstat(petJsonPath); + if (!st.isFile()) throw new PetError(500, 'pet_json_unreadable', 'pet.json is not a regular file'); + let raw; + try { + raw = JSON.parse(await readFile(petJsonPath, 'utf8')); + } catch { + throw new PetError(500, 'invalid_pet_json', 'pet.json is not valid JSON'); + } + const pet = normalizePetJson(raw); + if (pet.id !== expectedId) throw new PetError(500, 'invalid_id', 'pet.json id does not match directory'); + const spritePath = join(dir, pet.spritesheetPath); + const spriteStat = await lstat(spritePath); + if (!spriteStat.isFile()) throw new PetError(500, 'sprite_unreadable', 'spritesheet is not a regular file'); + checkSprite(await readHeaderBytes(spritePath), pet); + return pet; +} + +/** @param {string} child @param {string} parent */ +function isInside(child, parent) { + return child.startsWith(parent.endsWith(sep) ? parent : parent + sep); +} + +/** @param {string} name */ +function contentTypeForPetFile(name) { + if (name.endsWith('.json')) return 'application/json; charset=utf-8'; + if (name.endsWith('.webp')) return 'image/webp'; + if (name.endsWith('.png')) return 'image/png'; + return null; +} + +/** + * @typedef {{ debug(m: string, f?: object): void, info(m: string, f?: object): void, warn(m: string, f?: object): void, error(m: string, f?: object): void }} Logger + * @param {{ clientRoot: string, dataDir: string, logger: Logger }} options + */ +export function createPetStore({ clientRoot, dataDir, logger }) { + const bundledRoot = join(clientRoot, 'pets'); + const petsDir = join(dataDir, 'pets'); + const stagingRoot = join(dataDir, STAGING_DIR); + + /** @type {{ pets: PetSummary[], ids: Set } | null} */ + let bundledCache = null; + /** @type {Promise} */ + let queueTail = Promise.resolve(); + + /** + * @template T + * @param {() => Promise} task + * @returns {Promise} + */ + function enqueue(task) { + const run = queueTail.then(task, task); + queueTail = run.catch(() => {}); + return run; + } + + async function getBundled() { + if (bundledCache) return bundledCache; + const entries = await readdir(bundledRoot, { withFileTypes: true }); + /** @type {PetSummary[]} */ + const pets = []; + const ids = new Set(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + ids.add(entry.name); + if (!PET_ID_PATTERN.test(entry.name)) { + logger.warn('pet.catalog.bundled_invalid', { reason: 'invalid_id' }); + continue; + } + try { + const pet = await loadPetDir(join(bundledRoot, entry.name), entry.name); + pets.push(toSummary(pet, 'bundled')); + } catch (error) { + logger.warn('pet.catalog.bundled_invalid', { + reason: error instanceof PetError ? error.code : errnoCode(error), + }); + } + } + pets.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + bundledCache = { pets, ids }; + return bundledCache; + } + + /** + * @param {NormalizedPet} pet + * @param {'bundled' | 'imported'} source + * @returns {PetSummary} + */ + function toSummary(pet, source) { + return { + id: pet.id, + displayName: pet.displayName, + description: pet.description, + version: pet.version, + source, + petJsonUrl: source === 'bundled' ? `/assets/pets/${pet.id}/pet.json` : `/imported/${pet.id}/pet.json`, + }; + } + + async function listImported() { + let entries; + try { + entries = await readdir(petsDir, { withFileTypes: true }); + } catch (error) { + if (errnoCode(error) === 'ENOENT') return []; + throw error; + } + /** @type {PetSummary[]} */ + const pets = []; + for (const entry of entries) { + if (!entry.isDirectory() || !PET_ID_PATTERN.test(entry.name)) continue; + try { + pets.push(toSummary(await loadPetDir(join(petsDir, entry.name), entry.name), 'imported')); + } catch (error) { + logger.warn('pet.catalog.imported_invalid', { + reason: error instanceof PetError ? error.code : errnoCode(error), + }); + } + } + pets.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return pets; + } + + /** @returns {Promise} */ + async function listPets() { + return enqueue(async () => { + await recoverStaging(); + let bundled = []; + try { + bundled = (await getBundled()).pets; + } catch (error) { + logger.error('pet.catalog.bundled_unreadable', { errno: errnoCode(error) }); + } + return [...bundled, ...(await listImported())]; + }); + } + + /** + * Resolves an allowed imported pet file; returns null when not allowed / not found. + * @param {string} id + * @param {string} file + */ + async function resolveImportedFile(id, file) { + if (!PET_ID_PATTERN.test(id)) return null; + return enqueue(async () => { + await recoverStaging(); + return resolveImportedFileNow(id, file); + }); + } + + /** @param {string} id @param {string} file */ + async function resolveImportedFileNow(id, file) { + const dir = join(petsDir, id); + let pet; + try { + pet = await loadPetDir(dir, id); + } catch { + return null; + } + if (file !== 'pet.json' && file !== pet.spritesheetPath) return null; + const contentType = contentTypeForPetFile(file); + if (!contentType) return null; + const path = join(dir, file); + try { + const st = await lstat(path); + if (!st.isFile()) return null; + const [realFile, realPets] = await Promise.all([realpath(path), realpath(petsDir)]); + if (!isInside(realFile, realPets)) return null; + return { path: realFile, contentType }; + } catch { + return null; + } + } + + /** @param {string} dir */ + async function requireDirectory(dir) { + if (!(await lstat(dir)).isDirectory()) { + throw new PetError(500, 'pet_storage_unreadable', 'pet storage must use regular directories'); + } + } + + // A replacement backup's name records its destination before the atomic move. + // Unlike delete-* and import-*, it must never be swept before recovery succeeds. + // Run under the same queue as reads/writes so an active replacement is not recovered early. + async function recoverStaging() { + let entries; + try { + await requireDirectory(stagingRoot); + entries = await readdir(stagingRoot, { withFileTypes: true }); + } catch (error) { + if (errnoCode(error) === 'ENOENT') return; + throw error; + } + for (const entry of entries) { + const stagedPath = join(stagingRoot, entry.name); + const replacement = REPLACEMENT_NAME_PATTERN.exec(entry.name); + if (replacement) { + if (!entry.isDirectory()) { + throw new PetError(500, 'pet_recovery_failed', 'replacement backup must be a regular directory'); + } + const id = replacement[1]; + const finalDir = join(petsDir, id); + await mkdir(petsDir, { recursive: true }); + await requireDirectory(petsDir); + const finalExists = await lstat(finalDir).then( + () => true, + (error) => { + if (errnoCode(error) === 'ENOENT') return false; + throw error; + }, + ); + if (finalExists) { + await requireDirectory(finalDir); + await loadPetDir(finalDir, id); + await rm(stagedPath, { recursive: true, force: true }); + } else { + await loadPetDir(stagedPath, id); + await rename(stagedPath, finalDir); + logger.info('pet.import.recovered'); + } + } else if (DISPOSABLE_STAGE_PATTERN.test(entry.name)) { + await rm(stagedPath, { recursive: true, force: true }); + } else { + // Old trash-* could mean an interrupted replacement OR an intentional delete. + // Preserve it rather than guessing and either losing data or resurrecting a pet. + logger.warn('pet.staging.unrecognized_preserved'); + } + } + } + + function stagingName(prefix) { + return join(stagingRoot, `${prefix}-${Date.now()}-${randomBytes(6).toString('hex')}`); + } + + /** + * @param {unknown} body parsed JSON request body + * @returns {Promise} + */ + async function importPet(body) { + if (!isPlainObject(body)) throw new PetError(400, 'invalid_json', 'request body must be a JSON object'); + const pet = normalizePetJson(body.petJson); + const { spritesheet, replace } = body; + if ( + !isPlainObject(spritesheet) || + typeof spritesheet.fileName !== 'string' || + typeof spritesheet.base64 !== 'string' + ) { + throw new PetError(400, 'invalid_json', 'spritesheet must be { fileName: string, base64: string }'); + } + if (replace !== undefined && typeof replace !== 'boolean') { + throw new PetError(400, 'invalid_json', 'replace must be a boolean'); + } + if (spritesheet.fileName !== pet.spritesheetPath) { + throw new PetError(400, 'invalid_sprite_name', 'spritesheet.fileName must equal petJson.spritesheetPath'); + } + const sprite = decodeBase64Strict(spritesheet.base64); + checkSprite(sprite, pet); + + return enqueue(async () => { + await recoverStaging(); + const bundled = await getBundled(); + if (bundled.ids.has(pet.id)) { + throw new PetError(409, 'bundled_conflict', 'a bundled pet already uses this id'); + } + const finalDir = join(petsDir, pet.id); + const exists = await lstat(finalDir).then( + () => true, + (error) => { + if (errnoCode(error) === 'ENOENT') return false; + throw error; + }, + ); + if (exists && replace !== true) { + throw new PetError(409, 'pet_exists', 'an imported pet with this id exists; set replace to overwrite'); + } + + await mkdir(stagingRoot, { recursive: true }); + await requireDirectory(stagingRoot); + const stage = stagingName('import'); + try { + await mkdir(stage); + await writeFile(join(stage, 'pet.json'), `${JSON.stringify(toStoredJson(pet), null, 2)}\n`); + await writeFile(join(stage, pet.spritesheetPath), sprite); + await mkdir(petsDir, { recursive: true }); + await requireDirectory(petsDir); + if (exists) { + const backup = stagingName(`replace-${pet.id}`); + await rename(finalDir, backup); + try { + await rename(stage, finalDir); + } catch (error) { + // If rollback also fails, leave the named backup for the next recovery. + await rename(backup, finalDir); + throw error; + } + await rm(backup, { recursive: true, force: true }); + } else { + await rename(stage, finalDir); + } + } finally { + await rm(stage, { recursive: true, force: true }).catch(() => {}); + } + logger.info('pet.import.completed', { version: pet.version, replaced: exists }); + return toSummary(pet, 'imported'); + }); + } + + /** @param {string} id */ + async function deletePet(id) { + if (!PET_ID_PATTERN.test(id)) throw new PetError(400, 'invalid_id', 'invalid pet id'); + return enqueue(async () => { + await recoverStaging(); + const bundled = await getBundled(); + if (bundled.ids.has(id)) throw new PetError(403, 'bundled_read_only', 'bundled pets cannot be deleted'); + const dir = join(petsDir, id); + try { + const st = await lstat(dir); + if (!st.isDirectory()) throw new PetError(404, 'pet_not_found', 'pet not found'); + } catch (error) { + if (error instanceof PetError) throw error; + if (errnoCode(error) === 'ENOENT') throw new PetError(404, 'pet_not_found', 'pet not found'); + throw error; + } + await mkdir(stagingRoot, { recursive: true }); + await requireDirectory(stagingRoot); + const trash = stagingName('delete'); + await rename(dir, trash); + await rm(trash, { recursive: true, force: true }); + logger.info('pet.delete.completed'); + }); + } + + return { listPets, resolveImportedFile, importPet, deletePet }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/node/preferences-store.mjs b/plugins/minimax-ai/minimax-pet/miniapp/node/preferences-store.mjs new file mode 100644 index 0000000..6491817 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/node/preferences-store.mjs @@ -0,0 +1,50 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { DEFAULT_PREFERENCES, validatePreferences } from '../client/preferences.mjs'; + +export class PreferenceError extends Error { + constructor(status, code) { super(code); this.status = status; this.code = code; } +} + +export function createPreferenceStore({ dataDir, fs = { lstat, mkdir, open, readFile, rename, rm } }) { + const target = join(dataDir, 'preferences.json'); + let pending = Promise.resolve(); + function serialize(operation) { + const result = pending.then(operation); + pending = result.catch(() => {}); + return result; + } + async function read() { + try { + if (!(await fs.lstat(target)).isFile()) throw new Error('not a regular file'); + const body = JSON.parse(await fs.readFile(target, 'utf8')); + if (body.version !== 1) throw new Error('unknown version'); + return { preferences: validatePreferences(body.preferences), persisted: true }; + } catch (error) { + if (error.code === 'ENOENT') return { preferences: { ...DEFAULT_PREFERENCES }, persisted: false }; + throw new PreferenceError(500, 'preferences_unreadable'); + } + } + async function write(raw, { onlyIfUnpersisted = false } = {}) { + let patch; + try { patch = validatePreferences(raw, { partial: true }); } catch { throw new PreferenceError(400, 'invalid_preferences'); } + const current = await read(); + if (onlyIfUnpersisted && current.persisted) throw new PreferenceError(412, 'preferences_exists'); + const preferences = { ...current.preferences, ...patch }; + await fs.mkdir(dataDir, { recursive: true }); + const temporary = join(dataDir, `.preferences-${randomUUID()}.tmp`); + let handle; + try { + handle = await fs.open(temporary, 'wx', 0o600); + await handle.writeFile(JSON.stringify({ version: 1, preferences }) + '\n'); + await handle.sync(); + await handle.close(); + handle = null; + await fs.rename(temporary, target); + return { preferences, persisted: true }; + } catch { throw new PreferenceError(500, 'preferences_write_failed'); } + finally { await handle?.close().catch(() => {}); await fs.rm(temporary, { force: true }).catch(() => {}); } + } + return { get: () => serialize(read), patch: (raw, options) => serialize(() => write(raw, options)) }; +} diff --git a/plugins/minimax-ai/minimax-pet/miniapp/node/server.mjs b/plugins/minimax-ai/minimax-pet/miniapp/node/server.mjs new file mode 100644 index 0000000..5eac7ca --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/miniapp/node/server.mjs @@ -0,0 +1,387 @@ +// @ts-check +// MiniMax Pet — Codex Pet Mini App Node entry. Serves the pet page, static client assets, and the pet API. + +import { open, readFile, realpath } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, resolve, sep } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import { PET_ID_PATTERN, PetError, createPetStore } from './pet-store.mjs'; +import { PreferenceError, createPreferenceStore } from './preferences-store.mjs'; + +/** + * @typedef {{ debug(m: string, f?: object): void, info(m: string, f?: object): void, warn(m: string, f?: object): void, error(m: string, f?: object): void }} Logger + * @typedef {{ pluginId: string, pluginRoot: string, dataDir: string, listen: { host: string, port: number }, signal: AbortSignal, logger: Logger }} MiniAppContext + * @typedef {import('node:http').IncomingMessage} Req + * @typedef {import('node:http').ServerResponse} Res + */ + +const BODY_LIMIT = 12 * 1024 * 1024; +const DRAIN_LIMIT = 4 * BODY_LIMIT; + +const ASSET_MIME = /** @type {Record} */ ({ + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webp': 'image/webp', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}); + +class HttpError extends Error { + /** @param {number} status @param {string} code @param {string} message @param {Record} [headers] */ + constructor(status, code, message, headers) { + super(message); + this.status = status; + this.code = code; + this.headers = headers; + } +} + +/** + * @param {MiniAppContext} context + * @returns {Promise<{ dispose(): Promise }>} + */ +export async function start(context) { + const clientRoot = resolve(context.pluginRoot, 'miniapp', 'client'); + const logger = context.logger; + const store = createPetStore({ clientRoot, dataDir: context.dataDir, logger }); + const preferences = createPreferenceStore({ dataDir: context.dataDir }); + /** @type {string | null} */ + let clientRootReal = null; + + async function getClientRootReal() { + if (!clientRootReal) clientRootReal = await realpath(clientRoot); + return clientRootReal; + } + + const server = createServer((request, response) => { + handle(request, response).catch((error) => { + if (error instanceof HttpError || error instanceof PetError || error instanceof PreferenceError) { + const headers = error instanceof HttpError ? error.headers : undefined; + sendError(response, error.status, error.code, error.message, headers); + return; + } + logger.error('pet.http.internal_error', { errno: errnoCode(error) }); + sendError(response, 500, 'internal_error', 'internal error'); + }); + }); + + /** @param {Req} request @param {Res} response */ + async function handle(request, response) { + const rawUrl = request.url ?? ''; + if (!rawUrl.startsWith('/')) throw new HttpError(400, 'bad_request', 'invalid request target'); + const queryAt = rawUrl.indexOf('?'); + const path = queryAt === -1 ? rawUrl : rawUrl.slice(0, queryAt); + const method = request.method ?? 'GET'; + + if (path === '/pet') { + requireMethod(method, ['GET']); + return servePage(response); + } + if (path.startsWith('/assets/')) { + requireMethod(method, ['GET']); + return serveAsset(response, path.slice('/assets/'.length)); + } + if (path === '/api/preferences') { + requireMethod(method, ['GET', 'PATCH']); + if (method === 'GET') return sendJson(response, 200, await preferences.get()); + // This local API stores preferences only. Reject explicit cross-origin browser writes. + const origin = request.headers.origin; + if (origin && origin !== `http://${request.headers.host}`) throw new HttpError(403, 'origin_not_allowed', 'cross-origin write not allowed'); + const condition = request.headers['if-none-match']; + if (condition !== undefined && condition !== '*') throw new HttpError(400, 'invalid_preferences_condition', 'only If-None-Match: * is supported'); + return sendJson(response, 200, await preferences.patch(await readJsonBody(request, 16 * 1024), { onlyIfUnpersisted: condition === '*' })); + } + if (path === '/api/pets') { + requireMethod(method, ['GET', 'POST']); + if (method === 'GET') { + return sendJson(response, 200, { pets: await store.listPets() }); + } + const body = await readJsonBody(request); + const pet = await store.importPet(body); + return sendJson(response, 201, { pet }); + } + if (path.startsWith('/api/pets/')) { + const segments = splitSegments(path.slice('/api/pets/'.length)); + if (!segments || segments.length !== 1) throw notFound(); + requireMethod(method, ['DELETE']); + await store.deletePet(segments[0]); + response.writeHead(204, { 'cache-control': 'no-store' }); + response.end(); + return; + } + if (path.startsWith('/imported/')) { + const segments = splitSegments(path.slice('/imported/'.length)); + if (!segments || segments.length !== 2) throw notFound(); + requireMethod(method, ['GET']); + const [id, file] = segments; + if (!PET_ID_PATTERN.test(id)) throw notFound(); + const resolved = await store.resolveImportedFile(id, file); + if (!resolved) throw notFound(); + return sendFile(response, resolved.path, resolved.contentType); + } + throw notFound(); + } + + /** @param {Res} response */ + async function servePage(response) { + let html; + try { + html = await readFile(join(clientRoot, 'index.html')); + } catch (error) { + if (errnoCode(error) === 'ENOENT') { + throw new HttpError(503, 'client_missing', 'client index.html is not available'); + } + throw error; + } + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(html.length), + 'cache-control': 'no-cache', + 'x-content-type-options': 'nosniff', + }); + response.end(html); + } + + /** @param {Res} response @param {string} rawRest */ + async function serveAsset(response, rawRest) { + const segments = splitSegments(rawRest); + if (!segments) throw notFound(); + const contentType = ASSET_MIME[extname(segments[segments.length - 1]).toLowerCase()]; + if (!contentType) throw notFound(); + const candidate = resolve(clientRoot, ...segments); + if (!isInside(candidate, clientRoot)) throw notFound(); + let real; + let rootReal; + try { + [real, rootReal] = await Promise.all([realpath(candidate), getClientRootReal()]); + } catch { + throw notFound(); + } + if (!isInside(real, rootReal)) throw notFound(); + return sendFile(response, real, contentType); + } + + await listen(server, context.listen.host, context.listen.port); + logger.info('pet.runtime.listening'); + + /** @type {Promise | null} */ + let disposing = null; + const dispose = () => { + disposing ??= (async () => { + context.signal.removeEventListener('abort', onAbort); + server.closeAllConnections(); + await close(server); + logger.info('pet.runtime.disposed'); + })(); + return disposing; + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** + * Splits a raw path remainder into decoded segments. Returns null when any segment is empty, `.`, `..`, + * contains a backslash, NUL, or `/` after decoding, or fails percent-decoding. + * @param {string} raw + * @returns {string[] | null} + */ +function splitSegments(raw) { + if (raw.length === 0 || raw.includes('\\')) return null; + /** @type {string[]} */ + const out = []; + for (const part of raw.split('/')) { + let decoded; + try { + decoded = decodeURIComponent(part); + } catch { + return null; + } + if ( + decoded.length === 0 || + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('\0') + ) { + return null; + } + out.push(decoded); + } + return out; +} + +/** @param {string} child @param {string} parent */ +function isInside(child, parent) { + return child.startsWith(parent.endsWith(sep) ? parent : parent + sep); +} + +/** @param {string} method @param {string[]} allowed */ +function requireMethod(method, allowed) { + if (!allowed.includes(method)) { + throw new HttpError(405, 'method_not_allowed', `method ${method} not allowed`, { allow: allowed.join(', ') }); + } +} + +function notFound() { + return new HttpError(404, 'not_found', 'not found'); +} + +/** @param {unknown} error */ +function errnoCode(error) { + return error && typeof error === 'object' && 'code' in error ? String(error.code) : 'unknown'; +} + +/** @param {Res} response @param {number} status @param {unknown} payload @param {Record} [headers] */ +function sendJson(response, status, payload, headers) { + const body = Buffer.from(JSON.stringify(payload)); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(body.length), + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + ...headers, + }); + response.end(body); +} + +/** @param {Res} response @param {number} status @param {string} code @param {string} message @param {Record} [headers] */ +function sendError(response, status, code, message, headers) { + if (response.headersSent) { + response.destroy(); + return; + } + sendJson(response, status, { error: code, message }, headers); +} + +/** @param {Res} response @param {string} path @param {string} contentType */ +async function sendFile(response, path, contentType) { + let handle; + try { + handle = await open(path, 'r'); + } catch { + throw notFound(); + } + try { + const st = await handle.stat(); + if (!st.isFile()) throw notFound(); + response.writeHead(200, { + 'content-type': contentType, + 'content-length': String(st.size), + 'cache-control': 'no-cache', + 'x-content-type-options': 'nosniff', + }); + const stream = handle.createReadStream({ autoClose: false }); + try { + await pipeline(stream, response); + } catch { + response.destroy(); + } + } finally { + await handle.close().catch(() => {}); + } +} + +/** + * @param {Req} request + * @param {number} [limit] + * @returns {Promise} + */ +function readJsonBody(request, limit = BODY_LIMIT) { + const mediaType = String(request.headers['content-type'] ?? '') + .split(';')[0] + .trim() + .toLowerCase(); + if (mediaType !== 'application/json') { + return Promise.reject(new HttpError(415, 'unsupported_media_type', 'content-type must be application/json')); + } + const declared = Number(request.headers['content-length']); + if (Number.isFinite(declared) && declared > limit) { + drainOrDestroy(request, 0); + return Promise.reject(tooLarge(limit)); + } + return new Promise((resolvePromise, rejectPromise) => { + /** @type {Buffer[]} */ + const chunks = []; + let size = 0; + const onData = (/** @type {Buffer} */ chunk) => { + size += chunk.length; + if (size > limit) { + cleanup(); + drainOrDestroy(request, size); + rejectPromise(tooLarge(limit)); + return; + } + chunks.push(chunk); + }; + const onEnd = () => { + cleanup(); + try { + resolvePromise(JSON.parse(Buffer.concat(chunks, size).toString('utf8'))); + } catch { + rejectPromise(new HttpError(400, 'invalid_json', 'request body is not valid JSON')); + } + }; + const onError = () => { + cleanup(); + rejectPromise(new HttpError(400, 'bad_request', 'request body could not be read')); + }; + const cleanup = () => { + request.off('data', onData); + request.off('end', onEnd); + request.off('error', onError); + }; + request.on('data', onData); + request.on('end', onEnd); + request.on('error', onError); + }); +} + +function tooLarge(limit = BODY_LIMIT) { + return new HttpError(413, 'payload_too_large', `request body exceeds ${limit} bytes`); +} + +/** + * Stops buffering an oversized body. Remaining bytes are discarded so the 413 response reaches the + * client; the connection is destroyed if the client keeps sending far past the limit. + * @param {Req} request + * @param {number} alreadyRead + */ +function drainOrDestroy(request, alreadyRead) { + let drained = alreadyRead; + request.on('data', (/** @type {Buffer} */ chunk) => { + drained += chunk.length; + if (drained > DRAIN_LIMIT) request.destroy(); + }); + request.on('error', () => {}); + request.resume(); +} + +/** @param {import('node:http').Server} server @param {string} host @param {number} port */ +function listen(server, host, port) { + return new Promise((resolvePromise, rejectPromise) => { + /** @param {Error} error */ + const onError = (error) => rejectPromise(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolvePromise(undefined); + }); + }); +} + +/** @param {import('node:http').Server} server */ +function close(server) { + return new Promise((resolvePromise, rejectPromise) => { + server.close((error) => (error ? rejectPromise(error) : resolvePromise(undefined))); + }); +} diff --git a/plugins/minimax-ai/minimax-pet/package.json b/plugins/minimax-ai/minimax-pet/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/minimax-ai/minimax-pet/skills/minimax-pet/SKILL.md b/plugins/minimax-ai/minimax-pet/skills/minimax-pet/SKILL.md new file mode 100644 index 0000000..8540405 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/skills/minimax-pet/SKILL.md @@ -0,0 +1,38 @@ +--- +name: minimax-pet +description: Open MiniMax Pet or create, validate and import custom desktop-pet spritesheets. Use when making a new pet, turning a character image into a pet, or fixing an incompatible atlas. +--- + +# MiniMax Pet + +Choose the branch matching the user's request. Creating artwork produces files in the user's workspace; it does not create a separate chat or send, stop, or answer user conversations. + +## Open the pet or view reminders + +Call the Host's currently available `miniapp` tool: + +```json +{ "action": "open", "pluginId": "minimax-pet" } +``` + +Report the app open only when the tool returns `ok: true` and `result.opened: true`. Otherwise report the actual failure. The user can check installation and enablement in plugin management, use the appearance/tasks/settings tabs, and choose the desktop-window action. + +## Create or repair pet artwork + +1. Read the complete [atlas and metadata specification](references/spritesheet-format.md) before generating images. Reuse character/style/reference details already supplied; clarify only missing choices that materially affect the artwork. Use image-generation, drawing and processing tools actually available in this turn. Create original artwork by default; this package does not bundle third-party character art. +2. Prefer **v2: a static transparent RGBA PNG, exactly 1536 x 2288 pixels**. It has **8 columns x 11 rows**, each cell **192 x 208 pixels**, nine animation tracks and sixteen look directions. A portrait, animation GIF, video or arbitrary contact sheet is not an atlas. If the look directions cannot be made, explicitly deliver v1 at 1536 x 1872 with version 1 instead of claiming v2. +3. Create an output directory with `pet.json` and its named `spritesheet.png`. Use a custom pet ID such as `my-mint-pet`; `minimax-original` is reserved, while `minimax-pet` is the plugin identity and must not be changed. Keep the character, scale and baseline consistent, with real alpha transparency and no clipping across cell edges. When a generation tool cannot produce the exact layout, make consistent frames first, then use available tools to fit them proportionally, pad with transparency and assemble the exact grid. Stretching an arbitrary collage is not sufficient. +4. Run the bundled [PNG validation tool](scripts/validate-art.mjs): + + ```sh + node "/scripts/validate-art.mjs" "/pet.json" + ``` + + Resolve `` from the actual current location of this `SKILL.md`; do not guess an installation/profile path. The script needs Node.js 22+, no dependency installation, writes no files and calls no model. Read its real result: exit 0 and `ok: true` pass this generation profile. Fix errors and rerun. Review or explain warnings, and visually check character consistency, actions and look directions. If execution or visual review is unavailable, state what remains unverified; do not invent a successful check. +5. Deliver the two actual file paths, version, validation result and useful preview. When import is requested, open MiniMax Pet and select those two files through Appearance -> Import, inspect the preview and confirm. Replacing an existing ID needs user confirmation. Claim import only after observing success. Do not write the Host's dataDir, use private import endpoints or edit an installed package to bypass the import workflow. + +## Validation scope + +Use static 8-bit RGBA, non-interlaced PNG for generated artwork, with a conservative 8 MiB image budget. The importer also accepts other decodable PNG encodings and static WebP; the bundled script does not fully decode WebP, so its limitations are not the importer's complete support matrix. For existing WebP, use actual image tools to convert to this PNG profile before checking, or explicitly use the MiniApp's decoded preview. Renaming an extension is not conversion. + +The validator checks data, pixels and layout, plus limited quality signals. It cannot prove character identity, artistic consistency, action meaning or correct gaze semantics. Its transparent-background/nonempty-cell checks are generation delivery requirements, not a claim that the current importer performs the same pixel checks. diff --git a/plugins/minimax-ai/minimax-pet/skills/minimax-pet/references/spritesheet-format.md b/plugins/minimax-ai/minimax-pet/skills/minimax-pet/references/spritesheet-format.md new file mode 100644 index 0000000..c60efd8 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/skills/minimax-pet/references/spritesheet-format.md @@ -0,0 +1,87 @@ +# MiniMax Pet atlas specification + +This specification follows the plugin's player, import service and import UI. Choose a version before making frames, then deliver one static image and `pet.json`. + +## Deliverable + +```text +my-mint-pet/ + pet.json + spritesheet.png +``` + +Recommended v2 metadata: + +```json +{ + "id": "my-mint-pet", + "displayName": "My Mint Pet", + "description": "An original mint-colored companion.", + "spritesheetPath": "spritesheet.png", + "spriteVersionNumber": 2 +} +``` + +This is pet metadata, not a plugin manifest. Do not add `agentAccess`, change the plugin ID, or start Agent turns. + +- `id`: 1-80 characters matching `^[a-z0-9][a-z0-9._-]{0,79}$`. Choose a new custom ID; `minimax-original` is reserved. Whether an imported ID already exists is checked by the actual import UI, not this tool. +- `displayName`: nonblank, without leading/trailing spaces, at most **80 UTF-16 code units** (JavaScript `.length`; an emoji can occupy two). This fits both client and server limits. +- `description`: optional string, at most **300 UTF-16 code units**; do not use `null`. +- `spritesheetPath`: a plain same-directory filename. Prefer `spritesheet.png`; no directories, URL, or data URL. The runtime pattern is `^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(webp|png)$`. The extension is lowercase and must match the actual encoding. +- `spriteVersionNumber`: number 1 or 2; omission means 1. v2 must explicitly declare 2, not the string `"2"`. +- Arbitrary extra author/license/animation fields are not a persistent import protocol. Keep attribution separately if needed; select only the JSON and its atlas when importing. + +## Canvas and cells + +| Version | Exact image dimensions | Grid | Content | +| --- | --- | --- | --- | +| v1 | **1536 x 1872 px** | 8 columns x 9 rows | Nine animation tracks | +| v2, preferred | **1536 x 2288 px** | 8 columns x 11 rows | Nine tracks plus sixteen look poses | + +Each cell is **192 x 208 px**. Rows/columns are zero-based; a cell starts at `(column * 192, row * 208)`. There are no gutters, outer margins, labels, grid lines or borders. Leave unused trailing cells transparent. Do not add pixels to accommodate a title or change the cell size and merely relabel the JSON. + +The generation profile is **static 8-bit RGBA, non-interlaced PNG**, with real alpha transparency. Do not bake white, a checkerboard, a pedestal or explanatory text into the background. GIF, video, animated PNG/WebP, JPEG, SVG and a single portrait are not this deliverable. The runtime can import more PNG encodings and static WebP; the bundled validator fully checks only this PNG subset. + +## First nine rows: animations + +| Row | State | Valid columns / frame count | Intended pose | +| --- | --- | --- | --- | +| 0 | `idle` | 0-5 / 6 | Calm breathing or blinking | +| 1 | `running-right` | 0-7 / 8 | Run toward the right | +| 2 | `running-left` | 0-7 / 8 | Run toward the left | +| 3 | `waving` | 0-3 / 4 | Greeting | +| 4 | `jumping` | 0-4 / 5 | Takeoff, apex, return to baseline | +| 5 | `failed` | 0-7 / 8 | Failure/disappointment reaction | +| 6 | `waiting` | 0-5 / 6 | Waiting for the user's answer | +| 7 | `running` | 0-5 / 6 | Working; not another right-running row | +| 8 | `review` | 0-5 / 6 | Draw attention to a result | + +There are **57 valid animation cells**. Keep identity, palette, scale and foot baseline consistent, allowing intentional jump/run movement. Keep all limbs/antennae inside their cell; neighboring frames must not bleed in. Make motions connect across the cycle. Explain deliberately static tracks rather than claiming identical cells demonstrate verified animation. + +Timing belongs to the player, not JSON FPS fields. Idle's six frames total 6600 ms. Normal non-idle actions play three cycles then return to slow idle; previews can explicitly loop. Do not add frames or GIF delays to alter playback timing. + +## Last two v2 rows: look directions + +Each cell is a static gaze direction, not an extra animation. **Zero degrees points UP, increasing clockwise in 22.5-degree steps.** It does not start facing right. Keep the body baseline stable while changing the head or gaze. + +| Row | Column 0 | Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | Column 6 | Column 7 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 9 | 0 up | 22.5 up/right | 45 up-right | 67.5 right/up | 90 right | 112.5 right/down | 135 down-right | 157.5 down/right | +| 10 | 180 down | 202.5 down/left | 225 down-left | 247.5 left/down | 270 left | 292.5 left/up | 315 up-left | 337.5 up/left | + +Index `i` maps to row `9 + floor(i / 8)`, column `i % 8`. v2 has **73 valid cells** overall. Sixteen different hashes do not prove correct direction: inspect up/right/down/left at minimum. If these poses are unavailable, deliver the nine-row v1 image and version 1; two blank rows do not make a v2 pet. + +## Image-generation and assembly workflow + +1. Establish an original character reference and lock identity, palette, camera angle and scale. +2. Generate/draw the required frames against that reference. Enable actual transparent output when supported; a painted checkerboard is not transparency. +3. If processing is needed, fit each frame proportionally inside its cell, pad transparently to 192 x 208 and assemble the exact grid. A directly generated atlas is acceptable only after its actual layout, content and dimensions pass checks; ignore size claims printed by the model. +4. Export static RGBA PNG and matching JSON. Keep the atlas **at or below 8 MiB** for this generation profile. +5. Run the bundled [validator](../scripts/validate-art.mjs), then visually inspect motion, edges, consistency and gaze direction. Resolve or explain warnings; a successful script is not proof of artistic quality or action meaning. +6. Import only `pet.json` and its named atlas through the app, review the decoded preview and confirm. Preserve an honest unverified/failed status when any check or import was not observed. + +## Size and validation boundaries + +The HTTP limit is **12 MiB (12,582,912 bytes) for the entire UTF-8 JSON request**, not a 12 MiB image. Base64 expands an image by roughly 4/3. The UI budget is `ceil(imageBytes / 3) * 4 + 4096 <= 12 * 1024 * 1024`. The generator's 8 MiB image cap leaves room for metadata; the script also estimates the actual encoded request. Let the normal import UI encode the files, rather than constructing private Host requests or writing its data directory. + +The tool validates metadata, full PNG decoding, dimensions/version, nonempty valid cells and transparent backgrounds. Edge contact, motionless tracks and repeated gaze cells produce warnings. It reads no Host state and changes no import state, cannot detect existing user-imported IDs, and cannot understand the depicted character/action. The generation alpha/cell requirements are stricter than current runtime header checks. Convert WebP or palette PNG to this profile with real tools, or use a real decoded preview; do not claim this script fully verified a format it does not support. diff --git a/plugins/minimax-ai/minimax-pet/skills/minimax-pet/scripts/validate-art.mjs b/plugins/minimax-ai/minimax-pet/skills/minimax-pet/scripts/validate-art.mjs new file mode 100644 index 0000000..2518771 --- /dev/null +++ b/plugins/minimax-ai/minimax-pet/skills/minimax-pet/scripts/validate-art.mjs @@ -0,0 +1,251 @@ +// Copyright MiniMax. SPDX-License-Identifier: MIT +// Standalone generation check: static 8-bit RGBA PNG only. No app install or dependencies needed. +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateSync } from 'node:zlib'; + +const JSON_LIMIT = 1024 * 1024; +// A generation budget, not the UI's exact PNG limit. The UI limits the complete JSON request. +const PNG_LIMIT = 8 * 1024 * 1024; +const IMPORT_BODY_LIMIT = 12 * 1024 * 1024; +const CELL_W = 192; +const CELL_H = 208; +const FRAME_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8]; +const STATE_NAMES = ['idle', 'running-right', 'running-left', 'waving', 'jumping', 'failed', 'waiting', 'running', 'review']; +const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +class InvalidArt extends Error {} +function requireArt(condition, message) { + if (!condition) throw new InvalidArt(message); +} + +async function readRegularFile(filePath, limit, label) { + let handle; + try { + const metadata = await lstat(filePath); + requireArt(metadata.isFile() && !metadata.isSymbolicLink(), `${label} must be a regular file, not a symlink.`); + requireArt(metadata.size <= limit, `${label} exceeds the ${limit / 1024 / 1024} MiB generation budget.`); + handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + requireArt((await handle.stat()).isFile(), `${label} must be a regular file, not a symlink.`); + const bytes = Buffer.alloc(limit + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null); + if (!bytesRead) break; + length += bytesRead; + } + requireArt(length <= limit, `${label} exceeds the ${limit / 1024 / 1024} MiB generation budget.`); + return bytes.subarray(0, length); + } catch (error) { + if (error instanceof InvalidArt) throw error; + throw new InvalidArt(`Cannot read ${label} as a regular file.`); + } finally { + await handle?.close(); + } +} + +function validateMetadata(pet) { + const errors = []; + if (!pet || typeof pet !== 'object' || Array.isArray(pet)) return ['pet.json must contain a JSON object.']; + if (typeof pet.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(pet.id)) { + errors.push('id must match ^[a-z0-9][a-z0-9._-]{0,79}$.'); + } else if (pet.id === 'minimax-original') { + errors.push('The id minimax-original is reserved for the bundled pet; choose a unique custom id.'); + } + // The Node importer counts Unicode code points, but the page currently counts UTF-16 units. + // Use the stricter page limit so generated metadata also passes the import preview. + if (typeof pet.displayName !== 'string' || pet.displayName.trim().length < 1 || pet.displayName.length > 80) { + errors.push('displayName must be non-blank and at most 80 UTF-16 code units.'); + } + if (pet.description !== undefined && (typeof pet.description !== 'string' || pet.description.length > 300)) { + errors.push('description must be a string of at most 300 UTF-16 code units.'); + } + if (pet.spriteVersionNumber !== undefined && pet.spriteVersionNumber !== 1 && pet.spriteVersionNumber !== 2) { + errors.push('spriteVersionNumber must be omitted, 1 or 2.'); + } + if (typeof pet.spritesheetPath !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.png$/.test(pet.spritesheetPath)) { + errors.push('spritesheetPath must be a plain ASCII *.png filename, without a directory or URL.'); + } + return errors; +} + +const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + return crc >>> 0; +}); +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 255] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} +function paeth(left, above, upperLeft) { + const predictor = left + above - upperLeft; + const a = Math.abs(predictor - left); + const b = Math.abs(predictor - above); + const c = Math.abs(predictor - upperLeft); + return a <= b && a <= c ? left : b <= c ? above : upperLeft; +} + +function decodePng(bytes, version) { + requireArt(bytes.subarray(0, 8).equals(SIGNATURE), 'The spritesheet must be a PNG image; WebP is not decoded by this generation checker.'); + const width = 1536; + const height = version === 2 ? 2288 : 1872; + const compressedChunks = []; + let seenHeader = false; + let seenEnd = false; + let dataEnded = false; + let seenPalette = false; + for (let offset = 8; offset < bytes.length;) { + requireArt(offset + 12 <= bytes.length, 'PNG contains a truncated chunk.'); + const size = bytes.readUInt32BE(offset); + const end = offset + 12 + size; + requireArt(end <= bytes.length, 'PNG contains a truncated chunk.'); + const kind = bytes.toString('latin1', offset + 4, offset + 8); + requireArt(/^[A-Za-z]{2}[A-Z][A-Za-z]$/.test(kind), 'PNG contains an invalid chunk type.'); + requireArt(crc32(bytes.subarray(offset + 4, end - 4)) === bytes.readUInt32BE(end - 4), 'PNG chunk CRC does not match.'); + requireArt(seenHeader || kind === 'IHDR', 'PNG must start with IHDR.'); + const data = bytes.subarray(offset + 8, end - 4); + if (kind === 'IHDR') { + requireArt(!seenHeader && offset === 8 && size === 13, 'PNG must have exactly one 13-byte IHDR at the start.'); + seenHeader = true; + requireArt(data.readUInt32BE(0) === width && data.readUInt32BE(4) === height, `Version ${version} requires exactly ${width}×${height} pixels.`); + requireArt(data[8] === 8 && data[9] === 6 && data[10] === 0 && data[11] === 0 && data[12] === 0, + 'Export a non-interlaced, 8-bit RGBA PNG (color type 6, standard compression and filters).'); + } else if (kind === 'IDAT') { + requireArt(!dataEnded, 'PNG IDAT chunks must be consecutive.'); + compressedChunks.push(data); + } else { + if (compressedChunks.length) dataEnded = true; + requireArt(!['acTL', 'fcTL', 'fdAT'].includes(kind), 'Export a static PNG atlas, not an animated PNG.'); + if (kind === 'IEND') { + requireArt(size === 0 && end === bytes.length, 'PNG IEND must be empty and the final bytes of the file.'); + seenEnd = true; + } else if (kind === 'PLTE') { + requireArt(!seenPalette && !compressedChunks.length && size > 0 && size <= 768 && size % 3 === 0, 'PNG contains an invalid or misplaced PLTE chunk.'); + seenPalette = true; + } else { + requireArt(kind[0] === kind[0].toLowerCase(), 'PNG contains an unsupported critical chunk.'); + requireArt(kind !== 'tRNS', 'RGBA PNG must encode transparency in its alpha channel, without tRNS.'); + } + } + offset = end; + } + requireArt(seenHeader && seenEnd && compressedChunks.length > 0, 'PNG must contain IHDR, IDAT and IEND.'); + const compressed = Buffer.concat(compressedChunks); + const stride = width * 4; + const expectedLength = (stride + 1) * height; + let scanlines; + try { + const inflated = inflateSync(compressed, { maxOutputLength: expectedLength + 1, info: true }); + requireArt(inflated.engine.bytesWritten === compressed.length, 'PNG image data contains trailing compressed bytes.'); + scanlines = inflated.buffer; + } catch (error) { + if (error instanceof InvalidArt) throw error; + throw new InvalidArt('PNG image data is invalid, incomplete or exceeds its declared dimensions.'); + } + requireArt(scanlines.length === expectedLength, 'PNG decoded image data does not match its dimensions.'); + const pixels = Buffer.alloc(stride * height); + for (let y = 0; y < height; y++) { + const encodedOffset = y * (stride + 1); + const filter = scanlines[encodedOffset]; + requireArt(filter <= 4, 'PNG contains an unsupported scanline filter.'); + for (let x = 0; x < stride; x++) { + const index = y * stride + x; + const left = x >= 4 ? pixels[index - 4] : 0; + const above = y ? pixels[index - stride] : 0; + const upperLeft = y && x >= 4 ? pixels[index - stride - 4] : 0; + let predictor = 0; + if (filter === 1) predictor = left; + else if (filter === 2) predictor = above; + else if (filter === 3) predictor = Math.floor((left + above) / 2); + else if (filter === 4) predictor = paeth(left, above, upperLeft); + pixels[index] = (scanlines[encodedOffset + x + 1] + predictor) & 255; + } + } + return { width, height, pixels }; +} + +function inspectFrames({ width, pixels }, version) { + const errors = []; + const warnings = []; + const lookDigests = []; + let frames = 0; + for (let row = 0; row < (version === 2 ? 11 : 9); row++) { + const digests = []; + for (let frame = 0; frame < FRAME_COUNTS[row]; frame++) { + let visible = false; + let transparent = false; + let touchesEdge = false; + const normalized = Buffer.alloc(CELL_W * CELL_H * 4); + for (let y = 0; y < CELL_H; y++) { + for (let x = 0; x < CELL_W; x++) { + const source = ((row * CELL_H + y) * width + frame * CELL_W + x) * 4; + const alpha = pixels[source + 3]; + if (alpha === 0) transparent = true; + if (alpha > 24) { + visible = true; + pixels.copy(normalized, (y * CELL_W + x) * 4, source, source + 4); + if (x === 0 || x === CELL_W - 1 || y === 0 || y === CELL_H - 1) touchesEdge = true; + } + } + } + const position = `row ${row}, column ${frame}`; + if (!visible) errors.push(`${position} is empty: add visible pixels with alpha greater than 24.`); + if (!transparent) errors.push(`${position} has no fully transparent background pixel.`); + if (touchesEdge) warnings.push(`${position} touches the cell edge; check for clipping during playback.`); + const digest = createHash('sha256').update(normalized).digest('hex'); + digests.push(digest); + if (row >= 9) lookDigests.push(digest); + frames++; + } + if (row < 9 && new Set(digests).size === 1) { + warnings.push(`The ${STATE_NAMES[row]} animation has identical visible content in every frame.`); + } + } + if (version === 2 && new Set(lookDigests).size < 16) warnings.push('Some of the sixteen look directions have identical visible content; check the direction order and gaze.'); + return { errors, warnings, frames }; +} + +/** + * Check generated pet.json and its sibling PNG without importing or modifying either file. + * The report contains only format information and diagnostic text, never image data or metadata values. + * @param {string} petJsonPath + * @returns {Promise<{ok: true, version: number, width: number, height: number, frames: number, warnings: string[]} | {ok: false, errors: string[]}>} + */ +export async function validatePetArt(petJsonPath) { + try { + requireArt(typeof petJsonPath === 'string' && petJsonPath.length > 0, 'Supply the path to pet.json.'); + const jsonBytes = await readRegularFile(petJsonPath, JSON_LIMIT, 'pet.json'); + let pet; + try { + pet = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(jsonBytes)); + } catch { + throw new InvalidArt('pet.json must contain valid UTF-8 JSON.'); + } + const metadataErrors = validateMetadata(pet); + if (metadataErrors.length) return { ok: false, errors: metadataErrors }; + const bytes = await readRegularFile(path.join(path.dirname(petJsonPath), pet.spritesheetPath), PNG_LIMIT, 'spritesheet PNG'); + // Base64 has no JSON escape overhead. Include the replacement flag so both import requests fit. + const requestBytes = Buffer.byteLength(JSON.stringify({ petJson: pet, spritesheet: { fileName: pet.spritesheetPath, base64: '' }, replace: true })) + 4 * Math.ceil(bytes.length / 3); + requireArt(requestBytes <= IMPORT_BODY_LIMIT, 'The base64 import request exceeds the 12 MiB JSON body limit; reduce the PNG or metadata.'); + const version = pet.spriteVersionNumber === 2 ? 2 : 1; + const image = decodePng(bytes, version); + const { errors, warnings, frames } = inspectFrames(image, version); + if (errors.length) return { ok: false, errors }; + return { ok: true, version, width: image.width, height: image.height, frames, warnings }; + } catch (error) { + return { ok: false, errors: [error instanceof InvalidArt ? error.message : 'Unable to validate the generated pet files.'] }; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = process.argv.length === 3 + ? await validatePetArt(process.argv[2]) + : { ok: false, errors: ['Usage: node validate-art.mjs /pet.json'] }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.ok ? 0 : 1; +} diff --git a/scripts/lib/contract.mjs b/scripts/lib/contract.mjs index 46d67a0..8965417 100644 --- a/scripts/lib/contract.mjs +++ b/scripts/lib/contract.mjs @@ -76,6 +76,7 @@ export const MINIAPP_FIELDS = new Set([ 'hostConnectorAccess', 'agentAccess', 'agentConcurrency', + 'deviceAccess', ]); export const ARTIFACT_FIELDS = new Set(['client', 'node']); export const RUNTIME_FIELDS = new Set(['kind', 'entry', 'lifecycle']); @@ -85,6 +86,7 @@ export const SURFACE_WINDOW_BOOLEAN_FIELDS = ['resizable', 'frame', 'transparent export const SURFACE_WINDOW_DIMENSION_MIN = 200; export const SURFACE_WINDOW_DIMENSION_MAX = 10_000; export const AGENT_ACCESS_CAPABILITIES = new Set(['session.run', 'session.read']); +export const DEVICE_ACCESS_CAPABILITIES = new Set(['microphone']); export const AGENT_CONCURRENCY_MAX = 32; // The Host answers requests under this prefix itself; plugin routes there never work. export const RESERVED_ROUTE_PREFIX = '/__mavis'; diff --git a/scripts/lib/rules/miniapp.mjs b/scripts/lib/rules/miniapp.mjs index 11eae5d..5f77bd1 100644 --- a/scripts/lib/rules/miniapp.mjs +++ b/scripts/lib/rules/miniapp.mjs @@ -5,6 +5,7 @@ import { AGENT_CONCURRENCY_MAX, ARTIFACT_FIELDS, CONNECTOR_PROVIDER, + DEVICE_ACCESS_CAPABILITIES, ENTRY_EXTENSION, EXCLUDED_DIRECTORY, HOST_CONNECTOR_ACCESS_FIELDS, @@ -46,6 +47,7 @@ export async function checkMiniApp(report, { packageDir, manifest }) { checkHostConnectorAccess(report, manifest.hostConnectorAccess); checkAgentAccess(report, manifest.agentAccess); checkAgentConcurrency(report, manifest.agentConcurrency); + checkDeviceAccess(report, manifest.deviceAccess); if (manifest.agentAccess !== undefined) resolved.features.push('agentAccess'); if (Array.isArray(manifest.agentAccess) && manifest.agentAccess.includes('session.read')) resolved.features.push('agentAccess.session.read'); if (manifest.agentConcurrency !== undefined) resolved.features.push('agentConcurrency'); @@ -219,9 +221,27 @@ function checkHostConnectorAccess(report, value) { function checkAgentAccess(report, value) { if (value === undefined) return; - if (!Array.isArray(value) || value.some((capability) => !AGENT_ACCESS_CAPABILITIES.has(capability))) { - report.error('MINIAPP_AGENT_ACCESS_INVALID', `agentAccess must be an array containing only ${[...AGENT_ACCESS_CAPABILITIES].map((c) => `"${c}"`).join(', ')}`, FILE); + if (!Array.isArray(value) || value.some((capability) => !AGENT_ACCESS_CAPABILITIES.has(capability)) || new Set(value).size !== value.length) { + report.error('MINIAPP_AGENT_ACCESS_INVALID', `agentAccess must be a unique string array containing only ${[...AGENT_ACCESS_CAPABILITIES].map((c) => `"${c}"`).join(', ')}`, FILE); + return; + } +} + +function checkDeviceAccess(report, value) { + if (value === undefined) return; + if (!Array.isArray(value) || value.some((capability) => !DEVICE_ACCESS_CAPABILITIES.has(capability)) || new Set(value).size !== value.length) { + report.error('MINIAPP_DEVICE_ACCESS_INVALID', 'deviceAccess must be a unique string array containing only "microphone"', FILE); + return; } + if (value.includes('microphone')) reportHostExtension(report, 'deviceAccess: microphone'); +} + +function reportHostExtension(report, capability) { + report.warning( + 'HOST_EXTENSION_UNVERIFIED', + `${capability} requires a Host build that supports this extension; its minimum released client version is unverified. This check validates declaration shape, not runtime availability.`, + FILE, + ); } function checkAgentConcurrency(report, value) { diff --git a/sources/minimax-ai/minimax-pet/.gitignore b/sources/minimax-ai/minimax-pet/.gitignore new file mode 100644 index 0000000..b0006f8 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +dev/.data-local/ diff --git a/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json b/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json new file mode 100644 index 0000000..3b64abb --- /dev/null +++ b/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "name": "minimax-pet", + "displayName": "MiniMax Pet", + "version": "0.5.8", + "description": "轻量桌宠,聚合主窗口可见会话的任务状态、需要回答的提醒和最新结果,支持外观切换及图集导入。", + "author": "MiniMax", + "icon": "icon.png", + "category": "Other", + "exampleQueries": [ + "打开 MiniMax Pet,查看任务提醒", + "制作一只原创桌宠,生成可导入的图集" + ], + "apps": [], + "mcpServers": [], + "skills": [ + "skills/minimax-pet/SKILL.md" + ] +} diff --git a/sources/minimax-ai/minimax-pet/ARTWORK.md b/sources/minimax-ai/minimax-pet/ARTWORK.md new file mode 100644 index 0000000..889d90f --- /dev/null +++ b/sources/minimax-ai/minimax-pet/ARTWORK.md @@ -0,0 +1,13 @@ +# MiniMax Pet artwork + +The bundled `minimax-original` pet and `icon.png` are original geometric artwork created for this plugin. Copyright (c) 2026 MiniMax. They are distributed under the plugin's [MIT License](LICENSE). + +The editable source is `scripts/generate-pet-art.mjs` in `sources/minimax-ai/minimax-pet`. It draws the character and encodes its PNGs using Node built-ins, without loading external images, fonts, or models. To regenerate from that source: + +```sh +npm run generate:art +``` + +The generated atlas is 1536 × 2288 pixels: eight 192 × 208 cells per row, nine animation rows and two rows of look directions. It is compatible with the existing player, including idle, running, waving, jumping, failure, waiting, work and review poses. Tests compare decoded pixels with a fresh deterministic generation, so PNG compression differences between Node releases do not change the artwork contract. The source/distribution package check still compares every shipped file byte. + +No third-party character art or externally sourced pet spritesheet is included in the current package. User-imported artwork is not part of this distribution and keeps its own license. diff --git a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md new file mode 100644 index 0000000..98d994b --- /dev/null +++ b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md @@ -0,0 +1,28 @@ +# MiniMax Pet development notes + +Work from this directory with Node.js 22 or later: + +```sh +npm ci --ignore-scripts +npm test +npm run test:smoke +npm run package +npm run check:package +``` + +`npm run dev -- --port 8080 --data-dir /tmp/minimax-pet-local-data` starts an isolated local resource/API server. Outside a supported MiniMax Code Host, the read/observe, desktop-window, confirmed draft, and transcription bridges are unavailable. This launcher does not emulate them or send Agent messages. Never use a real Host data directory for smoke tests. + +## Behavior to preserve + +- The appearance/tasks/settings navigation responds to clicks and ArrowLeft/ArrowRight/Home/End. Background task events, preference saves and thumbnail results never navigate or steal focus. +- Imports are an inline subview with explicit preview/confirm/cancel. Late file reads and image results are fenced by generation. A successful import updates the gallery without switching away from a tab the user has since chosen. +- Wide task views show list and detail together; narrow views provide a return action with focus/scroll restoration. Reading a reply must survive background polls without scroll reset. +- The desktop pet uses compact five-line notification cards and keeps the pet visible while reading. Completed status and answer-needed state must agree across notification, list and detail. +- One global observation plus three detailed observations share the Host budget. Cancellation does not free a slot until its subscription settles. Global reconnect also waits for the previous observation. +- Draft input targets the original chat via confirmed `message.append(text)` and never auto-sends. Leaving the draft cancels recording/transcription but preserves typed text. +- Preference and imported-resource writes are local to `context.dataDir`. Interrupted replacements retain enough information to restore or finish safely before the next read/write. Legacy ambiguous trash is retained for recovery. +- Resizing the desktop pet first brings the transparent window inside the available screen area so smaller sprites remain reachable. + +## Verification + +The Node test suite covers state aggregation, observer admission/release, reminders, page navigation, draft and speech cancellation, window geometry, import races, and interrupted disk replacement. DOM tests use jsdom; API tests use temporary listeners and directories and close them. Simulated boundaries do not prove real GUI, microphone, model, or released-client behavior. See the README for the current acceptance boundary. diff --git a/sources/minimax-ai/minimax-pet/LICENSE b/sources/minimax-ai/minimax-pet/LICENSE new file mode 100644 index 0000000..132dd20 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sources/minimax-ai/minimax-pet/PROVENANCE.md b/sources/minimax-ai/minimax-pet/PROVENANCE.md new file mode 100644 index 0000000..6656ebe --- /dev/null +++ b/sources/minimax-ai/minimax-pet/PROVENANCE.md @@ -0,0 +1,36 @@ +# Source and distribution + +Author: MiniMax ([MiniMax-AI](https://github.com/MiniMax-AI)). This is the 0.5.8 open-source edition of the MiniMax Pet MiniApp. It uses public MiniApp page APIs and contains no Host implementation. Public documentation, dependency URLs, and license notices have been prepared for this repository; private development references and acceptance records are not part of the package. + +## Layout + +- `sources/minimax-ai/minimax-pet/`: complete editable source, synthetic tests, development tools, and the dependency lockfile. +- `plugins/minimax-ai/minimax-pet/`: self-contained ready-to-install files. + +Change the source first, stage the package, and copy the complete staged tree into the distribution directory. The installed plugin does not depend on the source directory or on `node_modules`. + +## Reproduce + +With Node.js 22 or later, from the source directory: + +```sh +npm ci --ignore-scripts +npm test +npm run test:smoke +npm run package +npm run check:package +``` + +`package` stages an explicit runtime/documentation allowlist under `dist/package`. Synchronize that complete directory to `plugins/minimax-ai/minimax-pet` before `check:package`. The comparison covers every path and file byte, including images, code, metadata and licenses, and rejects links. Tests, the development lockfile, dependencies, tools, and local state are not staged. + +The root `npm run check` validates package contracts through the repository's existing workflow. Run the source tests, HTTP smoke, staging and exact distribution comparison locally with the commands above; this package does not add or change repository CI jobs. Tests use synthetic data and temporary data directories; they do not need the user's real Host profile. + +## Dependencies and artwork + +Production JavaScript uses relative modules and Node built-ins. `jsdom` 24.1.3 is used only for DOM tests; the lockfile pins public npm registry URLs and integrity values. No dependency install or build step is required by the installed plugin. + +The bundled pet atlas and icon are original geometric artwork generated by `scripts/generate-pet-art.mjs` from Node built-ins. `npm run generate:art` reproduces them without external images or fonts. They use the same MiniMax [MIT License](LICENSE) as the original code and documentation; see [ARTWORK.md](ARTWORK.md). No third-party character art or spritesheet is bundled. + +## Verification boundaries + +See `README.md` for tested and unverified environments. Static validation and a loopback smoke test do not prove released-client API availability, GUI behavior on every platform, microphone recording, transcription, or official-market installation. diff --git a/sources/minimax-ai/minimax-pet/README.md b/sources/minimax-ai/minimax-pet/README.md new file mode 100644 index 0000000..2b47aed --- /dev/null +++ b/sources/minimax-ai/minimax-pet/README.md @@ -0,0 +1,61 @@ +# MiniMax Pet + +English | [简体中文](README.zh-CN.md) + +A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visible conversation activity, answer-needed reminders and recent results without creating another chat. The interface is currently in Chinese. + +## Features + +- **Appearance:** preview and select pets, play short interactions, import compatible pet JSON and spritesheets, and remove imported pets. +- **Tasks:** recent reminders, visible user conversations, and a scrollable latest reply. Desktop cards preview up to five lines. Clicking a card keeps the pet visible; observed `ask_user` calls show “需要你回答” (needs your answer). +- **Settings:** pet size, page-local reminder dismissal and mute, connection status, and reconnect. +- **Desktop window:** a draggable transparent pet with compact notification cards. Return it to the main panel to use the three tabs. +- **Original-chat draft:** explicitly open the draft tool, then ask the Host to confirm appending text to the chat that opened the pet. The user still sends it. Voice-to-text is available only when the Host provides it and microphone permission is granted. + +The pet does not run Agent turns, create independent chats, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; it does not navigate the Host to an arbitrary conversation. + +## Install and open + +1. Copy the entire `plugins/minimax-ai/minimax-pet/` directory, including `.minimax-plugin`, into `/plugins/minimax-pet/`. The default data directory is `~/.minimax`; use your configured directory if different. The `minimax-ai` author directory is not part of the installed path. +2. Restart MiniMax Code and confirm the plugin is enabled. +3. Open “MiniMax Pet” from the MiniApp list, or ask the Agent “打开 MiniMax Pet”. +4. Choose **放到桌面** to show the desktop pet. + +To update, close the app and exit the client before replacing the complete package. Keep backups outside the client's plugins directory. Imported pets and preferences are stored separately in the Host-assigned plugin data directory. + +Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` its own data directory; importing the new package does not automatically move an older plugin's pets or preferences. Keep any existing installation/data until you have exported what you need. + +## Tested environment + +- MiniMax Code **3.1.4 or later** is the repository's minimum installation requirement for this package's `session.read` declaration (upstream reads were observed on 3.1.4-preview). **This is not released-client GUI acceptance of this plugin or a guarantee of the observation and speech APIs below.** +- Task monitoring additionally requires a Host build exposing `agent.observe` and `agent.cancel`, alongside `agent.sessions` and `agent.session`. A minimum released version for complete task monitoring has not been established for this plugin. Missing APIs are reported as unsupported. +- Voice input additionally requires `deviceAccess: ["microphone"]`, `speech.transcribe`, and microphone permission; otherwise it remains unavailable. +- The preceding UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, draft confirmation, and desktop round trips. This open-source revision has Node/DOM tests and an isolated HTTP smoke test. +- The new original atlas and icon are checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The earlier native UI acceptance predates this artwork. +- No Windows GUI, released-client installation, or real microphone recording/transcription has been verified for this revision. Automated tests use synthetic data and do not prove those paths. + +## Data & access + +- **Conversation data:** requests only `session.read`. Reads visible user-conversation metadata, status and a selected conversation's latest reply through the public Host API. It does not read the Host database, logs, configuration, credentials or private files. Conversation state and reminders stay in page memory. +- **Observations:** one global observer aggregates visible task lifecycle events. Up to three detailed observers follow active tasks and detect new ask tool events. A completed/removed task releases its detailed slot; another task enters only after the previous subscription settles. The initial snapshot contains up to 50 recent visible conversations; observed tasks remain while the Host confirms visibility. +- **Coverage limits:** observers are tail-only. They cannot reconstruct questionnaires opened before subscribing or during a disconnect. More than three active tasks means only three receive detailed ask coverage. No observed ask does not prove no pending question. Historical idle tasks are not presented as newly completed. +- **Local files:** the Node service serves packaged assets and stores preferences and user-imported JSON/WebP/PNG spritesheets under opaque `context.dataDir`. Imports, replacement recovery and deletion are limited to that plugin data. Browser storage is used only to migrate old view preferences. +- **Drafts:** `message.append(text)` goes through Host confirmation and targets the original chat. Monitoring or selecting another task does not change that target or send a message. +- **Microphone and network:** recording is user-initiated in the draft tool. Audio stays in memory and is passed to Host `speech.transcribe`; leaving the tool cancels capture/transcription. The Host transcription service may use its configured network services. The plugin has no hard-coded external runtime endpoint; its own HTTP requests use its assigned loopback origin. +- **Processes and Agent turns:** the Host starts one Node process. The plugin spawns no additional runtime process, launches no shell command, offers no MCP server, and never calls `agent.run`. + +## Agent Skill + +The `minimax-pet` Skill opens the app and guides the current Agent through creating custom pet artwork. It includes exact v1/v2 dimensions, animation and look layouts, alpha and metadata requirements, and a self-contained Node PNG validation tool. Files stay in the user workspace until import is confirmed; this does not add automatic Agent runs to the MiniApp. + +## Bundled artwork + +The default is an original MiniMax Pet character, with all nine animation states and sixteen look directions. The atlas and icon can be regenerated with Node.js from the source directory. See [ARTWORK.md](ARTWORK.md). + +## Development + +The complete editable source is at `sources/minimax-ai/minimax-pet/`. The installable package is self-contained at `plugins/minimax-ai/minimax-pet/`. See [PROVENANCE.md](PROVENANCE.md) for build and byte-comparison steps; developer notes and tests stay outside the distributed runtime payload. + +## License + +Original code, documentation, the bundled pet and icon: [MIT](LICENSE), Copyright 2026 MiniMax. The default artwork is generated from editable geometric drawing code; see [ARTWORK.md](ARTWORK.md). No third-party pet artwork is bundled. User-imported artwork keeps its own license. diff --git a/sources/minimax-ai/minimax-pet/README.zh-CN.md b/sources/minimax-ai/minimax-pet/README.zh-CN.md new file mode 100644 index 0000000..4a650da --- /dev/null +++ b/sources/minimax-ai/minimax-pet/README.zh-CN.md @@ -0,0 +1,37 @@ +# MiniMax Pet + +[English](README.md) | 简体中文 + +作者:**MiniMax**。围绕主窗口可见会话提供轻量陪伴、任务状态与通知,界面提供“外观/任务/设置”三个页签。 + +## 使用 + +将仓库 `plugins/minimax-ai/minimax-pet/` 完整复制到 `/plugins/minimax-pet/`,包括隐藏的 `.minimax-plugin`;默认 `` 为 `~/.minimax`,自定义数据目录时采用实际配置。不要复制外层作者目录。重启 MiniMax Code、确认插件启用后,从 MiniApp 列表打开“MiniMax Pet”,或对 Agent 说“打开 MiniMax Pet”。点击“放到桌面”显示透明桌宠窗口。 + +外观页支持选择、互动、导入预览和删除已导入宠物;任务页显示提醒、全部可见任务和最近回复;设置页提供大小、提醒管理和重连。桌面通知最多预览五行,点击后可滚动读完整内容,桌宠保持可见。观察到 ask 时显示“需要你回答”。原属聊天草稿通过明确入口打开,经 Host 确认后追加到原聊天输入框,仍由用户发送。 + +旧的本地原型使用不同插件 ID。`minimax-pet` 会有独立的 Host 数据目录,安装本包不会自动搬迁旧插件的宠物和偏好;需要的素材先导出,保留旧安装和数据。 + +## 环境与兼容 + +本包声明 `session.read`,按仓库契约至少要求 MiniMax Code **3.1.4**;上游读取接口的已观察环境为 3.1.4-preview,这不代表本插件已完成正式客户端 GUI 验收,也不保证观察和语音接口可用。完整任务监控还要求 Host 提供 `agent.observe/cancel`,以及 `agent.sessions/session`,其最低正式发行版本尚未确认。缺少接口时显示不支持;语音还需要 `speech.transcribe`、麦克风声明和权限。 + +此前 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除、草稿确认和桌面切换;当前开源修订运行 Node/DOM 测试与隔离 HTTP smoke。新原创图集和图标通过 PNG 解码、动画帧覆盖与确定性重建检查;此前原生 UI 验收早于本次素材替换。Windows GUI、正式客户端安装、真实录音/ASR 未在当前修订中验收。 + +## 数据与能力边界 + +只申请 `session.read`,读取 Host 对本插件可见的用户会话状态和所选会话最新回复,不直接读取 Host 数据库、日志、配置或凭据。状态和提醒只在页面内存中,关闭或静音不修改 Host 的任务或未读事实。 + +全局观察聚合可见任务,初始快照至多 50 条;本连接已观察到、Host 仍确认可见的任务会继续保留。另三个详细观察名额监听活跃会话的 ask,任务结束或移除后取消,等待旧订阅真正结束再补位。详细观察是 tail-only,不能补回订阅前或断线期间的问卷;超过三个活跃任务时,只覆盖其中三个的详细事件。没有观察到 ask 不等于没有待答问卷。 + +偏好和用户导入素材写入独立的 `context.dataDir`;导入替换有中断恢复,删除限于本插件数据。不创建独立聊天、不逐会话点评、不自动调用模型、不发送或停止用户会话、不代答问卷,也不能跳转任意普通会话。 + +原属草稿调用 `message.append(text)`,选择其他任务不会改变草稿目标。语音只在用户进入草稿后明确开始;离开会取消录音或转写,音频仅在内存中交给 Host 的转写服务,该服务可能使用其配置的网络。插件本身没有硬编码的外部服务地址,只使用分配的 loopback 源,不启动额外子进程或 shell。 + +## Agent Skill + +随包的 `minimax-pet` Skill 既能打开插件,也能指导当前 Agent 制作、校验和导入自定义宠物。格式文档列出 v1/v2 的精确尺寸、逐帧布局、透明背景、视线方向和 `pet.json`,并附带无外部依赖的 PNG 校验脚本。生成文件先放在用户工作区,再通过界面确认导入;MiniApp 不自行启动模型。 + +## 开发与许可 + +保留源码/分发双目录,构建与完整字节对比步骤见 [PROVENANCE.md](PROVENANCE.md)。原创代码、文档、默认宠物和图标均按 [MIT](LICENSE) 开源,作者为 MiniMax。默认图集和图标由可编辑的几何绘图代码生成,见[原创素材说明](ARTWORK.md);当前包不含第三方宠物素材,用户自行导入的素材保留其各自许可。 diff --git a/sources/minimax-ai/minimax-pet/dev/activity.test.mjs b/sources/minimax-ai/minimax-pet/dev/activity.test.mjs new file mode 100644 index 0000000..e8af2f8 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/activity.test.mjs @@ -0,0 +1,530 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createActivitySource, normalizeSession } from '../miniapp/client/activity.mjs'; +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const row = (id, status = 'idle', updatedAtMs = 1) => ({ sessionId: id, title: `任务 ${id}`, status, updatedAtMs }); +function deferred() { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; } +function harness({ list, detail, origin = row('a') } = {}) { + const changes = [], streams = [], cancelled = [], calls = []; + let serial = 0; + const agent = { + sessions: () => list ? list() : Promise.resolve({ sessions: [row('a'), row('b')] }), + session: (id) => { calls.push(id); return detail ? detail(id) : Promise.resolve(id ? { ...row(id), lastAssistantText: '最后回复', workspaceDir: '/not-exposed' } : origin); }, + observe(input, options) { const end = deferred(); streams.push({ input, options, end }); return end.promise; }, + cancel(id) { cancelled.push(id); streams.find((s) => s.input.requestId === id)?.end.resolve(); }, + }; + const source = createActivitySource({ agent, onChange: (value) => changes.push(value), pollIntervalMs: 0, now: () => 100, makeRequestId: () => `observer-${++serial}` }); + return { source, agent, changes, streams, cancelled, calls, latest: () => changes.at(-1), emit: (event, stream = streams.at(-1)) => stream.options.onEvent(event) }; +} + +test('public sessions/session snapshot defaults to origin and only retains narrow display fields', async () => { + const h = harness({ list: async () => ({ sessions: [{ ...row('a', 'running'), workspaceDir: '/private', transcript: 'secret' }, row('b')] }) }); + await h.source.start(); await settle(); + assert.deepEqual(h.streams[0].input, { all: true, requestId: 'observer-1' }); + assert.equal(h.latest().selectedSessionId, 'a'); + assert.equal(h.latest().originSession.sessionId, 'a'); + assert.equal(h.latest().sessions[0].workspaceDir, undefined); + assert.equal(h.latest().detail.lastAssistantText, '最后回复'); + assert.equal(h.latest().detail.workspaceDir, undefined); + h.source.dispose(); assert.deepEqual(h.cancelled, ['observer-1']); +}); + +test('live start/finished/error/aborted use real event outcomes, not history or inferred waits/unread', async () => { + const h = harness(); await h.source.start(); + assert.equal(h.latest().sessions.find((s) => s.sessionId === 'a').liveCompleted, false); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 't1' }); + assert.equal(h.latest().sessions[0].status, 'running'); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 't1', status: 'finished' }); + assert.equal(h.latest().sessions[0].liveCompleted, true); + await h.source.refresh(); + assert.equal(h.latest().sessions.find((s) => s.sessionId === 'a').liveCompleted, true); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 't2' }); + assert.equal(h.latest().sessions[0].liveCompleted, false); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 't2', status: 'error' }); + assert.equal(h.latest().sessions[0].status, 'error'); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 't3', status: 'aborted' }); + assert.equal(h.latest().sessions[0].status, 'aborted'); + h.emit({ type: 'permission.requested', sessionId: 'a' }); + assert.equal(h.latest().sessions[0].status, 'aborted'); + h.source.dispose(); +}); + +test('events received during initial list fetch win over older rows and new events are not dropped', async () => { + const list = deferred(); const h = harness({ list: () => list.promise }); + const starting = h.source.start(); + h.emit({ type: 'turn.started', sessionId: 'a', title: '新标题', turnId: 'one' }); + h.emit({ type: 'turn.finished', sessionId: 'new-task', title: '新任务', status: 'finished' }); + list.resolve({ sessions: [row('a')] }); await starting; + assert.equal(h.latest().sessions.find((s) => s.sessionId === 'a').status, 'running'); + assert.equal(h.latest().sessions.find((s) => s.sessionId === 'new-task').liveCompleted, true); + h.source.dispose(); +}); + +test('resolved stream clears data, fences delayed callbacks and snapshot, then reconnects without resurrecting completion', async () => { + const list = deferred(); let first = true; + const h = harness({ list: () => first ? list.promise : Promise.resolve({ sessions: [row('a')] }) }); + const starting = h.source.start(); const old = h.streams[0]; + h.emit({ type: 'turn.finished', sessionId: 'a', status: 'finished' }); + old.end.resolve(); await settle(); + assert.equal(h.latest().availability, 'unavailable'); + assert.deepEqual(h.latest().sessions, []); + h.emit({ type: 'turn.started', sessionId: 'late-private' }, old); + list.resolve({ sessions: [row('late-private')] }); await starting; + assert.deepEqual(h.latest().sessions, []); + first = false; await h.source.refresh(); + assert.equal(h.streams.length, 2); + assert.equal(h.latest().availability, 'ready'); + assert.equal(h.latest().sessions[0].liveCompleted, false); + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'late-private'), false); + h.source.dispose(); +}); + +test('observer rejection and read revocation clear data and permit bounded manual recovery', async () => { + let permitted = true; + const h = harness({ list: async () => { if (!permitted) throw { message: 'not_permitted' }; return { sessions: [row('a')] }; } }); + await h.source.start(); await settle(); + h.streams[0].end.reject({ message: 'not_permitted' }); await settle(); + assert.equal(h.latest().availability, 'not_permitted'); + assert.deepEqual(h.latest().sessions, []); + assert.equal(h.latest().detail, null); + permitted = false; await h.source.refresh(); + assert.equal(h.latest().availability, 'not_permitted'); + permitted = true; await h.source.refresh(); + assert.equal(h.latest().availability, 'ready'); + h.source.dispose(); +}); + +test('reconnect waits for cancelled global settlement before taking a slot beside three detailed observers', async () => { + let readable = true; + const h = harness({ list: async () => { + if (!readable) throw { code: 'runtime_unavailable' }; + return { sessions: [row('a', 'running')] }; + } }); + const occupied = new Set(['detail-a', 'detail-b', 'detail-c']); + const originalObserve = h.agent.observe; + let peak = occupied.size; + h.agent.observe = (input, options) => { + assert.equal(h.latest().availability, 'connecting'); + assert.ok(occupied.size < 4, 'the old global must release its Host slot first'); + occupied.add(input.requestId); peak = Math.max(peak, occupied.size); + return originalObserve(input, options).finally(() => occupied.delete(input.requestId)); + }; + // An acknowledgement does not settle the cancelled observe request. + h.agent.cancel = (id) => { h.cancelled.push(id); return Promise.resolve(); }; + await h.source.start(); + const old = h.streams[0]; + readable = false; await h.source.refresh(); + assert.equal(h.latest().availability, 'unavailable'); + readable = true; + const reconnect = h.source.refresh(); + assert.equal(h.source.refresh(), reconnect); + await settle(); + assert.equal(h.streams.length, 1); + assert.equal(h.latest().availability, 'connecting'); + assert.equal(occupied.size, 4); + h.emit({ type: 'turn.started', sessionId: 'stale-private' }, old); + assert.deepEqual(h.latest().sessions, []); + old.end.reject({ code: 'cancelled' }); + await reconnect; + assert.equal(h.streams.length, 2); + assert.equal(h.latest().availability, 'ready'); + assert.equal(h.latest().sessions.some((session) => session.sessionId === 'stale-private'), false); + assert.equal(peak, 4); + assert.deepEqual(h.cancelled, ['observer-1']); + h.source.dispose(); h.streams[1].end.resolve(); await settle(); +}); + +test('dispose ends a pending reconnect without assuming that a failed cancellation released its slot', async () => { + for (const cancelFailure of ['throw', 'reject']) { + let readable = true; + const h = harness({ list: async () => { + if (!readable) throw { code: 'runtime_unavailable' }; + return { sessions: [row('a')] }; + } }); + h.agent.cancel = (id) => { + h.cancelled.push(id); + if (cancelFailure === 'throw') throw new Error('cancel_failed'); + return Promise.reject(new Error('cancel_failed')); + }; + await h.source.start(); + readable = false; await h.source.refresh(); readable = true; + const reconnect = h.source.refresh(); + await settle(); + assert.equal(h.streams.length, 1); + h.source.dispose(); h.source.dispose(); + await reconnect; + const count = h.changes.length; + h.streams[0].end.resolve(); await settle(); + assert.equal(h.streams.length, 1); + assert.equal(h.changes.length, count); + assert.deepEqual(h.cancelled, ['observer-1', 'observer-1', 'observer-1']); + } +}); + +test('refresh retries a failed cancellation and still waits for actual observer settlement', async () => { + for (const cancelFailure of ['throw', 'reject']) { + let readable = true; + const h = harness({ list: async () => { + if (!readable) throw { code: 'runtime_unavailable' }; + return { sessions: [row('a')] }; + } }); + h.agent.cancel = (id) => { + h.cancelled.push(id); + if (h.cancelled.length > 1) return Promise.resolve(); + if (cancelFailure === 'throw') throw new Error('cancel_failed'); + return Promise.reject(new Error('cancel_failed')); + }; + await h.source.start(); + readable = false; await h.source.refresh(); readable = true; + const reconnect = h.source.refresh(); await settle(); + assert.equal(h.streams.length, 1); + assert.equal(h.source.refresh(), reconnect); + await settle(); + assert.deepEqual(h.cancelled, ['observer-1', 'observer-1']); + assert.equal(h.streams.length, 1); + h.streams[0].end.resolve(); await reconnect; + assert.equal(h.latest().availability, 'ready'); + assert.equal(h.streams.length, 2); + h.source.dispose(); h.streams[1].end.resolve(); await settle(); + } +}); + +test('a synchronous global admission failure never publishes ready and can recover on the next refresh', async () => { + const h = harness(); + const originalObserve = h.agent.observe; + h.agent.observe = () => { throw { code: 'BUSY' }; }; + await h.source.start(); + assert.equal(h.latest().availability, 'unavailable'); + assert.equal(h.latest().error, 'BUSY'); + assert.equal(h.changes.some((snapshot) => snapshot.availability === 'ready'), false); + h.agent.observe = originalObserve; + await h.source.refresh(); + assert.equal(h.latest().availability, 'ready'); + assert.equal(h.streams.length, 1); + h.source.dispose(); +}); + +test('selected detail responses are fenced on selection change and origin remains the original chat', async () => { + const a = deferred(), b = deferred(); + const h = harness({ detail: (id) => id === undefined ? Promise.resolve(row('a')) : id === 'a' ? a.promise : b.promise }); + await h.source.start(); + const select = h.source.selectSession('b'); + b.resolve({ ...row('b'), lastAssistantText: '', error: { message: 'error' }, workspaceDir: '/hidden' }); + await select; + a.resolve({ ...row('a'), lastAssistantText: 'stale' }); await settle(); + assert.equal(h.latest().detail.sessionId, 'b'); + assert.equal(h.latest().detail.lastAssistantText, ''); + assert.equal(h.latest().originSession.sessionId, 'a'); + assert.equal(h.latest().detail.workspaceDir, undefined); + h.source.dispose(); +}); + +test('background refresh keeps the matching reply while loading and replaces it only with the next valid detail', async () => { + const pending = deferred(), previous = '原来的长回复\n'.repeat(100); + let refreshDetail = false; + const h = harness({ detail: (id) => id === undefined ? Promise.resolve(row('a')) + : refreshDetail ? pending.promise : Promise.resolve({ ...row(id), lastAssistantText: previous }) }); + await h.source.start(); await settle(); + assert.equal(h.latest().detail.lastAssistantText, previous); + const before = h.changes.length; + refreshDetail = true; await h.source.refresh(); + assert.equal(h.latest().selectedSessionId, 'a'); assert.equal(h.latest().detailLoading, true); + assert.equal(h.latest().detail.lastAssistantText, previous); + assert.equal(h.changes.slice(before).every((snapshot) => snapshot.detail?.lastAssistantText === previous), true); + pending.resolve({ ...row('a'), lastAssistantText: '新回复' }); await settle(); + assert.equal(h.latest().detailLoading, false); assert.equal(h.latest().detail.lastAssistantText, '新回复'); + h.source.dispose(); +}); + +test('switching the selected task immediately clears the previous reply until the new task is read', async () => { + const next = deferred(); + const h = harness({ detail: (id) => id === 'b' ? next.promise : Promise.resolve(id === undefined ? row('a') : { ...row(id), lastAssistantText: '任务 a 的完整回复' }) }); + await h.source.start(); await settle(); + assert.equal(h.latest().detail.sessionId, 'a'); + const before = h.changes.length, selected = h.source.selectSession('b'); + assert.equal(h.latest().selectedSessionId, 'b'); assert.equal(h.latest().detailLoading, true); + assert.equal(h.latest().detail, null); + assert.equal(h.changes.slice(before).every((snapshot) => snapshot.selectedSessionId === 'b' && snapshot.detail === null), true); + next.resolve({ ...row('b'), lastAssistantText: '任务 b 的回复' }); await selected; + assert.equal(h.latest().detail.sessionId, 'b'); assert.equal(h.latest().detail.lastAssistantText, '任务 b 的回复'); + h.source.dispose(); +}); + +test('failed or invalid background detail refresh clears the retained reply rather than showing stale content', async () => { + for (const failure of ['read_failed', 'invalid_request']) { + let refreshDetail = false; + const pending = deferred(); + const h = harness({ detail: (id) => id === undefined ? Promise.resolve(row('a')) + : refreshDetail ? pending.promise : Promise.resolve({ ...row(id), lastAssistantText: '原回复' }) }); + await h.source.start(); await settle(); refreshDetail = true; await h.source.refresh(); + assert.equal(h.latest().detail.lastAssistantText, '原回复'); assert.equal(h.latest().detailLoading, true); + if (failure === 'read_failed') pending.reject({ code: failure }); + else pending.resolve({ ...row('wrong-task'), lastAssistantText: '其他任务回复' }); + await settle(); + assert.equal(h.latest().detail, null); assert.equal(h.latest().detailLoading, false); + assert.equal(h.latest().detailError, failure); assert.equal(h.latest().selectedSessionId, 'a'); + h.source.dispose(); + } +}); + +test('visibility loss clears the removed task reply in the same snapshot that chooses the fallback task', async () => { + let listed = [row('a'), row('b')], waitForFallback = false; + const fallback = deferred(); + const h = harness({ list: async () => ({ sessions: listed }), detail: (id) => { + if (id === undefined) return Promise.resolve(row('a')); + if (id === 'a' && waitForFallback) return fallback.promise; + return Promise.resolve({ ...row(id), lastAssistantText: `任务 ${id} 的回复` }); + } }); + await h.source.start(); await settle(); await h.source.selectSession('b'); + assert.equal(h.latest().detail.sessionId, 'b'); + const before = h.changes.length; listed = [row('a')]; waitForFallback = true; + await h.source.refresh(); + assert.equal(h.latest().selectedSessionId, 'a'); assert.equal(h.latest().detail, null); + assert.equal(h.changes.slice(before).some((snapshot) => snapshot.selectedSessionId === 'a' && snapshot.detail?.sessionId === 'b'), false); + fallback.resolve({ ...row('a'), lastAssistantText: '恢复后的任务 a' }); await settle(); + assert.equal(h.latest().detail.sessionId, 'a'); h.source.dispose(); +}); + +test('revocation or deletion during a background detail refresh removes retained reply and ignores late results', async () => { + for (const code of ['not_permitted', 'runtime_unavailable', 'session_not_found']) { + let refreshDetail = false; + const pending = deferred(); + const h = harness({ detail: (id) => id === undefined ? Promise.resolve(row('a')) + : refreshDetail ? pending.promise : Promise.resolve({ ...row(id), lastAssistantText: '不可继续保留的旧回复' }) }); + await h.source.start(); await settle(); refreshDetail = true; await h.source.refresh(); + assert.equal(h.latest().detailLoading, true); assert.equal(h.latest().detail.sessionId, 'a'); + pending.reject({ code }); await settle(); + assert.equal(h.latest().detail, null); assert.equal(h.latest().selectedSessionId, null); + assert.equal(h.latest().detailLoading, false); + if (code !== 'session_not_found') assert.equal(h.latest().sessions.length, 0); + else assert.equal(h.latest().sessions.some((session) => session.sessionId === 'a'), false); + h.source.dispose(); + } +}); + +test('missing/invalid public API is explicit unavailable; session DTO rejects invented statuses', async () => { + for (const status of ['needs_input', 'ready', 'constructor', 'toString']) assert.throws(() => normalizeSession(row('a', status)), { code: 'invalid_request' }); + assert.throws(() => normalizeSession(row('a', 'idle', NaN)), { code: 'invalid_request' }); + const values = []; + const source = createActivitySource({ agent: {}, pollIntervalMs: 0, onChange: (s) => values.push(s) }); + await source.start(); + assert.equal(values.at(-1).availability, 'unavailable'); + assert.deepEqual(values.at(-1).sessions, []); + source.dispose(); +}); + +test('dispose cancels requestId and fences every late read/event/stream settlement', async () => { + const list = deferred(); const h = harness({ list: () => list.promise }); + const starting = h.source.start(); const stream = h.streams[0]; + h.source.dispose(); h.source.dispose(); const count = h.changes.length; + h.emit({ type: 'turn.started', sessionId: 'late' }, stream); + list.resolve({ sessions: [row('late')] }); await starting; await settle(); + assert.equal(h.changes.length, count); + assert.deepEqual(h.cancelled, ['observer-1']); +}); + +test('aggregate covers simultaneous tasks with mutually exclusive counts and selection only changes detail', async () => { + const h = harness({ list: async () => ({ sessions: [row('a'), row('b', 'running', 20), row('c', 'running', 30), row('d', 'aborted', 40)] }) }); + await h.source.start(); await settle(); + assert.deepEqual(h.latest().aggregate.counts, { total: 4, idle: 1, running: 2, error: 0, aborted: 1, completed: 0 }); + assert.equal(h.latest().aggregate.status, 'running'); + assert.equal(h.latest().aggregate.primarySession.sessionId, 'c'); + const key = h.latest().aggregate.animationKey; + await h.source.selectSession('d'); + assert.equal(h.latest().selectedSessionId, 'd'); + assert.equal(h.latest().detail.sessionId, 'd'); + assert.equal(h.latest().aggregate.primarySession.sessionId, 'c'); + assert.equal(h.latest().aggregate.animationKey, key); + h.source.dispose(); +}); + +test('aggregate priority is error, local completion, running, aborted, then idle; completion can be cleared', async () => { + let listed = [row('a'), row('b', 'running'), row('c', 'aborted')]; + const h = harness({ list: async () => ({ sessions: listed }) }); + await h.source.start(); + h.emit({ type: 'turn.finished', sessionId: 'c', turnId: 'c1', status: 'finished' }); + assert.equal(h.latest().aggregate.status, 'completed'); + assert.deepEqual(h.latest().aggregate.counts, { total: 3, idle: 1, running: 1, error: 0, aborted: 0, completed: 1 }); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 'a1', status: 'error' }); + assert.equal(h.latest().aggregate.status, 'error'); + assert.equal(h.latest().aggregate.primarySession.sessionId, 'a'); + h.source.clearCompleted(); + assert.equal(h.latest().aggregate.counts.completed, 0); + assert.equal(h.latest().sessions.find((s) => s.sessionId === 'c').status, 'idle'); + assert.equal(h.latest().aggregate.status, 'error'); + listed = [row('a'), row('b', 'running'), row('c')]; + await h.source.refresh(); + assert.equal(h.latest().aggregate.status, 'running'); + listed = [row('a'), row('b', 'aborted'), row('c')]; await h.source.refresh(); + assert.equal(h.latest().aggregate.status, 'aborted'); + listed = [row('a'), row('b'), row('c')]; await h.source.refresh(); + assert.equal(h.latest().aggregate.status, 'idle'); + h.source.dispose(); +}); + +test('same-state polls and duplicate/retired turn events do not replay animations, but a new turn does', async () => { + const h = harness({ list: async () => ({ sessions: [row('a', 'running')] }) }); + await h.source.start(); + const initial = h.latest().aggregate.animationKey; + await h.source.refresh(); + assert.equal(h.latest().aggregate.animationKey, initial); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 'one' }); + const one = h.latest().aggregate.animationKey; + assert.notEqual(one, initial); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 'one' }); + assert.equal(h.latest().aggregate.animationKey, one); + await h.source.refresh(); + assert.equal(h.latest().aggregate.animationKey, one); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 'two' }); + const two = h.latest().aggregate.animationKey; + assert.notEqual(two, one); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 'one', status: 'error' }); + assert.equal(h.latest().aggregate.animationKey, two); + assert.equal(h.latest().aggregate.status, 'running'); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 'two', status: 'finished' }); + const done = h.latest().aggregate.animationKey; + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 'two', status: 'finished' }); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 'two' }); + assert.equal(h.latest().aggregate.animationKey, done); + assert.equal(h.latest().aggregate.status, 'completed'); + h.source.dispose(); +}); + +test('same-class primary uses most recent activity and stable id ordering for historical ties', async () => { + const h = harness({ list: async () => ({ sessions: [row('z', 'error', 1), row('a', 'error', 1)] }), origin: null }); + await h.source.start(); + assert.equal(h.latest().aggregate.primarySession.sessionId, 'a'); + h.emit({ type: 'turn.finished', sessionId: 'a', turnId: 'one', status: 'error' }); + h.emit({ type: 'turn.finished', sessionId: 'z', turnId: 'two', status: 'error' }); + assert.equal(h.latest().aggregate.primarySession.sessionId, 'z'); + await h.source.refresh(); + assert.equal(h.latest().aggregate.primarySession.sessionId, 'z'); + h.source.dispose(); +}); + +test('observed roots outside the latest list survive refresh while deleted/hidden roots are removed', async () => { + let visible = true; + const h = harness({ detail: async (id) => { + if (id === undefined) return row('a'); + if (id === 'outside' && !visible) throw { code: 'session_not_found' }; + return { ...row(id, id === 'outside' ? 'running' : 'idle'), lastAssistantText: 'private reply', workspaceDir: '/private' }; + } }); + await h.source.start(); + h.emit({ type: 'turn.started', sessionId: 'outside', turnId: 'one', text: 'never retain' }); + await h.source.refresh(); + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'outside'), true); + assert.equal(h.latest().aggregate.counts.running, 1); + const outside = h.latest().sessions.find((s) => s.sessionId === 'outside'); + assert.equal(outside.lastAssistantText, undefined); + assert.equal(outside.workspaceDir, undefined); + assert.equal(outside.text, undefined); + visible = false; await h.source.refresh(); + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'outside'), false); + assert.equal(h.latest().aggregate.counts.total, 2); + h.source.dispose(); +}); + +test('snapshot-only roots fall out of the rolling list and cleared off-list completion is released', async () => { + let listed = [row('a'), row('old', 'aborted')]; + const h = harness({ list: async () => ({ sessions: listed }), detail: async (id) => row(id ?? 'a') }); + await h.source.start(); + listed = [row('a')]; + h.emit({ type: 'turn.finished', sessionId: 'outside', turnId: 'one', status: 'finished' }); + await h.source.refresh(); + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'old'), false); + assert.equal(h.latest().aggregate.counts.completed, 1); + h.source.clearCompleted(); + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'outside'), false); + const reads = h.calls.filter((id) => id === 'outside').length; + await h.source.refresh(); + assert.equal(h.calls.filter((id) => id === 'outside').length, reads); + h.source.dispose(); +}); + +test('off-list validations have at most four concurrent reads and late events win over their old answers', async () => { + const pending = [], waiting = new Map(); + let active = 0, peak = 0; + const h = harness({ detail: (id) => { + if (!id?.startsWith('out')) return Promise.resolve(row(id ?? 'a')); + active++; peak = Math.max(peak, active); + const result = deferred(); waiting.set(id, result); pending.push(id); + return result.promise.finally(() => active--); + } }); + await h.source.start(); + for (let i = 0; i < 7; i++) h.emit({ type: 'turn.started', sessionId: `out${i}`, turnId: 'one' }); + const refreshing = h.source.refresh(); await settle(); + assert.equal(pending.length, 4); + h.emit({ type: 'turn.finished', sessionId: 'out0', turnId: 'one', status: 'finished' }); + for (let i = 0; i < 4; i++) waiting.get(`out${i}`).resolve(row(`out${i}`, 'running')); + await settle(); + assert.equal(pending.length, 7); + for (let i = 4; i < 7; i++) waiting.get(`out${i}`).resolve(row(`out${i}`, 'running')); + await refreshing; + assert.equal(peak, 4); + assert.equal(h.latest().sessions.find((s) => s.sessionId === 'out0').liveCompleted, true); + assert.equal(h.latest().aggregate.counts.running, 6); + h.source.dispose(); +}); + +test('off-list read revocation clears every aggregate and delayed worker results cannot repopulate it', async () => { + const late = deferred(); + const h = harness({ detail: (id) => { + if (id === 'out-a') return Promise.reject({ code: 'not_permitted' }); + if (id === 'out-b') return late.promise; + return Promise.resolve(row(id ?? 'a')); + } }); + await h.source.start(); + for (const id of ['out-a', 'out-b']) h.emit({ type: 'turn.started', sessionId: id, turnId: 'one' }); + await h.source.refresh(); + assert.equal(h.latest().availability, 'not_permitted'); + assert.equal(h.latest().aggregate.status, null); + assert.deepEqual(h.latest().aggregate.counts, { total: 0, idle: 0, running: 0, error: 0, aborted: 0, completed: 0 }); + late.resolve(row('out-b', 'running')); await settle(); + assert.deepEqual(h.latest().sessions, []); + h.source.dispose(); +}); + +test('a selected root proven missing during an older snapshot is not resurrected by that snapshot', async () => { + let delayedList = null, missing = false; + const h = harness({ list: () => delayedList?.promise ?? Promise.resolve({ sessions: [row('a'), row('b')] }), detail: async (id) => { + if (id === 'b' && missing) throw { code: 'session_not_found' }; + return row(id ?? 'a'); + } }); + await h.source.start(); await settle(); + delayedList = deferred(); + const refreshing = h.source.refresh(); + missing = true; await h.source.selectSession('b'); + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'b'), false); + delayedList.resolve({ sessions: [row('a'), row('b')] }); + await refreshing; + assert.equal(h.latest().sessions.some((s) => s.sessionId === 'b'), false); + assert.equal(h.latest().selectedSessionId, 'a'); + h.source.dispose(); +}); + +test('missing original chat does not prevent global activity and an empty ready scope is distinct from disconnect', async () => { + const h = harness({ list: async () => ({ sessions: [] }), detail: async () => { throw { code: 'session_not_found' }; } }); + await h.source.start(); + assert.equal(h.latest().availability, 'ready'); + assert.equal(h.latest().aggregate.status, 'idle'); + assert.equal(h.latest().aggregate.primarySession, null); + assert.equal(h.latest().aggregate.counts.total, 0); + h.emit({ type: 'turn.started', sessionId: 'visible-root', turnId: 'one' }); + assert.equal(h.latest().aggregate.status, 'running'); + assert.equal(h.latest().selectedSessionId, null); + h.streams[0].end.resolve(); await settle(); + assert.equal(h.latest().aggregate.status, null); + assert.equal(h.latest().aggregate.primarySession, null); + h.source.dispose(); +}); + +test('ask followers receive only an observed turn identity and snapshots never invent it', async () => { + const h = harness({ list: async () => ({ sessions: [row('a', 'running')] }) }); + await h.source.start(); + assert.equal(h.latest().sessions[0].observedTurnId, null); + h.emit({ type: 'turn.started', sessionId: 'a', turnId: 'actual-turn' }); + assert.equal(h.latest().sessions[0].observedTurnId, 'actual-turn'); + await h.source.refresh(); + assert.equal(h.latest().sessions[0].observedTurnId, 'actual-turn'); + h.source.dispose(); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/ask-observer.test.mjs b/sources/minimax-ai/minimax-pet/dev/ask-observer.test.mjs new file mode 100644 index 0000000..c9291fe --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/ask-observer.test.mjs @@ -0,0 +1,291 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAskObserver } from '../miniapp/client/ask-observer.mjs'; + +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const row = (sessionId, observedTurnId = 't1', updatedAtMs = 1, status = 'running') => ({ sessionId, observedTurnId, updatedAtMs, status }); +function deferred() { + let resolve, reject, settled = false; + const promise = new Promise((a, b) => { resolve = a; reject = b; }); + return { + promise, + resolve(value) { settled = true; resolve(value); }, + reject(error) { settled = true; reject(error); }, + get settled() { return settled; }, + }; +} +function harness(t, options = {}) { + const streams = [], changes = [], cancelled = []; + const agent = { + observe(input, callbacks) { + const end = deferred(); streams.push({ input, callbacks, end }); return end.promise; + }, + cancel(id) { + cancelled.push(id); + if (!options.holdCancellation) streams.find((stream) => stream.input.requestId === id)?.end.resolve(); + }, + }; + const observer = createAskObserver({ ...options, agent, onChange(state) { changes.push(state); options.onChange?.(state, observer); } }); + const update = (sessions = [row('a')], patch = {}) => observer.update({ availability: 'ready', sessions, ...patch }); + const streamFor = (id) => streams.findLast((stream) => stream.input.sessionId === id); + const event = (type, { id = 'a', turnId = 't1', name = 'ask_user', stream = streamFor(id), ...extra } = {}) => { + stream.callbacks.onEvent({ type, sessionId: id, turnId, name, ...extra }); + }; + t.after(() => observer.dispose()); + return { observer, streams, changes, cancelled, update, event, streamFor, state: () => observer.getState() }; +} + +test('only ready running sessions are watched, and observation alone never invents an ask', async (t) => { + const h = harness(t); + h.update([row('a'), row('b', 't1', 2, 'idle')], { availability: 'connecting' }); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: [], runningCount: 0 }); + h.update([row('a'), row('b', 't1', 2, 'idle')]); + assert.equal(h.streams.length, 1); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: ['a'], runningCount: 1 }); + assert.deepEqual(Object.keys(h.streams[0].input).sort(), ['requestId', 'sessionId']); + assert.equal(h.changes.length, 0, 'onChange is never synchronous with update'); + await settle(); assert.equal(h.changes.length, 1); +}); + +test('selected running session wins a slot; recent order fills only the bounded remainder', (t) => { + const h = harness(t); + h.update([row('a', 't1', 1), row('b', 't1', 2), row('c', 't1', 3), row('d', 't1', 4)], { selectedSessionId: 'a' }); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: ['a', 'd', 'c'], runningCount: 4 }); +}); + +test('live ask becomes visible after the delay and stays visible through running snapshots', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); + h.update(); h.event('tool.started'); + t.mock.timers.tick(119); assert.deepEqual(h.state().needsAnswerIds, []); + t.mock.timers.tick(1); assert.deepEqual(h.state().needsAnswerIds, ['a']); + h.update(); assert.deepEqual(h.state().needsAnswerIds, ['a']); + await settle(); assert.deepEqual(h.changes.at(-1).needsAnswerIds, ['a']); + h.event('tool.finished', { ok: true }); + assert.deepEqual(h.state().needsAnswerIds, [], 'clear immediately, without waiting for a timer'); +}); + +test('same-tick finished and failed asks never flash a pending label', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); + h.event('tool.started'); h.event('tool.finished', { ok: true }); + h.event('tool.started'); h.event('tool.finished', { ok: false }); + t.mock.timers.tick(1000); await settle(); + assert.deepEqual(h.state().needsAnswerIds, []); + assert.ok(h.changes.every((change) => change.needsAnswerIds.length === 0)); +}); + +test('other tools do not clear an ask, and concurrent same-name calls require both finishes', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); + h.event('tool.started'); h.event('tool.started'); + h.event('tool.started', { name: 'bash' }); h.event('tool.finished', { name: 'bash', ok: true }); + t.mock.timers.tick(120); + h.event('tool.finished', { ok: true }); assert.deepEqual(h.state().needsAnswerIds, ['a']); + h.event('tool.finished', { ok: false }); assert.deepEqual(h.state().needsAnswerIds, []); + h.event('tool.finished', { ok: true }); assert.deepEqual(h.state().needsAnswerIds, []); +}); + +test('turn terminal clears waiting and rejects late tools in that turn', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); h.event('tool.started'); t.mock.timers.tick(120); + h.event('turn.finished', { status: 'finished' }); + h.event('tool.started'); h.event('turn.started'); t.mock.timers.tick(120); + assert.deepEqual(h.state().needsAnswerIds, []); +}); + +test('new snapshot turn clears waiting and fences delayed old tools and terminals', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); h.event('tool.started'); t.mock.timers.tick(120); + h.update([row('a', 't2')]); assert.deepEqual(h.state().needsAnswerIds, []); + h.event('tool.started'); t.mock.timers.tick(120); assert.deepEqual(h.state().needsAnswerIds, []); + h.event('tool.started', { turnId: 't2' }); t.mock.timers.tick(120); + h.event('turn.finished', { status: 'finished' }); h.event('tool.finished', { ok: true }); + assert.deepEqual(h.state().needsAnswerIds, ['a']); +}); + +test('live new turn is not rolled back by a lagging snapshot of its retired predecessor', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); + h.event('turn.started', { turnId: 't2' }); h.event('tool.started', { turnId: 't2' }); + t.mock.timers.tick(120); h.update([row('a', 't1')]); + assert.deepEqual(h.state().needsAnswerIds, ['a']); + h.event('turn.started', { turnId: 't1' }); h.event('turn.finished', { status: 'finished' }); + assert.deepEqual(h.state().needsAnswerIds, ['a']); +}); + +test('unknown snapshot turn can learn its first live tool turn; known turn rejects mismatches', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update([row('a', null)]); + h.event('tool.started', { turnId: 'known' }); t.mock.timers.tick(120); + assert.deepEqual(h.state().needsAnswerIds, ['a']); + h.update([row('a', 'known')]); + h.event('tool.finished', { turnId: 'other', ok: true }); + h.event('tool.finished', { id: 'wrong', stream: h.streamFor('a'), turnId: 'known', ok: true }); + assert.deepEqual(h.state().needsAnswerIds, ['a']); +}); + +test('snapshot non-running or removal clears and cancels, without retaining facts privately', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); h.event('tool.started'); t.mock.timers.tick(120); + const old = h.streamFor('a'); h.update([row('a', 't1', 2, 'idle')]); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: [], runningCount: 0 }); + h.event('tool.started', { stream: old }); t.mock.timers.tick(120); + assert.equal(h.cancelled.length, 1); await settle(); + h.update(); assert.equal(h.streams.length, 2, 'a removed and re-entered row may watch again'); +}); + +test('disconnect clears waiting, fences late callbacks, and explicit reconnection permits a new tail', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); h.event('tool.started'); t.mock.timers.tick(120); + const old = h.streamFor('a'); h.update([row('a')], { availability: 'unavailable' }); + h.event('tool.started', { stream: old }); t.mock.timers.tick(120); + assert.deepEqual(h.state().needsAnswerIds, []); await settle(); + h.update(); assert.equal(h.streams.length, 2); + assert.deepEqual(h.state().needsAnswerIds, [], 'reconnecting never infers an old unanswered question'); +}); + +test('failed observers do not retry on repeated same-turn snapshots; new turn retries once', async (t) => { + const h = harness(t); h.update(); h.streamFor('a').end.reject(new Error('runtime_unavailable')); + await settle(); + for (let i = 0; i < 5; i++) h.update(); + assert.equal(h.streams.length, 1); assert.deepEqual(h.state().watchingIds, []); + h.update([row('a', 't2')]); assert.equal(h.streams.length, 2); + h.streamFor('a').end.resolve(); await settle(); h.update([row('a', 't2')]); + assert.equal(h.streams.length, 2, 'unexpected EOF also blocks same-turn retry'); + h.update([]); h.update([row('a', 't2')]); assert.equal(h.streams.length, 3); +}); + +test('same-turn failure remains blocked when the lifecycle snapshot catches up to live turn', async (t) => { + const h = harness(t); h.update(); h.event('turn.started', { turnId: 't2' }); + h.streamFor('a').end.reject(new Error('gone')); await settle(); + h.update([row('a', 't2')]); assert.equal(h.streams.length, 1); + h.update([row('a', 't3')]); assert.equal(h.streams.length, 2); +}); + +test('pending asks keep their slots while selected session preempts another stable watcher', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); const rows = [row('a', 't1', 4), row('b', 't1', 3), row('c', 't1', 2), row('d', 't1', 1)]; + h.update(rows); h.event('tool.started', { id: 'a' }); + h.update(rows, { selectedSessionId: 'd' }); await settle(); + assert.deepEqual(h.state().watchingIds, ['a', 'b', 'd']); + assert.equal(h.cancelled[0], h.streams.find((stream) => stream.input.sessionId === 'c').input.requestId); + t.mock.timers.tick(120); assert.deepEqual(h.state().needsAnswerIds, ['a']); +}); + +test('replacement admission waits for cancelled stream to settle and never exceeds its quota', async (t) => { + const h = harness(t, { maxSubscriptions: 1, holdCancellation: true }); + const rows = [row('a', 't1', 3), row('b', 't1', 2)]; + h.update(rows); const old = h.streamFor('a'); + h.update(rows, { selectedSessionId: 'b' }); + assert.deepEqual(h.state().watchingIds, []); assert.equal(h.streams.length, 1); + h.update(rows, { selectedSessionId: 'b' }); assert.equal(h.streams.length, 1); + old.end.resolve(); await settle(); + assert.deepEqual(h.state().watchingIds, ['b']); assert.equal(h.streams.length, 2); +}); + +test('inactive and removed sessions release slots for waiting active sessions after cancellation settles', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t, { holdCancellation: true }); + h.update([row('a', 't1', 5), row('b', 't1', 4), row('c', 't1', 3), row('d', 't1', 2), row('e', 't1', 1)]); + const oldA = h.streamFor('a'), oldB = h.streamFor('b'); + h.event('tool.started', { id: 'a' }); t.mock.timers.tick(120); + const active = [row('a', 't1', 6, 'idle'), row('c', 't1', 3), row('d', 't1', 2), row('e', 't1', 1)]; + h.update(active); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: ['c'], runningCount: 3 }); + assert.deepEqual(new Set(h.cancelled), new Set([oldA.input.requestId, oldB.input.requestId])); + h.update(active); assert.equal(h.streams.length, 3, 'cancel acknowledgement alone cannot admit a replacement'); + h.event('tool.started', { id: 'a', stream: oldA }); t.mock.timers.tick(120); + assert.deepEqual(h.state().needsAnswerIds, [], 'late callbacks from an inactive session remain fenced'); + oldA.end.resolve(); await settle(); + assert.deepEqual(h.state().watchingIds, ['c', 'd']); + assert.equal(h.streams.filter((stream) => !stream.end.settled).length, 3); + oldB.end.resolve(); await settle(); + assert.deepEqual(h.state().watchingIds, ['c', 'd', 'e']); + assert.equal(h.streams.filter((stream) => !stream.end.settled).length, 3); +}); + +test('three pending asks retain their slots when a fourth active session becomes selected', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); + const rows = [row('a', 't1', 4), row('b', 't1', 3), row('c', 't1', 2), row('d', 't1', 1)]; + h.update(rows); + for (const id of ['a', 'b', 'c']) h.event('tool.started', { id }); + h.update(rows, { selectedSessionId: 'd' }); t.mock.timers.tick(120); + assert.deepEqual(h.state().watchingIds, ['a', 'b', 'c']); + assert.deepEqual(h.state().needsAnswerIds, ['a', 'b', 'c']); + assert.deepEqual(h.cancelled, []); +}); + +test('immediate reconnection keeps retiring streams in the three-slot budget until each ends', async (t) => { + const h = harness(t, { holdCancellation: true }); + const rows = [row('a', 't1', 4), row('b', 't1', 3), row('c', 't1', 2), row('d', 't1', 1)]; + h.update(rows); + const previous = [...h.streams]; + h.update(rows, { availability: 'unavailable' }); + h.update(rows, { selectedSessionId: 'd' }); + assert.deepEqual(h.state().watchingIds, []); assert.equal(h.streams.length, 3); + for (let i = 0; i < previous.length; i++) { + previous[i].end.resolve(); await settle(); + assert.equal(h.streams.filter((stream) => !stream.end.settled).length, 3, + 'with the separate global observer, at most four Host slots are occupied'); + assert.equal(h.state().watchingIds.length, i + 1); + } + assert.deepEqual(h.state().watchingIds, ['d', 'a', 'b']); + assert.equal(new Set(h.streams.slice(3).map((stream) => stream.input.requestId)).size, 3); +}); + +test('stable watchers are retained when only recent timestamps change', (t) => { + const h = harness(t); h.update([row('a', 't1', 4), row('b', 't1', 3), row('c', 't1', 2), row('d', 't1', 1)]); + h.update([row('a', 't1', 1), row('b', 't1', 2), row('c', 't1', 3), row('d', 't1', 99)]); + assert.deepEqual(h.state().watchingIds, ['a', 'b', 'c']); assert.equal(h.streams.length, 3); +}); + +test('onChange may synchronously feed update without recursion or duplicate subscriptions', async (t) => { + const h = harness(t, { onChange(_state, observer) { observer.update({ availability: 'ready', sessions: [row('a')] }); } }); + h.update(); await settle(); await settle(); + assert.equal(h.changes.length, 1); assert.equal(h.streams.length, 1); +}); + +test('dispose cancels timers and streams and emits no delayed or terminal callbacks', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = harness(t); h.update(); h.event('tool.started'); await settle(); + const count = h.changes.length, old = h.streamFor('a'); h.observer.dispose(); + h.event('tool.started', { stream: old }); t.mock.timers.tick(1000); await settle(); + h.update(); assert.equal(h.changes.length, count); assert.equal(h.cancelled.length, 1); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: [], runningCount: 0 }); +}); + +test('unsupported API and zero quota do not create observations', (t) => { + const h = harness(t, { maxSubscriptions: 0 }); h.update(); + assert.deepEqual(h.state(), { needsAnswerIds: [], watchingIds: [], runningCount: 1 }); + const unsupported = createAskObserver({ agent: {}, onChange() {} }); + unsupported.update({ availability: 'ready', sessions: [row('a')] }); + assert.deepEqual(unsupported.getState(), { needsAnswerIds: [], watchingIds: [], runningCount: 0 }); + unsupported.dispose(); +}); + +test('configured quota watches three active sessions and leaves one Host slot for global lifecycle', (t) => { + const h = harness(t, { maxSubscriptions: 100 }); + h.update([row('a'), row('b'), row('c'), row('d')]); + assert.equal(h.streams.length, 3); + assert.equal(h.state().runningCount, 4); +}); + +test('a synchronous observe failure is fenced and only explicit reconnection permits same-turn retry', async (t) => { + let calls = 0; + const changes = []; + const observer = createAskObserver({ + agent: { observe() { calls++; throw new Error('runtime_unavailable'); }, cancel() {} }, + onChange(state) { changes.push(state); }, + }); + t.after(() => observer.dispose()); + const snapshot = { availability: 'ready', sessions: [row('a')] }; + observer.update(snapshot); await settle(); + observer.update(snapshot); await settle(); + assert.equal(calls, 1); assert.deepEqual(observer.getState().watchingIds, []); + observer.update({ availability: 'connecting', sessions: [] }); + observer.update(snapshot); await settle(); + assert.equal(calls, 2); + assert.ok(changes.every((state) => !state.needsAnswerIds.length)); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs b/sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs new file mode 100644 index 0000000..09df9b6 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bindDraftEntry, createDraftController } from '../miniapp/client/chat-draft.mjs'; +const settle = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; } +class Control extends EventTarget { value = ''; } +function harness(append) { + const calls = [], results = [], starts = []; + const controller = createDraftController({ message: { append(...args) { calls.push(args); return append?.(...args); } } }); + controller.setOrigin({ sessionId: 'original', title: '原属对话 A' }); + const entries = {}, disposers = []; + for (const entry of ['panel', 'desktop']) { + const form = new Control(), input = new Control(); entries[entry] = { form, input }; + disposers.push(bindDraftEntry({ controller, entry, form, input, onPending: (s) => starts.push(s), onResult: (r) => results.push(r) })); + } + return { controller, calls, results, starts, entries, + edit(entry, text) { entries[entry].input.value = text; entries[entry].input.dispatchEvent(new Event('input')); }, + submit(entry) { entries[entry].form.dispatchEvent(new Event('submit', { cancelable: true })); }, + dispose() { for (const dispose of disposers) dispose(); controller.dispose(); }, + }; +} +for (const entry of ['panel', 'desktop']) { + test(`${entry}: append is synchronous, takes only text and freezes original-chat label`, async () => { + const h = harness(); h.edit(entry, 'draft'); h.submit(entry); + assert.deepEqual(h.calls, [['draft']]); assert.equal(h.starts[0].title, '原属对话 A'); + h.controller.setOrigin({ sessionId: 'original', title: '更新标题' }); + await settle(); assert.equal(h.results[0].submission.title, '原属对话 A'); assert.equal(h.entries[entry].input.value, ''); h.dispose(); + }); + test(`${entry}: pending edits including edit-away-and-back and ASR insertion survive older success`, async () => { + const request = deferred(); const h = harness(() => request.promise); + h.edit(entry, 'one'); h.submit(entry); h.edit(entry, 'two'); h.edit(entry, 'one'); + h.entries[entry].input.value = h.controller.appendText(entry, '\nASR final'); + request.resolve(); await settle(); assert.equal(h.results[0].clear, false); assert.equal(h.entries[entry].input.value, 'one\nASR final'); h.dispose(); + }); + test(`${entry}: cancellation, no origin, denied and unknown result preserve input with no retry`, async () => { + for (const code of ['user_cancelled', 'target_unavailable', 'not_allowed', 'result_unknown']) { + const h = harness(() => Promise.reject({ message: code })); h.edit(entry, 'keep'); h.submit(entry); await settle(); + assert.equal(h.results[0].code, code); assert.equal(h.entries[entry].input.value, 'keep'); assert.equal(h.calls.length, 1); h.dispose(); + } + }); +} +test('only one page-wide pending request; dispose prevents late result touching either entry', async () => { + const request = deferred(); const h = harness(() => request.promise); + h.edit('panel', 'keep'); h.submit('panel'); h.edit('desktop', 'other'); h.submit('desktop'); await settle(); + assert.equal(h.calls.length, 1); assert.equal(h.results[0].code, 'busy'); h.dispose(); request.resolve(); await settle(); + assert.equal(h.results.length, 1); assert.equal(h.entries.panel.input.value, 'keep'); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/compact-pet-view.test.mjs b/sources/minimax-ai/minimax-pet/dev/compact-pet-view.test.mjs new file mode 100644 index 0000000..d9b0bdd --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/compact-pet-view.test.mjs @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { createCompactPetView } from '../miniapp/client/compact-pet-view.mjs'; +import { createReminderStore } from '../miniapp/client/activity-reminders.mjs'; + +let JSDOM; +try { ({ JSDOM } = createRequire(import.meta.url)('jsdom')); } catch { /* Use the existing workspace DOM runtime. */ } +const domTest = JSDOM ? test : test.skip; +const task = (n, displayStatus = 'running') => ({ sessionId: `task-${n}`, title: `任务 ${n}`, displayStatus }); +const selection = (n, text = `任务 ${n} 的回复`) => ({ availability: 'ready', selectedSessionId: `task-${n}`, session: task(n), + detailLoading: false, detail: { sessionId: `task-${n}`, lastAssistantText: text } }); +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +function harness(t, callbacks = {}) { + const dom = new JSDOM('
'); + const container = dom.window.document.getElementById('compact'); + const calls = []; + const view = createCompactPetView({ container, + onClose: () => { calls.push(['close']); }, + onSelectTask: (...args) => { calls.push(['select', ...args]); }, + ...callbacks, + }); + t.after(() => { view.dispose(); dom.window.close(); }); + const button = (name, scope = container) => [...scope.querySelectorAll('button')].find((node) => node.textContent === name); + const update = (patch = {}) => view.update({ reminders: { items: [] }, ...patch }); + return { dom, container, calls, view, button, update }; +} + +domTest('default is task notifications with no independent chat, form, draft or generic Host entry', (t) => { + const h = harness(t, { onSubmit: () => assert.fail('No free request'), onDraftChange: () => assert.fail('No free draft') }); + assert.equal(h.view.opened, false); assert.equal(h.container.hidden, true); + h.update({ draft: '旧的自由问答草稿', agentSnapshot: { text: '旧的独立聊天回复' } }); h.view.open(); + assert.equal(h.view.opened, true); assert.equal(h.view.view, 'tasks'); + assert.equal(h.container.querySelector('textarea,input,form,[role=tab],h1,h2,fieldset'), null); + assert.deepEqual([...h.container.querySelectorAll('.compact-tasks .compact-footer button:not([hidden])')].map((node) => node.textContent), ['全部任务']); + assert.equal(h.container.textContent.includes('旧的独立聊天回复'), false); + for (const label of ['发送', '提问', '新对话', '主窗口', '点评这条会话', '查看点评详情', '停止点评']) assert.equal(h.button(label), undefined); + assert.equal(h.view.open('agent'), false); assert.equal(h.view.open('review'), false); assert.equal(h.view.view, 'tasks'); + assert.deepEqual(h.calls, []); +}); + +domTest('tasks show the latest three reminders and selection stays beside the pet', async (t) => { + const h = harness(t); + h.update({ reminders: { items: [task(4, 'completed'), task(3, 'error'), task(2), task(1)] } }); h.view.open(); + assert.equal(h.container.querySelectorAll('.compact-task').length, 3); + assert.deepEqual([...h.container.querySelectorAll('.compact-task-title')].map((node) => node.textContent), ['任务 4', '任务 3', '任务 2']); + assert.deepEqual([...h.container.querySelectorAll('.compact-task-status')].map((node) => node.textContent), ['已完成', '异常', '进行中']); + h.container.querySelectorAll('.compact-task-title')[1].click(); + await tick(); assert.deepEqual(h.calls, [['select', 'task-3']]); + assert.equal(h.view.opened, true); assert.equal(h.view.view, 'tasks'); +}); + +domTest('all tasks use the full supplied visible list and return from detail without opening Host', (t) => { + const h = harness(t), allSessions = [task(6), task(5), task(4), task(3), task(2), task(1)]; + h.update({ reminders: { items: [task(6)] }, allSessions }); h.view.open(); h.button('全部任务').click(); + assert.equal(h.view.view, 'allTasks'); assert.equal(h.container.querySelectorAll('.compact-task').length, 6); + assert.equal(h.container.querySelector('.compact-dismiss'), null); + h.container.querySelectorAll('.compact-task-title')[5].click(); h.view.open('detail'); + assert.deepEqual(h.calls, [['select', 'task-1']]); + h.button('返回任务', h.container.querySelector('.compact-detail')).click(); + assert.equal(h.view.view, 'allTasks'); assert.equal(h.view.opened, true); + assert.deepEqual(h.calls, [['select', 'task-1']]); +}); + +domTest('an observed completed turn has the same status in reminders, all tasks and its detail', (t) => { + const h = harness(t), session = { sessionId: 'task-1', title: '完成的任务', status: 'idle', liveCompleted: true, updatedAtMs: 1000 }; + const reminders = createReminderStore({ now: () => 1000 }).update([session]); + h.update({ reminders, allSessions: [session], taskSelection: { availability: 'ready', selectedSessionId: session.sessionId, session, + detail: { sessionId: session.sessionId, status: 'idle', lastAssistantText: '完整回复' } } }); + for (const pane of ['tasks', 'allTasks']) { + h.view.open(pane); + assert.equal(h.container.querySelector('.compact-task-status').textContent, '已完成'); + assert.equal(h.container.querySelector('.compact-task-status').dataset.status, 'completed'); + } + h.view.open('detail'); + assert.equal(h.container.querySelector('.compact-detail-status').textContent, '已完成'); + assert.equal(h.container.querySelector('.compact-detail-status').dataset.status, 'completed'); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '完整回复'); +}); + +domTest('raw task facts override stale completion hints and only observed idle completion is promoted', (t) => { + const h = harness(t); + const cases = [ + [{ status: 'idle', liveCompleted: false, displayStatus: 'completed' }, '空闲', 'idle'], + [{ status: 'idle' }, '空闲', 'idle'], + [{ status: 'running', liveCompleted: true, displayStatus: 'completed' }, '进行中', 'running'], + [{ status: 'error', liveCompleted: true, displayStatus: 'completed' }, '异常', 'error'], + [{ status: 'aborted', liveCompleted: true, displayStatus: 'completed' }, '已停止', 'aborted'], + [{ status: 'idle', liveCompleted: true, needsAnswer: true }, '需要你回答', 'needs_answer'], + [{ status: 'running', needsAnswer: true }, '需要你回答', 'needs_answer'], + [{ status: 'error', needsAnswer: true }, '需要你回答', 'needs_answer'], + ]; + for (const [facts, label, status] of cases) { + const session = { sessionId: 'task-1', title: '任务', ...facts }; + h.update({ allSessions: [session], taskSelection: { availability: 'ready', selectedSessionId: session.sessionId, session, + detail: { sessionId: session.sessionId, status: 'idle', lastAssistantText: '旧回复' } } }); + h.view.open('allTasks'); + assert.equal(h.container.querySelector('.compact-task-status').textContent, label); + assert.equal(h.container.querySelector('.compact-task-status').dataset.status, status); + h.view.open('detail'); + assert.equal(h.container.querySelector('.compact-detail-status').textContent, label); + assert.equal(h.container.querySelector('.compact-detail-status').dataset.status, status); + if (facts.needsAnswer) assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '需要你回答'); + } +}); + +domTest('duplicate and malformed task rows do not create duplicate controls', (t) => { + const h = harness(t); + h.update({ reminders: { items: [{ title: '无ID' }, task(1), task(1), task(2)] } }); h.view.open(); + assert.equal(h.container.querySelectorAll('.compact-task').length, 2); + assert.deepEqual([...h.container.querySelectorAll('.compact-task-title')].map((node) => node.textContent), ['任务 1', '任务 2']); +}); + +domTest('task preview preserves all inert text so CSS can show five lines without data truncation', (t) => { + const h = harness(t), previewText = '\n第二行\n第三行\n第四行\n第五行\n第六行'; + h.update({ reminders: { items: [{ ...task(1), previewText }] } }); h.view.open(); + const preview = h.container.querySelector('.compact-task-preview'); + assert.equal(preview.textContent, previewText); assert.equal(preview.hidden, false); assert.equal(h.container.querySelector('script'), null); + h.update({ reminders: { items: [{ ...task(1), previewText }] } }); assert.equal(h.container.querySelector('.compact-task-preview'), preview); + h.update({ reminders: { items: [task(1)] } }); assert.equal(preview.hidden, true); +}); + +domTest('live ask overrides task status and old preview without losing its session target', (t) => { + const h = harness(t); + h.update({ reminders: { items: [{ ...task(1), needsAnswer: true, previewText: '旧回复' }] } }); h.view.open(); + assert.equal(h.container.querySelector('.compact-task-status').textContent, '需要你回答'); + assert.equal(h.container.querySelector('.compact-task-preview').textContent, '需要你回答'); + h.container.querySelector('.compact-task-title').click(); assert.deepEqual(h.calls, [['select', 'task-1']]); + h.update({ reminders: { items: [{ ...task(1), needsAnswer: false, previewText: '回答后的新回复' }] } }); + assert.equal(h.container.querySelector('.compact-task-status').textContent, '进行中'); + assert.equal(h.container.querySelector('.compact-task-preview').textContent, '回答后的新回复'); +}); + +domTest('detail loads complete inert text and retains the pet without additional actions', async (t) => { + const h = harness(t), taskSelection = { ...selection(1), detail: null, detailLoading: true }; + h.update({ taskSelection }); h.view.open('detail'); + assert.equal(h.container.querySelector('.compact-detail-title').textContent, '任务 1'); + assert.equal(h.container.querySelector('.compact-detail-status').textContent, '进行中'); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '正在读取任务…'); + assert.equal(h.container.querySelector('.compact-detail').getAttribute('aria-busy'), 'true'); + assert.deepEqual(h.calls, []); + const text = '\n第一行\n第二行\n第三行\n第四行\n第五行\n最后一行'; + h.update({ taskSelection: selection(1, text) }); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, text); assert.equal(h.container.querySelector('img'), null); + assert.equal(h.container.querySelector('.compact-detail').getAttribute('aria-busy'), 'false'); + assert.deepEqual([...h.container.querySelector('.compact-detail .compact-footer').children].map((node) => node.textContent), ['返回任务']); + assert.deepEqual(h.calls, []); assert.equal(h.view.opened, true); +}); + +domTest('matching details preserve scroll but mismatched late details clear it and reset for the new task', (t) => { + const h = harness(t); h.update({ taskSelection: selection(1, '任务一旧回复') }); h.view.open('detail'); + const reply = h.container.querySelector('.compact-detail-reply'); reply.scrollTop = 50; + for (let n = 0; n < 20; n++) h.update({ taskSelection: selection(1, '任务一旧回复') }); + assert.equal(reply.scrollTop, 50); + h.update({ taskSelection: { ...selection(2), detail: { sessionId: 'task-1', lastAssistantText: '迟到的任务一回复' } } }); + assert.equal(reply.textContent, '正在读取任务…'); assert.equal(reply.scrollTop, 0); + assert.equal(h.container.querySelector('.compact-detail-title').textContent, '任务 2'); + h.update({ taskSelection: selection(2, '任务二最新回复') }); assert.equal(reply.textContent, '任务二最新回复'); + assert.deepEqual(h.calls, []); +}); + +domTest('same-session background loading retains complete reply DOM and reading position', (t) => { + const h = harness(t), text = Array.from({ length: 30 }, (_, n) => `第 ${n + 1} 行完整正文`).join('\n'); + const taskSelection = selection(1, text); h.update({ taskSelection }); h.view.open('detail'); + const reply = h.container.querySelector('.compact-detail-reply'), textNode = reply.firstChild; + reply.scrollTop = 90; + for (let n = 0; n < 5; n++) { + h.update({ taskSelection: { ...taskSelection, detailLoading: true } }); + assert.equal(reply.textContent, text); assert.equal(reply.firstChild, textNode); assert.equal(reply.scrollTop, 90); + assert.equal(h.container.querySelector('.compact-detail').getAttribute('aria-busy'), 'true'); + h.update({ taskSelection }); assert.equal(reply.firstChild, textNode); assert.equal(reply.scrollTop, 90); + } + h.update({ taskSelection: { ...selection(2), detail: null, detailLoading: true } }); + assert.equal(reply.textContent, '正在读取任务…'); assert.equal(reply.scrollTop, 0); +}); + +domTest('disconnect and removal clear prior task replies and stale ask indicators', (t) => { + const h = harness(t), taskSelection = { ...selection(1), session: { ...task(1), needsAnswer: true } }; + h.update({ taskSelection }); h.view.open('detail'); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '需要你回答'); + h.update({ taskSelection: { ...taskSelection, availability: 'disconnected' } }); + assert.equal(h.container.querySelector('.compact-detail-status').textContent, '暂不可用'); + assert.match(h.container.querySelector('.compact-detail-reply').textContent, /重新连接/); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent.includes('需要你回答'), false); + h.update({ taskSelection: { availability: 'ready', selectedSessionId: null, detailError: 'session_not_found' } }); + assert.match(h.container.querySelector('.compact-detail-reply').textContent, /session_not_found/); + assert.equal(h.view.opened, true); assert.deepEqual(h.calls, []); +}); + +domTest('a live detail ask beats old replies and its completion clears the exact prompt', (t) => { + const h = harness(t), taskSelection = { ...selection(1, '旧回复不得盖住提问'), detailLoading: true, session: { ...task(1), needsAnswer: true } }; + h.update({ taskSelection }); h.view.open('detail'); + assert.equal(h.container.querySelector('.compact-detail-status').textContent, '需要你回答'); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '需要你回答'); + assert.equal(h.container.querySelector('.compact-detail').getAttribute('aria-busy'), 'false'); + h.update({ taskSelection: { ...taskSelection, detailLoading: false, session: { ...task(1), needsAnswer: false } } }); + assert.equal(h.container.querySelector('.compact-detail-status').textContent, '进行中'); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '旧回复不得盖住提问'); +}); + +domTest('read failures and actual task errors replace old successful output', (t) => { + const h = harness(t), taskSelection = { ...selection(1), session: task(1, 'error'), + detail: { sessionId: 'task-1', status: 'error', error: '实际错误 ', lastAssistantText: '前一轮成功回复' } }; + h.update({ taskSelection }); h.view.open('detail'); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '实际错误 '); assert.equal(h.container.querySelector('img'), null); + h.update({ taskSelection: { ...taskSelection, detailError: 'request_failed' } }); + assert.equal(h.container.querySelector('.compact-detail-reply').textContent, '无法读取任务(request_failed)。'); +}); + +domTest('stable task updates retain focused controls and removal shows empty state', (t) => { + const h = harness(t), items = [task(2), task(1)]; h.update({ reminders: { items } }); h.view.open(); + const control = h.container.querySelectorAll('.compact-task-title')[1]; control.focus(); + h.update({ reminders: { items: structuredClone(items) } }); + assert.equal(h.dom.window.document.activeElement, control); assert.equal(h.container.querySelectorAll('.compact-task-title')[1], control); + h.update(); assert.equal(h.container.querySelectorAll('.compact-task').length, 0); assert.match(h.container.querySelector('.compact-empty').textContent, /暂无/); +}); + +domTest('legacy review state and callback arguments never create an entry or render results', (t) => { + const noCall = () => assert.fail('Removed review callbacks must not be used.'); + const h = harness(t, { onReviewTask: noCall, onCancelReview: noCall, onOpenReview: noCall }); + h.update({ taskSelection: selection(1), reviewSnapshot: { target: { sessionId: 'task-1', title: '任务 1' }, text: '旧点评结果', busy: true, canOpen: true, canCancel: true } }); + h.view.open(); + assert.equal(h.container.querySelector('[class*=review]'), null); + assert.equal(h.container.textContent.includes('点评'), false); assert.equal(h.container.textContent.includes('旧点评结果'), false); + assert.equal(h.view.open('review'), false); assert.equal(h.view.view, 'tasks'); + h.view.open('detail'); + assert.equal(h.container.textContent.includes('点评'), false); assert.equal(h.container.querySelector('input,textarea,form'), null); + assert.deepEqual(h.calls, []); +}); + +domTest('close callbacks are explicit and reopening defaults to tasks without hiding the pet owner', (t) => { + const h = harness(t); h.update({ taskSelection: selection(1) }); h.view.open('detail'); h.view.close(); + assert.equal(h.view.opened, false); assert.deepEqual(h.calls, []); + h.view.open(); assert.equal(h.view.view, 'tasks'); h.container.querySelector('.compact-close').click(); + assert.equal(h.view.opened, false); assert.deepEqual(h.calls, [['close']]); + h.view.open(); assert.equal(h.view.opened, true); assert.equal(h.container.parentNode, h.dom.window.document.body); +}); + +domTest('disposal ignores detached controls, late callbacks, updates and all future opens', async (t) => { + const request = deferred(); + const h = harness(t, { onSelectTask: () => request.promise }); + h.update({ reminders: { items: [task(1)] }, taskSelection: selection(1) }); h.view.open(); + const select = h.container.querySelector('.compact-task-title'); + select.click(); h.view.dispose(); request.reject(new Error('late')); await tick(); select.click(); + h.view.update({ taskSelection: selection(2) }); assert.equal(h.view.open(), false); + assert.equal(h.container.childElementCount, 0); assert.equal(h.view.opened, false); assert.deepEqual(h.calls, []); +}); + + +domTest('reminders and all tasks have no mark-as-seen action or callback', (t) => { + const h = harness(t, { onDismissTask: () => assert.fail('The removed action must never run.') }); + const items = [task(1, 'completed'), { ...task(2), needsAnswer: true }]; + h.update({ reminders: { items }, allSessions: [...items, task(3, 'idle')] }); + for (const view of ['tasks', 'allTasks']) { + h.view.open(view); + assert.equal(h.container.querySelector('.compact-dismiss'), null); + assert.equal([...h.container.querySelectorAll('button')].some((n) => /已看过/.test(n.textContent + n.getAttribute('aria-label'))), false); + assert.equal(h.container.querySelectorAll('.compact-task').length, view === 'tasks' ? 2 : 3); + } + assert.deepEqual(h.calls, []); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/import-selection.test.mjs b/sources/minimax-ai/minimax-pet/dev/import-selection.test.mjs new file mode 100644 index 0000000..c94644e --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/import-selection.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; + +// Exercise the page's production handlers. Only browser/file/network boundaries are substituted. +const source = await readFile(new URL('../miniapp/client/pet-app.js', import.meta.url), 'utf8'); +const selectionSource = source.slice(source.indexOf('function resetImport()'), source.indexOf('function startImportPreview(')); +const submitSource = source.slice(source.indexOf('async function confirmImport()'), source.indexOf('// Rendering (inline panel)')); +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const pet = (id) => ({ id, displayName: id, spritesheetPath: `${id}.webp` }); +const files = (id, text = async () => JSON.stringify(pet(id))) => [{ name: 'pet.json', text }, { name: `${id}.webp`, size: 1 }]; +function harness(overrides = {}) { + const element = () => ({ textContent: '', hidden: false, disabled: false, dataset: {}, removeAttribute() {}, replaceChildren() {} }); + const els = Object.fromEntries(['importSprite', 'importPreview', 'importErrors', 'importStateText', 'importConfirm', 'importMsg', 'importName', 'importMeta', 'importPick', 'importInput', 'importCancel', 'galleryMsg'].map((name) => [name, element()])); + const importState = { generation: 0, player: null, blobUrl: null, cycleTimer: null, busy: false, errors: [] }; + let blob = 0; + const context = vm.createContext({ + importState, els, disposed: false, panelNavigation: null, app: { pets: [] }, ID_RE: /^[a-z0-9][a-z0-9._-]{0,79}$/, IMPORT_BODY_LIMIT: 12 * 1024 * 1024, + clearInterval() {}, URL: { createObjectURL: () => `blob:${++blob}`, revokeObjectURL() {} }, + document: { createElement: () => ({ append() {} }) }, + setMsg: (el, text) => { el.textContent = text; }, basename: (value) => value.split(/[\\/]/).pop(), + loadPetFromParts: async (value) => ({ ...value, version: 1 }), startImportPreview() {}, + fileToBase64: async () => 'aQ==', postPet: async () => ({ res: { ok: true, status: 200 }, data: { pet: pet('new') } }), + refreshPets: async () => {}, selectPet: async () => {}, errCode: (error) => error?.message, console: { warn() {}, error() {} }, + ...overrides, + }); + vm.runInContext(`${selectionSource}\n${submitSource}\nglobalThis.api = { handleImportFiles, resetImport, confirmImport };`, context); + return { state: importState, els, context, ...context.api }; +} + +test('a late older JSON read cannot replace the latest selected pet', async () => { + const h = harness(), slow = deferred(); + const older = h.handleImportFiles(files('old', () => slow.promise)); + await h.handleImportFiles(files('new')); + slow.resolve(JSON.stringify(pet('old'))); await older; + assert.equal(h.state.petJson.id, 'new'); + assert.equal(h.state.loaded.id, 'new'); + assert.equal(h.els.importName.textContent, 'new'); +}); + +test('cancelling pending JSON invalidates both successful and failed old reads', async () => { + for (const fail of [false, true]) { + const h = harness(), slow = deferred(); + const pending = h.handleImportFiles(files('old', () => slow.promise)); + h.resetImport(); h.els.importMsg.textContent = '已取消导入'; + if (fail) slow.reject(new Error('bad JSON')); else slow.resolve(JSON.stringify(pet('old'))); + await pending; + assert.equal(h.state.petJson, null); + assert.equal(h.els.importPreview.hidden, true); + assert.equal(h.els.importMsg.textContent, '已取消导入'); + } +}); + +test('an old image decode cannot paint over a new selection or report its failure', async () => { + const slow = deferred(), started = deferred(), h = harness({ loadPetFromParts: (value) => { + if (value.id === 'old') { started.resolve(); return slow.promise; } + return Promise.resolve({ ...value, version: 1 }); + } }); + const older = h.handleImportFiles(files('old')); await started.promise; + await h.handleImportFiles(files('new')); + slow.reject(new Error('old decode failed')); await older; + assert.equal(h.state.loaded.id, 'new'); + assert.equal(h.state.errors.length, 0); +}); + +test('submitting holds the selected files and disables replacement controls until completion', async () => { + const slow = deferred(); let submitted; + const h = harness({ postPet: (body) => { submitted = body; return slow.promise; } }); + await h.handleImportFiles(files('old')); + const submitting = h.confirmImport(); await Promise.resolve(); + assert.equal(h.state.busy, true); + for (const name of ['importConfirm', 'importCancel', 'importInput', 'importPick']) assert.equal(h.els[name].disabled, true); + await h.handleImportFiles(files('new')); + assert.equal(h.state.petJson.id, 'old'); + assert.equal(submitted.petJson.id, 'old'); + slow.resolve({ res: { ok: true, status: 200 }, data: { pet: pet('old') } }); + await submitting; + assert.equal(h.state.busy, false); + assert.equal(h.els.importPick.disabled, false); + assert.match(h.els.importMsg.textContent, /导入成功/); + assert.match(h.els.galleryMsg.textContent, /导入成功/); +}); + +test('a failed upload releases controls without discarding the selected preview', async () => { + const h = harness({ postPet: async () => { throw new Error('network failed'); } }); + await h.handleImportFiles(files('old')); await h.confirmImport(); + assert.equal(h.state.busy, false); + assert.equal(h.state.loaded.id, 'old'); + assert.equal(h.els.importConfirm.disabled, false); + assert.match(h.els.importMsg.textContent, /导入失败/); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/notification-previews.test.mjs b/sources/minimax-ai/minimax-pet/dev/notification-previews.test.mjs new file mode 100644 index 0000000..54901aa --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/notification-previews.test.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createNotificationPreviews } from '../miniapp/client/notification-previews.mjs'; + +const settle = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; } +const reminder = (sessionId, key = 'turn-one', displayStatus = 'completed') => ({ sessionId, title: `任务 ${sessionId}`, status: displayStatus === 'completed' ? 'idle' : displayStatus, displayStatus, reminderKey: `${sessionId}:${key}` }); + +test('reads terminal notifications among the current top three, without reading running or retaining other detail fields', async () => { + const calls = [], changes = []; + const previews = createNotificationPreviews({ agent: { session: async (id) => { calls.push(id); return { sessionId: id, lastAssistantText: `${id} reply`, workspaceDir: '/private', transcript: 'not retained' }; } }, onChange: (state) => changes.push(state) }); + previews.update({ availability: 'ready', items: [reminder('a'), reminder('b', 'one', 'running'), reminder('c', 'one', 'error'), reminder('d')] }); + assert.deepEqual(calls, []); + await settle(); + assert.deepEqual(calls, ['a', 'c']); + assert.deepEqual(previews.getState(), { previews: { a: 'a reply', c: 'c reply' } }); + assert.equal(changes.length, 1); + previews.dispose(); +}); + +test('unchanged five-second snapshots read only once, a new turn clears the old reply and reads again', async () => { + let calls = 0; + const second = deferred(); + const previews = createNotificationPreviews({ agent: { session: async (id) => ++calls === 1 ? { sessionId: id, lastAssistantText: 'first reply' } : second.promise } }); + const input = { availability: 'ready', items: [reminder('a')] }; + previews.update(input); await settle(); + for (let i = 0; i < 5; i++) previews.update({ ...input, items: input.items.map((item) => ({ ...item })) }); + await settle(); + assert.equal(calls, 1); + previews.update({ availability: 'ready', items: [reminder('a', 'turn-two')] }); + assert.deepEqual(previews.getState(), { previews: {} }); + await settle(); + assert.equal(calls, 2); + second.resolve({ sessionId: 'a', lastAssistantText: 'second reply' }); await settle(); + assert.deepEqual(previews.getState(), { previews: { a: 'second reply' } }); + previews.dispose(); +}); + +test('newer turn wins when old detail arrives late, and mismatched session responses are ignored', async () => { + const old = deferred(), fresh = deferred(); let calls = 0; + const previews = createNotificationPreviews({ agent: { session: () => ++calls === 1 ? old.promise : fresh.promise } }); + previews.update({ availability: 'ready', items: [reminder('a', 'old')] }); await settle(); + previews.update({ availability: 'ready', items: [reminder('a', 'new')] }); await settle(); + fresh.resolve({ sessionId: 'a', lastAssistantText: 'new' }); await settle(); + old.resolve({ sessionId: 'a', lastAssistantText: 'old' }); await settle(); + assert.deepEqual(previews.getState(), { previews: { a: 'new' } }); + previews.update({ availability: 'ready', items: [reminder('b')] }); await settle(); + assert.deepEqual(previews.getState(), { previews: {} }); + previews.dispose(); +}); + +test('expiry, mute, deletion, or cancellation removes the current item and fences its pending response', async () => { + for (const replacement of [[], [reminder('a', 'one', 'aborted')], [reminder('a', 'one', 'running')]]) { + const result = deferred(); + const previews = createNotificationPreviews({ agent: { session: () => result.promise } }); + previews.update({ availability: 'ready', items: [reminder('a')] }); await settle(); + previews.update({ availability: 'ready', items: replacement }); + result.resolve({ sessionId: 'a', lastAssistantText: 'late reply' }); await settle(); + assert.deepEqual(previews.getState(), { previews: {} }); + previews.dispose(); + } +}); + +test('disconnect clears snippets and reconnect may reread the same identity; an old connection cannot overwrite it', async () => { + const old = deferred(), fresh = deferred(); let calls = 0; + const previews = createNotificationPreviews({ agent: { session: () => ++calls === 1 ? old.promise : fresh.promise } }); + const items = [reminder('a')]; + previews.update({ availability: 'ready', items }); await settle(); + previews.update({ availability: 'not_permitted', items }); + previews.update({ availability: 'ready', items }); await settle(); + assert.equal(calls, 2); + old.resolve({ sessionId: 'a', lastAssistantText: 'previous connection' }); await settle(); + assert.deepEqual(previews.getState(), { previews: {} }); + fresh.resolve({ sessionId: 'a', lastAssistantText: 'current connection' }); await settle(); + assert.deepEqual(previews.getState(), { previews: { a: 'current connection' } }); + previews.update({ availability: 'unavailable', items }); + assert.deepEqual(previews.getState(), { previews: {} }); + previews.dispose(); +}); + +test('failed optional reads do not throw or retry on identical snapshots and error snippets prefer the current error', async () => { + let calls = 0; + const previews = createNotificationPreviews({ agent: { session: async (id) => { calls++; if (id === 'bad') throw { code: 'not_permitted' }; return { sessionId: id, error: { message: 'current failure' }, lastAssistantText: 'previous successful turn' }; } } }); + const input = { availability: 'ready', items: [reminder('bad'), { sessionId: 'failed', status: 'error', animationKey: 'failure-turn' }] }; + previews.update(input); await settle(); + assert.deepEqual(previews.getState(), { previews: { failed: 'current failure' } }); + previews.update(input); await settle(); + assert.equal(calls, 2); + previews.dispose(); +}); + +test('public state is copied, bounded to 4000 text characters and only retains the current top three', async () => { + const previews = createNotificationPreviews({ agent: { session: async (id) => ({ sessionId: id, lastAssistantText: 'x'.repeat(5000) }) } }); + previews.update({ availability: 'ready', items: [reminder('a'), reminder('b'), reminder('c')] }); await settle(); + const state = previews.getState(); + assert.equal(state.previews.a.length, 4000); + state.previews.a = 'outside mutation'; + assert.equal(previews.getState().previews.a.length, 4000); + previews.update({ availability: 'ready', items: [reminder('d'), reminder('e'), reminder('f'), reminder('a')] }); await settle(); + assert.deepEqual(Object.keys(previews.getState().previews), ['d', 'e', 'f']); + previews.dispose(); +}); + +test('onChange is asynchronous and cannot loop when rendering resubmits the same reminders', async () => { + const input = { availability: 'ready', items: [reminder('a')] }; + let updates = 0; + const previews = createNotificationPreviews({ agent: { session: async (id) => ({ sessionId: id, lastAssistantText: 'reply' }) }, onChange: () => { updates++; previews.update(input); } }); + previews.update(input); + assert.equal(updates, 0); + await settle(); + assert.equal(updates, 1); + previews.dispose(); +}); + +test('dispose fences pending reads and notifications, including work not dispatched yet', async () => { + let calls = 0, changes = 0; + const pending = deferred(); + const previews = createNotificationPreviews({ agent: { session: () => { calls++; return pending.promise; } }, onChange: () => changes++ }); + previews.update({ availability: 'ready', items: [reminder('a')] }); await settle(); + previews.dispose(); previews.dispose(); + pending.resolve({ sessionId: 'a', lastAssistantText: 'late' }); await settle(); + previews.update({ availability: 'ready', items: [reminder('b')] }); + assert.equal(calls, 1); + assert.equal(changes, 0); + assert.deepEqual(previews.getState(), { previews: {} }); + const immediate = createNotificationPreviews({ agent: { session: () => { calls++; return Promise.resolve({}); } } }); + immediate.update({ availability: 'ready', items: [reminder('a')] }); immediate.dispose(); await settle(); + assert.equal(calls, 1); +}); + +test('unsupported API, missing identity, invalid ids and blank replies yield no snippet', async () => { + const unsupported = createNotificationPreviews({ agent: {} }); + unsupported.update({ availability: 'ready', items: [reminder('a')] }); await settle(); + assert.deepEqual(unsupported.getState(), { previews: {} }); unsupported.dispose(); + const calls = []; + const previews = createNotificationPreviews({ agent: { session: async (id) => { calls.push(id); return { sessionId: id, lastAssistantText: ' ' }; } } }); + previews.update({ availability: 'ready', items: [{ sessionId: 'no-key', displayStatus: 'completed' }, reminder(''), reminder('blank')] }); await settle(); + assert.deepEqual(calls, ['blank']); + assert.deepEqual(previews.getState(), { previews: {} }); previews.dispose(); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/original-pet-art.test.mjs b/sources/minimax-ai/minimax-pet/dev/original-pet-art.test.mjs new file mode 100644 index 0000000..7dbaa97 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/original-pet-art.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { inflateSync } from 'node:zlib'; +import test from 'node:test'; + +import { createPetAtlas, createPetIcon } from '../scripts/generate-pet-art.mjs'; +import { ANIMATIONS, CELL } from '../miniapp/client/pet-player.js'; +import { atlasVersionFromSize, readImageHeader } from '../miniapp/node/image-header.mjs'; + +// Decode the actual generated image instead of trusting its dimensions or generator metadata. +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} +function paeth(a, b, c) { + const value = a + b - c; + const [da, db, dc] = [Math.abs(value - a), Math.abs(value - b), Math.abs(value - c)]; + return da <= db && da <= dc ? a : db <= dc ? b : c; +} +function decodeRgbaPng(bytes) { + assert.deepEqual(bytes.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const chunks = []; + let width, height, ended = false; + for (let offset = 8; offset < bytes.length;) { + const size = bytes.readUInt32BE(offset); + const type = bytes.toString('ascii', offset + 4, offset + 8); + const data = bytes.subarray(offset + 8, offset + 8 + size); + assert.equal(crc32(bytes.subarray(offset + 4, offset + 8 + size)), bytes.readUInt32BE(offset + 8 + size), `${type} checksum`); + if (type === 'IHDR') { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + assert.deepEqual([...data.subarray(8)], [8, 6, 0, 0, 0], '8-bit, non-interlaced RGBA image'); + } else if (type === 'IDAT') chunks.push(data); + else if (type === 'IEND') ended = true; + offset += size + 12; + if (ended) assert.equal(offset, bytes.length); + } + assert.ok(ended && width > 0 && height > 0); + const encoded = inflateSync(Buffer.concat(chunks)); + const stride = width * 4; + assert.equal(encoded.length, (stride + 1) * height); + const pixels = Buffer.alloc(stride * height); + for (let y = 0; y < height; y += 1) { + const start = y * (stride + 1); + const filter = encoded[start]; + assert.ok(filter <= 4, 'recognized PNG row filter'); + for (let x = 0; x < stride; x += 1) { + const index = y * stride + x; + const left = x >= 4 ? pixels[index - 4] : 0; + const above = y > 0 ? pixels[index - stride] : 0; + const upperLeft = y > 0 && x >= 4 ? pixels[index - stride - 4] : 0; + const predictor = [0, left, above, Math.floor((left + above) / 2), paeth(left, above, upperLeft)][filter]; + pixels[index] = (encoded[start + x + 1] + predictor) & 255; + } + } + return { width, height, pixels }; +} +function cellPixels(atlas, row, col) { + const pixels = Buffer.alloc(CELL.width * CELL.height * 4); + for (let y = 0; y < CELL.height; y += 1) { + const from = ((row * CELL.height + y) * atlas.width + col * CELL.width) * 4; + atlas.pixels.copy(pixels, y * CELL.width * 4, from, from + CELL.width * 4); + } + return pixels; +} +function assertVisibleTransparentFrame(pixels, message) { + let visible = 0, transparent = 0; + for (let index = 3; index < pixels.length; index += 4) { + if (pixels[index] > 24) visible += 1; + if (pixels[index] === 0) transparent += 1; + } + assert.ok(visible > 0, `${message} contains artwork`); + assert.ok(transparent > 0, `${message} preserves a transparent background`); +} +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const v1 = createPetAtlas({ version: 1 }); +const v2 = createPetAtlas({ version: 2 }); +const atlases = [decodeRgbaPng(v1), decodeRgbaPng(v2)]; + +test('original PNG v1 and v2 fully decode and contain all playable animation frames', () => { + for (const [index, atlas] of atlases.entries()) { + const version = index + 1; + assert.equal(atlasVersionFromSize(atlas.width, atlas.height), version); + assert.deepEqual(readImageHeader(index === 0 ? v1 : v2), { format: 'png', width: atlas.width, height: atlas.height }); + for (const [state, animation] of Object.entries(ANIMATIONS)) { + const frames = animation.durations.map((_, col) => cellPixels(atlas, animation.row, col)); + for (const [col, frame] of frames.entries()) assertVisibleTransparentFrame(frame, `v${version} ${state} frame ${col}`); + assert.ok(new Set(frames.map(digest)).size > 1, `v${version} ${state} has actual animation`); + } + } +}); + +test('original v2 atlas contains sixteen distinct visible look directions', () => { + const frames = Array.from({ length: 16 }, (_, direction) => cellPixels(atlases[1], 9 + Math.floor(direction / 8), direction % 8)); + for (const [direction, frame] of frames.entries()) assertVisibleTransparentFrame(frame, `look direction ${direction}`); + assert.equal(new Set(frames.map(digest)).size, 16); +}); + +test('original gaze frames follow the player pointer convention: up, then clockwise', () => { + const eyeHighlight = (direction) => { + const pixels = cellPixels(atlases[1], 9 + Math.floor(direction / 8), direction % 8); + let count = 0, x = 0, y = 0; + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index] === 255 && pixels[index + 1] === 255 && pixels[index + 2] === 255 && pixels[index + 3] === 255) { + count += 1; x += (index / 4) % CELL.width; y += Math.floor(index / 4 / CELL.width); + } + } + assert.ok(count > 0, 'the original robot has visible eye highlights'); + return { x: x / count, y: y / count }; + }; + const [up, right, down, left] = [0, 4, 8, 12].map(eyeHighlight); + assert.equal(up.x, down.x); + assert.equal(left.y, right.y); + assert.ok(up.y < right.y && down.y > right.y); + assert.ok(left.x < up.x && right.x > up.x); +}); + +test('bundled first-party PNG is reproducible and has matching v2 pet metadata', async () => { + assert.deepEqual(createPetAtlas({ version: 1 }), v1); + assert.deepEqual(createPetAtlas({ version: 2 }), v2); + const bundled = new URL('../miniapp/client/pets/minimax-original/', import.meta.url); + const pet = JSON.parse(await readFile(new URL('pet.json', bundled), 'utf8')); + assert.equal(pet.id, 'minimax-original'); + assert.equal(pet.displayName, 'MiniMax Pet'); + assert.equal(pet.spriteVersionNumber, 2); + assert.equal(pet.spritesheetPath, 'spritesheet.png'); + assert.deepEqual(decodeRgbaPng(await readFile(new URL(pet.spritesheetPath, bundled))), atlases[1]); + const iconBytes = await readFile(new URL('../icon.png', import.meta.url)); + const icon = decodeRgbaPng(iconBytes); + assert.deepEqual(icon, decodeRgbaPng(createPetIcon()), 'bundled icon pixels match a fresh original generation'); + assertVisibleTransparentFrame(icon.pixels, 'first-party plugin icon'); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs b/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs new file mode 100644 index 0000000..349f488 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRequire } from 'node:module'; +import { readFile } from 'node:fs/promises'; +import { createPanelNavigation } from '../miniapp/client/panel-navigation.mjs'; + +let JSDOM; +try { ({ JSDOM } = createRequire(import.meta.url)('jsdom')); } catch { /* Supplied through the existing workspace NODE_PATH. */ } +const domTest = JSDOM ? test : test.skip; +const fixture = `
+ + +
+
+ +
+ + +
`; +function harness(t, options = {}) { + const dom = new JSDOM(fixture); + const panel = dom.window.document.getElementById('panel'); + const nav = createPanelNavigation({ panel, ...options }); + t.after(() => { nav.dispose(); dom.window.close(); }); + const find = (selector) => panel.querySelector(selector); + const tab = (name) => find(`[data-panel-tab="${name}"]`); + const page = (name) => find(`[data-panel-page="${name}"]`); + const key = (name, value) => { + const event = new dom.window.KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }); + tab(name).dispatchEvent(event); return event; + }; + return { dom, panel, nav, find, tab, page, key }; +} +const defaultState = { tab: 'appearance', appearanceView: 'pets', settingsView: 'main', taskFilter: 'recent', taskDetail: false }; + +function assertTab(h, active) { + for (const name of ['appearance', 'tasks', 'settings']) { + assert.equal(h.page(name).hidden, name !== active, `${name} page visibility`); + assert.equal(h.tab(name).getAttribute('aria-selected'), String(name === active), `${name} selected`); + assert.equal(h.tab(name).tabIndex, name === active ? 0 : -1, `${name} roving focus`); + } +} + +domTest('the panel starts on appearance with one keyboard-reachable tab', (t) => { + const h = harness(t); + assert.deepEqual(h.nav.getState(), defaultState); + assertTab(h, 'appearance'); + assert.equal(h.find('#appearance-main').hidden, false); + assert.equal(h.find('#import-panel').hidden, true); + assert.equal(h.find('#settings-main').hidden, false); + assert.equal(h.find('#draft-card').hidden, true); + assert.equal(h.find('#recent-tasks').hidden, false); + assert.equal(h.find('#all-sessions').hidden, true); +}); + +domTest('tab buttons switch pages and programmatic navigation only focuses when requested', (t) => { + const h = harness(t); + h.tab('appearance').focus(); + h.nav.showTab('tasks'); + assertTab(h, 'tasks'); + assert.equal(h.dom.window.document.activeElement, h.tab('appearance')); + h.nav.showTab('settings', { focus: true }); + assertTab(h, 'settings'); + assert.equal(h.dom.window.document.activeElement, h.tab('settings')); + h.tab('appearance').click(); assertTab(h, 'appearance'); +}); + +domTest('arrow, Home and End keys activate and focus tabs with wraparound', (t) => { + const h = harness(t); + h.tab('appearance').focus(); + for (const [from, key, expected] of [ + ['appearance', 'ArrowLeft', 'settings'], ['settings', 'ArrowRight', 'appearance'], + ['appearance', 'ArrowRight', 'tasks'], ['tasks', 'End', 'settings'], ['settings', 'Home', 'appearance'], + ]) { + assert.equal(h.key(from, key).defaultPrevented, true); + assertTab(h, expected); + assert.equal(h.dom.window.document.activeElement, h.tab(expected)); + } + assert.equal(h.key('appearance', 'ArrowDown').defaultPrevented, false); + assertTab(h, 'appearance'); +}); + +domTest('unknown tab or task filter does not corrupt the current page', (t) => { + const h = harness(t); + h.nav.showTab('tasks'); h.nav.setTaskFilter('all'); + const state = h.nav.getState(); + h.nav.showTab('missing'); h.nav.setTaskFilter('missing'); + assert.deepEqual(h.nav.getState(), state); + assertTab(h, 'tasks'); +}); + +domTest('import is an appearance subpage and changing tabs preserves its selection controls', (t) => { + const h = harness(t); + h.nav.showTab('settings'); h.find('#open-import').click(); + assertTab(h, 'appearance'); assert.equal(h.nav.getState().appearanceView, 'import'); + assert.equal(h.find('#appearance-main').hidden, true); assert.equal(h.find('#import-panel').hidden, false); + const input = h.find('#import-input'); + h.nav.showTab('tasks'); h.nav.showTab('appearance'); + assert.equal(h.nav.getState().appearanceView, 'import'); + assert.equal(h.find('#import-input'), input); + h.find('#import-back').click(); + assert.equal(h.nav.getState().appearanceView, 'pets'); + assert.equal(h.find('#appearance-main').hidden, false); assert.equal(h.find('#import-panel').hidden, true); +}); + +domTest('a busy import can veto returning until the upload releases it', (t) => { + let allowed = false, calls = 0; + const h = harness(t, { beforeCloseImport: () => { calls++; return allowed; } }); + h.nav.showImport(); h.find('#import-back').click(); + assert.equal(calls, 1); assert.equal(h.nav.getState().appearanceView, 'import'); + allowed = true; h.nav.closeImport(); + assert.equal(calls, 2); assert.equal(h.nav.getState().appearanceView, 'pets'); +}); + +domTest('a completed import returns to pets without changing the current page or calling user cancellation', (t) => { + let cancellations = 0; + const h = harness(t, { beforeCloseImport: () => { cancellations++; return false; } }); + h.nav.showImport(); h.nav.showTab('settings'); h.tab('settings').focus(); + h.nav.completeImport(); + assert.equal(h.nav.getState().appearanceView, 'pets'); assertTab(h, 'settings'); + assert.equal(h.dom.window.document.activeElement, h.tab('settings')); assert.equal(cancellations, 0); + h.nav.showTab('appearance'); + assert.equal(h.find('#appearance-main').hidden, false); assert.equal(h.find('#import-panel').hidden, true); +}); + +domTest('draft entry focuses its text and leaving cancels recording once while retaining typed text', (t) => { + let leaves = 0; + const h = harness(t, { onLeaveDraft: () => { leaves++; } }); + h.find('#open-draft').click(); + assertTab(h, 'settings'); assert.equal(h.nav.getState().settingsView, 'draft'); + assert.equal(h.find('#settings-main').hidden, true); assert.equal(h.find('#draft-card').hidden, false); + assert.equal(h.dom.window.document.activeElement, h.find('#talk-text')); + h.find('#talk-text').value = '需要保留的原属聊天草稿'; + h.nav.showTab('tasks'); + assert.equal(leaves, 1); assert.equal(h.nav.getState().settingsView, 'main'); + h.nav.showTab('appearance'); assert.equal(leaves, 1); + h.nav.showTab('settings'); + assert.equal(h.find('#settings-main').hidden, false); assert.equal(h.find('#draft-card').hidden, true); + h.nav.showDraft(); + assert.equal(h.find('#talk-text').value, '需要保留的原属聊天草稿'); + h.find('#draft-back').click(); + assert.equal(leaves, 2); assertTab(h, 'settings'); assert.equal(h.nav.getState().settingsView, 'main'); +}); + +domTest('entering a detail from another page switches to tasks and preserves the chosen list', (t) => { + const h = harness(t); + h.nav.setTaskFilter('all'); h.nav.showTab('appearance'); h.nav.showTaskDetail(); + assertTab(h, 'tasks'); assert.equal(h.nav.getState().taskDetail, true); + assert.equal(h.nav.getState().taskFilter, 'all'); assert.equal(h.panel.dataset.taskDetail, 'open'); + h.nav.showTab('settings'); h.nav.showTab('tasks'); + assert.equal(h.nav.getState().taskDetail, true); assert.equal(h.nav.getState().taskFilter, 'all'); +}); + +domTest('returning from a task keeps its list nodes, filter and reading position', (t) => { + const h = harness(t); + h.find('#task-filter-all').click(); + const list = h.find('#session-list'), row = list.firstElementChild; + list.scrollTop = 120; h.find('#all-sessions').scrollTop = 80; + h.nav.showTaskDetail(); h.find('#task-detail-back').click(); + assertTab(h, 'tasks'); assert.equal(h.nav.getState().taskDetail, false); assert.equal(h.nav.getState().taskFilter, 'all'); + assert.notEqual(h.panel.dataset.taskDetail, 'open'); + assert.equal(h.find('#session-list'), list); assert.equal(list.firstElementChild, row); + assert.equal(list.scrollTop, 120); assert.equal(h.find('#all-sessions').scrollTop, 80); + assert.equal(h.find('#all-sessions').hidden, false); assert.equal(h.find('#recent-tasks').hidden, true); + h.find('#task-filter-recent').click(); + assert.equal(h.nav.getState().taskFilter, 'recent'); assert.equal(h.find('#recent-tasks').hidden, false); + assert.equal(h.find('#all-sessions').hidden, true); +}); + +domTest('background connection and reminder updates do not switch pages or take keyboard focus', (t) => { + const h = harness(t); + h.nav.showDraft(); h.find('#talk-text').value = '尚未提交'; + const before = h.nav.getState(); + h.nav.update({ reminderCount: 3, availability: 'ready' }); + assert.deepEqual(h.nav.getState(), before); + assert.equal(h.find('#panel-task-count').textContent, '3'); assert.equal(h.find('#panel-task-count').hidden, false); + assert.equal(h.find('#connection-status').dataset.status, 'ready'); + assert.equal(h.find('#connection-status').textContent, '已连接'); + assert.equal(h.dom.window.document.activeElement, h.find('#talk-text')); assert.equal(h.find('#talk-text').value, '尚未提交'); + h.nav.update({ reminderCount: 0, availability: 'unavailable' }); + assert.equal(h.find('#panel-task-count').hidden, true); + assert.equal(h.find('#connection-status').dataset.status, 'unavailable'); + assert.equal(h.find('#connection-status').textContent, '连接中断'); + assert.deepEqual(h.nav.getState(), before); assert.equal(h.dom.window.document.activeElement, h.find('#talk-text')); +}); + +domTest('connection labels describe each availability and large reminder counts stay compact', (t) => { + const h = harness(t); + for (const [availability, text] of [['ready', '已连接'], ['connecting', '连接中'], ['not_permitted', '未授权'], ['unavailable', '连接中断']]) { + h.nav.update({ reminderCount: 101, availability }); + assert.equal(h.find('#connection-status').textContent, text); + assert.equal(h.find('#connection-status').dataset.status, availability); + assert.equal(h.find('#panel-task-count').textContent, '99+'); + } + assertTab(h, 'appearance'); +}); + +domTest('state snapshots cannot mutate the navigation state', (t) => { + const h = harness(t); + const snapshot = h.nav.getState(); snapshot.tab = 'tasks'; snapshot.taskDetail = true; + assert.deepEqual(h.nav.getState(), defaultState); assertTab(h, 'appearance'); +}); + +domTest('disposing disconnects controls and ignores late updates without invoking cleanup twice', (t) => { + let leaves = 0, closes = 0; + const h = harness(t, { onLeaveDraft: () => { leaves++; }, beforeCloseImport: () => { closes++; return true; } }); + h.nav.showDraft(); h.nav.dispose(); + const state = h.nav.getState(), calls = leaves, connection = h.find('#connection-status').textContent; + h.tab('tasks').click(); h.find('#open-import').click(); h.find('#draft-back').click(); + h.key('settings', 'Home'); h.nav.update({ reminderCount: 4, availability: 'ready' }); + h.nav.showTab('appearance'); h.nav.showImport(); h.nav.closeImport(); h.nav.completeImport(); h.nav.showDraft(); h.nav.showTaskDetail(); h.nav.showTaskList(); h.nav.setTaskFilter('all'); h.nav.dispose(); + assert.deepEqual(h.nav.getState(), state); assert.equal(leaves, calls); assert.equal(closes, 0); + assert.equal(h.find('#connection-status').textContent, connection); +}); + +domTest('the shipped page exposes all navigation targets and keeps the existing feature controls', async () => { + const html = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); + const dom = new JSDOM(html); + try { + const doc = dom.window.document; + const ids = [...doc.querySelectorAll('[id]')].map((node) => node.id); + assert.equal(new Set(ids).size, ids.length, 'IDs must remain unique'); + const panel = doc.getElementById('panel'); + assert.ok(panel); assert.equal(panel.querySelectorAll('#panel-tabs [role="tab"]').length, 3); + for (const name of ['appearance', 'tasks', 'settings']) { + assert.equal(panel.querySelectorAll(`[data-panel-tab="${name}"]`).length, 1); + assert.equal(panel.querySelectorAll(`[data-panel-page="${name}"]`).length, 1); + } + const required = ['appearance-main', 'import-panel', 'settings-main', 'draft-card', 'open-import', 'import-back', + 'open-draft', 'draft-back', 'recent-tasks', 'all-sessions', 'task-filter-recent', 'task-filter-all', + 'task-detail-back', 'panel-task-count', 'connection-status', 'panel-stage', 'pet-sprite', 'wave-btn', 'jump-btn', + 'gallery', 'gallery-msg', 'dropzone', 'import-pick', 'import-input', 'import-preview', 'import-cancel', 'import-confirm', + 'aggregate-counts', 'reminder-list', 'muted-reminders', 'muted-list', 'session-list', 'activity-retry', + 'detail-card', 'detail-title', 'detail-status', 'detail-time', 'detail-reply', 'talk-form', 'talk-text', 'talk-btn', + 'draft-speech', 'speech-start', 'speech-stop', 'speech-cancel', 'scale-group']; + for (const id of required) assert.ok(panel.querySelector(`#${id}`), `Missing existing or navigation control #${id}`); + const nav = createPanelNavigation({ panel }); + try { + assert.equal(nav.getState().tab, 'appearance'); + nav.showImport(); assert.equal(panel.querySelector('#import-panel').hidden, false); + nav.showDraft(); assert.equal(panel.querySelector('#draft-card').hidden, false); + nav.showTaskDetail(); assert.equal(panel.dataset.taskDetail, 'open'); + nav.showTaskList(); assert.equal(nav.getState().taskDetail, false); + } finally { nav.dispose(); } + } finally { dom.window.close(); } +}); diff --git a/sources/minimax-ai/minimax-pet/dev/panel-page-wiring.test.mjs b/sources/minimax-ai/minimax-pet/dev/panel-page-wiring.test.mjs new file mode 100644 index 0000000..478557c --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/panel-page-wiring.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; +import { createPanelNavigation } from '../miniapp/client/panel-navigation.mjs'; +import { taskStatus } from '../miniapp/client/compact-pet-view.mjs'; + +const { JSDOM } = createRequire(import.meta.url)('jsdom'); +const source = await readFile(new URL('../miniapp/client/pet-app.js', import.meta.url), 'utf8'); +const html = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); +const select = source.slice(source.indexOf('function selectSession('), source.indexOf('// Window', source.indexOf('function selectSession('))); +const detail = source.slice(source.indexOf('function renderDetail()'), source.indexOf('function initWorkbench()')); +const navigate = source.slice(source.indexOf('function focusFullPanel('), source.indexOf('function openFullPanel(')); +const deferred = () => { let resolve; const promise = new Promise((yes) => { resolve = yes; }); return { promise, resolve }; }; +const settle = () => new Promise((resolve) => setImmediate(resolve)); +function harness(t, patch = {}) { + const dom = new JSDOM(html), doc = dom.window.document; + const nav = createPanelNavigation({ panel: doc.getElementById('panel') }); + const els = Object.fromEntries(['detailTitle', 'detailStatus', 'detailTime', 'detailError', 'detailReply', 'detailNote'].map((name) => [name, doc.getElementById(name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`))])); + const app = { mode: 'inline', source: 'real', availability: 'ready', sessions: [], selectedSessionId: null, detail: null, ...patch }; + const selected = [], reports = [], compact = [], pending = deferred(); + const context = vm.createContext({ + app, els, document: doc, panelNavigation: nav, panelDetailId: null, disposed: false, canAppend: true, taskStatus, + activitySource: { selectSession: (id) => { selected.push(id); return pending.promise; } }, + openTaskPreview: (id) => compact.push(id), callBridge: (callback) => Promise.resolve(callback()), + setMsg: (_element, text) => reports.push(text), availabilityText: () => '连接不可用', + STATUS_LABEL: { idle: '空闲', completed: '本次完成', running: '工作中', error: '异常' }, + }); + vm.runInContext(`${select}\n${detail}\n${navigate}\nglobalThis.handlers = { selectSession, renderDetail, focusFullPanel };`, context); + t.after(() => { nav.dispose(); dom.window.close(); }); + return { app, nav, els, doc, context, selected, reports, compact, pending, ...context.handlers }; +} + +test('selecting an inline task reveals its detail immediately; its delayed result never changes the new tab', async (t) => { + const h = harness(t); h.nav.showTab('settings'); + h.selectSession('a'); + assert.deepEqual(h.selected, ['a']); + assert.equal(h.nav.getState().tab, 'tasks'); assert.equal(h.nav.getState().taskDetail, true); + h.nav.showTab('appearance'); h.pending.resolve(); await settle(); + assert.equal(h.nav.getState().tab, 'appearance'); +}); + +test('desktop task selection stays in the compact window and does not navigate the inline panel', (t) => { + const h = harness(t, { mode: 'window' }); h.nav.showTab('settings'); h.selectSession('a'); + assert.deepEqual(h.compact, ['a']); assert.deepEqual(h.selected, []); + assert.equal(h.nav.getState().tab, 'settings'); +}); + +test('explicit panel entries map tasks and original-chat draft to their real pages', (t) => { + const h = harness(t); h.focusFullPanel('draft'); + assert.equal(h.nav.getState().tab, 'settings'); assert.equal(h.nav.getState().settingsView, 'draft'); + assert.equal(h.doc.activeElement.id, 'talk-text'); + h.focusFullPanel('detail'); assert.equal(h.nav.getState().tab, 'tasks'); assert.equal(h.nav.getState().taskDetail, true); + h.focusFullPanel('tasks'); assert.equal(h.nav.getState().taskDetail, false); +}); + +test('an environment without append capability cannot enter the draft from a panel action', (t) => { + const h = harness(t); h.context.canAppend = false; h.focusFullPanel('draft'); + assert.equal(h.nav.getState().tab, 'appearance'); assert.equal(h.nav.getState().settingsView, 'main'); +}); + +test('explicit task navigation focuses the detail heading and returns to the selected row', (t) => { + const h = harness(t), button = h.doc.createElement('button'); + button.dataset.sessionId = 'a'; button.textContent = '任务A'; h.doc.getElementById('session-list').append(button); + h.nav.showTab('tasks'); h.nav.setTaskFilter('all'); button.focus(); h.nav.showTaskDetail(); + assert.equal(h.doc.activeElement.id, 'detail-title'); + h.nav.showTaskList(); + assert.equal(h.doc.activeElement, button); assert.equal(h.nav.getState().taskFilter, 'all'); +}); + +test('background details retain reply content and scroll; only a different task resets the reading position', (t) => { + const h = harness(t, { selectedSessionId: 'a', sessions: [{ sessionId: 'a', status: 'idle', liveCompleted: true }], + detail: { sessionId: 'a', title: '任务A', status: 'idle', updatedAtMs: 1, lastAssistantText: '完整任务回复'.repeat(200) } }); + h.renderDetail(); const node = h.els.detailReply.firstChild; h.els.detailReply.scrollTop = 180; + h.app.detailLoading = true; h.nav.showTab('settings'); h.renderDetail(); + assert.equal(h.els.detailReply.firstChild, node); assert.equal(h.els.detailReply.scrollTop, 180); + assert.equal(h.els.detailStatus.textContent, '本次完成'); assert.equal(h.nav.getState().tab, 'settings'); + h.app.selectedSessionId = 'b'; h.app.detail = null; h.renderDetail(); assert.equal(h.els.detailReply.scrollTop, 0); +}); + +test('live ask replaces the old reply in the formal task detail without navigating', (t) => { + const h = harness(t, { selectedSessionId: 'a', sessions: [{ sessionId: 'a', status: 'running', needsAnswer: true }], + detail: { sessionId: 'a', title: '任务A', status: 'running', updatedAtMs: 1, lastAssistantText: '上一轮旧回复' } }); + h.nav.showTab('appearance'); h.renderDetail(); + assert.equal(h.els.detailStatus.textContent, '需要你回答'); assert.equal(h.els.detailReply.textContent, '需要你回答'); + assert.equal(h.nav.getState().tab, 'appearance'); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/pet-art-validation.test.mjs b/sources/minimax-ai/minimax-pet/dev/pet-art-validation.test.mjs new file mode 100644 index 0000000..2618a4f --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/pet-art-validation.test.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { deflateSync, inflateSync } from 'node:zlib'; +import test from 'node:test'; + +import { createPetAtlas } from '../scripts/generate-pet-art.mjs'; +import { validatePetArt } from '../skills/minimax-pet/scripts/validate-art.mjs'; + +const run = promisify(execFile); +const script = new URL('../skills/minimax-pet/scripts/validate-art.mjs', import.meta.url); +const v1 = createPetAtlas({ version: 1 }); +const v2 = createPetAtlas({ version: 2 }); + +async function fixture(t, { png = v1, metadata = {}, rawJson } = {}) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'minimax-pet-art-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const petJsonPath = path.join(dir, 'pet.json'); + const pet = { id: 'custom-pet', displayName: 'My Pet', description: 'Original test pet', spritesheetPath: 'spritesheet.png', ...metadata }; + await writeFile(petJsonPath, rawJson ?? JSON.stringify(pet)); + await writeFile(path.join(dir, 'spritesheet.png'), png); + return { dir, petJsonPath, pet }; +} + +function chunks(png) { + const result = []; + for (let offset = 8; offset < png.length;) { + const size = png.readUInt32BE(offset); + result.push({ kind: png.toString('ascii', offset + 4, offset + 8), data: png.subarray(offset + 8, offset + 8 + size) }); + offset += size + 12; + } + return result; +} +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(kind, data) { + const bytes = Buffer.alloc(data.length + 12); + bytes.writeUInt32BE(data.length); + bytes.write(kind, 4, 4, 'ascii'); + data.copy(bytes, 8); + bytes.writeUInt32BE(crc32(bytes.subarray(4, data.length + 8)), data.length + 8); + return bytes; +} +function repack(parts) { + return Buffer.concat([v1.subarray(0, 8), ...parts.map(({ kind, data }) => chunk(kind, data))]); +} +function rawPixels(png) { + const parts = chunks(png); + const header = parts.find(({ kind }) => kind === 'IHDR').data; + const width = header.readUInt32BE(0); + const height = header.readUInt32BE(4); + const encoded = inflateSync(Buffer.concat(parts.filter(({ kind }) => kind === 'IDAT').map(({ data }) => data))); + const pixels = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + const offset = y * (width * 4 + 1); + assert.equal(encoded[offset], 0, 'original generator uses unfiltered scanlines'); + encoded.copy(pixels, y * width * 4, offset + 1, offset + 1 + width * 4); + } + return { width, height, pixels, header: Buffer.from(header) }; +} +function encodePixels(image, { filterCycle = false } = {}) { + const { width, height, pixels, header } = image; + const stride = width * 4; + const encoded = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + const filter = filterCycle ? y % 5 : 0; + encoded[y * (stride + 1)] = filter; + for (let x = 0; x < stride; x++) { + const index = y * stride + x; + const a = x >= 4 ? pixels[index - 4] : 0; + const b = y ? pixels[index - stride] : 0; + const c = y && x >= 4 ? pixels[index - stride - 4] : 0; + // Independent encoder: choose the nearest of a, b, c to the Paeth estimate. + const p = a + b - c; + const nearest = [[a, Math.abs(p - a)], [b, Math.abs(p - b)], [c, Math.abs(p - c)]] + .reduce((best, entry) => entry[1] < best[1] ? entry : best)[0]; + const predictor = [0, a, b, Math.floor((a + b) / 2), nearest][filter]; + encoded[y * (stride + 1) + 1 + x] = (pixels[index] - predictor + 256) & 255; + } + } + return repack([{ kind: 'IHDR', data: header }, { kind: 'IDAT', data: deflateSync(encoded) }, { kind: 'IEND', data: Buffer.alloc(0) }]); +} +function setCell(image, row, frame, alpha) { + for (let y = 0; y < 208; y++) { + for (let x = 0; x < 192; x++) image.pixels[((row * 208 + y) * image.width + frame * 192 + x) * 4 + 3] = alpha; + } +} +function copyCell(image, fromRow, fromFrame, toRow, toFrame) { + for (let y = 0; y < 208; y++) { + const source = ((fromRow * 208 + y) * image.width + fromFrame * 192) * 4; + const target = ((toRow * 208 + y) * image.width + toFrame * 192) * 4; + image.pixels.copy(image.pixels, target, source, source + 192 * 4); + } +} +function assertFailure(report, pattern) { + assert.equal(report.ok, false); + assert.ok(report.errors.some((error) => pattern.test(error)), JSON.stringify(report)); +} + +test('generated v1 and v2 exports pass with the exact playable frame count', async (t) => { + const first = await fixture(t); + const second = await fixture(t, { png: v2, metadata: { spriteVersionNumber: 2 } }); + assert.deepEqual(await validatePetArt(first.petJsonPath), { ok: true, version: 1, width: 1536, height: 1872, frames: 57, warnings: [] }); + assert.deepEqual(await validatePetArt(second.petJsonPath), { ok: true, version: 2, width: 1536, height: 2288, frames: 73, warnings: [] }); +}); + +test('the standalone CLI returns machine-readable success and failure with exit status', async (t) => { + const { petJsonPath } = await fixture(t); + const success = await run(process.execPath, [script.pathname, petJsonPath]); + assert.equal(JSON.parse(success.stdout).ok, true); + assert.equal(success.stderr, ''); + await writeFile(petJsonPath, '{'); + await assert.rejects(run(process.execPath, [script.pathname, petJsonPath]), (error) => { + assert.equal(error.code, 1); + assertFailure(JSON.parse(error.stdout), /valid UTF-8 JSON/); + assert.equal(error.stderr, ''); + return true; + }); +}); + +test('metadata errors are actionable without echoing private values or following unsafe names', async (t) => { + for (const metadata of [ + { id: '../private-value' }, { id: 'minimax-original' }, { displayName: ' ' }, + { displayName: '😀'.repeat(41) }, { description: '😀'.repeat(151) }, + { spriteVersionNumber: '2' }, { spritesheetPath: '../private-value.png' }, + { spritesheetPath: 'https://private-value/pet.png' }, { spritesheetPath: '私有.png' }, + { spritesheetPath: 'pet.webp' }, + ]) { + const { petJsonPath } = await fixture(t, { metadata }); + const result = await validatePetArt(petJsonPath); + assert.equal(result.ok, false, JSON.stringify(metadata)); + assert.equal(JSON.stringify(result).includes('private-value'), false); + } + const { petJsonPath } = await fixture(t, { metadata: { displayName: '😀'.repeat(40), description: '😀'.repeat(150) } }); + assert.equal((await validatePetArt(petJsonPath)).ok, true, 'UTF-16 boundary values also pass page import validation'); +}); + +test('malformed JSON, arrays and invalid UTF-8 are rejected before loading an image', async (t) => { + for (const rawJson of ['null', '[]', '{', Buffer.from([0x7b, 0x22, 0x80, 0x22, 0x3a, 0x31, 0x7d])]) { + const { petJsonPath } = await fixture(t, { rawJson }); + assert.equal((await validatePetArt(petJsonPath)).ok, false); + } +}); + +test('declared atlas version and decoded dimensions must agree', async (t) => { + for (const options of [ + { png: v2 }, { png: v1, metadata: { spriteVersionNumber: 2 } }, + ]) { + const { petJsonPath } = await fixture(t, options); + assertFailure(await validatePetArt(petJsonPath), /requires exactly/); + } +}); + +test('PNG signature, CRC, complete stream and static RGBA encoding are checked', async (t) => { + const brokenCrc = Buffer.from(v1); + brokenCrc[45] ^= 1; + const animated = chunks(v1); + animated.splice(1, 0, { kind: 'acTL', data: Buffer.from([0, 0, 0, 2, 0, 0, 0, 0]) }); + const interlaced = chunks(v1).map(({ kind, data }) => ({ kind, data: Buffer.from(data) })); + interlaced[0].data[12] = 1; + const invalidStream = chunks(v1).map((part) => part.kind === 'IDAT' ? { ...part, data: Buffer.from('not zlib') } : part); + const trailingStream = chunks(v1).map((part) => part.kind === 'IDAT' ? { ...part, data: Buffer.concat([part.data, Buffer.from('extra')]) } : part); + for (const [png, pattern] of [ + [Buffer.from('not a PNG'), /must be a PNG/], [brokenCrc, /CRC/], [v1.subarray(0, v1.length - 1), /truncated/], + [Buffer.concat([v1, Buffer.from('extra')]), /final bytes/], [repack(animated), /static PNG/], + [repack(interlaced), /non-interlaced/], [repack(invalidStream), /image data is invalid/], + [repack(trailingStream), /trailing compressed bytes/], + ]) { + const { petJsonPath } = await fixture(t, { png }); + assertFailure(await validatePetArt(petJsonPath), pattern); + } +}); + +test('all five standard PNG row filters decode to the same valid animation', async (t) => { + const { petJsonPath } = await fixture(t, { png: encodePixels(rawPixels(v1), { filterCycle: true }) }); + assert.deepEqual(await validatePetArt(petJsonPath), { ok: true, version: 1, width: 1536, height: 1872, frames: 57, warnings: [] }); +}); + +test('empty artwork and a fully opaque background fail per frame, including the last v2 look', async (t) => { + const empty = rawPixels(v1); + empty.pixels.fill(0); + const opaqueCell = rawPixels(v1); + setCell(opaqueCell, 0, 0, 255); + const missingLastLook = rawPixels(v2); + setCell(missingLastLook, 10, 7, 0); + for (const [image, metadata, pattern] of [ + [empty, {}, /row 0, column 0 is empty/], [opaqueCell, {}, /row 0, column 0 has no fully transparent/], + [missingLastLook, { spriteVersionNumber: 2 }, /row 10, column 7 is empty/], + ]) { + const { petJsonPath } = await fixture(t, { png: encodePixels(image), metadata }); + assertFailure(await validatePetArt(petJsonPath), pattern); + } +}); + +test('clipping, frozen animation and repeated look artwork are warnings rather than format failures', async (t) => { + const image = rawPixels(v2); + for (let frame = 1; frame < 6; frame++) copyCell(image, 0, 0, 0, frame); + for (let index = 1; index < 16; index++) copyCell(image, 9, 0, 9 + Math.floor(index / 8), index % 8); + image.pixels[3] = 255; + const { petJsonPath } = await fixture(t, { png: encodePixels(image), metadata: { spriteVersionNumber: 2 } }); + const result = await validatePetArt(petJsonPath); + assert.equal(result.ok, true); + assert.ok(result.warnings.some((warning) => /cell edge/.test(warning))); + assert.ok(result.warnings.some((warning) => /sixteen look directions/.test(warning))); + // The deliberate edge pixel makes the first frame differ; freeze another row as well. + for (let frame = 1; frame < 4; frame++) copyCell(image, 3, 0, 3, frame); + await writeFile(path.join(path.dirname(petJsonPath), 'spritesheet.png'), encodePixels(image)); + assert.ok((await validatePetArt(petJsonPath)).warnings.some((warning) => /waving.*identical/.test(warning))); +}); + +test('oversized inputs, symlinks and directories are refused without reading arbitrary targets', async (t) => { + const { dir, petJsonPath } = await fixture(t); + await writeFile(petJsonPath, Buffer.alloc(1024 * 1024 + 1)); + assertFailure(await validatePetArt(petJsonPath), /pet.json exceeds/); + const safe = await fixture(t); + await writeFile(path.join(safe.dir, 'spritesheet.png'), Buffer.alloc(8 * 1024 * 1024 + 1)); + assertFailure(await validatePetArt(safe.petJsonPath), /spritesheet PNG exceeds.*generation budget/); + const linked = await fixture(t); + await rm(path.join(linked.dir, 'spritesheet.png')); + await symlink(path.join(dir, 'spritesheet.png'), path.join(linked.dir, 'spritesheet.png')); + assertFailure(await validatePetArt(linked.petJsonPath), /regular file, not a symlink/); + const jsonLink = path.join(linked.dir, 'linked.json'); + await symlink(safe.petJsonPath, jsonLink); + assertFailure(await validatePetArt(jsonLink), /regular file, not a symlink/); + assertFailure(await validatePetArt(linked.dir), /regular file, not a symlink/); +}); + +test('inflated scanlines are bounded and must exactly match the atlas size', async (t) => { + const header = chunks(v1)[0]; + const excessiveScanlines = Buffer.alloc((1536 * 4 + 1) * 1872 + 2); + const png = repack([header, { kind: 'IDAT', data: deflateSync(excessiveScanlines) }, { kind: 'IEND', data: Buffer.alloc(0) }]); + const { petJsonPath } = await fixture(t, { png }); + assertFailure(await validatePetArt(petJsonPath), /image data is invalid.*exceeds/); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/pet-store-recovery.test.mjs b/sources/minimax-ai/minimax-pet/dev/pet-store-recovery.test.mjs new file mode 100644 index 0000000..f59c420 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/pet-store-recovery.test.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import * as disk from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { createPetStore } from '../miniapp/node/pet-store.mjs'; +import { createPetAtlas } from '../scripts/generate-pet-art.mjs'; + +const clientRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../miniapp/client'); +const logger = { debug() {}, info() {}, warn() {}, error() {} }; +const sprite = createPetAtlas({ version: 1 }); +const petId = 'user-pet'; +const backupName = `replace-${petId}-1720000000000-abcdef123456`; + +function payload(id = petId, displayName = 'Old pet', replace = false) { + return { + petJson: { id, displayName, spritesheetPath: 'spritesheet.png' }, + spritesheet: { fileName: 'spritesheet.png', base64: sprite.toString('base64') }, + replace, + }; +} +function store(dataDir) { return createPetStore({ clientRoot, dataDir, logger }); } +async function directory(t) { + const path = await disk.mkdtemp(join(tmpdir(), 'pet-store-recovery-')); + t.after(() => disk.rm(path, { recursive: true, force: true })); + return path; +} +async function seed(t) { + const dataDir = await directory(t); + await store(dataDir).importPet(payload()); + return dataDir; +} +async function assertOriginal(dataDir) { + assert.equal(JSON.parse(await disk.readFile(join(dataDir, 'pets', petId, 'pet.json'), 'utf8')).displayName, 'Old pet'); + assert.deepEqual(await disk.readFile(join(dataDir, 'pets', petId, 'spritesheet.png')), sprite); +} +function child(mode, dataDir, expectedStatus) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), 'fault-child', mode, dataDir], { encoding: 'utf8' }); + assert.equal(result.status, expectedStatus, result.stderr || result.stdout); +} + +if (process.argv[2] === 'fault-child') { + const mode = process.argv[3]; + const dataDir = process.argv[4]; + const originalRename = fs.promises.rename; + fs.promises.rename = async (source, destination) => { + const newFinalMove = source.startsWith(join(dataDir, '.staging', 'import-')) && destination === join(dataDir, 'pets', petId); + if (['rename-error', 'rollback-error'].includes(mode) && newFinalMove) throw new Error('injected replacement failure'); + if (mode === 'rollback-error' && source.startsWith(join(dataDir, '.staging', 'replace-'))) throw new Error('injected rollback failure'); + await originalRename(source, destination); + if (mode === 'old-moved' && destination.startsWith(join(dataDir, '.staging', 'replace-'))) process.exit(77); + if (mode === 'new-committed' && newFinalMove) process.exit(78); + if (mode === 'delete-moved' && destination.startsWith(join(dataDir, '.staging', 'delete-'))) process.exit(79); + }; + syncBuiltinESMExports(); + if (mode === 'delete-moved') { + await store(dataDir).deletePet(petId); + throw new Error('delete crash point was not reached'); + } + if (['rename-error', 'rollback-error'].includes(mode)) { + await assert.rejects(store(dataDir).importPet(payload(petId, 'New pet', true)), /injected .* failure/); + process.exit(0); + } + await store(dataDir).importPet(payload(petId, 'New pet', true)); + throw new Error('replacement crash point was not reached'); +} else { + test('fresh catalog read restores the original pet after process exit between replacement renames', async (t) => { + const dataDir = await seed(t); + child('old-moved', dataDir, 77); + const restarted = store(dataDir); + assert.equal((await restarted.listPets()).find((pet) => pet.id === petId).displayName, 'Old pet'); + await assertOriginal(dataDir); + assert.deepEqual(await disk.readdir(join(dataDir, '.staging')), []); + await restarted.importPet(payload('other-pet')); + await assertOriginal(dataDir); + }); + + test('direct asset read also recovers before resolving the imported sprite', async (t) => { + const dataDir = await seed(t); + child('old-moved', dataDir, 77); + const result = await store(dataDir).resolveImportedFile(petId, 'spritesheet.png'); + assert.deepEqual(await disk.readFile(result.path), sprite); + await assertOriginal(dataDir); + }); + + test('first write recovers before checking conflicts, and never sweeps the only original copy', async (t) => { + const dataDir = await seed(t); + child('old-moved', dataDir, 77); + const restarted = store(dataDir); + await assert.rejects(restarted.importPet(payload()), { code: 'pet_exists' }); + await restarted.importPet(payload('other-pet')); + await assertOriginal(dataDir); + }); + + test('process exit after new pet commit keeps the new pet and clears only the old replacement backup', async (t) => { + const dataDir = await seed(t); + child('new-committed', dataDir, 78); + assert.equal((await store(dataDir).listPets()).find((pet) => pet.id === petId).displayName, 'New pet'); + assert.deepEqual(await disk.readdir(join(dataDir, '.staging')), []); + }); + + test('an intentional delete is completed after restart and is never resurrected', async (t) => { + const dataDir = await seed(t); + child('delete-moved', dataDir, 79); + assert.equal((await store(dataDir).listPets()).some((pet) => pet.id === petId), false); + assert.deepEqual(await disk.readdir(join(dataDir, '.staging')), []); + }); + + test('ordinary replacement failure rolls back immediately without discarding the original bytes', async (t) => { + const dataDir = await seed(t); + child('rename-error', dataDir, 0); + await assertOriginal(dataDir); + assert.deepEqual(await disk.readdir(join(dataDir, '.staging')), []); + }); + + test('failed rollback leaves a named backup for retry rather than swallowing the recovery failure', async (t) => { + const dataDir = await seed(t); + child('rollback-error', dataDir, 0); + assert.equal((await disk.readdir(join(dataDir, '.staging'))).filter((name) => name.startsWith('replace-')).length, 1); + await store(dataDir).listPets(); + await assertOriginal(dataDir); + }); + + test('invalid committed data blocks recovery and writes while retaining the full replacement backup', async (t) => { + const dataDir = await seed(t); + const backup = join(dataDir, '.staging', backupName); + await disk.rename(join(dataDir, 'pets', petId), backup); + await disk.mkdir(join(dataDir, 'pets', petId)); + await disk.writeFile(join(dataDir, 'pets', petId, 'pet.json'), '{broken'); + const restarted = store(dataDir); + await assert.rejects(restarted.listPets()); + await assert.rejects(restarted.importPet(payload('other-pet'))); + await assert.rejects(restarted.deletePet(petId)); + assert.deepEqual(await disk.readFile(join(backup, 'spritesheet.png')), sprite); + await disk.rm(join(dataDir, 'pets', petId), { recursive: true }); + await restarted.listPets(); + await assertOriginal(dataDir); + }); + + test('replacement recovery rejects a symlink backup without reading or modifying its target', async (t) => { + const dataDir = await directory(t); + const outside = await seed(t); + await disk.mkdir(join(dataDir, '.staging')); + await disk.symlink(join(outside, 'pets', petId), join(dataDir, '.staging', backupName)); + await assert.rejects(store(dataDir).listPets(), { code: 'pet_recovery_failed' }); + await assertOriginal(outside); + assert.equal((await disk.lstat(join(dataDir, '.staging', backupName))).isSymbolicLink(), true); + }); + + test('an unreadable replacement backup is retained and blocks cleanup until recovery can succeed', async (t) => { + const dataDir = await seed(t); + const backup = join(dataDir, '.staging', backupName); + await disk.rename(join(dataDir, 'pets', petId), backup); + const originalJson = await disk.readFile(join(backup, 'pet.json')); + await disk.writeFile(join(backup, 'pet.json'), '{broken'); + const restarted = store(dataDir); + await assert.rejects(restarted.listPets()); + await assert.rejects(restarted.importPet(payload('other-pet'))); + assert.deepEqual(await disk.readFile(join(backup, 'spritesheet.png')), sprite); + await disk.writeFile(join(backup, 'pet.json'), originalJson); + await restarted.listPets(); + await assertOriginal(dataDir); + }); + + test('a symlinked staging root cannot redirect cleanup into another directory', async (t) => { + const dataDir = await directory(t); + const outside = await directory(t); + await disk.writeFile(join(outside, 'keep.txt'), 'keep'); + await disk.symlink(outside, join(dataDir, '.staging')); + await assert.rejects(store(dataDir).listPets(), { code: 'pet_storage_unreadable' }); + await assert.rejects(store(dataDir).importPet(payload()), { code: 'pet_storage_unreadable' }); + assert.equal(await disk.readFile(join(outside, 'keep.txt'), 'utf8'), 'keep'); + }); + + test('ambiguous legacy trash remains available without reviving an intentional deletion', async (t) => { + const dataDir = await seed(t); + const legacy = join(dataDir, '.staging', 'trash-1720000000000-abcdef123456'); + await disk.rename(join(dataDir, 'pets', petId), legacy); + const restarted = store(dataDir); + assert.equal((await restarted.listPets()).some((pet) => pet.id === petId), false); + await restarted.importPet(payload('other-pet')); + assert.deepEqual(await disk.readFile(join(legacy, 'spritesheet.png')), sprite); + assert.equal((await restarted.listPets()).some((pet) => pet.id === petId), false); + }); + + test('queued reads cannot recover or clean a replacement that is still in flight', async (t) => { + const dataDir = await seed(t); + const current = store(dataDir); + const [, pets, asset] = await Promise.all([ + current.importPet(payload(petId, 'New pet', true)), + current.listPets(), + current.resolveImportedFile(petId, 'pet.json'), + ]); + assert.equal(pets.find((pet) => pet.id === petId).displayName, 'New pet'); + assert.equal(JSON.parse(await disk.readFile(asset.path, 'utf8')).displayName, 'New pet'); + assert.deepEqual(await disk.readdir(join(dataDir, '.staging')), []); + }); +} diff --git a/sources/minimax-ai/minimax-pet/dev/player.test.mjs b/sources/minimax-ai/minimax-pet/dev/player.test.mjs new file mode 100644 index 0000000..3c8aca9 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/player.test.mjs @@ -0,0 +1,432 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +// Exercise the shipped module with deterministic browser/clock boundaries. +const source = await readFile(new URL('../miniapp/client/pet-player.js', import.meta.url), 'utf8'); +const { SpritePlayer, ANIMATIONS, STATES, atlasVersionFromSize, loadPet, loadPetFromParts } = + await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + +const original = Object.fromEntries(['document', 'performance', 'setTimeout', 'clearTimeout', 'Image', 'fetch'] + .map((key) => [key, globalThis[key]])); + +function harness(t) { + let now = 0, next = 0; + const timers = new Map(), listeners = new Set(), players = []; + let imageResult = { width: 1536, height: 2288 }; + let pixels = null; + const doc = { + hidden: false, + baseURI: 'https://pet.test/client/index.html', + addEventListener(name, fn) { assert.equal(name, 'visibilitychange'); listeners.add(fn); }, + removeEventListener(name, fn) { assert.equal(name, 'visibilitychange'); listeners.delete(fn); }, + createElement(name) { + assert.equal(name, 'canvas'); + return { getContext() { return { drawImage() {}, getImageData() { + if (!pixels) throw new Error('pixel sampling unavailable'); + return pixels; + } }; } }; + }, + }; + globalThis.document = doc; + globalThis.performance = { now: () => now }; + globalThis.setTimeout = (fn, ms) => { + assert.ok(ms >= 0 && Number.isFinite(ms)); + const id = ++next; + timers.set(id, { at: now + ms, fn }); + return id; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + globalThis.Image = class { + set src(value) { + this.srcValue = value; + queueMicrotask(() => { + if (imageResult.error) this.onerror?.(); + else { + this.naturalWidth = imageResult.width; + this.naturalHeight = imageResult.height; + this.onload?.(); + } + }); + } + }; + t.after(() => { + for (const p of players) p.destroy(); + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }); + return { + timers, listeners, + element() { + return { + style: {}, + getBoundingClientRect() { + return { left: 10, top: 20, width: parseFloat(this.style.width) || 192, height: parseFloat(this.style.height) || 208 }; + }, + }; + }, + pet(version = 2) { + return { version, spriteUrl: '/atlas.webp', image: { naturalWidth: 1536, naturalHeight: version === 2 ? 2288 : 1872 } }; + }, + player({ loaded = true, version = 2, scale = 1 } = {}) { + const element = this.element(); + const player = new SpritePlayer(element, { scale }); + players.push(player); + if (loaded) player.setPet(this.pet(version)); + return { player, element }; + }, + advance(ms) { + const until = now + ms; + let steps = 0; + while (true) { + const first = [...timers].sort((a, b) => a[1].at - b[1].at)[0]; + if (!first || first[1].at > until) break; + assert.ok(++steps < 100000, 'clock must make forward progress'); + timers.delete(first[0]); + now = first[1].at; + first[1].fn(); + } + now = until; + }, + visibility(hidden) { + doc.hidden = hidden; + for (const listener of listeners) listener(); + }, + image(result) { imageResult = result; }, + pixels(value) { pixels = value; }, + }; +} +const duration = (state) => ANIMATIONS[state].durations.reduce((sum, ms) => sum + ms, 0); + +// These timings are from the installed Codex native overlay playback path, +// rather than its separate always-looping avatar preview. +test('idle holds all six frames for 6600 ms per cycle', (t) => { + const h = harness(t), { player } = h.player(); + assert.equal(duration('idle'), 6600); + for (let col = 0; col < 6; col += 1) { + assert.deepEqual(player.frame, { row: 0, col }); + h.advance(ANIMATIONS.idle.durations[col] - 1); + assert.deepEqual(player.frame, { row: 0, col }); + h.advance(1); + } + assert.deepEqual(player.frame, { row: 0, col: 0 }); +}); + +for (const state of STATES.filter((name) => name !== 'idle')) { + test(`${state} plays three original-speed cycles, then slow idle without changing requested state`, async (t) => { + const h = harness(t), { player } = h.player(); + await player.play(state); + for (let cycle = 0; cycle < 3; cycle += 1) { + assert.deepEqual(player.frame, { row: ANIMATIONS[state].row, col: 0 }); + h.advance(duration(state) - 1); + assert.equal(player.pose, state); + assert.equal(player.frame.col, ANIMATIONS[state].durations.length - 1); + h.advance(1); + } + assert.equal(player.state, state); + assert.equal(player.pose, 'idle'); + assert.deepEqual(player.frame, { row: 0, col: 0 }); + h.advance(1679); + assert.deepEqual(player.frame, { row: 0, col: 0 }); + h.advance(1); + assert.deepEqual(player.frame, { row: 0, col: 1 }); + h.advance(20000); + assert.equal(player.state, state); + assert.equal(player.pose, 'idle'); + }); +} + +test('explicit loop keeps the requested animation repeating for resource preview', async (t) => { + const h = harness(t), { player } = h.player(); + await player.play('running', { loop: true }); + h.advance(duration('running') * 10); + assert.equal(player.state, 'running'); + assert.equal(player.pose, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); +}); + +test('a new explicit request with the same state restarts after the idle tail', async (t) => { + const h = harness(t), { player } = h.player(); + await player.play('running'); + h.advance(duration('running') * 3 + 5000); + assert.equal(player.state, 'running'); + assert.equal(player.pose, 'idle'); + await player.play('running'); + assert.equal(player.pose, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); +}); + +test('transient resolves after its repeat count and starts a normal then track', async (t) => { + const h = harness(t), { player } = h.player(); + let finished = false; + const done = player.playTransient('waving', { then: 'running' }).then(() => { finished = true; }); + h.advance(duration('waving') * 3 - 1); + await Promise.resolve(); + assert.equal(finished, false); + assert.equal(player.state, 'waving'); + h.advance(1); + await done; + assert.equal(player.state, 'running'); + assert.equal(player.pose, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); + h.advance(duration('running') * 3); + assert.equal(player.state, 'running'); + assert.equal(player.pose, 'idle'); +}); + +test('transient supports an explicit one-cycle manual action', async (t) => { + const h = harness(t), { player } = h.player(); + const done = player.playTransient('jumping', { repeat: 1 }); + h.advance(duration('jumping')); + await done; + assert.equal(player.state, 'idle'); + assert.equal(player.pose, 'idle'); + assert.equal(h.timers.size, 1); +}); + +test('a normal play interrupts and settles a pending transient without a late state change', async (t) => { + const h = harness(t), { player } = h.player(); + const done = player.playTransient('waving', { then: 'review' }); + h.advance(100); + await player.play('failed'); + await done; + assert.equal(player.state, 'failed'); + h.advance(20000); + assert.equal(player.state, 'failed'); + assert.equal(player.pose, 'idle'); +}); + +test('a second transient settles the previous one and owns the final target', async (t) => { + const h = harness(t), { player } = h.player(); + const first = player.playTransient('waving', { then: 'failed' }); + const second = player.playTransient('jumping', { then: 'review', repeat: 1 }); + await first; + assert.equal(player.state, 'jumping'); + h.advance(duration('jumping')); + await second; + assert.equal(player.state, 'review'); +}); + +test('v2 look is a static sixteen-direction frame and exit rebuilds the requested track', async (t) => { + const h = harness(t), { player } = h.player(); + await player.play('running'); + h.advance(duration('running') * 3); + assert.equal(player.pose, 'idle'); + player.look(-90); + assert.equal(player.state, 'running'); + assert.equal(player.pose, 'look'); + assert.deepEqual(player.frame, { row: 10, col: 4 }); + assert.equal(h.timers.size, 0); + h.advance(10000); + assert.deepEqual(player.frame, { row: 10, col: 4 }); + player.look(90); + assert.deepEqual(player.frame, { row: 9, col: 4 }); + player.look(null); + assert.equal(player.pose, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); + assert.equal(h.timers.size, 1); +}); + +test('look interrupts and settles a transient before rebuilding its normal target', async (t) => { + const h = harness(t), { player } = h.player(); + const done = player.playTransient('waving', { then: 'failed' }); + player.look(0); + await done; + assert.equal(player.state, 'failed'); + assert.equal(player.pose, 'look'); + player.look(null); + assert.equal(player.pose, 'failed'); + assert.deepEqual(player.frame, { row: 5, col: 0 }); +}); + +test('v1 ignores look and changing a v2 pet to v1 clears its direction override', async (t) => { + const h = harness(t), { player } = h.player({ version: 1 }); + await player.play('running'); + player.look(90); + assert.equal(player.supportsLook, false); + assert.equal(player.pose, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); + player.setPet(h.pet(2)); + player.look(90); + assert.equal(player.pose, 'look'); + player.setPet(h.pet(1)); + assert.equal(player.pose, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); +}); + +test('reduced motion shows the requested state first frame even after the track has reached idle', async (t) => { + const h = harness(t), { player } = h.player(); + await player.play('review'); + h.advance(duration('review') * 3 + 1000); + assert.equal(player.pose, 'idle'); + player.setReducedMotion(true); + assert.equal(player.state, 'review'); + assert.equal(player.pose, 'review'); + assert.deepEqual(player.frame, { row: 8, col: 0 }); + assert.equal(h.timers.size, 0); + h.advance(20000); + assert.deepEqual(player.frame, { row: 8, col: 0 }); + await player.play('failed'); + assert.deepEqual(player.frame, { row: 5, col: 0 }); + player.setReducedMotion(false); + assert.equal(h.timers.size, 1); + assert.deepEqual(player.frame, { row: 5, col: 0 }); +}); + +test('reduced motion settles transients immediately into their normal target', async (t) => { + const h = harness(t), { player } = h.player(); + player.setReducedMotion(true); + await player.playTransient('jumping', { then: 'running' }); + assert.equal(player.state, 'running'); + assert.deepEqual(player.frame, { row: 7, col: 0 }); + assert.equal(h.timers.size, 0); +}); + +test('hidden pages keep the current frame and its remaining hold across a long pause', async (t) => { + const h = harness(t), { player } = h.player(); + await player.play('running'); + h.advance(75); + h.visibility(true); + assert.equal(h.timers.size, 0); + h.advance(20000); + assert.deepEqual(player.frame, { row: 7, col: 0 }); + h.visibility(false); + h.advance(44); + assert.deepEqual(player.frame, { row: 7, col: 0 }); + h.advance(1); + assert.deepEqual(player.frame, { row: 7, col: 1 }); +}); + +test('a request made while hidden starts only when the page becomes visible', async (t) => { + const h = harness(t), { player } = h.player(); + h.visibility(true); + await player.play('failed'); + h.advance(20000); + assert.deepEqual(player.frame, { row: 5, col: 0 }); + h.visibility(false); + h.advance(139); + assert.deepEqual(player.frame, { row: 5, col: 0 }); + h.advance(1); + assert.deepEqual(player.frame, { row: 5, col: 1 }); +}); + +test('scale changes render the same animation phase rather than replaying it', async (t) => { + const h = harness(t), { player, element } = h.player(); + await player.play('running'); + h.advance(150); + assert.deepEqual(player.frame, { row: 7, col: 1 }); + player.setScale(0.75); + assert.equal(element.style.width, '144px'); + assert.equal(element.style.height, '156px'); + assert.deepEqual(player.frame, { row: 7, col: 1 }); + h.advance(89); + assert.deepEqual(player.frame, { row: 7, col: 1 }); + h.advance(1); + assert.deepEqual(player.frame, { row: 7, col: 2 }); +}); + +test('alpha hit testing samples the actual current pose, including the idle tail and look', async (t) => { + const h = harness(t), { player } = h.player(); + const data = new Uint8ClampedArray(1536 * 2288 * 4); + // Alpha > 24 only in running row7 col0 and look row9 col4 at local (0,0). + data[((7 * 208) * 1536) * 4 + 3] = 25; + data[((9 * 208) * 1536 + 4 * 192) * 4 + 3] = 255; + h.pixels({ width: 1536, height: 2288, data }); + await player.play('running'); + assert.equal(player.isOpaqueAt(10, 20), true); + assert.equal(player.isOpaqueAt(9, 20), false); + assert.equal(player.isOpaqueAt(202, 20), false); + h.advance(duration('running') * 3); + assert.equal(player.state, 'running'); + assert.equal(player.isOpaqueAt(10, 20), false); + player.look(90); + assert.equal(player.isOpaqueAt(10, 20), true); +}); + +test('unavailable pixel sampling uses the established bounding-box fallback', (t) => { + const h = harness(t), { player } = h.player(); + assert.equal(player.isOpaqueAt(10, 20), true); + assert.equal(player.isOpaqueAt(9, 20), false); +}); + +test('destroy settles a transient, clears timers/listeners/styles, and ignores late operations', async (t) => { + const h = harness(t), { player, element } = h.player(); + const done = player.playTransient('waving'); + player.destroy(); + await done; + assert.equal(h.timers.size, 0); + assert.equal(h.listeners.size, 0); + assert.equal(player.isOpaqueAt(10, 20), false); + for (const prop of ['width', 'height', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat']) { + assert.equal(element.style[prop], ''); + } + await player.play('running'); + await player.playTransient('jumping'); + player.look(90); + player.setPet(h.pet(2)); + player.setReducedMotion(true); + h.advance(20000); + assert.equal(h.timers.size, 0); + player.destroy(); +}); + +test('missing pet does not create timers or leave a transient promise pending', async (t) => { + const h = harness(t), { player } = h.player({ loaded: false }); + await player.play('running'); + assert.equal(player.state, 'running'); + assert.equal(h.timers.size, 0); + await player.playTransient('waving', { then: 'review' }); + assert.equal(player.state, 'review'); + player.setPet(h.pet(2)); + assert.equal(player.pose, 'review'); + assert.equal(h.timers.size, 1); +}); + +test('invalid requests fail without corrupting a running track', async (t) => { + const h = harness(t), { player } = h.player(); + await player.play('running'); + assert.throws(() => player.play('unknown'), RangeError); + assert.throws(() => player.playTransient('jumping', { then: 'unknown' }), RangeError); + for (const repeat of [0, -1, 1.5, NaN, Infinity]) { + assert.throws(() => player.playTransient('jumping', { repeat }), RangeError); + } + assert.equal(player.state, 'running'); + assert.equal(h.timers.size, 1); +}); + +test('atlas validation retains v1 legacy and v2 dimensions and rejects incompatible sheets', async (t) => { + const h = harness(t); + assert.equal(atlasVersionFromSize(1536, 1872), 1); + assert.equal(atlasVersionFromSize(1536, 2288), 2); + assert.equal(atlasVersionFromSize(1535, 2288), null); + assert.equal(atlasVersionFromSize(1536, 2000), null); + const json = { id: 'test', displayName: 'Test', spritesheetPath: './spritesheet.webp' }; + h.image({ width: 1536, height: 1872 }); + assert.equal((await loadPetFromParts(json, '/v1.webp')).version, 1); + h.image({ width: 1536, height: 2288 }); + assert.equal((await loadPetFromParts({ ...json, spriteVersionNumber: 2 }, '/v2.webp')).version, 2); + await assert.rejects(loadPetFromParts(json, '/mismatch.webp'), { code: 'sprite_version_mismatch' }); + h.image({ width: 1024, height: 1024 }); + await assert.rejects(loadPetFromParts(json, '/wrong-size.webp'), { code: 'sprite_size_invalid' }); + h.image({ error: true }); + await assert.rejects(loadPetFromParts(json, '/unreadable.webp'), { code: 'sprite_unreadable' }); + await assert.rejects(loadPetFromParts({ ...json, spriteVersionNumber: 3 }, '/invalid.webp'), { code: 'pet_json_invalid' }); +}); + +test('pet loading resolves relative assets and reports fetch or malformed JSON errors', async (t) => { + const h = harness(t); + let requested; + globalThis.fetch = async (url) => { + requested = url; + return { ok: true, text: async () => JSON.stringify({ id: 'test', displayName: 'Test', spritesheetPath: './sprite.webp', spriteVersionNumber: 2 }) }; + }; + const pet = await loadPet('./pets/test/pet.json'); + assert.equal(requested, 'https://pet.test/client/pets/test/pet.json'); + assert.equal(pet.spriteUrl, 'https://pet.test/client/pets/test/sprite.webp'); + globalThis.fetch = async () => ({ ok: true, text: async () => '{broken' }); + await assert.rejects(loadPet('./pet.json'), { code: 'pet_json_invalid' }); + globalThis.fetch = async () => ({ ok: false, status: 404 }); + await assert.rejects(loadPet('./pet.json'), { code: 'pet_json_unreadable' }); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/preferences.test.mjs b/sources/minimax-ai/minimax-pet/dev/preferences.test.mjs new file mode 100644 index 0000000..ae9a08d --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/preferences.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createPreferenceStore } from '../miniapp/node/preferences-store.mjs'; +import { DEFAULT_PREFERENCES, LEGACY_KEYS, loadPreferences } from '../miniapp/client/preferences.mjs'; + +async function directory(t) { const path = await fs.mkdtemp(join(tmpdir(), 'pet-preferences-test-')); t.after(() => fs.rm(path, { recursive: true, force: true })); return path; } + +test('preferences persist across store instances and isolated dataDir keeps defaults', async (t) => { + const dataDir = await directory(t); + const store = createPreferenceStore({ dataDir }); + assert.deepEqual(await store.get(), { preferences: DEFAULT_PREFERENCES, persisted: false }); + await store.patch({ petId: 'my-pet', scale: 1 }); + assert.deepEqual(await createPreferenceStore({ dataDir }).get(), { preferences: { ...DEFAULT_PREFERENCES, petId: 'my-pet', scale: 1 }, persisted: true }); + assert.equal((await createPreferenceStore({ dataDir: await directory(t) }).get()).persisted, false); +}); + +test('invalid preferences cannot replace persisted data or inject session/runtime/draft fields', async (t) => { + const dataDir = await directory(t); + const store = createPreferenceStore({ dataDir }); + await store.patch({ petId: 'safe-pet' }); + const before = await fs.readFile(join(dataDir, 'preferences.json'), 'utf8'); + for (const patch of [null, [], {}, { scale: 2 }, { scale: '1' }, { petId: '../escape' }, { petId: '' }, { autoCycle: '1' }, { sessionId: 'a' }, { runId: 'x' }, { draft: 'hello' }, JSON.parse('{"__proto__":{}}')]) { + await assert.rejects(store.patch(patch), { code: 'invalid_preferences' }); + } + assert.equal(await fs.readFile(join(dataDir, 'preferences.json'), 'utf8'), before); +}); + +test('serialized patches keep independent changes and produce valid final JSON', async (t) => { + const dataDir = await directory(t); + const store = createPreferenceStore({ dataDir }); + await Promise.all([store.patch({ petId: 'pet-a' }), store.patch({ scale: 0.5 }), store.patch({ autoCycle: true }), store.patch({ petId: 'pet-b' })]); + assert.deepEqual((await store.get()).preferences, { petId: 'pet-b', scale: 0.5, autoCycle: true, windowPosition: null }); + assert.deepEqual(JSON.parse(await fs.readFile(join(dataDir, 'preferences.json'), 'utf8')).preferences, (await store.get()).preferences); +}); + +test('window position persists with bounded integer coordinates; invalid patches cannot change it', async (t) => { + const dataDir = await directory(t); + const store = createPreferenceStore({ dataDir }); + await store.patch({ windowPosition: { x: -25, y: 401 } }); + assert.deepEqual((await createPreferenceStore({ dataDir }).get()).preferences.windowPosition, { x: -25, y: 401 }); + for (const position of [{ x: 100001, y: 0 }, { x: 1.5, y: 1 }, { x: 1 }, { x: 1, y: 2, screen: 'private' }, []]) await assert.rejects(store.patch({ windowPosition: position }), { code: 'invalid_preferences' }); + assert.deepEqual((await store.get()).preferences.windowPosition, { x: -25, y: 401 }); +}); + +test('older stored preferences without windowPosition load safely and gain it on next write', async (t) => { + const dataDir = await directory(t); + await fs.writeFile(join(dataDir, 'preferences.json'), JSON.stringify({ version: 1, preferences: { petId: 'old', scale: 1, autoCycle: true } })); + const store = createPreferenceStore({ dataDir }); + assert.equal((await store.get()).preferences.windowPosition, null); + await store.patch({ windowPosition: { x: 1, y: 2 } }); + assert.equal((await store.get()).preferences.petId, 'old'); +}); + +test('failed atomic rename preserves old data, removes temporary files and permits retry', async (t) => { + const dataDir = await directory(t); + const store = createPreferenceStore({ dataDir }); + await store.patch({ petId: 'old-pet' }); + let fail = true; + const faulty = createPreferenceStore({ dataDir, fs: { ...fs, async rename(...args) { if (fail) throw new Error('injected disk failure'); return fs.rename(...args); } } }); + await assert.rejects(faulty.patch({ petId: 'new-pet' }), { code: 'preferences_write_failed' }); + assert.equal((await store.get()).preferences.petId, 'old-pet'); + assert.deepEqual(await fs.readdir(dataDir), ['preferences.json']); + fail = false; + await faulty.patch({ petId: 'new-pet' }); + assert.equal((await store.get()).preferences.petId, 'new-pet'); +}); + +test('corrupt or symlinked preference file fails explicitly without overwriting its bytes', async (t) => { + const dataDir = await directory(t); + const target = join(dataDir, 'preferences.json'); + await fs.writeFile(target, '{broken'); + const store = createPreferenceStore({ dataDir }); + await assert.rejects(store.get(), { code: 'preferences_unreadable' }); + await assert.rejects(store.patch({ scale: 1 }), { code: 'preferences_unreadable' }); + assert.equal(await fs.readFile(target, 'utf8'), '{broken'); + await fs.rm(target); + const outside = join(await directory(t), 'outside.json'); + await fs.writeFile(outside, JSON.stringify({ version: 1, preferences: DEFAULT_PREFERENCES })); + await fs.symlink(outside, target); + await assert.rejects(store.get(), { code: 'preferences_unreadable' }); +}); + +function migrationHarness({ persisted = false, fail = false } = {}) { + const values = new Map([[LEGACY_KEYS.petId, 'old-pet'], [LEGACY_KEYS.scale, '0.5'], [LEGACY_KEYS.autoCycle, '1']]); + const requests = []; + const storage = { getItem: (key) => values.get(key) ?? null, removeItem: (key) => values.delete(key) }; + const fetchImpl = async (_url, options) => { + requests.push(options); + if (options.method) { + if (fail) return { ok: false, json: async () => ({ error: 'preferences_write_failed' }) }; + return { ok: true, json: async () => ({ persisted: true, preferences: { ...DEFAULT_PREFERENCES, ...JSON.parse(options.body) } }) }; + } + return { ok: true, json: async () => ({ persisted, preferences: { ...DEFAULT_PREFERENCES, ...(persisted ? { petId: 'stable-pet' } : {}) } }) }; + }; + return { values, requests, storage, fetchImpl }; +} + +test('legacy preferences migrate only after successful persistence', async () => { + const h = migrationHarness(); + assert.deepEqual(await loadPreferences(h.fetchImpl, h.storage), { persisted: true, preferences: { petId: 'old-pet', scale: 0.5, autoCycle: true, windowPosition: null } }); + assert.equal(h.values.size, 0); + assert.equal(h.requests.length, 2); +}); + +test('persisted values win over old-origin localStorage without a write', async () => { + const h = migrationHarness({ persisted: true }); + assert.equal((await loadPreferences(h.fetchImpl, h.storage)).preferences.petId, 'stable-pet'); + assert.equal(h.requests.length, 1); + assert.equal(h.values.size, 3); +}); + +test('failed migration retains legacy values and exposes persistence failure', async () => { + const h = migrationHarness({ fail: true }); + await assert.rejects(loadPreferences(h.fetchImpl, h.storage), /preferences_write_failed/); + assert.equal(h.values.size, 3); +}); + +test('migration cannot overwrite a live preference write racing after the initial GET', async (t) => { + const dataDir = await directory(t); + const store = createPreferenceStore({ dataDir }); + const h = migrationHarness(); + let reads = 0; + const fetchImpl = async (_url, options) => { + if (!options.method) { + const result = await store.get(); + if (++reads === 1) await store.patch({ petId: 'live-user-choice', scale: 1 }); + return { ok: true, status: 200, json: async () => result }; + } + try { + const result = await store.patch(JSON.parse(options.body), { onlyIfUnpersisted: options.headers['if-none-match'] === '*' }); + return { ok: true, status: 200, json: async () => result }; + } catch (error) { + return { ok: false, status: error.status, json: async () => ({ error: error.code }) }; + } + }; + const result = await loadPreferences(fetchImpl, h.storage); + assert.equal(result.preferences.petId, 'live-user-choice'); + assert.equal(result.preferences.scale, 1); + assert.equal((await store.get()).preferences.petId, 'live-user-choice'); + assert.equal(h.values.size, 0); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/presentation.test.mjs b/sources/minimax-ai/minimax-pet/dev/presentation.test.mjs new file mode 100644 index 0000000..8c854d7 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/presentation.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { aggregateText, notificationItems, notificationText, createAnimationGate } from '../miniapp/client/pet-presentation.mjs'; + + +test('live questions replace stale reply previews and outrank terminal notifications while ordinary running tasks stay quiet', () => { + const items = [ + { sessionId: 'running', displayStatus: 'running' }, + { sessionId: 'finished', displayStatus: 'completed' }, + { sessionId: 'ask', displayStatus: 'running', needsAnswer: true }, + { sessionId: 'failed', displayStatus: 'error' }, + ]; + assert.deepEqual(notificationItems(items).map((item) => item.sessionId), ['ask', 'finished', 'failed']); + assert.equal(notificationText(items[2], { ask: 'old response' }), '需要你回答'); + assert.equal(notificationText(items[1], { finished: 'new response' }), 'new response'); + assert.equal(notificationText(items[3]), '任务出现异常'); + assert.equal(items[0].sessionId, 'running'); +}); + + +test('a refreshed running snapshot does not replay after its pose has settled; a new turn does', () => { + const calls = []; + const player = { state: 'idle', pose: 'idle', play(state, options) { this.state = state; calls.push({ state, options }); return Promise.resolve(); } }; + const gate = createAnimationGate(player); + assert.equal(gate.apply('running', 'turn-one'), true); + player.pose = 'idle'; + assert.equal(gate.apply('running', 'turn-one'), false); + assert.equal(gate.apply('running', 'turn-two'), true); + gate.invalidate(); + assert.equal(gate.apply('running', 'turn-two'), true); + assert.equal(calls.length, 3); +}); + + +test('the aggregate status text includes every category and keeps local completion distinct', () => { + assert.equal(aggregateText({ total: 5, running: 1, completed: 1, error: 1, aborted: 1, idle: 1 }), '5 个可见任务 · 1 运行中 · 1 本次完成 · 1 异常 · 1 已停止 · 1 空闲'); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/reminders.test.mjs b/sources/minimax-ai/minimax-pet/dev/reminders.test.mjs new file mode 100644 index 0000000..fa9dc3c --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/reminders.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createReminderStore } from '../miniapp/client/activity-reminders.mjs'; + +const HOUR = 60 * 60 * 1000; +const WEEK = 7 * 24 * HOUR; +function row(sessionId, status, updatedAtMs, { epoch = 1, turnId = null, revision = 0, liveCompleted = false, ...extra } = {}) { + return { sessionId, title: sessionId, status, updatedAtMs, liveCompleted, activityRevision: revision, animationKey: JSON.stringify([epoch, sessionId, turnId, revision, liveCompleted ? 'completed' : status]), ...extra }; +} + +test('reminders choose the newest activity rather than permanently preferring an old error', () => { + const store = createReminderStore({ now: () => 1000 }); + const rows = [row('old-error', 'error', 100), row('new-running', 'running', 300), row('completed', 'idle', 200, { liveCompleted: true }), row('idle', 'idle', 400), row('aborted', 'aborted', 500)]; + const copy = structuredClone(rows); + const state = store.update(rows); + assert.deepEqual(state.items.map((item) => item.sessionId), ['new-running', 'completed', 'old-error']); + assert.equal(state.primary.displayStatus, 'running'); + assert.deepEqual(rows, copy); + assert.equal(rows.filter((item) => item.status === 'error').length, 1); +}); + +test('error expires after one hour and live completion after seven days from persisted activity time', () => { + let time = HOUR + 100; + const store = createReminderStore({ now: () => time }); + store.update([row('expired-error', 'error', 100), row('error', 'error', 101), row('completion', 'idle', 0, { liveCompleted: true }), row('run', 'running', 0)]); + assert.deepEqual(store.getState().items.map((item) => item.sessionId), ['error', 'completion', 'run']); + assert.equal(store.getState().items.find((item) => item.sessionId === 'error').expiresAtMs, HOUR + 101); + assert.equal(store.getState().items.find((item) => item.sessionId === 'completion').expiresAtMs, WEEK); + assert.equal(store.getState().items.find((item) => item.sessionId === 'run').expiresAtMs, null); + time = WEEK; + assert.deepEqual(store.getState().items.map((item) => item.sessionId), ['run']); +}); + +test('historical idle is never inferred to be completed, including after an observer reconnect', () => { + const store = createReminderStore({ now: () => 1000 }); + store.update([row('task', 'running', 100, { turnId: 'turn' })]); + store.update([]); + assert.equal(store.update([row('task', 'idle', 500, { epoch: 3 })]).items.length, 0); +}); + +test('identical poll snapshots and input reordering retain reminder and animation identities', () => { + const store = createReminderStore({ now: () => 1000 }); + const a = row('a', 'running', 100, { turnId: 'one', revision: 1 }); + const b = row('b', 'error', 100); + const first = store.update([b, a]); + const second = store.update([a, b]); + assert.equal(first.primary.sessionId, 'a'); + assert.deepEqual(second, first); + for (let count = 0; count < 5; count++) assert.equal(store.update([a, b]).animationKey, first.animationKey); +}); + +test('dismissing a running round also suppresses its terminal result, but a known new turn returns', () => { + const store = createReminderStore({ now: () => 1000 }); + const first = store.update([row('task', 'running', 100, { turnId: 'one', revision: 1 })]); + store.dismiss('task'); + assert.equal(store.update([row('task', 'error', 200, { turnId: 'one', revision: 2 })]).items.length, 0); + const second = store.update([row('task', 'running', 300, { turnId: 'two', revision: 3 })]); + assert.equal(second.items.length, 1); + assert.notEqual(second.primary.reminderKey, first.primary.reminderKey); +}); + +test('turn completion changes the pose identity without changing its dismissal identity', () => { + const store = createReminderStore({ now: () => 1000 }); + const running = store.update([row('task', 'running', 100, { turnId: 'one', revision: 1 })]); + const completed = store.update([row('task', 'idle', 200, { liveCompleted: true, turnId: 'one', revision: 2 })]); + assert.equal(completed.primary.reminderKey, running.primary.reminderKey); + assert.notEqual(completed.animationKey, running.animationKey); +}); + +test('epoch and revision resets alone cannot replay or revive a dismissed known turn', () => { + const store = createReminderStore({ now: () => 1000 }); + const initial = store.update([row('task', 'running', 100, { turnId: 'one', revision: 8 })]); + const reconnected = store.update([row('task', 'running', 110, { epoch: 3, revision: 0 })]); + assert.equal(reconnected.animationKey, initial.animationKey); + store.dismiss('task'); + store.update([]); + assert.equal(store.update([row('task', 'error', 120, { epoch: 5 })]).items.length, 0); + assert.equal(store.update([row('task', 'error', 120, { epoch: 5, turnId: 'one', revision: 1 })]).items.length, 0); + assert.equal(store.update([row('task', 'running', 200, { epoch: 5, turnId: 'two', revision: 2 })]).items.length, 1); +}); + +test('unknown turn identities survive a reconnect conservatively despite a timestamp change', () => { + const store = createReminderStore({ now: () => 1000 }); + store.update([row('error', 'error', 100)]); + store.dismiss('error'); + store.update([]); + assert.equal(store.update([row('error', 'error', 200, { epoch: 3 })]).items.length, 0); + // A later persisted error update on this stable connection is new activity. + assert.equal(store.update([row('error', 'error', 300, { epoch: 3 })]).items.length, 1); +}); + +test('a snapshot running round acquiring its observed turn does not revive a dismissal', () => { + const store = createReminderStore({ now: () => 1000 }); + store.update([row('task', 'running', 100)]); + store.dismiss('task'); + assert.equal(store.update([row('task', 'running', 200, { turnId: 'observed', revision: 1 })]).items.length, 0); + assert.equal(store.update([row('task', 'error', 300, { turnId: 'observed', revision: 2 })]).items.length, 0); +}); + +test('a new snapshot run after a terminal or idle state can remind again without a turn ID', () => { + const store = createReminderStore({ now: () => 1000 }); + store.update([row('task', 'error', 100)]); + store.dismiss('task'); + assert.equal(store.update([row('task', 'running', 200)]).items.length, 1); + store.dismiss('task'); + store.update([row('task', 'idle', 300)]); + assert.equal(store.update([row('task', 'running', 400)]).items.length, 1); +}); + +test('mute follows a session across new turns and unmute restores only undismissed active reminders', () => { + const store = createReminderStore({ now: () => 1000 }); + store.update([row('task', 'running', 100, { turnId: 'one' })]); + assert.deepEqual(store.mute('task').mutedIds, ['task']); + assert.equal(store.update([row('task', 'running', 200, { turnId: 'two' })]).items.length, 0); + assert.equal(store.unmute('task').items.length, 1); + store.dismiss('task'); + store.mute('task'); + assert.equal(store.unmute('task').items.length, 0); +}); + +test('dismissAll affects only currently active rounds and does not mute sessions or erase facts', () => { + const store = createReminderStore({ now: () => 1000 }); + const rows = [row('a', 'running', 100, { turnId: 'one' }), row('b', 'error', 200), row('c', 'idle', 300)]; + store.update(rows); + assert.equal(store.dismissAll().items.length, 0); + assert.deepEqual(store.getState().mutedIds, []); + assert.equal(store.update(rows).items.length, 0); + assert.equal(store.update([...rows.slice(0, 2), row('c', 'running', 400, { turnId: 'new' })]).primary.sessionId, 'c'); +}); + +test('removing or hiding a row removes its reminder without forgetting a same-round dismissal', () => { + const store = createReminderStore({ now: () => 1000 }); + const task = row('task', 'error', 100, { turnId: 'one' }); + store.update([task]); + store.dismiss('task'); + assert.equal(store.update([]).primary, null); + assert.equal(store.update([task]).primary, null); +}); + +test('returned state cannot mutate the stores primitive row fields or mute set', () => { + const store = createReminderStore({ now: () => 1000 }); + const state = store.update([row('task', 'running', 100)]); + state.items[0].title = 'changed'; + state.primary.title = 'changed'; + state.mutedIds.push('task'); + assert.equal(store.getState().primary.title, 'task'); + assert.deepEqual(store.getState().mutedIds, []); +}); + +test('invalid rows cannot manufacture an active reminder and missing animation keys degrade safely', () => { + const store = createReminderStore({ now: () => 1000 }); + const rows = [null, row('', 'error', 100), row('bad-time', 'error', NaN), row('bad-status', 'complete', 100), { sessionId: 'older', status: 'running', updatedAtMs: 100 }]; + assert.deepEqual(store.update(rows).items.map((item) => item.sessionId), ['older']); + store.dismiss('older'); + assert.equal(store.update(rows).items.length, 0); +}); + +test('a live question outranks newer completion without changing the underlying running fact or turn identity', () => { + const store = createReminderStore({ now: () => 1000 }); + const ask = row('ask', 'running', 100, { turnId: 'ask-turn' }); + const done = row('done', 'idle', 900, { liveCompleted: true }); + const before = store.update([ask, done]); + assert.equal(before.primary.sessionId, 'done'); + const waiting = store.update([{ ...ask, needsAnswer: true }, done]); + assert.equal(waiting.primary.sessionId, 'ask'); + assert.equal(waiting.primary.status, 'running'); + assert.equal(waiting.primary.displayStatus, 'running'); + assert.equal(waiting.items.find((item) => item.sessionId === 'ask').reminderKey, before.items.find((item) => item.sessionId === 'ask').reminderKey); + const after = store.update([{ ...ask, needsAnswer: false }, done]); + assert.equal(after.primary.sessionId, 'done'); + assert.notEqual(after.animationKey, waiting.animationKey); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/run-local.mjs b/sources/minimax-ai/minimax-pet/dev/run-local.mjs new file mode 100644 index 0000000..212f326 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/run-local.mjs @@ -0,0 +1,65 @@ +// MiniMax Pet — dev-only launcher. Not part of the runtime payload. +// Usage: node dev/run-local.mjs [--port N] [--data-dir PATH] + +import { mkdir } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const { values } = parseArgs({ + options: { + port: { type: 'string' }, + 'data-dir': { type: 'string' }, + }, +}); + +const port = values.port ? Number(values.port) : await freePort(); +if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error('invalid --port'); + process.exit(2); +} +const dataDir = resolve(values['data-dir'] ?? resolve(packageRoot, 'dev', '.data-local')); +await mkdir(dataDir, { recursive: true }); + +const controller = new AbortController(); +const log = (level) => (message, fields) => + console.error(JSON.stringify({ level, message, ...(fields ? { fields } : {}) })); +const logger = { debug: log('debug'), info: log('info'), warn: log('warn'), error: log('error') }; + +const { start } = await import(resolve(packageRoot, 'miniapp/node/server.mjs')); +const lifecycle = await start({ + pluginId: 'minimax-pet', + pluginRoot: packageRoot, + dataDir, + listen: { host: '127.0.0.1', port }, + signal: controller.signal, + logger, +}); + +console.log(`http://127.0.0.1:${port}/pet`); +console.log(`dataDir: ${dataDir}`); + +let stopping = false; +const stop = async () => { + if (stopping) return; + stopping = true; + controller.abort(); + await lifecycle?.dispose?.(); + process.exit(0); +}; +process.on('SIGINT', stop); +process.on('SIGTERM', stop); + +function freePort() { + return new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + probe.close(() => resolvePort(typeof address === 'object' && address ? address.port : 0)); + }); + }); +} diff --git a/sources/minimax-ai/minimax-pet/dev/scale-update.test.mjs b/sources/minimax-ai/minimax-pet/dev/scale-update.test.mjs new file mode 100644 index 0000000..d85b8dc --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/scale-update.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; + +const source = await readFile(new URL('../miniapp/client/pet-app.js', import.meta.url), 'utf8'); +const handler = source.slice(source.indexOf('function setScale(value)'), source.indexOf('// Import', source.indexOf('function setScale(value)'))); +const deferred = () => { let resolve; const promise = new Promise((yes) => { resolve = yes; }); return { promise, resolve }; }; +function harness(overrides = {}) { + const calls = [], prefs = { scale: 1 }, app = { mode: 'window', workbenchOpen: false, menuOpen: false }; + const context = vm.createContext({ + app, prefs, scaleIntent: 0, scaleUpdates: Promise.resolve(), disposed: false, win: {}, + screen: { availLeft: 0, availTop: 0, availWidth: 1920, availHeight: 1080 }, + fitWindowToScreen: async () => { calls.push(['fit']); return { error: null }; }, + player: { setScale: (value) => calls.push(['scale', value]) }, + savePreferences: (patch) => calls.push(['save', patch.scale]), renderScale() {}, refreshInteractivity() {}, positionMenu() {}, onWindowState() {}, + closeWorkbench: async () => { app.workbenchOpen = false; calls.push(['close']); }, + showTransient: (text) => calls.push(['error', text]), logError: () => (error) => assert.fail(error), ...overrides, + }); + vm.runInContext(`${handler}\nglobalThis.setScaleHandler = setScale;`, context); + return { calls, prefs, app, context, setScale: context.setScaleHandler }; +} + +test('a desktop size change fits geometry before applying and persisting the scale', async () => { + const h = harness(); h.app.workbenchOpen = true; + await h.setScale(0.5); + assert.deepEqual(h.calls, [['close'], ['fit'], ['scale', 0.5], ['save', 0.5]]); +}); + +test('failed reposition preserves the visible size and saved preference', async () => { + const h = harness({ fitWindowToScreen: async () => ({ error: 'runtime_unavailable' }) }); + await h.setScale(0.5); + assert.equal(h.prefs.scale, 1); + assert.deepEqual(h.calls.map((call) => call[0]), ['error']); +}); + +test('latest size wins after an older in-flight geometry response', async () => { + const pending = deferred(), started = deferred(); let count = 0; + const h = harness({ fitWindowToScreen: async () => { if (++count === 1) { started.resolve(); return pending.promise; } return { error: null }; } }); + const old = h.setScale(0.5); await started.promise; + const latest = h.setScale(0.75); pending.resolve({ error: null }); + await Promise.all([old, latest]); + assert.deepEqual(h.calls, [['scale', 0.75], ['save', 0.75]]); + assert.equal(h.prefs.scale, 0.75); +}); + +test('leaving the desktop during preparation invalidates a scale operation', async () => { + const pending = deferred(), started = deferred(); + const h = harness({ fitWindowToScreen: async (_handle, options) => { started.resolve(); await pending.promise; return { cancelled: !options.isCurrent() }; } }); + const operation = h.setScale(0.5); await started.promise; + h.app.mode = 'inline'; pending.resolve(); await operation; + assert.equal(h.prefs.scale, 1); assert.deepEqual(h.calls, []); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs b/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs new file mode 100644 index 0000000..1f23f11 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs @@ -0,0 +1,664 @@ +// MiniMax Pet — dev-only smoke test for the Node service. Run: node dev/smoke-api.mjs + +import assert from 'node:assert/strict'; +import { access, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { request as httpRequest } from 'node:http'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPetAtlas } from '../scripts/generate-pet-art.mjs'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const { start } = await import(resolve(packageRoot, 'miniapp/node/server.mjs')); +const { readImageHeader, atlasVersionFromSize } = await import(resolve(packageRoot, 'miniapp/node/image-header.mjs')); + +const BUNDLED_PET = 'minimax-original'; +const petsRoot = join(packageRoot, 'miniapp/client/pets'); + +const results = []; +const notes = []; +async function test(name, fn) { + try { + await fn(); + results.push({ name, ok: true }); + } catch (error) { + results.push({ name, ok: false, error }); + } +} + +function freePort() { + return new Promise((resolvePort, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address(); + probe.close(() => resolvePort(port)); + }); + }); +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; + +async function boot(dataDir, pluginRoot = packageRoot) { + const port = await freePort(); + const controller = new AbortController(); + const lifecycle = await start({ + pluginId: 'minimax-pet', + pluginRoot, + dataDir, + listen: { host: '127.0.0.1', port }, + signal: controller.signal, + logger: silentLogger, + }); + return { port, controller, lifecycle }; +} + +/** Raw HTTP request; path is sent verbatim (no client-side normalization). */ +function call(port, method, path, { headers = {}, body, chunks } = {}) { + return new Promise((resolveCall, reject) => { + let settled = false; + const req = httpRequest({ host: '127.0.0.1', port, method, path, headers }, (res) => { + const parts = []; + res.on('data', (c) => parts.push(c)); + res.on('end', () => { + settled = true; + const buf = Buffer.concat(parts); + let json; + try { + json = JSON.parse(buf.toString('utf8')); + } catch {} + resolveCall({ status: res.statusCode, headers: res.headers, body: buf, json }); + }); + }); + req.on('error', (error) => { + if (!settled) reject(error); + }); + if (chunks) { + let i = 0; + const pump = () => { + while (i < chunks.length) { + if (req.destroyed) return; + const ok = req.write(chunks[i++]); + if (!ok) return void req.once('drain', pump); + } + req.end(); + }; + pump(); + } else { + req.end(body); + } + }); +} + +const jsonHeaders = { 'content-type': 'application/json' }; +function postPet(port, payload, raw) { + const body = raw ?? JSON.stringify(payload); + return call(port, 'POST', '/api/pets', { + headers: { ...jsonHeaders, 'content-length': Buffer.byteLength(body) }, + body, + }); +} + +function patchPreferences(port, payload, headers = {}) { + const body = JSON.stringify(payload); + return call(port, 'PATCH', '/api/preferences', { headers: { ...jsonHeaders, ...headers }, body }); +} + +function pngHeader(width, height) { + const buf = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0); + buf.writeUInt32BE(13, 8); + buf.write('IHDR', 12, 'latin1'); + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + buf[24] = 8; + buf[25] = 6; + return buf; +} + +function webpVp8xHeader(width, height) { + const buf = Buffer.alloc(30); + buf.write('RIFF', 0, 'latin1'); + buf.writeUInt32LE(22, 4); + buf.write('WEBPVP8X', 8, 'latin1'); + buf.writeUInt32LE(10, 16); + buf.writeUIntLE(width - 1, 24, 3); + buf.writeUIntLE(height - 1, 27, 3); + return buf; +} + +function webpVp8Header(width, height) { + const buf = Buffer.alloc(30); + buf.write('RIFF', 0, 'latin1'); + buf.writeUInt32LE(22, 4); + buf.write('WEBPVP8 ', 8, 'latin1'); + buf.writeUInt32LE(10, 16); + buf[20] = 0x10; // keyframe + buf[23] = 0x9d; + buf[24] = 0x01; + buf[25] = 0x2a; + buf.writeUInt16LE(width, 26); + buf.writeUInt16LE(height, 28); + return buf; +} + +async function stagingLeftovers(dataDir) { + try { + return await readdir(join(dataDir, '.staging')); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } +} + +// --------------------------------------------------------------------------------------------- + +const versionOneSprite = createPetAtlas({ version: 1 }); +const versionTwoSprite = createPetAtlas({ version: 2 }); +const bundledSprite = await readFile(join(petsRoot, BUNDLED_PET, 'spritesheet.png')); +const bundledJson = JSON.parse(await readFile(join(petsRoot, BUNDLED_PET, 'pet.json'), 'utf8')); +const versionOneJson = { + id: 'version-one-fixture', + displayName: 'Version one fixture', + description: 'Generated first-party atlas for import validation.', + spritesheetPath: 'spritesheet.png', +}; + +await test('image-header: generated first-party PNG is 1536x1872 (v1)', () => { + const info = readImageHeader(versionOneSprite); + notes.push(`v1 header: ${JSON.stringify(info)}`); + assert.deepEqual([info.format, info.width, info.height], ['png', 1536, 1872]); + assert.equal(atlasVersionFromSize(info.width, info.height), 1); +}); +await test('image-header: bundled first-party PNG is 1536x2288 (v2)', () => { + const info = readImageHeader(bundledSprite); + assert.deepEqual(bundledSprite, versionTwoSprite); + assert.equal(bundledJson.id, BUNDLED_PET); + assert.equal(bundledJson.spriteVersionNumber, 2); + notes.push(`v2 header: ${JSON.stringify(info)}`); + assert.deepEqual([info.format, info.width, info.height], ['png', 1536, 2288]); + assert.equal(atlasVersionFromSize(info.width, info.height), 2); +}); +await test('image-header: synthetic PNG / VP8X / VP8 / garbage', () => { + assert.deepEqual(readImageHeader(pngHeader(1536, 2288)), { format: 'png', width: 1536, height: 2288 }); + assert.deepEqual(readImageHeader(pngHeader(100, 100)), { format: 'png', width: 100, height: 100 }); + const x = readImageHeader(webpVp8xHeader(1536, 1872)); + assert.deepEqual([x.format, x.variant, x.width, x.height], ['webp', 'VP8X', 1536, 1872]); + const v = readImageHeader(webpVp8Header(1536, 2288)); + assert.deepEqual([v.format, v.variant, v.width, v.height], ['webp', 'VP8', 1536, 2288]); + assert.equal(readImageHeader(Buffer.from('GIF89a........................')), null); + assert.equal(readImageHeader(pngHeader(1, 1).subarray(0, 20)), null); +}); + +const dataDir = await mkdtemp(join(tmpdir(), 'minimax-pet-smoke-')); +const { port, lifecycle } = await boot(dataDir); + +try { + await test('GET preferences defaults are explicit and no-store', async () => { + const r = await call(port, 'GET', '/api/preferences'); + assert.equal(r.status, 200); + assert.equal(r.headers['cache-control'], 'no-store'); + assert.deepEqual(r.json, { preferences: { petId: null, scale: 0.75, autoCycle: false, windowPosition: null }, persisted: false }); + }); + + await test('PATCH preferences persists only supported values, merges concurrent patches', async () => { + const responses = await Promise.all([patchPreferences(port, { petId: BUNDLED_PET }), patchPreferences(port, { scale: 0.5 }), patchPreferences(port, { autoCycle: true })]); + for (const r of responses) { assert.equal(r.status, 200); assert.equal(r.json.persisted, true); } + assert.deepEqual((await call(port, 'GET', '/api/preferences')).json.preferences, { petId: BUNDLED_PET, scale: 0.5, autoCycle: true, windowPosition: null }); + }); + + await test('invalid preferences fail without modifying persisted preferences', async () => { + const before = await readFile(join(dataDir, 'preferences.json'), 'utf8'); + for (const payload of [null, [], {}, { scale: 2 }, { petId: '../bad' }, { autoCycle: 1 }, { sessionId: 'private' }]) { + const r = await patchPreferences(port, payload); + assert.equal(r.status, 400); + assert.equal(r.json.error, 'invalid_preferences'); + } + assert.equal(await readFile(join(dataDir, 'preferences.json'), 'utf8'), before); + }); + + await test('conditional migration cannot overwrite existing stable preferences', async () => { + const r = await patchPreferences(port, { petId: 'stale-localstorage' }, { 'if-none-match': '*' }); + assert.equal(r.status, 412); + assert.equal(r.json.error, 'preferences_exists'); + assert.equal((await call(port, 'GET', '/api/preferences')).json.preferences.petId, BUNDLED_PET); + }); + + await test('preferences reject wrong methods, media type, oversized bodies and cross-origin writes', async () => { + assert.equal((await call(port, 'POST', '/api/preferences')).status, 405); + assert.equal((await call(port, 'PATCH', '/api/preferences', { body: '{}' })).status, 415); + assert.equal((await call(port, 'PATCH', '/api/preferences', { headers: jsonHeaders, body: 'x'.repeat(16 * 1024 + 1) })).status, 413); + const foreign = await patchPreferences(port, { scale: 1 }, { origin: 'https://unrelated.example' }); + assert.equal(foreign.status, 403); + assert.equal(foreign.json.error, 'origin_not_allowed'); + assert.equal((await patchPreferences(port, { scale: 0.5 }, { origin: `http://127.0.0.1:${port}` })).status, 200); + }); + + await test('GET /pet → 200 html or 503 client_missing', async () => { + const r = await call(port, 'GET', '/pet'); + let hasIndex = true; + await access(join(packageRoot, 'miniapp/client/index.html')).catch(() => (hasIndex = false)); + if (hasIndex) { + assert.equal(r.status, 200); + assert.match(r.headers['content-type'], /^text\/html/); + notes.push('GET /pet: index.html present → 200 text/html'); + } else { + assert.equal(r.status, 503); + assert.equal(r.json.error, 'client_missing'); + notes.push('GET /pet: index.html missing → 503 client_missing'); + } + }); + + await test('POST /pet → 405', async () => { + const r = await call(port, 'POST', '/pet'); + assert.equal(r.status, 405); + }); + + await test('GET bundled pet.json via /assets → 200 application/json, no-cache', async () => { + const r = await call(port, 'GET', `/assets/pets/${BUNDLED_PET}/pet.json`); + assert.equal(r.status, 200); + assert.match(r.headers['content-type'], /^application\/json/); + assert.equal(r.headers['cache-control'], 'no-cache'); + assert.equal(r.json.id, BUNDLED_PET); + }); + + await test('production activity, preferences and draft modules are served as JavaScript', async () => { + for (const name of ['activity.mjs', 'preferences.mjs', 'chat-draft.mjs', 'window-position.mjs', 'speech-input.mjs', 'pet-presentation.mjs', 'activity-reminders.mjs', 'compact-pet-view.mjs', 'notification-previews.mjs', 'ask-observer.mjs', 'workbench-window.mjs']) { + const r = await call(port, 'GET', `/assets/${name}`); + assert.equal(r.status, 200); + assert.match(r.headers['content-type'], /^text\/javascript/); + assert.ok(r.body.length > 0); + } + }); + + await test('GET bundled spritesheet via /assets → 200 image/png', async () => { + const r = await call(port, 'GET', `/assets/pets/${BUNDLED_PET}/spritesheet.png`); + assert.equal(r.status, 200); + assert.equal(r.headers['content-type'], 'image/png'); + assert.deepEqual(r.body, bundledSprite); + assert.deepEqual(r.body, versionTwoSprite); + }); + + for (const path of [ + '/assets/../node/server.mjs', + '/assets/%2e%2e/node/server.mjs', + '/assets/..%2fnode%2fserver.mjs', + '/assets/pets\\..\\..\\node\\server.mjs', + '/assets/pets/%2e%2e/%2e%2e/node/server.mjs', + '/assets/./pets/' + BUNDLED_PET + '/pet.json', + '/assets/pets/%00/pet.json', + '/assets/pets/%E0%A4%A/pet.json', + '/assets/pets/' + BUNDLED_PET + '/spritesheet.txt', + '/assets/pets', + ]) { + await test(`traversal/invalid ${JSON.stringify(path)} → not 200`, async () => { + const r = await call(port, 'GET', path); + assert.notEqual(r.status, 200); + assert.ok(!r.body.toString('utf8').includes('createServer')); + }); + } + + await test('GET /api/pets lists one first-party bundled v2 pet with petJsonUrl, no-store', async () => { + const r = await call(port, 'GET', '/api/pets'); + assert.equal(r.status, 200); + assert.equal(r.headers['cache-control'], 'no-store'); + const pets = r.json.pets; + assert.deepEqual( + pets.map((p) => [p.id, p.version, p.source, p.petJsonUrl]), + [ + [BUNDLED_PET, 2, 'bundled', `/assets/pets/${BUNDLED_PET}/pet.json`], + ], + ); + assert.equal(typeof pets[0].displayName, 'string'); + assert.equal(typeof pets[0].description, 'string'); + }); + + await test('PUT /api/pets → 405', async () => { + assert.equal((await call(port, 'PUT', '/api/pets')).status, 405); + }); + + const copyJson = { ...versionOneJson, id: 'user-copy', displayName: 'User copy' }; + const copyPayload = { + petJson: copyJson, + spritesheet: { fileName: 'spritesheet.png', base64: versionOneSprite.toString('base64') }, + }; + + await test('import user-copy → 201', async () => { + const r = await postPet(port, copyPayload); + assert.equal(r.status, 201, r.body.toString()); + assert.deepEqual(r.json.pet, { + id: 'user-copy', + displayName: 'User copy', + description: versionOneJson.description, + version: 1, + source: 'imported', + petJsonUrl: '/imported/user-copy/pet.json', + }); + }); + + await test('listing contains imported user-copy after bundled', async () => { + const r = await call(port, 'GET', '/api/pets'); + const ids = r.json.pets.map((p) => `${p.source}:${p.id}`); + assert.deepEqual(ids, [`bundled:${BUNDLED_PET}`, 'imported:user-copy']); + }); + + await test('/imported/user-copy/pet.json → 200 normalized subset', async () => { + const r = await call(port, 'GET', '/imported/user-copy/pet.json'); + assert.equal(r.status, 200); + assert.match(r.headers['content-type'], /^application\/json/); + assert.deepEqual(r.json, { + id: 'user-copy', + displayName: 'User copy', + description: versionOneJson.description, + spritesheetPath: 'spritesheet.png', + }); + }); + + await test('/imported/user-copy/spritesheet.png → 200 image/png', async () => { + const r = await call(port, 'GET', '/imported/user-copy/spritesheet.png'); + assert.equal(r.status, 200); + assert.equal(r.headers['content-type'], 'image/png'); + assert.ok(r.body.equals(versionOneSprite)); + }); + + await test('/imported/user-copy/other.txt → 404 JSON', async () => { + await writeFile(join(dataDir, 'pets/user-copy/other.txt'), 'nope'); + const r = await call(port, 'GET', '/imported/user-copy/other.txt'); + assert.equal(r.status, 404); + assert.equal(r.json.error, 'not_found'); + }); + + await test('/imported traversal → not 200', async () => { + for (const p of ['/imported/user-copy/..%2f..%2fpets', '/imported/../pets/x', '/imported/user-copy']) { + assert.notEqual((await call(port, 'GET', p)).status, 200, p); + } + }); + + await test('re-import same id → 409 pet_exists', async () => { + const r = await postPet(port, copyPayload); + assert.equal(r.status, 409); + assert.equal(r.json.error, 'pet_exists'); + }); + + await test('re-import with replace:true → 201 and content replaced', async () => { + const r = await postPet(port, { + ...copyPayload, + petJson: { ...copyJson, displayName: 'Replaced' }, + replace: true, + }); + assert.equal(r.status, 201, r.body.toString()); + const j = await call(port, 'GET', '/imported/user-copy/pet.json'); + assert.equal(j.json.displayName, 'Replaced'); + const leftover = await call(port, 'GET', '/imported/user-copy/other.txt'); + assert.equal(leftover.status, 404); + }); + + await test('import v2 first-party copy → 201 stores spriteVersionNumber 2', async () => { + const r = await postPet(port, { + petJson: { id: 'version-two', displayName: 'Version two', spriteVersionNumber: 2, spritesheetPath: 'atlas.png' }, + spritesheet: { fileName: 'atlas.png', base64: versionTwoSprite.toString('base64') }, + }); + assert.equal(r.status, 201, r.body.toString()); + assert.equal(r.json.pet.version, 2); + const j = await call(port, 'GET', '/imported/version-two/pet.json'); + assert.deepEqual(j.json, { + id: 'version-two', + displayName: 'Version two', + description: '', + spritesheetPath: 'atlas.png', + spriteVersionNumber: 2, + }); + }); + + const errorCases = [ + ['bundled id → 409 bundled_conflict', 409, 'bundled_conflict', { ...copyPayload, petJson: { ...versionOneJson, id: BUNDLED_PET } }], + [ + 'synthetic 100x100 PNG → 400 sprite_size_invalid', + 400, + 'sprite_size_invalid', + { + petJson: { id: 'tiny', displayName: 'Tiny', spritesheetPath: 'a.png' }, + spritesheet: { fileName: 'a.png', base64: pngHeader(100, 100).toString('base64') }, + }, + ], + [ + 'v1 image with spriteVersionNumber 2 → 400 sprite_version_mismatch', + 400, + 'sprite_version_mismatch', + { ...copyPayload, petJson: { ...copyJson, id: 'mismatch', spriteVersionNumber: 2 } }, + ], + [ + 'PNG bytes with .webp name → 400 sprite_format_invalid', + 400, + 'sprite_format_invalid', + { + petJson: { ...copyJson, id: 'wrongext', spritesheetPath: 'a.webp' }, + spritesheet: { fileName: 'a.webp', base64: versionOneSprite.toString('base64') }, + }, + ], + [ + 'non-image bytes → 400 sprite_format_invalid', + 400, + 'sprite_format_invalid', + { + petJson: { ...copyJson, id: 'garbage' }, + spritesheet: { fileName: 'spritesheet.png', base64: Buffer.from('hello world!').toString('base64') }, + }, + ], + [ + 'invalid base64 → 400 sprite_format_invalid', + 400, + 'sprite_format_invalid', + { ...copyPayload, petJson: { ...copyJson, id: 'badb64' }, spritesheet: { fileName: 'spritesheet.png', base64: 'UklG*RiI=' } }, + ], + [ + 'fileName ≠ spritesheetPath → 400 invalid_sprite_name', + 400, + 'invalid_sprite_name', + { ...copyPayload, petJson: { ...copyJson, id: 'namediff' }, spritesheet: { ...copyPayload.spritesheet, fileName: 'other.webp' } }, + ], + [ + 'spritesheetPath with directory → 400 invalid_sprite_name', + 400, + 'invalid_sprite_name', + { + petJson: { ...copyJson, id: 'withdir', spritesheetPath: 'sprites/spritesheet.png' }, + spritesheet: { ...copyPayload.spritesheet, fileName: 'sprites/spritesheet.png' }, + }, + ], + [ + 'spritesheetPath ../x.webp → 400 invalid_sprite_name', + 400, + 'invalid_sprite_name', + { + petJson: { ...copyJson, id: 'dotdot', spritesheetPath: '../x.webp' }, + spritesheet: { ...copyPayload.spritesheet, fileName: '../x.webp' }, + }, + ], + ['bad id → 400 invalid_id', 400, 'invalid_id', { ...copyPayload, petJson: { ...copyJson, id: 'Bad/ID' } }], + ['id starting with dot → 400 invalid_id', 400, 'invalid_id', { ...copyPayload, petJson: { ...copyJson, id: '.staging' } }], + [ + 'displayName too long → 400 invalid_pet_json', + 400, + 'invalid_pet_json', + { ...copyPayload, petJson: { ...copyJson, id: 'longname', displayName: 'x'.repeat(81) } }, + ], + [ + 'description too long → 400 invalid_pet_json', + 400, + 'invalid_pet_json', + { ...copyPayload, petJson: { ...copyJson, id: 'longdesc', description: 'x'.repeat(301) } }, + ], + [ + 'spriteVersionNumber 3 → 400 invalid_pet_json', + 400, + 'invalid_pet_json', + { ...copyPayload, petJson: { ...copyJson, id: 'v3', spriteVersionNumber: 3 } }, + ], + ['petJson not object → 400 invalid_pet_json', 400, 'invalid_pet_json', { ...copyPayload, petJson: 'nope' }], + ['spritesheet missing → 400 invalid_json', 400, 'invalid_json', { petJson: { ...copyJson, id: 'nosprite' } }], + ]; + for (const [name, status, code, payload] of errorCases) { + await test(name, async () => { + const r = await postPet(port, payload); + assert.equal(r.status, status, r.body.toString().slice(0, 200)); + assert.equal(r.json.error, code); + assert.equal(typeof r.json.message, 'string'); + }); + } + + await test('malformed JSON → 400 invalid_json', async () => { + const r = await postPet(port, null, '{"petJson": '); + assert.equal(r.status, 400); + assert.equal(r.json.error, 'invalid_json'); + }); + + await test('wrong content-type → 415', async () => { + const r = await call(port, 'POST', '/api/pets', { headers: { 'content-type': 'text/plain' }, body: '{}' }); + assert.equal(r.status, 415); + }); + + await test('> 12 MB body with content-length → 413 payload_too_large', async () => { + const big = Buffer.alloc(13 * 1024 * 1024, 0x20); + const r = await call(port, 'POST', '/api/pets', { + headers: { ...jsonHeaders, 'content-length': big.length }, + body: big, + }); + assert.equal(r.status, 413); + assert.equal(r.json.error, 'payload_too_large'); + }); + + await test('> 12 MB chunked body (no content-length) → 413 payload_too_large', async () => { + const chunk = Buffer.alloc(1024 * 1024, 0x20); + const r = await call(port, 'POST', '/api/pets', { + headers: { ...jsonHeaders }, + chunks: Array.from({ length: 13 }, () => chunk), + }); + assert.equal(r.status, 413); + assert.equal(r.json.error, 'payload_too_large'); + }); + + await test('server still healthy after 413', async () => { + assert.equal((await call(port, 'GET', '/api/pets')).status, 200); + }); + + await test('DELETE bundled → 403 bundled_read_only', async () => { + const r = await call(port, 'DELETE', `/api/pets/${BUNDLED_PET}`); + assert.equal(r.status, 403); + assert.equal(r.json.error, 'bundled_read_only'); + }); + + await test('DELETE unknown → 404, bad id → 400 invalid_id', async () => { + assert.equal((await call(port, 'DELETE', '/api/pets/does-not-exist')).status, 404); + const bad = await call(port, 'DELETE', '/api/pets/BAD'); + assert.equal(bad.status, 400); + assert.equal(bad.json.error, 'invalid_id'); + }); + + await test('GET /api/pets/ → 405', async () => { + assert.equal((await call(port, 'GET', '/api/pets/user-copy')).status, 405); + }); + + await test('DELETE imported → 204, then GET 404 and not listed', async () => { + const d = await call(port, 'DELETE', '/api/pets/user-copy'); + assert.equal(d.status, 204); + assert.equal((await call(port, 'GET', '/imported/user-copy/pet.json')).status, 404); + const list = await call(port, 'GET', '/api/pets'); + assert.ok(!list.json.pets.some((p) => p.id === 'user-copy')); + }); + + await test('concurrent imports of same id: exactly one 201, rest 409', async () => { + const payload = { ...copyPayload, petJson: { ...copyJson, id: 'race' } }; + const rs = await Promise.all(Array.from({ length: 4 }, () => postPet(port, payload))); + const statuses = rs.map((r) => r.status).sort(); + assert.deepEqual(statuses, [201, 409, 409, 409]); + }); + + await test('no leftover staging dirs in dataDir', async () => { + assert.deepEqual(await stagingLeftovers(dataDir), []); + const petDirs = (await readdir(join(dataDir, 'pets'))).sort(); + assert.deepEqual(petDirs, ['race', 'version-two']); + }); + + await test('/unknown-route-for-smoke not handled by plugin → 404', async () => { + const r = await call(port, 'GET', '/unknown-route-for-smoke'); + assert.equal(r.status, 404); + }); + + await test('unknown route → 404 JSON', async () => { + const r = await call(port, 'GET', '/nope'); + assert.equal(r.status, 404); + assert.equal(r.json.error, 'not_found'); + }); +} finally { + await lifecycle.dispose(); +} + +const restarted = await boot(dataDir); +try { + await test('preferences survive a fresh listener at another loopback origin', async () => { + assert.notEqual(restarted.port, port); + const r = await call(restarted.port, 'GET', '/api/preferences'); + assert.equal(r.status, 200); + assert.equal(r.json.persisted, true); + assert.deepEqual(r.json.preferences, { petId: BUNDLED_PET, scale: 0.5, autoCycle: true, windowPosition: null }); + }); +} finally { await restarted.lifecycle.dispose(); } + +// Fault injection: `pets` is a regular file, so the final rename fails after staging was written. +const faultDir = await mkdtemp(join(tmpdir(), 'minimax-pet-smoke-fault-')); +await writeFile(join(faultDir, 'pets'), 'not a directory'); +const fault = await boot(faultDir); +try { + await test('write failure mid-import → 500 internal_error, no staging leftovers', async () => { + const r = await postPet(fault.port, { + petJson: { ...versionOneJson, id: 'faulty' }, + spritesheet: { fileName: 'spritesheet.png', base64: versionOneSprite.toString('base64') }, + }); + assert.equal(r.status, 500, r.body.toString()); + assert.equal(r.json.error, 'internal_error'); + assert.deepEqual(await stagingLeftovers(faultDir), []); + }); +} finally { + fault.controller.abort(); + await fault.lifecycle.dispose(); +} + +// Symlink escape: a fake plugin root whose client dir contains a symlink pointing outside it. +const fakeRoot = await mkdtemp(join(tmpdir(), 'minimax-pet-smoke-root-')); +await mkdir(join(fakeRoot, 'miniapp/client/pets'), { recursive: true }); +await writeFile(join(fakeRoot, 'secret.json'), '{"secret":true}'); +await writeFile(join(fakeRoot, 'miniapp/client/ok.json'), '{"ok":true}'); +await symlink(join(fakeRoot, 'secret.json'), join(fakeRoot, 'miniapp/client/evil.json')); +await symlink(join(fakeRoot, 'miniapp/client/ok.json'), join(fakeRoot, 'miniapp/client/inner.json')); +const fakeData = await mkdtemp(join(tmpdir(), 'minimax-pet-smoke-fakedata-')); +const fake = await boot(fakeData, fakeRoot); +try { + await test('symlink escaping client root → 404; symlink inside → 200', async () => { + assert.equal((await call(fake.port, 'GET', '/assets/ok.json')).status, 200); + assert.equal((await call(fake.port, 'GET', '/assets/inner.json')).status, 200); + const evil = await call(fake.port, 'GET', '/assets/evil.json'); + assert.equal(evil.status, 404); + assert.ok(!evil.body.toString().includes('secret')); + }); +} finally { + await fake.lifecycle.dispose(); +} +await rm(fakeRoot, { recursive: true, force: true }); +await rm(fakeData, { recursive: true, force: true }); + +await test('dispose closed the listener', async () => { + await assert.rejects(call(port, 'GET', '/api/pets')); +}); + +await rm(dataDir, { recursive: true, force: true }); +await rm(faultDir, { recursive: true, force: true }); + +for (const r of results) { + console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.name}${r.ok ? '' : `\n ${r.error?.message?.split('\n').join('\n ')}`}`); +} +for (const n of notes) console.log(`note: ${n}`); +const failed = results.filter((r) => !r.ok).length; +console.log(`\n${results.length - failed}/${results.length} passed`); +process.exit(failed ? 1 : 0); diff --git a/sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs b/sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs new file mode 100644 index 0000000..b055a58 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createSpeechInput } from '../miniapp/client/speech-input.mjs'; + +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture(options = {}) { + const states = [], texts = [], partials = [], calls = [], contexts = [], streams = []; + const timers = new Map(); + let nextTimer = 0, gumCalls = 0; + const makeStream = () => { + const listeners = new Set(); + const track = { + readyState: 'live', stops: 0, + stop() { this.stops++; this.readyState = 'ended'; }, + addEventListener(type, listener) { if (type === 'ended') listeners.add(listener); }, + removeEventListener(type, listener) { if (type === 'ended') listeners.delete(listener); }, + end() { this.readyState = 'ended'; for (const listener of [...listeners]) listener(); }, + }; + const stream = { track, getTracks: () => [track], getAudioTracks: () => [track] }; + streams.push(stream); + return stream; + }; + const miniapp = { + capabilities: { has: (name) => name === 'speech.transcribe' }, + speech: { transcribe: async (input, callbacks) => { + calls.push(input); + assert.equal(streams.at(-1).track.stops, 1, 'microphone closes before transcription'); + callbacks.onPartial('processing'); + return options.transcribe ? options.transcribe(input, callbacks) : { text: 'recognized' }; + } }, + }; + const makeContext = () => { + const node = () => ({ disconnects: 0, connect() {}, disconnect() { this.disconnects++; } }); + const context = { + sampleRate: options.rate ?? 48000, destination: {}, closes: 0, + source: node(), processor: node(), mute: { ...node(), gain: { value: 1 } }, + resume: () => options.resume ? options.resume() : Promise.resolve(), + close() { this.closes++; return Promise.resolve(); }, + createMediaStreamSource() { this.createdSource = true; return this.source; }, + createScriptProcessor() { + if (options.setupFailure) throw new Error('processor failed'); + this.createdProcessor = true; + return this.processor; + }, + createGain() { this.createdMute = true; return this.mute; }, + }; + contexts.push(context); + return context; + }; + const input = createSpeechInput({ + getMiniApp: () => options.miniapp === undefined ? miniapp : options.miniapp, + onState: (state) => states.push(state), onText: (text) => texts.push(text), + onPartial: (text) => partials.push(text), + dependencies: { + getUserMedia: (constraints) => { + gumCalls++; + assert.equal(constraints.audio.channelCount, 1); + return options.getUserMedia ? options.getUserMedia() : Promise.resolve(makeStream()); + }, + createAudioContext: makeContext, + setTimeout: (callback, ms) => { const id = ++nextTimer; timers.set(id, { callback, ms }); return id; }, + clearTimeout: (id) => timers.delete(id), + }, + }); + const feed = (...channels) => { + const arrays = channels.map((values) => Float32Array.from(values)); + contexts.at(-1).processor.onaudioprocess?.({ inputBuffer: { + length: arrays[0].length, numberOfChannels: arrays.length, + getChannelData: (index) => arrays[index], + } }); + }; + const assertReleased = () => { + for (const context of contexts) { + assert.equal(context.closes, 1); + if (context.createdSource) assert.equal(context.source.disconnects, 1); + if (context.createdProcessor) { + assert.equal(context.processor.disconnects, 1); + assert.equal(context.processor.onaudioprocess, null); + } + if (context.createdMute) assert.equal(context.mute.disconnects, 1); + } + for (const stream of streams) assert.equal(stream.track.stops, 1); + assert.equal(timers.size, 0); + }; + return { input, miniapp, states, texts, partials, calls, contexts, streams, timers, + makeStream, feed, assertReleased, gumCalls: () => gumCalls }; +} + +test('records actual 48 kHz stereo input, downmixes/resamples to 16 kHz s16, and delivers one final text', async () => { + const f = fixture(); + assert.equal(f.input.isAvailable(), true); + await f.input.start(); + f.feed(Array(480).fill(0.8), Array(480).fill(0.2)); + await f.input.stop(); + assert.equal(f.calls.length, 1); + assert.ok(f.calls[0].audio instanceof Int16Array); + assert.equal(f.calls[0].audio.length, 160); + assert.ok(f.calls[0].audio.every((value) => value === 16384)); + assert.deepEqual(f.calls[0].format, { sampleRate: 16000, channelCount: 1, sampleFormat: 's16' }); + assert.deepEqual(f.states.map(({ state }) => state), ['requesting', 'recording', 'transcribing', 'idle']); + assert.deepEqual(f.partials, ['processing']); + assert.deepEqual(f.texts, ['recognized']); + f.assertReleased(); +}); + +test('uses the actual 44.1 kHz context rate rather than the requested rate', async () => { + const f = fixture({ rate: 44100 }); + await f.input.start(); + f.feed(Array(441).fill(1)); + await f.input.stop(); + assert.equal(f.calls[0].audio.length, 160); + assert.ok(f.calls[0].audio.every((value) => value === 32767)); + f.assertReleased(); +}); + +test('converts clipped/non-finite samples safely and supports lower sample rates', async () => { + const f = fixture({ rate: 16000 }); + await f.input.start(); + f.feed([-2, -1, -0.5, 0, 0.5, 1, 2, NaN]); + await f.input.stop(); + assert.deepEqual([...f.calls[0].audio], [-32768, -32768, -16384, 0, 16384, 32767, 32767, 0]); + f.assertReleased(); + const low = fixture({ rate: 8000 }); + await low.input.start(); + low.feed([0, 0.5, 1, 0]); + await low.input.stop(); + assert.equal(low.calls[0].audio.length, 8); + low.assertReleased(); +}); + +test('feature detection requires the synchronous capability and the real transcription method', async () => { + for (const miniapp of [null, {}, { speech: { transcribe() {} } }, + { capabilities: { has: () => false }, speech: { transcribe() {} } }, + { capabilities: { has: () => true } }, { capabilities: { has: async () => true }, speech: { transcribe() {} } }]) { + const f = fixture({ miniapp }); + assert.equal(f.input.isAvailable(), false); + await f.input.start(); + assert.deepEqual(f.states, [{ state: 'error', errorCode: 'speech_unavailable' }]); + assert.equal(f.gumCalls(), 0); + } +}); + +test('capability support does not bypass the microphone permission grant', async () => { + const f = fixture({ getUserMedia: () => Promise.reject(Object.assign(new Error('denied'), { name: 'NotAllowedError' })) }); + assert.equal(f.input.isAvailable(), true); + await f.input.start(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'not_permitted' }); + assert.equal(f.calls.length, 0); + f.assertReleased(); +}); + +test('missing/unreadable microphone and setup failures release every acquired resource', async () => { + for (const name of ['NotFoundError', 'NotReadableError']) { + const f = fixture({ getUserMedia: () => Promise.reject(Object.assign(new Error(name), { name })) }); + await f.input.start(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'microphone_unavailable' }); + f.assertReleased(); + } + const f = fixture({ setupFailure: true }); + await f.input.start(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'capture_failed' }); + f.assertReleased(); +}); + +test('cancel or stop during getUserMedia settles start and stops a late granted stream', async () => { + for (const action of ['cancel', 'stop']) { + const permission = deferred(); + const f = fixture({ getUserMedia: () => permission.promise }); + const starting = f.input.start(); + f.input[action](); + await starting; + permission.resolve(f.makeStream()); + await flush(); + assert.deepEqual(f.states.map(({ state }) => state), ['requesting', 'idle']); + assert.equal(f.calls.length, 0); + f.assertReleased(); + } +}); + +test('dispose during permission prompt drops callbacks, releases context and stops the late stream', async () => { + const permission = deferred(); + const f = fixture({ getUserMedia: () => permission.promise }); + const starting = f.input.start(); + f.input.dispose(); + f.input.dispose(); + await starting; + permission.resolve(f.makeStream()); + await flush(); + assert.deepEqual(f.states, [{ state: 'requesting' }]); + assert.equal(f.input.isAvailable(), false); + await f.input.start(); + assert.equal(f.gumCalls(), 1); + f.assertReleased(); +}); + +test('a failed audio context resume cannot leak a late microphone stream', async () => { + const permission = deferred(); + const f = fixture({ getUserMedia: () => permission.promise, resume: () => Promise.reject(new Error('audio unavailable')) }); + await f.input.start(); + permission.resolve(f.makeStream()); + await flush(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'capture_failed' }); + f.assertReleased(); +}); + +test('cancel during recording discards audio; an externally ended track reports microphone failure', async () => { + const f = fixture(); + await f.input.start(); + f.feed([1, 1, 1]); + f.input.cancel(); + await f.input.stop(); + assert.equal(f.calls.length, 0); + assert.deepEqual(f.states.at(-1), { state: 'idle' }); + f.assertReleased(); + const ended = fixture(); + await ended.input.start(); + ended.streams[0].track.end(); + assert.deepEqual(ended.states.at(-1), { state: 'error', errorCode: 'microphone_unavailable' }); + ended.assertReleased(); +}); + +test('empty recordings report no_audio without calling ASR', async () => { + const f = fixture(); + await f.input.start(); + await f.input.stop(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'no_audio' }); + assert.equal(f.calls.length, 0); + f.assertReleased(); +}); + +test('the 60 second timer stops recording and transcribes the audio actually received', async () => { + const f = fixture({ rate: 16000 }); + await f.input.start(); + f.feed(Array(160).fill(0.2)); + const timer = [...f.timers.values()][0]; + assert.equal(timer.ms, 60000); + timer.callback(); + await flush(); + assert.equal(f.calls[0].audio.length, 160); + assert.deepEqual(f.texts, ['recognized']); + f.assertReleased(); +}); + +test('an audio block is bounded to 60 seconds even if the wall-clock timer is delayed', async () => { + const f = fixture({ rate: 16000 }); + await f.input.start(); + f.feed(new Float32Array(16000 * 60 + 100)); + await flush(); + assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].audio.length, 16000 * 60); + f.assertReleased(); +}); + +test('repeated start and stop cannot duplicate capture, transcription or final text', async () => { + const result = deferred(); + const f = fixture({ transcribe: () => result.promise }); + const first = f.input.start(); + await f.input.start(); + await first; + await f.input.start(); + f.feed([1, 1, 1]); + const one = f.input.stop(); + const two = f.input.stop(); + assert.equal(one, two); + await flush(); + assert.equal(f.gumCalls(), 1); + assert.equal(f.calls.length, 1); + result.resolve({ text: 'only once' }); + await Promise.all([one, two]); + assert.deepEqual(f.texts, ['only once']); + f.assertReleased(); +}); + +test('cancel/dispose during ASR ignore its late partial, final and failure results', async () => { + for (const action of ['cancel', 'dispose']) { + const result = deferred(); + let callbacks; + const f = fixture({ transcribe: (_input, options) => { callbacks = options; return result.promise; } }); + await f.input.start(); + f.feed([1, 1, 1]); + const stopping = f.input.stop(); + await flush(); + f.input[action](); + const count = f.states.length; + callbacks.onPartial('late partial'); + result.resolve({ text: 'late text' }); + await stopping; + assert.deepEqual(f.texts, []); + assert.deepEqual(f.partials, ['processing']); + assert.equal(f.states.length, count); + f.assertReleased(); + } +}); + +test('Host rejection codes and malformed results are surfaced without text or retry', async () => { + for (const code of ['not_permitted', 'busy', 'runtime_unavailable', 'transcription_failed', 'result_unknown']) { + const f = fixture({ transcribe: () => Promise.reject(new Error(code)) }); + await f.input.start(); + f.feed([1, 1, 1]); + await f.input.stop(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: code }); + assert.deepEqual(f.texts, []); + assert.equal(f.calls.length, 1); + f.assertReleased(); + } + const f = fixture({ transcribe: () => ({ text: 42 }) }); + await f.input.start(); + f.feed([1, 1, 1]); + await f.input.stop(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'result_unknown' }); + f.assertReleased(); +}); + +test('capability withdrawal after recording preserves the recording failure and never calls ASR', async () => { + const f = fixture(); + await f.input.start(); + f.feed([1, 1, 1]); + f.miniapp.capabilities.has = () => false; + await f.input.stop(); + assert.deepEqual(f.states.at(-1), { state: 'error', errorCode: 'speech_unavailable' }); + assert.equal(f.calls.length, 0); + f.assertReleased(); +}); + +test('a cancelled ASR cannot settle or overwrite a newer recording and result', async () => { + const old = deferred(); + let invocation = 0; + const f = fixture({ transcribe: () => ++invocation === 1 ? old.promise : { text: 'new recording' } }); + await f.input.start(); + f.feed([1, 1, 1]); + const oldStop = f.input.stop(); + await flush(); + f.input.cancel(); + await f.input.start(); + f.feed([0.5, 0.5, 0.5]); + await f.input.stop(); + old.resolve({ text: 'old recording' }); + await oldStop; + assert.deepEqual(f.texts, ['new recording']); + assert.deepEqual(f.states.at(-1), { state: 'idle' }); + assert.equal(f.calls.length, 2); + f.assertReleased(); +}); + +test('a cancelled ASR rejection is ignored rather than replacing the idle state with an error', async () => { + const old = deferred(); + const f = fixture({ transcribe: () => old.promise }); + await f.input.start(); + f.feed([1, 1, 1]); + const stopping = f.input.stop(); + await flush(); + f.input.cancel(); + const count = f.states.length; + old.reject(new Error('transcription_failed')); + await stopping; + assert.equal(f.states.length, count); + assert.deepEqual(f.states.at(-1), { state: 'idle' }); + assert.deepEqual(f.texts, []); + f.assertReleased(); +}); + +test('synchronous getUserMedia failure and asynchronous resume failure are both consumed', async () => { + const f = fixture({ + getUserMedia: () => { throw Object.assign(new Error('denied'), { name: 'NotAllowedError' }); }, + resume: () => Promise.reject(new Error('resume failed')), + }); + await f.input.start(); + await flush(); + assert.equal(f.states.at(-1).state, 'error'); + assert.equal(f.calls.length, 0); + f.assertReleased(); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/window-position.test.mjs b/sources/minimax-ai/minimax-pet/dev/window-position.test.mjs new file mode 100644 index 0000000..2fdbea1 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/window-position.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { restoreWindowPosition, fitWindowToScreen } from '../miniapp/client/window-position.mjs'; + +function frameBridge() { + let pending = []; + const calls = []; + const state = { mode: 'window', window: { x: 100, y: 100, clickThrough: true } }; + return { + calls, state, + update(patch) { calls.push(patch); return new Promise((resolve, reject) => { pending.push({ patch, resolve, reject }); }); }, + flush() { + const batch = pending; pending = []; + const patch = Object.assign({}, ...batch.map((item) => item.patch)); + // Model the public frame-merged update contract: reject the whole patch before any mutation. + if (Math.abs(patch.x ?? 0) > 10000 || Math.abs(patch.y ?? 0) > 10000) { + for (const item of batch) item.reject({ message: 'not_permitted' }); + } else { + Object.assign(state.window, patch); + for (const item of batch) item.resolve({ mode: state.mode, window: { ...state.window } }); + } + }, + }; +} + +const advance = async () => { await Promise.resolve(); await Promise.resolve(); }; + +test('initial safe interaction settles independently before an off-screen geometry rejection', async () => { + const handle = frameBridge(); + const restoring = restoreWindowPosition(handle, { x: 99999, y: 99999 }); + assert.deepEqual(handle.calls, [{ clickThrough: false }]); + handle.flush(); await advance(); + assert.equal(handle.state.window.clickThrough, false); + assert.deepEqual(handle.calls, [{ clickThrough: false }, { x: 99999, y: 99999 }]); + handle.flush(); + const result = await restoring; + assert.equal(result.error, 'not_permitted'); + assert.equal(result.interactive, true); + assert.equal(result.restored, false); + assert.equal(handle.state.window.clickThrough, false); + assert.deepEqual([handle.state.window.x, handle.state.window.y], [100, 100]); +}); + +test('valid restoration only patches interaction then x/y; it never changes mode', async () => { + const calls = []; + const state = { mode: 'window', window: { x: 42, y: 51, clickThrough: false } }; + const result = await restoreWindowPosition({ update(patch) { calls.push(patch); return state; } }, { x: 42, y: 51 }); + assert.equal(result.restored, true); + assert.equal(result.interactive, true); + assert.deepEqual(calls, [{ clickThrough: false }, { x: 42, y: 51 }]); +}); + +test('a window without saved geometry still starts interactive', async () => { + const calls = []; + const result = await restoreWindowPosition({ update(patch) { calls.push(patch); return { mode: 'window' }; } }, null); + assert.deepEqual(calls, [{ clickThrough: false }]); + assert.equal(result.interactive, true); + assert.equal(result.restored, false); +}); + +test('closing, replacement or disposal fences work before and after the safe interaction roundtrip', async () => { + const inactive = frameBridge(); + assert.equal((await restoreWindowPosition(inactive, { x: 42, y: 51 }, { isCurrent: () => false })).cancelled, true); + assert.deepEqual(inactive.calls, []); + const handle = frameBridge(); + let live = true; + const restoring = restoreWindowPosition(handle, { x: 42, y: 51 }, { isCurrent: () => live }); + live = false; + handle.flush(); + assert.equal((await restoring).cancelled, true); + assert.deepEqual(handle.calls, [{ clickThrough: false }]); +}); + +test('interaction failure never attempts geometry; uncertain geometry does not undo the accepted safe patch', async () => { + const calls = []; + const unsafe = await restoreWindowPosition({ update(patch) { calls.push(patch); throw { message: 'runtime_unavailable' }; } }, { x: 42, y: 51 }); + assert.equal(unsafe.interactive, false); + assert.deepEqual(calls, [{ clickThrough: false }]); + const uncertain = await restoreWindowPosition({ update(patch) { if ('x' in patch) throw { message: 'result_unknown' }; return { mode: 'window', window: { clickThrough: false } }; } }, { x: 42, y: 51 }); + assert.equal(uncertain.interactive, true); + assert.equal(uncertain.error, 'result_unknown'); +}); + +test('resize preparation brings a partly off-screen transparent window fully into its work area', async () => { + for (const rect of [{ x: 0, y: 23, width: 1920, height: 1057 }, { x: -1920, y: -1080, width: 1920, height: 1057 }]) { + for (const [x, y] of [[rect.x + 1864, rect.y + 1040], [rect.x - 224, rect.y - 290]]) { + let state = { mode: 'window', window: { x, y, width: 280, height: 340, clickThrough: false } }; + const calls = []; + const result = await fitWindowToScreen({ getState: async () => state, update: async (patch) => { + calls.push(patch); state = { ...state, window: { ...state.window, ...patch } }; return state; + } }, { screenRect: () => rect }); + assert.equal(result.error, null); + assert.equal(calls.length, 1); + assert.deepEqual(Object.keys(calls[0]).sort(), ['x', 'y']); + const bounds = result.state.window; + assert.ok(bounds.x >= rect.x && bounds.x + bounds.width <= rect.x + rect.width); + assert.ok(bounds.y >= rect.y && bounds.y + bounds.height <= rect.y + rect.height); + } + } +}); + +test('a fully visible window needs no move; unavailable geometry and rejected moves are explicit', async () => { + const state = { mode: 'window', window: { x: 100, y: 100, width: 280, height: 340 } }; + const handle = { getState: async () => state, update: () => assert.fail('No unnecessary move') }; + const screenRect = () => ({ x: 0, y: 0, width: 1920, height: 1080 }); + assert.equal((await fitWindowToScreen(handle, { screenRect })).state, state); + assert.equal((await fitWindowToScreen(handle)).error, 'screen_unavailable'); + assert.equal((await fitWindowToScreen(handle, { screenRect: () => ({ x: 0, y: 0, width: 100, height: 100 }) })).error, 'screen_too_small'); + const failed = await fitWindowToScreen({ ...handle, getState: async () => ({ ...state, window: { ...state.window, x: 1864 } }), + update() { throw { message: 'runtime_unavailable' }; } }, { screenRect }); + assert.equal(failed.error, 'runtime_unavailable'); +}); + +test('closing during resize preparation never sends geometry to a different surface', async () => { + let resolve, live = true; + const pending = new Promise((yes) => { resolve = yes; }); + const operation = fitWindowToScreen({ getState: () => pending, update: () => assert.fail('No stale move') }, { + screenRect: () => ({ x: 0, y: 0, width: 1920, height: 1080 }), isCurrent: () => live, + }); + live = false; resolve({ mode: 'window', window: { x: 1864, y: 100, width: 280, height: 340 } }); + assert.equal((await operation).cancelled, true); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/workbench-window.test.mjs b/sources/minimax-ai/minimax-pet/dev/workbench-window.test.mjs new file mode 100644 index 0000000..6e82794 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/workbench-window.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createWorkbenchWindow } from '../miniapp/client/workbench-window.mjs'; + +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +const clone = (value) => structuredClone(value); +function harness(options = {}) { + let state = { mode: 'window', window: { x: 1500, y: 680, width: 280, height: 340, minWidth: 240, minHeight: 280, resizable: true }, ...options.state }; + let listener, disposed = false; + const calls = []; + const api = { + getState: async () => clone(state), + update: async (patch) => { calls.push(clone(patch)); state = { ...state, window: { ...state.window, ...patch } }; listener?.(clone(state)); return clone(state); }, + onDidChangeState(callback) { listener = callback; return { dispose() { disposed = true; listener = null; } }; }, + ...options.api, + }; + const controller = createWorkbenchWindow({ windowAPI: api, screenRect: () => ({ x: 0, y: 25, width: 1920, height: 1055 }), expandedSize: { width: 420, height: 640 }, ...options }); + return { controller, api, calls, state: () => clone(state), setState(value) { state = value; }, emit(value) { state = value; listener?.(clone(value)); }, unsubscribed: () => disposed }; +} + +test('the default companion bubble keeps the compact height and only adds a small width', async () => { + const h = harness({ expandedSize: undefined }); + await h.controller.setExpanded(true); + assert.deepEqual(h.calls[0], { x: 1480, y: 680, width: 300, height: 340 }); + await h.controller.setExpanded(false); + assert.deepEqual(h.calls[1], { x: 1500, y: 680, width: 280, height: 340 }); +}); + +test('expands once at the bottom-right anchor and closes to the original compact size', async () => { + const h = harness(); + assert.deepEqual(await h.controller.setExpanded(true), { ok: true }); + assert.deepEqual(h.calls, [{ x: 1360, y: 380, width: 420, height: 640 }]); + assert.deepEqual(await h.controller.setExpanded(true), { ok: true }); + assert.equal(h.calls.length, 1, 'opening an already expanded workbench is idempotent'); + assert.deepEqual(await h.controller.setExpanded(false), { ok: true }); + assert.deepEqual(h.calls[1], { x: 1500, y: 680, width: 280, height: 340 }); + h.controller.dispose(); assert.equal(h.unsubscribed(), true); +}); + +test('clamps into a negative-origin work area instead of assuming the primary display', async () => { + const h = harness({ state: { mode: 'window', window: { x: -1900, y: -200, width: 280, height: 340, resizable: true } }, screenRect: () => ({ x: -1920, y: -300, width: 1920, height: 1080 }) }); + await h.controller.setExpanded(true); + assert.deepEqual(h.calls[0], { x: -1920, y: -300, width: 420, height: 640 }); +}); + +test('closing after an expanded drag retains the new bottom-right position', async () => { + const h = harness(); await h.controller.setExpanded(true); + h.setState({ mode: 'window', window: { ...h.state().window, x: 100, y: 100 } }); + await h.controller.setExpanded(false); + assert.deepEqual(h.calls[1], { x: 240, y: 400, width: 280, height: 340 }); +}); + +test('small screens bound dimensions and declared minimums are respected', async () => { + const h = harness({ screenRect: () => ({ x: 0, y: 0, width: 400, height: 600 }) }); + await h.controller.setExpanded(true); + assert.deepEqual(h.calls[0], { x: 0, y: 0, width: 400, height: 600 }); + const tooSmall = harness({ screenRect: () => ({ x: 0, y: 0, width: 200, height: 600 }) }); + assert.deepEqual(await tooSmall.controller.setExpanded(true), { ok: false, code: 'screen_too_small' }); + assert.deepEqual(tooSmall.calls, []); +}); + +test('unknown work area makes no geometry change and a failed opening does not capture a compact size', async () => { + let rect; + const h = harness({ screenRect: () => rect }); + assert.deepEqual(await h.controller.setExpanded(true), { ok: false, code: 'screen_unavailable' }); + assert.deepEqual(h.calls, []); + h.setState({ mode: 'window', window: { ...h.state().window, width: 300, height: 360 } }); + rect = { x: 0, y: 0, width: 1920, height: 1080 }; + await h.controller.setExpanded(true); await h.controller.setExpanded(false); + assert.equal(h.calls[1].width, 300); assert.equal(h.calls[1].height, 360); +}); + +test('open then close before a state read settles drops the stale open', async () => { + const read = deferred(); let reads = 0; + const h = harness({ api: { getState() { reads += 1; return reads === 1 ? read.promise : Promise.resolve(h.state()); } } }); + const open = h.controller.setExpanded(true); await settle(); + const close = h.controller.setExpanded(false); + read.resolve(h.state()); + assert.deepEqual(await open, { ok: false, code: 'superseded' }); + assert.deepEqual(await close, { ok: true }); assert.deepEqual(h.calls, []); +}); + +test('open already committed in flight is serialized before the latest close', async () => { + const update = deferred(); + const h = harness({ api: { update(patch) { h.calls.push(clone(patch)); if (h.calls.length === 1) return update.promise; h.setState({ mode: 'window', window: { ...h.state().window, ...patch } }); return Promise.resolve(h.state()); } } }); + const open = h.controller.setExpanded(true); await settle(); + const close = h.controller.setExpanded(false); await settle(); + assert.equal(h.calls.length, 1, 'second geometry request waits for the first'); + h.setState({ mode: 'window', window: { ...h.state().window, ...h.calls[0] } }); update.resolve(h.state()); + assert.deepEqual(await open, { ok: false, code: 'superseded' }); + assert.deepEqual(await close, { ok: true }); + assert.deepEqual(h.calls[1], { x: 1500, y: 680, width: 280, height: 340 }); +}); + +test('multiple queued intents apply only the latest requested geometry', async () => { + const h = harness(); + const results = await Promise.all([h.controller.setExpanded(true), h.controller.setExpanded(false), h.controller.setExpanded(true)]); + assert.deepEqual(results, [{ ok: false, code: 'superseded' }, { ok: false, code: 'superseded' }, { ok: true }]); + assert.equal(h.calls.length, 1); +}); + +test('inline event or external lifetime invalidation fences a pending state read', async () => { + for (const cause of ['inline', 'current', 'dispose']) { + const read = deferred(); let current = true; + const h = harness({ isCurrent: () => current, api: { getState: () => read.promise } }); + const open = h.controller.setExpanded(true); await settle(); + if (cause === 'inline') h.emit({ mode: 'inline' }); + if (cause === 'current') current = false; + if (cause === 'dispose') h.controller.dispose(); + read.resolve({ mode: 'window', window: { x: 10, y: 10, width: 280, height: 340 } }); + assert.deepEqual(await open, { ok: false, code: 'cancelled' }, cause); assert.deepEqual(h.calls, [], cause); + } +}); + +test('late update resolution after inline or disposal never causes a second geometry patch', async () => { + for (const cause of ['inline', 'dispose']) { + const update = deferred(); + const h = harness({ api: { update(patch) { h.calls.push(clone(patch)); return update.promise; } } }); + const open = h.controller.setExpanded(true); await settle(); + const close = h.controller.setExpanded(false); + if (cause === 'inline') h.emit({ mode: 'inline' }); else h.controller.dispose(); + update.resolve({ mode: 'window', window: { x: 0, y: 0, width: 420, height: 640 } }); + assert.deepEqual(await open, { ok: false, code: 'cancelled' }); + assert.deepEqual(await close, { ok: false, code: 'cancelled' }); assert.equal(h.calls.length, 1); + } +}); + +test('mode changes between expansion cycles reset the saved compact dimensions', async () => { + const h = harness(); await h.controller.setExpanded(true); + h.emit({ mode: 'inline' }); + h.emit({ mode: 'window', window: { x: 100, y: 100, width: 320, height: 380, resizable: true } }); + await h.controller.setExpanded(true); await h.controller.setExpanded(false); + assert.equal(h.calls.at(-1).width, 320); assert.equal(h.calls.at(-1).height, 380); +}); + +test('read and resize failures are explicit, preserve state and permit a deliberate retry', async () => { + const h = harness(); const original = h.state(); + h.api.getState = async () => { throw new Error('runtime_unavailable'); }; + assert.deepEqual(await h.controller.setExpanded(true), { ok: false, code: 'runtime_unavailable' }); + h.api.getState = async () => h.state(); + const update = h.api.update; + h.api.update = async () => { throw { code: 'not_permitted' }; }; + assert.deepEqual(await h.controller.setExpanded(true), { ok: false, code: 'not_permitted' }); + assert.deepEqual(h.state(), original); assert.deepEqual(h.calls, []); + h.api.update = update; + await h.controller.setExpanded(true); + h.api.update = async () => { throw new Error('busy'); }; + assert.deepEqual(await h.controller.setExpanded(false), { ok: false, code: 'busy' }); + assert.equal(h.state().window.width, 420); + h.api.update = update; assert.deepEqual(await h.controller.setExpanded(false), { ok: true }); + assert.equal(h.state().window.width, 280); +}); + +test('unavailable, inline and non-resizable handles fail without writing geometry', async () => { + assert.deepEqual(await createWorkbenchWindow().setExpanded(true), { ok: false, code: 'unsupported' }); + const inline = harness({ state: { mode: 'inline' } }); + assert.deepEqual(await inline.controller.setExpanded(true), { ok: false, code: 'inline_mode' }); + const fixed = harness({ state: { mode: 'window', window: { x: 0, y: 0, width: 280, height: 340, resizable: false } } }); + assert.deepEqual(await fixed.controller.setExpanded(true), { ok: false, code: 'not_permitted' }); + assert.deepEqual(fixed.calls, []); assert.deepEqual(inline.calls, []); +}); diff --git a/sources/minimax-ai/minimax-pet/icon.png b/sources/minimax-ai/minimax-pet/icon.png new file mode 100644 index 0000000..46ffdfe Binary files /dev/null and b/sources/minimax-ai/minimax-pet/icon.png differ diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/activity-reminders.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/activity-reminders.mjs new file mode 100644 index 0000000..cfdb719 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/activity-reminders.mjs @@ -0,0 +1,111 @@ +// Page-local reminders are independent of task facts and Host read/unread state. +const HOUR_MS = 60 * 60 * 1000; +const WEEK_MS = 7 * 24 * HOUR_MS; +const STATUSES = new Set(['idle', 'running', 'error', 'aborted']); +const compareId = (a, b) => a < b ? -1 : a > b ? 1 : 0; + +function identity(row) { + let parts; + try { parts = JSON.parse(row.animationKey); } catch { /* Older adapters need not expose an animation key. */ } + const valid = Array.isArray(parts) && parts.length >= 5 && parts[1] === row.sessionId; + return { + epoch: valid ? parts[0] : null, + turnId: valid && typeof parts[2] === 'string' && parts[2] ? parts[2] : null, + revision: Number.isSafeInteger(row.activityRevision) ? row.activityRevision : valid && Number.isSafeInteger(parts[3]) ? parts[3] : 0, + }; +} + +function normalize(row) { + if (!row || typeof row !== 'object' || typeof row.sessionId !== 'string' || !row.sessionId || !STATUSES.has(row.status) || !Number.isSafeInteger(row.updatedAtMs) || row.updatedAtMs < 0) return null; + const displayStatus = row.liveCompleted === true && row.status === 'idle' ? 'completed' : row.status; + return { ...row, displayStatus }; +} + +export function createReminderStore({ now = Date.now } = {}) { + const history = new Map(); + const dismissed = new Set(); + const muted = new Set(); + let visible = new Map(); + let sequence = 0; + + function newRound(previous, row, next) { + if (!previous) return true; + // A known new turn is the authoritative boundary. Revisions are local to the + // observer connection, so comparing them alone would replay after reconnect. + if (next.turnId && previous.turnId) return next.turnId !== previous.turnId; + if (row.displayStatus === 'running' && previous.status !== 'running') return true; + // A running snapshot can acquire its turn ID when the observer catches up. + // Treat that as the same round rather than reviving a dismissed reminder. + if (next.turnId) return false; + if (next.epoch !== previous.epoch) return false; + // A snapshot-only error has no turn ID. On a stable connection a later + // persisted timestamp is evidence of new activity; repeated 5s snapshots are + // identical. Across reconnect this is ambiguous and deliberately suppressed. + return row.displayStatus === 'error' && previous.status === 'error' + && !previous.turnId && row.updatedAtMs > previous.updatedAtMs; + } + + function getState() { + const timestamp = now(); + const items = []; + for (const [id, value] of visible) { + if (muted.has(id) || dismissed.has(value.reminderKey)) continue; + if (!['running', 'error', 'completed'].includes(value.row.displayStatus)) continue; + const expiresAtMs = value.row.displayStatus === 'running' ? null : value.row.updatedAtMs + (value.row.displayStatus === 'error' ? HOUR_MS : WEEK_MS); + if (expiresAtMs !== null && expiresAtMs <= timestamp) continue; + items.push({ ...value.row, reminderKey: value.reminderKey, expiresAtMs }); + } + items.sort((a, b) => Number(b.needsAnswer === true) - Number(a.needsAnswer === true) || b.updatedAtMs - a.updatedAtMs || compareId(a.sessionId, b.sessionId)); + const primary = items[0] ? { ...items[0] } : null; + return { + items, + primary, + // Status changes within a round can change pose; epoch/refresh changes + // cannot. Removing the primary has one stable idle animation identity. + animationKey: primary ? JSON.stringify([primary.reminderKey, primary.displayStatus, primary.needsAnswer === true]) : JSON.stringify(['idle']), + mutedIds: [...muted].sort(compareId), + }; + } + + return { + update(sessions) { + const nextVisible = new Map(); + for (const raw of Array.isArray(sessions) ? sessions : []) { + const row = normalize(raw); + if (!row) continue; + const next = identity(row); + const previous = history.get(row.sessionId); + const fresh = newRound(previous, row, next); + const reminderKey = fresh ? JSON.stringify([row.sessionId, ++sequence]) : previous.reminderKey; + const value = { + ...next, + // Keep a known turn across snapshot-only reconnects. Losing it would + // let a late observation of the same turn bypass a local dismissal. + turnId: next.turnId || (!fresh ? previous?.turnId : null), + status: row.displayStatus, + updatedAtMs: row.updatedAtMs, + reminderKey, + }; + history.set(row.sessionId, value); + nextVisible.set(row.sessionId, { row, reminderKey }); + } + visible = nextVisible; + return getState(); + }, + getState, + dismiss(sessionId) { + const current = visible.get(sessionId); + if (current) dismissed.add(current.reminderKey); + return getState(); + }, + dismissAll() { + for (const item of getState().items) dismissed.add(item.reminderKey); + return getState(); + }, + mute(sessionId) { + if (typeof sessionId === 'string' && sessionId) muted.add(sessionId); + return getState(); + }, + unmute(sessionId) { muted.delete(sessionId); return getState(); }, + }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/activity.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/activity.mjs new file mode 100644 index 0000000..53c91ef --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/activity.mjs @@ -0,0 +1,302 @@ +// Public Page Agent adapter. No Host activity projection, private IPC or inferred asks/unread state. +export const STATUS_ANIM = Object.freeze({ idle: 'idle', running: 'running', error: 'failed', aborted: 'idle', completed: 'review' }); +const RAW_STATUSES = new Set(['idle', 'running', 'error', 'aborted']); +const PRIORITY = ['error', 'completed', 'running', 'aborted', 'idle']; +const codeOf = (error) => error?.code || error?.message || 'runtime_unavailable'; +const invalid = () => Object.assign(new Error('invalid_request'), { code: 'invalid_request' }); +const displayStatus = (row) => row.liveCompleted ? 'completed' : row.status; +const emptyCounts = () => ({ total: 0, idle: 0, running: 0, error: 0, aborted: 0, completed: 0 }); +const idOf = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null; + +export function normalizeSession(raw, { detail = false } = {}) { + if (!raw || typeof raw !== 'object' || !idOf(raw.sessionId) || !RAW_STATUSES.has(raw.status) || !Number.isSafeInteger(raw.updatedAtMs) || raw.updatedAtMs < 0 || (raw.title !== undefined && typeof raw.title !== 'string')) throw invalid(); + const session = { sessionId: raw.sessionId, title: (raw.title || '未命名对话').slice(0, 1000), status: raw.status, updatedAtMs: raw.updatedAtMs }; + if (detail) { + if (typeof raw.error?.message === 'string') session.error = raw.error.message; + if (typeof raw.lastAssistantText === 'string') session.lastAssistantText = raw.lastAssistantText; + } + return session; +} + +export function createActivitySource({ agent, onChange, pollIntervalMs = 5000, now = Date.now, makeRequestId = () => `pet-observe-${globalThis.crypto.randomUUID()}` }) { + let generation = 0; + let sequence = 0; + let detailSequence = 0; + let inFlight = null; + let observer = null; + let started = false; + let disposed = false; + let timer = null; + let desiredId = null; + let rows = new Map(); + const activity = new Map(); + const removedAt = new Map(); + let snapshotIds = new Set(); + let state = { availability: 'connecting', sessions: [], originSession: null, selectedSessionId: null, detail: null, detailLoading: false, detailError: null, error: null }; + const releaseWaiters = new Set(); + const current = (epoch) => !disposed && generation === epoch; + const eventSequence = (id) => activity.get(id)?.eventSequence ?? 0; + const order = (a, b) => b.updatedAtMs - a.updatedAtMs || eventSequence(b.sessionId) - eventSequence(a.sessionId) || a.sessionId.localeCompare(b.sessionId); + + function meta(id) { + if (!activity.has(id)) activity.set(id, { revision: 0, eventSequence: 0, turnId: null, outcome: null, retiredTurns: [], retainOutsideList: false }); + return activity.get(id); + } + function withIdentity(row) { + const value = meta(row.sessionId); + return { ...row, observedTurnId: value.turnId, activityRevision: value.revision, animationKey: JSON.stringify([generation, row.sessionId, value.turnId, value.revision, displayStatus(row)]) }; + } + function aggregate(sessions) { + const counts = emptyCounts(); + for (const row of sessions) { counts.total++; counts[displayStatus(row)]++; } + const status = state.availability === 'ready' ? PRIORITY.find((name) => counts[name] > 0) ?? 'idle' : null; + const primarySession = status ? sessions.find((row) => displayStatus(row) === status) ?? null : null; + return { status, primarySession, counts, animationKey: primarySession?.animationKey ?? JSON.stringify([generation, status]) }; + } + function emit(patch) { + if (disposed) return; + state = { ...state, ...patch, sessions: [...rows.values()].sort(order).map(withIdentity) }; + const combined = aggregate(state.sessions); + onChange({ ...state, sessions: state.sessions.map((s) => ({ ...s })), originSession: state.originSession ? { ...state.originSession } : null, detail: state.detail ? { ...state.detail } : null, aggregate: { ...combined, counts: { ...combined.counts }, primarySession: combined.primarySession ? { ...combined.primarySession } : null } }); + } + function advanceGeneration() { + generation++; + for (const stop of [...releaseWaiters]) stop(); + return generation; + } + function cancelObserver() { + const previous = observer; + if (previous && (!previous.cancelled || previous.cancelFailed)) { + previous.cancelled = true; + previous.cancelFailed = false; + try { Promise.resolve(agent.cancel(previous.requestId)).catch(() => { previous.cancelFailed = true; }); } + catch { previous.cancelFailed = true; /* Keep its slot occupied until observe settles. */ } + } + return previous; + } + function waitForObserver(previous, epoch) { + return new Promise((resolve) => { + const finish = (released) => { releaseWaiters.delete(stop); resolve(released && current(epoch)); }; + const stop = () => finish(false); + releaseWaiters.add(stop); + previous.done.then(() => finish(true)); + }); + } + function clear(availability, error = null) { + rows.clear(); activity.clear(); removedAt.clear(); snapshotIds.clear(); detailSequence++; + emit({ availability, originSession: null, selectedSessionId: null, detail: null, detailLoading: false, detailError: null, error }); + } + function fail(error, epoch) { + if (!current(epoch)) return; + advanceGeneration(); + cancelObserver(); + clear(codeOf(error) === 'not_permitted' ? 'not_permitted' : 'unavailable', codeOf(error)); + } + function removeRow(id) { rows.delete(id); activity.delete(id); } + function mergeSnapshot(row) { + const previous = rows.get(row.sessionId); + const liveCompleted = row.status === 'idle' && previous?.liveCompleted === true; + if (previous && (previous.status !== row.status || previous.liveCompleted !== liveCompleted)) meta(row.sessionId).revision++; + return { ...row, liveCompleted }; + } + async function readDetail(epoch = generation) { + const id = state.selectedSessionId; + const ticket = ++detailSequence; + if (!id || !current(epoch)) return; + // A background refresh of the same visible task must not replace its long + // reply with a short loading placeholder, which collapses native scrollTop. + // Selection changes and visibility/error paths still clear the old detail. + const previous = rows.has(id) && state.detail?.sessionId === id ? state.detail : null; + emit({ detail: previous, detailLoading: true, detailError: null }); + try { + const raw = await agent.session(id); + if (!current(epoch) || ticket !== detailSequence || state.selectedSessionId !== id) return; + if (!raw) throw Object.assign(new Error('session_not_found'), { code: 'session_not_found' }); + const detail = normalizeSession(raw, { detail: true }); + if (detail.sessionId !== id) throw invalid(); + const row = rows.get(id); + if (!row) return; + emit({ detail: { ...detail, title: row.title, status: row.status, updatedAtMs: row.updatedAtMs }, detailLoading: false, detailError: null }); + } catch (error) { + if (!current(epoch) || ticket !== detailSequence) return; + if (codeOf(error) === 'session_not_found') { + removeRow(id); + removedAt.set(id, ++sequence); + if (desiredId === id) desiredId = null; + emit({ selectedSessionId: null, detail: null, detailLoading: false, detailError: 'session_not_found', ...(state.originSession?.sessionId === id ? { originSession: null } : {}) }); + } else if (['not_permitted', 'runtime_unavailable'].includes(codeOf(error))) fail(error, epoch); + else emit({ detail: null, detailLoading: false, detailError: codeOf(error) }); + } + } + function onEvent(event, epoch) { + if (!current(epoch) || !event || !idOf(event.sessionId)) return; + if (!['turn.started', 'turn.finished'].includes(event.type)) return; // Never retain message text from observer events. + if (event.type === 'turn.finished' && !['finished', 'error', 'aborted'].includes(event.status)) return; + const value = meta(event.sessionId); + const turnId = idOf(event.turnId); + const outcome = event.type === 'turn.started' ? 'running' : event.status; + if (turnId && value.retiredTurns.includes(turnId)) return; + if (turnId && value.turnId === turnId && value.outcome && value.outcome !== 'running') return; + // A delayed terminal event from another turn cannot finish the current running turn. + if (event.type === 'turn.finished' && turnId && value.turnId && value.turnId !== turnId && value.outcome === 'running') return; + if (value.turnId === turnId && value.outcome === outcome) return; + if (turnId !== value.turnId && value.turnId) { + value.retiredTurns.push(value.turnId); + if (value.retiredTurns.length > 8) value.retiredTurns.shift(); + } + value.turnId = turnId; + value.outcome = outcome; + value.revision++; + value.eventSequence = ++sequence; + value.retainOutsideList = true; + removedAt.delete(event.sessionId); + const previous = rows.get(event.sessionId); + const status = outcome === 'finished' ? 'idle' : outcome; + rows.set(event.sessionId, { sessionId: event.sessionId, title: typeof event.title === 'string' && event.title ? event.title.slice(0, 1000) : previous?.title || '未命名对话', status, updatedAtMs: now(), liveCompleted: outcome === 'finished' }); + emit({}); + if (state.selectedSessionId === event.sessionId) void readDetail(epoch); + } + function startObserver(epoch) { + const next = { requestId: makeRequestId(), cancelled: false, done: null }; + observer = next; + let subscription; + try { subscription = agent.observe({ all: true, requestId: next.requestId }, { onEvent: (event) => onEvent(event, epoch) }); } + catch (error) { if (observer === next) observer = null; fail(error, epoch); return false; } + const ended = (error) => { + if (observer === next) observer = null; + if (!next.cancelled) fail(error, epoch); + }; + next.done = Promise.resolve(subscription).then( + () => ended({ code: 'runtime_unavailable' }), + (error) => ended(error), + ); + return true; + } + // The SDK list is only the latest 50. Revalidate previously observed off-list roots with + // session(id), which checks current visibility/deletion. Never retain its reply text here. + async function readOutsideList(ids, epoch) { + const results = new Map(); + let cursor = 0; + async function worker() { + while (current(epoch) && cursor < ids.length) { + const id = ids[cursor++]; + const before = eventSequence(id); + try { + const raw = await agent.session(id); + if (!current(epoch)) return; + if (!raw) { results.set(id, { row: null, before }); continue; } + const row = normalizeSession(raw); + if (row.sessionId !== id) throw invalid(); + results.set(id, { row, before }); + } catch (error) { + if (!current(epoch)) return; + if (codeOf(error) === 'session_not_found') results.set(id, { row: null, before }); + else throw error; + } + } + } + await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker)); + return results; + } + async function readSnapshot(epoch) { + const before = sequence; + try { + const [result, originRaw] = await Promise.all([agent.sessions(), agent.session().catch((error) => { + if (codeOf(error) === 'session_not_found') return null; + throw error; + })]); + if (!current(epoch)) return; + if (!result || !Array.isArray(result.sessions)) throw invalid(); + const incoming = result.sessions.map((raw) => normalizeSession(raw)); + if (new Set(incoming.map((s) => s.sessionId)).size !== incoming.length) throw invalid(); + const origin = originRaw ? normalizeSession(originRaw) : null; + if (origin && !incoming.some((s) => s.sessionId === origin.sessionId)) incoming.push(origin); + const incomingIds = new Set(incoming.map((row) => row.sessionId)); + const outsideIds = [...rows.keys()].filter((id) => !incomingIds.has(id) && activity.get(id)?.retainOutsideList); + const outside = await readOutsideList(outsideIds, epoch); + if (!current(epoch)) return; + const next = new Map(); + for (const row of incoming) { + if ((removedAt.get(row.sessionId) ?? 0) > before) continue; + const previous = rows.get(row.sessionId); + next.set(row.sessionId, eventSequence(row.sessionId) > before && previous ? previous : mergeSnapshot(row)); + } + for (const [id, checked] of outside) { + if (!rows.has(id)) continue; + if (eventSequence(id) > checked.before) next.set(id, rows.get(id)); + else if (checked.row && activity.get(id)?.retainOutsideList) next.set(id, mergeSnapshot(checked.row)); + } + // Events that arrived after the list was requested may introduce entirely new roots. + for (const [id, row] of rows) if (eventSequence(id) > before && !incomingIds.has(id) && !outside.has(id)) next.set(id, row); + rows = next; + snapshotIds = incomingIds; + for (const id of activity.keys()) if (!rows.has(id)) activity.delete(id); + for (const [id, serial] of removedAt) if (serial <= before) removedAt.delete(id); + if (desiredId && !rows.has(desiredId)) desiredId = null; + const selected = desiredId ?? (origin && rows.has(origin.sessionId) ? origin.sessionId : null); + const matchingDetail = selected && state.detail?.sessionId === selected; + emit({ availability: 'ready', originSession: origin && rows.has(origin.sessionId) ? rows.get(origin.sessionId) : null, selectedSessionId: selected, error: null, + ...(!matchingDetail ? { detail: null, detailLoading: false, detailError: null } : {}) }); + if (selected) void readDetail(epoch); + else { detailSequence++; emit({ detail: null, detailLoading: false, detailError: null }); } + } catch (error) { fail(error, epoch); } + } + function refresh() { + if (disposed) return Promise.resolve(); + if (!agent || ['sessions', 'session', 'observe', 'cancel'].some((name) => typeof agent[name] !== 'function')) { + advanceGeneration(); cancelObserver(); clear('unavailable', 'unsupported'); return Promise.resolve(); + } + if (inFlight?.epoch === generation) { + // A later refresh can retry a failed cancel, while preserving the release barrier. + if (observer?.cancelled && observer.cancelFailed) cancelObserver(); + return inFlight.promise; + } + const reconnect = state.availability !== 'ready'; + let previous = null; + if (reconnect) { + advanceGeneration(); + previous = cancelObserver(); + clear('connecting'); + } + const epoch = generation; + const operation = { epoch, promise: null }; + operation.promise = (async () => { + // cancel() acknowledges a request, not release of the Host observer slot. + // Admit the global observer before publishing ready to detailed followers. + if (previous && !await waitForObserver(previous, epoch)) return; + if (!current(epoch) || (reconnect && !startObserver(epoch))) return; + // A synchronously failing observer must not be followed by a reviving snapshot. + if (!observer) return; + await readSnapshot(epoch); + })().finally(() => { if (inFlight === operation) inFlight = null; }); + inFlight = operation; + return operation.promise; + } + return { + start() { if (started || disposed) return inFlight?.promise ?? Promise.resolve(); started = true; emit({}); if (pollIntervalMs > 0) timer = setInterval(() => { void refresh(); }, pollIntervalMs); return refresh(); }, + refresh, + selectSession(id) { + if (state.availability !== 'ready' || !rows.has(id)) throw Object.assign(new Error('session_not_found'), { code: 'session_not_found' }); + desiredId = id; + emit({ selectedSessionId: id, detail: null, detailLoading: false, detailError: null }); + return readDetail(); + }, + clearCompleted() { + if (disposed) return; + for (const [id, row] of rows) { + if (!row.liveCompleted) continue; + const value = meta(id); + value.revision++; + value.retainOutsideList = false; + if (snapshotIds.has(id)) rows.set(id, { ...row, liveCompleted: false }); + else removeRow(id); + } + if (state.selectedSessionId && !rows.has(state.selectedSessionId)) { + desiredId = null; + detailSequence++; + emit({ selectedSessionId: null, detail: null, detailLoading: false, detailError: null }); + } else emit({}); + }, + dispose() { if (disposed) return; disposed = true; advanceGeneration(); detailSequence++; clearInterval(timer); cancelObserver(); rows.clear(); activity.clear(); removedAt.clear(); snapshotIds.clear(); }, + }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/ask-observer.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/ask-observer.mjs new file mode 100644 index 0000000..a4e7747 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/ask-observer.mjs @@ -0,0 +1,168 @@ +// Public single-session observers supply live tool lifecycle; all:true supplies none. +// This intentionally cannot recover an ask raised before this tail-only subscription. +let requestSequence = 0; +const idOf = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null; +const turnOf = (row) => idOf(row?.observedTurnId); + +export function createAskObserver({ agent, onChange, maxSubscriptions = 3, askDelayMs = 120 }) { + // The fourth Host observer slot belongs to the global lifecycle subscription. + const limit = Number.isSafeInteger(maxSubscriptions) ? Math.min(3, Math.max(0, maxSubscriptions)) : 3; + const delay = Number.isFinite(askDelayMs) ? Math.max(0, askDelayMs) : 120; + const watches = new Map(), retiring = new Set(), failed = new Map(); + let disposed = false, ready = false, running = new Map(), selectedId = null; + let queued = false, emitted = null; + + function getState() { + return { + needsAnswerIds: [...watches.values()].filter((watch) => watch.needsAnswer).map((watch) => watch.id), + watchingIds: [...watches.keys()], + runningCount: running.size, + }; + } + function notify() { + if (disposed || queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + if (disposed) return; + const state = getState(), key = JSON.stringify(state); + if (key === emitted) return; + emitted = key; + onChange?.(state); + }); + } + const current = (watch) => !disposed && ready && watches.get(watch.id) === watch; + function clearAsk(watch) { + clearTimeout(watch.timer); watch.timer = null; + watch.askCount = 0; watch.needsAnswer = false; + } + function enterTurn(watch, turnId) { + if (watch.turnId === turnId) return; + if (watch.turnId) { + watch.retiredTurns.add(watch.turnId); + if (watch.retiredTurns.size > 16) watch.retiredTurns.delete(watch.retiredTurns.values().next().value); + } + clearAsk(watch); watch.turnId = turnId; watch.terminal = false; + } + function retire(watch) { + watches.delete(watch.id); clearAsk(watch); retiring.add(watch); + // Admission of a replacement waits for the old observe promise to settle. + // cancel() is best effort and its acknowledgement need not free the Host slot yet. + try { Promise.resolve(agent.cancel(watch.requestId)).catch(() => {}); } catch { /* Keep the occupied slot fenced. */ } + } + function failedFor(row) { + const previous = failed.get(row.sessionId); + if (!previous) return false; + const turnId = turnOf(row); + return !turnId || previous.turnIds.has(turnId); + } + function reconcile() { + if (disposed || !ready) return; + const desired = []; + const add = (id) => { + const row = running.get(id); + if (row && desired.length < limit && !desired.includes(id) && !failedFor(row)) desired.push(id); + }; + // Never evict a question just because another session became selected. + for (const watch of watches.values()) if (watch.askCount > 0) add(watch.id); + add(selectedId); + for (const id of watches.keys()) add(id); + const recent = [...running.values()].sort((a, b) => (b.updatedAtMs || 0) - (a.updatedAtMs || 0) || a.sessionId.localeCompare(b.sessionId)); + for (const row of recent) add(row.sessionId); + for (const watch of [...watches.values()]) if (!desired.includes(watch.id)) retire(watch); + for (const id of desired) { + if (watches.has(id) || watches.size + retiring.size >= limit) continue; + start(running.get(id)); + } + notify(); + } + function start(row) { + const watch = { + id: row.sessionId, requestId: `pet-ask-observer-${Date.now().toString(36)}-${++requestSequence}`, + turnId: turnOf(row), retiredTurns: new Set(), terminal: false, + askCount: 0, needsAnswer: false, timer: null, + }; + watches.set(watch.id, watch); + const onEvent = (event) => { + if (!current(watch) || event?.sessionId !== watch.id) return; + const turnId = idOf(event.turnId); + if (!turnId || watch.retiredTurns.has(turnId)) return; + if (event.type === 'turn.started') { + if (watch.turnId === turnId && watch.terminal) return; + enterTurn(watch, turnId); notify(); return; + } + if (!['tool.started', 'tool.finished', 'turn.finished'].includes(event.type)) return; + if (!watch.turnId) enterTurn(watch, turnId); + if (watch.turnId !== turnId || watch.terminal) return; + if (event.type === 'turn.finished') { + watch.terminal = true; clearAsk(watch); notify(); return; + } + if (event.name !== 'ask_user') return; + if (event.type === 'tool.started') { + watch.askCount++; + if (watch.askCount === 1) { + watch.timer = setTimeout(() => { + watch.timer = null; + if (!current(watch) || !watch.askCount || watch.terminal) return; + watch.needsAnswer = true; notify(); + }, delay); + } + } else { + watch.askCount = Math.max(0, watch.askCount - 1); + if (!watch.askCount) clearAsk(watch); + } + notify(); + }; + let operation; + try { operation = agent.observe({ sessionId: watch.id, requestId: watch.requestId }, { onEvent }); } + catch (error) { operation = Promise.reject(error); } + void Promise.resolve(operation).then(() => ended(watch), () => ended(watch)); + } + function ended(watch) { + const wasCurrent = watches.get(watch.id) === watch; + retiring.delete(watch); + if (wasCurrent) { + watches.delete(watch.id); clearAsk(watch); + // Do not retry a failing stream on every 5s snapshot of the same turn. + const turnIds = new Set([watch.turnId, turnOf(running.get(watch.id))].filter(Boolean)); + failed.set(watch.id, { turnIds }); + } + if (!disposed) reconcile(); + } + + return { + update(snapshot = {}) { + if (disposed) return getState(); + const nextReady = snapshot.availability === 'ready' + && typeof agent?.observe === 'function' && typeof agent?.cancel === 'function'; + selectedId = idOf(snapshot.selectedSessionId); + running = new Map(); + if (nextReady) for (const row of Array.isArray(snapshot.sessions) ? snapshot.sessions : []) { + if (idOf(row?.sessionId) && row.status === 'running') running.set(row.sessionId, row); + } + if (!nextReady) { + ready = false; failed.clear(); + for (const watch of [...watches.values()]) retire(watch); + notify(); return getState(); + } + ready = true; + for (const [id, entry] of failed) { + const row = running.get(id), turnId = turnOf(row); + if (!row || (turnId && !entry.turnIds.has(turnId))) failed.delete(id); + } + for (const watch of [...watches.values()]) { + const row = running.get(watch.id); + if (!row) { retire(watch); continue; } + const turnId = turnOf(row); + if (turnId && !watch.retiredTurns.has(turnId)) enterTurn(watch, turnId); + } + reconcile(); return getState(); + }, + getState, + dispose() { + if (disposed) return; + disposed = true; ready = false; running.clear(); failed.clear(); + for (const watch of [...watches.values()]) retire(watch); + }, + }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs new file mode 100644 index 0000000..29c93ed --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs @@ -0,0 +1,50 @@ +// Original-chat drafts only. The observed task never changes the Host's bound destination. +const codeOf = (error) => error?.code || error?.message || 'result_unknown'; +export function createDraftController({ message, onChange = () => {} }) { + const drafts = new Map(); + let pending = false; + let disposed = false; + let origin = null; + const draft = (entry) => { if (!drafts.has(entry)) drafts.set(entry, { text: '', revision: 0 }); return drafts.get(entry); }; + const emit = () => { if (!disposed) onChange({ pending, origin: origin ? { ...origin } : null }); }; + return { + getState: () => ({ pending, origin: origin ? { ...origin } : null }), + getDraft: (entry) => ({ ...draft(entry) }), + setOrigin(value) { origin = value ? { sessionId: value.sessionId, title: value.title } : null; emit(); }, + edit(entry, text) { const current = draft(entry); current.text = String(text); current.revision++; }, + syncText(entry, text) { const current = draft(entry); if (current.text !== text) { current.text = String(text); current.revision++; } }, + appendText(entry, text) { const current = draft(entry); current.text += String(text); current.revision++; return current.text; }, + submit(entry) { + const fail = (code) => ({ submission: null, result: Promise.resolve({ ok: false, clear: false, code, submission: null, entry }) }); + if (disposed) return fail('target_unavailable'); + if (pending) return fail('busy'); + if (typeof message?.append !== 'function') return fail('unsupported'); + const current = draft(entry); + if (!current.text.trim()) return fail('empty'); + const submission = Object.freeze({ entry, text: current.text, title: origin?.title || '打开桌宠的原属对话', editRevision: current.revision }); + pending = true; + let request; + // No await and exactly one argument: this must remain in the real submit gesture. + try { request = message.append(submission.text); } catch (error) { request = Promise.reject(error); } + emit(); + const result = Promise.resolve(request).then( + () => { const value = draft(entry); const clear = !disposed && value.revision === submission.editRevision; if (clear) { value.text = ''; value.revision++; } return { ok: true, clear, submission, entry }; }, + (error) => ({ ok: false, clear: false, code: codeOf(error), submission, entry }), + ).finally(() => { pending = false; emit(); }); + return { submission, result }; + }, + dispose() { disposed = true; origin = null; drafts.clear(); }, + }; +} +export function bindDraftEntry({ controller, entry, form, input, onPending, onResult }) { + let active = true; + const onInput = () => controller.edit(entry, input.value); + const onSubmit = (event) => { + event.preventDefault(); controller.syncText(entry, input.value); + const attempt = controller.submit(entry); + if (attempt.submission) onPending?.(attempt.submission); + void attempt.result.then((result) => { if (!active) return; if (result.clear) input.value = controller.getDraft(entry).text; onResult(result); }); + }; + input.addEventListener('input', onInput); form.addEventListener('submit', onSubmit); + return () => { active = false; input.removeEventListener('input', onInput); form.removeEventListener('submit', onSubmit); }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs new file mode 100644 index 0000000..b0eecee --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs @@ -0,0 +1,167 @@ +const STATUS = { running: '进行中', error: '异常', completed: '已完成', aborted: '已停止', idle: '空闲', needs_answer: '需要你回答' }; +const textOf = (value) => typeof value === 'string' ? value : ''; +const failureCode = (error) => textOf(error?.code) || textOf(error?.message) || textOf(error) || 'result_unknown'; +// A completed live turn returns to raw idle. Only that observed completion +// promotes idle; newer running/error/aborted facts must retain their status. +export const taskStatus = (row) => row?.needsAnswer === true ? 'needs_answer' + : row?.status === 'idle' && row.liveCompleted === true ? 'completed' + : textOf(row?.status) || textOf(row?.displayStatus); + +/** Task notifications and their complete replies stay beside the pet. */ +export function createCompactPetView({ + container, onClose, onSelectTask, +}) { + if (!container?.ownerDocument) throw new TypeError('A compact pet container is required.'); + const doc = container.ownerDocument; + const hadRootClass = container.classList.contains('compact-pet-card'); + container.classList.add('compact-pet-card'); + let disposed = false, opened = false, view = 'tasks', reminders = { items: [] }; + let allSessions = null, taskSelection = {}, detailBackView = 'tasks'; + let paintedDetailId = null; + const rows = new Map(); + const setText = (node, value) => { const next = String(value ?? ''); if (node.textContent !== next) node.textContent = next; }; + const element = (tag, className, text) => { + const node = doc.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + const callbacks = []; + const bind = (node, event, listener) => { node.addEventListener(event, listener); callbacks.push(() => node.removeEventListener(event, listener)); }; + const button = (label, className, handler) => { + const node = element('button', `compact-button ${className || ''}`.trim(), label); + node.type = 'button'; bind(node, 'click', handler); + return node; + }; + const selection = () => { + const id = textOf(taskSelection.selectedSessionId); + return { id, + session: id && taskSelection.session?.sessionId === id ? taskSelection.session : null, + detail: id && taskSelection.detail?.sessionId === id ? taskSelection.detail : null, + available: taskSelection.availability === 'ready', + }; + }; + const call = async (callback, ...args) => { + if (disposed) return { ok: false, code: 'disposed' }; + try { return await callback?.(...args); } + catch (error) { return { ok: false, code: failureCode(error) }; } + }; + + const header = element('div', 'compact-header'); + const state = element('span', 'compact-state'); state.setAttribute('role', 'status'); + const closeButton = button('×', 'compact-close', () => { + if (disposed) return; + api.close(); void call(onClose); + }); + closeButton.setAttribute('aria-label', '关闭桌宠快捷面板'); + header.append(state, closeButton); + + const tasksPane = element('div', 'compact-tasks compact-body'); + const taskList = element('ul', 'compact-task-list'); + const empty = element('li', 'compact-empty', '暂无新任务提醒'); + const tasksFooter = element('div', 'compact-footer'); + const allTasks = button('全部任务', 'compact-link', () => api.open('allTasks')); + tasksFooter.append(allTasks); tasksPane.append(taskList, tasksFooter); + + const detailPane = element('div', 'compact-detail compact-body'); + const detailTitle = element('strong', 'compact-detail-title'); + const detailStatus = element('span', 'compact-detail-status'); detailStatus.setAttribute('role', 'status'); + const detailReply = element('p', 'compact-detail-reply'); + detailReply.setAttribute('aria-label', '任务最近回复'); detailReply.tabIndex = 0; + const detailFooter = element('div', 'compact-footer'); + const back = button('返回任务', 'compact-detail-back compact-link', () => api.open(detailBackView)); + detailFooter.append(back); detailPane.append(detailTitle, detailStatus, detailReply, detailFooter); + container.append(header, tasksPane, detailPane); + + function paintTasks() { + const reminderItems = Array.isArray(reminders?.items) ? reminders.items : Array.isArray(reminders) ? reminders : []; + const items = view === 'allTasks' && Array.isArray(allSessions) ? allSessions : reminderItems; + const visible = [], seen = new Set(); + for (const item of items) { + if (!textOf(item?.sessionId) || seen.has(item.sessionId)) continue; + seen.add(item.sessionId); visible.push(item); + if (view !== 'allTasks' && visible.length === 3) break; + } + const keep = new Set(visible.map((item) => item.sessionId)); + for (const [id, row] of rows) if (!keep.has(id)) { row.node.remove(); rows.delete(id); } + if (visible.length) empty.remove(); + for (const [index, item] of visible.entries()) { + let row = rows.get(item.sessionId); + if (!row) { + row = { item, node: element('li', 'compact-task') }; + row.title = button('', 'compact-task-title', () => { void call(onSelectTask, row.item.sessionId); }); + row.status = element('span', 'compact-task-status'); + row.preview = element('p', 'compact-task-preview'); + row.node.append(row.title, row.status, row.preview); rows.set(item.sessionId, row); + } + row.item = item; + setText(row.title, textOf(item.title) || '未命名任务'); + row.title.setAttribute('aria-label', `查看任务:${textOf(item.title) || '未命名任务'}`); + setText(row.preview, item.needsAnswer === true ? '需要你回答' : textOf(item.previewText)); row.preview.hidden = !row.preview.textContent; + const status = taskStatus(item); + setText(row.status, STATUS[status] || '待查看'); row.status.dataset.status = status; + row.title.disabled = typeof onSelectTask !== 'function'; + if (taskList.children[index] !== row.node) taskList.insertBefore(row.node, taskList.children[index] || null); + } + setText(empty, view === 'allTasks' ? '暂无可见任务' : '暂无新任务提醒'); + allTasks.hidden = view === 'allTasks'; + if (!visible.length && empty.parentNode !== taskList) taskList.append(empty); + } + + function paintDetail() { + const { id, session, detail, available } = selection(); + const needsAnswer = available && session?.needsAnswer === true; + const status = taskStatus(session) || taskStatus(detail); + setText(detailTitle, textOf(session?.title) || textOf(detail?.title) || '任务回复'); + setText(detailStatus, !available ? '暂不可用' : STATUS[status] || '待查看'); + detailStatus.dataset.status = !available ? 'unavailable' : status; + let text; + if (!available) text = '任务信息暂不可用,重新连接后继续查看。'; + else if (needsAnswer) text = '需要你回答'; + // A background refresh keeps the matching reply mounted; a short loading + // placeholder would shrink its scroll range and reset a reader's position. + else if (taskSelection.detailLoading && !detail) text = '正在读取任务…'; + else if (taskSelection.detailError) text = `无法读取任务(${failureCode(taskSelection.detailError)})。`; + else if (!id) text = '选择任务后查看回复。'; + else if (!detail) text = '正在读取任务…'; + else text = (detail.status === 'error' && textOf(detail.error)) || textOf(detail.lastAssistantText) || '该任务暂时没有可读取的最后回复。'; + setText(detailReply, text); + detailPane.setAttribute('aria-busy', String(available && !needsAnswer && taskSelection.detailLoading === true)); + if (paintedDetailId !== id) { detailReply.scrollTop = 0; detailPane.scrollTop = 0; paintedDetailId = id; } + } + + function paint() { + if (disposed) return; + container.hidden = !opened; + tasksPane.hidden = !['tasks', 'allTasks'].includes(view); detailPane.hidden = view !== 'detail'; + setText(state, view === 'tasks' ? '最近任务' : view === 'allTasks' ? '全部可见任务' : '任务回复'); + paintTasks(); paintDetail(); + } + + const api = { + get opened() { return opened && !disposed; }, + get view() { return view; }, + update(next = {}) { + if (disposed) return; + reminders = next.reminders || { items: [] }; + allSessions = Array.isArray(next.allSessions) ? next.allSessions : null; + taskSelection = next.taskSelection || {}; + paint(); + }, + open(next = 'tasks') { + if (disposed || !['tasks', 'allTasks', 'detail'].includes(next)) return false; + if (next === 'detail' && ['tasks', 'allTasks'].includes(view)) detailBackView = view; + view = next; opened = true; paint(); return true; + }, + close() { if (!disposed) { opened = false; paint(); } }, + dispose() { + if (disposed) return; + disposed = true; opened = false; + for (const unbind of callbacks) unbind(); + header.remove(); tasksPane.remove(); detailPane.remove(); rows.clear(); + container.hidden = true; + if (!hadRootClass) container.classList.remove('compact-pet-card'); + }, + }; + paint(); return api; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/index.html b/sources/minimax-ai/minimax-pet/miniapp/client/index.html new file mode 100644 index 0000000..3486d6a --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/index.html @@ -0,0 +1,240 @@ + + + + + + + MiniMax Pet + + + + +
+
+
+

MiniMax Pet

+

陪伴工作,留意任务的新动静。

+
+
+ +

+
+
+ + + + + + +
+
+
+
+

动画预览

+
+ +
+

正在加载宠物…

+

+

+
+ +
+ 更多 +
+
+
+
+ +
+ + +
+ + + + +
+
+ + + + diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/notification-previews.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/notification-previews.mjs new file mode 100644 index 0000000..adf4424 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/notification-previews.mjs @@ -0,0 +1,64 @@ +// Notification copy is an optional, bounded detail read; activity facts stay in the observer. +export function createNotificationPreviews({ agent, onChange = () => {} }) { + let entries = new Map(); + let disposed = false; + let notificationPending = false; + + function getState() { + return { previews: Object.fromEntries([...entries].filter(([, entry]) => entry.text).map(([id, entry]) => [id, entry.text])) }; + } + function notify() { + if (notificationPending || disposed) return; + notificationPending = true; + queueMicrotask(() => { + notificationPending = false; + if (!disposed) onChange(getState()); + }); + } + function snippet(raw, status) { + const reply = typeof raw.lastAssistantText === 'string' ? raw.lastAssistantText : ''; + const error = typeof raw.error?.message === 'string' ? raw.error.message : ''; + // An errored turn may still expose the preceding turn's reply. Prefer its actual error. + const text = status === 'error' ? (error.trim() ? error : reply) : reply; + return text.trim() ? text.slice(0, 4000) : ''; + } + function load(id, entry) { + Promise.resolve().then(async () => { + if (disposed || entries.get(id) !== entry) return; + const raw = await agent.session(id); + if (disposed || entries.get(id) !== entry || raw?.sessionId !== id) return; + const text = snippet(raw, entry.status); + if (text && text !== entry.text) { entry.text = text; notify(); } + }).catch(() => { /* A failed optional read must not change the task's observed state. */ }); + } + function update({ availability, items } = {}) { + if (disposed) return; + const previous = getState().previews; + const next = new Map(); + const pending = []; + if (availability === 'ready' && typeof agent?.session === 'function' && Array.isArray(items)) { + for (const item of items.slice(0, 3)) { + const id = item?.sessionId; + const status = item?.displayStatus ?? (item?.status === 'error' ? 'error' : null); + if (typeof id !== 'string' || !id || id.length > 256 || !['completed', 'error'].includes(status) || next.has(id)) continue; + const key = item.reminderKey ?? item.animationKey; + // The reminder controller supplies a per-turn identity. Missing identity must not + // accidentally reuse a preceding turn's reply or turn polling into repeated reads. + if (typeof key !== 'string' || !key) continue; + const old = entries.get(id); + const entry = old?.key === key && old.status === status ? old : { key, status, text: '' }; + next.set(id, entry); + if (entry !== old) pending.push([id, entry]); + } + } + entries = next; + const current = getState().previews; + if (Object.keys(previous).length !== Object.keys(current).length || Object.keys(previous).some((id) => previous[id] !== current[id])) notify(); + for (const [id, entry] of pending) load(id, entry); + } + return { + update, + getState, + dispose() { if (disposed) return; disposed = true; entries.clear(); }, + }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs new file mode 100644 index 0000000..14b8725 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs @@ -0,0 +1,127 @@ +const TABS = ['appearance', 'tasks', 'settings']; +const FILTERS = ['recent', 'all']; +const CONNECTION = { ready: '已连接', connecting: '连接中', not_permitted: '未授权', unavailable: '连接中断' }; + +/** Owns the inline panel only; task updates never navigate or replace its content. */ +export function createPanelNavigation({ panel, beforeCloseImport = () => true, onLeaveDraft = () => {} }) { + if (!panel?.ownerDocument) throw new TypeError('A panel element is required.'); + const find = (selector) => panel.querySelector(selector); + const tabs = new Map(TABS.map((name) => [name, find(`[data-panel-tab="${name}"]`)])); + const pages = new Map(TABS.map((name) => [name, find(`[data-panel-page="${name}"]`)])); + if ([...tabs.values(), ...pages.values()].some((node) => !node)) throw new TypeError('Three panel tabs and pages are required.'); + let state = { tab: 'appearance', appearanceView: 'pets', settingsView: 'main', taskFilter: 'recent', taskDetail: false }; + let disposed = false; + let taskReturnFocus = null, taskReturnId = null; + const listeners = []; + const listen = (element, event, callback) => { + if (!element) return; + element.addEventListener(event, callback); + listeners.push(() => element.removeEventListener(event, callback)); + }; + const hidden = (selector, value) => { const node = find(selector); if (node) node.hidden = value; }; + const focus = (selector) => find(selector)?.focus({ preventScroll: true }); + function paint() { + for (const name of TABS) { + tabs.get(name).setAttribute('aria-selected', String(name === state.tab)); + tabs.get(name).tabIndex = name === state.tab ? 0 : -1; + pages.get(name).hidden = name !== state.tab; + } + hidden('#appearance-main', state.appearanceView !== 'pets'); + hidden('#import-panel', state.appearanceView !== 'import'); + hidden('#settings-main', state.settingsView !== 'main'); + hidden('#draft-card', state.settingsView !== 'draft'); + hidden('#recent-tasks', state.taskFilter !== 'recent'); + hidden('#all-sessions', state.taskFilter !== 'all'); + for (const filter of FILTERS) find(`#task-filter-${filter}`)?.setAttribute('aria-pressed', String(state.taskFilter === filter)); + panel.dataset.taskDetail = state.taskDetail ? 'open' : 'closed'; + } + function leaveDraft() { + if (state.settingsView !== 'draft') return; + state.settingsView = 'main'; + onLeaveDraft(); + } + function showTab(tab, { focus: moveFocus = false } = {}) { + if (disposed || !TABS.includes(tab)) return false; + if (tab !== 'settings') leaveDraft(); + state.tab = tab; + paint(); + if (moveFocus) tabs.get(tab).focus({ preventScroll: true }); + return true; + } + const api = { + showTab, + showImport() { + if (disposed) return false; + showTab('appearance'); state.appearanceView = 'import'; paint(); focus('#import-pick'); return true; + }, + closeImport() { + if (disposed || state.appearanceView !== 'import' || beforeCloseImport() === false) return false; + state.appearanceView = 'pets'; paint(); focus('#open-import'); return true; + }, + completeImport() { + if (disposed) return false; + state.appearanceView = 'pets'; paint(); return true; + }, + showDraft() { + if (disposed) return false; + showTab('settings'); state.settingsView = 'draft'; paint(); focus('#talk-text'); return true; + }, + showTaskDetail() { + if (disposed) return false; + const active = panel.ownerDocument.activeElement; + if (find('#task-list-pane')?.contains(active)) { + taskReturnFocus = active; + taskReturnId = active.closest('[data-session-id]')?.dataset.sessionId || null; + } + showTab('tasks'); state.taskDetail = true; paint(); focus('#detail-title'); return true; + }, + showTaskList() { + if (disposed) return false; + state.taskDetail = false; paint(); + const target = taskReturnFocus?.isConnected ? taskReturnFocus : taskReturnId + ? [...panel.querySelectorAll('#task-list-pane [data-session-id]')].find((node) => node.dataset.sessionId === taskReturnId) : null; + if (target) (target.matches('button') ? target : target.querySelector('button'))?.focus({ preventScroll: true }); + else focus(`#task-filter-${state.taskFilter}`); + return true; + }, + setTaskFilter(filter) { + if (disposed || !FILTERS.includes(filter)) return false; + state.taskFilter = filter; state.taskDetail = false; paint(); return true; + }, + update({ reminderCount = 0, availability = 'unavailable' } = {}) { + if (disposed) return; + const count = Number.isSafeInteger(reminderCount) && reminderCount > 0 ? reminderCount : 0; + const badge = find('#panel-task-count'); + if (badge) { badge.hidden = count === 0; badge.textContent = count > 99 ? '99+' : String(count); } + const connection = find('#connection-status'); + if (connection) { + connection.textContent = CONNECTION[availability] || CONNECTION.unavailable; + connection.dataset.status = availability in CONNECTION ? availability : 'unavailable'; + } + }, + getState: () => ({ ...state }), + dispose() { + if (disposed) return; + disposed = true; + for (const remove of listeners.splice(0)) remove(); + }, + }; + for (const [name, tab] of tabs) { + listen(tab, 'click', () => showTab(name)); + listen(tab, 'keydown', (event) => { + const index = TABS.indexOf(name); + const next = event.key === 'ArrowRight' ? TABS[(index + 1) % TABS.length] + : event.key === 'ArrowLeft' ? TABS[(index + TABS.length - 1) % TABS.length] + : event.key === 'Home' ? TABS[0] : event.key === 'End' ? TABS.at(-1) : null; + if (next) { event.preventDefault(); showTab(next, { focus: true }); } + }); + } + listen(find('#open-import'), 'click', api.showImport); + listen(find('#import-back'), 'click', api.closeImport); + listen(find('#open-draft'), 'click', api.showDraft); + listen(find('#draft-back'), 'click', () => { if (!disposed) { leaveDraft(); paint(); focus('#open-draft'); } }); + listen(find('#task-detail-back'), 'click', api.showTaskList); + for (const filter of FILTERS) listen(find(`#task-filter-${filter}`), 'click', () => api.setTaskFilter(filter)); + paint(); + return api; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js new file mode 100644 index 0000000..a334227 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -0,0 +1,2310 @@ +// Desktop pet page: a three-tab inline panel and a compact desktop companion. +import { STATUS_ANIM, createActivitySource } from './activity.mjs'; +import { DEFAULT_PREFERENCES, loadPreferences, preferenceRequest } from './preferences.mjs'; +import { bindDraftEntry, createDraftController } from './chat-draft.mjs'; +import { createSpeechInput } from './speech-input.mjs'; +import { restoreWindowPosition, fitWindowToScreen } from './window-position.mjs'; +import { aggregateText, notificationItems, notificationText, createAnimationGate } from './pet-presentation.mjs'; +import { createReminderStore } from './activity-reminders.mjs'; +import { createCompactPetView, taskStatus } from './compact-pet-view.mjs'; +import { createWorkbenchWindow } from './workbench-window.mjs'; +import { createNotificationPreviews } from './notification-previews.mjs'; +import { createAskObserver } from './ask-observer.mjs'; +import { createPanelNavigation } from './panel-navigation.mjs'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STATUS_TEXT = { idle: '', running: '正在工作…', error: '任务出现异常', aborted: '任务已停止', completed: '任务已完成' }; +const STATUS_LABEL = { idle: '空闲', running: '工作中', error: '异常', aborted: '已停止', completed: '已完成' }; +const DEMO_ORDER = ['idle', 'running', 'completed', 'error', 'aborted']; +const SCALES = [ + { value: 0.5, label: '小' }, + { value: 0.75, label: '中' }, + { value: 1, label: '大' }, +]; +const ID_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const IMPORT_BODY_LIMIT = 12 * 1024 * 1024; +const THUMB_SCALE = 0.375; +const DRAG_THRESHOLD = 4; +const DIRECTION_HYSTERESIS = 12; +const LOOK_DEAD_ZONE = 1; + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +const $ = (id) => document.getElementById(id); +const root = document.documentElement; + +function errCode(error) { + if (error && typeof error === 'object') return error.code || error.message || 'unknown_error'; + return String(error ?? 'unknown_error'); +} +function logError(tag) { + return (error) => console.error(`[pet] ${tag}:`, errCode(error), error); +} +function setMsg(el, text, kind = 'info') { + el.textContent = text || ''; + el.dataset.kind = kind; +} +function pointInRect(rect, x, y) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} +function clamp(v, min, max) { + return Math.min(Math.max(v, min), max); +} +function basename(path) { + return String(path).split(/[\\/]/).pop(); +} +function callBridge(fn) { + // Bridge calls must start synchronously inside the gesture handler; this only normalizes throws. + try { + return Promise.resolve(fn()); + } catch (error) { + return Promise.reject(error); + } +} + +// --------------------------------------------------------------------------- +// Public Mini App bridge. Resource preview must be explicitly requested. +const params = new URLSearchParams(location.search); +const host = window.mcode?.miniapp ?? null; +const win = host?.window ?? null; +const agent = host?.agent ?? null; +const canAppend = typeof host?.message?.append === 'function'; +const explicitDemo = params.get('demo') === '1'; +const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const prefs = { ...DEFAULT_PREFERENCES }; +let preferenceWrites = Promise.resolve(); +let preferenceWriteVersion = 0; +let activitySource = null; +let disposed = false; +let panelNavigation = null; +let panelDetailId = null; + +const app = { + mode: 'standalone', // 'standalone' | 'inline' | 'window' + modeSwitching: false, + pets: [], + spriteUrls: new Map(), + pet: null, // LoadedPet from the player module + petSummary: null, + loadError: null, + selectToken: 0, + source: explicitDemo ? 'demo' : 'real', + availability: explicitDemo ? 'ready' : 'connecting', + aggregate: null, + hovered: false, + workbenchOpen: false, + workbenchView: 'tasks', + workbenchRevision: 0, + sessionsError: null, + sessions: [], + rawSessions: [], + originSession: null, + selectedSessionId: null, + detail: null, + detailLoading: false, + detailError: null, + restoringPosition: false, + awaitingPointer: false, + positionRestoreToken: 0, + positionTimer: null, + demoStatus: 'idle', + autoTimer: null, + // window mode + winPos: null, + winBounds: null, + compacting: false, + clickThrough: null, // last value we believe the Host has; null = unknown + ctRetryAt: 0, + // Forwarded-mousemove watchdog: 'unknown' | 'verified' | 'broken'. Until a forwarded move is seen + // with click-through applied, never stay click-through long enough to strand an unclickable pet. + ctForwarding: 'unknown', + ctAppliedAt: 0, + ctWatchdog: null, + ctSafeHold: false, + ctRevertedAt: 0, + ctRevertPointer: null, + ctFailures: 0, + pointer: null, + drag: null, + dragging: false, + menuOpen: false, + menuView: 'root', + menuAnchor: { x: 0, y: 0 }, + menuCloseTimer: null, + composerOpen: false, + composerFocused: false, + quickbarTimer: null, + transient: null, + transientTimer: null, + bubbleSessionId: null, + oneShot: false, + oneShotToken: 0, + looking: false, +}; + +const importState = { + petJson: null, + file: null, + blobUrl: null, + loaded: null, + player: null, + cycleTimer: null, + errors: [], + busy: false, + generation: 0, +}; + +let scaleIntent = 0; +let scaleUpdates = Promise.resolve(); + +const disposers = []; +const menuItemByButton = new WeakMap(); +function listen(target, type, listener, options) { + target.addEventListener(type, listener, options); + disposers.push(() => target.removeEventListener(type, listener, options)); +} + +const els = { + panel: $('panel'), + petName: $('pet-name'), + petDesc: $('pet-desc'), + versionBadge: $('version-badge'), + sourceBadge: $('source-badge'), + popoutBtn: $('popout-btn'), + popoutMsg: $('popout-msg'), + hostNote: $('host-note'), + loadError: $('load-error'), + panelStage: $('panel-stage'), + sprite: $('pet-sprite'), + panelStatus: $('panel-status'), + waveBtn: $('wave-btn'), + jumpBtn: $('jump-btn'), + activityNote: $('activity-note'), + taskConnection: $('task-connection'), + aggregateCounts: $('aggregate-counts'), + clearCompleted: $('clear-completed'), + speechSection: $('speech-section'), + draftSpeech: $('draft-speech'), + reminderList: $('reminder-list'), + reminderNote: $('reminder-note'), + mutedReminders: $('muted-reminders'), + mutedList: $('muted-list'), + workbench: $('workbench'), + activityCard: $('activity-card'), + detailCard: $('detail-card'), + draftCard: $('draft-card'), + demoControls: $('demo-controls'), + demoStatus: $('demo-status'), + sessionList: $('session-list'), + sessionMsg: $('session-msg'), + detailTitle: $('detail-title'), + detailStatus: $('detail-status'), + detailTime: $('detail-time'), + detailError: $('detail-error'), + detailReply: $('detail-reply'), + detailNote: $('detail-note'), + speechControls: $('speech-controls'), + speechStart: $('speech-start'), + speechStop: $('speech-stop'), + speechCancel: $('speech-cancel'), + speechMsg: $('speech-msg'), + speechPartial: $('speech-partial'), + activityRetry: $('activity-retry'), + preferenceMsg: $('preference-msg'), + talkForm: $('talk-form'), + talkText: $('talk-text'), + talkBtn: $('talk-btn'), + talkMsg: $('talk-msg'), + scaleGroup: $('scale-group'), + gallery: $('gallery'), + galleryMsg: $('gallery-msg'), + dropzone: $('dropzone'), + importPick: $('import-pick'), + importBack: $('import-back'), + importInput: $('import-input'), + importPreview: $('import-preview'), + importSprite: $('import-sprite'), + importName: $('import-name'), + importMeta: $('import-meta'), + importStateText: $('import-state'), + importErrors: $('import-errors'), + importCancel: $('import-cancel'), + importConfirm: $('import-confirm'), + importMsg: $('import-msg'), + desk: $('desk'), + deskPetSlot: $('desk-pet-slot'), + bubble: $('bubble'), + bubbleTitle: $('bubble-title'), + bubbleText: $('bubble-text'), + notificationStack: $('notification-stack'), + quickbar: $('quickbar'), + quickTasks: $('quick-tasks'), + quickMenu: $('quick-menu'), + composer: $('composer'), + composerInput: $('composer-input'), + composerSend: $('composer-send'), + composerClose: $('composer-close'), + composerMsg: $('composer-msg'), + draftOrigin: $('draft-origin'), + composerOrigin: $('composer-origin'), + menu: $('menu'), +}; +const draftController = createDraftController({ message: host?.message, onChange: renderDraftControls }); +let speechInput = null; +let speechState = 'idle'; +let animationGate = null; +let workbenchView = null; +let workbenchWindow = null; +const reminders = createReminderStore(); +const compactBubbleSize = { width: 300, height: 340 }; +let notificationPreviews = null; +let askObserver = null; + +// Player module is loaded dynamically so a missing/broken module surfaces in the UI instead of a blank page. +let STATES = []; +let SpritePlayer = null; +let loadPet = null; +let loadPetFromParts = null; +let player = null; + +// --------------------------------------------------------------------------- +// Activity (REAL sessions bridge or DEMO) +// --------------------------------------------------------------------------- + +function activity() { + if (app.source === 'real') { + if (app.availability !== 'ready') return { status: null, session: null, key: `unavailable:${app.availability}` }; + const notice = reminders.getState(); + return { status: notice.primary?.displayStatus ?? 'idle', session: notice.primary, key: notice.animationKey }; + } + return { status: app.demoStatus, session: null, key: `demo:${app.demoStatus}` }; +} + +function activityAnim() { + return activity().session?.needsAnswer ? 'waiting' : STATUS_ANIM[activity().status] ?? 'idle'; +} + +function desiredAnimation() { + if (activity().session?.needsAnswer) return { state: 'waiting', key: activity().key }; + const base = { state: activityAnim(), key: activity().key }; + if (app.hovered && app.mode === 'window' && !app.menuOpen && !app.composerOpen && !app.workbenchOpen) return { state: 'jumping', key: 'hover' }; + return base; +} + +function availabilityText() { + if (app.availability === 'not_permitted') return '请先在插件详情中允许读取会话,再重试。'; + if (app.availability === 'connecting') return '正在连接本机任务…'; + return `暂时无法读取本机任务${app.sessionsError ? `(${app.sessionsError})` : ''}。可重新连接。`; +} + +function applyActivity() { + renderActivityUI(); + renderBubble(); + renderMenuIfOpen(); + if (!app.pet || app.oneShot || app.dragging) return; + const desired = desiredAnimation(); + if (!['idle', 'running', 'waving'].includes(desired.state)) stopLook(); + animationGate?.apply(desired.state, desired.key, { loop: app.source === 'demo' }); +} + +function playOneShot(state) { + if (!app.pet) return; + stopLook(); + const token = ++app.oneShotToken; + app.oneShot = true; + player.playTransient(state, { then: activityAnim(), repeat: 3 }).then(() => { + if (disposed || token !== app.oneShotToken) return; + app.oneShot = false; + animationGate?.invalidate(); + applyActivity(); + }, logError('play_one_shot')); +} + +async function initActivitySource() { + if (explicitDemo) return; + askObserver = createAskObserver({ agent, maxSubscriptions: 3, onChange: () => { + if (disposed) return; + syncObservedAsks(); + applyActivity(); + } }); + disposers.push(() => askObserver.dispose()); + activitySource = createActivitySource({ + agent, + onChange(snapshot) { + Object.assign(app, { + availability: snapshot.availability, sessionsError: snapshot.error, + rawSessions: snapshot.sessions, originSession: snapshot.originSession, aggregate: snapshot.aggregate, + selectedSessionId: snapshot.selectedSessionId, detail: snapshot.detail, + detailLoading: snapshot.detailLoading, detailError: snapshot.detailError, + }); + askObserver.update({ availability: snapshot.availability, sessions: snapshot.sessions, selectedSessionId: snapshot.selectedSessionId }); + syncObservedAsks(); + draftController.setOrigin(snapshot.originSession); + renderHeader(); + applyActivity(); + }, + }); + disposers.push(() => activitySource.dispose()); + void activitySource.start(); +} + +function syncObservedAsks() { + const ids = new Set(askObserver?.getState().needsAnswerIds ?? []); + app.sessions = app.rawSessions.map((row) => ({ ...row, needsAnswer: row.status === 'running' && ids.has(row.sessionId) })); + reminders.update(app.sessions); +} + +function setDemoStatus(status) { + if (!Object.hasOwn(STATUS_ANIM, status)) return; + app.demoStatus = status; + applyActivity(); +} + +async function initPreferences() { + try { + let storage = null; + try { storage = localStorage; } catch { /* The stable API still works without browser storage. */ } + const result = await loadPreferences(fetch, storage); + Object.assign(prefs, result.preferences); + setMsg(els.preferenceMsg, result.persisted ? '偏好已保存到本机。' : '调整后会保存到本机。'); + } catch (error) { + console.warn('[pet] preferences_load_failed:', errCode(error), error); + setMsg(els.preferenceMsg, `偏好读取失败(${errCode(error)}),当前使用临时设置。`, 'error'); + } +} + +function savePreferences(patch) { + const version = ++preferenceWriteVersion; + setMsg(els.preferenceMsg, '正在保存偏好…'); + preferenceWrites = preferenceWrites.catch(() => {}).then(() => preferenceRequest(fetch, patch)).then((result) => { + if (!result.persisted) throw new Error('preferences_not_persisted'); + if (version === preferenceWriteVersion && !disposed) setMsg(els.preferenceMsg, '偏好已保存到本机。', 'ok'); + }).catch((error) => { + console.warn('[pet] preferences_save_failed:', errCode(error), error); + if (disposed) return; + const text = `偏好未保存(${errCode(error)}),本次调整仅临时生效。`; + setMsg(els.preferenceMsg, text, 'error'); + if (app.mode === 'window') showTransient(text, 'error'); + }); + return preferenceWrites; +} + +function selectSession(sessionId, report = (text, kind) => setMsg(els.sessionMsg, text, kind)) { + if (app.mode === 'window') { openTaskPreview(sessionId); return; } + if (!activitySource || app.availability !== 'ready') return; + panelNavigation?.showTaskDetail(); + callBridge(() => activitySource.selectSession(sessionId)).then(() => { + if (app.availability !== 'ready') report('任务信息暂不可用', 'error'); + else report('已切换任务详情;桌宠继续聚合全部任务。', 'ok'); + }, (error) => report(`无法关注此任务(${errCode(error)})`, 'error')); +} + +// Host message.append +// --------------------------------------------------------------------------- + +function describeAppendError(code) { + switch (code) { + case 'user_cancelled': + return '已取消'; + case 'target_unavailable': + return '打开桌宠的原属对话不可用,文字已保留。请从可用的聊天重新打开桌宠。'; + case 'not_permitted': + return '无法追加到原属对话。请直接点击重试;文字已保留。'; + case 'unsupported': + return '当前环境不能加到对话输入框。'; + case 'empty': + return '先写点什么吧'; + case 'busy': + return '正在等待上一次确认,文字已保留。'; + case 'result_unknown': + return '请先检查输入框,文本可能已经加进去了'; + default: + return `没能加到输入框(${code}),请检查对话后再试`; + } +} + +function draftResultText(result) { + if (!result.ok) return describeAppendError(result.code); + return `已加到「${result.submission.title}」的输入框,仍需你在对话里按发送。${result.clear ? '' : '新编辑的草稿已保留。'}`; +} + +function renderDraftControls() { + const state = draftController.getState(); + els.talkBtn.disabled = !canAppend || state.pending; + els.composerSend.disabled = !canAppend || state.pending; + const target = state.origin ? `原属对话:${state.origin.title}` : '投递到打开桌宠的原属对话,由 Host 确认。'; + els.draftOrigin.textContent = target; + els.composerOrigin.textContent = target; + els.composerOrigin.title = target; +} + +function bindDraftForms() { + disposers.push(() => draftController.dispose()); + for (const [entry, form, input, message] of [ + ['panel', els.talkForm, els.talkText, els.talkMsg], + ['desktop', els.composer, els.composerInput, els.composerMsg], + ]) { + disposers.push(bindDraftEntry({ + controller: draftController, entry, form, input, + onPending(submission) { setMsg(message, `请在 Host 确认框核对「${submission.title}」及文字…`); }, + onResult(result) { + const text = draftResultText(result); + setMsg(message, text, result.ok ? 'ok' : result.code === 'user_cancelled' || result.code === 'empty' ? 'info' : 'error'); + if (result.ok) playOneShot('waving'); + if (entry === 'desktop' && result.ok && result.clear) { closeComposer(); showTransient(text, 'ok'); } + }, + })); + } +} + +// --------------------------------------------------------------------------- +// Window mode switching +// --------------------------------------------------------------------------- + +function describeModeError(code, target) { + const where = target === 'window' ? '桌面上是否已经出现宠物' : '宠物是否已经回到面板'; + switch (code) { + case 'not_permitted': + return '需要直接点击才能切换窗口(not_permitted)'; + case 'busy': + return '窗口正在切换,请稍候再试(busy)'; + case 'result_unknown': + return `没收到切换结果,请看看${where}(result_unknown)`; + default: + return `切换失败(${code})`; + } +} + +async function initMode() { + if (!host) { + applyMode('standalone'); + return; + } + if (!win) { + applyMode('inline'); + return; + } + try { + const sub = win.onDidChangeState(onWindowState); + disposers.push(() => sub?.dispose?.()); + onWindowState(await win.getState()); + } catch (error) { + const code = errCode(error); + console.error('[pet] window_state_unavailable:', code, error); + applyMode('inline'); + setMsg(els.popoutMsg, `读不到窗口状态(${code})`, 'error'); + } +} + +function onWindowState(state) { + if (!state || (state.mode !== 'inline' && state.mode !== 'window')) { + console.warn('[pet] unexpected_window_state:', state); + return; + } + if (state.mode === 'window' && state.window) { + const entering = app.mode !== 'window'; + app.winPos = { x: state.window.x, y: state.window.y }; + app.winBounds = state.window; + if (app.mode !== 'window') app.clickThrough = state.window.clickThrough ?? null; + if (entering) { + const saved = prefs.windowPosition; + const token = ++app.positionRestoreToken; + app.restoringPosition = true; + app.awaitingPointer = true; + applyMode('window'); + const isCurrent = () => !disposed && token === app.positionRestoreToken && app.mode === 'window'; + void restoreWindowPosition(win, saved, { isCurrent }).then(async (result) => { + if (disposed || token !== app.positionRestoreToken || app.mode !== 'window') return; + let restoredState = result.state; + if (result.interactive && restoredState?.window && (restoredState.window.width !== 280 || restoredState.window.height !== 340)) { + try { restoredState = await win.update({ width: 280, height: 340 }); } + catch (error) { if (!isCurrent()) return; result.error = errCode(error); } + if (!isCurrent()) return; + } + if (restoredState) onWindowState(restoredState); + if (!isCurrent()) return; + app.clickThrough = result.interactive ? false : null; + app.restoringPosition = false; + if (result.error) showTransient(result.interactive + ? `位置未恢复(${result.error}),当前窗口仍可交互。` + : `窗口交互状态未确认(${result.error}),请收回面板重试。`, 'error'); + rememberPosition(); + }); + return; + } + if (!app.restoringPosition) rememberPosition(); + } + if (state.mode !== app.mode) applyMode(state.mode); +} + +function rememberPosition() { + if (!app.winPos || disposed || app.compacting || app.mode !== 'window') return; + const bounds = app.winBounds; + const position = app.workbenchOpen && bounds + ? { x: Math.round(bounds.x + bounds.width - 280), y: Math.round(bounds.y + bounds.height - 340) } + : { x: Math.round(app.winPos.x), y: Math.round(app.winPos.y) }; + if (prefs.windowPosition?.x === position.x && prefs.windowPosition?.y === position.y) return; + prefs.windowPosition = position; + clearTimeout(app.positionTimer); + app.positionTimer = setTimeout(() => { app.positionTimer = null; void savePreferences({ windowPosition: prefs.windowPosition }); }, 250); +} + +function applyMode(mode) { + const leavingWindow = app.mode === 'window' && mode !== 'window'; + if (leavingWindow) { app.hovered = false; animationGate?.invalidate(); } + app.mode = mode; + app.workbenchOpen = false; + app.compacting = false; + app.workbenchRevision++; + workbenchView?.close(); + root.classList.remove('workbench-open'); + if (mode !== 'window') { app.positionRestoreToken++; app.restoringPosition = false; app.awaitingPointer = false; } + if (mode === 'window' && !['idle', 'error'].includes(speechState)) { + speechInput?.cancel(); + showTransient('已取消面板录音。请回到面板重新录制。'); + } + root.classList.toggle('mode-window', mode === 'window'); + root.classList.toggle('mode-inline', mode !== 'window'); + root.dataset.mode = mode; + els.panel.hidden = mode === 'window'; + els.desk.hidden = mode !== 'window'; + + closeMenu(); + closeComposer(); + hideQuickbar(); + cancelDrag(); + stopLook(); + app.pointer = null; + clearForwardingWatchdog(); + app.ctSafeHold = false; + if (leavingWindow) app.clickThrough = null; + + if (mode === 'window') { + els.deskPetSlot.append(els.sprite); + els.sprite.tabIndex = 0; + els.sprite.setAttribute('role', 'button'); + player?.setScale(prefs.scale); + // The manifest opens the window click-through. Until forwarded moves are proven to reach this + // page, start interactive so the pet can never come up unclickable and undraggable. + if (!app.restoringPosition && app.ctForwarding !== 'verified') setClickThrough(false); + } else { + els.panelStage.append(els.sprite); + els.sprite.removeAttribute('tabindex'); + els.sprite.setAttribute('role', 'img'); + player?.setScale(1); + } + renderAll(); +} + +function popOut() { + if (!win) return; + if (!['idle', 'error'].includes(speechState)) { setMsg(els.popoutMsg, '请先停止录音并等待转写完成。'); return; } + els.popoutBtn.disabled = true; + setMsg(els.popoutMsg, '正在弹出…'); + app.modeSwitching = true; + // Synchronous inside the click handler: mode changes need a user gesture. + callBridge(() => win.update({ mode: 'window', width: 280, height: 340 })) + .then( + (state) => { + setMsg(els.popoutMsg, ''); + onWindowState(state); + }, + (error) => { + const code = errCode(error); + console.warn('[pet] pop_out_failed:', code, error); + setMsg(els.popoutMsg, describeModeError(code, 'window'), 'error'); + }, + ) + .finally(() => { + app.modeSwitching = false; + els.popoutBtn.disabled = false; + }); +} + +function returnToPanel() { + if (!win) return Promise.resolve(false); + app.modeSwitching = true; + return callBridge(() => win.update({ mode: 'inline' })) + .then((state) => { onWindowState(state); return state?.mode === 'inline'; }, (error) => { + const code = errCode(error); + console.warn('[pet] return_to_panel_failed:', code, error); + showTransient(describeModeError(code, 'inline'), 'error'); + return false; + }) + .finally(() => { + app.modeSwitching = false; + refreshInteractivity(); + }); +} + +// --------------------------------------------------------------------------- +// Click-through + pointer tracking (window mode) +// --------------------------------------------------------------------------- + +let opaqueFallbackLogged = false; +function isOverPet(x, y) { + if (!app.pet) return false; + try { + return player.isOpaqueAt(x, y); + } catch (error) { + if (!opaqueFallbackLogged) { + opaqueFallbackLogged = true; + console.warn('[pet] is_opaque_failed (falling back to bounding box):', errCode(error), error); + } + return pointInRect(els.sprite.getBoundingClientRect(), x, y); + } +} + +function isOverOverlay(x, y) { + return [els.bubble, els.notificationStack, els.quickbar, els.composer, els.menu, els.workbench].some( + (el) => !el.hidden && pointInRect(el.getBoundingClientRect(), x, y), + ); +} + +function refreshInteractivity() { + if (app.mode !== 'window' || !win || app.modeSwitching || app.restoringPosition || app.awaitingPointer) return; + const p = app.pointer; + const interactive = + app.workbenchOpen || + app.dragging || + app.menuOpen || + app.composerFocused || + (p ? isOverPet(p.x, p.y) || isOverOverlay(p.x, p.y) : false); + setClickThrough(!interactive); +} + +function setClickThrough(value) { + if (value && (app.ctForwarding === 'broken' || app.ctSafeHold)) value = false; + if (value === app.clickThrough) return; + if (app.clickThrough === null && Date.now() < app.ctRetryAt) return; + app.clickThrough = value; + clearForwardingWatchdog(); + app.ctAppliedAt = 0; + callBridge(() => win.update({ clickThrough: value })).then( + () => { + if (!value || app.clickThrough !== true || app.mode !== 'window') return; + app.ctAppliedAt = performance.now(); + if (app.ctForwarding === 'unknown') armForwardingWatchdog(); + }, + (error) => { + const code = errCode(error); + console.warn('[pet] click_through_update_failed:', code, error); + if (app.mode === 'window') { + app.clickThrough = null; + app.ctRetryAt = Date.now() + 1000; + } + }, + ); +} + +function armForwardingWatchdog() { + const armPointer = app.pointer; + app.ctWatchdog = setTimeout(() => { + app.ctWatchdog = null; + if (app.mode !== 'window' || app.clickThrough !== true) return; + console.warn('[pet] click_through_unverified_reverted'); + app.ctSafeHold = true; + app.ctRevertedAt = performance.now(); + app.ctRevertPointer = armPointer; + setClickThrough(false); + }, 1500); +} + +function clearForwardingWatchdog() { + if (app.ctWatchdog) clearTimeout(app.ctWatchdog); + app.ctWatchdog = null; +} + +/** Called for every document mousemove in window mode, before interactivity is recomputed. */ +function noteForwardedMove(e) { + if ( + app.ctForwarding === 'unknown' && + app.clickThrough === true && + app.ctAppliedAt > 0 && + e.timeStamp > app.ctAppliedAt + ) { + app.ctForwarding = 'verified'; + clearForwardingWatchdog(); + console.info('[pet] click_through_forwarding_verified'); + } + if (!app.ctSafeHold) return; + // First move after a watchdog revert. A quick move far from where the pointer was when + // click-through turned on means the user kept moving and we saw none of it: forwarding failed. + const p = app.ctRevertPointer; + const quick = performance.now() - app.ctRevertedAt < 150; + const far = p ? Math.hypot(e.clientX - p.x, e.clientY - p.y) > 8 : true; + app.ctSafeHold = false; + if (!(quick && far)) return; + app.ctFailures += 1; + if (app.ctFailures >= 3) { + app.ctForwarding = 'broken'; + console.warn('[pet] click_through_forwarding_unavailable'); + showTransient('这里收不到穿透时的鼠标移动,透明区域会挡住下面的点击', 'error'); + } +} + +function insideDesk(x, y) { + return pointInRect(els.desk.getBoundingClientRect(), x, y); +} + +function onDocumentMouseMove(e) { + if (app.mode !== 'window') return; + if (app.restoringPosition) return; + app.awaitingPointer = false; + noteForwardedMove(e); + if (!app.dragging && !insideDesk(e.clientX, e.clientY)) { + onPointerLeave(); + return; + } + app.pointer = { x: e.clientX, y: e.clientY }; + refreshInteractivity(); + trackMenuHover(e.clientX, e.clientY); + updateLook(e.clientX, e.clientY); +} + +function onPointerLeave() { + if (app.mode !== 'window') return; + app.pointer = null; + stopLook(); + refreshInteractivity(); // stays interactive while menu/composer/drag force it + if (app.menuOpen) armMenuClose(); +} + +// --------------------------------------------------------------------------- +// Look direction (v2) + idle liveliness +// --------------------------------------------------------------------------- + +function updateLook(x, y) { + if (!app.pet || !player.supportsLook) return; + if ( + app.mode !== 'window' || + app.hovered || + app.dragging || + app.menuOpen || + app.composerOpen || + app.oneShot || + app.workbenchOpen || + reducedMotion.matches || + !['idle', 'running', 'waving'].includes(activityAnim()) + ) { + stopLook(); + return; + } + const r = els.sprite.getBoundingClientRect(); + const dx = x - (r.left + r.width / 2); + const dy = y - (r.top + r.height / 2); + if (Math.hypot(dx, dy) <= LOOK_DEAD_ZONE) { + stopLook(); + return; + } + let angle = (Math.atan2(dx, -dy) * 180) / Math.PI; // 0° = up, clockwise + if (angle < 0) angle += 360; + player.look(angle); + app.looking = true; +} + +function stopLook() { + if (!app.looking) return; + app.looking = false; + player?.look(null); +} + +function noteInteraction() { + // Deliberate interactions own transient animations; no random idle interruptions. +} + +// --------------------------------------------------------------------------- +// Dragging +// --------------------------------------------------------------------------- + +function onSpritePointerDown(e) { + if (app.mode !== 'window' || e.button !== 0 || !app.pet) return; + noteInteraction(); + if (app.menuOpen) closeMenu(); + e.preventDefault(); + try { + els.sprite.setPointerCapture(e.pointerId); + } catch (error) { + console.warn('[pet] pointer_capture_failed:', errCode(error), error); + } + app.drag = { + pointerId: e.pointerId, + startX: e.screenX, + startY: e.screenY, + win: app.winPos ? { ...app.winPos } : null, + active: false, + dir: null, + extreme: e.screenX, + }; +} + +function onSpritePointerMove(e) { + const d = app.drag; + if (!d || e.pointerId !== d.pointerId) return; + const dx = e.screenX - d.startX; + const dy = e.screenY - d.startY; + if (!d.active) { + if (Math.hypot(dx, dy) <= DRAG_THRESHOLD) return; + d.active = true; + app.dragging = true; + app.oneShotToken++; + app.oneShot = false; + stopLook(); + hideQuickbar(); + els.sprite.classList.add('is-dragging'); + refreshInteractivity(); + if (!d.win) console.warn('[pet] drag_without_window_position: 还没拿到窗口位置,只播放动画'); + } + updateDragDirection(d, e.screenX); + if (!d.win) return; + callBridge(() => win.update({ x: Math.round(d.win.x + dx), y: Math.round(d.win.y + dy) })).catch((error) => { + const code = errCode(error); + if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted(目标位置不在任何屏幕上)'); + else console.warn('[pet] drag_move_failed:', code, error); + }); +} + +function updateDragDirection(d, sx) { + const before = d.dir; + if (d.dir === 'right') { + if (sx > d.extreme) d.extreme = sx; + else if (d.extreme - sx > DIRECTION_HYSTERESIS) { + d.dir = 'left'; + d.extreme = sx; + } + } else if (d.dir === 'left') { + if (sx < d.extreme) d.extreme = sx; + else if (sx - d.extreme > DIRECTION_HYSTERESIS) { + d.dir = 'right'; + d.extreme = sx; + } + } else if (Math.abs(sx - d.startX) > DIRECTION_HYSTERESIS / 2) { + d.dir = sx > d.startX ? 'right' : 'left'; + d.extreme = sx; + } + if (d.dir !== before) { + player.play(d.dir === 'right' ? 'running-right' : 'running-left').catch(logError('play_drag')); + } +} + +function finishDrag(e, cancelled) { + const d = app.drag; + if (!d || e.pointerId !== d.pointerId) return; + app.drag = null; + try { + if (els.sprite.hasPointerCapture(e.pointerId)) els.sprite.releasePointerCapture(e.pointerId); + } catch (error) { + console.warn('[pet] pointer_release_failed:', errCode(error), error); + } + els.sprite.classList.remove('is-dragging'); + if (d.active) { + app.dragging = false; + if (cancelled || desiredAnimation().state === 'jumping') { + // Hover already owns the same three-cycle jump; release must not queue it twice. + animationGate?.invalidate(); applyActivity(); + } else playOneShot('jumping'); + callBridge(() => win.getState()).then(onWindowState, logError('get_state_after_drag')); + } else if (!cancelled) { + void openWorkbench('tasks'); + } + refreshInteractivity(); +} + +function cancelDrag() { + if (!app.drag && !app.dragging) return; + app.drag = null; + app.dragging = false; + els.sprite.classList.remove('is-dragging'); +} + +// --------------------------------------------------------------------------- +// Bubble / quick bar / composer (window mode) +// --------------------------------------------------------------------------- + +function showTransient(text, kind = 'info', sticky = false) { + clearTimeout(app.transientTimer); + app.transient = text ? { text, kind } : null; + if (text && !sticky) { + app.transientTimer = setTimeout(() => { + app.transient = null; + renderBubble(); + }, 4000); + } + renderBubble(); +} + +function renderBubble() { + renderNotificationStack(); + if (app.composerOpen || app.workbenchOpen) { + els.bubble.hidden = true; + app.bubbleSessionId = null; + refreshInteractivity(); + return; + } + const a = activity(); + let title = ''; + let text = ''; + let kind = 'info'; + let clickable = false; + if (app.transient) { + ({ text, kind } = app.transient); + } else if (!app.pet && app.loadError) { + text = app.loadError; + kind = 'error'; + } else if (app.source === 'real' && app.availability !== 'ready') { + text = availabilityText(); + kind = app.availability === 'connecting' ? 'info' : 'error'; + } else { + if (a.session) { + text = a.session.needsAnswer ? '需要你回答' : a.status === 'running' ? '' : STATUS_TEXT[a.status]; + title = a.session.title; + clickable = !!text; + } + + } + if (app.source === 'demo') { + title = '状态演示'; + text = STATUS_TEXT[app.demoStatus] ? `演示:${STATUS_TEXT[app.demoStatus]}` : ''; + clickable = false; + } + els.bubbleTitle.textContent = title; + els.bubbleTitle.hidden = !title; + els.bubbleText.textContent = text; + els.bubble.dataset.kind = kind; + els.bubble.classList.toggle('is-clickable', clickable); + els.bubble.tabIndex = text ? 0 : -1; + els.bubble.setAttribute('aria-label', text ? `${title ? `${title},` : ''}${text},打开桌宠工作面板` : '任务提示'); + els.bubble.hidden = !text || (!app.transient && !els.notificationStack.hidden); + app.bubbleSessionId = clickable && a.session ? a.session.sessionId : null; + refreshInteractivity(); +} + +function renderNotificationStack() { + const container = els.notificationStack; + const items = notificationItems(reminders.getState().items); + const visible = app.mode === 'window' && app.availability === 'ready' && items.length + && !app.workbenchOpen && !app.composerOpen && !app.transient; + container.hidden = !visible; + if (!visible) return; + const previews = notificationPreviews?.getState().previews ?? {}; + const existing = new Map([...container.children].map((node) => [node.dataset.sessionId, node])); + const nodes = items.map((item) => { + let node = existing.get(item.sessionId); + if (!node) { + node = document.createElement('div'); node.className = 'pet-notice'; node.dataset.sessionId = item.sessionId; + const open = document.createElement('button'); open.type = 'button'; open.className = 'pet-notice-open'; + const title = document.createElement('strong'), preview = document.createElement('span'); preview.className = 'pet-notice-preview'; + open.append(title, preview); + open.addEventListener('click', () => { openTaskPreview(node.dataset.sessionId); }); + const dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'pet-notice-dismiss'; dismiss.textContent = '×'; + dismiss.addEventListener('click', () => { reminders.dismiss(node.dataset.sessionId); applyActivity(); }); + node.append(open, dismiss); + } + const open = node.children[0]; + open.children[0].textContent = item.title; + open.children[1].textContent = notificationText(item, previews); + open.setAttribute('aria-label', `查看通知:${item.title}`); + node.children[1].setAttribute('aria-label', `关闭通知:${item.title}`); + return node; + }); + if (nodes.length !== container.children.length || nodes.some((node, i) => node !== container.children[i])) container.replaceChildren(...nodes); +} + +function toggleQuickbar() { + if (els.quickbar.hidden) showQuickbar(); + else hideQuickbar(); +} +function showQuickbar() { + if (app.mode !== 'window') return; + els.quickbar.hidden = false; + armQuickbarHide(); + refreshInteractivity(); +} +function hideQuickbar() { + clearTimeout(app.quickbarTimer); + if (els.quickbar.hidden) return; + els.quickbar.hidden = true; + refreshInteractivity(); +} +function armQuickbarHide() { + clearTimeout(app.quickbarTimer); + app.quickbarTimer = setTimeout(() => { + const p = app.pointer; + if (p && pointInRect(els.quickbar.getBoundingClientRect(), p.x, p.y)) armQuickbarHide(); + else hideQuickbar(); + }, 6000); +} + +function openComposer() { + if (!canAppend) { + showTransient('当前环境不能加到对话输入框', 'error'); + return; + } + hideQuickbar(); + closeMenu(); + stopLook(); + app.composerOpen = true; + els.composer.hidden = false; + // Called from a click/keyboard handler: focus() needs a user gesture. Lets the pet window take keyboard input. + if (typeof win?.focus === 'function') { + callBridge(() => win.focus()).catch((error) => console.warn('[pet] window_focus_failed:', errCode(error), error)); + } + els.composerInput.focus(); + renderBubble(); + refreshInteractivity(); +} + +function closeComposer({ restoreFocus = false } = {}) { + if (!app.composerOpen) return; + app.composerOpen = false; + app.composerFocused = false; + els.composer.hidden = true; + if (restoreFocus) els.sprite.focus({ preventScroll: true }); + renderBubble(); + refreshInteractivity(); +} + +// --------------------------------------------------------------------------- +// Context menu (window mode) +// --------------------------------------------------------------------------- + +const MENU_TITLES = { + root: '宠物菜单', + demo: '动作预览(演示)', + sessions: '全部任务', + pets: '换宠物', + scale: '大小', +}; + +function menuModel(view) { + if (view === 'demo') { + return [ + ...DEMO_ORDER.map((s) => ({ + label: STATUS_LABEL[s], + role: 'menuitemradio', + checked: app.demoStatus === s, + run: () => setDemoStatus(s), + })), + ]; + } + if (view === 'sessions') { + if (!app.sessions.length) return [{ label: '还没有会话', disabled: true }]; + return app.sessions.map((s) => ({ + label: s.title, + detail: STATUS_LABEL[s.status] ?? s.status, + disabled: false, + run: () => selectSession(s.sessionId, showTransient), + })); + } + if (view === 'pets') { + if (!app.pets.length) return [{ label: '没有可用的宠物', disabled: true }]; + return app.pets.map((p) => ({ + label: p.displayName, + detail: `v${p.version}`, + role: 'menuitemradio', + checked: app.pet?.id === p.id, + run: () => selectPet(p.id), + })); + } + if (view === 'scale') { + return SCALES.map((s) => ({ + label: s.label, + role: 'menuitemradio', + checked: prefs.scale === s.value, + run: () => setScale(s.value), + })); + } + return [ + app.source === 'demo' && { label: '动作预览', submenu: 'demo' }, + app.source === 'real' && app.availability === 'ready' && { label: '全部任务', run: () => { void openWorkbench('tasks'); } }, + app.source === 'real' && app.availability !== 'ready' && { label: '重新连接会话', run: () => { void activitySource?.refresh(); } }, + { label: '打招呼', run: () => playOneShot('waving') }, + { label: '跳一下', run: () => playOneShot('jumping') }, + canAppend && { label: '追加到原属聊天草稿…', run: () => { void openWorkbench('draft'); } }, + { label: '换宠物', submenu: 'pets' }, + { label: '大小', submenu: 'scale' }, + win && { label: '收回到面板', run: returnToPanel }, + ].filter(Boolean); +} + +function menuButton(item) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'menu-item'; + b.tabIndex = -1; + b.setAttribute('role', item.role ?? 'menuitem'); + if (item.role === 'menuitemradio' || item.role === 'menuitemcheckbox') { + b.setAttribute('aria-checked', String(Boolean(item.checked))); + } + if (item.submenu) b.setAttribute('aria-haspopup', 'menu'); + if (item.disabled) b.setAttribute('aria-disabled', 'true'); + + const mark = document.createElement('span'); + mark.className = 'mi-mark'; + mark.setAttribute('aria-hidden', 'true'); + mark.textContent = item.back ? '‹' : item.checked ? '✓' : ''; + const label = document.createElement('span'); + label.className = 'mi-label'; + label.textContent = item.label; + b.append(mark, label); + if (item.detail) { + const detail = document.createElement('span'); + detail.className = 'mi-detail'; + detail.textContent = item.detail; + b.append(detail); + } + if (item.submenu) { + const arrow = document.createElement('span'); + arrow.className = 'mi-arrow'; + arrow.setAttribute('aria-hidden', 'true'); + arrow.textContent = '›'; + b.append(arrow); + } + b.addEventListener('click', () => activateMenuItem(item)); + menuItemByButton.set(b, item); + return b; +} + +function activateMenuItem(item) { + if (item.disabled) return; + if (item.back) { + goMenu('root'); + return; + } + if (item.submenu) { + goMenu(item.submenu); + return; + } + // Runs synchronously inside the click/keydown, so gesture-gated bridge calls stay valid. + // Run before closing: returnToPanel sets modeSwitching first, so closing does not queue a + // clickThrough patch that would race the mode switch (inline_mode). + item.run(); + closeMenu(); +} + +function goMenu(view) { + app.menuView = view; + renderMenu(0); +} + +function renderMenu(focusIndex = 0) { + const nodes = []; + if (app.menuView !== 'root') nodes.push(menuButton({ label: '返回', back: true })); + for (const item of menuModel(app.menuView)) nodes.push(menuButton(item)); + els.menu.replaceChildren(...nodes); + els.menu.setAttribute('aria-label', MENU_TITLES[app.menuView] ?? '菜单'); + els.menu.hidden = false; + positionMenu(); + const buttons = menuButtons(); + buttons[clamp(focusIndex, 0, buttons.length - 1)]?.focus({ preventScroll: true }); + refreshInteractivity(); +} + +function renderMenuIfOpen() { + if (!app.menuOpen) return; + const buttons = menuButtons(); + const index = Math.max(0, buttons.indexOf(document.activeElement)); + if (app.menuView === 'demo' && app.source !== 'demo') app.menuView = 'root'; + if (app.menuView === 'sessions' && app.source !== 'real') app.menuView = 'root'; + renderMenu(index); +} + +function menuButtons() { + return [...els.menu.querySelectorAll('.menu-item')]; +} + +function positionMenu() { + const desk = els.desk.getBoundingClientRect(); + els.menu.style.left = '0px'; + els.menu.style.top = '0px'; + const m = els.menu.getBoundingClientRect(); + const left = clamp(app.menuAnchor.x - desk.left - 8, 4, Math.max(4, desk.width - m.width - 4)); + const top = clamp(app.menuAnchor.y - desk.top - 8, 4, Math.max(4, desk.height - m.height - 4)); + els.menu.style.left = `${Math.round(left)}px`; + els.menu.style.top = `${Math.round(top)}px`; +} + +function openMenu(x, y) { + if (app.mode !== 'window') return; + noteInteraction(); + hideQuickbar(); + closeComposer(); + stopLook(); + disarmMenuClose(); + app.menuOpen = true; + app.menuView = 'root'; + app.menuAnchor = { x, y }; + renderMenu(0); +} + +function closeMenu({ restoreFocus = false } = {}) { + if (!app.menuOpen) return; + app.menuOpen = false; + disarmMenuClose(); + els.menu.hidden = true; + els.menu.replaceChildren(); + if (restoreFocus) els.sprite.focus({ preventScroll: true }); + refreshInteractivity(); +} + +function trackMenuHover(x, y) { + if (!app.menuOpen) return; + if (pointInRect(els.menu.getBoundingClientRect(), x, y)) disarmMenuClose(); + else armMenuClose(); +} +function armMenuClose() { + if (!app.menuCloseTimer) app.menuCloseTimer = setTimeout(() => closeMenu(), 1500); +} +function disarmMenuClose() { + clearTimeout(app.menuCloseTimer); + app.menuCloseTimer = null; +} + +function onMenuKeyDown(e) { + const buttons = menuButtons(); + const i = buttons.indexOf(document.activeElement); + const focusAt = (n) => buttons[(n + buttons.length) % buttons.length]?.focus({ preventScroll: false }); + const item = menuItemByButton.get(document.activeElement); + switch (e.key) { + case 'ArrowDown': + focusAt(i + 1); + break; + case 'ArrowUp': + focusAt(i < 0 ? -1 : i - 1); + break; + case 'Home': + focusAt(0); + break; + case 'End': + focusAt(-1); + break; + case 'ArrowRight': + if (item?.submenu) goMenu(item.submenu); + break; + case 'ArrowLeft': + case 'Backspace': + if (app.menuView !== 'root') goMenu('root'); + break; + case 'Escape': + case 'Tab': + closeMenu({ restoreFocus: true }); + break; + default: + return; + } + e.preventDefault(); + e.stopPropagation(); +} + +// --------------------------------------------------------------------------- +// Pets: list, select, delete +// --------------------------------------------------------------------------- + +async function readErrorBody(res) { + try { + const body = await res.json(); + return { code: body?.error || `http_${res.status}`, message: body?.message || '' }; + } catch (error) { + console.warn('[pet] error_body_not_json:', res.status, error); + return { code: `http_${res.status}`, message: '' }; + } +} + +async function refreshPets() { + let res; + try { + res = await fetch('/api/pets', { cache: 'no-store' }); + } catch (error) { + throw Object.assign(new Error('读不到宠物列表'), { code: 'pets_list_unreachable', cause: error }); + } + if (!res.ok) { + const { code, message } = await readErrorBody(res); + throw Object.assign(new Error(message || '读不到宠物列表'), { code }); + } + const body = await res.json(); + app.pets = Array.isArray(body?.pets) ? body.pets : []; + app.spriteUrls.clear(); + renderGallery(); + renderMenuIfOpen(); + resolveThumbs(); +} + +async function resolveThumbs() { + const results = await Promise.allSettled( + app.pets.map(async (pet) => { + const jsonUrl = new URL(pet.petJsonUrl, location.href); + const res = await fetch(jsonUrl, { cache: 'no-store' }); + if (!res.ok) throw Object.assign(new Error(`pet.json HTTP ${res.status}`), { code: 'thumb_pet_json_unreadable' }); + const json = await res.json(); + app.spriteUrls.set(pet.id, new URL(json.spritesheetPath, jsonUrl).href); + }), + ); + results.forEach((r, i) => { + if (r.status === 'rejected') console.warn('[pet] thumb_failed:', app.pets[i]?.id, errCode(r.reason), r.reason); + }); + renderGallery(); +} + +async function selectPet(id) { + const summary = app.pets.find((p) => p.id === id); + if (!summary) { + showLoadError({ code: 'pet_not_found', message: `没有 id 为 ${id} 的宠物` }); + return; + } + const token = ++app.selectToken; + setMsg(els.galleryMsg, `正在加载「${summary.displayName}」…`); + try { + const loaded = await loadPet(new URL(summary.petJsonUrl, location.href).href); + if (token !== app.selectToken) return; + stopLook(); + app.pet = loaded; + app.petSummary = summary; + player.setPet(loaded); + app.oneShot = false; + app.oneShotToken++; + animationGate?.invalidate(); applyActivity(); + prefs.petId = id; + savePreferences({ petId: id }); + showLoadError(null); + setMsg(els.galleryMsg, ''); + } catch (error) { + if (token !== app.selectToken) return; + showLoadError(error, summary.displayName); + setMsg(els.galleryMsg, ''); + } + renderAll(); +} + +function showLoadError(error, name) { + if (!error) { + app.loadError = null; + els.loadError.hidden = true; + els.loadError.textContent = ''; + renderBubble(); + return; + } + const code = errCode(error); + const detail = error.message && error.message !== code ? `:${error.message}` : ''; + const text = `${name ? `「${name}」` : ''}加载失败(${code})${detail}`; + console.error('[pet] load_failed:', code, error); + app.loadError = text; + els.loadError.textContent = text; + els.loadError.hidden = false; + if (app.mode === 'window' && app.pet) showTransient(text, 'error'); + renderBubble(); +} + +async function deletePet(pet) { + if (!window.confirm(`删除导入的宠物「${pet.displayName}」?删除后不能恢复。`)) { + setMsg(els.galleryMsg, '已取消删除'); + return; + } + setMsg(els.galleryMsg, `正在删除「${pet.displayName}」…`); + try { + const res = await fetch(`/api/pets/${encodeURIComponent(pet.id)}`, { method: 'DELETE' }); + if (!res.ok) { + const { code, message } = await readErrorBody(res); + setMsg(els.galleryMsg, `删除失败(${code})${message ? `:${message}` : ''}`, 'error'); + return; + } + await refreshPets(); + setMsg(els.galleryMsg, `已删除「${pet.displayName}」`, 'ok'); + if (app.pet?.id === pet.id || prefs.petId === pet.id) { + const next = app.pets[0]; + if (next) await selectPet(next.id); + } + } catch (error) { + const code = errCode(error); + console.error('[pet] delete_failed:', code, error); + setMsg(els.galleryMsg, `删除失败(${code})`, 'error'); + } +} + +function setScale(value) { + const intent = ++scaleIntent; + const current = () => !disposed && intent === scaleIntent; + scaleUpdates = scaleUpdates.then(async () => { + if (!current()) return; + if (app.workbenchOpen) await closeWorkbench(); + if (!current()) return; + if (app.mode === 'window') { + // The Host only requires the transparent window to overlap a display. + // Fit its whole content area before shrinking so opaque pet pixels remain reachable. + const result = await fitWindowToScreen(win, { + screenRect: () => ({ x: screen.availLeft, y: screen.availTop, width: screen.availWidth, height: screen.availHeight }), + isCurrent: () => current() && app.mode === 'window', + }); + if (!current() || result.cancelled) return; + if (result.error) { showTransient(`尺寸未调整(${result.error}),请稍后重试。`, 'error'); return; } + if (result.state) onWindowState(result.state); + player.setScale(value); + if (app.menuOpen) positionMenu(); + refreshInteractivity(); + } + prefs.scale = value; + savePreferences({ scale: value }); + renderScale(); + }).catch(logError('scale_update')); + return scaleUpdates; +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +function resetImport() { + importState.generation++; + clearInterval(importState.cycleTimer); + importState.player?.destroy(); + if (importState.blobUrl) URL.revokeObjectURL(importState.blobUrl); + Object.assign(importState, { + petJson: null, + file: null, + blobUrl: null, + loaded: null, + player: null, + cycleTimer: null, + errors: [], + busy: false, + }); + els.importSprite.removeAttribute('style'); + els.importPreview.hidden = true; + els.importErrors.replaceChildren(); + els.importStateText.textContent = ''; +} + +function renderImportErrors() { + els.importErrors.replaceChildren( + ...importState.errors.map(({ code, message }) => { + const li = document.createElement('li'); + const c = document.createElement('code'); + c.textContent = code; + li.append(c, ` ${message}`); + return li; + }), + ); + els.importConfirm.disabled = importState.busy || importState.errors.length > 0 || !importState.loaded; + els.importPick.disabled = importState.busy; + els.importInput.disabled = importState.busy; + els.importCancel.disabled = importState.busy; + if (els.importBack) els.importBack.disabled = importState.busy; +} + +function precheckImport(petJson, file) { + const errs = []; + if (typeof petJson.id !== 'string' || !ID_RE.test(petJson.id)) { + errs.push({ code: 'invalid_id', message: 'id 只能用小写字母、数字、点、下划线、连字符,最长 80 个字符' }); + } else if (app.pets.some((p) => p.id === petJson.id && p.source === 'bundled')) { + errs.push({ code: 'bundled_conflict', message: '和内置宠物的 id 重复了' }); + } + if (typeof petJson.displayName !== 'string' || petJson.displayName.length < 1 || petJson.displayName.length > 80) { + errs.push({ code: 'invalid_pet_json', message: 'displayName 需要 1–80 个字符' }); + } + if (petJson.description != null && (typeof petJson.description !== 'string' || petJson.description.length > 300)) { + errs.push({ code: 'invalid_pet_json', message: 'description 最多 300 个字符' }); + } + if (!/^[^/\\]+\.(webp|png)$/.test(petJson.spritesheetPath)) { + errs.push({ code: 'invalid_sprite_name', message: 'spritesheetPath 只能是同目录下的 .webp 或 .png 文件名' }); + } + if (Math.ceil(file.size / 3) * 4 + 4096 > IMPORT_BODY_LIMIT) { + errs.push({ code: 'payload_too_large', message: '图集太大,上传内容会超过 12 MB' }); + } + return errs; +} + +async function handleImportFiles(fileList) { + if (importState.busy) return; + resetImport(); + const generation = importState.generation; + const current = () => !disposed && generation === importState.generation; + setMsg(els.importMsg, ''); + const files = [...fileList]; + const jsons = files.filter((f) => /\.json$/i.test(f.name)); + if (jsons.length !== 1) { + setMsg( + els.importMsg, + jsons.length === 0 + ? '缺少 pet.json,请和图集一起选择(import_pet_json_missing)' + : '一次只能导入一个宠物,选了多个 .json(import_pet_json_ambiguous)', + 'error', + ); + return; + } + let petJson; + try { + petJson = JSON.parse(await jsons[0].text()); + } catch (error) { + if (!current()) return; + console.warn('[pet] import_pet_json_unreadable:', error); + setMsg(els.importMsg, `pet.json 不是有效的 JSON(pet_json_unreadable)`, 'error'); + return; + } + if (!current()) return; + if (!petJson || typeof petJson !== 'object' || Array.isArray(petJson) || typeof petJson.spritesheetPath !== 'string') { + setMsg(els.importMsg, 'pet.json 缺少 spritesheetPath(pet_json_invalid)', 'error'); + return; + } + const spriteName = basename(petJson.spritesheetPath); + const file = files.find((f) => f.name === spriteName); + if (!file) { + setMsg(els.importMsg, `没找到 ${spriteName},请和 pet.json 一起选择(import_sprite_missing)`, 'error'); + return; + } + + importState.petJson = petJson; + importState.file = file; + importState.blobUrl = URL.createObjectURL(file); + importState.errors = precheckImport(petJson, file); + els.importName.textContent = petJson.displayName || petJson.id || '未命名宠物'; + els.importMeta.textContent = `id:${petJson.id ?? '—'} · 图集:${file.name}(${Math.round(file.size / 1024)} KB)`; + els.importPreview.hidden = false; + els.importStateText.textContent = '正在检查图集…'; + renderImportErrors(); + + const blobUrl = importState.blobUrl; + try { + const loaded = await loadPetFromParts(petJson, blobUrl); + if (!current() || importState.blobUrl !== blobUrl) return; + importState.loaded = loaded; + els.importMeta.textContent += ` · v${loaded.version}${loaded.version === 2 ? '(支持看向鼠标)' : ''}`; + startImportPreview(loaded); + } catch (error) { + if (!current() || importState.blobUrl !== blobUrl) return; + const code = errCode(error); + console.warn('[pet] import_preview_failed:', code, error); + importState.errors.push({ code, message: error?.message && error.message !== code ? error.message : '图集无法预览' }); + els.importStateText.textContent = ''; + } + renderImportErrors(); +} + +function startImportPreview(loaded) { + const preview = new SpritePlayer(els.importSprite, { scale: 0.75 }); + preview.setReducedMotion(reducedMotion.matches); + preview.setPet(loaded); + importState.player = preview; + let i = 0; + const step = () => { + const state = STATES[i % STATES.length]; + i++; + preview.play(state, { loop: true }).catch(logError('import_preview_play')); + els.importStateText.textContent = `预览动作:${state}(${((i - 1) % STATES.length) + 1}/${STATES.length})`; + }; + step(); + importState.cycleTimer = setInterval(step, 1200); +} + +function fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result); + resolve(result.slice(result.indexOf(',') + 1)); + }; + reader.onerror = () => reject(Object.assign(new Error('读不到图集文件'), { code: 'sprite_unreadable', cause: reader.error })); + reader.readAsDataURL(file); + }); +} + +async function postPet(body) { + const res = await fetch('/api/pets', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + let data = null; + try { + data = await res.json(); + } catch (error) { + console.warn('[pet] import_response_not_json:', res.status, error); + } + return { res, data }; +} + +async function confirmImport() { + const { petJson, file } = importState; + if (!petJson || !file || !importState.loaded || importState.errors.length || importState.busy) return; + let generation = importState.generation; + const current = () => !disposed && generation === importState.generation; + importState.busy = true; + renderImportErrors(); + setMsg(els.importMsg, '正在导入…'); + try { + const base64 = await fileToBase64(file); + if (!current()) return; + const payload = { petJson, spritesheet: { fileName: file.name, base64 } }; + let { res, data } = await postPet(payload); + if (!current()) return; + if (res.status === 409 && data?.error === 'pet_exists') { + if (!window.confirm(`已经导入过 id 为「${petJson.id}」的宠物,要用这次的文件替换它吗?`)) { + setMsg(els.importMsg, '已取消导入,原来的宠物没有变化'); + return; + } + ({ res, data } = await postPet({ ...payload, replace: true })); + if (!current()) return; + } + if (!res.ok) { + const code = data?.error || `http_${res.status}`; + setMsg(els.importMsg, `导入失败(${code})${data?.message ? `:${data.message}` : ''}`, 'error'); + return; + } + const newId = data?.pet?.id ?? petJson.id; + resetImport(); + generation = importState.generation; + importState.busy = true; + renderImportErrors(); + await refreshPets(); + if (!current()) return; + await selectPet(newId); + if (!current()) return; + panelNavigation?.completeImport(); + const message = `导入成功,已切换到「${data?.pet?.displayName ?? newId}」`; + setMsg(els.importMsg, message, 'ok'); + setMsg(els.galleryMsg, message, 'ok'); + } catch (error) { + if (!current()) return; + const code = errCode(error); + console.error('[pet] import_failed:', code, error); + setMsg(els.importMsg, `导入失败(${code})`, 'error'); + } finally { + if (current()) { importState.busy = false; renderImportErrors(); } + } +} + +// --------------------------------------------------------------------------- +// Rendering (inline panel) +// --------------------------------------------------------------------------- + +function renderAll() { + renderHeader(); + renderActivityUI(); + renderTalk(); + renderScale(); + renderGallery(); + renderBubble(); +} + +function renderHeader() { + const pet = app.pet; + els.petName.textContent = pet?.displayName ?? (app.loadError ? '宠物没加载出来' : '正在加载宠物…'); + els.petDesc.textContent = pet?.description ?? ''; + els.versionBadge.hidden = !pet; + els.versionBadge.textContent = pet?.version === 2 ? 'v2·可看向鼠标' : 'v1'; + const sourceLabel = app.source === 'demo' ? '资源预览' : app.availability === 'ready' ? '真实会话' : app.availability === 'connecting' ? '连接中' : app.availability === 'not_permitted' ? '未授权' : '不可用'; + els.sourceBadge.textContent = sourceLabel; + els.sourceBadge.dataset.source = app.source; + els.popoutBtn.hidden = !win || app.mode === 'window'; + + let note = ''; + if (app.mode === 'standalone') { + note = '普通浏览器没有任务和窗口能力;可用 ?demo=1 手动预览资源动作。'; + } else if (!win) { + note = '当前 MiniMax Code 版本不支持弹出窗口,只能在面板里使用。'; + } + els.hostNote.textContent = note; + els.hostNote.hidden = !note; +} + +function renderActivityUI() { + const a = activity(); + const real = app.source === 'real'; + els.demoControls.hidden = real; + const available = real && app.availability === 'ready'; + panelNavigation?.update({ reminderCount: reminders.getState().items.length, availability: app.availability }); + els.taskConnection.hidden = available || !real; + if (!available && real) setMsg(els.taskConnection, `${availabilityText()} 可在设置中重新连接。`, app.availability === 'connecting' ? 'info' : 'error'); + els.sessionList.hidden = !available; + els.activityRetry.hidden = !real || available; + if (available) { + const coverage = askObserver?.getState(); + els.activityNote.textContent = `聚合最近 50 个用户主会话及本次可见活动。新出现的 ask 实时跟随 ${coverage?.watchingIds.length ?? 0}/${coverage?.runningCount ?? 0} 个运行任务;订阅前的问题无法恢复。选择条目只查看详情。`; + renderSessionList(a.session); + } else if (real) { + els.activityNote.textContent = availabilityText(); + els.sessionList.replaceChildren(); + } else { + els.activityNote.textContent = '手动资源动作预览(演示),不代表真实任务。'; + syncSegmented(els.demoStatus, app.demoStatus); + } + els.aggregateCounts.textContent = available ? aggregateText(app.aggregate?.counts) : ''; + els.clearCompleted.hidden = !available || !app.aggregate?.counts.completed; + renderReminders(); + const text = real && !available ? availabilityText() : a.session?.needsAnswer ? '需要你回答' : a.session || !real ? STATUS_TEXT[a.status] || '空闲中' : '暂无当前提醒'; + els.panelStatus.textContent = a.session?.needsAnswer ? `「${a.session.title}」需要你回答` : a.session && STATUS_TEXT[a.status] ? `「${a.session.title}」${text}` : text; + renderDetail(); + const name = app.pet?.displayName ?? '宠物'; + const stateLabel = a.session?.needsAnswer ? '需要你回答' : STATUS_LABEL[a.status] || sourceLabelForActivity(); + els.sprite.setAttribute('aria-label', app.mode === 'window' + ? `${name},${stateLabel}。点击或回车查看任务,可拖动` : `${name},${stateLabel}`); + +} + +function sourceLabelForActivity() { return app.availability === 'connecting' ? '连接中' : app.availability === 'not_permitted' ? '未授权读取会话' : '会话状态不可用'; } + +function renderSessionList(current) { + const focusedId = els.sessionList.contains(document.activeElement) ? document.activeElement?.dataset?.sessionId : null; + const scrollTop = els.sessionList.scrollTop; + const items = app.sessions.map((s) => { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'session-row'; + btn.dataset.sessionId = s.sessionId; + btn.disabled = false; + const title = document.createElement('span'); + title.className = 'session-title'; + title.textContent = s.title; + title.title = s.title; + const pill = document.createElement('span'); + pill.className = 'pill'; + const displayStatus = taskStatus(s); + pill.dataset.status = displayStatus === 'needs_answer' ? 'needs_input' : displayStatus; + pill.textContent = s.needsAnswer ? '需要你回答' : STATUS_LABEL[displayStatus] ?? displayStatus; + btn.append(title); + if (current?.sessionId === s.sessionId) { + const tag = document.createElement('span'); + tag.className = 'session-current'; + tag.textContent = '当前提示'; + btn.append(tag); + } + btn.append(pill); + if (app.selectedSessionId === s.sessionId) btn.setAttribute('aria-current', 'true'); + btn.setAttribute('aria-label', `${s.title},${pill.textContent},查看详情`); + btn.addEventListener('click', () => selectSession(s.sessionId, (t, k) => setMsg(els.sessionMsg, t, k))); + li.append(btn); + return li; + }); + if (!items.length) { + const li = document.createElement('li'); + li.className = 'muted'; + li.textContent = '还没有会话'; + items.push(li); + } + els.sessionList.replaceChildren(...items); + els.sessionList.scrollTop = scrollTop; + if (focusedId) els.sessionList.querySelector(`[data-session-id="${CSS.escape(focusedId)}"]`)?.focus(); +} + +function renderTalk() { + renderDraftControls(); + const entry = $('open-draft'); + if (entry) { entry.hidden = !canAppend; if (entry.closest('section')) entry.closest('section').hidden = !canAppend; } + els.talkText.disabled = !canAppend; + if (!canAppend && !els.talkMsg.textContent) setMsg(els.talkMsg, '当前环境不能加到对话输入框。'); +} + +function renderDetail() { + const detail = app.detail; + const session = app.sessions.find((row) => row.sessionId === app.selectedSessionId); + const needsAnswer = session?.needsAnswer; + els.detailTitle.textContent = detail?.title || '任务概览'; + const status = taskStatus(session) || taskStatus(detail); + els.detailStatus.textContent = needsAnswer ? '需要你回答' : detail ? STATUS_LABEL[status] || status : '—'; + els.detailTime.textContent = detail ? new Date(detail.updatedAtMs).toLocaleString('zh-CN') : '—'; + els.detailError.textContent = detail?.error || app.detailError || ''; + els.detailError.hidden = !els.detailError.textContent; + const reply = needsAnswer ? '需要你回答' : detail?.lastAssistantText || ''; + if (els.detailReply.textContent !== reply) els.detailReply.textContent = reply; + if (panelDetailId !== app.selectedSessionId) { els.detailReply.scrollTop = 0; panelDetailId = app.selectedSessionId; } + els.detailNote.textContent = app.source === 'demo' ? '资源动作预览,不提供真实任务概览。' + : app.availability !== 'ready' ? availabilityText() + : app.detailLoading ? (detail ? '正在更新…' : '正在读取任务…') : !detail ? '选择任务后查看最近回复。' + : needsAnswer ? '请回到 MiniMax Code 的对应聊天回答。' + : detail.lastAssistantText ? '显示任务的最近回复。后续处理请回到对应聊天。' : '这项任务暂时没有可查看的回复。'; +} + +function initWorkbench() { + workbenchView = createCompactPetView({ container: els.workbench, + onClose: () => { void closeWorkbench(); }, + onSelectTask: openTaskPreview, + }); + notificationPreviews = createNotificationPreviews({ agent, onChange: () => { renderBubble(); renderCompactView(); } }); + if (win) workbenchWindow = createWorkbenchWindow({ windowAPI: win, + screenRect: () => ({ x: screen.availLeft, y: screen.availTop, width: screen.availWidth, height: screen.availHeight }), + isCurrent: () => !disposed && app.mode === 'window', expandedSize: compactBubbleSize, + }); + disposers.push(() => { notificationPreviews.dispose(); workbenchWindow?.dispose(); workbenchView.dispose(); }); +} + +function renderCompactView() { + const notice = reminders.getState(); + notificationPreviews?.update({ availability: app.availability, items: notificationItems(notice.items) }); + const previews = notificationPreviews?.getState().previews ?? {}; + workbenchView?.update({ reminders: { ...notice, items: notice.items.map((item) => ({ ...item, previewText: item.needsAnswer ? '需要你回答' : previews[item.sessionId] || '' })) }, allSessions: app.sessions, + taskSelection: { availability: app.availability, selectedSessionId: app.selectedSessionId, session: app.sessions.find((row) => row.sessionId === app.selectedSessionId), detail: app.detail, detailLoading: app.detailLoading, detailError: app.detailError } }); +} + +function openTaskPreview(sessionId) { + if (!activitySource || app.availability !== 'ready') { showTransient(availabilityText(), 'error'); return; } + try { void activitySource.selectSession(sessionId).catch((error) => showTransient(`无法读取任务(${errCode(error)})。`, 'error')); } + catch (error) { showTransient(`无法读取任务(${errCode(error)})。`, 'error'); return; } + void openWorkbench('detail'); +} + +function focusFullPanel(view) { + if (view === 'draft') { if (canAppend) panelNavigation?.showDraft(); } + else if (view === 'detail') panelNavigation?.showTaskDetail(); + else { panelNavigation?.showTab('tasks'); panelNavigation?.showTaskList(); } +} + +function openFullPanel(view = 'tasks') { + const result = app.mode === 'window' ? returnToPanel() : Promise.resolve(true); + return result.then((ok) => { if (ok && !disposed && app.mode !== 'window') focusFullPanel(view); }); +} + +async function openWorkbench(view = 'tasks') { + if (disposed) return; + app.workbenchView = view; + if (app.mode !== 'window') { + focusFullPanel(view); + return; + } + if (view === 'draft') return openFullPanel('draft'); + const token = ++app.workbenchRevision; + app.compacting = false; + const alreadyOpen = workbenchView.opened; + app.workbenchOpen = true; + closeComposer(); closeMenu(); hideQuickbar(); stopLook(); + setClickThrough(false); + const waitingAt = Date.now(); + while (app.restoringPosition && token === app.workbenchRevision && !disposed && Date.now() - waitingAt < 3000) await new Promise((resolve) => setTimeout(resolve, 20)); + if (disposed || token !== app.workbenchRevision || app.mode !== 'window' || !app.workbenchOpen) return; + const result = app.restoringPosition ? { ok: false, code: 'window_restoring' } : alreadyOpen ? { ok: true } : await workbenchWindow.setExpanded(true); + if (disposed || token !== app.workbenchRevision || app.mode !== 'window' || !app.workbenchOpen) return; + if (!result.ok) { + app.workbenchOpen = false; + showTransient(`暂时无法展开(${result.code}),可收回面板继续使用。`, 'error'); + refreshInteractivity(); return; + } + workbenchView.open(view); + root.classList.add('workbench-open'); + applyActivity(); +} + +async function closeWorkbench() { + const token = ++app.workbenchRevision; + app.compacting = true; + app.workbenchOpen = false; + workbenchView.close(); + root.classList.remove('workbench-open'); + applyActivity(); + const result = await workbenchWindow?.setExpanded(false); + if (token === app.workbenchRevision) app.compacting = false; + if (!disposed && token === app.workbenchRevision && app.mode === 'window' && !app.workbenchOpen) { + if (result && !result.ok && !['stale', 'superseded', 'cancelled'].includes(result.code)) showTransient(`窗口大小暂未恢复(${result.code}),仍可继续操作。`, 'error'); + const current = () => !disposed && token === app.workbenchRevision && app.mode === 'window' && !app.workbenchOpen; + callBridge(() => win.getState()).then((state) => { if (current()) onWindowState(state); }, (error) => { if (current()) logError('get_state_after_collapse')(error); }); + refreshInteractivity(); + } +} + +function renderReminders() { + const state = reminders.getState(); + renderCompactView(); + els.reminderNote.textContent = state.items.length ? '按最新活动显示;静音只影响桌宠本次提示。' : '当前没有需要提示的活动。历史状态仍在全部会话中。'; + const existing = new Map([...els.reminderList.children].map((node) => [node.dataset.sessionId, node])); + const nodes = state.items.slice(0, 10).map((item) => { + let li = existing.get(item.sessionId); + if (!li) { + li = document.createElement('li'); li.className = 'reminder-item'; li.dataset.sessionId = item.sessionId; + const title = document.createElement('button'); title.type = 'button'; title.className = 'reminder-title'; + title.addEventListener('click', () => { selectSession(item.sessionId); }); + const status = document.createElement('span'); status.className = 'pill'; + const actions = document.createElement('div'); actions.className = 'row'; + const mute = document.createElement('button'); mute.type = 'button'; mute.className = 'btn small'; mute.textContent = '本次静音'; + mute.addEventListener('click', () => { reminders.mute(item.sessionId); applyActivity(); }); + actions.append(mute); li.append(title, status, actions); + } + li.children[0].textContent = item.title; + li.children[0].setAttribute('aria-label', `查看提醒:${item.title}`); + li.children[1].textContent = item.needsAnswer ? '需要你回答' : STATUS_LABEL[item.displayStatus]; li.children[1].dataset.status = item.needsAnswer ? 'needs_input' : item.displayStatus; + li.children[2].children[0].setAttribute('aria-label', `本次静音:${item.title}`); + return li; + }); + if (!nodes.length) { const empty = document.createElement('li'); empty.className = 'muted'; empty.textContent = '暂无当前提醒'; nodes.push(empty); } + const focused = document.activeElement; + if (nodes.length !== els.reminderList.children.length || nodes.some((node, i) => node !== els.reminderList.children[i])) els.reminderList.replaceChildren(...nodes); + if (focused?.isConnected && els.reminderList.contains(focused)) focused.focus({ preventScroll: true }); + els.mutedReminders.hidden = !state.mutedIds.length; + const mutedButtons = new Map([...els.mutedList.children].map((button) => [button.dataset.sessionId, button])); + const mutedFocus = document.activeElement; + const mutedNodes = state.mutedIds.map((id) => { + let button = mutedButtons.get(id); + if (!button) { + button = document.createElement('button'); button.type = 'button'; button.className = 'btn small'; button.dataset.sessionId = id; + button.addEventListener('click', () => { reminders.unmute(id); applyActivity(); }); + } + button.textContent = `恢复提醒:${app.sessions.find((row) => row.sessionId === id)?.title || '该会话'}`; + return button; + }); + if (mutedNodes.length !== els.mutedList.children.length || mutedNodes.some((node, i) => node !== els.mutedList.children[i])) els.mutedList.replaceChildren(...mutedNodes); + if (mutedFocus?.isConnected && els.mutedList.contains(mutedFocus)) mutedFocus.focus({ preventScroll: true }); +} + +function initSpeech() { + const labels = { requesting: '正在请求麦克风…', recording: '正在录音,请点击停止。', transcribing: '正在转写…' }; + speechInput = createSpeechInput({ + getMiniApp: () => window.mcode?.miniapp, + onState(value) { + speechState = value.state; + const busy = !['idle', 'error'].includes(value.state); + els.speechStart.disabled = busy; + els.speechStop.disabled = value.state !== 'recording'; + els.speechCancel.disabled = !busy; + els.draftSpeech.disabled = busy || !speechInput?.isAvailable(); + els.popoutBtn.disabled = busy || app.modeSwitching; + setMsg(els.speechMsg, value.state === 'error' ? `语音未完成(${value.errorCode || 'unknown'}),已有文字保持不变。` : labels[value.state] || '转写只填入原属草稿区,不会自动追加或发送。', value.state === 'error' ? 'error' : 'info'); + if (value.state !== 'transcribing') els.speechPartial.textContent = ''; + }, + onPartial(text) { els.speechPartial.textContent = `转写处理中:${text}`; }, + onText(text) { + if (disposed || !text) return; + draftController.syncText('panel', els.talkText.value); + els.talkText.value = draftController.appendText('panel', `${els.talkText.value ? '\n' : ''}${text}`); + setMsg(els.talkMsg, '转写已填入原聊天草稿区。确认后可添加到原聊天输入框。', 'ok'); + }, + }); + const available = speechInput.isAvailable(); + els.speechControls.hidden = !available; + els.draftSpeech.hidden = !available; + setMsg(els.speechMsg, available ? '转写只填入原属草稿区,不会自动追加或发送。' : '当前环境不支持语音转写,可直接输入文字。'); + listen(els.speechStart, 'click', () => { startSpeech(); }); + listen(els.draftSpeech, 'click', () => startSpeech()); + listen(els.speechStop, 'click', () => { void speechInput.stop(); }); + listen(els.speechCancel, 'click', () => { speechInput.cancel(); }); + disposers.push(() => speechInput.dispose()); +} + +function startSpeech() { + if (!['idle', 'error'].includes(speechState)) return; + void speechInput.start(); +} + +function renderScale() { + root.style.setProperty('--minimax-pet-height', `${208 * prefs.scale}px`); + compactBubbleSize.height = Math.max(340, Math.ceil(208 * prefs.scale) + 232); + syncSegmented(els.scaleGroup, prefs.scale); +} + +function renderGallery() { + const focusedPetId = document.activeElement?.dataset?.petId; + const items = app.pets.map((pet) => { + const selected = app.pet?.id === pet.id; + const li = document.createElement('li'); + li.className = 'pet-card'; + li.dataset.selected = String(selected); + + const pick = document.createElement('button'); + pick.type = 'button'; + pick.className = 'pet-pick'; + pick.dataset.petId = pet.id; + pick.setAttribute('aria-pressed', String(selected)); + pick.setAttribute('aria-label', `使用 ${pet.displayName}`); + const thumb = document.createElement('span'); + thumb.className = 'thumb'; + thumb.setAttribute('aria-hidden', 'true'); + const url = app.spriteUrls.get(pet.id); + if (url) { + thumb.style.backgroundImage = `url(${JSON.stringify(url)})`; + thumb.style.backgroundSize = `${1536 * THUMB_SCALE}px auto`; + } + const info = document.createElement('span'); + info.className = 'pet-info'; + const name = document.createElement('span'); + name.className = 'pet-card-name'; + name.textContent = pet.displayName; + name.title = pet.description || pet.displayName; + const meta = document.createElement('span'); + meta.className = 'pet-card-meta'; + const ver = document.createElement('span'); + ver.className = 'badge'; + ver.textContent = `v${pet.version}`; + const src = document.createElement('span'); + src.className = 'badge'; + src.textContent = pet.source === 'imported' ? '导入' : '内置'; + meta.append(ver, src); + if (selected) { + const on = document.createElement('span'); + on.className = 'badge is-selected'; + on.textContent = '使用中'; + meta.append(on); + } + info.append(name, meta); + pick.append(thumb, info); + pick.addEventListener('click', () => selectPet(pet.id)); + li.append(pick); + + if (pet.source === 'imported') { + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'btn small danger'; + del.textContent = '删除'; + del.setAttribute('aria-label', `删除 ${pet.displayName}`); + del.addEventListener('click', () => deletePet(pet)); + li.append(del); + } + return li; + }); + if (!items.length) { + const li = document.createElement('li'); + li.className = 'muted'; + li.textContent = '还没有可用的宠物'; + items.push(li); + } + els.gallery.replaceChildren(...items); + if (focusedPetId) els.gallery.querySelector(`[data-pet-id="${CSS.escape(focusedPetId)}"]`)?.focus(); +} + +function buildSegmented(fieldset, name, options, onChange) { + for (const opt of options) { + const label = document.createElement('label'); + label.className = 'seg-option'; + const input = document.createElement('input'); + input.type = 'radio'; + input.name = name; + input.value = String(opt.value); + input.addEventListener('change', () => { + if (input.checked) onChange(opt.value); + }); + const span = document.createElement('span'); + span.textContent = opt.label; + label.append(input, span); + fieldset.append(label); + } +} + +function syncSegmented(fieldset, value) { + for (const input of fieldset.querySelectorAll('input')) input.checked = input.value === String(value); +} + +// --------------------------------------------------------------------------- +// Event wiring +// --------------------------------------------------------------------------- + +function bindPanel() { + buildSegmented( + els.demoStatus, + 'demo-status', + DEMO_ORDER.map((s) => ({ value: s, label: STATUS_LABEL[s] })), + setDemoStatus, + ); + buildSegmented(els.scaleGroup, 'scale', SCALES, setScale); + listen(els.activityRetry, 'click', () => { void activitySource?.refresh(); }); + listen(els.clearCompleted, 'click', () => { activitySource?.clearCompleted(); }); + listen(els.popoutBtn, 'click', popOut); + listen(els.waveBtn, 'click', () => playOneShot('waving')); + listen(els.jumpBtn, 'click', () => playOneShot('jumping')); + + listen(els.importPick, 'click', () => els.importInput.click()); + listen(els.importInput, 'change', () => { + const files = els.importInput.files; + if (files?.length) handleImportFiles(files).catch(logError('import_files')); + els.importInput.value = ''; + }); + listen(els.dropzone, 'dragover', (e) => { + e.preventDefault(); + els.dropzone.classList.add('is-over'); + }); + listen(els.dropzone, 'dragleave', (e) => { + if (!els.dropzone.contains(e.relatedTarget)) els.dropzone.classList.remove('is-over'); + }); + listen(els.dropzone, 'drop', (e) => { + e.preventDefault(); + els.dropzone.classList.remove('is-over'); + const files = e.dataTransfer?.files; + if (files?.length) handleImportFiles(files).catch(logError('import_drop')); + }); + // A stray drop elsewhere must not navigate the managed page to the file. + listen(document, 'dragover', (e) => e.preventDefault()); + listen(document, 'drop', (e) => e.preventDefault()); + listen(els.importCancel, 'click', () => { + panelNavigation?.closeImport(); + }); + listen(els.importConfirm, 'click', () => confirmImport()); +} + +function bindDesk() { + listen(document, 'mousemove', onDocumentMouseMove); + listen(document, 'mouseout', (e) => { + if (!e.relatedTarget) onPointerLeave(); + }); + listen(root, 'mouseleave', onPointerLeave); + listen(window, 'blur', () => { + if (app.mode === 'window') closeMenu(); + }); + + listen(els.sprite, 'pointerenter', () => { if (app.mode !== 'window') return; app.hovered = true; applyActivity(); }); + listen(els.sprite, 'pointerleave', () => { app.hovered = false; applyActivity(); }); + listen(els.sprite, 'pointerdown', onSpritePointerDown); + listen(els.sprite, 'pointermove', onSpritePointerMove); + listen(els.sprite, 'pointerup', (e) => finishDrag(e, false)); + listen(els.sprite, 'pointercancel', (e) => finishDrag(e, true)); + listen(els.sprite, 'keydown', (e) => { + if (app.mode !== 'window') return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + noteInteraction(); + void openWorkbench('tasks'); + } else if (e.key === 'F10' && e.shiftKey) { + e.preventDefault(); + const r = els.sprite.getBoundingClientRect(); + openMenu(r.left + r.width / 2, r.top + r.height / 2); + } + }); + + listen(document, 'contextmenu', (e) => { + if (app.mode !== 'window') return; + if (els.workbench.contains(e.target)) return; + e.preventDefault(); + if (!els.desk.contains(e.target) || els.menu.contains(e.target)) return; + let { clientX: x, clientY: y } = e; + if (x === 0 && y === 0) { + // Keyboard ContextMenu key: anchor to the sprite. + const r = els.sprite.getBoundingClientRect(); + x = r.left + r.width / 2; + y = r.top + r.height / 2; + } + openMenu(x, y); + }); + + listen(document, 'pointerdown', (e) => { + if (app.mode !== 'window') return; + noteInteraction(); + if (app.menuOpen && !els.menu.contains(e.target)) closeMenu(); + }); + + listen(document, 'keydown', (e) => { + if (app.mode !== 'window' || e.key !== 'Escape') return; + if (app.workbenchOpen) { void closeWorkbench(); } + else if (app.composerOpen) closeComposer({ restoreFocus: true }); + else hideQuickbar(); + }); + + listen(els.menu, 'keydown', onMenuKeyDown); + + listen(els.quickTasks, 'click', () => { void openWorkbench('tasks'); }); + listen(els.bubble, 'click', () => { void openWorkbench('tasks'); }); + listen(els.bubble, 'keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void openWorkbench('tasks'); } + }); + listen(els.quickMenu, 'click', () => { + const r = els.quickMenu.getBoundingClientRect(); + openMenu(r.left + r.width / 2, r.bottom); + }); + listen(els.quickbar, 'pointerenter', armQuickbarHide); + + listen(els.composerClose, 'click', () => closeComposer({ restoreFocus: true })); + listen(els.composer, 'focusin', () => { + app.composerFocused = true; + refreshInteractivity(); + }); + listen(els.composer, 'focusout', (e) => { + if (els.composer.contains(e.relatedTarget)) return; + app.composerFocused = false; + refreshInteractivity(); + }); +} + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- + +function showFatal(text) { + root.classList.remove('booting'); + els.loadError.textContent = text; + els.loadError.hidden = false; +} + +async function boot() { + player = new SpritePlayer(els.sprite, { scale: 1 }); + animationGate = createAnimationGate(player, logError('play_activity')); + player.setReducedMotion(reducedMotion.matches); + panelNavigation = createPanelNavigation({ + panel: els.panel, + beforeCloseImport() { + if (importState.busy) return false; + resetImport(); setMsg(els.importMsg, '已取消导入'); return true; + }, + onLeaveDraft() { + if (!['idle', 'error'].includes(speechState)) speechInput?.cancel(); + }, + }); + disposers.push(() => panelNavigation.dispose()); + listen(reducedMotion, 'change', (e) => { + player.setReducedMotion(e.matches); + importState.player?.setReducedMotion(e.matches); + if (e.matches) stopLook(); + }); + + bindPanel(); + initWorkbench(); + bindDesk(); + bindDraftForms(); + initSpeech(); + listen(window, 'pagehide', disposeApp); + await initPreferences(); + if (disposed) return; + await initMode(); + if (disposed) return; + root.classList.remove('booting'); + await initActivitySource(); + renderAll(); + + try { + await refreshPets(); + } catch (error) { + showLoadError(error); + } + const initial = app.pets.find((p) => p.id === prefs.petId) ?? app.pets[0]; + if (initial) await selectPet(initial.id); + else if (!app.loadError) showLoadError({ code: 'no_pets', message: '没有可用的宠物' }); + renderAll(); + noteInteraction(); + +} + +function disposeApp() { + if (disposed) return; + disposed = true; + for (const dispose of disposers.splice(0).reverse()) { + try { dispose(); } catch (error) { console.warn('[pet] dispose_failed:', errCode(error), error); } + } + for (const key of ['positionTimer', 'ctWatchdog', 'menuCloseTimer', 'quickbarTimer', 'transientTimer']) { + clearTimeout(app[key]); + clearInterval(app[key]); + app[key] = null; + } + clearInterval(importState.cycleTimer); + importState.player?.destroy(); + if (importState.blobUrl) URL.revokeObjectURL(importState.blobUrl); + player?.destroy(); +} + +try { + const mod = await import('/assets/pet-player.js'); + ({ STATES, SpritePlayer, loadPet, loadPetFromParts } = mod); + if (typeof SpritePlayer !== 'function' || typeof loadPet !== 'function' || typeof loadPetFromParts !== 'function') { + throw Object.assign(new Error('pet-player.js 缺少约定的导出'), { code: 'player_module_invalid' }); + } +} catch (error) { + console.error('[pet] player_module_unavailable:', errCode(error), error); + SpritePlayer = null; + showFatal(`播放器加载失败(${error?.code ?? 'player_module_unavailable'})`); +} + +if (SpritePlayer) { + boot().catch((error) => { + console.error('[pet] boot_failed:', errCode(error), error); + showFatal(`启动失败(${errCode(error)})`); + }); +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-player.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-player.js new file mode 100644 index 0000000..cfaa102 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-player.js @@ -0,0 +1,383 @@ +// Codex-compatible atlas player. Zero dependencies; no DOM access at import time. +// Desktop playback: three action cycles, then slow idle; explicit loop is for previews. + +export const CELL = { width: 192, height: 208, columns: 8 }; + +export const STATES = ['idle', 'running-right', 'running-left', 'waving', 'jumping', 'failed', 'waiting', 'running', 'review']; + +const frames = (count, ms, last) => [...Array(count - 1).fill(ms), last]; + +/** @type {Record} */ +export const ANIMATIONS = { + idle: { row: 0, durations: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, durations: frames(8, 120, 220) }, + 'running-left': { row: 2, durations: frames(8, 120, 220) }, + waving: { row: 3, durations: frames(4, 140, 280) }, + jumping: { row: 4, durations: frames(5, 140, 280) }, + failed: { row: 5, durations: frames(8, 140, 240) }, + waiting: { row: 6, durations: frames(6, 150, 260) }, + running: { row: 7, durations: frames(6, 120, 220) }, + review: { row: 8, durations: frames(6, 150, 280) }, +}; + +const ATLAS_WIDTH = CELL.width * CELL.columns; // 1536 +const ROWS = { 1: 9, 2: 11 }; +const LOOK_ROW = 9; +const ALPHA_THRESHOLD = 24; + +/** 1536×1872 → 1, 1536×2288 → 2, otherwise null. */ +export function atlasVersionFromSize(width, height) { + if (width !== ATLAS_WIDTH) return null; + if (height === ROWS[1] * CELL.height) return 1; + if (height === ROWS[2] * CELL.height) return 2; + return null; +} + +function petError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0; + +function validatePetJson(petJson) { + if (!petJson || typeof petJson !== 'object' || Array.isArray(petJson)) { + throw petError('pet_json_invalid', 'pet.json must be a JSON object'); + } + for (const key of ['id', 'displayName', 'spritesheetPath']) { + if (!isNonEmptyString(petJson[key])) throw petError('pet_json_invalid', `pet.json "${key}" must be a non-empty string`); + } + if (petJson.description !== undefined && typeof petJson.description !== 'string') { + throw petError('pet_json_invalid', 'pet.json "description" must be a string'); + } + const v = petJson.spriteVersionNumber; + if (v !== undefined && v !== 1 && v !== 2) { + throw petError('pet_json_invalid', `pet.json "spriteVersionNumber" must be 1 or 2 (got ${JSON.stringify(v)})`); + } + return v === 2 ? 2 : 1; +} + +function loadImage(url) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(petError('sprite_unreadable', `Could not load spritesheet: ${url}`)); + image.src = url; + }); +} + +/** + * @typedef {{ id: string, displayName: string, description: string, spriteUrl: string, version: 1|2, image: HTMLImageElement }} LoadedPet + * @param {any} petJson parsed pet.json object + * @param {string} spriteUrl directly loadable image URL (http(s):, blob:, data:) + * @returns {Promise} + */ +export async function loadPetFromParts(petJson, spriteUrl) { + const declared = validatePetJson(petJson); + const image = await loadImage(spriteUrl); + const { naturalWidth: w, naturalHeight: h } = image; + const actual = atlasVersionFromSize(w, h); + if (actual === null) { + throw petError('sprite_size_invalid', `Spritesheet is ${w}×${h}; expected 1536×1872 (v1) or 1536×2288 (v2)`); + } + if (actual !== declared) { + throw petError('sprite_version_mismatch', `pet.json declares v${declared} but spritesheet size ${w}×${h} is v${actual}`); + } + return { + id: petJson.id, + displayName: petJson.displayName, + description: petJson.description ?? '', + spriteUrl, + version: actual, + image, + }; +} + +/** @param {string} petJsonUrl @returns {Promise} */ +export async function loadPet(petJsonUrl) { + const absolute = new URL(petJsonUrl, globalThis.document?.baseURI ?? globalThis.location?.href).href; + let text; + try { + const res = await fetch(absolute); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + text = await res.text(); + } catch (cause) { + throw petError('pet_json_unreadable', `Could not fetch ${absolute}: ${cause.message}`); + } + let petJson; + try { + petJson = JSON.parse(text); + } catch (cause) { + throw petError('pet_json_invalid', `pet.json is not valid JSON: ${cause.message}`); + } + validatePetJson(petJson); + return loadPetFromParts(petJson, new URL(petJson.spritesheetPath, absolute).href); +} + +// One ImageData per sprite image, shared across players. Value null = sampling unavailable (tainted etc.). +const pixelCache = new WeakMap(); + +function pixelsFor(image) { + if (!pixelCache.has(image)) { + let pixels = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, 0, 0); + pixels = ctx.getImageData(0, 0, canvas.width, canvas.height); + } catch { + pixels = null; + } + pixelCache.set(image, pixels); + } + return pixelCache.get(image); +} + +const assertState = (state) => { + if (!STATES.includes(state)) throw new RangeError(`Unknown pet state: ${state}`); +}; + +export class SpritePlayer { + /** @param {HTMLElement} element empty element; player owns its width/height/background-* */ + constructor(element, { scale = 1 } = {}) { + this._el = element; + this._pet = null; + this._scale = clampScale(scale); + this._reduced = false; + this._lookIndex = null; + this._state = 'idle'; // requested semantic state, independent of the rendered pose + this._pose = 'idle'; + this._loop = false; + this._repeat = 3; + this._round = 0; + this._transientThen = null; + this._col = 0; + this._resolve = null; // pending transient resolver + this._timer = null; + this._due = 0; + this._remaining = null; // ms left on the current frame while paused (hidden) + this._destroyed = false; + this._onVisibility = () => this._handleVisibility(); + document.addEventListener('visibilitychange', this._onVisibility); + } + + /** @param {LoadedPet} loadedPet */ + setPet(loadedPet) { + if (this._destroyed) return; + this._pet = loadedPet; + if (loadedPet.version !== 2) this._lookIndex = null; + this._restart(); + } + + get state() { return this._state; } + + /** Actual rendered pose; 'look' denotes the v2 static direction override. */ + get pose() { + if (this._reduced) return this._state; + return this._lookIndex === null ? this._pose : 'look'; + } + + get frame() { + if (this._reduced) return { row: ANIMATIONS[this._state].row, col: 0 }; + if (this._lookIndex !== null) return { row: LOOK_ROW + Math.floor(this._lookIndex / 8), col: this._lookIndex % 8 }; + return { row: ANIMATIONS[this._pose].row, col: this._col }; + } + + get supportsLook() { return this._pet?.version === 2; } + + /** + * Request a normal playback track. Non-idle actions play three cycles, then + * render slow idle while `state` retains the request. Callers deduplicate + * their task/event identity; an explicit repeat call intentionally restarts. + * @returns {Promise} resolves immediately + */ + play(state, { loop = false } = {}) { + assertState(state); + if (this._destroyed) return Promise.resolve(); + this._settle(); + this._setNormal(state, loop); + this._restart(); + return Promise.resolve(); + } + + /** + * Temporary action, then a normal track for `then`. Resolves when the action + * finishes or is interrupted. Reduced motion / no loaded pet finishes it + * immediately so callers never wait for a non-existent animation timer. + * @returns {Promise} + */ + playTransient(state, { then = 'idle', repeat = 3 } = {}) { + assertState(state); + assertState(then); + if (!Number.isSafeInteger(repeat) || repeat < 1) throw new RangeError('repeat must be a positive integer'); + if (this._destroyed) return Promise.resolve(); + this._settle(); + this._state = state; + this._loop = false; + this._repeat = repeat; + this._transientThen = then; + const done = new Promise((resolve) => { this._resolve = resolve; }); + this._restart(); + return done; + } + + look(angleDegOrNull) { + if (this._destroyed || !this.supportsLook) return; + if (angleDegOrNull === null || angleDegOrNull === undefined || !Number.isFinite(angleDegOrNull)) { + if (this._lookIndex === null) return; + this._lookIndex = null; + this._restart(); + return; + } + const index = ((Math.round(angleDegOrNull / 22.5) % 16) + 16) % 16; + const wasLooking = this._lookIndex !== null; + this._lookIndex = index; + if (!wasLooking) { + this._stopTimer(); + if (this._transientThen !== null) this._finishTransient(); + } + this._render(); + } + + setScale(scale) { + this._scale = clampScale(scale); + this._render(); + } + + setReducedMotion(enabled) { + if (this._destroyed || this._reduced === !!enabled) return; + this._reduced = !!enabled; + this._restart(); + } + + isOpaqueAt(clientX, clientY) { + if (!this._pet || this._destroyed) return false; + const rect = this._el.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + if (x < 0 || y < 0 || x >= rect.width || y >= rect.height) return false; + const pixels = pixelsFor(this._pet.image); + if (!pixels) return true; // bounding-box fallback + const { row, col } = this.frame; + const px = col * CELL.width + Math.min(CELL.width - 1, Math.floor((x * CELL.width) / rect.width)); + const py = row * CELL.height + Math.min(CELL.height - 1, Math.floor((y * CELL.height) / rect.height)); + return pixels.data[(py * pixels.width + px) * 4 + 3] > ALPHA_THRESHOLD; + } + + destroy() { + if (this._destroyed) return; + this._stopTimer(); + this._settle(); + document.removeEventListener('visibilitychange', this._onVisibility); + const s = this._el.style; + for (const prop of ['width', 'height', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat']) s[prop] = ''; + this._destroyed = true; + this._pet = null; + } + + // --- internals --- + + _settle() { + const resolve = this._resolve; + this._resolve = null; + resolve?.(); + } + + _stopTimer() { + clearTimeout(this._timer); + this._timer = null; + this._remaining = null; + } + + _setNormal(state, loop = false) { + this._state = state; + this._pose = state; + this._loop = !!loop; + this._repeat = 3; + this._round = 0; + this._transientThen = null; + this._col = 0; + } + + _finishTransient() { + const then = this._transientThen; + if (then === null) return; + this._setNormal(then); + this._settle(); + } + + /** Rebuild the requested track from its first frame (also used on look exit). */ + _restart() { + this._stopTimer(); + this._pose = this._state; + this._round = 0; + this._col = 0; + if (!this._pet || this._reduced || this._lookIndex !== null) { + if (this._transientThen !== null) this._finishTransient(); + this._render(); + return; + } + this._render(); + this._schedule(ANIMATIONS[this._pose].durations[0]); + } + + _schedule(ms) { + if (document.hidden) { + this._remaining = ms; + return; + } + this._due = performance.now() + ms; + this._timer = setTimeout(() => this._tick(), ms); + } + + _tick() { + this._timer = null; + if (this._destroyed) return; + this._col += 1; + if (this._col >= ANIMATIONS[this._pose].durations.length) { + this._col = 0; + this._round += 1; + if (!this._loop && this._round >= this._repeat) { + if (this._transientThen !== null) this._finishTransient(); + else this._pose = 'idle'; + } + } + this._render(); + this._schedule(ANIMATIONS[this._pose].durations[this._col]); + } + + _handleVisibility() { + if (this._destroyed) return; + if (document.hidden) { + if (this._timer === null) return; + clearTimeout(this._timer); + this._timer = null; + this._remaining = Math.max(0, this._due - performance.now()); + } else if (this._remaining !== null) { + const ms = this._remaining; + this._remaining = null; + this._schedule(ms); + } + } + + _render() { + if (!this._pet || this._destroyed) return; + const k = this._scale; + const { row, col } = this.frame; + const s = this._el.style; + s.width = `${CELL.width * k}px`; + s.height = `${CELL.height * k}px`; + s.backgroundImage = `url("${this._pet.spriteUrl}")`; + s.backgroundRepeat = 'no-repeat'; + s.backgroundSize = `${ATLAS_WIDTH * k}px ${ROWS[this._pet.version] * CELL.height * k}px`; + s.backgroundPosition = `-${col * CELL.width * k}px -${row * CELL.height * k}px`; + } +} + +function clampScale(scale) { + const n = Number(scale); + return Number.isFinite(n) ? Math.min(1.5, Math.max(0.5, n)) : 1; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-presentation.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/pet-presentation.mjs new file mode 100644 index 0000000..015d63c --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-presentation.mjs @@ -0,0 +1,29 @@ +export function aggregateText(counts) { + if (!counts) return ''; + return `${counts.total} 个可见任务 · ${counts.running} 运行中 · ${counts.completed} 本次完成 · ${counts.error} 异常 · ${counts.aborted} 已停止 · ${counts.idle} 空闲`; +} + +export function notificationItems(items) { + return items.filter((item) => item.needsAnswer === true || ['completed', 'error'].includes(item.displayStatus)) + .sort((a, b) => Number(b.needsAnswer === true) - Number(a.needsAnswer === true)).slice(0, 3); +} + +export function notificationText(item, previews = {}) { + if (item.needsAnswer === true) return '需要你回答'; + return previews[item.sessionId] || (item.displayStatus === 'error' ? '任务出现异常' : '本次观察到任务完成'); +} + +// A task snapshot may refresh while its animation has already settled into calm idle. +export function createAnimationGate(player, onError = () => {}) { + let previous = null; + return { + apply(state, key, { loop = false } = {}) { + const identity = `${key}:${state}:${loop}`; + if (previous === identity && player.state === state) return false; + previous = identity; + void player.play(state, { loop }).catch(onError); + return true; + }, + invalidate() { previous = null; }, + }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet.css b/sources/minimax-ai/minimax-pet/miniapp/client/pet.css new file mode 100644 index 0000000..2b24b95 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet.css @@ -0,0 +1,1680 @@ +/* MiniMax Pet — desktop pet: styles for the Codex Pet Mini App page (inline panel + window mode). */ + +: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; + --pet-shadow: 0 0 20px rgb(10 10 10 / 8%); + --pet-bubble-shadow: 0 1px 8px rgb(10 10 10 / 14%); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --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; + --pet-shadow: 0 0 20px rgb(0 0 0 / 30%); + --pet-bubble-shadow: 0 1px 8px rgb(0 0 0 / 40%); + color-scheme: dark; + } +} + +[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; + --pet-shadow: 0 0 20px rgb(0 0 0 / 30%); + --pet-bubble-shadow: 0 1px 8px rgb(0 0 0 / 40%); + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + font-family: ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 22px; + color: var(--mcode-text); + background: var(--mcode-bg); +} + +html.booting body { + visibility: hidden; + background: transparent; +} + +code { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.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; +} + +/* ------------------------------------------------------------------ */ +/* Inline panel */ +/* ------------------------------------------------------------------ */ + +.panel { + max-width: 1040px; + margin: 0 auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.panel-title { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.header-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + max-width: 320px; +} + +h1 { + margin: 0; + font-size: 18px; + line-height: 28px; + font-weight: 600; +} + +h2 { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 500; +} + +.muted { + margin: 0; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +.msg { + margin: 0; + min-height: 0; + font-size: 12px; + line-height: 18px; + color: var(--mcode-text-muted); +} +.msg:empty { + display: none; +} +.msg[data-kind='error'] { + color: var(--mcode-danger); +} +.msg[data-kind='ok'] { + color: var(--mcode-success); +} + +.note { + margin: 0; + padding: 8px 12px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +.alert { + padding: 8px 12px; + border: 1px solid var(--mcode-danger); + border-radius: 8px; + background: var(--mcode-surface); + color: var(--mcode-danger); + font-size: 13px; + line-height: 20px; +} + +.badge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px solid var(--mcode-border); + border-radius: 999px; + background: var(--mcode-surface-muted); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; + white-space: nowrap; +} +.badge[data-source='real'] { + color: var(--mcode-success); + border-color: currentColor; +} +.badge[data-source='demo'] { + color: var(--mcode-warning); + border-color: currentColor; +} +.badge.is-selected { + color: var(--mcode-accent); + border-color: currentColor; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 0 14px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; + white-space: nowrap; + cursor: pointer; + transition: + background-color 150ms ease-out, + opacity 150ms ease-out; +} +.btn:hover { + background: var(--mcode-bg-secondary); +} +.btn.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.btn.primary:hover { + opacity: 0.86; +} +.btn.danger { + color: var(--mcode-danger); +} +.btn.small { + height: 28px; + padding: 0 10px; + font-size: 12px; +} +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +:focus-visible { + outline: 2px solid var(--mcode-accent); + outline-offset: 2px; +} + +.row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} +.row.end { + justify-content: flex-end; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 4fr); + gap: 16px; + align-items: start; +} + +.side { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + min-width: 0; +} + +.stage { + display: flex; + align-items: center; + justify-content: center; + min-height: 248px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + overflow: hidden; +} + +.stage-status { + margin: 0; + text-align: center; + color: var(--mcode-text-muted); +} + +.stage-card .row { + justify-content: center; +} + +/* Segmented control (radio group) */ +.segmented { + display: inline-flex; + flex-wrap: wrap; + gap: 2px; + margin: 0; + padding: 2px; + border: 0; + border-radius: 8px; + background: var(--mcode-bg-secondary); + align-self: flex-start; +} +.seg-option { + position: relative; +} +.seg-option input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; + cursor: pointer; +} +.seg-option span { + display: block; + padding: 4px 12px; + border-radius: 6px; + color: var(--mcode-text-muted); + font-size: 13px; + line-height: 22px; + transition: background-color 150ms ease-out; +} +.seg-option input:checked + span { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 0 0 1px var(--mcode-border); +} +.seg-option input:focus-visible + span { + outline: 2px solid var(--mcode-accent); + outline-offset: 1px; +} + +.demo-controls { + display: flex; + flex-direction: column; + gap: 8px; +} + +.check { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--mcode-text-muted); +} + +.session-list { + max-height: 250px; + overflow: auto; + overscroll-behavior: contain; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.session-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 38px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--mcode-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.session-row:hover { + background: var(--mcode-bg-secondary); +} +.session-row:disabled { + cursor: default; +} +.session-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-current { + font-size: 12px; + color: var(--mcode-accent); +} + +.pill { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid currentColor; + font-size: 12px; + white-space: nowrap; + color: var(--mcode-text-muted); +} +.pill[data-status='running'] { + color: var(--mcode-accent); +} +.pill[data-status='needs_input'] { + color: var(--mcode-warning); +} +.pill[data-status='ready'] { + color: var(--mcode-success); +} +.pill[data-status='blocked'] { + color: var(--mcode-danger); +} + +.talk-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.draft-target-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.target-select { + width: 100%; + min-width: 0; + height: 30px; + padding: 0 6px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + color: var(--mcode-text); + font: inherit; +} + +textarea, +.composer input { + width: 100%; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface-muted); + color: var(--mcode-text); + font: inherit; +} +textarea { + padding: 8px 12px; + resize: vertical; + min-height: 72px; +} +textarea::placeholder, +.composer input::placeholder { + color: var(--mcode-text-subtle); +} +textarea:focus, +.composer input:focus { + outline: none; + border-color: var(--mcode-accent); +} + +/* Gallery */ +.gallery { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; +} +.pet-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); +} +.pet-card[data-selected='true'] { + border-color: var(--mcode-accent); +} +.pet-pick { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.thumb { + flex: none; + width: calc(192px * 0.375); + height: calc(208px * 0.375); + border-radius: 8px; + background-color: var(--mcode-bg-secondary); + background-repeat: no-repeat; + background-position: 0 0; +} +.pet-info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +.pet-card-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} +.pet-card-meta { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +/* Import */ +.dropzone { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + min-height: 72px; + padding: 12px; + border: 1px dashed var(--mcode-border-strong); + border-radius: 12px; + color: var(--mcode-text-muted); + opacity: 0.9; + transition: background-color 150ms ease-out; +} +.dropzone.is-over { + border-color: var(--mcode-accent); + background: var(--mcode-bg-secondary); +} +.import-preview { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: flex-start; +} +.import-stage { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 168px; + height: 180px; + border-radius: 8px; + background: var(--mcode-bg-secondary); + overflow: hidden; +} +.import-info { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 6px; +} +.import-errors { + margin: 0; + padding-left: 18px; + color: var(--mcode-danger); + font-size: 13px; + line-height: 20px; +} +.import-errors:empty { + display: none; +} + +@media (max-width: 768px) { + .panel { + padding: 16px; + } + .layout { + grid-template-columns: 1fr; + } + .header-actions { + align-items: flex-start; + } +} + +/* ------------------------------------------------------------------ */ +/* Window mode (popped out: transparent, frameless, 280×340) */ +/* ------------------------------------------------------------------ */ + +html.mode-window, +html.mode-window body { + height: 100%; + overflow: hidden; + background: transparent; + user-select: none; + -webkit-user-select: none; +} + +.desk { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 8px 8px 4px; + overflow: hidden; +} + +.desk-overlays { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + max-width: 100%; + min-height: 0; + max-height: calc(100% - var(--minimax-pet-height, 104px) - 12px); + overflow-y: auto; +} + +.desk-pet-slot { + flex: none; + display: flex; + justify-content: center; +} + +html.mode-window .pet-sprite { + cursor: grab; + touch-action: none; + border-radius: 8px; +} +html.mode-window .pet-sprite.is-dragging { + cursor: grabbing; +} +html.mode-window .pet-sprite:focus-visible { + outline-offset: -2px; +} + +.bubble { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + max-width: 248px; + min-height: 0; + overflow-y: auto; + padding: 6px 12px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--pet-bubble-shadow); + font-size: 13px; + line-height: 18px; + text-align: center; + overflow-wrap: anywhere; +} +.bubble[data-kind='error'] { + color: var(--mcode-danger); +} +.bubble[data-kind='ok'] { + color: var(--mcode-success); +} +.bubble.is-clickable { + cursor: pointer; +} +.bubble-title { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 16px; +} +.bubble-link { + padding: 0; + border: 0; + background: transparent; + color: var(--mcode-accent); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.quickbar { + display: flex; + gap: 6px; +} + +.chip { + height: 28px; + padding: 0 10px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: var(--pet-bubble-shadow); + font: inherit; + font-size: 12px; + white-space: nowrap; + cursor: pointer; + transition: background-color 150ms ease-out; +} +.chip:hover { + background: var(--mcode-bg-secondary); +} +.chip.primary { + background: var(--mcode-primary-action-bg); + color: var(--mcode-primary-action-text); + border-color: transparent; +} +.chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.composer { + position: absolute; + z-index: 4; + top: 8px; + left: 50%; + transform: translateX(-50%); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + flex: none; + align-items: center; + gap: 4px; + width: 256px; + max-width: 100%; + padding: 4px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + box-shadow: var(--pet-bubble-shadow); +} +.composer input { + grid-column: 1 / -1; + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + font-size: 13px; + user-select: text; + -webkit-user-select: text; +} +.composer .target-select, +#composer-msg { + grid-column: 1 / -1; +} +#composer-origin { + grid-column: 1 / -1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0 4px; +} +#composer-msg { + max-height: 72px; + overflow-y: auto; + margin: 0; + padding: 0 4px; +} +.composer .chip { + box-shadow: none; +} +#composer-send { + justify-self: end; +} + +.menu { + position: absolute; + z-index: 10; + min-width: 160px; + max-width: calc(100% - 8px); + max-height: calc(100% - 8px); + overflow-y: auto; + padding: 4px; + border: 1px solid var(--mcode-border); + border-radius: 12px; + background: var(--mcode-surface); + box-shadow: var(--pet-bubble-shadow); +} +.menu-item { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 32px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--mcode-text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} +.menu-item:hover, +.menu-item:focus { + outline: none; + background: var(--mcode-bg-secondary); +} +.menu-item:focus-visible { + box-shadow: inset 0 0 0 2px var(--mcode-accent); +} +.menu-item[aria-disabled='true'] { + color: var(--mcode-text-subtle); + cursor: default; +} +.mi-mark { + flex: none; + width: 14px; + color: var(--mcode-accent); +} +.mi-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mi-detail { + flex: none; + color: var(--mcode-text-muted); + font-size: 12px; +} +.mi-arrow { + flex: none; + color: var(--mcode-text-muted); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; + } +} + +.last-reply { + max-height: 20rem; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; + user-select: text; + -webkit-user-select: text; +} + +.aggregate-counts { font-weight: 600; line-height: 1.7; } + +/* Small companion bubble; detailed workflows stay in the main panel. */ +.workbench { + position: absolute; + z-index: 5; + left: 8px; + right: 8px; + bottom: calc(var(--minimax-pet-height, 104px) + 12px); + max-height: 216px; + display: flex; + flex-direction: column; + border: 1px solid var(--mcode-border); + border-radius: 12px; + overflow: hidden; + background: var(--mcode-surface); + box-shadow: var(--pet-bubble-shadow); + color: var(--mcode-text); + text-align: left; + user-select: text; + -webkit-user-select: text; + font-size: 12px; + line-height: 18px; +} +.compact-header { display: flex; align-items: center; justify-content: space-between; min-height: 26px; padding: 2px 8px 0 10px; gap: 6px; } +.compact-state { color: var(--mcode-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.compact-button { border: 0; border-radius: 6px; background: transparent; color: var(--mcode-text); font: inherit; cursor: pointer; padding: 3px 6px; } +.compact-button:hover { background: var(--mcode-bg-secondary); } +.compact-button:disabled { opacity: .4; cursor: default; } +.compact-close { color: var(--mcode-text-muted); margin-left: auto; font-size: 17px; padding: 0 5px; } +.compact-body { min-height: 0; overflow: auto; padding: 2px 10px 8px; } +.compact-hint { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--mcode-text-muted); margin: 5px 0; font-size: 11px; line-height: 16px; } +.compact-hint[data-kind='error'] { color: var(--mcode-danger); } +.compact-footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 4px; } +.compact-link { color: var(--mcode-text-muted); font-size: 11px; } +.compact-task-list { list-style: none; margin: 0; padding: 0; } +.compact-task { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0 4px; padding: 3px 0; } +.compact-task + .compact-task { border-top: 1px solid var(--mcode-border); } +.compact-task-title { text-align: left; padding-left: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compact-task-status { justify-self: end; padding-inline: 6px; text-align: right; white-space: nowrap; color: var(--mcode-text-muted); font-size: 10px; } +.compact-task-preview { grid-column: 1 / -1; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; margin: 0; color: var(--mcode-text-muted); font-size: 11px; line-height: 16px; } +.compact-empty { color: var(--mcode-text-muted); padding: 8px 0; } +.notification-stack { display: flex; flex-direction: column-reverse; gap: 5px; width: 248px; max-height: 216px; overflow-y: auto; } +.pet-notice { display: flex; align-items: start; border: 1px solid var(--mcode-border); border-radius: 10px; background: var(--mcode-surface); box-shadow: var(--pet-bubble-shadow); padding: 6px 7px 6px 10px; flex: none; } +.pet-notice-open { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--mcode-text); text-align: left; font: inherit; font-size: 12px; line-height: 17px; padding: 0; cursor: pointer; } +.pet-notice-open strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; } +.pet-notice-preview { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; color: var(--mcode-text-muted); overflow-wrap: anywhere; } +.compact-detail-title { margin: 0; font-size: 12px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.compact-detail-status { margin: 2px 0 2px 6px; font-size: 11px; line-height: 16px; color: var(--mcode-text-muted); } +.compact-detail-reply { margin: 5px 0; max-height: 126px; overflow: auto; overscroll-behavior: contain; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; -webkit-user-select: text; } +.compact-detail-back { color: var(--mcode-text-muted); font-size: 11px; } +.pet-notice-dismiss { flex: none; border: 0; background: transparent; color: var(--mcode-text-muted); cursor: pointer; font-size: 15px; padding: 0 2px 0 6px; } +.reminder-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.reminder-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 6px; padding: 8px; border-radius: 8px; background: var(--mcode-bg-secondary); } +.reminder-item .row { grid-column: 1 / -1; } +.reminder-title { text-align: left; padding: 0; border: 0; color: var(--mcode-text); background: transparent; font: inherit; font-weight: 500; cursor: pointer; overflow-wrap: anywhere; } +.reminder-heading { justify-content: space-between; } +summary { cursor: pointer; color: var(--mcode-text-muted); font-size: 12px; } +.pill[data-status='error'] { color: var(--mcode-danger); } +.pill[data-status='completed'] { color: var(--mcode-success); } +.pill[data-status='running'] { color: var(--mcode-accent); } +#speech-section { display: flex; flex-direction: column; gap: 6px; } + + + +/* Main panel: appearance, task reading, and settings share one quiet surface. + All selectors are panel-scoped so the desktop companion keeps its layout. */ +html.mode-inline, +html.mode-inline body { + height: 100%; + overflow: hidden; +} + +#panel { + width: 100%; + max-width: 1040px; + height: 100dvh; + min-height: 0; + margin: 0 auto; + padding: 0; + gap: 0; + overflow: hidden; + background: var(--mcode-bg); +} + +#panel .panel-header { + flex: none; + flex-wrap: nowrap; + align-items: center; + padding: 24px 28px 18px; +} + +#panel .panel-title { + gap: 2px; +} + +#panel .header-actions { + flex: none; + max-width: 46%; + align-items: flex-end; +} + +#panel .header-actions .msg { + overflow-wrap: anywhere; + text-align: right; +} + +#panel .panel-tabs { + display: flex; + flex: none; + gap: 24px; + padding: 0 28px; + border-bottom: 1px solid var(--mcode-border); +} + +#panel .panel-tabs [role='tab'] { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 42px; + padding: 0 2px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--mcode-text-muted); + font: inherit; + cursor: pointer; +} + +#panel .panel-tabs [aria-selected='true'] { + border-bottom-color: var(--mcode-text); + color: var(--mcode-text); + font-weight: 600; +} + +#panel .tab-count { + min-width: 18px; + padding: 0 5px; + border-radius: 6px; + background: var(--mcode-bg-secondary); + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +#panel > .note, +#panel > .alert { + flex: none; + margin: 12px 28px 0; +} + +#panel .panel-main { + display: block; + flex: 1; + min-height: 0; + overflow: hidden; +} + +#panel .panel-page { + height: 100%; + min-width: 0; + min-height: 0; + padding: 28px; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +#panel .panel-page:focus { + outline-offset: -3px; +} + +#panel h2 { + font-weight: 600; +} + +#panel .appearance-layout { + display: grid; + grid-template-columns: minmax(200px, .85fr) minmax(0, 1.15fr); + align-items: start; + gap: 32px; +} + +#panel .appearance-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + min-width: 0; + padding: 16px 16px 24px; + border-radius: 16px; + background: var(--mcode-bg-secondary); + text-align: center; +} + +#panel #panel-stage { + width: 100%; + min-height: 240px; + background: transparent; +} + +#panel #pet-name { + max-width: 100%; + font-size: 18px; + line-height: 28px; + overflow-wrap: anywhere; +} + +#panel #pet-desc { + max-width: 32ch; + overflow-wrap: anywhere; +} + +#panel .stage-status { + max-width: 100%; + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +#panel .appearance-actions { + justify-content: center; + margin-top: 8px; +} + +#panel .pet-more { + position: relative; +} + +#panel .pet-more > summary { + padding: 7px 6px; + list-style: none; +} + +#panel .pet-more > summary::-webkit-details-marker { + display: none; +} + +#panel .pet-more-content { + position: absolute; + z-index: 2; + top: calc(100% + 4px); + right: 0; + padding: 6px; + border: 1px solid var(--mcode-border); + border-radius: 8px; + background: var(--mcode-surface); +} + +#panel .appearance-gallery { + min-width: 0; +} + +#panel .section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 2px 0 18px; +} + +#panel .gallery { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +#panel .pet-card { + flex-direction: column; + min-width: 0; + padding: 14px 10px; + border-radius: 10px; +} + +#panel .pet-pick { + flex-direction: column; + align-self: stretch; + gap: 8px; + text-align: center; +} + +#panel .pet-info { + align-items: center; + width: 100%; +} + +#panel .pet-card-name { + max-width: 100%; +} + +#panel .pet-card-meta { + justify-content: center; +} + +#panel .pet-card-meta .badge:first-child { + display: none; +} + +#panel .pet-card-meta .badge { + height: 18px; + padding: 0 5px; + border: 0; + border-radius: 4px; + background: transparent; +} + +#panel .pet-card-meta .badge.is-selected { + color: var(--mcode-accent); +} + +#panel #gallery-msg { + margin-top: 12px; +} + +#panel .inline-subview { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 720px; + margin: 0 auto; +} + +#panel .subview-heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +#panel .back-button { + align-self: flex-start; + padding-inline: 0; + border: 0; + background: transparent; + color: var(--mcode-text-muted); +} + +#panel .dropzone { + min-height: 132px; + border-color: var(--mcode-border); + opacity: 1; +} + +#panel .import-preview { + gap: 24px; +} + +#panel .import-info { + min-width: min(240px, 100%); + gap: 10px; + overflow-wrap: anywhere; +} + +#panel .task-page { + display: flex; + overflow: hidden; + padding: 20px 28px 0; +} + +#panel .activity-layout { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + min-height: 0; +} + +#panel .task-toolbar { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding-bottom: 16px; + border-bottom: 1px solid var(--mcode-border); +} + +#panel .task-filters { + display: inline-flex; + gap: 2px; + padding: 3px; + border-radius: 8px; + background: var(--mcode-bg-secondary); +} + +#panel .task-filters .btn { + height: 28px; + padding-inline: 10px; + border: 0; + background: transparent; + color: var(--mcode-text-muted); +} + +#panel .task-filters [aria-pressed='true'] { + background: var(--mcode-surface); + color: var(--mcode-text); + box-shadow: 0 0 0 1px var(--mcode-border); +} + +#panel .aggregate-counts { + margin: 0; + font-size: 12px; + font-weight: 400; + line-height: 18px; +} + +#panel .task-panes { + display: grid; + grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); + flex: 1; + min-width: 0; + min-height: 0; +} + +#panel .task-list-pane { + min-width: 0; + min-height: 0; + padding: 18px 20px 24px 0; + border-right: 1px solid var(--mcode-border); + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +#panel .task-list-note { + margin-bottom: 12px; +} + +#panel .session-list { + max-height: none; + overflow: visible; + gap: 0; +} + +#panel .session-list > li + li, +#panel .reminder-item + .reminder-item { + border-top: 1px solid var(--mcode-border); +} + +#panel .session-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 3px 12px; + min-height: 52px; + padding: 12px 8px; +} + +#panel .session-row[aria-current='true'] { + background: var(--mcode-bg-secondary); +} + +#panel .session-title { + grid-column: 1; + grid-row: 1; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; + overflow-wrap: anywhere; +} + +#panel .session-current { + grid-column: 1; + grid-row: 2; +} + +#panel .session-row .pill { + grid-column: 2; + grid-row: 1; + justify-self: end; +} + +#panel .pill { + height: auto; + padding: 0; + border: 0; + border-radius: 0; + line-height: 22px; + text-align: right; +} + +#panel .pill[data-status='completed'] { + color: var(--mcode-text-muted); +} + +#panel .reminder-list { + gap: 0; +} + +#panel .reminder-item { + gap: 6px 12px; + padding: 12px 8px; + border-radius: 0; + background: transparent; +} + +#panel .reminder-title { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +#panel .reminder-item > .pill { + justify-self: end; +} + +#panel .reminder-item > .row { + justify-content: flex-end; +} + +#panel .reminder-item .btn { + height: 24px; + padding-inline: 0; + border: 0; + background: transparent; + color: var(--mcode-text-muted); +} + +#panel .task-detail { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + min-height: 0; + padding: 20px 0 24px 24px; + overflow: hidden; +} + +#panel #task-detail-back { + display: none; +} + +#panel #detail-title { + overflow-wrap: anywhere; +} + +#panel .detail-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + color: var(--mcode-text-muted); + font-size: 12px; + line-height: 18px; +} + +#panel #detail-note, +#panel #detail-error { + flex: none; + overflow-wrap: anywhere; +} + +#panel #detail-reply { + flex: 1; + min-height: 0; + max-height: none; + margin: 4px 0 0; + padding-right: 8px; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + line-height: 1.7; +} + +#panel #session-msg { + margin-top: 12px; +} + +#panel .settings-content { + max-width: 720px; + margin: 0 auto; +} + +#panel .setting-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px 24px; + padding: 24px 0; + border-bottom: 1px solid var(--mcode-border); +} + +#panel .setting-row:first-child { + padding-top: 0; +} + +#panel .setting-copy { + min-width: 0; +} + +#panel .setting-copy h2 { + margin-bottom: 4px; + font-size: 14px; + line-height: 22px; +} + +#panel .setting-copy p { + overflow-wrap: anywhere; +} + +#panel .setting-message { + grid-column: 1 / -1; + min-width: 0; +} + +#panel #muted-list { + flex-direction: column; + align-items: flex-start; + margin: 10px 0; +} + +#panel #muted-list .btn { + max-width: 100%; + height: auto; + min-height: 28px; + white-space: normal; + overflow-wrap: anywhere; + text-align: left; +} + +#panel #clear-completed { + margin-top: 4px; +} + +#panel .panel-help { + padding-top: 20px; +} + +#panel .panel-help > summary { + padding-block: 4px; +} + +#panel .help-content { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 16px; + overflow-wrap: anywhere; +} + +#panel .talk-form { + gap: 12px; +} + +@media (max-width: 640px) { + #panel .panel-header { + gap: 12px; + padding: 18px 20px 14px; + } + + #panel .panel-header .panel-title > .muted { + display: none; + } + + #panel .header-actions { + max-width: 50%; + } + + #panel .panel-tabs { + gap: 28px; + padding-inline: 20px; + } + + #panel > .note, + #panel > .alert { + margin-inline: 20px; + } + + #panel .panel-page { + padding: 20px; + } + + #panel .appearance-layout { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } + + #panel .appearance-preview { + gap: 5px; + padding: 6px 12px 18px; + } + + #panel #panel-stage { + min-height: 210px; + } + + #panel .appearance-actions { + margin-top: 4px; + } + + #panel .gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + #panel .pet-card { + padding-inline: 8px; + } + + #panel .task-page { + padding-bottom: 0; + overflow: hidden; + } + + #panel .task-toolbar { + align-items: flex-start; + gap: 8px; + padding-bottom: 12px; + } + + #panel .aggregate-counts { + flex-basis: 100%; + } + + #panel .task-panes { + display: flex; + flex-direction: column; + } + + #panel .task-list-pane { + flex: 1; + padding: 16px 0 20px; + border-right: 0; + } + + #panel .task-detail { + flex: 1; + padding: 12px 0 20px; + } + + #panel #task-detail-back { + display: inline-flex; + } + + #panel[data-task-detail='closed'] #detail-card { + display: none; + } + + #panel[data-task-detail='open'] #task-list-pane { + display: none; + } + + #panel .setting-row { + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding-block: 20px; + } + + #panel .setting-row > .btn, + #panel .setting-row .segmented { + justify-self: start; + } + + #panel .import-preview { + gap: 16px; + } + + #panel .import-stage { + margin-inline: auto; + } + + #panel .import-info { + flex-basis: 100%; + min-width: 0; + } +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/pet.json b/sources/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/pet.json new file mode 100644 index 0000000..713e856 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/pet.json @@ -0,0 +1,9 @@ +{ + "id": "minimax-original", + "displayName": "MiniMax Pet", + "description": "MiniMax 原创薄荷色机器人。图集由随包源代码独立绘制,采用 MIT 许可。", + "spritesheetPath": "spritesheet.png", + "spriteVersionNumber": 2, + "author": "MiniMax", + "license": "MIT" +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/spritesheet.png b/sources/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/spritesheet.png new file mode 100644 index 0000000..1207168 Binary files /dev/null and b/sources/minimax-ai/minimax-pet/miniapp/client/pets/minimax-original/spritesheet.png differ diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/preferences.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/preferences.mjs new file mode 100644 index 0000000..7c64eda --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/preferences.mjs @@ -0,0 +1,57 @@ +export const DEFAULT_PREFERENCES = Object.freeze({ petId: null, scale: 0.75, autoCycle: false, windowPosition: null }); +const KEYS = Object.keys(DEFAULT_PREFERENCES); +// Read-once compatibility keys from earlier local builds; these are not plugin IDs. +export const LEGACY_KEYS = Object.freeze({ petId: 'codexPetSpike.selectedPetId', scale: 'codexPetSpike.scale', autoCycle: 'codexPetSpike.demoAutoCycle' }); + +export function validatePreferences(raw, { partial = false } = {}) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).some((key) => !KEYS.includes(key)) || (!partial && KEYS.some((key) => key !== 'windowPosition' && !Object.hasOwn(raw, key)))) throw new Error('invalid_preferences'); + const result = partial ? {} : { windowPosition: null }; + for (const key of KEYS) { + if (!Object.hasOwn(raw, key)) continue; + const value = raw[key]; + if (key === 'petId' && value !== null && (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(value))) throw new Error('invalid_preferences'); + if (key === 'scale' && ![0.5, 0.75, 1].includes(value)) throw new Error('invalid_preferences'); + if (key === 'autoCycle' && typeof value !== 'boolean') throw new Error('invalid_preferences'); + if (key === 'windowPosition' && value !== null && (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((name) => !['x', 'y'].includes(name)) || !['x', 'y'].every((name) => Number.isInteger(value[name]) && Math.abs(value[name]) <= 100000))) throw new Error('invalid_preferences'); + result[key] = value; + } + if (partial && !Object.keys(result).length) throw new Error('invalid_preferences'); + return result; +} + +export async function preferenceRequest(fetchImpl, patch, { onlyIfUnpersisted = false } = {}) { + const response = await fetchImpl('/api/preferences', patch === undefined ? { cache: 'no-store' } : { method: 'PATCH', headers: { 'content-type': 'application/json', ...(onlyIfUnpersisted ? { 'if-none-match': '*' } : {}) }, body: JSON.stringify(validatePreferences(patch, { partial: true })) }); + let body; + try { body = await response.json(); } catch { throw new Error('preferences_response_invalid'); } + if (onlyIfUnpersisted && response.status === 412 && body?.error === 'preferences_exists') return preferenceRequest(fetchImpl); + if (!response.ok) throw new Error(body?.error || `http_${response.status}`); + const preferences = validatePreferences(body.preferences); + if (typeof body.persisted !== 'boolean') throw new Error('preferences_response_invalid'); + return { preferences, persisted: body.persisted }; +} + +export function readLegacyPreferences(storage) { + const patch = {}; + for (const [key, storageKey] of Object.entries(LEGACY_KEYS)) { + const raw = storage.getItem(storageKey); + if (raw === null) continue; + const value = key === 'scale' ? Number(raw) : key === 'autoCycle' ? raw === '1' : raw; + try { Object.assign(patch, validatePreferences({ [key]: value }, { partial: true })); } catch { /* Ignore malformed legacy values, never persist them. */ } + } + return patch; +} + +export async function loadPreferences(fetchImpl, storage) { + const current = await preferenceRequest(fetchImpl); + if (current.persisted || !storage) return current; + let legacy; + try { legacy = readLegacyPreferences(storage); } catch { return current; } + if (!Object.keys(legacy).length) return current; + const migrated = await preferenceRequest(fetchImpl, legacy, { onlyIfUnpersisted: true }); + if (!migrated.persisted) throw new Error('preferences_not_persisted'); + // Keep the migration source if persistence failed or its result is uncertain. + if (migrated.persisted) { + for (const key of Object.values(LEGACY_KEYS)) { try { storage.removeItem(key); } catch { /* Persisted values already won. */ } } + } + return migrated; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs new file mode 100644 index 0000000..381d5f4 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs @@ -0,0 +1,280 @@ +// The page records one utterance; the public Host API only transcribes its PCM. +const FORMAT = Object.freeze({ sampleRate: 16000, channelCount: 1, sampleFormat: 's16' }); +const MAX_DURATION_MS = 60000; +const BUFFER_SIZE = 4096; +const HOST_ERRORS = new Set([ + 'invalid_request', 'not_permitted', 'audio_too_large', 'busy', + 'runtime_unavailable', 'transcription_failed', 'result_unknown', +]); + +function errorCode(error, phase) { + const code = error?.code || error?.message; + if (HOST_ERRORS.has(code)) return code; + if (['NotAllowedError', 'PermissionDeniedError', 'SecurityError'].includes(error?.name)) + return 'not_permitted'; + if (['NotFoundError', 'DevicesNotFoundError', 'NotReadableError', 'TrackStartError', 'OverconstrainedError'].includes(error?.name)) + return 'microphone_unavailable'; + return phase === 'transcribing' ? 'result_unknown' : 'capture_failed'; +} + +function stopTracks(stream) { + for (const track of stream?.getTracks?.() ?? []) { + try { track.stop(); } catch { /* Release the other tracks too. */ } + } +} + +function toPcm(chunks, length, sampleRate) { + const samples = new Float32Array(length); + let offset = 0; + for (const chunk of chunks) { + samples.set(chunk, offset); + offset += chunk.length; + } + const ratio = sampleRate / FORMAT.sampleRate; + const pcm = new Int16Array(Math.floor(length / ratio)); + for (let i = 0; i < pcm.length; i++) { + const start = i * ratio; + let sample; + if (ratio >= 1) { + // Average each source interval, including fractional edges, rather than + // mislabelling 44.1/48 kHz samples as 16 kHz or just dropping samples. + const end = Math.min(length, (i + 1) * ratio); + let sum = 0; + for (let j = Math.floor(start); j < Math.ceil(end); j++) { + const weight = Math.min(end, j + 1) - Math.max(start, j); + sum += (Number.isFinite(samples[j]) ? samples[j] : 0) * weight; + } + sample = sum / (end - start); + } else { + const left = Math.floor(start); + const right = Math.min(length - 1, left + 1); + const fraction = start - left; + sample = samples[left] * (1 - fraction) + samples[right] * fraction; + } + sample = Number.isFinite(sample) ? Math.max(-1, Math.min(1, sample)) : 0; + pcm[i] = Math.round(sample < 0 ? sample * 32768 : sample * 32767); + } + return pcm; +} + +/** + * Owns one microphone recording and one final-text delivery at a time. + * Expected failures resolve start/stop and are reported through onState. + * `dependencies` only replaces browser/timer boundaries for focused Node tests. + */ +export function createSpeechInput({ + getMiniApp = () => globalThis.window?.mcode?.miniapp, + onState = () => {}, + onText = () => {}, + onPartial = () => {}, + dependencies = {}, +} = {}) { + const getUserMedia = dependencies.getUserMedia ?? ((constraints) => + globalThis.navigator.mediaDevices.getUserMedia(constraints)); + const createAudioContext = dependencies.createAudioContext ?? (() => { + const AudioContext = globalThis.AudioContext ?? globalThis.webkitAudioContext; + return new AudioContext({ sampleRate: FORMAT.sampleRate }); + }); + const schedule = dependencies.setTimeout ?? globalThis.setTimeout.bind(globalThis); + const unschedule = dependencies.clearTimeout ?? globalThis.clearTimeout.bind(globalThis); + let state = 'idle'; + let generation = 0; + let disposed = false; + let capture = null; + let stopping = null; + + function emit(callback, value) { + try { callback(value); } catch { /* A view callback must not leak the microphone. */ } + } + function setState(next, code) { + if (disposed) return; + state = next; + emit(onState, { state: next, ...(code ? { errorCode: code } : {}) }); + } + function speech() { + try { + const miniapp = getMiniApp(); + return miniapp?.capabilities?.has?.('speech.transcribe') === true && + typeof miniapp?.speech?.transcribe === 'function' ? miniapp.speech : null; + } catch { return null; } + } + function isAvailable() { + return !disposed && !!speech() && + (!!dependencies.getUserMedia || typeof globalThis.navigator?.mediaDevices?.getUserMedia === 'function') && + (!!dependencies.createAudioContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function'); + } + const current = (token) => !disposed && generation === token; + + function release(recording) { + if (!recording || recording.released) return Promise.resolve(); + recording.released = true; + recording.cancelReady(); + if (recording.timer !== null) unschedule(recording.timer); + recording.processor && (recording.processor.onaudioprocess = null); + for (const stop of recording.trackListeners) stop(); + for (const node of [recording.source, recording.processor, recording.mute]) { + try { node?.disconnect(); } catch { /* Continue releasing remaining resources. */ } + } + stopTracks(recording.stream); + recording.chunks.length = 0; + try { return Promise.resolve(recording.context.close()).catch(() => {}); } + catch { return Promise.resolve(); } + } + + function fail(token, code) { + if (!current(token)) return; + generation++; + const recording = capture; + capture = null; + void release(recording); + setState('error', code); + } + + async function start() { + if (disposed || ['requesting', 'recording', 'transcribing'].includes(state)) return; + if (!isAvailable()) { setState('error', 'speech_unavailable'); return; } + const token = ++generation; + setState('requesting'); + // A callback may have synchronously cancelled or disposed the request. + if (!current(token)) return; + let recording; + try { + const context = createAudioContext(); + let cancelReady; + const cancelled = new Promise((resolve) => { cancelReady = resolve; }); + recording = { + context, stream: null, source: null, processor: null, mute: null, + chunks: [], frames: 0, timer: null, released: false, + trackListeners: [], cancelReady, + }; + capture = recording; + if (!Number.isFinite(context.sampleRate) || context.sampleRate <= 0) + throw new Error('Invalid microphone sample rate'); + // Start/resume inside start's user gesture, before awaiting the permission prompt. + const resumed = Promise.resolve(context.resume()); + let media; + try { + media = getUserMedia({ audio: { + channelCount: 1, echoCancellation: true, autoGainControl: true, noiseSuppression: true, + } }); + } catch (error) { media = Promise.reject(error); } + const acquired = Promise.resolve(media).then((stream) => { + if (!current(token) || capture !== recording || recording.released) { + stopTracks(stream); + return; + } + recording.stream = stream; + }); + await Promise.race([Promise.all([resumed, acquired]), cancelled]); + if (!current(token) || capture !== recording || recording.released) return; + const tracks = recording.stream?.getAudioTracks?.() ?? recording.stream?.getTracks?.() ?? []; + if (!tracks.length || tracks.every((track) => track.readyState === 'ended')) + throw Object.assign(new Error('No active microphone'), { name: 'NotFoundError' }); + for (const track of tracks) { + const ended = () => fail(token, 'microphone_unavailable'); + track.addEventListener?.('ended', ended); + recording.trackListeners.push(() => track.removeEventListener?.('ended', ended)); + } + recording.source = context.createMediaStreamSource(recording.stream); + // ScriptProcessor is the same zero-build capture boundary used by the Host's + // public copyable helper. No worklet/runtime package or encoded audio is needed. + recording.processor = context.createScriptProcessor(BUFFER_SIZE, 1, 1); + recording.mute = context.createGain(); + recording.mute.gain.value = 0; + recording.processor.onaudioprocess = (event) => { + if (!current(token) || capture !== recording || recording.released) return; + try { + const input = event.inputBuffer; + const remaining = Math.max(0, Math.floor(context.sampleRate * MAX_DURATION_MS / 1000) - recording.frames); + const length = Math.min(input.length, remaining); + if (length > 0) { + const mono = new Float32Array(length); + const channels = input.numberOfChannels; + for (let channel = 0; channel < channels; channel++) { + const values = input.getChannelData(channel); + for (let i = 0; i < length; i++) mono[i] += (Number.isFinite(values[i]) ? values[i] : 0) / channels; + } + recording.chunks.push(mono); + recording.frames += length; + } + if (!remaining || length === remaining) void stop(); + } catch { fail(token, 'capture_failed'); } + }; + recording.source.connect(recording.processor); + recording.processor.connect(recording.mute); + recording.mute.connect(context.destination); + recording.timer = schedule(() => { if (current(token)) void stop(); }, MAX_DURATION_MS); + setState('recording'); + } catch (error) { + if (current(token)) fail(token, errorCode(error, 'capture')); + else void release(recording); + } + } + + function stop() { + if (disposed) return Promise.resolve(); + if (stopping) return stopping; + if (state === 'requesting') { cancel(); return Promise.resolve(); } + if (state !== 'recording' || !capture) return Promise.resolve(); + const token = generation; + const recording = capture; + capture = null; + let pcm; + try { pcm = toPcm(recording.chunks, recording.frames, recording.context.sampleRate); } + catch { void release(recording); fail(token, 'capture_failed'); return Promise.resolve(); } + const released = release(recording); + if (!pcm.length) { fail(token, 'no_audio'); return released; } + setState('transcribing'); + if (!current(token)) return released; + const pending = (async () => { + try { + await released; + if (!current(token)) return; + const api = speech(); + if (!api) { fail(token, 'speech_unavailable'); return; } + const transcription = api.transcribe({ audio: pcm, format: { ...FORMAT } }, { + onPartial: (text) => { + if (current(token) && state === 'transcribing' && typeof text === 'string') emit(onPartial, text); + }, + }); + // The bridge owns the dispatched audio; do not retain another recording + // reference while waiting, particularly after cancel/dispose. + pcm = null; + const result = await transcription; + if (!current(token)) return; + if (!result || typeof result.text !== 'string') { fail(token, 'result_unknown'); return; } + emit(onText, result.text); + if (current(token)) setState('idle'); + } catch (error) { + fail(token, errorCode(error, 'transcribing')); + } finally { + pcm = null; + if (stopping === pending) stopping = null; + } + })(); + stopping = pending; + return pending; + } + + function cancel() { + if (disposed) return; + generation++; + const recording = capture; + capture = null; + stopping = null; + void release(recording); + setState('idle'); + } + + function dispose() { + if (disposed) return; + disposed = true; + generation++; + const recording = capture; + capture = null; + stopping = null; + void release(recording); + } + + return { isAvailable, start, stop, cancel, dispose }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/window-position.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/window-position.mjs new file mode 100644 index 0000000..c259e09 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/window-position.mjs @@ -0,0 +1,47 @@ +/** + * Called only after manual pop-out. The safe interaction patch must settle in its own frame: + * an invalid geometry patch is atomically rejected along with every field merged into that frame. + */ +export async function restoreWindowPosition(handle, position, { isCurrent = () => true } = {}) { + const result = { restored: false, interactive: false, cancelled: false, error: null, state: null }; + if (!isCurrent()) return { ...result, cancelled: true }; + try { + const safeState = await handle.update({ clickThrough: false }); + if (!isCurrent()) return { ...result, interactive: true, cancelled: true }; + result.interactive = true; + result.state = safeState; + if (!position) return result; + const state = await handle.update({ x: position.x, y: position.y }); + if (!isCurrent()) return { ...result, cancelled: true }; + return { ...result, restored: true, state }; + } catch (error) { + if (!isCurrent()) return { ...result, cancelled: true }; + return { ...result, error: error?.code || error?.message || 'result_unknown' }; + } +} + +/** Fit the complete transparent content area, not only the Host's minimum overlap. */ +export async function fitWindowToScreen(handle, { screenRect, isCurrent = () => true } = {}) { + const cancelled = () => ({ cancelled: true, error: null, state: null }); + if (!isCurrent()) return cancelled(); + try { + const state = await handle.getState(); + if (!isCurrent()) return cancelled(); + const bounds = state?.window, rect = screenRect?.(); + if (state?.mode !== 'window') return cancelled(); + const finite = (value) => Number.isFinite(value); + if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(finite) + || bounds.width <= 0 || bounds.height <= 0) return { error: 'invalid_state' }; + if (!rect || ![rect.x, rect.y, rect.width, rect.height].every(finite) + || rect.width <= 0 || rect.height <= 0) return { error: 'screen_unavailable' }; + if (bounds.width > rect.width || bounds.height > rect.height) return { error: 'screen_too_small' }; + const x = Math.ceil(Math.max(rect.x, Math.min(bounds.x, rect.x + rect.width - bounds.width))); + const y = Math.ceil(Math.max(rect.y, Math.min(bounds.y, rect.y + rect.height - bounds.height))); + if (x === bounds.x && y === bounds.y) return { state, error: null }; + const updated = await handle.update({ x, y }); + if (!isCurrent() || updated?.mode !== 'window') return cancelled(); + return { state: updated, error: null }; + } catch (error) { + return isCurrent() ? { error: error?.code || error?.message || 'result_unknown' } : cancelled(); + } +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/workbench-window.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/workbench-window.mjs new file mode 100644 index 0000000..1f7f728 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/client/workbench-window.mjs @@ -0,0 +1,104 @@ +const fail = (code) => ({ ok: false, code }); +const positive = (value) => Number.isFinite(value) && value > 0; +const errorCode = (error) => typeof error?.code === 'string' ? error.code + : typeof error?.message === 'string' ? error.message : 'result_unknown'; + +function boundsOf(state) { + const bounds = state?.window; + return state?.mode === 'window' && bounds && positive(bounds.width) && positive(bounds.height) + && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) ? bounds : null; +} + +function resizePatch(bounds, size, rect) { + if (!rect || !Number.isFinite(rect.x) || !Number.isFinite(rect.y) + || !positive(rect.width) || !positive(rect.height)) return fail('screen_unavailable'); + if (!positive(size?.width) || !positive(size?.height)) return fail('invalid_size'); + const minWidth = positive(bounds.minWidth) ? Math.ceil(bounds.minWidth) : 1; + const minHeight = positive(bounds.minHeight) ? Math.ceil(bounds.minHeight) : 1; + const width = Math.max(minWidth, Math.min(Math.round(size.width), Math.floor(rect.width))); + const height = Math.max(minHeight, Math.min(Math.round(size.height), Math.floor(rect.height))); + if (width > rect.width || height > rect.height) return fail('screen_too_small'); + // Work areas can start left of or above the primary display. Clamp to that + // rectangle, retaining the current bottom-right anchor whenever it fits. + const x = Math.round(Math.max(rect.x, Math.min(bounds.x + bounds.width - width, rect.x + rect.width - width))); + const y = Math.round(Math.max(rect.y, Math.min(bounds.y + bounds.height - height, rect.y + rect.height - height))); + return { ok: true, patch: { x, y, width, height } }; +} + +/** + * Geometry only: callers own panel visibility, focus and click-through. A + * rejected resize leaves the remembered compact size and expansion unchanged. + * screenRect must describe the current display's available area; when unknown + * no geometry is guessed or written. + */ +export function createWorkbenchWindow({ + windowAPI, + screenRect, + isCurrent = () => true, + expandedSize = { width: 300, height: 340 }, +} = {}) { + let disposed = false, expanded = false, compactSize = null; + let intent = 0, lifetime = 0, queue = Promise.resolve(); + const active = (generation) => !disposed && generation === lifetime && isCurrent(); + const invalidateWindow = () => { + lifetime += 1; + intent += 1; + expanded = false; + compactSize = null; + }; + const subscription = windowAPI?.onDidChangeState?.((state) => { + if (state?.mode === 'inline') invalidateWindow(); + }); + + async function apply(target, request, generation) { + try { + if (!active(generation)) return fail('cancelled'); + if (request !== intent) return fail('superseded'); + if (typeof windowAPI?.getState !== 'function' || typeof windowAPI?.update !== 'function') return fail('unsupported'); + const state = await windowAPI.getState(); + if (!active(generation)) return fail('cancelled'); + if (request !== intent) return fail('superseded'); + if (state?.mode === 'inline') { invalidateWindow(); return fail('inline_mode'); } + const bounds = boundsOf(state); + if (!bounds) return fail('invalid_state'); + if (expanded === target) return { ok: true }; + if (bounds.resizable === false) return fail('not_permitted'); + const originalSize = compactSize || { width: bounds.width, height: bounds.height }; + const geometry = resizePatch(bounds, target ? expandedSize : originalSize, screenRect?.()); + if (!geometry.ok) return geometry; + if (!active(generation)) return fail('cancelled'); + if (request !== intent) return fail('superseded'); + const updated = await windowAPI.update(geometry.patch); + if (!active(generation)) return fail('cancelled'); + if (updated?.mode === 'inline') { invalidateWindow(); return fail('inline_mode'); } + if (!boundsOf(updated)) return fail('invalid_state'); + // An update already sent to the Host cannot be recalled. Remember its + // accepted size even if a newer close arrived, so the serialized close + // restores the compact size from the actual new bottom-right position. + compactSize = originalSize; + expanded = target; + return request === intent ? { ok: true } : fail('superseded'); + } catch (error) { + return disposed || generation !== lifetime ? fail('cancelled') : fail(errorCode(error)); + } + } + + return { + setExpanded(target) { + if (disposed) return Promise.resolve(fail('cancelled')); + if (typeof target !== 'boolean') return Promise.resolve(fail('invalid_request')); + const request = ++intent, generation = lifetime; + const pending = queue.then(() => apply(target, request, generation)); + // All API/callback failures are explicit results, so the next deliberate + // intent can run without an automatic retry or a hidden rejected chain. + queue = pending.then(() => undefined); + return pending; + }, + dispose() { + if (disposed) return; + disposed = true; + invalidateWindow(); + subscription?.dispose(); + }, + }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/miniapp.json b/sources/minimax-ai/minimax-pet/miniapp/miniapp.json new file mode 100644 index 0000000..f823575 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/miniapp.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "agentAccess": [ + "session.read" + ], + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/pet", + "window": { + "width": 280, + "height": 340, + "minWidth": 200, + "minHeight": 200, + "resizable": true, + "frame": false, + "transparent": true, + "alwaysOnTop": true, + "skipTaskbar": true, + "clickThrough": true + } + }, + "mcpEndpoints": [], + "deviceAccess": [ + "microphone" + ] +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/node/image-header.mjs b/sources/minimax-ai/minimax-pet/miniapp/node/image-header.mjs new file mode 100644 index 0000000..ff38d5a --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/node/image-header.mjs @@ -0,0 +1,93 @@ +// @ts-check +// MiniMax Pet — magic-byte format detection and width/height parsing from image file headers. +// Pure functions over a Buffer; no I/O. + +/** Bytes needed to parse any supported header. */ +export const HEADER_BYTES = 32; + +export const ATLAS_SIZES = Object.freeze({ + 1: Object.freeze({ width: 1536, height: 1872 }), + 2: Object.freeze({ width: 1536, height: 2288 }), +}); + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * @param {Buffer} buf + * @returns {'png' | 'webp' | null} + */ +export function detectImageFormat(buf) { + if (buf.length >= 8 && buf.subarray(0, 8).equals(PNG_SIGNATURE)) return 'png'; + if ( + buf.length >= 12 && + buf.toString('latin1', 0, 4) === 'RIFF' && + buf.toString('latin1', 8, 12) === 'WEBP' + ) { + return 'webp'; + } + return null; +} + +/** + * @param {Buffer} buf + * @returns {{ format: 'png' | 'webp', width: number, height: number, variant?: string } | null} + */ +export function readImageHeader(buf) { + const format = detectImageFormat(buf); + if (format === 'png') return readPngHeader(buf); + if (format === 'webp') return readWebpHeader(buf); + return null; +} + +/** @param {Buffer} buf */ +function readPngHeader(buf) { + if (buf.length < 24) return null; + if (buf.readUInt32BE(8) !== 13 || buf.toString('latin1', 12, 16) !== 'IHDR') return null; + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + if (width === 0 || height === 0) return null; + return { format: /** @type {const} */ ('png'), width, height }; +} + +/** @param {Buffer} buf */ +function readWebpHeader(buf) { + if (buf.length < 20) return null; + const fourcc = buf.toString('latin1', 12, 16); + if (fourcc === 'VP8 ') { + if (buf.length < 30) return null; + // Keyframe bit (bit 0 of frame tag) must be 0, followed by start code 9d 01 2a. + if ((buf[20] & 0x01) !== 0) return null; + if (buf[23] !== 0x9d || buf[24] !== 0x01 || buf[25] !== 0x2a) return null; + const width = buf.readUInt16LE(26) & 0x3fff; + const height = buf.readUInt16LE(28) & 0x3fff; + if (width === 0 || height === 0) return null; + return { format: /** @type {const} */ ('webp'), variant: 'VP8', width, height }; + } + if (fourcc === 'VP8L') { + if (buf.length < 25) return null; + if (buf[20] !== 0x2f) return null; + const bits = buf.readUInt32LE(21); + if (bits >>> 29 !== 0) return null; // version must be 0 + const width = (bits & 0x3fff) + 1; + const height = ((bits >>> 14) & 0x3fff) + 1; + return { format: /** @type {const} */ ('webp'), variant: 'VP8L', width, height }; + } + if (fourcc === 'VP8X') { + if (buf.length < 30) return null; + const width = buf.readUIntLE(24, 3) + 1; + const height = buf.readUIntLE(27, 3) + 1; + return { format: /** @type {const} */ ('webp'), variant: 'VP8X', width, height }; + } + return null; +} + +/** + * @param {number} width + * @param {number} height + * @returns {1 | 2 | null} + */ +export function atlasVersionFromSize(width, height) { + if (width === ATLAS_SIZES[1].width && height === ATLAS_SIZES[1].height) return 1; + if (width === ATLAS_SIZES[2].width && height === ATLAS_SIZES[2].height) return 2; + return null; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/node/pet-store.mjs b/sources/minimax-ai/minimax-pet/miniapp/node/pet-store.mjs new file mode 100644 index 0000000..a94dd93 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/node/pet-store.mjs @@ -0,0 +1,505 @@ +// @ts-check +// MiniMax Pet — bundled + imported pet catalog, import, delete, normalization. + +import { randomBytes } from 'node:crypto'; +import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import { join, sep } from 'node:path'; + +import { HEADER_BYTES, atlasVersionFromSize, readImageHeader } from './image-header.mjs'; + +export const PET_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/; +const SPRITE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(webp|png)$/; +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/; +const DISPLAY_NAME_MAX = 80; +const DESCRIPTION_MAX = 300; +const STAGING_DIR = '.staging'; +const REPLACEMENT_NAME_PATTERN = /^replace-([a-z0-9][a-z0-9._-]{0,79})-\d+-[a-f0-9]{12}$/; +const DISPOSABLE_STAGE_PATTERN = /^(import|delete)-\d+-[a-f0-9]{12}$/; + +export class PetError extends Error { + /** + * @param {number} status + * @param {string} code + * @param {string} message + */ + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +/** @param {unknown} value @returns {value is Record} */ +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** @param {string} s */ +function charLength(s) { + return [...s].length; +} + +/** + * @typedef {{ id: string, displayName: string, description: string, spritesheetPath: string, version: 1 | 2 }} NormalizedPet + * @typedef {{ id: string, displayName: string, description: string, version: 1 | 2, source: 'bundled' | 'imported', petJsonUrl: string }} PetSummary + */ + +/** + * Validates pet.json shape (not the image). Throws PetError. + * @param {unknown} raw + * @returns {NormalizedPet} + */ +export function normalizePetJson(raw) { + if (!isPlainObject(raw)) throw new PetError(400, 'invalid_pet_json', 'pet.json must be an object'); + const { id, displayName, description, spritesheetPath, spriteVersionNumber } = raw; + if (typeof id !== 'string' || !PET_ID_PATTERN.test(id)) { + throw new PetError(400, 'invalid_id', 'id must match ^[a-z0-9][a-z0-9._-]{0,79}$'); + } + if (typeof displayName !== 'string') { + throw new PetError(400, 'invalid_pet_json', 'displayName must be a string'); + } + const name = displayName.trim(); + if (charLength(name) < 1 || charLength(name) > DISPLAY_NAME_MAX) { + throw new PetError(400, 'invalid_pet_json', `displayName must be 1-${DISPLAY_NAME_MAX} characters`); + } + if (description !== undefined && typeof description !== 'string') { + throw new PetError(400, 'invalid_pet_json', 'description must be a string'); + } + const desc = (description ?? '').trim(); + if (charLength(desc) > DESCRIPTION_MAX) { + throw new PetError(400, 'invalid_pet_json', `description must be at most ${DESCRIPTION_MAX} characters`); + } + if (spriteVersionNumber !== undefined && spriteVersionNumber !== 1 && spriteVersionNumber !== 2) { + throw new PetError(400, 'invalid_pet_json', 'spriteVersionNumber must be 1 or 2 when present'); + } + if (typeof spritesheetPath !== 'string') { + throw new PetError(400, 'invalid_pet_json', 'spritesheetPath must be a string'); + } + if (!SPRITE_NAME_PATTERN.test(spritesheetPath)) { + throw new PetError( + 400, + 'invalid_sprite_name', + 'spritesheetPath must be a plain *.webp or *.png file name without directories', + ); + } + return { + id, + displayName: name, + description: desc, + spritesheetPath, + version: spriteVersionNumber === 2 ? 2 : 1, + }; +} + +/** @param {NormalizedPet} pet */ +function toStoredJson(pet) { + /** @type {Record} */ + const out = { + id: pet.id, + displayName: pet.displayName, + description: pet.description, + spritesheetPath: pet.spritesheetPath, + }; + if (pet.version === 2) out.spriteVersionNumber = 2; + return out; +} + +/** + * Validates decoded image header against the pet's extension and declared version. Throws PetError. + * @param {Buffer} header + * @param {NormalizedPet} pet + */ +function checkSprite(header, pet) { + const info = readImageHeader(header); + if (!info) throw new PetError(400, 'sprite_format_invalid', 'spritesheet is not a PNG or WebP image'); + const ext = pet.spritesheetPath.slice(pet.spritesheetPath.lastIndexOf('.') + 1); + if (info.format !== ext) { + throw new PetError(400, 'sprite_format_invalid', `spritesheet content is ${info.format}, extension is .${ext}`); + } + const version = atlasVersionFromSize(info.width, info.height); + if (version === null) { + throw new PetError( + 400, + 'sprite_size_invalid', + `spritesheet is ${info.width}x${info.height}; expected 1536x1872 (v1) or 1536x2288 (v2)`, + ); + } + if (version !== pet.version) { + throw new PetError( + 400, + 'sprite_version_mismatch', + `spritesheet size matches v${version} but pet.json declares v${pet.version}`, + ); + } +} + +/** @param {string} value */ +function decodeBase64Strict(value) { + if (value.length === 0 || value.length % 4 !== 0 || !BASE64_PATTERN.test(value)) { + throw new PetError(400, 'sprite_format_invalid', 'spritesheet.base64 is not valid base64'); + } + const buf = Buffer.from(value, 'base64'); + if (buf.toString('base64') !== value) { + throw new PetError(400, 'sprite_format_invalid', 'spritesheet.base64 is not canonical base64'); + } + return buf; +} + +/** @param {string} path */ +async function readHeaderBytes(path) { + const handle = await open(path, 'r'); + try { + const buf = Buffer.alloc(HEADER_BYTES); + const { bytesRead } = await handle.read(buf, 0, HEADER_BYTES, 0); + return buf.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} + +/** @param {unknown} error */ +function errnoCode(error) { + return error && typeof error === 'object' && 'code' in error ? String(error.code) : 'unknown'; +} + +/** + * Reads and fully validates a pet directory (pet.json + sprite header). Throws PetError or fs errors. + * @param {string} dir + * @param {string} expectedId + * @returns {Promise} + */ +async function loadPetDir(dir, expectedId) { + const petJsonPath = join(dir, 'pet.json'); + const st = await lstat(petJsonPath); + if (!st.isFile()) throw new PetError(500, 'pet_json_unreadable', 'pet.json is not a regular file'); + let raw; + try { + raw = JSON.parse(await readFile(petJsonPath, 'utf8')); + } catch { + throw new PetError(500, 'invalid_pet_json', 'pet.json is not valid JSON'); + } + const pet = normalizePetJson(raw); + if (pet.id !== expectedId) throw new PetError(500, 'invalid_id', 'pet.json id does not match directory'); + const spritePath = join(dir, pet.spritesheetPath); + const spriteStat = await lstat(spritePath); + if (!spriteStat.isFile()) throw new PetError(500, 'sprite_unreadable', 'spritesheet is not a regular file'); + checkSprite(await readHeaderBytes(spritePath), pet); + return pet; +} + +/** @param {string} child @param {string} parent */ +function isInside(child, parent) { + return child.startsWith(parent.endsWith(sep) ? parent : parent + sep); +} + +/** @param {string} name */ +function contentTypeForPetFile(name) { + if (name.endsWith('.json')) return 'application/json; charset=utf-8'; + if (name.endsWith('.webp')) return 'image/webp'; + if (name.endsWith('.png')) return 'image/png'; + return null; +} + +/** + * @typedef {{ debug(m: string, f?: object): void, info(m: string, f?: object): void, warn(m: string, f?: object): void, error(m: string, f?: object): void }} Logger + * @param {{ clientRoot: string, dataDir: string, logger: Logger }} options + */ +export function createPetStore({ clientRoot, dataDir, logger }) { + const bundledRoot = join(clientRoot, 'pets'); + const petsDir = join(dataDir, 'pets'); + const stagingRoot = join(dataDir, STAGING_DIR); + + /** @type {{ pets: PetSummary[], ids: Set } | null} */ + let bundledCache = null; + /** @type {Promise} */ + let queueTail = Promise.resolve(); + + /** + * @template T + * @param {() => Promise} task + * @returns {Promise} + */ + function enqueue(task) { + const run = queueTail.then(task, task); + queueTail = run.catch(() => {}); + return run; + } + + async function getBundled() { + if (bundledCache) return bundledCache; + const entries = await readdir(bundledRoot, { withFileTypes: true }); + /** @type {PetSummary[]} */ + const pets = []; + const ids = new Set(); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + ids.add(entry.name); + if (!PET_ID_PATTERN.test(entry.name)) { + logger.warn('pet.catalog.bundled_invalid', { reason: 'invalid_id' }); + continue; + } + try { + const pet = await loadPetDir(join(bundledRoot, entry.name), entry.name); + pets.push(toSummary(pet, 'bundled')); + } catch (error) { + logger.warn('pet.catalog.bundled_invalid', { + reason: error instanceof PetError ? error.code : errnoCode(error), + }); + } + } + pets.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + bundledCache = { pets, ids }; + return bundledCache; + } + + /** + * @param {NormalizedPet} pet + * @param {'bundled' | 'imported'} source + * @returns {PetSummary} + */ + function toSummary(pet, source) { + return { + id: pet.id, + displayName: pet.displayName, + description: pet.description, + version: pet.version, + source, + petJsonUrl: source === 'bundled' ? `/assets/pets/${pet.id}/pet.json` : `/imported/${pet.id}/pet.json`, + }; + } + + async function listImported() { + let entries; + try { + entries = await readdir(petsDir, { withFileTypes: true }); + } catch (error) { + if (errnoCode(error) === 'ENOENT') return []; + throw error; + } + /** @type {PetSummary[]} */ + const pets = []; + for (const entry of entries) { + if (!entry.isDirectory() || !PET_ID_PATTERN.test(entry.name)) continue; + try { + pets.push(toSummary(await loadPetDir(join(petsDir, entry.name), entry.name), 'imported')); + } catch (error) { + logger.warn('pet.catalog.imported_invalid', { + reason: error instanceof PetError ? error.code : errnoCode(error), + }); + } + } + pets.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return pets; + } + + /** @returns {Promise} */ + async function listPets() { + return enqueue(async () => { + await recoverStaging(); + let bundled = []; + try { + bundled = (await getBundled()).pets; + } catch (error) { + logger.error('pet.catalog.bundled_unreadable', { errno: errnoCode(error) }); + } + return [...bundled, ...(await listImported())]; + }); + } + + /** + * Resolves an allowed imported pet file; returns null when not allowed / not found. + * @param {string} id + * @param {string} file + */ + async function resolveImportedFile(id, file) { + if (!PET_ID_PATTERN.test(id)) return null; + return enqueue(async () => { + await recoverStaging(); + return resolveImportedFileNow(id, file); + }); + } + + /** @param {string} id @param {string} file */ + async function resolveImportedFileNow(id, file) { + const dir = join(petsDir, id); + let pet; + try { + pet = await loadPetDir(dir, id); + } catch { + return null; + } + if (file !== 'pet.json' && file !== pet.spritesheetPath) return null; + const contentType = contentTypeForPetFile(file); + if (!contentType) return null; + const path = join(dir, file); + try { + const st = await lstat(path); + if (!st.isFile()) return null; + const [realFile, realPets] = await Promise.all([realpath(path), realpath(petsDir)]); + if (!isInside(realFile, realPets)) return null; + return { path: realFile, contentType }; + } catch { + return null; + } + } + + /** @param {string} dir */ + async function requireDirectory(dir) { + if (!(await lstat(dir)).isDirectory()) { + throw new PetError(500, 'pet_storage_unreadable', 'pet storage must use regular directories'); + } + } + + // A replacement backup's name records its destination before the atomic move. + // Unlike delete-* and import-*, it must never be swept before recovery succeeds. + // Run under the same queue as reads/writes so an active replacement is not recovered early. + async function recoverStaging() { + let entries; + try { + await requireDirectory(stagingRoot); + entries = await readdir(stagingRoot, { withFileTypes: true }); + } catch (error) { + if (errnoCode(error) === 'ENOENT') return; + throw error; + } + for (const entry of entries) { + const stagedPath = join(stagingRoot, entry.name); + const replacement = REPLACEMENT_NAME_PATTERN.exec(entry.name); + if (replacement) { + if (!entry.isDirectory()) { + throw new PetError(500, 'pet_recovery_failed', 'replacement backup must be a regular directory'); + } + const id = replacement[1]; + const finalDir = join(petsDir, id); + await mkdir(petsDir, { recursive: true }); + await requireDirectory(petsDir); + const finalExists = await lstat(finalDir).then( + () => true, + (error) => { + if (errnoCode(error) === 'ENOENT') return false; + throw error; + }, + ); + if (finalExists) { + await requireDirectory(finalDir); + await loadPetDir(finalDir, id); + await rm(stagedPath, { recursive: true, force: true }); + } else { + await loadPetDir(stagedPath, id); + await rename(stagedPath, finalDir); + logger.info('pet.import.recovered'); + } + } else if (DISPOSABLE_STAGE_PATTERN.test(entry.name)) { + await rm(stagedPath, { recursive: true, force: true }); + } else { + // Old trash-* could mean an interrupted replacement OR an intentional delete. + // Preserve it rather than guessing and either losing data or resurrecting a pet. + logger.warn('pet.staging.unrecognized_preserved'); + } + } + } + + function stagingName(prefix) { + return join(stagingRoot, `${prefix}-${Date.now()}-${randomBytes(6).toString('hex')}`); + } + + /** + * @param {unknown} body parsed JSON request body + * @returns {Promise} + */ + async function importPet(body) { + if (!isPlainObject(body)) throw new PetError(400, 'invalid_json', 'request body must be a JSON object'); + const pet = normalizePetJson(body.petJson); + const { spritesheet, replace } = body; + if ( + !isPlainObject(spritesheet) || + typeof spritesheet.fileName !== 'string' || + typeof spritesheet.base64 !== 'string' + ) { + throw new PetError(400, 'invalid_json', 'spritesheet must be { fileName: string, base64: string }'); + } + if (replace !== undefined && typeof replace !== 'boolean') { + throw new PetError(400, 'invalid_json', 'replace must be a boolean'); + } + if (spritesheet.fileName !== pet.spritesheetPath) { + throw new PetError(400, 'invalid_sprite_name', 'spritesheet.fileName must equal petJson.spritesheetPath'); + } + const sprite = decodeBase64Strict(spritesheet.base64); + checkSprite(sprite, pet); + + return enqueue(async () => { + await recoverStaging(); + const bundled = await getBundled(); + if (bundled.ids.has(pet.id)) { + throw new PetError(409, 'bundled_conflict', 'a bundled pet already uses this id'); + } + const finalDir = join(petsDir, pet.id); + const exists = await lstat(finalDir).then( + () => true, + (error) => { + if (errnoCode(error) === 'ENOENT') return false; + throw error; + }, + ); + if (exists && replace !== true) { + throw new PetError(409, 'pet_exists', 'an imported pet with this id exists; set replace to overwrite'); + } + + await mkdir(stagingRoot, { recursive: true }); + await requireDirectory(stagingRoot); + const stage = stagingName('import'); + try { + await mkdir(stage); + await writeFile(join(stage, 'pet.json'), `${JSON.stringify(toStoredJson(pet), null, 2)}\n`); + await writeFile(join(stage, pet.spritesheetPath), sprite); + await mkdir(petsDir, { recursive: true }); + await requireDirectory(petsDir); + if (exists) { + const backup = stagingName(`replace-${pet.id}`); + await rename(finalDir, backup); + try { + await rename(stage, finalDir); + } catch (error) { + // If rollback also fails, leave the named backup for the next recovery. + await rename(backup, finalDir); + throw error; + } + await rm(backup, { recursive: true, force: true }); + } else { + await rename(stage, finalDir); + } + } finally { + await rm(stage, { recursive: true, force: true }).catch(() => {}); + } + logger.info('pet.import.completed', { version: pet.version, replaced: exists }); + return toSummary(pet, 'imported'); + }); + } + + /** @param {string} id */ + async function deletePet(id) { + if (!PET_ID_PATTERN.test(id)) throw new PetError(400, 'invalid_id', 'invalid pet id'); + return enqueue(async () => { + await recoverStaging(); + const bundled = await getBundled(); + if (bundled.ids.has(id)) throw new PetError(403, 'bundled_read_only', 'bundled pets cannot be deleted'); + const dir = join(petsDir, id); + try { + const st = await lstat(dir); + if (!st.isDirectory()) throw new PetError(404, 'pet_not_found', 'pet not found'); + } catch (error) { + if (error instanceof PetError) throw error; + if (errnoCode(error) === 'ENOENT') throw new PetError(404, 'pet_not_found', 'pet not found'); + throw error; + } + await mkdir(stagingRoot, { recursive: true }); + await requireDirectory(stagingRoot); + const trash = stagingName('delete'); + await rename(dir, trash); + await rm(trash, { recursive: true, force: true }); + logger.info('pet.delete.completed'); + }); + } + + return { listPets, resolveImportedFile, importPet, deletePet }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/node/preferences-store.mjs b/sources/minimax-ai/minimax-pet/miniapp/node/preferences-store.mjs new file mode 100644 index 0000000..6491817 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/node/preferences-store.mjs @@ -0,0 +1,50 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { DEFAULT_PREFERENCES, validatePreferences } from '../client/preferences.mjs'; + +export class PreferenceError extends Error { + constructor(status, code) { super(code); this.status = status; this.code = code; } +} + +export function createPreferenceStore({ dataDir, fs = { lstat, mkdir, open, readFile, rename, rm } }) { + const target = join(dataDir, 'preferences.json'); + let pending = Promise.resolve(); + function serialize(operation) { + const result = pending.then(operation); + pending = result.catch(() => {}); + return result; + } + async function read() { + try { + if (!(await fs.lstat(target)).isFile()) throw new Error('not a regular file'); + const body = JSON.parse(await fs.readFile(target, 'utf8')); + if (body.version !== 1) throw new Error('unknown version'); + return { preferences: validatePreferences(body.preferences), persisted: true }; + } catch (error) { + if (error.code === 'ENOENT') return { preferences: { ...DEFAULT_PREFERENCES }, persisted: false }; + throw new PreferenceError(500, 'preferences_unreadable'); + } + } + async function write(raw, { onlyIfUnpersisted = false } = {}) { + let patch; + try { patch = validatePreferences(raw, { partial: true }); } catch { throw new PreferenceError(400, 'invalid_preferences'); } + const current = await read(); + if (onlyIfUnpersisted && current.persisted) throw new PreferenceError(412, 'preferences_exists'); + const preferences = { ...current.preferences, ...patch }; + await fs.mkdir(dataDir, { recursive: true }); + const temporary = join(dataDir, `.preferences-${randomUUID()}.tmp`); + let handle; + try { + handle = await fs.open(temporary, 'wx', 0o600); + await handle.writeFile(JSON.stringify({ version: 1, preferences }) + '\n'); + await handle.sync(); + await handle.close(); + handle = null; + await fs.rename(temporary, target); + return { preferences, persisted: true }; + } catch { throw new PreferenceError(500, 'preferences_write_failed'); } + finally { await handle?.close().catch(() => {}); await fs.rm(temporary, { force: true }).catch(() => {}); } + } + return { get: () => serialize(read), patch: (raw, options) => serialize(() => write(raw, options)) }; +} diff --git a/sources/minimax-ai/minimax-pet/miniapp/node/server.mjs b/sources/minimax-ai/minimax-pet/miniapp/node/server.mjs new file mode 100644 index 0000000..5eac7ca --- /dev/null +++ b/sources/minimax-ai/minimax-pet/miniapp/node/server.mjs @@ -0,0 +1,387 @@ +// @ts-check +// MiniMax Pet — Codex Pet Mini App Node entry. Serves the pet page, static client assets, and the pet API. + +import { open, readFile, realpath } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, resolve, sep } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import { PET_ID_PATTERN, PetError, createPetStore } from './pet-store.mjs'; +import { PreferenceError, createPreferenceStore } from './preferences-store.mjs'; + +/** + * @typedef {{ debug(m: string, f?: object): void, info(m: string, f?: object): void, warn(m: string, f?: object): void, error(m: string, f?: object): void }} Logger + * @typedef {{ pluginId: string, pluginRoot: string, dataDir: string, listen: { host: string, port: number }, signal: AbortSignal, logger: Logger }} MiniAppContext + * @typedef {import('node:http').IncomingMessage} Req + * @typedef {import('node:http').ServerResponse} Res + */ + +const BODY_LIMIT = 12 * 1024 * 1024; +const DRAIN_LIMIT = 4 * BODY_LIMIT; + +const ASSET_MIME = /** @type {Record} */ ({ + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.webp': 'image/webp', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}); + +class HttpError extends Error { + /** @param {number} status @param {string} code @param {string} message @param {Record} [headers] */ + constructor(status, code, message, headers) { + super(message); + this.status = status; + this.code = code; + this.headers = headers; + } +} + +/** + * @param {MiniAppContext} context + * @returns {Promise<{ dispose(): Promise }>} + */ +export async function start(context) { + const clientRoot = resolve(context.pluginRoot, 'miniapp', 'client'); + const logger = context.logger; + const store = createPetStore({ clientRoot, dataDir: context.dataDir, logger }); + const preferences = createPreferenceStore({ dataDir: context.dataDir }); + /** @type {string | null} */ + let clientRootReal = null; + + async function getClientRootReal() { + if (!clientRootReal) clientRootReal = await realpath(clientRoot); + return clientRootReal; + } + + const server = createServer((request, response) => { + handle(request, response).catch((error) => { + if (error instanceof HttpError || error instanceof PetError || error instanceof PreferenceError) { + const headers = error instanceof HttpError ? error.headers : undefined; + sendError(response, error.status, error.code, error.message, headers); + return; + } + logger.error('pet.http.internal_error', { errno: errnoCode(error) }); + sendError(response, 500, 'internal_error', 'internal error'); + }); + }); + + /** @param {Req} request @param {Res} response */ + async function handle(request, response) { + const rawUrl = request.url ?? ''; + if (!rawUrl.startsWith('/')) throw new HttpError(400, 'bad_request', 'invalid request target'); + const queryAt = rawUrl.indexOf('?'); + const path = queryAt === -1 ? rawUrl : rawUrl.slice(0, queryAt); + const method = request.method ?? 'GET'; + + if (path === '/pet') { + requireMethod(method, ['GET']); + return servePage(response); + } + if (path.startsWith('/assets/')) { + requireMethod(method, ['GET']); + return serveAsset(response, path.slice('/assets/'.length)); + } + if (path === '/api/preferences') { + requireMethod(method, ['GET', 'PATCH']); + if (method === 'GET') return sendJson(response, 200, await preferences.get()); + // This local API stores preferences only. Reject explicit cross-origin browser writes. + const origin = request.headers.origin; + if (origin && origin !== `http://${request.headers.host}`) throw new HttpError(403, 'origin_not_allowed', 'cross-origin write not allowed'); + const condition = request.headers['if-none-match']; + if (condition !== undefined && condition !== '*') throw new HttpError(400, 'invalid_preferences_condition', 'only If-None-Match: * is supported'); + return sendJson(response, 200, await preferences.patch(await readJsonBody(request, 16 * 1024), { onlyIfUnpersisted: condition === '*' })); + } + if (path === '/api/pets') { + requireMethod(method, ['GET', 'POST']); + if (method === 'GET') { + return sendJson(response, 200, { pets: await store.listPets() }); + } + const body = await readJsonBody(request); + const pet = await store.importPet(body); + return sendJson(response, 201, { pet }); + } + if (path.startsWith('/api/pets/')) { + const segments = splitSegments(path.slice('/api/pets/'.length)); + if (!segments || segments.length !== 1) throw notFound(); + requireMethod(method, ['DELETE']); + await store.deletePet(segments[0]); + response.writeHead(204, { 'cache-control': 'no-store' }); + response.end(); + return; + } + if (path.startsWith('/imported/')) { + const segments = splitSegments(path.slice('/imported/'.length)); + if (!segments || segments.length !== 2) throw notFound(); + requireMethod(method, ['GET']); + const [id, file] = segments; + if (!PET_ID_PATTERN.test(id)) throw notFound(); + const resolved = await store.resolveImportedFile(id, file); + if (!resolved) throw notFound(); + return sendFile(response, resolved.path, resolved.contentType); + } + throw notFound(); + } + + /** @param {Res} response */ + async function servePage(response) { + let html; + try { + html = await readFile(join(clientRoot, 'index.html')); + } catch (error) { + if (errnoCode(error) === 'ENOENT') { + throw new HttpError(503, 'client_missing', 'client index.html is not available'); + } + throw error; + } + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'content-length': String(html.length), + 'cache-control': 'no-cache', + 'x-content-type-options': 'nosniff', + }); + response.end(html); + } + + /** @param {Res} response @param {string} rawRest */ + async function serveAsset(response, rawRest) { + const segments = splitSegments(rawRest); + if (!segments) throw notFound(); + const contentType = ASSET_MIME[extname(segments[segments.length - 1]).toLowerCase()]; + if (!contentType) throw notFound(); + const candidate = resolve(clientRoot, ...segments); + if (!isInside(candidate, clientRoot)) throw notFound(); + let real; + let rootReal; + try { + [real, rootReal] = await Promise.all([realpath(candidate), getClientRootReal()]); + } catch { + throw notFound(); + } + if (!isInside(real, rootReal)) throw notFound(); + return sendFile(response, real, contentType); + } + + await listen(server, context.listen.host, context.listen.port); + logger.info('pet.runtime.listening'); + + /** @type {Promise | null} */ + let disposing = null; + const dispose = () => { + disposing ??= (async () => { + context.signal.removeEventListener('abort', onAbort); + server.closeAllConnections(); + await close(server); + logger.info('pet.runtime.disposed'); + })(); + return disposing; + }; + const onAbort = () => { + void dispose(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** + * Splits a raw path remainder into decoded segments. Returns null when any segment is empty, `.`, `..`, + * contains a backslash, NUL, or `/` after decoding, or fails percent-decoding. + * @param {string} raw + * @returns {string[] | null} + */ +function splitSegments(raw) { + if (raw.length === 0 || raw.includes('\\')) return null; + /** @type {string[]} */ + const out = []; + for (const part of raw.split('/')) { + let decoded; + try { + decoded = decodeURIComponent(part); + } catch { + return null; + } + if ( + decoded.length === 0 || + decoded === '.' || + decoded === '..' || + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('\0') + ) { + return null; + } + out.push(decoded); + } + return out; +} + +/** @param {string} child @param {string} parent */ +function isInside(child, parent) { + return child.startsWith(parent.endsWith(sep) ? parent : parent + sep); +} + +/** @param {string} method @param {string[]} allowed */ +function requireMethod(method, allowed) { + if (!allowed.includes(method)) { + throw new HttpError(405, 'method_not_allowed', `method ${method} not allowed`, { allow: allowed.join(', ') }); + } +} + +function notFound() { + return new HttpError(404, 'not_found', 'not found'); +} + +/** @param {unknown} error */ +function errnoCode(error) { + return error && typeof error === 'object' && 'code' in error ? String(error.code) : 'unknown'; +} + +/** @param {Res} response @param {number} status @param {unknown} payload @param {Record} [headers] */ +function sendJson(response, status, payload, headers) { + const body = Buffer.from(JSON.stringify(payload)); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(body.length), + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + ...headers, + }); + response.end(body); +} + +/** @param {Res} response @param {number} status @param {string} code @param {string} message @param {Record} [headers] */ +function sendError(response, status, code, message, headers) { + if (response.headersSent) { + response.destroy(); + return; + } + sendJson(response, status, { error: code, message }, headers); +} + +/** @param {Res} response @param {string} path @param {string} contentType */ +async function sendFile(response, path, contentType) { + let handle; + try { + handle = await open(path, 'r'); + } catch { + throw notFound(); + } + try { + const st = await handle.stat(); + if (!st.isFile()) throw notFound(); + response.writeHead(200, { + 'content-type': contentType, + 'content-length': String(st.size), + 'cache-control': 'no-cache', + 'x-content-type-options': 'nosniff', + }); + const stream = handle.createReadStream({ autoClose: false }); + try { + await pipeline(stream, response); + } catch { + response.destroy(); + } + } finally { + await handle.close().catch(() => {}); + } +} + +/** + * @param {Req} request + * @param {number} [limit] + * @returns {Promise} + */ +function readJsonBody(request, limit = BODY_LIMIT) { + const mediaType = String(request.headers['content-type'] ?? '') + .split(';')[0] + .trim() + .toLowerCase(); + if (mediaType !== 'application/json') { + return Promise.reject(new HttpError(415, 'unsupported_media_type', 'content-type must be application/json')); + } + const declared = Number(request.headers['content-length']); + if (Number.isFinite(declared) && declared > limit) { + drainOrDestroy(request, 0); + return Promise.reject(tooLarge(limit)); + } + return new Promise((resolvePromise, rejectPromise) => { + /** @type {Buffer[]} */ + const chunks = []; + let size = 0; + const onData = (/** @type {Buffer} */ chunk) => { + size += chunk.length; + if (size > limit) { + cleanup(); + drainOrDestroy(request, size); + rejectPromise(tooLarge(limit)); + return; + } + chunks.push(chunk); + }; + const onEnd = () => { + cleanup(); + try { + resolvePromise(JSON.parse(Buffer.concat(chunks, size).toString('utf8'))); + } catch { + rejectPromise(new HttpError(400, 'invalid_json', 'request body is not valid JSON')); + } + }; + const onError = () => { + cleanup(); + rejectPromise(new HttpError(400, 'bad_request', 'request body could not be read')); + }; + const cleanup = () => { + request.off('data', onData); + request.off('end', onEnd); + request.off('error', onError); + }; + request.on('data', onData); + request.on('end', onEnd); + request.on('error', onError); + }); +} + +function tooLarge(limit = BODY_LIMIT) { + return new HttpError(413, 'payload_too_large', `request body exceeds ${limit} bytes`); +} + +/** + * Stops buffering an oversized body. Remaining bytes are discarded so the 413 response reaches the + * client; the connection is destroyed if the client keeps sending far past the limit. + * @param {Req} request + * @param {number} alreadyRead + */ +function drainOrDestroy(request, alreadyRead) { + let drained = alreadyRead; + request.on('data', (/** @type {Buffer} */ chunk) => { + drained += chunk.length; + if (drained > DRAIN_LIMIT) request.destroy(); + }); + request.on('error', () => {}); + request.resume(); +} + +/** @param {import('node:http').Server} server @param {string} host @param {number} port */ +function listen(server, host, port) { + return new Promise((resolvePromise, rejectPromise) => { + /** @param {Error} error */ + const onError = (error) => rejectPromise(error); + server.once('error', onError); + server.listen(port, host, () => { + server.off('error', onError); + resolvePromise(undefined); + }); + }); +} + +/** @param {import('node:http').Server} server */ +function close(server) { + return new Promise((resolvePromise, rejectPromise) => { + server.close((error) => (error ? rejectPromise(error) : resolvePromise(undefined))); + }); +} diff --git a/sources/minimax-ai/minimax-pet/package-lock.json b/sources/minimax-ai/minimax-pet/package-lock.json new file mode 100644 index 0000000..26d9e79 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/package-lock.json @@ -0,0 +1,848 @@ +{ + "name": "minimax-pet-source", + "version": "0.5.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minimax-pet-source", + "version": "0.5.8", + "devDependencies": { + "jsdom": "24.1.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sources/minimax-ai/minimax-pet/package.json b/sources/minimax-ai/minimax-pet/package.json new file mode 100644 index 0000000..a97442e --- /dev/null +++ b/sources/minimax-ai/minimax-pet/package.json @@ -0,0 +1,21 @@ +{ + "name": "minimax-pet-source", + "version": "0.5.8", + "private": true, + "type": "module", + "description": "桌宠插件的开发与验证工具,不进入分发包。", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test dev/*.test.mjs", + "test:smoke": "node dev/smoke-api.mjs", + "dev": "node dev/run-local.mjs", + "package": "node scripts/package.mjs", + "check:package": "node scripts/check-package.mjs", + "generate:art": "node scripts/generate-pet-art.mjs" + }, + "devDependencies": { + "jsdom": "24.1.3" + } +} diff --git a/sources/minimax-ai/minimax-pet/scripts/check-package.mjs b/sources/minimax-ai/minimax-pet/scripts/check-package.mjs new file mode 100644 index 0000000..5a93554 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/scripts/check-package.mjs @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; +import { lstat, readdir, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +async function inventory(directory, relative = '', files = new Map()) { + for (const name of (await readdir(join(directory, relative))).sort()) { + const path = relative ? `${relative}/${name}` : name; + const stat = await lstat(join(directory, path)); + if (stat.isDirectory()) await inventory(directory, path, files); + else if (stat.isFile()) { + files.set(path, createHash('sha256').update(await readFile(join(directory, path))).digest('hex')); + } else throw new Error(`Unsupported package entry (links are not allowed): ${path}`); + } + return files; +} + +/** Compare every packaged file, including code, binary assets, metadata and licenses. */ +export async function comparePackages(built, published) { + const [expected, actual] = await Promise.all([inventory(built), inventory(published)]); + const differences = []; + for (const path of [...new Set([...expected.keys(), ...actual.keys()])].sort()) { + if (!expected.has(path)) differences.push(`Unexpected published file: ${path}`); + else if (!actual.has(path)) differences.push(`Missing published file: ${path}`); + else if (expected.get(path) !== actual.get(path)) differences.push(`Content differs: ${path}`); + } + return { files: expected.size, differences }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const root = fileURLToPath(new URL('..', import.meta.url)); + const built = resolve(process.argv[2] ?? join(root, 'dist/package')); + const published = resolve(process.argv[3] ?? join(root, '../../../plugins/minimax-ai/minimax-pet')); + const result = await comparePackages(built, published); + if (result.differences.length) { + console.error(`Desktop pet package does not match a fresh source build:\n${result.differences.join('\n')}`); + console.error('Rebuild/package sources/minimax-ai/minimax-pet and synchronize dist/package to plugins/minimax-ai/minimax-pet.'); + process.exitCode = 1; + } else console.log(`Desktop pet package matches the source build: ${result.files} files, exact bytes, no exclusions.`); +} diff --git a/sources/minimax-ai/minimax-pet/scripts/generate-pet-art.mjs b/sources/minimax-ai/minimax-pet/scripts/generate-pet-art.mjs new file mode 100644 index 0000000..2329d36 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/scripts/generate-pet-art.mjs @@ -0,0 +1,259 @@ +// Original MiniMax Pet artwork. Copyright MiniMax. SPDX-License-Identifier: MIT +// Every shape is drawn here; generation needs no external images, fonts or packages. +import { deflateSync } from 'node:zlib'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CELL_W = 192; +const CELL_H = 208; +const COLUMNS = 8; +const COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8]; +const INK = [35, 55, 58, 255]; +const MINT = [109, 218, 184, 255]; +const LIGHT = [178, 243, 214, 255]; +const CREAM = [249, 252, 233, 255]; +const CORAL = [248, 119, 108, 255]; +const GOLD = [255, 207, 103, 255]; + +function canvas(width, height) { + const pixels = Buffer.alloc(width * height * 4); + function pixel(x, y, color) { + if (x < 0 || x >= width || y < 0 || y >= height) return; + const offset = (Math.floor(y) * width + Math.floor(x)) * 4; + for (let i = 0; i < 4; i++) pixels[offset + i] = color[i]; + } + function rect(x, y, w, h, color) { + for (let py = Math.ceil(y); py < y + h; py++) { + for (let px = Math.ceil(x); px < x + w; px++) pixel(px, py, color); + } + } + function ellipse(cx, cy, rx, ry, color) { + for (let y = Math.ceil(cy - ry); y <= cy + ry; y++) { + for (let x = Math.ceil(cx - rx); x <= cx + rx; x++) { + if (((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1) pixel(x, y, color); + } + } + } + function roundRect(x, y, w, h, radius, color) { + rect(x + radius, y, w - 2 * radius, h, color); + rect(x, y + radius, w, h - 2 * radius, color); + for (const cx of [x + radius, x + w - radius - 1]) { + for (const cy of [y + radius, y + h - radius - 1]) { + ellipse(cx, cy, radius, radius, color); + } + } + } + function line(x1, y1, x2, y2, thickness, color) { + const steps = Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1), 1); + for (let i = 0; i <= steps; i++) { + const t = i / steps; + ellipse(Math.round(x1 + (x2 - x1) * t), Math.round(y1 + (y2 - y1) * t), thickness / 2, thickness / 2, color); + } + } + return { pixels, rect, ellipse, roundRect, line }; +} + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(name, data) { + const kind = Buffer.from(name, 'ascii'); + const size = Buffer.alloc(4); + const checksum = Buffer.alloc(4); + size.writeUInt32BE(data.length); + checksum.writeUInt32BE(crc32(Buffer.concat([kind, data]))); + return Buffer.concat([size, kind, data, checksum]); +} + +function encodePng(width, height, pixels) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; // Eight-bit RGBA, preserving transparent cell backgrounds. + const scanlines = Buffer.alloc((width * 4 + 1) * height); + for (let y = 0; y < height; y++) { + pixels.copy(scanlines, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4); + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(scanlines, { level: 9 })), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +function drawPet(sheet, row, frame) { + // Drawing on a 96 x 104 grid gives every sprite a clean two-pixel edge. + const small = canvas(CELL_W / 2, CELL_H / 2); + const { rect, ellipse, roundRect, line } = small; + const cycle = frame / COUNTS[row] * Math.PI * 2; + const stride = Math.round(Math.sin(cycle) * 5); + const runningSide = row === 1 || row === 2; + const jump = row === 4 ? [0, 7, 15, 7, 0][frame] : 0; + const bob = runningSide || row === 7 ? (frame % 2 ? -1 : 1) : (row === 0 && frame === 3 ? 1 : 0); + const sad = row === 5; + const headDrop = sad ? [0, 1, 2, 3, 3, 2, 1, 0][frame] : 0; + const dx = runningSide ? (row === 1 ? 2 : -2) : 0; + const dy = bob - jump; + const headY = 28 + dy + headDrop; + const bodyY = 62 + dy; + const lookAngle = row >= 9 ? ((row - 9) * 8 + frame) * Math.PI / 8 : null; + const gazeX = lookAngle === null ? (row === 1 ? 2 : row === 2 ? -2 : 0) : Math.round(Math.sin(lookAngle) * 3); + const gazeY = lookAngle === null ? 0 : Math.round(-Math.cos(lookAngle) * 2); + + // A small coral antenna is the pet's identifying accent. + line(48 + dx, headY - 6, 48 + dx, headY + 1, 3, INK); + ellipse(48 + dx, headY - 7, 4, 4, INK); + ellipse(48 + dx, headY - 8, 2.5, 2.5, CORAL); + + // Feet move independently during walking and running, staying inside the cell. + const legL = runningSide || row === 7 ? stride : (row === 4 ? -2 : 0); + const legR = runningSide || row === 7 ? -stride : (row === 4 ? 2 : 0); + line(39 + dx, bodyY + 18, 38 + dx + legL, bodyY + 29, 6, INK); + line(57 + dx, bodyY + 18, 58 + dx + legR, bodyY + 29, 6, INK); + roundRect(29 + dx + legL, bodyY + 26, 14, 7, 2, INK); + roundRect(54 + dx + legR, bodyY + 26, 14, 7, 2, INK); + rect(32 + dx + legL, bodyY + 27, 9, 3, MINT); + rect(56 + dx + legR, bodyY + 27, 9, 3, MINT); + + // Rounded forearms make the four-frame wave legible at small display sizes. + const leftEnd = row === 4 ? [22 + dx, bodyY - 5] : [27 + dx, bodyY + 14 + (runningSide ? stride : 0)]; + const rightEnd = row === 3 + ? [[75, 43], [80, 35], [75, 30], [72, 38]][frame].map((n, i) => n + (i ? dy : dx)) + : row === 4 ? [74 + dx, bodyY - 5] + : row === 8 ? [70 + dx, bodyY - 6] : [69 + dx, bodyY + 14 - (runningSide ? stride : 0)]; + for (const [startX, end] of [[34 + dx, leftEnd], [62 + dx, rightEnd]]) { + line(startX, bodyY + 4, end[0], end[1], 9, INK); + line(startX, bodyY + 4, end[0], end[1], 5, MINT); + ellipse(end[0], end[1], 4, 4, INK); + ellipse(end[0], end[1] - 1, 2.5, 2.5, LIGHT); + } + + roundRect(32 + dx, bodyY - 1, 33, 25, 6, INK); + roundRect(34 + dx, bodyY + 1, 29, 21, 4, MINT); + rect(39 + dx, bodyY + 4, 19, 2, LIGHT); + roundRect(42 + dx, bodyY + 9, 13, 8, 2, INK); + rect(45 + dx, bodyY + 11, 7, 3, row === 7 ? GOLD : CORAL); + + // Wide cream face, mint shell and two tiny ears: all original geometric art. + roundRect(19 + dx, headY + 9, 8, 16, 3, INK); + roundRect(69 + dx, headY + 9, 8, 16, 3, INK); + rect(21 + dx, headY + 12, 4, 9, MINT); + rect(71 + dx, headY + 12, 4, 9, MINT); + roundRect(24 + dx, headY, 49, 37, 10, INK); + roundRect(26 + dx, headY + 2, 45, 33, 8, MINT); + rect(33 + dx, headY + 4, 26, 2, LIGHT); + roundRect(29 + dx, headY + 8, 39, 24, 6, INK); + roundRect(31 + dx, headY + 10, 35, 20, 4, CREAM); + + const eyesY = headY + 18 + gazeY; + const blinking = row === 0 && frame === 1; + const blinkWaiting = row === 6 && frame === 4; + for (const eyeX of [39 + dx + gazeX, 57 + dx + gazeX]) { + if (blinking || blinkWaiting) line(eyeX - 2, eyesY, eyeX + 2, eyesY, 2, INK); + else if (sad) { + line(eyeX - 2, eyesY + 1, eyeX + 2, eyesY - 1, 2, INK); + } else { + roundRect(eyeX - 2, eyesY - 3, 5, 7, 1, INK); + rect(eyeX - 1, eyesY - 2, 1, 2, [255, 255, 255, 255]); + } + } + if (sad) { + line(46 + dx, headY + 27, 48 + dx, headY + 25, 1, INK); + line(48 + dx, headY + 25, 50 + dx, headY + 27, 1, INK); + } else if (row === 4 || row === 3) { + roundRect(45 + dx, headY + 24, 7, 5, 2, INK); + rect(47 + dx, headY + 27, 3, 1, CORAL); + } else { + line(45 + dx, headY + 24, 47 + dx, headY + 26, 1, INK); + line(47 + dx, headY + 26, 50 + dx, headY + 24, 1, INK); + } + rect(33 + dx, headY + 23, 3, 2, [247, 187, 165, 255]); + rect(60 + dx, headY + 23, 3, 2, [247, 187, 165, 255]); + + if (row === 6) { + // Sequential dots communicate waiting without text or an imported glyph. + for (let i = 0; i < 3; i++) ellipse(35 + i * 12, 8, 2, 2, i <= frame % 3 ? CORAL : LIGHT); + } + if (row === 7) { + // Two alternating sparks accompany the working expression. + for (const [x, y] of frame % 2 ? [[14, 52], [79, 72]] : [[15, 70], [79, 49]]) { + line(x - 3, y, x + 3, y, 2, GOLD); + line(x, y - 3, x, y + 3, 2, GOLD); + } + } + if (row === 8) { + // A raised hand and soft pulse ask for attention. + const y = 12 + (frame % 2); + roundRect(76, y, 5, 10, 2, CORAL); + ellipse(78, y + 14, 2, 2, CORAL); + } + + const atlasWidth = CELL_W * COLUMNS; + for (let y = 0; y < CELL_H / 2; y++) { + for (let x = 0; x < CELL_W / 2; x++) { + const input = (y * CELL_W / 2 + x) * 4; + for (let iy = 0; iy < 2; iy++) { + for (let ix = 0; ix < 2; ix++) { + const output = (((row * CELL_H + y * 2 + iy) * atlasWidth) + frame * CELL_W + x * 2 + ix) * 4; + small.pixels.copy(sheet, output, input, input + 4); + } + } + } + } +} + +/** Create a complete deterministic PNG atlas without reading or writing files. */ +export function createPetAtlas({ version = 2 } = {}) { + if (version !== 1 && version !== 2) throw new RangeError('Pet atlas version must be 1 or 2'); + const width = CELL_W * COLUMNS; + const rows = version === 1 ? 9 : 11; + const height = CELL_H * rows; + const pixels = Buffer.alloc(width * height * 4); + for (let row = 0; row < rows; row++) { + for (let frame = 0; frame < COUNTS[row]; frame++) drawPet(pixels, row, frame); + } + return encodePng(width, height, pixels); +} + +/** Square first-party icon, drawn from the same original geometry as the atlas. */ +export function createPetIcon() { + const atlasWidth = CELL_W * COLUMNS; + const firstRow = Buffer.alloc(atlasWidth * CELL_H * 4); + drawPet(firstRow, 0, 0); + const size = 256; + const pixels = Buffer.alloc(size * size * 4); + for (let y = 0; y < CELL_H; y++) { + firstRow.copy(pixels, ((y + 24) * size + 32) * 4, y * atlasWidth * 4, (y * atlasWidth + CELL_W) * 4); + } + return encodePng(size, size, pixels); +} + +async function main() { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const destination = path.join(root, 'miniapp/client/pets/minimax-original'); + await mkdir(destination, { recursive: true }); + await writeFile(path.join(destination, 'spritesheet.png'), createPetAtlas()); + const metadata = { + id: 'minimax-original', + displayName: 'MiniMax Pet', + description: 'MiniMax 原创薄荷色机器人。图集由随包源代码独立绘制,采用 MIT 许可。', + spritesheetPath: 'spritesheet.png', + spriteVersionNumber: 2, + author: 'MiniMax', + license: 'MIT', + }; + await writeFile(path.join(destination, 'pet.json'), `${JSON.stringify(metadata, null, 2)}\n`); + await writeFile(path.join(root, 'icon.png'), createPetIcon()); + process.stdout.write('Generated original MiniMax Pet v2 atlas (1536 x 2288) and icon (256 x 256).\n'); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) await main(); diff --git a/sources/minimax-ai/minimax-pet/scripts/package.mjs b/sources/minimax-ai/minimax-pet/scripts/package.mjs new file mode 100644 index 0000000..99be987 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/scripts/package.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const stage = join(root, 'dist/package'); +await rm(stage, { recursive: true, force: true }); +await mkdir(stage, { recursive: true }); +for (const name of ['.minimax-plugin', 'miniapp', 'skills', 'icon.png', 'README.md', 'README.zh-CN.md', 'LICENSE', 'PROVENANCE.md', 'ARTWORK.md']) { + await cp(join(root, name), join(stage, name), { recursive: true }); +} +await writeFile(join(stage, 'package.json'), JSON.stringify({ + mcode: { schemaVersion: 2, miniApp: './miniapp/miniapp.json' }, +}, null, 2) + '\n'); +// Stage a self-contained package; development dependencies stay outside it. +console.log(stage); diff --git a/sources/minimax-ai/minimax-pet/skills/minimax-pet/SKILL.md b/sources/minimax-ai/minimax-pet/skills/minimax-pet/SKILL.md new file mode 100644 index 0000000..8540405 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/skills/minimax-pet/SKILL.md @@ -0,0 +1,38 @@ +--- +name: minimax-pet +description: Open MiniMax Pet or create, validate and import custom desktop-pet spritesheets. Use when making a new pet, turning a character image into a pet, or fixing an incompatible atlas. +--- + +# MiniMax Pet + +Choose the branch matching the user's request. Creating artwork produces files in the user's workspace; it does not create a separate chat or send, stop, or answer user conversations. + +## Open the pet or view reminders + +Call the Host's currently available `miniapp` tool: + +```json +{ "action": "open", "pluginId": "minimax-pet" } +``` + +Report the app open only when the tool returns `ok: true` and `result.opened: true`. Otherwise report the actual failure. The user can check installation and enablement in plugin management, use the appearance/tasks/settings tabs, and choose the desktop-window action. + +## Create or repair pet artwork + +1. Read the complete [atlas and metadata specification](references/spritesheet-format.md) before generating images. Reuse character/style/reference details already supplied; clarify only missing choices that materially affect the artwork. Use image-generation, drawing and processing tools actually available in this turn. Create original artwork by default; this package does not bundle third-party character art. +2. Prefer **v2: a static transparent RGBA PNG, exactly 1536 x 2288 pixels**. It has **8 columns x 11 rows**, each cell **192 x 208 pixels**, nine animation tracks and sixteen look directions. A portrait, animation GIF, video or arbitrary contact sheet is not an atlas. If the look directions cannot be made, explicitly deliver v1 at 1536 x 1872 with version 1 instead of claiming v2. +3. Create an output directory with `pet.json` and its named `spritesheet.png`. Use a custom pet ID such as `my-mint-pet`; `minimax-original` is reserved, while `minimax-pet` is the plugin identity and must not be changed. Keep the character, scale and baseline consistent, with real alpha transparency and no clipping across cell edges. When a generation tool cannot produce the exact layout, make consistent frames first, then use available tools to fit them proportionally, pad with transparency and assemble the exact grid. Stretching an arbitrary collage is not sufficient. +4. Run the bundled [PNG validation tool](scripts/validate-art.mjs): + + ```sh + node "/scripts/validate-art.mjs" "/pet.json" + ``` + + Resolve `` from the actual current location of this `SKILL.md`; do not guess an installation/profile path. The script needs Node.js 22+, no dependency installation, writes no files and calls no model. Read its real result: exit 0 and `ok: true` pass this generation profile. Fix errors and rerun. Review or explain warnings, and visually check character consistency, actions and look directions. If execution or visual review is unavailable, state what remains unverified; do not invent a successful check. +5. Deliver the two actual file paths, version, validation result and useful preview. When import is requested, open MiniMax Pet and select those two files through Appearance -> Import, inspect the preview and confirm. Replacing an existing ID needs user confirmation. Claim import only after observing success. Do not write the Host's dataDir, use private import endpoints or edit an installed package to bypass the import workflow. + +## Validation scope + +Use static 8-bit RGBA, non-interlaced PNG for generated artwork, with a conservative 8 MiB image budget. The importer also accepts other decodable PNG encodings and static WebP; the bundled script does not fully decode WebP, so its limitations are not the importer's complete support matrix. For existing WebP, use actual image tools to convert to this PNG profile before checking, or explicitly use the MiniApp's decoded preview. Renaming an extension is not conversion. + +The validator checks data, pixels and layout, plus limited quality signals. It cannot prove character identity, artistic consistency, action meaning or correct gaze semantics. Its transparent-background/nonempty-cell checks are generation delivery requirements, not a claim that the current importer performs the same pixel checks. diff --git a/sources/minimax-ai/minimax-pet/skills/minimax-pet/references/spritesheet-format.md b/sources/minimax-ai/minimax-pet/skills/minimax-pet/references/spritesheet-format.md new file mode 100644 index 0000000..c60efd8 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/skills/minimax-pet/references/spritesheet-format.md @@ -0,0 +1,87 @@ +# MiniMax Pet atlas specification + +This specification follows the plugin's player, import service and import UI. Choose a version before making frames, then deliver one static image and `pet.json`. + +## Deliverable + +```text +my-mint-pet/ + pet.json + spritesheet.png +``` + +Recommended v2 metadata: + +```json +{ + "id": "my-mint-pet", + "displayName": "My Mint Pet", + "description": "An original mint-colored companion.", + "spritesheetPath": "spritesheet.png", + "spriteVersionNumber": 2 +} +``` + +This is pet metadata, not a plugin manifest. Do not add `agentAccess`, change the plugin ID, or start Agent turns. + +- `id`: 1-80 characters matching `^[a-z0-9][a-z0-9._-]{0,79}$`. Choose a new custom ID; `minimax-original` is reserved. Whether an imported ID already exists is checked by the actual import UI, not this tool. +- `displayName`: nonblank, without leading/trailing spaces, at most **80 UTF-16 code units** (JavaScript `.length`; an emoji can occupy two). This fits both client and server limits. +- `description`: optional string, at most **300 UTF-16 code units**; do not use `null`. +- `spritesheetPath`: a plain same-directory filename. Prefer `spritesheet.png`; no directories, URL, or data URL. The runtime pattern is `^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.(webp|png)$`. The extension is lowercase and must match the actual encoding. +- `spriteVersionNumber`: number 1 or 2; omission means 1. v2 must explicitly declare 2, not the string `"2"`. +- Arbitrary extra author/license/animation fields are not a persistent import protocol. Keep attribution separately if needed; select only the JSON and its atlas when importing. + +## Canvas and cells + +| Version | Exact image dimensions | Grid | Content | +| --- | --- | --- | --- | +| v1 | **1536 x 1872 px** | 8 columns x 9 rows | Nine animation tracks | +| v2, preferred | **1536 x 2288 px** | 8 columns x 11 rows | Nine tracks plus sixteen look poses | + +Each cell is **192 x 208 px**. Rows/columns are zero-based; a cell starts at `(column * 192, row * 208)`. There are no gutters, outer margins, labels, grid lines or borders. Leave unused trailing cells transparent. Do not add pixels to accommodate a title or change the cell size and merely relabel the JSON. + +The generation profile is **static 8-bit RGBA, non-interlaced PNG**, with real alpha transparency. Do not bake white, a checkerboard, a pedestal or explanatory text into the background. GIF, video, animated PNG/WebP, JPEG, SVG and a single portrait are not this deliverable. The runtime can import more PNG encodings and static WebP; the bundled validator fully checks only this PNG subset. + +## First nine rows: animations + +| Row | State | Valid columns / frame count | Intended pose | +| --- | --- | --- | --- | +| 0 | `idle` | 0-5 / 6 | Calm breathing or blinking | +| 1 | `running-right` | 0-7 / 8 | Run toward the right | +| 2 | `running-left` | 0-7 / 8 | Run toward the left | +| 3 | `waving` | 0-3 / 4 | Greeting | +| 4 | `jumping` | 0-4 / 5 | Takeoff, apex, return to baseline | +| 5 | `failed` | 0-7 / 8 | Failure/disappointment reaction | +| 6 | `waiting` | 0-5 / 6 | Waiting for the user's answer | +| 7 | `running` | 0-5 / 6 | Working; not another right-running row | +| 8 | `review` | 0-5 / 6 | Draw attention to a result | + +There are **57 valid animation cells**. Keep identity, palette, scale and foot baseline consistent, allowing intentional jump/run movement. Keep all limbs/antennae inside their cell; neighboring frames must not bleed in. Make motions connect across the cycle. Explain deliberately static tracks rather than claiming identical cells demonstrate verified animation. + +Timing belongs to the player, not JSON FPS fields. Idle's six frames total 6600 ms. Normal non-idle actions play three cycles then return to slow idle; previews can explicitly loop. Do not add frames or GIF delays to alter playback timing. + +## Last two v2 rows: look directions + +Each cell is a static gaze direction, not an extra animation. **Zero degrees points UP, increasing clockwise in 22.5-degree steps.** It does not start facing right. Keep the body baseline stable while changing the head or gaze. + +| Row | Column 0 | Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | Column 6 | Column 7 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 9 | 0 up | 22.5 up/right | 45 up-right | 67.5 right/up | 90 right | 112.5 right/down | 135 down-right | 157.5 down/right | +| 10 | 180 down | 202.5 down/left | 225 down-left | 247.5 left/down | 270 left | 292.5 left/up | 315 up-left | 337.5 up/left | + +Index `i` maps to row `9 + floor(i / 8)`, column `i % 8`. v2 has **73 valid cells** overall. Sixteen different hashes do not prove correct direction: inspect up/right/down/left at minimum. If these poses are unavailable, deliver the nine-row v1 image and version 1; two blank rows do not make a v2 pet. + +## Image-generation and assembly workflow + +1. Establish an original character reference and lock identity, palette, camera angle and scale. +2. Generate/draw the required frames against that reference. Enable actual transparent output when supported; a painted checkerboard is not transparency. +3. If processing is needed, fit each frame proportionally inside its cell, pad transparently to 192 x 208 and assemble the exact grid. A directly generated atlas is acceptable only after its actual layout, content and dimensions pass checks; ignore size claims printed by the model. +4. Export static RGBA PNG and matching JSON. Keep the atlas **at or below 8 MiB** for this generation profile. +5. Run the bundled [validator](../scripts/validate-art.mjs), then visually inspect motion, edges, consistency and gaze direction. Resolve or explain warnings; a successful script is not proof of artistic quality or action meaning. +6. Import only `pet.json` and its named atlas through the app, review the decoded preview and confirm. Preserve an honest unverified/failed status when any check or import was not observed. + +## Size and validation boundaries + +The HTTP limit is **12 MiB (12,582,912 bytes) for the entire UTF-8 JSON request**, not a 12 MiB image. Base64 expands an image by roughly 4/3. The UI budget is `ceil(imageBytes / 3) * 4 + 4096 <= 12 * 1024 * 1024`. The generator's 8 MiB image cap leaves room for metadata; the script also estimates the actual encoded request. Let the normal import UI encode the files, rather than constructing private Host requests or writing its data directory. + +The tool validates metadata, full PNG decoding, dimensions/version, nonempty valid cells and transparent backgrounds. Edge contact, motionless tracks and repeated gaze cells produce warnings. It reads no Host state and changes no import state, cannot detect existing user-imported IDs, and cannot understand the depicted character/action. The generation alpha/cell requirements are stricter than current runtime header checks. Convert WebP or palette PNG to this profile with real tools, or use a real decoded preview; do not claim this script fully verified a format it does not support. diff --git a/sources/minimax-ai/minimax-pet/skills/minimax-pet/scripts/validate-art.mjs b/sources/minimax-ai/minimax-pet/skills/minimax-pet/scripts/validate-art.mjs new file mode 100644 index 0000000..2518771 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/skills/minimax-pet/scripts/validate-art.mjs @@ -0,0 +1,251 @@ +// Copyright MiniMax. SPDX-License-Identifier: MIT +// Standalone generation check: static 8-bit RGBA PNG only. No app install or dependencies needed. +import { createHash } from 'node:crypto'; +import { constants } from 'node:fs'; +import { lstat, open } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateSync } from 'node:zlib'; + +const JSON_LIMIT = 1024 * 1024; +// A generation budget, not the UI's exact PNG limit. The UI limits the complete JSON request. +const PNG_LIMIT = 8 * 1024 * 1024; +const IMPORT_BODY_LIMIT = 12 * 1024 * 1024; +const CELL_W = 192; +const CELL_H = 208; +const FRAME_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6, 8, 8]; +const STATE_NAMES = ['idle', 'running-right', 'running-left', 'waving', 'jumping', 'failed', 'waiting', 'running', 'review']; +const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +class InvalidArt extends Error {} +function requireArt(condition, message) { + if (!condition) throw new InvalidArt(message); +} + +async function readRegularFile(filePath, limit, label) { + let handle; + try { + const metadata = await lstat(filePath); + requireArt(metadata.isFile() && !metadata.isSymbolicLink(), `${label} must be a regular file, not a symlink.`); + requireArt(metadata.size <= limit, `${label} exceeds the ${limit / 1024 / 1024} MiB generation budget.`); + handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + requireArt((await handle.stat()).isFile(), `${label} must be a regular file, not a symlink.`); + const bytes = Buffer.alloc(limit + 1); + let length = 0; + while (length < bytes.length) { + const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null); + if (!bytesRead) break; + length += bytesRead; + } + requireArt(length <= limit, `${label} exceeds the ${limit / 1024 / 1024} MiB generation budget.`); + return bytes.subarray(0, length); + } catch (error) { + if (error instanceof InvalidArt) throw error; + throw new InvalidArt(`Cannot read ${label} as a regular file.`); + } finally { + await handle?.close(); + } +} + +function validateMetadata(pet) { + const errors = []; + if (!pet || typeof pet !== 'object' || Array.isArray(pet)) return ['pet.json must contain a JSON object.']; + if (typeof pet.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(pet.id)) { + errors.push('id must match ^[a-z0-9][a-z0-9._-]{0,79}$.'); + } else if (pet.id === 'minimax-original') { + errors.push('The id minimax-original is reserved for the bundled pet; choose a unique custom id.'); + } + // The Node importer counts Unicode code points, but the page currently counts UTF-16 units. + // Use the stricter page limit so generated metadata also passes the import preview. + if (typeof pet.displayName !== 'string' || pet.displayName.trim().length < 1 || pet.displayName.length > 80) { + errors.push('displayName must be non-blank and at most 80 UTF-16 code units.'); + } + if (pet.description !== undefined && (typeof pet.description !== 'string' || pet.description.length > 300)) { + errors.push('description must be a string of at most 300 UTF-16 code units.'); + } + if (pet.spriteVersionNumber !== undefined && pet.spriteVersionNumber !== 1 && pet.spriteVersionNumber !== 2) { + errors.push('spriteVersionNumber must be omitted, 1 or 2.'); + } + if (typeof pet.spritesheetPath !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.png$/.test(pet.spritesheetPath)) { + errors.push('spritesheetPath must be a plain ASCII *.png filename, without a directory or URL.'); + } + return errors; +} + +const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + return crc >>> 0; +}); +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 255] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} +function paeth(left, above, upperLeft) { + const predictor = left + above - upperLeft; + const a = Math.abs(predictor - left); + const b = Math.abs(predictor - above); + const c = Math.abs(predictor - upperLeft); + return a <= b && a <= c ? left : b <= c ? above : upperLeft; +} + +function decodePng(bytes, version) { + requireArt(bytes.subarray(0, 8).equals(SIGNATURE), 'The spritesheet must be a PNG image; WebP is not decoded by this generation checker.'); + const width = 1536; + const height = version === 2 ? 2288 : 1872; + const compressedChunks = []; + let seenHeader = false; + let seenEnd = false; + let dataEnded = false; + let seenPalette = false; + for (let offset = 8; offset < bytes.length;) { + requireArt(offset + 12 <= bytes.length, 'PNG contains a truncated chunk.'); + const size = bytes.readUInt32BE(offset); + const end = offset + 12 + size; + requireArt(end <= bytes.length, 'PNG contains a truncated chunk.'); + const kind = bytes.toString('latin1', offset + 4, offset + 8); + requireArt(/^[A-Za-z]{2}[A-Z][A-Za-z]$/.test(kind), 'PNG contains an invalid chunk type.'); + requireArt(crc32(bytes.subarray(offset + 4, end - 4)) === bytes.readUInt32BE(end - 4), 'PNG chunk CRC does not match.'); + requireArt(seenHeader || kind === 'IHDR', 'PNG must start with IHDR.'); + const data = bytes.subarray(offset + 8, end - 4); + if (kind === 'IHDR') { + requireArt(!seenHeader && offset === 8 && size === 13, 'PNG must have exactly one 13-byte IHDR at the start.'); + seenHeader = true; + requireArt(data.readUInt32BE(0) === width && data.readUInt32BE(4) === height, `Version ${version} requires exactly ${width}×${height} pixels.`); + requireArt(data[8] === 8 && data[9] === 6 && data[10] === 0 && data[11] === 0 && data[12] === 0, + 'Export a non-interlaced, 8-bit RGBA PNG (color type 6, standard compression and filters).'); + } else if (kind === 'IDAT') { + requireArt(!dataEnded, 'PNG IDAT chunks must be consecutive.'); + compressedChunks.push(data); + } else { + if (compressedChunks.length) dataEnded = true; + requireArt(!['acTL', 'fcTL', 'fdAT'].includes(kind), 'Export a static PNG atlas, not an animated PNG.'); + if (kind === 'IEND') { + requireArt(size === 0 && end === bytes.length, 'PNG IEND must be empty and the final bytes of the file.'); + seenEnd = true; + } else if (kind === 'PLTE') { + requireArt(!seenPalette && !compressedChunks.length && size > 0 && size <= 768 && size % 3 === 0, 'PNG contains an invalid or misplaced PLTE chunk.'); + seenPalette = true; + } else { + requireArt(kind[0] === kind[0].toLowerCase(), 'PNG contains an unsupported critical chunk.'); + requireArt(kind !== 'tRNS', 'RGBA PNG must encode transparency in its alpha channel, without tRNS.'); + } + } + offset = end; + } + requireArt(seenHeader && seenEnd && compressedChunks.length > 0, 'PNG must contain IHDR, IDAT and IEND.'); + const compressed = Buffer.concat(compressedChunks); + const stride = width * 4; + const expectedLength = (stride + 1) * height; + let scanlines; + try { + const inflated = inflateSync(compressed, { maxOutputLength: expectedLength + 1, info: true }); + requireArt(inflated.engine.bytesWritten === compressed.length, 'PNG image data contains trailing compressed bytes.'); + scanlines = inflated.buffer; + } catch (error) { + if (error instanceof InvalidArt) throw error; + throw new InvalidArt('PNG image data is invalid, incomplete or exceeds its declared dimensions.'); + } + requireArt(scanlines.length === expectedLength, 'PNG decoded image data does not match its dimensions.'); + const pixels = Buffer.alloc(stride * height); + for (let y = 0; y < height; y++) { + const encodedOffset = y * (stride + 1); + const filter = scanlines[encodedOffset]; + requireArt(filter <= 4, 'PNG contains an unsupported scanline filter.'); + for (let x = 0; x < stride; x++) { + const index = y * stride + x; + const left = x >= 4 ? pixels[index - 4] : 0; + const above = y ? pixels[index - stride] : 0; + const upperLeft = y && x >= 4 ? pixels[index - stride - 4] : 0; + let predictor = 0; + if (filter === 1) predictor = left; + else if (filter === 2) predictor = above; + else if (filter === 3) predictor = Math.floor((left + above) / 2); + else if (filter === 4) predictor = paeth(left, above, upperLeft); + pixels[index] = (scanlines[encodedOffset + x + 1] + predictor) & 255; + } + } + return { width, height, pixels }; +} + +function inspectFrames({ width, pixels }, version) { + const errors = []; + const warnings = []; + const lookDigests = []; + let frames = 0; + for (let row = 0; row < (version === 2 ? 11 : 9); row++) { + const digests = []; + for (let frame = 0; frame < FRAME_COUNTS[row]; frame++) { + let visible = false; + let transparent = false; + let touchesEdge = false; + const normalized = Buffer.alloc(CELL_W * CELL_H * 4); + for (let y = 0; y < CELL_H; y++) { + for (let x = 0; x < CELL_W; x++) { + const source = ((row * CELL_H + y) * width + frame * CELL_W + x) * 4; + const alpha = pixels[source + 3]; + if (alpha === 0) transparent = true; + if (alpha > 24) { + visible = true; + pixels.copy(normalized, (y * CELL_W + x) * 4, source, source + 4); + if (x === 0 || x === CELL_W - 1 || y === 0 || y === CELL_H - 1) touchesEdge = true; + } + } + } + const position = `row ${row}, column ${frame}`; + if (!visible) errors.push(`${position} is empty: add visible pixels with alpha greater than 24.`); + if (!transparent) errors.push(`${position} has no fully transparent background pixel.`); + if (touchesEdge) warnings.push(`${position} touches the cell edge; check for clipping during playback.`); + const digest = createHash('sha256').update(normalized).digest('hex'); + digests.push(digest); + if (row >= 9) lookDigests.push(digest); + frames++; + } + if (row < 9 && new Set(digests).size === 1) { + warnings.push(`The ${STATE_NAMES[row]} animation has identical visible content in every frame.`); + } + } + if (version === 2 && new Set(lookDigests).size < 16) warnings.push('Some of the sixteen look directions have identical visible content; check the direction order and gaze.'); + return { errors, warnings, frames }; +} + +/** + * Check generated pet.json and its sibling PNG without importing or modifying either file. + * The report contains only format information and diagnostic text, never image data or metadata values. + * @param {string} petJsonPath + * @returns {Promise<{ok: true, version: number, width: number, height: number, frames: number, warnings: string[]} | {ok: false, errors: string[]}>} + */ +export async function validatePetArt(petJsonPath) { + try { + requireArt(typeof petJsonPath === 'string' && petJsonPath.length > 0, 'Supply the path to pet.json.'); + const jsonBytes = await readRegularFile(petJsonPath, JSON_LIMIT, 'pet.json'); + let pet; + try { + pet = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(jsonBytes)); + } catch { + throw new InvalidArt('pet.json must contain valid UTF-8 JSON.'); + } + const metadataErrors = validateMetadata(pet); + if (metadataErrors.length) return { ok: false, errors: metadataErrors }; + const bytes = await readRegularFile(path.join(path.dirname(petJsonPath), pet.spritesheetPath), PNG_LIMIT, 'spritesheet PNG'); + // Base64 has no JSON escape overhead. Include the replacement flag so both import requests fit. + const requestBytes = Buffer.byteLength(JSON.stringify({ petJson: pet, spritesheet: { fileName: pet.spritesheetPath, base64: '' }, replace: true })) + 4 * Math.ceil(bytes.length / 3); + requireArt(requestBytes <= IMPORT_BODY_LIMIT, 'The base64 import request exceeds the 12 MiB JSON body limit; reduce the PNG or metadata.'); + const version = pet.spriteVersionNumber === 2 ? 2 : 1; + const image = decodePng(bytes, version); + const { errors, warnings, frames } = inspectFrames(image, version); + if (errors.length) return { ok: false, errors }; + return { ok: true, version, width: image.width, height: image.height, frames, warnings }; + } catch (error) { + return { ok: false, errors: [error instanceof InvalidArt ? error.message : 'Unable to validate the generated pet files.'] }; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = process.argv.length === 3 + ? await validatePetArt(process.argv[2]) + : { ok: false, errors: ['Usage: node validate-art.mjs /pet.json'] }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.ok ? 0 : 1; +} diff --git a/test/miniapp.test.mjs b/test/miniapp.test.mjs index 08de4b4..15c7e1c 100644 --- a/test/miniapp.test.mjs +++ b/test/miniapp.test.mjs @@ -140,22 +140,51 @@ test('miniapp: surface.window rejects unknown fields, non-booleans, min above si assert.deepEqual(equal.report.diagnostics, []); }); -test('miniapp: agentAccess allows session.run and session.read and reports the features', async () => { +test('miniapp: agentAccess keeps session.run and session.read separate', async () => { const granted = await run((m) => { m.agentAccess = ['session.run']; return m; }); assert.deepEqual(granted.report.diagnostics, []); assert.deepEqual(granted.resolved.features, ['agentAccess']); const denied = await run((m) => { m.agentAccess = []; return m; }); assert.deepEqual(denied.report.diagnostics, []); assert.deepEqual(denied.resolved.features, ['agentAccess']); - const read = await run((m) => { m.agentAccess = ['session.run', 'session.read']; return m; }); - assert.deepEqual(read.report.diagnostics, []); - assert.deepEqual(read.resolved.features, ['agentAccess', 'agentAccess.session.read']); - for (const value of [['session.run', 'session.write'], 'session.run', [null]]) { + for (const value of [['session.read'], ['session.run', 'session.read']]) { + const read = await run((m) => { m.agentAccess = value; return m; }); + assert.deepEqual(read.report.diagnostics, []); + assert.deepEqual(read.resolved.features, ['agentAccess', 'agentAccess.session.read']); + } + for (const value of [['session.write'], ['session.run', 'session.write'], ['session.run', 'session.run'], ['session.read', 'session.read'], 'session.run', [null], null, {}]) { const bad = await run((m) => { m.agentAccess = value; return m; }); assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_AGENT_ACCESS_INVALID'], JSON.stringify(value)); } }); +test('miniapp: microphone is a separate Host extension and cannot declare other devices', async () => { + const enabled = await run((m) => { m.deviceAccess = ['microphone']; return m; }); + assert.deepEqual(codes(enabled.report.diagnostics), ['HOST_EXTENSION_UNVERIFIED']); + assert.equal(enabled.report.diagnostics[0].level, 'warning'); + assert.match(enabled.report.diagnostics[0].message, /deviceAccess: microphone/u); + assert.deepEqual(enabled.resolved.features, []); + const empty = await run((m) => { m.deviceAccess = []; return m; }); + assert.deepEqual(empty.report.diagnostics, []); + for (const value of [['camera'], ['microphone', 'camera'], ['microphone', 'microphone'], 'microphone', [null], null, {}]) { + const bad = await run((m) => { m.deviceAccess = value; return m; }); + assert.deepEqual(codes(bad.report.diagnostics), ['MINIAPP_DEVICE_ACCESS_INVALID'], JSON.stringify(value)); + } +}); + +test('miniapp: read-only observation and microphone do not imply Agent runs or accept unknown fields', async () => { + const read = await run((m) => { + m.agentAccess = ['session.read']; + m.deviceAccess = ['microphone']; + return m; + }); + assert.deepEqual(codes(read.report.diagnostics), ['HOST_EXTENSION_UNVERIFIED']); + assert.match(read.report.diagnostics[0].message, /microphone/u); + assert.deepEqual(read.resolved.features, ['agentAccess', 'agentAccess.session.read']); + const unknown = await run((m) => { m.devices = ['microphone']; return m; }); + assert.deepEqual(codes(unknown.report.diagnostics), ['MINIAPP_UNKNOWN_FIELD']); +}); + test('miniapp: agentConcurrency is an integer from 1 to 32 and reports the feature', async () => { for (const value of [1, 32]) { const ok = await run((m) => { m.agentAccess = ['session.run']; m.agentConcurrency = value; return m; });