dsh-connect-minimaxcode 0.5.10 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +71 -0
- package/README.md +46 -0
- package/lib/client.js +383 -4
- package/lib/index.d.ts +19 -0
- package/lib/index.js +497 -2
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -302,6 +302,63 @@ The card shows the parts worth acting on:
|
|
|
302
302
|
|
|
303
303
|
This data **needs no M Plan subscription**: the endpoint is an account one, read with the OAuth bearer, unlike the limit windows, which belong to the subscription.
|
|
304
304
|
|
|
305
|
+
### Image and video generation (H3)
|
|
306
|
+
|
|
307
|
+
The plugin page gained a **Generate** panel, which drives MiniMax's **H3 image
|
|
308
|
+
model** and **H3 video generation** directly:
|
|
309
|
+
|
|
310
|
+
- Images: `MiniMax-H3-Image` (and `MiniMax-H3-Image-Highspeed`), with an
|
|
311
|
+
aspect ratio and a 1K / 2K / 4K area
|
|
312
|
+
- Video: `MiniMax-H3`, with a length of 5–15 seconds, 768P / 2K and an
|
|
313
|
+
aspect ratio
|
|
314
|
+
|
|
315
|
+
Both go through the endpoints the open platform's own documentation gives:
|
|
316
|
+
|
|
317
|
+
```
|
|
318
|
+
POST https://api.minimax.cn/v1/images/generations # text to image, synchronous
|
|
319
|
+
POST https://api.minimax.cn/v2/video_generation # create a video task, asynchronous
|
|
320
|
+
GET https://api.minimax.cn/v2/query/video_generation/{id} # poll the task
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**No key has to be configured.** The generation endpoints want the open
|
|
324
|
+
platform's API key (`sk-cp-…`), and that key belongs to the *account*: the
|
|
325
|
+
plugin asks `token_plan_credit` with the OAuth bearer — the endpoint it
|
|
326
|
+
already reads for the credit balance — and the `api_key` in the answer is
|
|
327
|
+
it. Measured on 2026-11-10:
|
|
328
|
+
|
|
329
|
+
| Request | Credential | Result |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| `/v1/images/generations` + `sk-cp-` key | 200, 8.5 s | 1184×880 JPEG, 395 KB, 6401 tokens |
|
|
332
|
+
| `/v1/images/generations` + OAuth bearer | 401 `1004 login fail` | the endpoint takes only the API key |
|
|
333
|
+
| `/v2/video_generation` + `sk-cp-` key | 200, task `succeeded` after 90 s | 5 s 768P clip, 162745 tokens |
|
|
334
|
+
|
|
335
|
+
The generated picture is **committed to DSH's own attachment store**
|
|
336
|
+
(`ctx.attachments`), so it lives in DSH's storage rather than only in this
|
|
337
|
+
browser tab.
|
|
338
|
+
|
|
339
|
+
#### Two differences from the desktop app, stated plainly
|
|
340
|
+
|
|
341
|
+
1. **It cannot appear in the model picker like an ordinary model.** The host's
|
|
342
|
+
`ctx.llm` has exactly one registration point, `registerAdapter`, for
|
|
343
|
+
chat adapters; there is nowhere to register an image provider. The model
|
|
344
|
+
descriptor `LlmModelInfo` has no output-modality field, so the picker
|
|
345
|
+
could not tell an image model apart. pi-ai does have an image-generation
|
|
346
|
+
API (`ImagesProvider` / `generateImages`), but DSH never constructs an
|
|
347
|
+
`ImagesModels`, and one the plugin built for itself would have nothing
|
|
348
|
+
reading it. All three are host-side, and a plugin cannot change them.
|
|
349
|
+
2. **Video has no carrier in DSH at all.** Across `pi-ai`, `dsh-llm`,
|
|
350
|
+
`dsh-attachment` and `dsh-llm-pi-ai` there is not one video-related
|
|
351
|
+
declaration: no video content block, no attachment media type, no
|
|
352
|
+
rendering. A video can therefore be generated, watched and downloaded in
|
|
353
|
+
the panel, but it cannot join a conversation the way a picture can.
|
|
354
|
+
|
|
355
|
+
A picture *can* reach a conversation: the host publishes
|
|
356
|
+
`ctx.attachments.admitPromptContent()`, so a plugin can complete "generate →
|
|
357
|
+
store → durable reference" on its own. But the attachment entry point is a
|
|
358
|
+
browser `File` upload and there is **no attachment library** to pick from,
|
|
359
|
+
so it still has to travel as part of the next prompt. That last hop is
|
|
360
|
+
done in 0.6.1 (see below).
|
|
361
|
+
|
|
305
362
|
### About token refresh
|
|
306
363
|
|
|
307
364
|
The OAuth token is valid for about an hour and is refreshed automatically by the client. The plugin **only refreshes it once, with the refresh token the app already stored, when its own token has expired**, and writes the rotated new tokens back to the same file atomically, in the client's **original format** (with `generation` unchanged), so the desktop app's login state is unaffected. The plugin never starts a login, never signs out, and never calls any paid or subscription endpoint.
|
|
@@ -327,6 +384,20 @@ The OAuth token is valid for about an hour and is refreshed automatically by the
|
|
|
327
384
|
|
|
328
385
|
## Changelog
|
|
329
386
|
|
|
387
|
+
### 0.6.1 (2026-11-11)
|
|
388
|
+
|
|
389
|
+
- **A generated picture can now go straight into the conversation**: the composer tool row gained a **Generate an image** entry. Write a prompt, press generate, and the picture is sent as part of this turn — through the host's own road (`ctx.sessions.binding(id).session.prompt(content, mode, signal, requestId)`, the very call the send button makes), so the host validates it, commits it as a durable attachment and treats it as an ordinary message.
|
|
390
|
+
- A host that exposes no session face gets a plain "could not send it to the conversation" instead of a silent failure, and the plugin page's panel keeps working regardless.
|
|
391
|
+
- Added `tests/generate-dock.spec.ts` (4 cases) pinning: the entry is registered in the tool row, it declares the `sessions` service, the picture travels as `{type:"image", mediaType, data}` (raw base64, no data-url prefix) together with the prompt, and the message shown when there is no session to send to. All 252 cases pass.
|
|
392
|
+
|
|
393
|
+
### 0.6.0 (2026-11-11)
|
|
394
|
+
|
|
395
|
+
- **Added H3 image and video generation**: the plugin page gained a **Generate** panel that drives MiniMax's `MiniMax-H3-Image` (text to image) and `MiniMax-H3` (video) directly. It goes through the endpoints the open platform's documentation gives (`POST /v1/images/generations`, `POST /v2/video_generation` plus polling `/v2/query/video_generation/{id}`), and **no key has to be configured**: the generation endpoints want the account's own open-platform key, and the plugin gets it by asking `token_plan_credit` with the OAuth bearer — the endpoint it already reads for the balance.
|
|
396
|
+
- **Generated pictures are committed to DSH's own attachment store** (`ctx.attachments`), so they live in DSH's storage rather than only in a browser tab.
|
|
397
|
+
- The choices (aspect ratio, area, length, resolution) are published by the server, and they are the ones the upstream really serves.
|
|
398
|
+
- **Measured** (2026-11-10): a picture in 8.5 s, 1184×880 JPEG / 395 KB / 6401 tokens; a 5 s 768P video whose task reached `succeeded` after 90 s, 162745 tokens. The generation endpoints take only the API key and refuse the OAuth bearer (`1004 login fail`) — every step of that chain is pinned by tests (new `tests/generation.spec.ts`, 12 cases).
|
|
399
|
+
- **The differences from the desktop app, stated plainly**: H3 **cannot appear in the model picker like an ordinary model** (the host's `ctx.llm` has a single chat-adapter registration point, and the model descriptor has no output-modality field), and **video has no carrier in DSH at all** (not one video-related declaration across the four packages). A picture can reach a conversation, but the attachment entry point is a browser upload with no library to pick from, so it still has to travel as part of the next prompt — that last hop is planned for the next release.
|
|
400
|
+
|
|
330
401
|
### 0.5.10 (2026-11-10)
|
|
331
402
|
|
|
332
403
|
- **Removed the `awesome-dsh-plugin-submission/` directory**: the listings are no longer maintained, the draft entry in it was merged into the official registry long ago, and the rules it described are spelled out in the upstream `contributing.md`. Keeping it only suggests it is still needed. The README's mention of it is gone as well — where the changelog mentions the directory, the text stays but the link does not, so no dead link is left behind.
|
package/README.md
CHANGED
|
@@ -338,6 +338,38 @@ Authorization: Bearer <OAuth 令牌>
|
|
|
338
338
|
|
|
339
339
|
这一节读到的数据**不需要订阅 M Plan**:该接口属于账户域,用 OAuth 令牌即可读取,与限额窗口(属于订阅)不同。
|
|
340
340
|
|
|
341
|
+
### 图像与视频生成(H3)
|
|
342
|
+
|
|
343
|
+
插件页多了一个「生成」面板,可以直接用 MiniMax 的 **H3 生图**与 **H3 视频生成**:
|
|
344
|
+
|
|
345
|
+
- 图片:`MiniMax-H3-Image`(另有 `MiniMax-H3-Image-Highspeed`),可选画幅与 1K / 2K / 4K 清晰度
|
|
346
|
+
- 视频:`MiniMax-H3`,可选 5–15 秒、768P / 2K 与画幅
|
|
347
|
+
|
|
348
|
+
两者走的都是**开放平台官方文档给出的接口**:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
POST https://api.minimax.cn/v1/images/generations # 文生图,同步返回
|
|
352
|
+
POST https://api.minimax.cn/v2/video_generation # 创建视频任务,异步
|
|
353
|
+
GET https://api.minimax.cn/v2/query/video_generation/{id} # 轮询任务
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**不需要用户配置任何密钥。** 生成接口要的是开放平台的 API 密钥(`sk-cp-…`),而这个密钥是**账户自己的**:插件用 OAuth 令牌问 `token_plan_credit`(它本来就在读这个接口拿积分),响应里的 `api_key` 就是它。实测(2026-11-10):
|
|
357
|
+
|
|
358
|
+
| 请求 | 鉴权 | 结果 |
|
|
359
|
+
|---|---|---|
|
|
360
|
+
| `/v1/images/generations` + `sk-cp-` 密钥 | 200,8.5 秒出图 | 1184×880 JPEG,395 KB,6401 tokens |
|
|
361
|
+
| `/v1/images/generations` + OAuth 令牌 | 401 `1004 login fail` | 生成接口只认 API 密钥 |
|
|
362
|
+
| `/v2/video_generation` + `sk-cp-` 密钥 | 200,任务 90 秒后 `succeeded` | 5 秒 768P 成片,162745 tokens |
|
|
363
|
+
|
|
364
|
+
生成的图片会**存进 DSH 的附件库**(`ctx.attachments`),所以它留在 DSH 自己的存储里,而不只是这个浏览器标签页。
|
|
365
|
+
|
|
366
|
+
#### 与桌面 App 的两处不同(实测所限,如实说明)
|
|
367
|
+
|
|
368
|
+
1. **不能像普通模型那样出现在模型选择器里。** DSH 宿主的 `ctx.llm` 只有对话适配器这一个注册点(`registerAdapter`),没有图像 provider 的挂载处;模型描述符 `LlmModelInfo` 也没有「输出模态」字段,选择器无从区分图像模型。pi-ai 自己虽有图像生成 API(`ImagesProvider` / `generateImages`),但 DSH 从不构造 `ImagesModels`,插件自己 `new` 一个也没有人会读。这三点都是宿主层面的事,插件改不动。
|
|
369
|
+
2. **视频在 DSH 侧目前没有承载。** `pi-ai`、`dsh-llm`、`dsh-attachment`、`dsh-llm-pi-ai` 四个包里关于 video 的声明各为零处 —— 没有视频内容块、没有附件媒体类型、没有渲染。视频因此只能生成后在面板里播放与下载,不能像图片那样进对话。
|
|
370
|
+
|
|
371
|
+
图片进对话是可行的:宿主公开了 `ctx.attachments.admitPromptContent()`,插件可以独立完成「生成 → 落库 → 拿到持久引用」;但附件入口是浏览器 `File` 上传,**没有附件库**可供挑选,因此要进对话仍需作为下一轮 prompt 的一部分提交。这一步已由 0.6.1 完成(见下)。
|
|
372
|
+
|
|
341
373
|
### 关于续期
|
|
342
374
|
|
|
343
375
|
OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自己的令牌已过期时**才用 App 已存的 refresh token 续一次,并把轮换后的新令牌按客户端**原格式原子写回**同一个文件(`generation` 保持不变),因此桌面 App 的登录态不受影响。插件不会发起登录、不会登出、也不会调用任何付费/订阅类接口。
|
|
@@ -363,6 +395,20 @@ OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自
|
|
|
363
395
|
|
|
364
396
|
## 更新记录
|
|
365
397
|
|
|
398
|
+
### 0.6.1(2026-11-11)
|
|
399
|
+
|
|
400
|
+
- **生成的图片现在能直接进对话**:输入框工具行新增一个「生成图片」入口,写下提示词点生成,图片会作为**这一轮消息的一部分**发给模型 —— 走的是宿主自己那条路(`ctx.sessions.binding(id).session.prompt(content, mode, signal, requestId)`,与输入框按发送键完全相同),因此图片会被宿主校验、落成持久附件,并按普通消息参与对话。
|
|
401
|
+
- 宿主若不暴露会话句柄,该入口会明确提示「无法发送到对话」,而不是静默失败;插件页的生成面板仍可照常使用。
|
|
402
|
+
- 新增 `tests/generate-dock.spec.ts`(4 个用例)钉住:入口注册在工具行、声明了 `sessions` 服务、生成的图以 `{type:"image", mediaType, data}` 的形态(原始 base64,不带 data-url 前缀)随提示词一起发出、以及没有会话句柄时的提示。全量 252 个用例通过。
|
|
403
|
+
|
|
404
|
+
### 0.6.0(2026-11-11)
|
|
405
|
+
|
|
406
|
+
- **新增 H3 图像与视频生成**:插件页多了一个「生成」面板,可直接调用 MiniMax 的 `MiniMax-H3-Image`(文生图)与 `MiniMax-H3`(视频生成)。走的是开放平台官方文档给出的接口(`POST /v1/images/generations`、`POST /v2/video_generation` + 轮询 `/v2/query/video_generation/{id}`),**用户无需配置任何密钥** —— 生成接口要的是账户自己的开放平台密钥,插件用 OAuth 令牌问本来就在读的 `token_plan_credit` 就能拿到。
|
|
407
|
+
- **生成的图片存入 DSH 的附件库**(`ctx.attachments`),因此它留在 DSH 自己的存储里,而不只是浏览器标签页。
|
|
408
|
+
- 选项(画幅、清晰度、时长、分辨率)由服务端下发,取的是上游真正提供的那一组。
|
|
409
|
+
- **实测**(2026-11-10):图片 8.5 秒出图,1184×880 JPEG / 395 KB / 6401 tokens;视频 5 秒 768P,任务 90 秒后 `succeeded`,162745 tokens。生成接口只认 API 密钥,OAuth 令牌会被拒(`1004 login fail`)—— 这条链路的每一步都有测试钉住(新增 `tests/generation.spec.ts`,12 个用例)。
|
|
410
|
+
- **如实说明与桌面 App 的差异**:H3 **不能像普通模型那样出现在模型选择器里**(宿主的 `ctx.llm` 只有对话适配器一个注册点,模型描述符也没有输出模态字段),**视频在 DSH 侧目前完全没有承载**(四个包里关于 video 的声明各为零处)。图片进对话是可行的,但附件入口只有浏览器上传、没有附件库,因此仍需作为下一轮 prompt 的一部分提交 —— 这一步的宿主接线留待下一版。
|
|
411
|
+
|
|
366
412
|
### 0.5.10(2026-11-10)
|
|
367
413
|
|
|
368
414
|
- **删除 `awesome-dsh-plugin-submission/` 目录**:收录已停止维护,目录里的草稿条目早已合入官方注册表、提交规则在上游 `contributing.md` 里也有完整说明,留着只会让人误以为还需要它。README 中对应的说明一并去掉(历史更新记录里提到该目录的地方保留文字、只去掉链接,不留死链)。
|
package/lib/client.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const NAMESPACE = 'dsh-connect-minimaxcode';
|
|
20
20
|
const STATUS_PATH = '/plugins/dsh-connect-minimaxcode/status';
|
|
21
21
|
const ACTION_PATH = '/plugins/dsh-connect-minimaxcode/action';
|
|
22
|
+
const GENERATE_PATH = '/plugins/dsh-connect-minimaxcode/generate';
|
|
22
23
|
const POLL_INTERVAL_MS = 20000;
|
|
23
24
|
/**
|
|
24
25
|
* The provider id the host side registers with `llm.registerAdapter`.
|
|
@@ -75,6 +76,28 @@
|
|
|
75
76
|
usageRanking: '用量排名前 {percent}%',
|
|
76
77
|
usageBestDay: '最活跃的一天 {date}',
|
|
77
78
|
planNoSubscription: '未订阅 M Plan(仅积分包),因此没有限额窗口',
|
|
79
|
+
generateTitle: '生成',
|
|
80
|
+
generateImage: '图片',
|
|
81
|
+
generateVideo: '视频',
|
|
82
|
+
generatePrompt: '描述你想要的画面',
|
|
83
|
+
generateAspect: '画幅',
|
|
84
|
+
generateArea: '清晰度',
|
|
85
|
+
generateDuration: '时长',
|
|
86
|
+
generateResolution: '分辨率',
|
|
87
|
+
generateRatio: '画幅',
|
|
88
|
+
generateRun: '生成',
|
|
89
|
+
generateRunning: '生成中…',
|
|
90
|
+
generateVideoRunning: '视频生成中,约需 1–2 分钟…',
|
|
91
|
+
generateImageDone: '图片已生成',
|
|
92
|
+
generateVideoDone: '视频已生成',
|
|
93
|
+
generateDownload: '下载',
|
|
94
|
+
generateSaved: '已存入 DSH 附件库',
|
|
95
|
+
generateFailed: '生成失败',
|
|
96
|
+
generateHint: '生成走 MiniMax 的开放平台,用的是你账号自己的额度。图片会并存进 DSH 的附件库。',
|
|
97
|
+
generateToChat: '生成图片',
|
|
98
|
+
generateToChatHint: '生成的图片会作为这一轮消息的一部分发给模型。',
|
|
99
|
+
generateSent: '图片已发送到对话',
|
|
100
|
+
generateSendFailed: '无法发送到对话,请到插件页生成后下载。',
|
|
78
101
|
};
|
|
79
102
|
const en = {
|
|
80
103
|
title: 'MiniMax Code credits',
|
|
@@ -124,6 +147,28 @@
|
|
|
124
147
|
usageRanking: 'Top {percent}% by usage',
|
|
125
148
|
usageBestDay: 'Busiest day {date}',
|
|
126
149
|
planNoSubscription: 'No M Plan subscription (credits only), so there are no usage windows',
|
|
150
|
+
generateTitle: 'Generate',
|
|
151
|
+
generateImage: 'Image',
|
|
152
|
+
generateVideo: 'Video',
|
|
153
|
+
generatePrompt: 'Describe what you want',
|
|
154
|
+
generateAspect: 'Aspect',
|
|
155
|
+
generateArea: 'Area',
|
|
156
|
+
generateDuration: 'Length',
|
|
157
|
+
generateResolution: 'Resolution',
|
|
158
|
+
generateRatio: 'Aspect',
|
|
159
|
+
generateRun: 'Generate',
|
|
160
|
+
generateRunning: 'Generating…',
|
|
161
|
+
generateVideoRunning: 'Generating the video, about 1–2 minutes…',
|
|
162
|
+
generateImageDone: 'Image ready',
|
|
163
|
+
generateVideoDone: 'Video ready',
|
|
164
|
+
generateDownload: 'Download',
|
|
165
|
+
generateSaved: 'Saved to DSH attachments',
|
|
166
|
+
generateFailed: 'Generation failed',
|
|
167
|
+
generateHint: 'Generation runs on MiniMax\'s open platform with your own account\'s allowance. Images are also stored in DSH\'s attachment store.',
|
|
168
|
+
generateToChat: 'Generate an image',
|
|
169
|
+
generateToChatHint: 'The generated picture is sent as part of this turn.',
|
|
170
|
+
generateSent: 'The picture is on its way to the conversation',
|
|
171
|
+
generateSendFailed: 'Could not send it to the conversation; generate on the plugin page and download it instead.',
|
|
127
172
|
};
|
|
128
173
|
const loader = globalThis.__ModuleLoader__;
|
|
129
174
|
if (loader === undefined)
|
|
@@ -133,6 +178,13 @@
|
|
|
133
178
|
factory(require) {
|
|
134
179
|
const React = require('react');
|
|
135
180
|
const h = React.createElement;
|
|
181
|
+
/**
|
|
182
|
+
* The host's client context, once the plugin is applied.
|
|
183
|
+
*
|
|
184
|
+
* The components are closures over the module, not over the context, so
|
|
185
|
+
* this is how they reach the host's services.
|
|
186
|
+
*/
|
|
187
|
+
let clientContext;
|
|
136
188
|
/** Format a credit amount without pretending to more precision. */
|
|
137
189
|
function credits(value, digits) {
|
|
138
190
|
if (value === undefined || !Number.isFinite(value))
|
|
@@ -382,6 +434,110 @@
|
|
|
382
434
|
return null;
|
|
383
435
|
return h(CreditPill, props);
|
|
384
436
|
}
|
|
437
|
+
/**
|
|
438
|
+
* Generate a picture and send it to the conversation.
|
|
439
|
+
*
|
|
440
|
+
* The composer already knows how to send a message with an image in it,
|
|
441
|
+
* and the picture the upstream produces is an ordinary image file, so
|
|
442
|
+
* the dock does the same thing the composer does: it asks the session
|
|
443
|
+
* to accept a turn whose content is the picture plus the prompt.
|
|
444
|
+
*
|
|
445
|
+
* Nothing here is required of the host beyond what the composer itself
|
|
446
|
+
* uses. A host that does not expose the session face simply gets the
|
|
447
|
+
* message that says so, and the plugin page still offers the picture.
|
|
448
|
+
*/
|
|
449
|
+
function GenerateDock(props) {
|
|
450
|
+
const t = props.t ?? ((key, params) => {
|
|
451
|
+
const template = zh[key] ?? key;
|
|
452
|
+
return template.replace(/\\{(\\w+)\\}/g, (_match, name) => String(params?.[name] ?? ''));
|
|
453
|
+
});
|
|
454
|
+
const [open, setOpen] = React.useState(false);
|
|
455
|
+
const [prompt, setPrompt] = React.useState('');
|
|
456
|
+
const [busy, setBusy] = React.useState(false);
|
|
457
|
+
const [note, setNote] = React.useState('');
|
|
458
|
+
/**
|
|
459
|
+
* The session's own send path.
|
|
460
|
+
*
|
|
461
|
+
* `binding(id).session.prompt(content, mode, signal, requestId)` is
|
|
462
|
+
* what the composer calls when the person presses send, so a picture
|
|
463
|
+
* sent this way is a normal turn.
|
|
464
|
+
*/
|
|
465
|
+
const sessionFace = () => {
|
|
466
|
+
const sessionId = props.sessionId;
|
|
467
|
+
if (sessionId === undefined)
|
|
468
|
+
return undefined;
|
|
469
|
+
const binding = clientContext?.sessions?.binding?.(sessionId);
|
|
470
|
+
const send = binding?.session?.prompt;
|
|
471
|
+
return typeof send === 'function' ? { prompt: send } : undefined;
|
|
472
|
+
};
|
|
473
|
+
/** Turn a data URL into the parts the session's admission accepts. */
|
|
474
|
+
const imagePart = (dataUrl, mediaType) => {
|
|
475
|
+
const comma = dataUrl.indexOf(',');
|
|
476
|
+
if (comma < 0)
|
|
477
|
+
return undefined;
|
|
478
|
+
return { type: 'image', mediaType, data: dataUrl.slice(comma + 1) };
|
|
479
|
+
};
|
|
480
|
+
async function send() {
|
|
481
|
+
const text = prompt.trim();
|
|
482
|
+
if (text === '')
|
|
483
|
+
return;
|
|
484
|
+
setBusy(true);
|
|
485
|
+
setNote('');
|
|
486
|
+
try {
|
|
487
|
+
const response = await fetch(GENERATE_PATH, {
|
|
488
|
+
method: 'POST',
|
|
489
|
+
headers: { 'content-type': 'application/json' },
|
|
490
|
+
body: JSON.stringify({ kind: 'image', prompt: text, area: '1K' }),
|
|
491
|
+
});
|
|
492
|
+
const body = await response.json();
|
|
493
|
+
if (body.ok !== true || body.dataUrl === undefined) {
|
|
494
|
+
setNote(body.error ?? t('generateFailed'));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const part = imagePart(body.dataUrl, body.mediaType ?? 'image/jpeg');
|
|
498
|
+
const face = sessionFace();
|
|
499
|
+
if (part === undefined || face === undefined) {
|
|
500
|
+
setNote(t('generateSendFailed'));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
await face.prompt([part, { type: 'text', text }], 'queue', undefined, `minimax-h3-${Date.now()}`);
|
|
504
|
+
setPrompt('');
|
|
505
|
+
setOpen(false);
|
|
506
|
+
setNote(t('generateSent'));
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
setNote(t('generateSendFailed'));
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
setBusy(false);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (!open) {
|
|
516
|
+
return h('button', {
|
|
517
|
+
type: 'button',
|
|
518
|
+
style: buttonStyle,
|
|
519
|
+
title: t('generateToChatHint'),
|
|
520
|
+
onClick: () => { setOpen(true); setNote(''); },
|
|
521
|
+
}, t('generateToChat'));
|
|
522
|
+
}
|
|
523
|
+
return h('div', { style: dockStyle, 'data-minimax-generate-dock': true }, h('input', {
|
|
524
|
+
value: prompt,
|
|
525
|
+
placeholder: t('generatePrompt'),
|
|
526
|
+
style: dockInputStyle,
|
|
527
|
+
disabled: busy,
|
|
528
|
+
onChange: (event) => setPrompt(event.target.value),
|
|
529
|
+
}), h('button', {
|
|
530
|
+
type: 'button',
|
|
531
|
+
style: buttonStyle,
|
|
532
|
+
disabled: busy || prompt.trim() === '',
|
|
533
|
+
onClick: () => { void send(); },
|
|
534
|
+
}, busy ? t('generateRunning') : t('generateRun')), h('button', {
|
|
535
|
+
type: 'button',
|
|
536
|
+
style: buttonStyle,
|
|
537
|
+
disabled: busy,
|
|
538
|
+
onClick: () => { setOpen(false); setPrompt(''); setNote(''); },
|
|
539
|
+
}, '✕'), note === '' ? null : h('span', { style: mutedStyle }, note));
|
|
540
|
+
}
|
|
385
541
|
/** Ask the host to do something, and let the card read the result. */
|
|
386
542
|
async function act(action, body) {
|
|
387
543
|
try {
|
|
@@ -468,6 +624,144 @@
|
|
|
468
624
|
* The summary view is the one line the plugin list shows; the page view
|
|
469
625
|
* is the full card, where the expiry detail belongs.
|
|
470
626
|
*/
|
|
627
|
+
/**
|
|
628
|
+
* The generation panel.
|
|
629
|
+
*
|
|
630
|
+
* A picture comes back in one answer; a video is a task that takes a
|
|
631
|
+
* minute and a half, so it is polled until the upstream says it is
|
|
632
|
+
* done. The choices come from the server rather than from this half, so
|
|
633
|
+
* they are the ones the upstream actually serves.
|
|
634
|
+
*/
|
|
635
|
+
function GenerationPanel(props) {
|
|
636
|
+
const [status] = useStatus();
|
|
637
|
+
const t = props.t ?? ((key, params) => {
|
|
638
|
+
const template = zh[key] ?? key;
|
|
639
|
+
return template.replace(/\\{(\\w+)\\}/g, (_match, name) => String(params?.[name] ?? ''));
|
|
640
|
+
});
|
|
641
|
+
const options = status?.generation;
|
|
642
|
+
const [mode, setMode] = React.useState('image');
|
|
643
|
+
const [prompt, setPrompt] = React.useState('');
|
|
644
|
+
const [model, setModel] = React.useState('');
|
|
645
|
+
const [aspect, setAspect] = React.useState('auto');
|
|
646
|
+
const [area, setArea] = React.useState('4K');
|
|
647
|
+
const [duration, setDuration] = React.useState(5);
|
|
648
|
+
const [resolution, setResolution] = React.useState('2K');
|
|
649
|
+
const [ratio, setRatio] = React.useState('16:9');
|
|
650
|
+
const [busy, setBusy] = React.useState(false);
|
|
651
|
+
const [note, setNote] = React.useState('');
|
|
652
|
+
const [picture, setPicture] = React.useState(undefined);
|
|
653
|
+
const [video, setVideo] = React.useState(undefined);
|
|
654
|
+
const imageModels = options?.imageModels ?? ['MiniMax-H3-Image'];
|
|
655
|
+
/** Ask for a picture; the answer carries the bytes inline. */
|
|
656
|
+
async function makeImage() {
|
|
657
|
+
setBusy(true);
|
|
658
|
+
setNote('');
|
|
659
|
+
setVideo(undefined);
|
|
660
|
+
try {
|
|
661
|
+
const response = await fetch(GENERATE_PATH, {
|
|
662
|
+
method: 'POST',
|
|
663
|
+
headers: { 'content-type': 'application/json' },
|
|
664
|
+
body: JSON.stringify({
|
|
665
|
+
kind: 'image',
|
|
666
|
+
prompt,
|
|
667
|
+
model: model === '' ? undefined : model,
|
|
668
|
+
aspectRatio: aspect,
|
|
669
|
+
area,
|
|
670
|
+
}),
|
|
671
|
+
});
|
|
672
|
+
const body = await response.json();
|
|
673
|
+
if (body.ok !== true || body.dataUrl === undefined) {
|
|
674
|
+
setNote(body.error ?? t('generateFailed'));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
setPicture({ dataUrl: body.dataUrl, size: body.size, saved: body.attachment !== undefined });
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
setNote(t('generateFailed'));
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
setBusy(false);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** Start a video task, then wait for it the way the upstream does. */
|
|
687
|
+
async function makeVideo() {
|
|
688
|
+
setBusy(true);
|
|
689
|
+
setNote('');
|
|
690
|
+
setPicture(undefined);
|
|
691
|
+
try {
|
|
692
|
+
const response = await fetch(GENERATE_PATH, {
|
|
693
|
+
method: 'POST',
|
|
694
|
+
headers: { 'content-type': 'application/json' },
|
|
695
|
+
body: JSON.stringify({
|
|
696
|
+
kind: 'video',
|
|
697
|
+
prompt,
|
|
698
|
+
duration,
|
|
699
|
+
resolution,
|
|
700
|
+
ratio,
|
|
701
|
+
}),
|
|
702
|
+
});
|
|
703
|
+
const body = await response.json();
|
|
704
|
+
if (body.ok !== true || body.taskId === undefined) {
|
|
705
|
+
setNote(body.error ?? t('generateFailed'));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// The task takes a minute and a half; the upstream is asked every
|
|
709
|
+
// ten seconds, the way its own documentation recommends.
|
|
710
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
711
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
712
|
+
const polled = await fetch(`${GENERATE_PATH}?taskId=${encodeURIComponent(body.taskId)}`);
|
|
713
|
+
const task = await polled.json();
|
|
714
|
+
if (task.url !== undefined) {
|
|
715
|
+
setVideo({ url: task.url, status: task.status });
|
|
716
|
+
setNote('');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (task.status !== undefined && /fail|cancel/i.test(task.status)) {
|
|
720
|
+
setNote(task.error ?? t('generateFailed'));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
setNote(t('generateFailed'));
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
setNote(t('generateFailed'));
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
setBusy(false);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const select = (value, onChange, values) => h('select', {
|
|
734
|
+
value,
|
|
735
|
+
style: selectStyle,
|
|
736
|
+
onChange: (event) => onChange(event.target.value),
|
|
737
|
+
}, ...values.map(option => h('option', { key: option, value: option }, option)));
|
|
738
|
+
return h('div', { style: sectionBoxStyle, 'data-minimax-generation': true }, h('div', { style: sectionTitleStyle }, t('generateTitle')), h('div', { style: rowStyle }, h('button', {
|
|
739
|
+
type: 'button',
|
|
740
|
+
style: mode === 'image' ? tabActiveStyle : actionButtonStyle,
|
|
741
|
+
onClick: () => { setMode('image'); setPicture(undefined); setVideo(undefined); },
|
|
742
|
+
}, t('generateImage')), h('button', {
|
|
743
|
+
type: 'button',
|
|
744
|
+
style: mode === 'video' ? tabActiveStyle : actionButtonStyle,
|
|
745
|
+
onClick: () => { setMode('video'); setPicture(undefined); setVideo(undefined); },
|
|
746
|
+
}, t('generateVideo'))), h('textarea', {
|
|
747
|
+
value: prompt,
|
|
748
|
+
placeholder: t('generatePrompt'),
|
|
749
|
+
style: promptStyle,
|
|
750
|
+
rows: 3,
|
|
751
|
+
onChange: (event) => setPrompt(event.target.value),
|
|
752
|
+
}), mode === 'image'
|
|
753
|
+
? h('div', { style: rowStyle }, select(model === '' ? imageModels[0] : model, setModel, imageModels), select(aspect, setAspect, options?.imageAspectRatios ?? ['auto', '16:9', '1:1']), select(area, setArea, options?.imageAreas ?? ['1K', '2K', '4K']))
|
|
754
|
+
: h('div', { style: rowStyle }, select(String(duration), next => setDuration(Number(next)), (options?.videoDurations ?? [5, 10]).map(String)), select(resolution, setResolution, options?.videoResolutions ?? ['768P', '2K']), select(ratio, setRatio, options?.videoRatios ?? ['16:9', '9:16'])), h('div', { style: rowStyle }, h('button', {
|
|
755
|
+
type: 'button',
|
|
756
|
+
style: actionButtonStyle,
|
|
757
|
+
disabled: busy || prompt.trim() === '',
|
|
758
|
+
onClick: () => { void (mode === 'image' ? makeImage() : makeVideo()); },
|
|
759
|
+
}, busy ? (mode === 'video' ? t('generateVideoRunning') : t('generateRunning')) : t('generateRun'))), note === '' ? null : h('div', { style: mutedStyle }, note), picture === undefined
|
|
760
|
+
? null
|
|
761
|
+
: h('div', { style: generationResultStyle, 'data-minimax-generation-image': true }, h('img', { src: picture.dataUrl, alt: prompt, style: generatedImageStyle }), h('div', { style: rowStyle }, h('span', null, picture.size === undefined ? t('generateImageDone') : `${t('generateImageDone')} · ${picture.size}`), picture.saved === true ? h('span', null, t('generateSaved')) : null, h('a', { href: picture.dataUrl, download: 'minimax-h3-image.jpg', style: linkStyle }, t('generateDownload')))), video === undefined
|
|
762
|
+
? null
|
|
763
|
+
: h('div', { style: generationResultStyle, 'data-minimax-generation-video': true }, h('video', { src: video.url, controls: true, style: generatedVideoStyle }), h('div', { style: rowStyle }, h('span', null, t('generateVideoDone')), h('a', { href: video.url, download: 'minimax-h3-video.mp4', style: linkStyle }, t('generateDownload')))), h('div', { style: mutedStyle }, t('generateHint')));
|
|
764
|
+
}
|
|
471
765
|
function PluginPage(props) {
|
|
472
766
|
const [status, refresh] = useStatus();
|
|
473
767
|
const t = props.t ?? ((key, params) => {
|
|
@@ -487,15 +781,19 @@
|
|
|
487
781
|
: '', signin?.today?.claimable === true ? ` · ${t('claimableToday')}` : '');
|
|
488
782
|
}
|
|
489
783
|
if (!available) {
|
|
490
|
-
return h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: rowStyle }, h('span', null, t('unavailable')), h('span', null, credits_?.state === 'expired' ? t('expired') : '')), loginBlock(t, login, refresh))
|
|
784
|
+
return h('div', { style: pageStyle, 'data-minimax-page': true }, h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: rowStyle }, h('span', null, t('unavailable')), h('span', null, credits_?.state === 'expired' ? t('expired') : '')), loginBlock(t, login, refresh)),
|
|
785
|
+
// The generation panel does not need the balance: it spends the
|
|
786
|
+
// account's generation allowance, not its credits, so it stays
|
|
787
|
+
// usable even when the balance cannot be read.
|
|
788
|
+
h(GenerationPanel, props));
|
|
491
789
|
}
|
|
492
|
-
return h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: bigNumberStyle }, t('balance', { total: credits(credits_.total, 2) })), row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), expiryBlock(t, credits_), rateLimitBlock(t, status?.rateLimit), planNotice(t, status?.rateLimit), signinBlock(t, signin, refresh), h('div', { style: sectionTitleStyle }, t('lots')), (credits_.upcoming ?? []).length === 0
|
|
790
|
+
return h('div', { style: pageStyle, 'data-minimax-page': true }, h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: bigNumberStyle }, t('balance', { total: credits(credits_.total, 2) })), row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), expiryBlock(t, credits_), rateLimitBlock(t, status?.rateLimit), planNotice(t, status?.rateLimit), signinBlock(t, signin, refresh), h('div', { style: sectionTitleStyle }, t('lots')), (credits_.upcoming ?? []).length === 0
|
|
493
791
|
? h('div', { style: mutedStyle }, t('noLots'))
|
|
494
792
|
: h('ul', { style: listStyle }, ...(credits_.upcoming ?? []).map((lot, index) => h('li', { key: `${lot.expireAtMs}-${index}`, style: rowStyle }, h('span', null, day(lot.expireAtMs)), h('span', null, credits(lot.amount, 2))))), usage !== undefined
|
|
495
793
|
? h('div', { style: usageBoxStyle }, row(t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) }), ''), row(t('cost', { amount: money(usage.estimatedCny) }), ''), usage.requests > 0 ? row(t('requests', { count: usage.requests }), usage.lastModel ?? '') : null)
|
|
496
794
|
: null, usageProfileBlock(t, status?.usageProfile), loginBlock(t, login, refresh), credits_.updatedAtMs !== undefined
|
|
497
795
|
? h('div', { style: mutedStyle }, t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }))
|
|
498
|
-
: null);
|
|
796
|
+
: null), h(GenerationPanel, props));
|
|
499
797
|
}
|
|
500
798
|
const wrapStyle = {
|
|
501
799
|
position: 'relative',
|
|
@@ -515,6 +813,21 @@
|
|
|
515
813
|
opacity: 0.8,
|
|
516
814
|
padding: '1px 8px',
|
|
517
815
|
};
|
|
816
|
+
const dockStyle = {
|
|
817
|
+
display: 'flex',
|
|
818
|
+
alignItems: 'center',
|
|
819
|
+
gap: '4px',
|
|
820
|
+
};
|
|
821
|
+
const dockInputStyle = {
|
|
822
|
+
width: '160px',
|
|
823
|
+
padding: '2px 6px',
|
|
824
|
+
borderRadius: '999px',
|
|
825
|
+
border: '1px solid var(--dsh-composer-dock-inset, currentColor)',
|
|
826
|
+
background: 'transparent',
|
|
827
|
+
color: 'inherit',
|
|
828
|
+
font: 'inherit',
|
|
829
|
+
fontSize: '12px',
|
|
830
|
+
};
|
|
518
831
|
const detailStyle = {
|
|
519
832
|
position: 'absolute',
|
|
520
833
|
bottom: 'calc(100% + 6px)',
|
|
@@ -550,6 +863,12 @@
|
|
|
550
863
|
fontSize: '13px',
|
|
551
864
|
lineHeight: 1.6,
|
|
552
865
|
};
|
|
866
|
+
// The page stacks the balance card and the generation panel.
|
|
867
|
+
const pageStyle = {
|
|
868
|
+
display: 'flex',
|
|
869
|
+
flexDirection: 'column',
|
|
870
|
+
gap: '10px',
|
|
871
|
+
};
|
|
553
872
|
const cardTitleStyle = { fontWeight: 600 };
|
|
554
873
|
const bigNumberStyle = { fontSize: '20px', fontWeight: 600, marginBottom: '2px' };
|
|
555
874
|
const sectionTitleStyle = { marginTop: '8px', fontWeight: 600 };
|
|
@@ -597,10 +916,61 @@
|
|
|
597
916
|
borderTop: '1px solid var(--dsh-panel-border, currentColor)',
|
|
598
917
|
opacity: 0.9,
|
|
599
918
|
};
|
|
919
|
+
const tabActiveStyle = {
|
|
920
|
+
appearance: 'none',
|
|
921
|
+
padding: '2px 8px',
|
|
922
|
+
borderRadius: '6px',
|
|
923
|
+
border: '1px solid var(--dsh-panel-border, currentColor)',
|
|
924
|
+
background: 'var(--dsh-panel-bg, transparent)',
|
|
925
|
+
color: 'inherit',
|
|
926
|
+
font: 'inherit',
|
|
927
|
+
cursor: 'pointer',
|
|
928
|
+
fontWeight: 600,
|
|
929
|
+
};
|
|
930
|
+
const promptStyle = {
|
|
931
|
+
width: '100%',
|
|
932
|
+
boxSizing: 'border-box',
|
|
933
|
+
padding: '6px',
|
|
934
|
+
borderRadius: '6px',
|
|
935
|
+
border: '1px solid var(--dsh-panel-border, currentColor)',
|
|
936
|
+
background: 'transparent',
|
|
937
|
+
color: 'inherit',
|
|
938
|
+
font: 'inherit',
|
|
939
|
+
resize: 'vertical',
|
|
940
|
+
};
|
|
941
|
+
const selectStyle = {
|
|
942
|
+
padding: '2px 4px',
|
|
943
|
+
borderRadius: '6px',
|
|
944
|
+
border: '1px solid var(--dsh-panel-border, currentColor)',
|
|
945
|
+
background: 'transparent',
|
|
946
|
+
color: 'inherit',
|
|
947
|
+
font: 'inherit',
|
|
948
|
+
};
|
|
949
|
+
const generationResultStyle = {
|
|
950
|
+
marginTop: '8px',
|
|
951
|
+
display: 'flex',
|
|
952
|
+
flexDirection: 'column',
|
|
953
|
+
gap: '6px',
|
|
954
|
+
};
|
|
955
|
+
const generatedImageStyle = {
|
|
956
|
+
maxWidth: '100%',
|
|
957
|
+
borderRadius: '8px',
|
|
958
|
+
border: '1px solid var(--dsh-panel-border, currentColor)',
|
|
959
|
+
};
|
|
960
|
+
const generatedVideoStyle = {
|
|
961
|
+
maxWidth: '100%',
|
|
962
|
+
borderRadius: '8px',
|
|
963
|
+
};
|
|
600
964
|
return {
|
|
601
|
-
|
|
965
|
+
// The sessions service is what lets a generated picture be delivered
|
|
966
|
+
// into the conversation the composer belongs to.
|
|
967
|
+
inject: ['slots', 'locale', 'sessions'],
|
|
602
968
|
apply(ctx) {
|
|
603
969
|
const context = ctx;
|
|
970
|
+
// The components need the host's sessions service to deliver a
|
|
971
|
+
// generated picture into the conversation, so it is kept where the
|
|
972
|
+
// components can reach it.
|
|
973
|
+
clientContext = context;
|
|
604
974
|
// Visible text goes through the host's locale service.
|
|
605
975
|
context.effect(() => context.locale.register(NAMESPACE, { zh, en }), `${BUNDLE_NAME}: locale`);
|
|
606
976
|
// The composer and plugin-page slots carry no translator in their
|
|
@@ -620,6 +990,15 @@
|
|
|
620
990
|
locale: NAMESPACE,
|
|
621
991
|
inject: () => ({ t }),
|
|
622
992
|
}, CreditPillGate));
|
|
993
|
+
// The image dock sits beside the balance: it is the same provider's
|
|
994
|
+
// capability, and it needs the session the composer belongs to.
|
|
995
|
+
context.slots.inject('conversation.input.left', () => context.slots.register({
|
|
996
|
+
name: 'conversation.input.left',
|
|
997
|
+
id: 'minimax-generate',
|
|
998
|
+
order: 5,
|
|
999
|
+
locale: NAMESPACE,
|
|
1000
|
+
inject: () => ({ t }),
|
|
1001
|
+
}, GenerateDock));
|
|
623
1002
|
// The plugin's own page, keyed by the package name, where the
|
|
624
1003
|
// balance and the expiry schedule are spelled out.
|
|
625
1004
|
context.slots.inject('plugins.bundle.config', () => context.slots.register({
|
package/lib/index.d.ts
CHANGED
|
@@ -1038,6 +1038,21 @@ interface UsageProfileStatus {
|
|
|
1038
1038
|
/** Why the profile could not be read. */
|
|
1039
1039
|
error?: string;
|
|
1040
1040
|
}
|
|
1041
|
+
/** The choices the generation endpoints offer, as the card needs them. */
|
|
1042
|
+
interface GenerationOptions {
|
|
1043
|
+
/** The image models that can be asked for. */
|
|
1044
|
+
imageModels: string[];
|
|
1045
|
+
/** The aspect ratios an image can be made in. */
|
|
1046
|
+
imageAspectRatios: string[];
|
|
1047
|
+
/** The output areas an image can be made at. */
|
|
1048
|
+
imageAreas: string[];
|
|
1049
|
+
/** The lengths a video can be, in seconds. */
|
|
1050
|
+
videoDurations: number[];
|
|
1051
|
+
/** The output resolutions a video can be made at. */
|
|
1052
|
+
videoResolutions: string[];
|
|
1053
|
+
/** The aspect ratios a video can be made in. */
|
|
1054
|
+
videoRatios: string[];
|
|
1055
|
+
}
|
|
1041
1056
|
/** What the card renders. */
|
|
1042
1057
|
interface StatusDocument {
|
|
1043
1058
|
state: 'signed-in' | 'signed-out' | 'expired';
|
|
@@ -1070,6 +1085,8 @@ interface StatusDocument {
|
|
|
1070
1085
|
login?: LoginStatus;
|
|
1071
1086
|
/** The account's usage profile, when it could be read. */
|
|
1072
1087
|
usageProfile?: UsageProfileStatus;
|
|
1088
|
+
/** The choices the generation endpoints offer. */
|
|
1089
|
+
generation?: GenerationOptions;
|
|
1073
1090
|
}
|
|
1074
1091
|
/** Inputs the document is derived from. */
|
|
1075
1092
|
interface StatusDeps {
|
|
@@ -1100,6 +1117,8 @@ interface StatusDeps {
|
|
|
1100
1117
|
login?: LoginStatus;
|
|
1101
1118
|
/** The account's usage profile, when the account API could be reached. */
|
|
1102
1119
|
usageProfile?: UsageProfileStatus;
|
|
1120
|
+
/** The choices the generation endpoints offer. */
|
|
1121
|
+
generation?: GenerationOptions;
|
|
1103
1122
|
}
|
|
1104
1123
|
/**
|
|
1105
1124
|
* Build the card's status document.
|
package/lib/index.js
CHANGED
|
@@ -738,6 +738,14 @@ function imageInputHooks(ctx) {
|
|
|
738
738
|
const MINIMAX_STATUS_PATH = "/plugins/dsh-connect-minimaxcode/status";
|
|
739
739
|
/** Route the card posts to when it wants something done rather than read. */
|
|
740
740
|
const MINIMAX_ACTION_PATH = "/plugins/dsh-connect-minimaxcode/action";
|
|
741
|
+
/**
|
|
742
|
+
* Route the card posts to when it wants a picture or a video made.
|
|
743
|
+
*
|
|
744
|
+
* It is a route of its own rather than another action because generation is
|
|
745
|
+
* expensive and asynchronous: an action answers at once, while a video task
|
|
746
|
+
* takes a minute and a half and is polled.
|
|
747
|
+
*/
|
|
748
|
+
const MINIMAX_GENERATE_PATH = "/plugins/dsh-connect-minimaxcode/generate";
|
|
741
749
|
const HINTS = {
|
|
742
750
|
"app-missing": "未找到 MiniMax Code 的登录信息。请先安装并登录 MiniMax Code 桌面 App,然后重启 DSH。",
|
|
743
751
|
"not-signed-in": "MiniMax Code 尚未登录。请在 MiniMax Code 桌面 App 中登录后重启 DSH。",
|
|
@@ -767,7 +775,8 @@ function statusDocument(deps) {
|
|
|
767
775
|
...deps.usage === void 0 ? {} : { usage: deps.usage },
|
|
768
776
|
...deps.signin === void 0 ? {} : { signin: deps.signin },
|
|
769
777
|
...deps.login === void 0 ? {} : { login: deps.login },
|
|
770
|
-
...deps.usageProfile === void 0 ? {} : { usageProfile: deps.usageProfile }
|
|
778
|
+
...deps.usageProfile === void 0 ? {} : { usageProfile: deps.usageProfile },
|
|
779
|
+
...deps.generation === void 0 ? {} : { generation: deps.generation }
|
|
771
780
|
};
|
|
772
781
|
if (auth.state === "expired") return {
|
|
773
782
|
...base,
|
|
@@ -792,6 +801,305 @@ function isLoopback(host) {
|
|
|
792
801
|
return name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1";
|
|
793
802
|
}
|
|
794
803
|
//#endregion
|
|
804
|
+
//#region src/generation.ts
|
|
805
|
+
/** Open-platform API host per region. */
|
|
806
|
+
const MINIMAX_API_HOSTS = {
|
|
807
|
+
cn: "https://api.minimax.cn",
|
|
808
|
+
io: "https://api.minimaxi.com",
|
|
809
|
+
com: "https://api.minimaxi.com"
|
|
810
|
+
};
|
|
811
|
+
/** Image generation endpoint: the documented OpenAI-style path. */
|
|
812
|
+
const IMAGE_GENERATIONS_PATH = "/v1/images/generations";
|
|
813
|
+
/** The endpoint that hands out the account's open-platform key. */
|
|
814
|
+
const PLAN_CREDIT_PATH = "/backend/account/token_plan_credit";
|
|
815
|
+
/** The host the plan key comes from, per region. */
|
|
816
|
+
const PLAN_CREDIT_HOSTS = {
|
|
817
|
+
cn: "https://www.minimaxi.com",
|
|
818
|
+
io: "https://www.minimaxi.com",
|
|
819
|
+
com: "https://www.minimaxi.com"
|
|
820
|
+
};
|
|
821
|
+
/** Video generation task endpoint. */
|
|
822
|
+
const VIDEO_GENERATION_PATH = "/v2/video_generation";
|
|
823
|
+
/** Video task query endpoint; the task id is the path segment. */
|
|
824
|
+
const VIDEO_QUERY_PATH = "/v2/query/video_generation";
|
|
825
|
+
/** The image models the endpoint serves. */
|
|
826
|
+
const IMAGE_MODELS = ["MiniMax-H3-Image", "MiniMax-H3-Image-Highspeed"];
|
|
827
|
+
/** The video model the endpoint serves. */
|
|
828
|
+
const VIDEO_MODEL = "MiniMax-H3";
|
|
829
|
+
/** Aspect ratios the desktop app offers for images. */
|
|
830
|
+
const IMAGE_ASPECT_RATIOS = [
|
|
831
|
+
"auto",
|
|
832
|
+
"16:9",
|
|
833
|
+
"3:2",
|
|
834
|
+
"4:3",
|
|
835
|
+
"5:4",
|
|
836
|
+
"1:1",
|
|
837
|
+
"4:5",
|
|
838
|
+
"3:4",
|
|
839
|
+
"2:3",
|
|
840
|
+
"9:16",
|
|
841
|
+
"3:1",
|
|
842
|
+
"1:3"
|
|
843
|
+
];
|
|
844
|
+
/** Output areas the endpoint serves. */
|
|
845
|
+
const IMAGE_AREAS = [
|
|
846
|
+
"1K",
|
|
847
|
+
"2K",
|
|
848
|
+
"4K"
|
|
849
|
+
];
|
|
850
|
+
/** Aspect ratios the desktop app offers for video. */
|
|
851
|
+
const VIDEO_RATIOS = [
|
|
852
|
+
"21:9",
|
|
853
|
+
"16:9",
|
|
854
|
+
"4:3",
|
|
855
|
+
"1:1",
|
|
856
|
+
"3:4",
|
|
857
|
+
"9:16"
|
|
858
|
+
];
|
|
859
|
+
/** Output resolutions the endpoint serves for video. */
|
|
860
|
+
const VIDEO_RESOLUTIONS = ["768P", "2K"];
|
|
861
|
+
/** Durations the endpoint serves for video, in seconds. */
|
|
862
|
+
const VIDEO_DURATIONS = [
|
|
863
|
+
5,
|
|
864
|
+
6,
|
|
865
|
+
7,
|
|
866
|
+
8,
|
|
867
|
+
9,
|
|
868
|
+
10,
|
|
869
|
+
11,
|
|
870
|
+
12,
|
|
871
|
+
13,
|
|
872
|
+
14,
|
|
873
|
+
15
|
|
874
|
+
];
|
|
875
|
+
/** A generation call that the upstream refused, with the reason it gave. */
|
|
876
|
+
var GenerationError = class extends Error {
|
|
877
|
+
/** The upstream's own status code, when it gave one. */
|
|
878
|
+
code;
|
|
879
|
+
constructor(message, code) {
|
|
880
|
+
super(message);
|
|
881
|
+
this.name = "GenerationError";
|
|
882
|
+
this.code = code;
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
/**
|
|
886
|
+
* Read the account's open-platform key.
|
|
887
|
+
*
|
|
888
|
+
* The credit endpoint answers the desktop app's OAuth bearer, and its
|
|
889
|
+
* answer carries the key. It is not a secret the person had to configure: it
|
|
890
|
+
* belongs to the account, the same way the balance does.
|
|
891
|
+
*/
|
|
892
|
+
async function fetchPlanApiKey(credential, region, fetchImpl = fetch) {
|
|
893
|
+
const response = await fetchImpl(`${PLAN_CREDIT_HOSTS[region]}${PLAN_CREDIT_PATH}?business=Agent&device_platform=desktop`, { headers: {
|
|
894
|
+
accept: "application/json",
|
|
895
|
+
authorization: `Bearer ${credential.token}`
|
|
896
|
+
} });
|
|
897
|
+
if (!response.ok) throw new GenerationError(`token plan credit request failed: HTTP ${response.status}`, response.status);
|
|
898
|
+
const key = (await response.json()).api_key;
|
|
899
|
+
return typeof key === "string" && key.length > 0 ? key : void 0;
|
|
900
|
+
}
|
|
901
|
+
/** The upstream's error message, when the body carries one. */
|
|
902
|
+
function readErrorMessage(body) {
|
|
903
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
904
|
+
const record = body;
|
|
905
|
+
const error = record.error;
|
|
906
|
+
if (typeof error === "object" && error !== null) {
|
|
907
|
+
const message = error.message;
|
|
908
|
+
if (typeof message === "string") return message;
|
|
909
|
+
}
|
|
910
|
+
const base = record.base_resp;
|
|
911
|
+
if (typeof base === "object" && base !== null) {
|
|
912
|
+
const message = base.status_msg;
|
|
913
|
+
if (typeof message === "string") return message;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* The upstream's own refusal, when it answers with one.
|
|
918
|
+
*
|
|
919
|
+
* The generation endpoints do not always use the HTTP status to say no: a
|
|
920
|
+
* task that does not exist comes back as `200` with
|
|
921
|
+
* `base_resp.status_code: 2013`. Reading only the HTTP status would turn
|
|
922
|
+
* that into an empty answer and leave the caller polling a task that will
|
|
923
|
+
* never arrive, so the body's own code is checked too.
|
|
924
|
+
*/
|
|
925
|
+
function readBodyRefusal(body) {
|
|
926
|
+
const code = readErrorCode(body);
|
|
927
|
+
if (code === void 0 || code === 0) return void 0;
|
|
928
|
+
return {
|
|
929
|
+
message: readErrorMessage(body) ?? `the upstream refused the request (${code})`,
|
|
930
|
+
code
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/** The upstream's own code, when the body carries one. */
|
|
934
|
+
function readErrorCode(body) {
|
|
935
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
936
|
+
const record = body;
|
|
937
|
+
const error = record.error;
|
|
938
|
+
if (typeof error === "object" && error !== null) {
|
|
939
|
+
const code = error.http_code;
|
|
940
|
+
const parsed = typeof code === "string" ? Number(code) : code;
|
|
941
|
+
if (typeof parsed === "number" && Number.isFinite(parsed)) return parsed;
|
|
942
|
+
}
|
|
943
|
+
const base = record.base_resp;
|
|
944
|
+
if (typeof base === "object" && base !== null) {
|
|
945
|
+
const code = base.status_code;
|
|
946
|
+
if (typeof code === "number" && Number.isFinite(code)) return code;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Call a generation endpoint and turn its refusal into a readable error.
|
|
951
|
+
*
|
|
952
|
+
* The upstream reports a bad parameter as HTTP 400 with a code in the
|
|
953
|
+
* message, and a bad credential as HTTP 401; both arrive as an error body
|
|
954
|
+
* whose `error.message` says what was wrong. The message is kept verbatim
|
|
955
|
+
* because it names the offending field.
|
|
956
|
+
*/
|
|
957
|
+
async function callGeneration(apiKey, url, body, fetchImpl) {
|
|
958
|
+
const response = await fetchImpl(url, {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: {
|
|
961
|
+
accept: "application/json",
|
|
962
|
+
authorization: `Bearer ${apiKey}`,
|
|
963
|
+
"content-type": "application/json"
|
|
964
|
+
},
|
|
965
|
+
body: JSON.stringify(body)
|
|
966
|
+
});
|
|
967
|
+
const text = await response.text();
|
|
968
|
+
let document;
|
|
969
|
+
try {
|
|
970
|
+
document = JSON.parse(text);
|
|
971
|
+
} catch {
|
|
972
|
+
if (!response.ok) throw new GenerationError(`generation request failed: HTTP ${response.status}`, response.status);
|
|
973
|
+
throw new GenerationError("generation response was not JSON");
|
|
974
|
+
}
|
|
975
|
+
if (!response.ok) throw new GenerationError(readErrorMessage(document) ?? `generation request failed: HTTP ${response.status}`, readErrorCode(document) ?? response.status);
|
|
976
|
+
const refusal = readBodyRefusal(document);
|
|
977
|
+
if (refusal !== void 0) throw new GenerationError(refusal.message, refusal.code);
|
|
978
|
+
return document ?? {};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Generate images from a prompt.
|
|
982
|
+
*
|
|
983
|
+
* The bytes are fetched while the URL is still fresh: the endpoint's link
|
|
984
|
+
* expires in 24 hours, and a plugin that only kept the link would leave the
|
|
985
|
+
* picture behind.
|
|
986
|
+
*/
|
|
987
|
+
async function generateImages(options) {
|
|
988
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
989
|
+
const body = {
|
|
990
|
+
model: options.model ?? IMAGE_MODELS[0],
|
|
991
|
+
prompt: options.prompt,
|
|
992
|
+
response_format: "url"
|
|
993
|
+
};
|
|
994
|
+
if (options.count !== void 0) body.n = options.count;
|
|
995
|
+
if (options.aspectRatio !== void 0) body.aspect_ratio = options.aspectRatio;
|
|
996
|
+
if (options.area !== void 0) body.area = options.area;
|
|
997
|
+
const document = await callGeneration(options.apiKey, `${MINIMAX_API_HOSTS[options.region]}${IMAGE_GENERATIONS_PATH}`, body, fetchImpl);
|
|
998
|
+
const images = [];
|
|
999
|
+
for (const entry of document.data ?? []) {
|
|
1000
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1001
|
+
const record = entry;
|
|
1002
|
+
const image = {};
|
|
1003
|
+
if (typeof record.url === "string") image.url = record.url;
|
|
1004
|
+
if (typeof record.size === "string") image.size = record.size;
|
|
1005
|
+
if (typeof record.revised_prompt === "string") image.revisedPrompt = record.revised_prompt;
|
|
1006
|
+
if (typeof record.b64_json === "string") {
|
|
1007
|
+
image.bytes = Buffer.from(record.b64_json, "base64");
|
|
1008
|
+
image.mediaType = "image/jpeg";
|
|
1009
|
+
}
|
|
1010
|
+
images.push(image);
|
|
1011
|
+
}
|
|
1012
|
+
const usage = document.usage;
|
|
1013
|
+
return {
|
|
1014
|
+
images,
|
|
1015
|
+
generatedImages: typeof usage?.generated_images === "number" ? usage.generated_images : void 0,
|
|
1016
|
+
totalTokens: typeof usage?.total_tokens === "number" ? usage.total_tokens : void 0,
|
|
1017
|
+
requestId: typeof document.request_id === "string" ? document.request_id : void 0
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Download a generated image's bytes.
|
|
1022
|
+
*
|
|
1023
|
+
* The link the endpoint hands out is good for a day; the picture is fetched
|
|
1024
|
+
* now so that nothing the plugin shows depends on that clock.
|
|
1025
|
+
*/
|
|
1026
|
+
async function downloadImage(url, fetchImpl = fetch) {
|
|
1027
|
+
const response = await fetchImpl(url);
|
|
1028
|
+
if (!response.ok) throw new GenerationError(`image download failed: HTTP ${response.status}`, response.status);
|
|
1029
|
+
return {
|
|
1030
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
1031
|
+
mediaType: (response.headers.get("content-type") ?? "image/jpeg").split(";")[0] ?? "image/jpeg"
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Start a video generation task.
|
|
1036
|
+
*
|
|
1037
|
+
* The task is asynchronous: the endpoint answers with an identifier and the
|
|
1038
|
+
* video appears later, so the caller polls {@link queryVideoTask}.
|
|
1039
|
+
*/
|
|
1040
|
+
async function startVideoTask(options) {
|
|
1041
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1042
|
+
const content = [{
|
|
1043
|
+
type: "text",
|
|
1044
|
+
text: options.prompt
|
|
1045
|
+
}];
|
|
1046
|
+
if (options.firstFrameUrl !== void 0) content.push({
|
|
1047
|
+
type: "image_url",
|
|
1048
|
+
image_url: { url: options.firstFrameUrl },
|
|
1049
|
+
role: "first_frame"
|
|
1050
|
+
});
|
|
1051
|
+
if (options.lastFrameUrl !== void 0) content.push({
|
|
1052
|
+
type: "image_url",
|
|
1053
|
+
image_url: { url: options.lastFrameUrl },
|
|
1054
|
+
role: "last_frame"
|
|
1055
|
+
});
|
|
1056
|
+
const body = {
|
|
1057
|
+
model: VIDEO_MODEL,
|
|
1058
|
+
content
|
|
1059
|
+
};
|
|
1060
|
+
if (options.duration !== void 0) body.duration = options.duration;
|
|
1061
|
+
if (options.resolution !== void 0) body.resolution = options.resolution;
|
|
1062
|
+
if (options.ratio !== void 0) body.ratio = options.ratio;
|
|
1063
|
+
const taskId = (await callGeneration(options.apiKey, `${MINIMAX_API_HOSTS[options.region]}${VIDEO_GENERATION_PATH}`, body, fetchImpl)).task_id;
|
|
1064
|
+
if (typeof taskId !== "string" || taskId.length === 0) throw new GenerationError("the video task carried no task id");
|
|
1065
|
+
return taskId;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Read a video task's state.
|
|
1069
|
+
*
|
|
1070
|
+
* The finished video's address is in the task's content; the endpoint also
|
|
1071
|
+
* reports a plain `status` string for the simpler shape.
|
|
1072
|
+
*/
|
|
1073
|
+
async function queryVideoTask(options) {
|
|
1074
|
+
const response = await (options.fetchImpl ?? fetch)(`${MINIMAX_API_HOSTS[options.region]}${VIDEO_QUERY_PATH}/${encodeURIComponent(options.taskId)}`, { headers: {
|
|
1075
|
+
accept: "application/json",
|
|
1076
|
+
authorization: `Bearer ${options.apiKey}`
|
|
1077
|
+
} });
|
|
1078
|
+
const text = await response.text();
|
|
1079
|
+
let document;
|
|
1080
|
+
try {
|
|
1081
|
+
document = JSON.parse(text) ?? {};
|
|
1082
|
+
} catch {
|
|
1083
|
+
throw new GenerationError(`video task query failed: HTTP ${response.status}`, response.status);
|
|
1084
|
+
}
|
|
1085
|
+
if (!response.ok) throw new GenerationError(readErrorMessage(document) ?? `video task query failed: HTTP ${response.status}`, readErrorCode(document) ?? response.status);
|
|
1086
|
+
const refusal = readBodyRefusal(document);
|
|
1087
|
+
if (refusal !== void 0) throw new GenerationError(refusal.message, refusal.code);
|
|
1088
|
+
const task = document.task ?? document;
|
|
1089
|
+
const content = task.content;
|
|
1090
|
+
const usage = task.usage;
|
|
1091
|
+
return {
|
|
1092
|
+
taskId: options.taskId,
|
|
1093
|
+
status: typeof task.status === "string" ? task.status : void 0,
|
|
1094
|
+
url: typeof content?.url === "string" ? content.url : void 0,
|
|
1095
|
+
resolution: typeof task.resolution === "string" ? task.resolution : void 0,
|
|
1096
|
+
duration: typeof task.duration === "number" ? task.duration : void 0,
|
|
1097
|
+
ratio: typeof task.ratio === "string" ? task.ratio : void 0,
|
|
1098
|
+
totalSeconds: typeof usage?.total_seconds === "number" ? usage.total_seconds : void 0,
|
|
1099
|
+
totalTokens: typeof usage?.total_tokens === "number" ? usage.total_tokens : void 0
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
//#endregion
|
|
795
1103
|
//#region src/credits.ts
|
|
796
1104
|
/**
|
|
797
1105
|
* The MiniMax Code account's credit balance.
|
|
@@ -2783,7 +3091,15 @@ function apply(ctx) {
|
|
|
2783
3091
|
usage: buildUsageStatus(),
|
|
2784
3092
|
signin: buildSigninStatus(),
|
|
2785
3093
|
login: buildLoginStatus(),
|
|
2786
|
-
usageProfile: buildUsageProfileStatus()
|
|
3094
|
+
usageProfile: buildUsageProfileStatus(),
|
|
3095
|
+
generation: {
|
|
3096
|
+
imageModels: [...IMAGE_MODELS],
|
|
3097
|
+
imageAspectRatios: [...IMAGE_ASPECT_RATIOS],
|
|
3098
|
+
imageAreas: [...IMAGE_AREAS],
|
|
3099
|
+
videoDurations: [...VIDEO_DURATIONS],
|
|
3100
|
+
videoResolutions: [...VIDEO_RESOLUTIONS],
|
|
3101
|
+
videoRatios: [...VIDEO_RATIOS]
|
|
3102
|
+
}
|
|
2787
3103
|
});
|
|
2788
3104
|
};
|
|
2789
3105
|
(async () => {
|
|
@@ -2910,6 +3226,134 @@ function apply(ctx) {
|
|
|
2910
3226
|
};
|
|
2911
3227
|
}
|
|
2912
3228
|
};
|
|
3229
|
+
/**
|
|
3230
|
+
* The plan's own API key, remembered for the session.
|
|
3231
|
+
*
|
|
3232
|
+
* The generation endpoints want the open platform's key rather than the
|
|
3233
|
+
* desktop app's OAuth bearer, and the key belongs to the account: the
|
|
3234
|
+
* credit endpoint hands it out to the OAuth credential the plugin already
|
|
3235
|
+
* holds. It is fetched once and kept for as long as the plugin runs.
|
|
3236
|
+
*/
|
|
3237
|
+
let planApiKey;
|
|
3238
|
+
const resolvePlanApiKey = async () => {
|
|
3239
|
+
if (planApiKey !== void 0) return planApiKey;
|
|
3240
|
+
const account = await resolveAccountCredential();
|
|
3241
|
+
if (account.state !== "signed-in") throw new GenerationError("尚未登录 MiniMax Code");
|
|
3242
|
+
const key = await fetchPlanApiKey(account.credential, region);
|
|
3243
|
+
if (key === void 0) throw new GenerationError("账户没有开放平台密钥,无法调用生成接口");
|
|
3244
|
+
planApiKey = key;
|
|
3245
|
+
return key;
|
|
3246
|
+
};
|
|
3247
|
+
/**
|
|
3248
|
+
* Commit a generated picture to the host's attachment store.
|
|
3249
|
+
*
|
|
3250
|
+
* The store is read per request: the host may provide it after this plugin
|
|
3251
|
+
* loads, and a host without one must still show the picture. The media type
|
|
3252
|
+
* is narrowed to the four the store accepts, because it checks the declared
|
|
3253
|
+
* type against the decoded bytes.
|
|
3254
|
+
*
|
|
3255
|
+
* @returns The durable reference, when the picture was stored.
|
|
3256
|
+
*/
|
|
3257
|
+
const saveGeneratedImage = async (bytes, mediaType) => {
|
|
3258
|
+
const store = ctx.get("attachments");
|
|
3259
|
+
if (store === void 0) return void 0;
|
|
3260
|
+
const type = mediaType === "image/png" || mediaType === "image/webp" || mediaType === "image/gif" ? mediaType : "image/jpeg";
|
|
3261
|
+
try {
|
|
3262
|
+
const ref = await store.saveImage({
|
|
3263
|
+
data: bytes,
|
|
3264
|
+
mediaType: type,
|
|
3265
|
+
name: "minimax-h3-image"
|
|
3266
|
+
});
|
|
3267
|
+
return {
|
|
3268
|
+
attachmentId: ref.attachmentId,
|
|
3269
|
+
width: ref.width,
|
|
3270
|
+
height: ref.height,
|
|
3271
|
+
bytes: ref.bytes
|
|
3272
|
+
};
|
|
3273
|
+
} catch {
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
/**
|
|
3278
|
+
* Make a picture or start a video.
|
|
3279
|
+
*
|
|
3280
|
+
* A picture comes back at once, as bytes: the upstream's link is good for
|
|
3281
|
+
* a day, and a link that expires would leave the picture behind. A video
|
|
3282
|
+
* is a task that takes a minute and a half, so this answers with the task's
|
|
3283
|
+
* identifier and the card polls for the result.
|
|
3284
|
+
*/
|
|
3285
|
+
const runGeneration = async (body) => {
|
|
3286
|
+
const kind = body.kind;
|
|
3287
|
+
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
|
3288
|
+
if (prompt.length === 0) return {
|
|
3289
|
+
ok: false,
|
|
3290
|
+
error: "请填写提示词"
|
|
3291
|
+
};
|
|
3292
|
+
const apiKey = await resolvePlanApiKey();
|
|
3293
|
+
if (kind === "video") return {
|
|
3294
|
+
ok: true,
|
|
3295
|
+
kind: "video",
|
|
3296
|
+
taskId: await startVideoTask({
|
|
3297
|
+
apiKey,
|
|
3298
|
+
region,
|
|
3299
|
+
prompt,
|
|
3300
|
+
duration: typeof body.duration === "number" ? body.duration : void 0,
|
|
3301
|
+
resolution: typeof body.resolution === "string" ? body.resolution : void 0,
|
|
3302
|
+
ratio: typeof body.ratio === "string" ? body.ratio : void 0,
|
|
3303
|
+
firstFrameUrl: typeof body.firstFrameUrl === "string" ? body.firstFrameUrl : void 0,
|
|
3304
|
+
lastFrameUrl: typeof body.lastFrameUrl === "string" ? body.lastFrameUrl : void 0
|
|
3305
|
+
})
|
|
3306
|
+
};
|
|
3307
|
+
const result = await generateImages({
|
|
3308
|
+
apiKey,
|
|
3309
|
+
region,
|
|
3310
|
+
prompt,
|
|
3311
|
+
model: typeof body.model === "string" ? body.model : void 0,
|
|
3312
|
+
aspectRatio: typeof body.aspectRatio === "string" ? body.aspectRatio : void 0,
|
|
3313
|
+
area: typeof body.area === "string" ? body.area : void 0,
|
|
3314
|
+
count: typeof body.count === "number" ? body.count : void 0
|
|
3315
|
+
});
|
|
3316
|
+
const first = result.images[0];
|
|
3317
|
+
if (first === void 0) return {
|
|
3318
|
+
ok: false,
|
|
3319
|
+
error: "上游没有返回图片"
|
|
3320
|
+
};
|
|
3321
|
+
if (first.bytes === void 0 && first.url === void 0) return {
|
|
3322
|
+
ok: false,
|
|
3323
|
+
error: "上游没有返回图片"
|
|
3324
|
+
};
|
|
3325
|
+
const image = first.bytes !== void 0 ? {
|
|
3326
|
+
bytes: first.bytes,
|
|
3327
|
+
mediaType: first.mediaType ?? "image/jpeg"
|
|
3328
|
+
} : await downloadImage(first.url);
|
|
3329
|
+
const saved = await saveGeneratedImage(image.bytes, image.mediaType);
|
|
3330
|
+
return {
|
|
3331
|
+
ok: true,
|
|
3332
|
+
kind: "image",
|
|
3333
|
+
size: first.size,
|
|
3334
|
+
revisedPrompt: first.revisedPrompt,
|
|
3335
|
+
totalTokens: result.totalTokens,
|
|
3336
|
+
requestId: result.requestId,
|
|
3337
|
+
mediaType: image.mediaType,
|
|
3338
|
+
dataUrl: `data:${image.mediaType};base64,${Buffer.from(image.bytes).toString("base64")}`,
|
|
3339
|
+
...saved === void 0 ? {} : { attachment: saved }
|
|
3340
|
+
};
|
|
3341
|
+
};
|
|
3342
|
+
/** Read a video task's state. */
|
|
3343
|
+
const readVideoTask = async (taskId) => {
|
|
3344
|
+
const task = await queryVideoTask({
|
|
3345
|
+
apiKey: await resolvePlanApiKey(),
|
|
3346
|
+
region,
|
|
3347
|
+
taskId
|
|
3348
|
+
});
|
|
3349
|
+
return {
|
|
3350
|
+
ok: true,
|
|
3351
|
+
kind: "video",
|
|
3352
|
+
taskId: task.taskId,
|
|
3353
|
+
status: task.status,
|
|
3354
|
+
url: task.url
|
|
3355
|
+
};
|
|
3356
|
+
};
|
|
2913
3357
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2914
3358
|
webCtx.effect(() => webCtx.webServer.register({
|
|
2915
3359
|
kind: "exact",
|
|
@@ -2963,6 +3407,57 @@ function apply(ctx) {
|
|
|
2963
3407
|
}
|
|
2964
3408
|
}
|
|
2965
3409
|
}));
|
|
3410
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
3411
|
+
kind: "exact",
|
|
3412
|
+
path: MINIMAX_GENERATE_PATH,
|
|
3413
|
+
handler: async (req, res) => {
|
|
3414
|
+
if (!isLoopback(req.headers.host)) {
|
|
3415
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
3416
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
3420
|
+
try {
|
|
3421
|
+
if (req.method === "GET") {
|
|
3422
|
+
const taskId = url.searchParams.get("taskId") ?? "";
|
|
3423
|
+
if (taskId.length === 0) {
|
|
3424
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3425
|
+
res.end(JSON.stringify({
|
|
3426
|
+
ok: false,
|
|
3427
|
+
error: "缺少 taskId"
|
|
3428
|
+
}));
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3432
|
+
res.end(JSON.stringify(await readVideoTask(taskId)));
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
if (req.method !== "POST") {
|
|
3436
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
3437
|
+
res.end(JSON.stringify({ error: "POST only" }));
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
let body = {};
|
|
3441
|
+
try {
|
|
3442
|
+
const text = await readRequestBody(req);
|
|
3443
|
+
if (text.length > 0) {
|
|
3444
|
+
const parsed = JSON.parse(text);
|
|
3445
|
+
if (typeof parsed === "object" && parsed !== null) body = parsed;
|
|
3446
|
+
}
|
|
3447
|
+
} catch {}
|
|
3448
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3449
|
+
res.end(JSON.stringify(await runGeneration(body)));
|
|
3450
|
+
} catch (error) {
|
|
3451
|
+
const status = error instanceof GenerationError ? 400 : 500;
|
|
3452
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
3453
|
+
res.end(JSON.stringify({
|
|
3454
|
+
ok: false,
|
|
3455
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3456
|
+
...error instanceof GenerationError && error.code !== void 0 ? { code: error.code } : {}
|
|
3457
|
+
}));
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
}));
|
|
2966
3461
|
});
|
|
2967
3462
|
}
|
|
2968
3463
|
/** Read a request body, up to a size that no card needs to exceed. */
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-minimaxcode",
|
|
3
3
|
"displayName": "DSH Connect MiniMax Code",
|
|
4
4
|
"description": "把本机已登录的 MiniMax Code 桌面 App 模型接入 DeepSeek Harness:零配置、Anthropic Messages 兼容、动态模型目录,并显示积分余额与到期 — bring MiniMax Code desktop app models into DSH with a dynamic model catalog and a credit balance with expiry details.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.6.1",
|
|
6
6
|
"author": "JeffTsuiCUG",
|
|
7
7
|
"contributors": [
|
|
8
8
|
{
|