From 8038abe58a9b03d0c9a58476e40184f0aa5f3ae3 Mon Sep 17 00:00:00 2001 From: dazhi Date: Wed, 11 Nov 2026 15:40:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(minimax-pet):=200.6.0=20=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E4=B8=8E=E4=B8=BB=E9=A2=98=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=89=93=E6=8B=9B=E5=91=BC/?= =?UTF-8?q?=E8=B7=B3=E4=B8=80=E4=B8=8B=E5=92=8C=E5=8E=9F=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E8=8D=89=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除右键菜单和外观页的「打招呼」「跳一下」;悬停与拖拽松手的跳跃动画保留。 - 删除整个原属聊天草稿功能:菜单项、设置入口、草稿页、桌面输入框、语音转写, 以及声明为麦克风的 deviceAccess 权限。 - 任务详情(面板与桌面卡片)新增「打开对话」/「去回答」,调用 host.navigation.open({ id: 'session', sessionId, source: 'local' }); 点击中同步发起、不自动重试、切换任务后丢弃迟到结果,旧客户端隐藏按钮。 - 通过 host.ui.subscribe 跟随 MiniMax Code 明暗主题,旧客户端仍跟随系统设置。 - 两项新能力均先做能力检测,最低客户端版本保持 3.1.6。 - 同步更新源码测试、README(中英)、DEVELOPMENT、PROVENANCE 与根 README 表格。 --- README.md | 2 +- README.zh-CN.md | 2 +- .../minimax-pet/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-pet/PROVENANCE.md | 8 +- plugins/minimax-ai/minimax-pet/README.md | 21 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 14 +- .../minimax-pet/miniapp/client/chat-draft.mjs | 50 --- .../miniapp/client/compact-pet-view.mjs | 29 +- .../minimax-pet/miniapp/client/index.html | 54 +-- .../miniapp/client/panel-navigation.mjs | 18 +- .../minimax-pet/miniapp/client/pet-app.js | 290 ++++---------- .../minimax-pet/miniapp/client/pet.css | 156 +------- .../miniapp/client/speech-input.mjs | 280 ------------- .../minimax-pet/miniapp/miniapp.json | 5 +- .../minimax-pet/.minimax-plugin/plugin.json | 2 +- sources/minimax-ai/minimax-pet/DEVELOPMENT.md | 7 +- sources/minimax-ai/minimax-pet/PROVENANCE.md | 8 +- sources/minimax-ai/minimax-pet/README.md | 21 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 14 +- .../minimax-pet/dev/chat-draft.test.mjs | 47 --- .../minimax-pet/dev/compact-pet-view.test.mjs | 38 +- .../minimax-pet/dev/panel-navigation.test.mjs | 61 +-- .../dev/panel-page-wiring.test.mjs | 129 +++++- .../minimax-ai/minimax-pet/dev/smoke-api.mjs | 4 +- .../minimax-pet/dev/speech-input.test.mjs | 378 ------------------ .../minimax-pet/miniapp/client/chat-draft.mjs | 50 --- .../miniapp/client/compact-pet-view.mjs | 29 +- .../minimax-pet/miniapp/client/index.html | 54 +-- .../miniapp/client/panel-navigation.mjs | 18 +- .../minimax-pet/miniapp/client/pet-app.js | 290 ++++---------- .../minimax-pet/miniapp/client/pet.css | 156 +------- .../miniapp/client/speech-input.mjs | 280 ------------- .../minimax-pet/miniapp/miniapp.json | 5 +- .../minimax-ai/minimax-pet/package-lock.json | 4 +- sources/minimax-ai/minimax-pet/package.json | 2 +- 35 files changed, 495 insertions(+), 2033 deletions(-) delete mode 100644 plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs delete mode 100644 plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs delete mode 100644 sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs delete mode 100644 sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs delete mode 100644 sources/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs delete mode 100644 sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs diff --git a/README.md b/README.md index f9512f6a..16babc07 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [AI Node Canvas](plugins/antianqi/ai-node-canvas/) | An infinite-canvas node graph for MiniMax Code: wire text, image, video and audio runs into a graph, let each result flow downstream, and run media tools on any result | [antianqi](https://github.com/antianqi) | | [Agent Flow](plugins/huixin615/agent-flow/) | Import this plugin's Hook event file to inspect native task calls and sub-agent lifecycles, with exact task-to-child links when IDs are available. Choose a recorded chat or the latest imported record; no Host session files are opened. Requires MiniMax Code 3.1.4 or later | [Huixin615](https://github.com/Huixin615) | | [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry from a local ledger you maintain, and get a "spend the card or let the credits burn first" recommendation with the reasoning spelled out | [Hylouis](https://github.com/Hylouis233) | -| [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 MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | +| [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders, a one-click jump to a task's conversation (3.1.11 or later) and a transparent desktop pet. Requires MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) | | [本机大盘 · Local Market Board](plugins/jzlikewei/kurumi-monitor/) | Watch your own machine as a stock terminal: CPU, GPU, temperature, memory, disk, network and every running process become listed stocks with live candlesticks, moving averages, a watchlist, a leaderboard and a trade tape, on macOS and Windows. Requires minimax Code 3.1.2 or later | [jzlikewei](https://github.com/jzlikewei) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 1032a896..637d42d9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [AI 节点画布](plugins/antianqi/ai-node-canvas/README.zh-CN.md) | MiniMax Code 里的无限画布节点图:把文本、图片、视频、音频的生成接成一张图,结果顺着连线往下传,任何一张结果都能直接做二次处理 | [antianqi](https://github.com/antianqi) | | [Agent 协作透视](plugins/huixin615/agent-flow/README.zh-CN.md) | 手动导入插件 Hook 记录,查看原生 task 调用与子 Agent 运行区间;有明确 ID 时关联父子任务。可选择记录会话或一键查看最近导入的记录,不读取宿主会话文件。需要 MiniMax Code 3.1.4 或更高版本 | [Huixin615](https://github.com/Huixin615) | | [蹬王之王](plugins/hylouis233/expiry-guardian/README.md) | 看你自己维护的本地账本,算出「该用卡还是该留积分」,并把理由说清楚 | [Hylouis](https://github.com/Hylouis233) | -| [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | +| [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒、一键打开任务对话(3.1.11 起)和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) | | [本机大盘](plugins/jzlikewei/kurumi-monitor/README.zh-CN.md) | 把本机当成行情终端来看:CPU、GPU、温度、内存、磁盘、网络与每个运行中的进程都是一只个股,实时 K 线、均线、自选行情、主力动向与逐笔成交,支持 macOS 与 Windows。需要 minimax Code 3.1.2 或更高版本 | [jzlikewei](https://github.com/jzlikewei) | diff --git a/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json index 707488bb..588f806d 100644 --- a/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-pet", "displayName": "MiniMax Pet", - "version": "0.5.11", + "version": "0.6.0", "description": "轻量桌宠,聚合主窗口可见会话的任务状态、需要回答的提醒和最新结果,支持外观切换及图集导入。", "author": "MiniMax", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-pet/PROVENANCE.md b/plugins/minimax-ai/minimax-pet/PROVENANCE.md index 8e8b34ce..f664087a 100644 --- a/plugins/minimax-ai/minimax-pet/PROVENANCE.md +++ b/plugins/minimax-ai/minimax-pet/PROVENANCE.md @@ -1,6 +1,6 @@ # Source and distribution -Author: MiniMax ([MiniMax-AI](https://github.com/MiniMax-AI)). This is the 0.5.10 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. +Author: MiniMax ([MiniMax-AI](https://github.com/MiniMax-AI)). This is the 0.6.0 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 @@ -35,8 +35,8 @@ Maiyatang (麦芽糖), contributed by [Fectivnfy112357](https://github.com/Fecti ## Verification boundaries -The supported client baseline is MiniMax Code **3.1.6**. On 2026-10-26, the official [mainland China macOS](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml), [Global macOS](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml), and [mainland China Windows](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest.yml) production feeds listed 3.1.6, released on 2026-10-23. The corresponding release implementation was separately checked for session reads and observation, cancellation, capability checks, speech transcription, microphone declaration, and the click-through window handle. This is a confirmed released baseline, not a claim that 3.1.6 was the earliest version with each API. +The supported client baseline is MiniMax Code **3.1.6**. On 2026-10-26, the official [mainland China macOS](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml), [Global macOS](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml), and [mainland China Windows](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest.yml) production feeds listed 3.1.6, released on 2026-10-23. The corresponding release implementation was separately checked for session reads and observation, cancellation, capability checks, and the click-through window handle. Version 0.6.0 also uses `host.navigation.open` and `host.ui.subscribe` from MiniMax Code 3.1.11 when present; they are feature-detected and do not raise the baseline. This is a confirmed released baseline, not a claim that 3.1.6 was the earliest version with each API. -The production feeds identify the distributed version; they do not establish that the plugin was installed or exercised on that client. This revision has not undergone 3.1.6 released-client GUI acceptance or real microphone recording/transcription. Runtime capability checks and permission checks remain necessary, and transcription also depends on the Host service and account access. +The production feeds identify the distributed version; they do not establish that the plugin was installed or exercised on that client. This revision has not undergone 3.1.6 released-client GUI acceptance, nor GUI acceptance of Host navigation and theme on 3.1.11. Runtime capability checks and permission checks remain necessary. -See `README.md` for tested and unverified environments. Static validation and a loopback smoke test do not prove GUI behavior on every platform, microphone recording, transcription, or official-market installation. +See `README.md` for tested and unverified environments. Static validation and a loopback smoke test do not prove GUI behavior on every platform or official-market installation. diff --git a/plugins/minimax-ai/minimax-pet/README.md b/plugins/minimax-ai/minimax-pet/README.md index a02602b9..5ff6d8e6 100644 --- a/plugins/minimax-ai/minimax-pet/README.md +++ b/plugins/minimax-ai/minimax-pet/README.md @@ -6,13 +6,16 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib ## Features -- **Appearance:** preview and select pets, play short interactions, import compatible pet JSON and spritesheets, and remove imported pets. +- **Appearance:** preview and select pets, 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). +- **Open the conversation:** on MiniMax Code 3.1.11 or later, a task's detail, in the panel and on the desktop, has a **打开对话** (open conversation) button, labelled **去回答** (go answer) while the task needs an answer. It brings the MiniMax Code main window forward on that conversation. Older clients do not show the button. - **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. +- **Theme:** on MiniMax Code 3.1.11 or later the panel and desktop cards follow the client's light or dark theme; older clients follow the system setting. -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. +The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. + +Version **0.6.0** removes the **打招呼** (wave) and **跳一下** (jump) actions from the menu and the appearance page, and removes the original-chat draft with its voice input; the package no longer requests the microphone. It adds **打开对话** / **去回答** and the client theme. The supported baseline stays MiniMax Code 3.1.6: both additions are feature-detected and need no new access. ## Install and open @@ -29,10 +32,10 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - MiniMax Code **3.1.6 or later** is the supported baseline. On 2026-10-26, the official [mainland China](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml) and [Global](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml) production update feeds both listed 3.1.6. The corresponding release implementation was checked for the required APIs. This establishes a released API baseline, not GUI acceptance of this plugin on that release. - Task monitoring uses `agent.sessions`, `agent.session`, `agent.observe`, and `agent.cancel` with the `session.read` grant. Runtime capability and permission checks remain in place; missing APIs or disabled access are reported as unavailable. -- Voice input additionally requires `deviceAccess: ["microphone"]`, `speech.transcribe`, the plugin's microphone switch, system microphone permission, and access to the Host transcription service; otherwise it remains unavailable. A supported API does not guarantee service availability for every account. -- 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. +- **打开对话** / **去回答** uses `host.navigation.open` and the theme uses `host.ui.subscribe`, both added in MiniMax Code **3.1.11**; on 2026-11-10 the macOS and Windows installers of 3.1.11 answered on both production CDNs. Both calls were checked against the MiniMax Code source: opening a conversation the Mini App already holds needs no `hostAccess` and no extra grant, must start inside a click, and brings the main window forward. +- An earlier 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, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. -- No Windows GUI, released-client installation or GUI acceptance on 3.1.6, or real microphone recording/transcription has been verified for this revision. Automated tests use synthetic data and do not prove those paths. +- Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access @@ -40,8 +43,10 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - **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. +- **Host navigation:** only when you click **打开对话** or **去回答**, the page calls `host.navigation.open({ id: 'session', sessionId, source: 'local' })` for a conversation it already lists through `session.read`. It does not declare `hostAccess` and never reads which page of MiniMax Code you are on. It writes nothing to chat inputs and sends no message. +- **Display preferences:** `host.ui.subscribe` reports the client theme, which the page applies; the reported language is ignored. No grant is involved. +- **Network:** the plugin has no hard-coded external runtime endpoint; its own HTTP requests use its assigned loopback origin. +- **Microphone, camera and notifications:** not used and not declared. - **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 diff --git a/plugins/minimax-ai/minimax-pet/README.zh-CN.md b/plugins/minimax-ai/minimax-pet/README.zh-CN.md index aa0ecc38..ded2d144 100644 --- a/plugins/minimax-ai/minimax-pet/README.zh-CN.md +++ b/plugins/minimax-ai/minimax-pet/README.zh-CN.md @@ -8,21 +8,25 @@ 将仓库 `plugins/minimax-ai/minimax-pet/` 完整复制到 `/plugins/minimax-pet/`,包括隐藏的 `.minimax-plugin`;默认 `` 为 `~/.minimax`,自定义数据目录时采用实际配置。不要复制外层作者目录。重启 MiniMax Code、确认插件启用后,从 MiniApp 列表打开“MiniMax Pet”,或对 Agent 说“打开 MiniMax Pet”。点击“放到桌面”显示透明桌宠窗口。 -外观页支持选择、互动、导入预览和删除已导入宠物;任务页显示提醒、全部可见任务和最近回复;设置页提供大小、提醒管理和重连。桌面通知最多预览五行,点击后可滚动读完整内容,桌宠保持可见。观察到 ask 时显示“需要你回答”。原属聊天草稿通过明确入口打开,经 Host 确认后追加到原聊天输入框,仍由用户发送。 +外观页支持选择、导入预览和删除已导入宠物;任务页显示提醒、全部可见任务和最近回复;设置页提供大小、提醒管理和重连。桌面通知最多预览五行,点击后可滚动读完整内容,桌宠保持可见。观察到 ask 时显示“需要你回答”。 + +在 MiniMax Code 3.1.11 及以上版本,面板和桌面上的任务详情都有“打开对话”按钮,任务需要回答时显示为“去回答”;点一下会把 MiniMax Code 主窗口切到这个对话。面板和桌面卡片的明暗也跟随 MiniMax Code 的主题。旧版本客户端不显示该按钮,明暗跟随系统设置。 **0.5.10** 新增可选内置宠物 **麦芽糖**,是一位珊瑚粉触手长发的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她,预览下方会显示她的自述;薄荷色机器人仍为默认宠物。麦芽糖采用现有 v2 图集,包含九种动作和十六个视线方向,沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验,并在 Linux 的本地插件界面完成导入和选择;这不代表正式客户端的 bundled 安装或透明桌面窗口已验收。AI 创作流程和贡献者许可见[素材说明](ARTWORK.md)。 **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 +**0.6.0** 删除了菜单和外观页里的“打招呼”“跳一下”,删除了原属聊天草稿及其语音输入,不再申请麦克风权限;新增“打开对话/去回答”和主题跟随。支持基线仍为 MiniMax Code 3.1.6:两项新功能都先检测客户端能力,不需要新增权限。 + 旧的本地原型使用不同插件 ID。`minimax-pet` 会有独立的 Host 数据目录,安装本包不会自动搬迁旧插件的宠物和偏好;需要的素材先导出,保留旧安装和数据。 ## 环境与兼容 本包支持 MiniMax Code **3.1.6 及以上版本**。2026-10-26 核对的[中国大陆](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml)与 [Global](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml) 正式更新清单均为 3.1.6,并已核对同版本发行实现包含所需接口。这确认了正式发布的 API 基线,不代表本插件已在该正式客户端完成 GUI 验收。 -任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。语音还需要 `deviceAccess: ["microphone"]`、`speech.transcribe`、插件麦克风开关、系统麦克风授权和 Host 转写服务可用;接口已支持不保证每个账号都能使用转写服务。 +任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -此前 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除、草稿确认和桌面切换;当前开源修订运行 Node/DOM 测试与隔离 HTTP smoke。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。当前修订尚未验收 Windows GUI、正式客户端安装、3.1.6 正式版 GUI 或真实录音/ASR;本地开发版和合成数据测试不能证明这些路径。 +更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 ## 数据与能力边界 @@ -30,9 +34,9 @@ 全局观察聚合可见任务,初始快照至多 50 条;本连接已观察到、Host 仍确认可见的任务会继续保留。另三个详细观察名额监听活跃会话的 ask,任务结束或移除后取消,等待旧订阅真正结束再补位。详细观察是 tail-only,不能补回订阅前或断线期间的问卷;超过三个活跃任务时,只覆盖其中三个的详细事件。没有观察到 ask 不等于没有待答问卷。 -偏好和用户导入素材写入独立的 `context.dataDir`;导入替换有中断恢复,删除限于本插件数据。不创建独立聊天、不逐会话点评、不自动调用模型、不发送或停止用户会话、不代答问卷,也不能跳转任意普通会话。 +偏好和用户导入素材写入独立的 `context.dataDir`;导入替换有中断恢复,删除限于本插件数据。不创建独立聊天、不写入聊天输入框、不逐会话点评、不自动调用模型、不发送或停止用户会话、不代答问卷。 -原属草稿调用 `message.append(text)`,选择其他任务不会改变草稿目标。语音只在用户进入草稿后明确开始;离开会取消录音或转写,音频仅在内存中交给 Host 的转写服务,该服务可能使用其配置的网络。插件本身没有硬编码的外部服务地址,只使用分配的 loopback 源,不启动额外子进程或 shell。 +只有在你点击“打开对话”或“去回答”时,页面才调用 `host.navigation.open({ id: 'session', sessionId, source: 'local' })`,目标是已通过 `session.read` 列出的会话。未声明 `hostAccess`,不读取你在 MiniMax Code 里停留的页面。`host.ui.subscribe` 只用来读取并应用主题,返回的语言不使用,无需授权。不使用也不声明麦克风、摄像头或系统通知。插件本身没有硬编码的外部服务地址,只使用分配的 loopback 源,不启动额外子进程或 shell。 ## Agent Skill diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs deleted file mode 100644 index 29c93eda..00000000 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs +++ /dev/null @@ -1,50 +0,0 @@ -// 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 index b0eeceeb..6d8cebee 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs @@ -9,7 +9,7 @@ export const taskStatus = (row) => row?.needsAnswer === true ? 'needs_answer' /** Task notifications and their complete replies stay beside the pet. */ export function createCompactPetView({ - container, onClose, onSelectTask, + container, onClose, onSelectTask, onOpenSession, }) { if (!container?.ownerDocument) throw new TypeError('A compact pet container is required.'); const doc = container.ownerDocument; @@ -17,7 +17,7 @@ export function createCompactPetView({ container.classList.add('compact-pet-card'); let disposed = false, opened = false, view = 'tasks', reminders = { items: [] }; let allSessions = null, taskSelection = {}, detailBackView = 'tasks'; - let paintedDetailId = null; + let paintedDetailId = null, openToken = 0; const rows = new Map(); const setText = (node, value) => { const next = String(value ?? ''); if (node.textContent !== next) node.textContent = next; }; const element = (tag, className, text) => { @@ -70,7 +70,22 @@ export function createCompactPetView({ 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); + const openHint = element('p', 'compact-hint'); openHint.setAttribute('role', 'status'); openHint.hidden = true; + const setHint = (text, kind = 'info') => { setText(openHint, text); openHint.dataset.kind = kind; openHint.hidden = !openHint.textContent; }; + // The Host checks user activation, so the callback must run synchronously inside this click. + const openSession = button('打开对话', 'compact-open', () => { + const { id } = selection(); + if (disposed || !id || typeof onOpenSession !== 'function') return; + const token = ++openToken; + openSession.disabled = true; setHint('正在打开对话…'); + void call(onOpenSession, id).then((result) => { + if (disposed || token !== openToken) return; + openSession.disabled = false; + const failed = result?.ok === false; + setHint(textOf(result?.text) || (failed ? `没能打开对话(${failureCode(result)})。` : ''), textOf(result?.kind) || (failed ? 'error' : 'info')); + }); + }); + detailFooter.append(back, openSession); detailPane.append(detailTitle, detailStatus, detailReply, detailFooter, openHint); container.append(header, tasksPane, detailPane); function paintTasks() { @@ -127,7 +142,13 @@ export function createCompactPetView({ 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; } + if (paintedDetailId !== id) { + detailReply.scrollTop = 0; detailPane.scrollTop = 0; paintedDetailId = id; + openToken++; openSession.disabled = false; setHint(''); + } + openSession.hidden = typeof onOpenSession !== 'function' || !available || !id; + setText(openSession, needsAnswer ? '去回答' : '打开对话'); + openSession.setAttribute('aria-label', `${needsAnswer ? '去回答' : '打开对话'}:${textOf(session?.title) || textOf(detail?.title) || '该任务'}`); } function paint() { diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/index.html b/plugins/minimax-ai/minimax-pet/miniapp/client/index.html index 2a676002..60ea288c 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/index.html +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/index.html @@ -41,13 +41,6 @@

动画预览

正在加载宠物…

-
- -
- 更多 -
-
-
-
-
-

原聊天草稿

-

把文字或语音放入打开桌宠的聊天,由你确认后发送。

-
- -
版本与帮助
-

MiniMax Pet 0.5.11

+

MiniMax Pet 0.6.0

@@ -182,32 +172,6 @@

原聊天草稿

- - @@ -224,14 +188,6 @@

写入原聊天草稿

-
diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs index 14b87257..57a33f36 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs @@ -3,13 +3,13 @@ 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 = () => {} }) { +export function createPanelNavigation({ panel, beforeCloseImport = () => true }) { 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 state = { tab: 'appearance', appearanceView: 'pets', taskFilter: 'recent', taskDetail: false }; let disposed = false; let taskReturnFocus = null, taskReturnId = null; const listeners = []; @@ -28,21 +28,13 @@ export function createPanelNavigation({ panel, beforeCloseImport = () => true, o } 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 }); @@ -62,10 +54,6 @@ export function createPanelNavigation({ panel, beforeCloseImport = () => true, o 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; @@ -118,8 +106,6 @@ export function createPanelNavigation({ panel, beforeCloseImport = () => true, o } 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(); diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 579fe544..a6bbb345 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -1,8 +1,6 @@ // 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'; @@ -73,7 +71,8 @@ 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 hostNavigation = host?.host?.navigation ?? null; +const canOpenSession = typeof hostNavigation?.open === 'function'; const explicitDemo = params.get('demo') === '1'; const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); @@ -141,8 +140,6 @@ const app = { menuView: 'root', menuAnchor: { x: 0, y: 0 }, menuCloseTimer: null, - composerOpen: false, - composerFocused: false, quickbarTimer: null, transient: null, transientTimer: null, @@ -187,14 +184,10 @@ const els = { 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'), @@ -202,7 +195,6 @@ const els = { workbench: $('workbench'), activityCard: $('activity-card'), detailCard: $('detail-card'), - draftCard: $('draft-card'), demoControls: $('demo-controls'), demoStatus: $('demo-status'), sessionList: $('session-list'), @@ -210,21 +202,14 @@ const els = { detailTitle: $('detail-title'), detailStatus: $('detail-status'), detailTime: $('detail-time'), + detailActions: $('detail-actions'), + detailOpen: $('detail-open'), + detailOpenMsg: $('detail-open-msg'), 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'), @@ -250,18 +235,8 @@ const els = { 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; @@ -269,6 +244,8 @@ const reminders = createReminderStore(); const compactBubbleSize = { width: 300, height: 340 }; let notificationPreviews = null; let askObserver = null; +let detailOpenToken = 0; +let detailOpenSessionId = null; // Player module is loaded dynamically so a missing/broken module surfaces in the UI instead of a blank page. let STATES = []; @@ -297,7 +274,7 @@ function activityAnim() { 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' }; + if (app.hovered && app.mode === 'window' && !app.menuOpen && !app.workbenchOpen) return { state: 'jumping', key: 'hover' }; return base; } @@ -349,7 +326,6 @@ async function initActivitySource() { }); askObserver.update({ availability: snapshot.availability, sessions: snapshot.sessions, selectedSessionId: snapshot.selectedSessionId }); syncObservedAsks(); - draftController.setOrigin(snapshot.originSession); renderHeader(); applyActivity(); }, @@ -409,61 +385,66 @@ function selectSession(sessionId, report = (text, kind) => setMsg(els.sessionMsg }, (error) => report(`无法关注此任务(${errCode(error)})`, 'error')); } -// Host message.append +// Host navigation and display preferences // --------------------------------------------------------------------------- -function describeAppendError(code) { +function describeOpenError(code) { switch (code) { - case 'user_cancelled': - return '已取消'; case 'target_unavailable': - return '打开桌宠的原属对话不可用,文字已保留。请从可用的聊天重新打开桌宠。'; + return '这个对话已不存在或暂时打不开(target_unavailable)。'; + case 'unsupported_source': + return '这个对话不在本机,桌宠打不开(unsupported_source)。'; case 'not_permitted': - return '无法追加到原属对话。请直接点击重试;文字已保留。'; - case 'unsupported': - return '当前环境不能加到对话输入框。'; - case 'empty': - return '先写点什么吧'; - case 'busy': - return '正在等待上一次确认,文字已保留。'; + return '需要直接点击按钮才能打开(not_permitted)。'; + case 'host_unavailable': + return 'MiniMax Code 主窗口暂不可用,请稍后再试(host_unavailable)。'; case 'result_unknown': - return '请先检查输入框,文本可能已经加进去了'; + return '没收到打开结果,请看看主窗口是否已经切过去(result_unknown)。'; 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'); } - }, - })); + return `没能打开对话(${code})。`; + } +} + +/** Must be called synchronously from a click or key handler: the Host checks user activation. */ +function openSessionInHost(sessionId) { + if (!canOpenSession) return Promise.resolve({ kind: 'error', text: '当前 MiniMax Code 版本不能从桌宠打开对话。' }); + return callBridge(() => hostNavigation.open({ id: 'session', sessionId, source: 'local' })).then( + (result) => { + if (result?.status === 'opened') return { kind: 'ok', text: '已在 MiniMax Code 主窗口打开这个对话。' }; + if (result?.reason === 'user_cancelled') return { kind: 'info', text: '已取消,主窗口留在原来的页面。' }; + return { kind: 'info', text: '这次打开被另一次跳转取代了。' }; + }, + (error) => { + const code = errCode(error); + console.warn('[pet] open_session_failed:', code, error); + return { kind: 'error', text: describeOpenError(code) }; + }, + ); +} + +function openSelectedSession() { + const sessionId = app.selectedSessionId; + if (!sessionId) return; + const token = ++detailOpenToken; + els.detailOpen.disabled = true; + setMsg(els.detailOpenMsg, '正在打开对话…'); + void openSessionInHost(sessionId).then((result) => { + if (disposed || token !== detailOpenToken) return; + els.detailOpen.disabled = false; + setMsg(els.detailOpenMsg, result.text, result.kind); + }); +} + +function initHostTheme() { + const ui = host?.host?.ui; + if (typeof ui?.subscribe !== 'function') return; + try { + const subscription = ui.subscribe(({ theme }) => { + if (theme === 'light' || theme === 'dark') root.dataset.theme = theme; + }, { onError: (error) => console.warn('[pet] host_theme_unavailable:', errCode(error), error) }); + disposers.push(() => subscription?.dispose?.()); + } catch (error) { + console.warn('[pet] host_theme_unavailable:', errCode(error), error); } } @@ -569,10 +550,6 @@ function applyMode(mode) { 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; @@ -580,7 +557,6 @@ function applyMode(mode) { els.desk.hidden = mode !== 'window'; closeMenu(); - closeComposer(); hideQuickbar(); cancelDrag(); stopLook(); @@ -608,7 +584,6 @@ function applyMode(mode) { function popOut() { if (!win) return; - if (!['idle', 'error'].includes(speechState)) { setMsg(els.popoutMsg, '请先停止录音并等待转写完成。'); return; } els.popoutBtn.disabled = true; setMsg(els.popoutMsg, '正在弹出…'); app.modeSwitching = true; @@ -666,7 +641,7 @@ function isOverPet(x, y) { } function isOverOverlay(x, y) { - return [els.bubble, els.notificationStack, els.quickbar, els.composer, els.menu, els.workbench].some( + return [els.bubble, els.notificationStack, els.quickbar, els.menu, els.workbench].some( (el) => !el.hidden && pointInRect(el.getBoundingClientRect(), x, y), ); } @@ -678,7 +653,6 @@ function refreshInteractivity() { app.workbenchOpen || app.dragging || app.menuOpen || - app.composerFocused || (p ? isOverPet(p.x, p.y) || isOverOverlay(p.x, p.y) : false); setClickThrough(!interactive); } @@ -776,7 +750,7 @@ function onPointerLeave() { if (app.mode !== 'window') return; app.pointer = null; stopLook(); - refreshInteractivity(); // stays interactive while menu/composer/drag force it + refreshInteractivity(); // stays interactive while menu/drag force it if (app.menuOpen) armMenuClose(); } @@ -791,7 +765,6 @@ function updateLook(x, y) { app.hovered || app.dragging || app.menuOpen || - app.composerOpen || app.oneShot || app.workbenchOpen || reducedMotion.matches || @@ -928,7 +901,7 @@ function cancelDrag() { } // --------------------------------------------------------------------------- -// Bubble / quick bar / composer (window mode) +// Bubble / quick bar (window mode) // --------------------------------------------------------------------------- function showTransient(text, kind = 'info', sticky = false) { @@ -945,7 +918,7 @@ function showTransient(text, kind = 'info', sticky = false) { function renderBubble() { renderNotificationStack(); - if (app.composerOpen || app.workbenchOpen) { + if (app.workbenchOpen) { els.bubble.hidden = true; app.bubbleSessionId = null; refreshInteractivity(); @@ -993,7 +966,7 @@ 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; + && !app.workbenchOpen && !app.transient; container.hidden = !visible; if (!visible) return; const previews = notificationPreviews?.getState().previews ?? {}; @@ -1045,35 +1018,6 @@ function armQuickbarHide() { }, 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) // --------------------------------------------------------------------------- @@ -1128,9 +1072,6 @@ function menuModel(view) { 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 }, @@ -1238,7 +1179,6 @@ function openMenu(x, y) { if (app.mode !== 'window') return; noteInteraction(); hideQuickbar(); - closeComposer(); stopLook(); disarmMenuClose(); app.menuOpen = true; @@ -1705,7 +1645,6 @@ async function confirmImport() { function renderAll() { renderHeader(); renderActivityUI(); - renderTalk(); renderScale(); renderGallery(); renderBubble(); @@ -1812,14 +1751,6 @@ function renderSessionList(current) { 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); @@ -1833,17 +1764,31 @@ function renderDetail() { 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; } + renderDetailOpen(needsAnswer); els.detailNote.textContent = app.source === 'demo' ? '资源动作预览,不提供真实任务概览。' : app.availability !== 'ready' ? availabilityText() : app.detailLoading ? (detail ? '正在更新…' : '正在读取任务…') : !detail ? '选择任务后查看最近回复。' - : needsAnswer ? '请回到 MiniMax Code 的对应聊天回答。' - : detail.lastAssistantText ? '显示任务的最近回复。后续处理请回到对应聊天。' : '这项任务暂时没有可查看的回复。'; + : needsAnswer ? (canOpenSession ? '点「去回答」在主窗口打开这个对话后回答。' : '请回到 MiniMax Code 的对应聊天回答。') + : detail.lastAssistantText ? `显示任务的最近回复。${canOpenSession ? '点「打开对话」继续处理。' : '后续处理请回到对应聊天。'}` : '这项任务暂时没有可查看的回复。'; +} + +function renderDetailOpen(needsAnswer) { + // Only a different task resets a pending open; a brief disconnect just hides the button. + if (detailOpenSessionId !== app.selectedSessionId) { + detailOpenSessionId = app.selectedSessionId; + detailOpenToken++; + els.detailOpen.disabled = false; + setMsg(els.detailOpenMsg, ''); + } + els.detailActions.hidden = !canOpenSession || !app.selectedSessionId || app.source !== 'real' || app.availability !== 'ready'; + els.detailOpen.textContent = needsAnswer ? '去回答' : '打开对话'; } function initWorkbench() { workbenchView = createCompactPetView({ container: els.workbench, onClose: () => { void closeWorkbench(); }, onSelectTask: openTaskPreview, + onOpenSession: canOpenSession ? openSessionInHost : null, }); notificationPreviews = createNotificationPreviews({ agent, onChange: () => { renderBubble(); renderCompactView(); } }); if (win) workbenchWindow = createWorkbenchWindow({ windowAPI: win, @@ -1869,16 +1814,10 @@ function openTaskPreview(sessionId) { } function focusFullPanel(view) { - if (view === 'draft') { if (canAppend) panelNavigation?.showDraft(); } - else if (view === 'detail') panelNavigation?.showTaskDetail(); + 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; @@ -1886,12 +1825,11 @@ async function openWorkbench(view = 'tasks') { 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(); + 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)); @@ -1968,45 +1906,6 @@ function renderReminders() { 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); @@ -2119,8 +2018,7 @@ function bindPanel() { 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.detailOpen, 'click', openSelectedSession); listen(els.importPick, 'click', () => els.importInput.click()); listen(els.importInput, 'change', () => { @@ -2203,7 +2101,6 @@ function bindDesk() { 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(); }); @@ -2219,17 +2116,6 @@ function bindDesk() { 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(); - }); } // --------------------------------------------------------------------------- @@ -2252,9 +2138,6 @@ async function boot() { 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) => { @@ -2263,11 +2146,10 @@ async function boot() { if (e.matches) stopLook(); }); + initHostTheme(); bindPanel(); initWorkbench(); bindDesk(); - bindDraftForms(); - initSpeech(); listen(window, 'pagehide', disposeApp); await initPreferences(); if (disposed) return; diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css b/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css index 2b24b952..dd35b03b 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet.css @@ -293,9 +293,6 @@ h2 { flex-wrap: wrap; align-items: center; } -.row.end { - justify-content: flex-end; -} .layout { display: grid; @@ -465,52 +462,6 @@ h2 { 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; @@ -764,70 +715,11 @@ html.mode-window .pet-sprite:focus-visible { .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; @@ -960,6 +852,7 @@ html.mode-window .pet-sprite:focus-visible { .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; } +.compact-open { font-size: 11px; font-weight: 600; } .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); } @@ -970,7 +863,6 @@ 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; } @@ -1130,34 +1022,6 @@ html.mode-inline body { 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; @@ -1449,6 +1313,16 @@ html.mode-inline body { overflow-wrap: anywhere; } +#panel .detail-actions { + flex: none; + align-items: center; + gap: 4px 12px; +} + +#panel #detail-open-msg { + overflow-wrap: anywhere; +} + #panel .detail-meta { display: flex; flex-wrap: wrap; @@ -1552,10 +1426,6 @@ html.mode-inline body { overflow-wrap: anywhere; } -#panel .talk-form { - gap: 12px; -} - @media (max-width: 640px) { #panel .panel-header { gap: 12px; @@ -1598,10 +1468,6 @@ html.mode-inline body { min-height: 210px; } - #panel .appearance-actions { - margin-top: 4px; - } - #panel .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs b/plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs deleted file mode 100644 index 381d5f41..00000000 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs +++ /dev/null @@ -1,280 +0,0 @@ -// 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/miniapp.json b/plugins/minimax-ai/minimax-pet/miniapp/miniapp.json index f8235755..0f482eb9 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/miniapp.json +++ b/plugins/minimax-ai/minimax-pet/miniapp/miniapp.json @@ -31,8 +31,5 @@ "clickThrough": true } }, - "mcpEndpoints": [], - "deviceAccess": [ - "microphone" - ] + "mcpEndpoints": [] } diff --git a/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json b/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json index 707488bb..588f806d 100644 --- a/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json +++ b/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-pet", "displayName": "MiniMax Pet", - "version": "0.5.11", + "version": "0.6.0", "description": "轻量桌宠,聚合主窗口可见会话的任务状态、需要回答的提醒和最新结果,支持外观切换及图集导入。", "author": "MiniMax", "icon": "icon.png", diff --git a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md index 98d994bb..e1ef78b8 100644 --- a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md +++ b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md @@ -10,7 +10,7 @@ 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. +`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, Host navigation, and theme 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 @@ -19,10 +19,11 @@ npm run check:package - 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. +- **打开对话** / **去回答** calls `host.navigation.open` synchronously inside the click (the Host checks user activation), never retries automatically, and drops a late result once another task is selected. Without the API the button stays hidden. +- The page follows `host.ui.subscribe` themes through `data-theme`; without it the CSS falls back to `prefers-color-scheme`. - 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. +The Node test suite covers state aggregation, observer admission/release, reminders, page navigation, Host navigation and theme, 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, model, or released-client behavior. See the README for the current acceptance boundary. diff --git a/sources/minimax-ai/minimax-pet/PROVENANCE.md b/sources/minimax-ai/minimax-pet/PROVENANCE.md index 8e8b34ce..f664087a 100644 --- a/sources/minimax-ai/minimax-pet/PROVENANCE.md +++ b/sources/minimax-ai/minimax-pet/PROVENANCE.md @@ -1,6 +1,6 @@ # Source and distribution -Author: MiniMax ([MiniMax-AI](https://github.com/MiniMax-AI)). This is the 0.5.10 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. +Author: MiniMax ([MiniMax-AI](https://github.com/MiniMax-AI)). This is the 0.6.0 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 @@ -35,8 +35,8 @@ Maiyatang (麦芽糖), contributed by [Fectivnfy112357](https://github.com/Fecti ## Verification boundaries -The supported client baseline is MiniMax Code **3.1.6**. On 2026-10-26, the official [mainland China macOS](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml), [Global macOS](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml), and [mainland China Windows](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest.yml) production feeds listed 3.1.6, released on 2026-10-23. The corresponding release implementation was separately checked for session reads and observation, cancellation, capability checks, speech transcription, microphone declaration, and the click-through window handle. This is a confirmed released baseline, not a claim that 3.1.6 was the earliest version with each API. +The supported client baseline is MiniMax Code **3.1.6**. On 2026-10-26, the official [mainland China macOS](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml), [Global macOS](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml), and [mainland China Windows](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest.yml) production feeds listed 3.1.6, released on 2026-10-23. The corresponding release implementation was separately checked for session reads and observation, cancellation, capability checks, and the click-through window handle. Version 0.6.0 also uses `host.navigation.open` and `host.ui.subscribe` from MiniMax Code 3.1.11 when present; they are feature-detected and do not raise the baseline. This is a confirmed released baseline, not a claim that 3.1.6 was the earliest version with each API. -The production feeds identify the distributed version; they do not establish that the plugin was installed or exercised on that client. This revision has not undergone 3.1.6 released-client GUI acceptance or real microphone recording/transcription. Runtime capability checks and permission checks remain necessary, and transcription also depends on the Host service and account access. +The production feeds identify the distributed version; they do not establish that the plugin was installed or exercised on that client. This revision has not undergone 3.1.6 released-client GUI acceptance, nor GUI acceptance of Host navigation and theme on 3.1.11. Runtime capability checks and permission checks remain necessary. -See `README.md` for tested and unverified environments. Static validation and a loopback smoke test do not prove GUI behavior on every platform, microphone recording, transcription, or official-market installation. +See `README.md` for tested and unverified environments. Static validation and a loopback smoke test do not prove GUI behavior on every platform or official-market installation. diff --git a/sources/minimax-ai/minimax-pet/README.md b/sources/minimax-ai/minimax-pet/README.md index a02602b9..5ff6d8e6 100644 --- a/sources/minimax-ai/minimax-pet/README.md +++ b/sources/minimax-ai/minimax-pet/README.md @@ -6,13 +6,16 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib ## Features -- **Appearance:** preview and select pets, play short interactions, import compatible pet JSON and spritesheets, and remove imported pets. +- **Appearance:** preview and select pets, 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). +- **Open the conversation:** on MiniMax Code 3.1.11 or later, a task's detail, in the panel and on the desktop, has a **打开对话** (open conversation) button, labelled **去回答** (go answer) while the task needs an answer. It brings the MiniMax Code main window forward on that conversation. Older clients do not show the button. - **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. +- **Theme:** on MiniMax Code 3.1.11 or later the panel and desktop cards follow the client's light or dark theme; older clients follow the system setting. -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. +The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. + +Version **0.6.0** removes the **打招呼** (wave) and **跳一下** (jump) actions from the menu and the appearance page, and removes the original-chat draft with its voice input; the package no longer requests the microphone. It adds **打开对话** / **去回答** and the client theme. The supported baseline stays MiniMax Code 3.1.6: both additions are feature-detected and need no new access. ## Install and open @@ -29,10 +32,10 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - MiniMax Code **3.1.6 or later** is the supported baseline. On 2026-10-26, the official [mainland China](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml) and [Global](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml) production update feeds both listed 3.1.6. The corresponding release implementation was checked for the required APIs. This establishes a released API baseline, not GUI acceptance of this plugin on that release. - Task monitoring uses `agent.sessions`, `agent.session`, `agent.observe`, and `agent.cancel` with the `session.read` grant. Runtime capability and permission checks remain in place; missing APIs or disabled access are reported as unavailable. -- Voice input additionally requires `deviceAccess: ["microphone"]`, `speech.transcribe`, the plugin's microphone switch, system microphone permission, and access to the Host transcription service; otherwise it remains unavailable. A supported API does not guarantee service availability for every account. -- 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. +- **打开对话** / **去回答** uses `host.navigation.open` and the theme uses `host.ui.subscribe`, both added in MiniMax Code **3.1.11**; on 2026-11-10 the macOS and Windows installers of 3.1.11 answered on both production CDNs. Both calls were checked against the MiniMax Code source: opening a conversation the Mini App already holds needs no `hostAccess` and no extra grant, must start inside a click, and brings the main window forward. +- An earlier 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, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. -- No Windows GUI, released-client installation or GUI acceptance on 3.1.6, or real microphone recording/transcription has been verified for this revision. Automated tests use synthetic data and do not prove those paths. +- Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access @@ -40,8 +43,10 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - **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. +- **Host navigation:** only when you click **打开对话** or **去回答**, the page calls `host.navigation.open({ id: 'session', sessionId, source: 'local' })` for a conversation it already lists through `session.read`. It does not declare `hostAccess` and never reads which page of MiniMax Code you are on. It writes nothing to chat inputs and sends no message. +- **Display preferences:** `host.ui.subscribe` reports the client theme, which the page applies; the reported language is ignored. No grant is involved. +- **Network:** the plugin has no hard-coded external runtime endpoint; its own HTTP requests use its assigned loopback origin. +- **Microphone, camera and notifications:** not used and not declared. - **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 diff --git a/sources/minimax-ai/minimax-pet/README.zh-CN.md b/sources/minimax-ai/minimax-pet/README.zh-CN.md index aa0ecc38..ded2d144 100644 --- a/sources/minimax-ai/minimax-pet/README.zh-CN.md +++ b/sources/minimax-ai/minimax-pet/README.zh-CN.md @@ -8,21 +8,25 @@ 将仓库 `plugins/minimax-ai/minimax-pet/` 完整复制到 `/plugins/minimax-pet/`,包括隐藏的 `.minimax-plugin`;默认 `` 为 `~/.minimax`,自定义数据目录时采用实际配置。不要复制外层作者目录。重启 MiniMax Code、确认插件启用后,从 MiniApp 列表打开“MiniMax Pet”,或对 Agent 说“打开 MiniMax Pet”。点击“放到桌面”显示透明桌宠窗口。 -外观页支持选择、互动、导入预览和删除已导入宠物;任务页显示提醒、全部可见任务和最近回复;设置页提供大小、提醒管理和重连。桌面通知最多预览五行,点击后可滚动读完整内容,桌宠保持可见。观察到 ask 时显示“需要你回答”。原属聊天草稿通过明确入口打开,经 Host 确认后追加到原聊天输入框,仍由用户发送。 +外观页支持选择、导入预览和删除已导入宠物;任务页显示提醒、全部可见任务和最近回复;设置页提供大小、提醒管理和重连。桌面通知最多预览五行,点击后可滚动读完整内容,桌宠保持可见。观察到 ask 时显示“需要你回答”。 + +在 MiniMax Code 3.1.11 及以上版本,面板和桌面上的任务详情都有“打开对话”按钮,任务需要回答时显示为“去回答”;点一下会把 MiniMax Code 主窗口切到这个对话。面板和桌面卡片的明暗也跟随 MiniMax Code 的主题。旧版本客户端不显示该按钮,明暗跟随系统设置。 **0.5.10** 新增可选内置宠物 **麦芽糖**,是一位珊瑚粉触手长发的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她,预览下方会显示她的自述;薄荷色机器人仍为默认宠物。麦芽糖采用现有 v2 图集,包含九种动作和十六个视线方向,沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验,并在 Linux 的本地插件界面完成导入和选择;这不代表正式客户端的 bundled 安装或透明桌面窗口已验收。AI 创作流程和贡献者许可见[素材说明](ARTWORK.md)。 **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 +**0.6.0** 删除了菜单和外观页里的“打招呼”“跳一下”,删除了原属聊天草稿及其语音输入,不再申请麦克风权限;新增“打开对话/去回答”和主题跟随。支持基线仍为 MiniMax Code 3.1.6:两项新功能都先检测客户端能力,不需要新增权限。 + 旧的本地原型使用不同插件 ID。`minimax-pet` 会有独立的 Host 数据目录,安装本包不会自动搬迁旧插件的宠物和偏好;需要的素材先导出,保留旧安装和数据。 ## 环境与兼容 本包支持 MiniMax Code **3.1.6 及以上版本**。2026-10-26 核对的[中国大陆](https://filecdn.minimax.chat/public/minimax-agent-prod/release/latest-mac.yml)与 [Global](https://file.cdn.minimax.io/public/minimax-agent-prod/release/latest-mac.yml) 正式更新清单均为 3.1.6,并已核对同版本发行实现包含所需接口。这确认了正式发布的 API 基线,不代表本插件已在该正式客户端完成 GUI 验收。 -任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。语音还需要 `deviceAccess: ["microphone"]`、`speech.transcribe`、插件麦克风开关、系统麦克风授权和 Host 转写服务可用;接口已支持不保证每个账号都能使用转写服务。 +任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -此前 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除、草稿确认和桌面切换;当前开源修订运行 Node/DOM 测试与隔离 HTTP smoke。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。当前修订尚未验收 Windows GUI、正式客户端安装、3.1.6 正式版 GUI 或真实录音/ASR;本地开发版和合成数据测试不能证明这些路径。 +更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 ## 数据与能力边界 @@ -30,9 +34,9 @@ 全局观察聚合可见任务,初始快照至多 50 条;本连接已观察到、Host 仍确认可见的任务会继续保留。另三个详细观察名额监听活跃会话的 ask,任务结束或移除后取消,等待旧订阅真正结束再补位。详细观察是 tail-only,不能补回订阅前或断线期间的问卷;超过三个活跃任务时,只覆盖其中三个的详细事件。没有观察到 ask 不等于没有待答问卷。 -偏好和用户导入素材写入独立的 `context.dataDir`;导入替换有中断恢复,删除限于本插件数据。不创建独立聊天、不逐会话点评、不自动调用模型、不发送或停止用户会话、不代答问卷,也不能跳转任意普通会话。 +偏好和用户导入素材写入独立的 `context.dataDir`;导入替换有中断恢复,删除限于本插件数据。不创建独立聊天、不写入聊天输入框、不逐会话点评、不自动调用模型、不发送或停止用户会话、不代答问卷。 -原属草稿调用 `message.append(text)`,选择其他任务不会改变草稿目标。语音只在用户进入草稿后明确开始;离开会取消录音或转写,音频仅在内存中交给 Host 的转写服务,该服务可能使用其配置的网络。插件本身没有硬编码的外部服务地址,只使用分配的 loopback 源,不启动额外子进程或 shell。 +只有在你点击“打开对话”或“去回答”时,页面才调用 `host.navigation.open({ id: 'session', sessionId, source: 'local' })`,目标是已通过 `session.read` 列出的会话。未声明 `hostAccess`,不读取你在 MiniMax Code 里停留的页面。`host.ui.subscribe` 只用来读取并应用主题,返回的语言不使用,无需授权。不使用也不声明麦克风、摄像头或系统通知。插件本身没有硬编码的外部服务地址,只使用分配的 loopback 源,不启动额外子进程或 shell。 ## Agent Skill diff --git a/sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs b/sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs deleted file mode 100644 index 09df9b65..00000000 --- a/sources/minimax-ai/minimax-pet/dev/chat-draft.test.mjs +++ /dev/null @@ -1,47 +0,0 @@ -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 index d9b0bdd1..c866fdf6 100644 --- a/sources/minimax-ai/minimax-pet/dev/compact-pet-view.test.mjs +++ b/sources/minimax-ai/minimax-pet/dev/compact-pet-view.test.mjs @@ -144,7 +144,7 @@ domTest('detail loads complete inert text and retains the pet without additional 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.container.querySelector('.compact-detail .compact-footer').children].filter((node) => !node.hidden).map((node) => node.textContent), ['返回任务']); assert.deepEqual(h.calls, []); assert.equal(h.view.opened, true); }); @@ -260,3 +260,39 @@ domTest('reminders and all tasks have no mark-as-seen action or callback', (t) = } assert.deepEqual(h.calls, []); }); + +domTest('the detail can open its conversation in the Host only when a handler is supplied', async (t) => { + const opened = []; + const h = harness(t, { onOpenSession: (id) => { opened.push(id); return { kind: 'ok', text: '已打开' }; } }); + h.update({ taskSelection: selection(1) }); h.view.open('detail'); + const open = h.container.querySelector('.compact-open'), hint = h.container.querySelector('.compact-detail .compact-hint'); + assert.equal(open.hidden, false); assert.equal(open.textContent, '打开对话'); assert.equal(hint.hidden, true); + open.click(); + assert.deepEqual(opened, ['task-1'], 'the Host call starts inside the click'); + assert.equal(open.disabled, true); assert.equal(hint.textContent, '正在打开对话…'); + await tick(); + assert.equal(open.disabled, false); assert.equal(hint.textContent, '已打开'); assert.equal(hint.dataset.kind, 'ok'); + assert.equal(h.view.opened, true); assert.equal(h.view.view, 'detail'); assert.deepEqual(h.calls, []); + h.update({ taskSelection: { ...selection(1), session: { ...task(1), needsAnswer: true } } }); + assert.equal(open.textContent, '去回答'); assert.match(open.getAttribute('aria-label'), /去回答:任务 1/); + h.update({ taskSelection: { ...selection(1), availability: 'disconnected' } }); assert.equal(open.hidden, true); + h.update({ taskSelection: { availability: 'ready', selectedSessionId: null } }); assert.equal(open.hidden, true); + const plain = harness(t); plain.update({ taskSelection: selection(1) }); plain.view.open('detail'); + assert.equal(plain.container.querySelector('.compact-open').hidden, true); +}); + +domTest('switching tasks clears a pending open result and a throwing handler reports its code', async (t) => { + const request = deferred(); + const h = harness(t, { onOpenSession: () => request.promise }); + h.update({ taskSelection: selection(1) }); h.view.open('detail'); + const open = h.container.querySelector('.compact-open'), hint = h.container.querySelector('.compact-detail .compact-hint'); + open.click(); h.update({ taskSelection: selection(2) }); + assert.equal(open.disabled, false); assert.equal(hint.hidden, true); + request.resolve({ kind: 'ok', text: '迟到的结果' }); await tick(); + assert.equal(hint.hidden, true); + const failing = harness(t, { onOpenSession: () => { throw { code: 'host_unavailable' }; } }); + failing.update({ taskSelection: selection(1) }); failing.view.open('detail'); + failing.container.querySelector('.compact-open').click(); await tick(); + const failedHint = failing.container.querySelector('.compact-detail .compact-hint'); + assert.match(failedHint.textContent, /host_unavailable/); assert.equal(failedHint.dataset.kind, 'error'); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs b/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs index 349f488f..377958ca 100644 --- a/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs +++ b/sources/minimax-ai/minimax-pet/dev/panel-navigation.test.mjs @@ -25,8 +25,7 @@ const fixture = `
完整回复
`; function harness(t, options = {}) { @@ -43,7 +42,7 @@ function harness(t, options = {}) { }; return { dom, panel, nav, find, tab, page, key }; } -const defaultState = { tab: 'appearance', appearanceView: 'pets', settingsView: 'main', taskFilter: 'recent', taskDetail: false }; +const defaultState = { tab: 'appearance', appearanceView: 'pets', taskFilter: 'recent', taskDetail: false }; function assertTab(h, active) { for (const name of ['appearance', 'tasks', 'settings']) { @@ -60,7 +59,6 @@ domTest('the panel starts on appearance with one keyboard-reachable tab', (t) => 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); }); @@ -135,25 +133,6 @@ domTest('a completed import returns to pets without changing the current page or 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(); @@ -181,19 +160,19 @@ domTest('returning from a task keeps its list nodes, filter and reading position 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 = '尚未提交'; + h.nav.showTab('tasks'); h.nav.setTaskFilter('all'); h.find('#task-filter-all').focus(); 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, '尚未提交'); + assert.equal(h.dom.window.document.activeElement, h.find('#task-filter-all')); 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')); + assert.deepEqual(h.nav.getState(), before); assert.equal(h.dom.window.document.activeElement, h.find('#task-filter-all')); }); domTest('connection labels describe each availability and large reminder counts stay compact', (t) => { @@ -214,18 +193,18 @@ domTest('state snapshots cannot mutate the navigation state', (t) => { }); 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(); + let closes = 0; + const h = harness(t, { beforeCloseImport: () => { closes++; return true; } }); + h.nav.showTab('settings'); h.nav.dispose(); + const state = h.nav.getState(), connection = h.find('#connection-status').textContent; + h.tab('tasks').click(); h.find('#open-import').click(); h.find('#import-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); + h.nav.showTab('appearance'); h.nav.showImport(); h.nav.closeImport(); h.nav.completeImport(); h.nav.showTaskDetail(); h.nav.showTaskList(); h.nav.setTaskFilter('all'); h.nav.dispose(); + assert.deepEqual(h.nav.getState(), state); 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 () => { +domTest('the shipped page exposes all navigation targets and keeps only the current feature controls', async () => { const html = await readFile(new URL('../miniapp/client/index.html', import.meta.url), 'utf8'); const dom = new JSDOM(html); try { @@ -238,19 +217,21 @@ domTest('the shipped page exposes all navigation targets and keeps the existing 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', + const required = ['appearance-main', 'import-panel', 'settings-main', 'open-import', 'import-back', + 'recent-tasks', 'all-sessions', 'task-filter-recent', 'task-filter-all', + 'task-detail-back', 'panel-task-count', 'connection-status', 'panel-stage', 'pet-sprite', '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']; + 'detail-card', 'detail-title', 'detail-status', 'detail-time', 'detail-actions', 'detail-open', 'detail-open-msg', + 'detail-reply', 'scale-group']; for (const id of required) assert.ok(panel.querySelector(`#${id}`), `Missing existing or navigation control #${id}`); + for (const id of ['wave-btn', 'jump-btn', 'open-draft', 'draft-card', 'talk-form', 'talk-text', 'draft-speech', + 'speech-start', 'composer', 'composer-input']) assert.equal(doc.getElementById(id), null, `Removed control #${id} is back`); + assert.equal(doc.querySelector('textarea'), null); 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(); } 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 index 478557c2..89eae0cf 100644 --- a/sources/minimax-ai/minimax-pet/dev/panel-page-wiring.test.mjs +++ b/sources/minimax-ai/minimax-pet/dev/panel-page-wiring.test.mjs @@ -11,25 +11,33 @@ const source = await readFile(new URL('../miniapp/client/pet-app.js', import.met 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 navigate = source.slice(source.indexOf('function focusFullPanel('), source.indexOf('async function openWorkbench(')); +const hostOpen = source.slice(source.indexOf('function describeOpenError('), source.indexOf('function initHostTheme(')); +const hostTheme = source.slice(source.indexOf('function initHostTheme('), source.indexOf('// Window mode switching')); +const errCode = source.slice(source.indexOf('function errCode('), source.indexOf('function logError(')); 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 els = Object.fromEntries(['detailTitle', 'detailStatus', 'detailTime', 'detailError', 'detailReply', 'detailNote', + 'detailActions', 'detailOpen', 'detailOpenMsg'].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 selected = [], reports = [], compact = [], opens = [], warnings = [], pending = deferred(); + const hostNavigation = { open: (target) => { opens.push(target); return patch.openResult ? patch.openResult(target) : Promise.resolve({ status: 'opened' }); } }; const context = vm.createContext({ - app, els, document: doc, panelNavigation: nav, panelDetailId: null, disposed: false, canAppend: true, taskStatus, + app, els, document: doc, panelNavigation: nav, panelDetailId: null, disposed: false, taskStatus, + canOpenSession: patch.canOpenSession ?? true, hostNavigation, detailOpenToken: 0, detailOpenSessionId: null, 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: () => '连接不可用', + openTaskPreview: (id) => compact.push(id), + callBridge: (callback) => { try { return Promise.resolve(callback()); } catch (error) { return Promise.reject(error); } }, + setMsg: (element, text, kind = 'info') => { reports.push(text); if (element) { element.textContent = text || ''; element.dataset.kind = kind; } }, + availabilityText: () => '连接不可用', console: { warn: (...args) => warnings.push(args) }, STATUS_LABEL: { idle: '空闲', completed: '本次完成', running: '工作中', error: '异常' }, }); - vm.runInContext(`${select}\n${detail}\n${navigate}\nglobalThis.handlers = { selectSession, renderDetail, focusFullPanel };`, context); + vm.runInContext(`${errCode}\n${select}\n${detail}\n${navigate}\n${hostOpen}\nglobalThis.handlers = { selectSession, renderDetail, focusFullPanel, openSelectedSession, openSessionInHost };`, context); t.after(() => { nav.dispose(); dom.window.close(); }); - return { app, nav, els, doc, context, selected, reports, compact, pending, ...context.handlers }; + return { app, nav, els, doc, context, selected, reports, compact, opens, warnings, pending, ...context.handlers }; } test('selecting an inline task reveals its detail immediately; its delayed result never changes the new tab', async (t) => { @@ -47,19 +55,12 @@ test('desktop task selection stays in the compact window and does not navigate t 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'); +test('explicit panel entries map tasks and task detail to their real pages', (t) => { + const h = harness(t); h.nav.showTab('settings'); 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); @@ -86,3 +87,97 @@ test('live ask replaces the old reply in the formal task detail without navigati assert.equal(h.els.detailStatus.textContent, '需要你回答'); assert.equal(h.els.detailReply.textContent, '需要你回答'); assert.equal(h.nav.getState().tab, 'appearance'); }); + +const readySelection = (needsAnswer = false) => ({ selectedSessionId: 'a', + sessions: [{ sessionId: 'a', title: '任务A', status: 'running', needsAnswer }], + detail: { sessionId: 'a', title: '任务A', status: 'running', updatedAtMs: 1, lastAssistantText: '回复' } }); + +test('the detail offers to open the selected conversation, or to answer it, only when the Host can navigate', (t) => { + const h = harness(t, readySelection()); + h.renderDetail(); + assert.equal(h.els.detailActions.hidden, false); assert.equal(h.els.detailOpen.textContent, '打开对话'); + h.app.sessions = [{ ...h.app.sessions[0], needsAnswer: true }]; h.renderDetail(); + assert.equal(h.els.detailOpen.textContent, '去回答'); + for (const patch of [{ availability: 'unavailable' }, { availability: 'ready', selectedSessionId: null }, { source: 'demo', selectedSessionId: 'a' }]) { + Object.assign(h.app, patch); h.renderDetail(); assert.equal(h.els.detailActions.hidden, true, JSON.stringify(patch)); + } + const old = harness(t, { ...readySelection(), canOpenSession: false }); + old.renderDetail(); assert.equal(old.els.detailActions.hidden, true); + assert.deepEqual(h.opens, []); +}); + +test('opening calls the Host synchronously with the local session and reports the outcome', async (t) => { + const open = deferred(); + const h = harness(t, { ...readySelection(), openResult: () => open.promise }); + h.renderDetail(); h.openSelectedSession(); + assert.equal(JSON.stringify(h.opens), JSON.stringify([{ id: 'session', sessionId: 'a', source: 'local' }])); + assert.equal(h.els.detailOpen.disabled, true); assert.equal(h.els.detailOpenMsg.textContent, '正在打开对话…'); + open.resolve({ status: 'opened' }); await settle(); + assert.equal(h.els.detailOpen.disabled, false); assert.equal(h.els.detailOpenMsg.dataset.kind, 'ok'); + assert.match(h.els.detailOpenMsg.textContent, /已在 MiniMax Code 主窗口打开/); + assert.equal(h.nav.getState().tab, 'appearance'); +}); + +test('a brief disconnect hides the button without dropping a pending open', async (t) => { + const open = deferred(); + const h = harness(t, { ...readySelection(), openResult: () => open.promise }); + h.renderDetail(); h.openSelectedSession(); + h.app.availability = 'unavailable'; h.renderDetail(); assert.equal(h.els.detailActions.hidden, true); + h.app.availability = 'ready'; h.renderDetail(); + assert.equal(h.els.detailActions.hidden, false); assert.equal(h.els.detailOpen.disabled, true); + open.resolve({ status: 'opened' }); await settle(); + assert.equal(h.els.detailOpen.disabled, false); assert.equal(h.els.detailOpenMsg.dataset.kind, 'ok'); +}); + +test('a late open result for a previous task never lands on the newly selected one', async (t) => { + const open = deferred(); + const h = harness(t, { ...readySelection(), openResult: () => open.promise }); + h.renderDetail(); h.openSelectedSession(); + h.app.selectedSessionId = 'b'; h.app.detail = null; h.renderDetail(); + assert.equal(h.els.detailOpen.disabled, false); assert.equal(h.els.detailOpenMsg.textContent, ''); + open.resolve({ status: 'opened' }); await settle(); + assert.equal(h.els.detailOpenMsg.textContent, ''); +}); + +test('cancellations and Host errors are explained once and never retried', async (t) => { + const outcomes = [ + [() => Promise.resolve({ status: 'cancelled', reason: 'user_cancelled' }), 'info', /已取消/], + [() => Promise.resolve({ status: 'cancelled', reason: 'superseded' }), 'info', /取代/], + [() => Promise.reject({ code: 'target_unavailable', message: 'gone' }), 'error', /target_unavailable/], + [() => Promise.reject({ code: 'unsupported_source', message: 'remote' }), 'error', /不在本机/], + [() => Promise.reject({ code: 'result_unknown', message: 'timeout' }), 'error', /result_unknown/], + [() => { throw { code: 'not_permitted', message: 'gesture' }; }, 'error', /直接点击/], + [() => Promise.reject({ code: 'brand_new', message: '' }), 'error', /brand_new/], + ]; + for (const [openResult, kind, text] of outcomes) { + const h = harness(t, { ...readySelection(), openResult }); + h.renderDetail(); h.openSelectedSession(); await settle(); + assert.equal(h.opens.length, 1); assert.equal(h.els.detailOpenMsg.dataset.kind, kind); + assert.match(h.els.detailOpenMsg.textContent, text); assert.equal(h.els.detailOpen.disabled, false); + } +}); + +test('an older Host without navigation answers without calling anything', async (t) => { + const h = harness(t, { canOpenSession: false }); + assert.equal(JSON.stringify(await h.openSessionInHost('a')), JSON.stringify({ kind: 'error', text: '当前 MiniMax Code 版本不能从桌宠打开对话。' })); + assert.deepEqual(h.opens, []); +}); + +test('the page follows the Host theme and disposes its subscription', () => { + const root = { dataset: {} }, disposers = [], listeners = []; + let disposed = 0; + const ui = { subscribe: (listener, options) => { listeners.push([listener, options]); return { dispose: () => { disposed++; } }; } }; + const context = vm.createContext({ host: { host: { ui } }, root, disposers, console: { warn() {} } }); + vm.runInContext(`${errCode}\n${hostTheme}\ninitHostTheme();`, context); + assert.equal(listeners.length, 1); + const [listener, { onError }] = listeners[0]; + listener({ theme: 'dark', language: 'zh-Hans' }); assert.equal(root.dataset.theme, 'dark'); + listener({ theme: 'sepia', language: 'en' }); assert.equal(root.dataset.theme, 'dark'); + listener({ theme: 'light', language: 'en' }); assert.equal(root.dataset.theme, 'light'); + onError({ code: 'host_unavailable', message: '' }); assert.equal(root.dataset.theme, 'light'); + for (const dispose of disposers) dispose(); + assert.equal(disposed, 1); + const absent = vm.createContext({ host: {}, root: { dataset: {} }, disposers: [], console: { warn() {} } }); + vm.runInContext(`${errCode}\n${hostTheme}\ninitHostTheme();`, absent); + assert.deepEqual(absent.disposers, []); assert.equal(absent.root.dataset.theme, undefined); +}); diff --git a/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs b/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs index fbfa8ecf..02175d14 100644 --- a/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs +++ b/sources/minimax-ai/minimax-pet/dev/smoke-api.mjs @@ -275,8 +275,8 @@ try { 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']) { + await test('production activity, preferences and view modules are served as JavaScript', async () => { + for (const name of ['activity.mjs', 'preferences.mjs', 'window-position.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/); diff --git a/sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs b/sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs deleted file mode 100644 index b055a587..00000000 --- a/sources/minimax-ai/minimax-pet/dev/speech-input.test.mjs +++ /dev/null @@ -1,378 +0,0 @@ -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/miniapp/client/chat-draft.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs deleted file mode 100644 index 29c93eda..00000000 --- a/sources/minimax-ai/minimax-pet/miniapp/client/chat-draft.mjs +++ /dev/null @@ -1,50 +0,0 @@ -// 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 index b0eeceeb..6d8cebee 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs +++ b/sources/minimax-ai/minimax-pet/miniapp/client/compact-pet-view.mjs @@ -9,7 +9,7 @@ export const taskStatus = (row) => row?.needsAnswer === true ? 'needs_answer' /** Task notifications and their complete replies stay beside the pet. */ export function createCompactPetView({ - container, onClose, onSelectTask, + container, onClose, onSelectTask, onOpenSession, }) { if (!container?.ownerDocument) throw new TypeError('A compact pet container is required.'); const doc = container.ownerDocument; @@ -17,7 +17,7 @@ export function createCompactPetView({ container.classList.add('compact-pet-card'); let disposed = false, opened = false, view = 'tasks', reminders = { items: [] }; let allSessions = null, taskSelection = {}, detailBackView = 'tasks'; - let paintedDetailId = null; + let paintedDetailId = null, openToken = 0; const rows = new Map(); const setText = (node, value) => { const next = String(value ?? ''); if (node.textContent !== next) node.textContent = next; }; const element = (tag, className, text) => { @@ -70,7 +70,22 @@ export function createCompactPetView({ 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); + const openHint = element('p', 'compact-hint'); openHint.setAttribute('role', 'status'); openHint.hidden = true; + const setHint = (text, kind = 'info') => { setText(openHint, text); openHint.dataset.kind = kind; openHint.hidden = !openHint.textContent; }; + // The Host checks user activation, so the callback must run synchronously inside this click. + const openSession = button('打开对话', 'compact-open', () => { + const { id } = selection(); + if (disposed || !id || typeof onOpenSession !== 'function') return; + const token = ++openToken; + openSession.disabled = true; setHint('正在打开对话…'); + void call(onOpenSession, id).then((result) => { + if (disposed || token !== openToken) return; + openSession.disabled = false; + const failed = result?.ok === false; + setHint(textOf(result?.text) || (failed ? `没能打开对话(${failureCode(result)})。` : ''), textOf(result?.kind) || (failed ? 'error' : 'info')); + }); + }); + detailFooter.append(back, openSession); detailPane.append(detailTitle, detailStatus, detailReply, detailFooter, openHint); container.append(header, tasksPane, detailPane); function paintTasks() { @@ -127,7 +142,13 @@ export function createCompactPetView({ 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; } + if (paintedDetailId !== id) { + detailReply.scrollTop = 0; detailPane.scrollTop = 0; paintedDetailId = id; + openToken++; openSession.disabled = false; setHint(''); + } + openSession.hidden = typeof onOpenSession !== 'function' || !available || !id; + setText(openSession, needsAnswer ? '去回答' : '打开对话'); + openSession.setAttribute('aria-label', `${needsAnswer ? '去回答' : '打开对话'}:${textOf(session?.title) || textOf(detail?.title) || '该任务'}`); } function paint() { diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/index.html b/sources/minimax-ai/minimax-pet/miniapp/client/index.html index 2a676002..60ea288c 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/index.html +++ b/sources/minimax-ai/minimax-pet/miniapp/client/index.html @@ -41,13 +41,6 @@

动画预览

正在加载宠物…

-
- -
- 更多 -
-
-
-
-
-

原聊天草稿

-

把文字或语音放入打开桌宠的聊天,由你确认后发送。

-
- -
版本与帮助
-

MiniMax Pet 0.5.11

+

MiniMax Pet 0.6.0

@@ -182,32 +172,6 @@

原聊天草稿

- - @@ -224,14 +188,6 @@

写入原聊天草稿

-
diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs index 14b87257..57a33f36 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs +++ b/sources/minimax-ai/minimax-pet/miniapp/client/panel-navigation.mjs @@ -3,13 +3,13 @@ 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 = () => {} }) { +export function createPanelNavigation({ panel, beforeCloseImport = () => true }) { 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 state = { tab: 'appearance', appearanceView: 'pets', taskFilter: 'recent', taskDetail: false }; let disposed = false; let taskReturnFocus = null, taskReturnId = null; const listeners = []; @@ -28,21 +28,13 @@ export function createPanelNavigation({ panel, beforeCloseImport = () => true, o } 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 }); @@ -62,10 +54,6 @@ export function createPanelNavigation({ panel, beforeCloseImport = () => true, o 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; @@ -118,8 +106,6 @@ export function createPanelNavigation({ panel, beforeCloseImport = () => true, o } 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(); diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 579fe544..a6bbb345 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -1,8 +1,6 @@ // 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'; @@ -73,7 +71,8 @@ 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 hostNavigation = host?.host?.navigation ?? null; +const canOpenSession = typeof hostNavigation?.open === 'function'; const explicitDemo = params.get('demo') === '1'; const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); @@ -141,8 +140,6 @@ const app = { menuView: 'root', menuAnchor: { x: 0, y: 0 }, menuCloseTimer: null, - composerOpen: false, - composerFocused: false, quickbarTimer: null, transient: null, transientTimer: null, @@ -187,14 +184,10 @@ const els = { 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'), @@ -202,7 +195,6 @@ const els = { workbench: $('workbench'), activityCard: $('activity-card'), detailCard: $('detail-card'), - draftCard: $('draft-card'), demoControls: $('demo-controls'), demoStatus: $('demo-status'), sessionList: $('session-list'), @@ -210,21 +202,14 @@ const els = { detailTitle: $('detail-title'), detailStatus: $('detail-status'), detailTime: $('detail-time'), + detailActions: $('detail-actions'), + detailOpen: $('detail-open'), + detailOpenMsg: $('detail-open-msg'), 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'), @@ -250,18 +235,8 @@ const els = { 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; @@ -269,6 +244,8 @@ const reminders = createReminderStore(); const compactBubbleSize = { width: 300, height: 340 }; let notificationPreviews = null; let askObserver = null; +let detailOpenToken = 0; +let detailOpenSessionId = null; // Player module is loaded dynamically so a missing/broken module surfaces in the UI instead of a blank page. let STATES = []; @@ -297,7 +274,7 @@ function activityAnim() { 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' }; + if (app.hovered && app.mode === 'window' && !app.menuOpen && !app.workbenchOpen) return { state: 'jumping', key: 'hover' }; return base; } @@ -349,7 +326,6 @@ async function initActivitySource() { }); askObserver.update({ availability: snapshot.availability, sessions: snapshot.sessions, selectedSessionId: snapshot.selectedSessionId }); syncObservedAsks(); - draftController.setOrigin(snapshot.originSession); renderHeader(); applyActivity(); }, @@ -409,61 +385,66 @@ function selectSession(sessionId, report = (text, kind) => setMsg(els.sessionMsg }, (error) => report(`无法关注此任务(${errCode(error)})`, 'error')); } -// Host message.append +// Host navigation and display preferences // --------------------------------------------------------------------------- -function describeAppendError(code) { +function describeOpenError(code) { switch (code) { - case 'user_cancelled': - return '已取消'; case 'target_unavailable': - return '打开桌宠的原属对话不可用,文字已保留。请从可用的聊天重新打开桌宠。'; + return '这个对话已不存在或暂时打不开(target_unavailable)。'; + case 'unsupported_source': + return '这个对话不在本机,桌宠打不开(unsupported_source)。'; case 'not_permitted': - return '无法追加到原属对话。请直接点击重试;文字已保留。'; - case 'unsupported': - return '当前环境不能加到对话输入框。'; - case 'empty': - return '先写点什么吧'; - case 'busy': - return '正在等待上一次确认,文字已保留。'; + return '需要直接点击按钮才能打开(not_permitted)。'; + case 'host_unavailable': + return 'MiniMax Code 主窗口暂不可用,请稍后再试(host_unavailable)。'; case 'result_unknown': - return '请先检查输入框,文本可能已经加进去了'; + return '没收到打开结果,请看看主窗口是否已经切过去(result_unknown)。'; 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'); } - }, - })); + return `没能打开对话(${code})。`; + } +} + +/** Must be called synchronously from a click or key handler: the Host checks user activation. */ +function openSessionInHost(sessionId) { + if (!canOpenSession) return Promise.resolve({ kind: 'error', text: '当前 MiniMax Code 版本不能从桌宠打开对话。' }); + return callBridge(() => hostNavigation.open({ id: 'session', sessionId, source: 'local' })).then( + (result) => { + if (result?.status === 'opened') return { kind: 'ok', text: '已在 MiniMax Code 主窗口打开这个对话。' }; + if (result?.reason === 'user_cancelled') return { kind: 'info', text: '已取消,主窗口留在原来的页面。' }; + return { kind: 'info', text: '这次打开被另一次跳转取代了。' }; + }, + (error) => { + const code = errCode(error); + console.warn('[pet] open_session_failed:', code, error); + return { kind: 'error', text: describeOpenError(code) }; + }, + ); +} + +function openSelectedSession() { + const sessionId = app.selectedSessionId; + if (!sessionId) return; + const token = ++detailOpenToken; + els.detailOpen.disabled = true; + setMsg(els.detailOpenMsg, '正在打开对话…'); + void openSessionInHost(sessionId).then((result) => { + if (disposed || token !== detailOpenToken) return; + els.detailOpen.disabled = false; + setMsg(els.detailOpenMsg, result.text, result.kind); + }); +} + +function initHostTheme() { + const ui = host?.host?.ui; + if (typeof ui?.subscribe !== 'function') return; + try { + const subscription = ui.subscribe(({ theme }) => { + if (theme === 'light' || theme === 'dark') root.dataset.theme = theme; + }, { onError: (error) => console.warn('[pet] host_theme_unavailable:', errCode(error), error) }); + disposers.push(() => subscription?.dispose?.()); + } catch (error) { + console.warn('[pet] host_theme_unavailable:', errCode(error), error); } } @@ -569,10 +550,6 @@ function applyMode(mode) { 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; @@ -580,7 +557,6 @@ function applyMode(mode) { els.desk.hidden = mode !== 'window'; closeMenu(); - closeComposer(); hideQuickbar(); cancelDrag(); stopLook(); @@ -608,7 +584,6 @@ function applyMode(mode) { function popOut() { if (!win) return; - if (!['idle', 'error'].includes(speechState)) { setMsg(els.popoutMsg, '请先停止录音并等待转写完成。'); return; } els.popoutBtn.disabled = true; setMsg(els.popoutMsg, '正在弹出…'); app.modeSwitching = true; @@ -666,7 +641,7 @@ function isOverPet(x, y) { } function isOverOverlay(x, y) { - return [els.bubble, els.notificationStack, els.quickbar, els.composer, els.menu, els.workbench].some( + return [els.bubble, els.notificationStack, els.quickbar, els.menu, els.workbench].some( (el) => !el.hidden && pointInRect(el.getBoundingClientRect(), x, y), ); } @@ -678,7 +653,6 @@ function refreshInteractivity() { app.workbenchOpen || app.dragging || app.menuOpen || - app.composerFocused || (p ? isOverPet(p.x, p.y) || isOverOverlay(p.x, p.y) : false); setClickThrough(!interactive); } @@ -776,7 +750,7 @@ function onPointerLeave() { if (app.mode !== 'window') return; app.pointer = null; stopLook(); - refreshInteractivity(); // stays interactive while menu/composer/drag force it + refreshInteractivity(); // stays interactive while menu/drag force it if (app.menuOpen) armMenuClose(); } @@ -791,7 +765,6 @@ function updateLook(x, y) { app.hovered || app.dragging || app.menuOpen || - app.composerOpen || app.oneShot || app.workbenchOpen || reducedMotion.matches || @@ -928,7 +901,7 @@ function cancelDrag() { } // --------------------------------------------------------------------------- -// Bubble / quick bar / composer (window mode) +// Bubble / quick bar (window mode) // --------------------------------------------------------------------------- function showTransient(text, kind = 'info', sticky = false) { @@ -945,7 +918,7 @@ function showTransient(text, kind = 'info', sticky = false) { function renderBubble() { renderNotificationStack(); - if (app.composerOpen || app.workbenchOpen) { + if (app.workbenchOpen) { els.bubble.hidden = true; app.bubbleSessionId = null; refreshInteractivity(); @@ -993,7 +966,7 @@ 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; + && !app.workbenchOpen && !app.transient; container.hidden = !visible; if (!visible) return; const previews = notificationPreviews?.getState().previews ?? {}; @@ -1045,35 +1018,6 @@ function armQuickbarHide() { }, 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) // --------------------------------------------------------------------------- @@ -1128,9 +1072,6 @@ function menuModel(view) { 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 }, @@ -1238,7 +1179,6 @@ function openMenu(x, y) { if (app.mode !== 'window') return; noteInteraction(); hideQuickbar(); - closeComposer(); stopLook(); disarmMenuClose(); app.menuOpen = true; @@ -1705,7 +1645,6 @@ async function confirmImport() { function renderAll() { renderHeader(); renderActivityUI(); - renderTalk(); renderScale(); renderGallery(); renderBubble(); @@ -1812,14 +1751,6 @@ function renderSessionList(current) { 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); @@ -1833,17 +1764,31 @@ function renderDetail() { 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; } + renderDetailOpen(needsAnswer); els.detailNote.textContent = app.source === 'demo' ? '资源动作预览,不提供真实任务概览。' : app.availability !== 'ready' ? availabilityText() : app.detailLoading ? (detail ? '正在更新…' : '正在读取任务…') : !detail ? '选择任务后查看最近回复。' - : needsAnswer ? '请回到 MiniMax Code 的对应聊天回答。' - : detail.lastAssistantText ? '显示任务的最近回复。后续处理请回到对应聊天。' : '这项任务暂时没有可查看的回复。'; + : needsAnswer ? (canOpenSession ? '点「去回答」在主窗口打开这个对话后回答。' : '请回到 MiniMax Code 的对应聊天回答。') + : detail.lastAssistantText ? `显示任务的最近回复。${canOpenSession ? '点「打开对话」继续处理。' : '后续处理请回到对应聊天。'}` : '这项任务暂时没有可查看的回复。'; +} + +function renderDetailOpen(needsAnswer) { + // Only a different task resets a pending open; a brief disconnect just hides the button. + if (detailOpenSessionId !== app.selectedSessionId) { + detailOpenSessionId = app.selectedSessionId; + detailOpenToken++; + els.detailOpen.disabled = false; + setMsg(els.detailOpenMsg, ''); + } + els.detailActions.hidden = !canOpenSession || !app.selectedSessionId || app.source !== 'real' || app.availability !== 'ready'; + els.detailOpen.textContent = needsAnswer ? '去回答' : '打开对话'; } function initWorkbench() { workbenchView = createCompactPetView({ container: els.workbench, onClose: () => { void closeWorkbench(); }, onSelectTask: openTaskPreview, + onOpenSession: canOpenSession ? openSessionInHost : null, }); notificationPreviews = createNotificationPreviews({ agent, onChange: () => { renderBubble(); renderCompactView(); } }); if (win) workbenchWindow = createWorkbenchWindow({ windowAPI: win, @@ -1869,16 +1814,10 @@ function openTaskPreview(sessionId) { } function focusFullPanel(view) { - if (view === 'draft') { if (canAppend) panelNavigation?.showDraft(); } - else if (view === 'detail') panelNavigation?.showTaskDetail(); + 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; @@ -1886,12 +1825,11 @@ async function openWorkbench(view = 'tasks') { 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(); + 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)); @@ -1968,45 +1906,6 @@ function renderReminders() { 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); @@ -2119,8 +2018,7 @@ function bindPanel() { 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.detailOpen, 'click', openSelectedSession); listen(els.importPick, 'click', () => els.importInput.click()); listen(els.importInput, 'change', () => { @@ -2203,7 +2101,6 @@ function bindDesk() { 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(); }); @@ -2219,17 +2116,6 @@ function bindDesk() { 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(); - }); } // --------------------------------------------------------------------------- @@ -2252,9 +2138,6 @@ async function boot() { 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) => { @@ -2263,11 +2146,10 @@ async function boot() { if (e.matches) stopLook(); }); + initHostTheme(); bindPanel(); initWorkbench(); bindDesk(); - bindDraftForms(); - initSpeech(); listen(window, 'pagehide', disposeApp); await initPreferences(); if (disposed) return; diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet.css b/sources/minimax-ai/minimax-pet/miniapp/client/pet.css index 2b24b952..dd35b03b 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/pet.css +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet.css @@ -293,9 +293,6 @@ h2 { flex-wrap: wrap; align-items: center; } -.row.end { - justify-content: flex-end; -} .layout { display: grid; @@ -465,52 +462,6 @@ h2 { 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; @@ -764,70 +715,11 @@ html.mode-window .pet-sprite:focus-visible { .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; @@ -960,6 +852,7 @@ html.mode-window .pet-sprite:focus-visible { .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; } +.compact-open { font-size: 11px; font-weight: 600; } .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); } @@ -970,7 +863,6 @@ 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; } @@ -1130,34 +1022,6 @@ html.mode-inline body { 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; @@ -1449,6 +1313,16 @@ html.mode-inline body { overflow-wrap: anywhere; } +#panel .detail-actions { + flex: none; + align-items: center; + gap: 4px 12px; +} + +#panel #detail-open-msg { + overflow-wrap: anywhere; +} + #panel .detail-meta { display: flex; flex-wrap: wrap; @@ -1552,10 +1426,6 @@ html.mode-inline body { overflow-wrap: anywhere; } -#panel .talk-form { - gap: 12px; -} - @media (max-width: 640px) { #panel .panel-header { gap: 12px; @@ -1598,10 +1468,6 @@ html.mode-inline body { min-height: 210px; } - #panel .appearance-actions { - margin-top: 4px; - } - #panel .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs b/sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs deleted file mode 100644 index 381d5f41..00000000 --- a/sources/minimax-ai/minimax-pet/miniapp/client/speech-input.mjs +++ /dev/null @@ -1,280 +0,0 @@ -// 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/miniapp.json b/sources/minimax-ai/minimax-pet/miniapp/miniapp.json index f8235755..0f482eb9 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/miniapp.json +++ b/sources/minimax-ai/minimax-pet/miniapp/miniapp.json @@ -31,8 +31,5 @@ "clickThrough": true } }, - "mcpEndpoints": [], - "deviceAccess": [ - "microphone" - ] + "mcpEndpoints": [] } diff --git a/sources/minimax-ai/minimax-pet/package-lock.json b/sources/minimax-ai/minimax-pet/package-lock.json index 515a7dc6..bb047cd5 100644 --- a/sources/minimax-ai/minimax-pet/package-lock.json +++ b/sources/minimax-ai/minimax-pet/package-lock.json @@ -1,12 +1,12 @@ { "name": "minimax-pet-source", - "version": "0.5.11", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "minimax-pet-source", - "version": "0.5.11", + "version": "0.6.0", "devDependencies": { "jsdom": "24.1.3" }, diff --git a/sources/minimax-ai/minimax-pet/package.json b/sources/minimax-ai/minimax-pet/package.json index 8d42c830..88796304 100644 --- a/sources/minimax-ai/minimax-pet/package.json +++ b/sources/minimax-ai/minimax-pet/package.json @@ -1,6 +1,6 @@ { "name": "minimax-pet-source", - "version": "0.5.11", + "version": "0.6.0", "private": true, "type": "module", "description": "桌宠插件的开发与验证工具,不进入分发包。",