focalapi-cli 0.1.0 → 0.1.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.md +40 -21
- package/dist/cli.js +458 -164
- package/package.json +2 -2
- package/skills/focalapi/SKILL.md +24 -24
- package/skills/focalapi-chat/SKILL.md +13 -35
- package/skills/focalapi-gen/SKILL.md +36 -0
- package/skills/focalapi-search/SKILL.md +0 -31
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
# focalapi-cli
|
|
4
4
|
|
|
5
|
-
**给你的 AI Agent 赋予 focalapi
|
|
5
|
+
**给你的 AI Agent 赋予 focalapi 的创作模型能力**
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/focalapi-cli)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
|
|
11
|
-
focalapi
|
|
11
|
+
focalapi 的图像、视频、音频与视觉创作能力,可以通过 focalapi CLI + Skills 交给本地 Agent 调用;DeepSeek 仅作为提示词、分镜和轻量文本的备用能力。
|
|
12
12
|
|
|
13
13
|
```shell
|
|
14
14
|
npm i -g focalapi-cli
|
|
@@ -22,11 +22,9 @@ npm i -g focalapi-cli
|
|
|
22
22
|
|
|
23
23
|
让你的 AI Agent 具备这些 focalapi 能力,并能在复杂任务中组合调用:
|
|
24
24
|
|
|
25
|
-
-
|
|
26
|
-
- **图片 / 视频生成**:文生图、文生视频,产物自动下载到本地;视频任务支持异步编排
|
|
27
|
-
- **联网搜索**:`/v1/alpha/search`,给 Agent 接上实时信息
|
|
25
|
+
- **图片 / 视频生成**:文生图、图生图、文生视频,产物自动下载到本地;视频任务支持异步编排
|
|
28
26
|
- **音频**:语音转写(transcribe)、文字转语音(speech)
|
|
29
|
-
-
|
|
27
|
+
- **DeepSeek 文本辅助**:提示词、分镜、旁白和轻量脚本草稿
|
|
30
28
|
- **Agent 接入**:`focalapi connect` 把内置 Skills 装进本机 Agent,Agent 即刻学会调用 focalapi
|
|
31
29
|
- **治理闭环**:`usage` 额度用量、`doctor` 只读诊断(用免费演练模型做端到端自检,不花额度)
|
|
32
30
|
|
|
@@ -40,7 +38,7 @@ focalapi-cli 不是让你记住更多命令,而是把 focalapi 的模型能力
|
|
|
40
38
|
|
|
41
39
|
调用链路(Agent 视角):
|
|
42
40
|
models list 选模型
|
|
43
|
-
->
|
|
41
|
+
-> gen / audio / chat(DeepSeek 备用)调用
|
|
44
42
|
-> 产物落盘(图像/视频/音频)
|
|
45
43
|
-> usage 看额度,doctor 做诊断
|
|
46
44
|
```
|
|
@@ -71,34 +69,57 @@ export FOCALAPI_API_KEY=sk-xxxx
|
|
|
71
69
|
|
|
72
70
|
## 常用任务
|
|
73
71
|
|
|
74
|
-
###
|
|
72
|
+
### DeepSeek 文本辅助
|
|
75
73
|
|
|
76
74
|
```shell
|
|
77
|
-
focalapi chat "
|
|
75
|
+
focalapi chat "把这个产品简介写成 6 镜头分镜" -m <DeepSeek模型>
|
|
78
76
|
|
|
79
|
-
cat
|
|
77
|
+
cat brief.md | focalapi chat -m <DeepSeek模型> --system "你是专业的广告分镜师"
|
|
80
78
|
|
|
81
|
-
focalapi chat "
|
|
79
|
+
focalapi chat "提炼这张参考图的构图和色彩" -m <DeepSeek模型> --input @photo.jpg
|
|
82
80
|
```
|
|
83
81
|
|
|
84
82
|
### 生成图片 / 视频
|
|
85
83
|
|
|
86
84
|
```shell
|
|
85
|
+
# 先读取模型的端点、支持参数、默认值与范围;适合 Agent 在生成前自检
|
|
86
|
+
focalapi models get doubao-seedream-4-5-251128
|
|
87
|
+
focalapi models get doubao-seedance-2-0-260128 --json
|
|
88
|
+
|
|
87
89
|
focalapi gen image "未来城市海报" -m <图像模型> --size 1024x1024 -o ./out
|
|
90
|
+
focalapi gen image "产品主视觉" -m gpt-image-2 --size 1536x1024 --quality high --background opaque -o ./out
|
|
91
|
+
focalapi gen image "将背景改为雨夜" -m gpt-image-2 --image https://example.com/source.png \
|
|
92
|
+
--mask https://example.com/mask.png --response-format b64_json -o ./out
|
|
93
|
+
# OpenAI 图像模型可选持久任务:立即获得 task_id,稍后查询 data[].url
|
|
94
|
+
focalapi gen image "产品主视觉" -m gpt-image-2 --size 1024x1024 --no-wait --json
|
|
95
|
+
focalapi task status <task_id> --json
|
|
96
|
+
|
|
97
|
+
# Gemini 图像模型是原生 Gemini 端点,不走 OpenAI 图像端点
|
|
98
|
+
focalapi gen gemini-image "一只水彩风格的橘猫" -m gemini-3.1-flash-image-preview \
|
|
99
|
+
--aspect-ratio 16:9 --image-size 2K -o ./out
|
|
100
|
+
# Gemini native generationConfig fields can be passed through --config; named flags cover --image, --system, --seed, --thinking-level, --temperature, and --top-p. responseFormat.image and a single candidate are fixed.
|
|
88
101
|
|
|
89
102
|
# 视频:默认等待完成并下载;异步编排用 --no-wait
|
|
90
103
|
focalapi gen video "海浪拍打礁石" -m <视频模型> --seconds 5
|
|
104
|
+
focalapi gen video "海浪拍打礁石" -m doubao-seedance-2-0-260128 \
|
|
105
|
+
--seconds 5 --resolution 720p --ratio 16:9 --generate-audio true --no-wait --json
|
|
106
|
+
focalapi gen video "让海浪缓慢推进" -m doubao-seedance-2-0-260128 \
|
|
107
|
+
--image https://example.com/frame.png --generate-audio false --watermark true \
|
|
108
|
+
--return-last-frame true --callback-url https://example.com/callback \
|
|
109
|
+
--execution-expires-after 7200 --safety-identifier customer-42 --priority 4 --no-wait --json
|
|
110
|
+
# Ark-compatible content (text, image_url, video_url, audio_url with roles) is available unchanged through metadata.content:
|
|
111
|
+
focalapi gen video "ignored when content is supplied" -m doubao-seedance-2-0-260128 \
|
|
112
|
+
--content '[{"type":"text","text":"A cinematic ocean wave."}]' --no-wait --json
|
|
91
113
|
focalapi gen video "海浪拍打礁石" -m <视频模型> --no-wait --json # 拿 task_id
|
|
92
114
|
focalapi task status <task_id> --json
|
|
93
115
|
focalapi task download <task_id> -o ./out
|
|
94
116
|
```
|
|
95
117
|
|
|
96
|
-
|
|
118
|
+
`models get` 是生成前的权威检查入口:返回 `supported_endpoint_types`、`supported_params`,并在可用时提供官方文档链接。CLI 会在本地拒绝已知的不支持参数,例如 Seedream 4.5 的低于 3.69 MP 的尺寸、Seedance Fast/Mini 的 1080p、或 Seedance 的非 4–15 秒时长;不会把这些确定会失败的请求交给上游。同步图像生成时,等待进度输出到 stderr,因此 `--json` 的 stdout 始终保持为可解析 JSON。
|
|
119
|
+
|
|
120
|
+
### 音频创作
|
|
97
121
|
|
|
98
122
|
```shell
|
|
99
|
-
focalapi search "过去7天 AI 行业新闻" -m <搜索模型> --json
|
|
100
|
-
focalapi embed "待编码文本" -m <向量模型> --json
|
|
101
|
-
focalapi rerank -m <rerank模型> --query "问题" --docs @docs.json
|
|
102
123
|
focalapi audio transcribe meeting.mp3 -m <转写模型>
|
|
103
124
|
focalapi audio speech "大家好" -m <TTS模型> -o hello.mp3
|
|
104
125
|
```
|
|
@@ -114,9 +135,9 @@ focalapi doctor # 全链路只读诊断,任何调用失败先跑它
|
|
|
114
135
|
## Agent 接入(connect)
|
|
115
136
|
|
|
116
137
|
```shell
|
|
117
|
-
focalapi connect list #
|
|
138
|
+
focalapi connect list # 探测本机已支持的 Agent
|
|
118
139
|
focalapi connect install # 向全部检测到的 Agent 安装 skills
|
|
119
|
-
focalapi connect install
|
|
140
|
+
focalapi connect install <agent-id> # 只装指定 Agent
|
|
120
141
|
focalapi connect uninstall # 按 manifest 精确卸载,不碰其他文件
|
|
121
142
|
```
|
|
122
143
|
|
|
@@ -134,12 +155,10 @@ focalapi connect uninstall # 按 manifest 精确卸载,不碰其他
|
|
|
134
155
|
| 任务 | 命令 |
|
|
135
156
|
|---|---|
|
|
136
157
|
| 登录 / 状态 / 登出 | `focalapi auth login / status / logout` |
|
|
137
|
-
|
|
|
158
|
+
| DeepSeek 文本辅助 | `focalapi chat` |
|
|
138
159
|
| 图片 / 视频生成 | `focalapi gen image / gen video` |
|
|
139
|
-
| 任务查询与产物下载 | `focalapi task status / download` |
|
|
140
|
-
| 联网搜索 | `focalapi search` |
|
|
160
|
+
| 任务查询与产物下载 | `focalapi task status / download`(图像任务使用 status 读取 `data[].url`) |
|
|
141
161
|
| 语音转写 / 合成 | `focalapi audio transcribe / speech` |
|
|
142
|
-
| 向量化 / 重排序 | `focalapi embed / rerank` |
|
|
143
162
|
| 模型查询 | `focalapi models list / get` |
|
|
144
163
|
| 额度用量 | `focalapi usage` |
|
|
145
164
|
| 诊断排障 | `focalapi doctor` |
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,10 @@ var ApiError = class extends Error {
|
|
|
8
8
|
code;
|
|
9
9
|
status;
|
|
10
10
|
hint;
|
|
11
|
+
/** 上游返回的稳定错误代码或类型;不包含密钥等敏感请求信息。 */
|
|
12
|
+
upstreamCode;
|
|
13
|
+
/** 可用于向服务方关联日志的请求 ID。 */
|
|
14
|
+
requestId;
|
|
11
15
|
/** 上游原始响应体(已截断),仅调试用途,打印前需脱敏。 */
|
|
12
16
|
body;
|
|
13
17
|
constructor(code, message, opts) {
|
|
@@ -17,6 +21,8 @@ var ApiError = class extends Error {
|
|
|
17
21
|
this.status = opts?.status;
|
|
18
22
|
this.hint = opts?.hint;
|
|
19
23
|
this.body = opts?.body;
|
|
24
|
+
this.upstreamCode = opts?.upstreamCode;
|
|
25
|
+
this.requestId = opts?.requestId;
|
|
20
26
|
}
|
|
21
27
|
toJSON() {
|
|
22
28
|
return {
|
|
@@ -24,12 +30,14 @@ var ApiError = class extends Error {
|
|
|
24
30
|
code: this.code,
|
|
25
31
|
message: this.message,
|
|
26
32
|
...this.hint ? { hint: this.hint } : {},
|
|
27
|
-
...this.status !== void 0 ? { status: this.status } : {}
|
|
33
|
+
...this.status !== void 0 ? { status: this.status } : {},
|
|
34
|
+
...this.upstreamCode ? { upstream_code: this.upstreamCode } : {},
|
|
35
|
+
...this.requestId ? { request_id: this.requestId } : {}
|
|
28
36
|
}
|
|
29
37
|
};
|
|
30
38
|
}
|
|
31
39
|
};
|
|
32
|
-
function refineErrorCode(status, message) {
|
|
40
|
+
function refineErrorCode(status, message, opts) {
|
|
33
41
|
const m = message.toLowerCase();
|
|
34
42
|
if (m.includes("quota") || m.includes("\u989D\u5EA6") || m.includes("insufficient")) {
|
|
35
43
|
return "insufficient_quota";
|
|
@@ -38,14 +46,17 @@ function refineErrorCode(status, message) {
|
|
|
38
46
|
return "model_not_found";
|
|
39
47
|
}
|
|
40
48
|
if (m.includes("key") || m.includes("token") || m.includes("auth")) {
|
|
41
|
-
|
|
49
|
+
if (status === 401 || status === 403) {
|
|
50
|
+
return opts?.authFailureIsInvalidApiKey ? "invalid_api_key" : "upstream_auth_failed";
|
|
51
|
+
}
|
|
52
|
+
return "invalid_request";
|
|
42
53
|
}
|
|
43
54
|
switch (status) {
|
|
44
55
|
case 400:
|
|
45
56
|
return "invalid_request";
|
|
46
57
|
case 401:
|
|
47
58
|
case 403:
|
|
48
|
-
return "invalid_api_key";
|
|
59
|
+
return opts?.authFailureIsInvalidApiKey ? "invalid_api_key" : "authentication_failed";
|
|
49
60
|
case 404:
|
|
50
61
|
return "model_not_found";
|
|
51
62
|
case 429:
|
|
@@ -103,6 +114,14 @@ function printError(err, opts) {
|
|
|
103
114
|
const hint = err.hint;
|
|
104
115
|
if (hint) {
|
|
105
116
|
process.stderr.write(`\u63D0\u793A\uFF1A${hint}
|
|
117
|
+
`);
|
|
118
|
+
}
|
|
119
|
+
if (err.upstreamCode) {
|
|
120
|
+
process.stderr.write(`\u4E0A\u6E38\u4EE3\u7801\uFF1A${err.upstreamCode}
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
if (err.requestId) {
|
|
124
|
+
process.stderr.write(`\u8BF7\u6C42 ID\uFF1A${err.requestId}
|
|
106
125
|
`);
|
|
107
126
|
}
|
|
108
127
|
}
|
|
@@ -136,7 +155,7 @@ function displayWidth(s) {
|
|
|
136
155
|
}
|
|
137
156
|
|
|
138
157
|
// src/lib/version.ts
|
|
139
|
-
var VERSION = true ? "0.1.
|
|
158
|
+
var VERSION = true ? "0.1.1" : "0.0.0-dev";
|
|
140
159
|
|
|
141
160
|
// src/commands/auth.ts
|
|
142
161
|
import { createInterface } from "readline/promises";
|
|
@@ -248,7 +267,8 @@ function extractErrorMessage(raw) {
|
|
|
248
267
|
const obj = parsed;
|
|
249
268
|
const errObj = obj?.error;
|
|
250
269
|
const message = typeof errObj?.message === "string" && errObj.message || typeof obj?.message === "string" && obj.message || JSON.stringify(parsed).slice(0, 500);
|
|
251
|
-
|
|
270
|
+
const upstreamCode = [errObj?.code, errObj?.type, obj?.code, obj?.type].find((value) => typeof value === "string");
|
|
271
|
+
return { message, body: parsed, upstreamCode: typeof upstreamCode === "string" ? upstreamCode : void 0 };
|
|
252
272
|
}
|
|
253
273
|
async function request(opts) {
|
|
254
274
|
const res = await rawRequest(opts);
|
|
@@ -298,9 +318,10 @@ async function rawRequest(opts) {
|
|
|
298
318
|
}
|
|
299
319
|
if (!res.ok) {
|
|
300
320
|
const text = await res.text().catch(() => "");
|
|
301
|
-
const { message, body } = extractErrorMessage(text);
|
|
302
|
-
const code = refineErrorCode(res.status, message);
|
|
303
|
-
|
|
321
|
+
const { message, body, upstreamCode } = extractErrorMessage(text);
|
|
322
|
+
const code = refineErrorCode(res.status, message, { authFailureIsInvalidApiKey: opts.authFailureIsInvalidApiKey });
|
|
323
|
+
const requestId = res.headers.get("x-request-id") ?? res.headers.get("request-id") ?? res.headers.get("x-requestid") ?? void 0;
|
|
324
|
+
throw new ApiError(code, message, { status: res.status, body, upstreamCode, requestId });
|
|
304
325
|
}
|
|
305
326
|
return res;
|
|
306
327
|
}
|
|
@@ -342,7 +363,8 @@ async function fetchTokenUsage(baseUrl, apiKey) {
|
|
|
342
363
|
baseUrl,
|
|
343
364
|
path: "/api/usage/token/",
|
|
344
365
|
apiKey,
|
|
345
|
-
timeoutMs: 15e3
|
|
366
|
+
timeoutMs: 15e3,
|
|
367
|
+
authFailureIsInvalidApiKey: true
|
|
346
368
|
});
|
|
347
369
|
if (!res.data) {
|
|
348
370
|
throw new ApiError("bad_response", "\u7528\u91CF\u63A5\u53E3\u54CD\u5E94\u7F3A\u5C11 data \u5B57\u6BB5");
|
|
@@ -444,6 +466,39 @@ function registerAuth(program) {
|
|
|
444
466
|
}
|
|
445
467
|
|
|
446
468
|
// src/commands/models.ts
|
|
469
|
+
function textValue(value) {
|
|
470
|
+
if (value === null || value === void 0 || value === "") return "-";
|
|
471
|
+
if (Array.isArray(value)) return value.length === 0 ? "-" : value.map(textValue).join(", ");
|
|
472
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
473
|
+
return String(value);
|
|
474
|
+
}
|
|
475
|
+
function parameterConstraint(parameter) {
|
|
476
|
+
const constraints = [];
|
|
477
|
+
if (parameter.required) constraints.push("\u5FC5\u586B");
|
|
478
|
+
if (parameter.default !== void 0) constraints.push(`\u9ED8\u8BA4 ${String(parameter.default)}`);
|
|
479
|
+
if (parameter.values?.length) constraints.push(`\u53EF\u9009 ${parameter.values.join(" / ")}`);
|
|
480
|
+
if (parameter.minimum !== void 0 || parameter.maximum !== void 0) {
|
|
481
|
+
constraints.push(`${parameter.minimum ?? "-\u221E"}\u2013${parameter.maximum ?? "\u221E"}`);
|
|
482
|
+
}
|
|
483
|
+
return [parameter.type, ...constraints, parameter.description].join("\uFF1B");
|
|
484
|
+
}
|
|
485
|
+
function printModelDetails(model) {
|
|
486
|
+
const { supported_params: supportedParams, ...summary } = model;
|
|
487
|
+
printTable(
|
|
488
|
+
["\u5B57\u6BB5", "\u503C"],
|
|
489
|
+
Object.entries(summary).map(([key, value]) => [key, textValue(value)])
|
|
490
|
+
);
|
|
491
|
+
if (Array.isArray(supportedParams) && supportedParams.length > 0) {
|
|
492
|
+
process.stdout.write("\n\u652F\u6301\u53C2\u6570\n");
|
|
493
|
+
printTable(
|
|
494
|
+
["\u53C2\u6570", "\u7EA6\u675F"],
|
|
495
|
+
supportedParams.map((parameter) => {
|
|
496
|
+
const item = parameter;
|
|
497
|
+
return [item.name, parameterConstraint(item)];
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
447
502
|
function registerModels(program) {
|
|
448
503
|
const models = program.command("models").description("\u53EF\u7528\u6A21\u578B\u67E5\u8BE2");
|
|
449
504
|
models.command("list").description("\u5217\u51FA\u5F53\u524D Key \u53EF\u7528\u7684\u5168\u90E8\u6A21\u578B").option("--filter <keyword>", "\u6309 id \u5173\u952E\u5B57\u8FC7\u6EE4\uFF08\u4E0D\u533A\u5206\u5927\u5C0F\u5199\uFF09").action(async (opts, cmd) => {
|
|
@@ -459,8 +514,8 @@ function registerModels(program) {
|
|
|
459
514
|
printJson({ data: list });
|
|
460
515
|
} else {
|
|
461
516
|
printTable(
|
|
462
|
-
["\u6A21\u578B ID", "\u63D0\u4F9B\u65B9"],
|
|
463
|
-
list.map((m) => [m.id, m.owned_by ?? "-"])
|
|
517
|
+
["\u6A21\u578B ID", "\u63D0\u4F9B\u65B9", "\u652F\u6301\u7AEF\u70B9"],
|
|
518
|
+
list.map((m) => [m.id, m.owned_by ?? "-", textValue(m.supported_endpoint_types)])
|
|
464
519
|
);
|
|
465
520
|
}
|
|
466
521
|
});
|
|
@@ -475,10 +530,7 @@ function registerModels(program) {
|
|
|
475
530
|
if (g.json) {
|
|
476
531
|
printJson(res);
|
|
477
532
|
} else {
|
|
478
|
-
|
|
479
|
-
["\u5B57\u6BB5", "\u503C"],
|
|
480
|
-
Object.entries(res).map(([k, v]) => [k, typeof v === "object" ? JSON.stringify(v) : String(v)])
|
|
481
|
-
);
|
|
533
|
+
printModelDetails(res);
|
|
482
534
|
}
|
|
483
535
|
});
|
|
484
536
|
}
|
|
@@ -551,7 +603,7 @@ function extractText(content) {
|
|
|
551
603
|
return "";
|
|
552
604
|
}
|
|
553
605
|
function registerChat(program) {
|
|
554
|
-
program.command("chat").description("\u5BF9\u8BDD\u4E0E\
|
|
606
|
+
program.command("chat").description("DeepSeek \u5907\u7528\u5BF9\u8BDD\u4E0E\u63D0\u793A\u8BCD\u8F85\u52A9\uFF08/v1/chat/completions\uFF09").argument("[prompt...]", "\u63D0\u793A\u8BCD\uFF1B\u7701\u7565\u4E14 stdin \u4E3A\u7BA1\u9053\u65F6\u4ECE stdin \u8BFB\u53D6").option("-m, --model <model>", "\u6A21\u578B ID\uFF08\u6216\u8BBE FOCALAPI_MODEL\uFF09").option("--system <text>", "system \u63D0\u793A\u8BCD").option("--input <file...>", "\u8F93\u5165\u6587\u4EF6\uFF08\u56FE\u7247\u8F6C data URL\uFF0C\u5982 --input @photo.jpg\uFF1B@ \u524D\u7F00\u53EF\u9009\uFF09").option("--max-tokens <n>", "max_tokens", (v) => Number.parseInt(v, 10)).option("--stream", "\u5F3A\u5236\u6D41\u5F0F\u8F93\u51FA").option("--no-stream", "\u5F3A\u5236\u975E\u6D41\u5F0F").action(
|
|
555
607
|
async (promptParts, opts, cmd) => {
|
|
556
608
|
const g = cmd.optsWithGlobals();
|
|
557
609
|
const auth = resolveAuth(g);
|
|
@@ -724,6 +776,187 @@ import { join as join3, resolve as resolve2 } from "path";
|
|
|
724
776
|
import { pipeline as pipeline2 } from "stream/promises";
|
|
725
777
|
import { Readable as Readable2 } from "stream";
|
|
726
778
|
|
|
779
|
+
// src/lib/model-capabilities.ts
|
|
780
|
+
var IMAGE_CONSTRAINTS = {
|
|
781
|
+
"gpt-image-2": {
|
|
782
|
+
defaultSize: "1024x1024",
|
|
783
|
+
maxN: 8,
|
|
784
|
+
maxReferenceImages: 16,
|
|
785
|
+
minMegapixels: 0.65536,
|
|
786
|
+
maxMegapixels: 8.2944,
|
|
787
|
+
minEdge: 1024,
|
|
788
|
+
maxEdge: 3840,
|
|
789
|
+
edgeMultiple: 16,
|
|
790
|
+
maxAspectRatio: 3,
|
|
791
|
+
qualities: ["low", "medium", "high"],
|
|
792
|
+
backgrounds: ["auto", "opaque"]
|
|
793
|
+
},
|
|
794
|
+
"doubao-seedream-4-5-251128": {
|
|
795
|
+
defaultSize: "2048x2048",
|
|
796
|
+
maxN: 10,
|
|
797
|
+
maxReferenceImages: 10,
|
|
798
|
+
maxTotalImages: 15,
|
|
799
|
+
minMegapixels: 3.6864,
|
|
800
|
+
maxMegapixels: 16.777216
|
|
801
|
+
},
|
|
802
|
+
"doubao-seedream-5-0-pro-260628": {
|
|
803
|
+
defaultSize: "1024x1024",
|
|
804
|
+
maxN: 1,
|
|
805
|
+
maxReferenceImages: 10,
|
|
806
|
+
minMegapixels: 0.92,
|
|
807
|
+
maxMegapixels: 4.194304
|
|
808
|
+
},
|
|
809
|
+
"doubao-seedream-5-0-lite-260128": {
|
|
810
|
+
defaultSize: "2048x2048",
|
|
811
|
+
maxN: 14,
|
|
812
|
+
maxReferenceImages: 14,
|
|
813
|
+
maxTotalImages: 15,
|
|
814
|
+
minMegapixels: 3.6864,
|
|
815
|
+
maxMegapixels: 16.777216
|
|
816
|
+
},
|
|
817
|
+
"grok-imagine-image-quality": { defaultSize: "1024x1024", maxN: 10, maxReferenceImages: 3 },
|
|
818
|
+
"grok-imagine-image": { defaultSize: "1024x1024", maxN: 10, maxReferenceImages: 3 },
|
|
819
|
+
"grok-imagine-image-pro": { defaultSize: "1024x1024", maxN: 10, maxReferenceImages: 1 }
|
|
820
|
+
};
|
|
821
|
+
var SEEDANCE_RATIOS = ["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9"];
|
|
822
|
+
var VIDEO_CONSTRAINTS = {
|
|
823
|
+
"doubao-seedance-2-0-260128": {
|
|
824
|
+
resolutions: ["480p", "720p", "1080p", "4k"],
|
|
825
|
+
ratios: SEEDANCE_RATIOS,
|
|
826
|
+
minSeconds: 4,
|
|
827
|
+
maxSeconds: 15
|
|
828
|
+
},
|
|
829
|
+
"doubao-seedance-2-0-fast-260128": {
|
|
830
|
+
resolutions: ["480p", "720p"],
|
|
831
|
+
ratios: SEEDANCE_RATIOS,
|
|
832
|
+
minSeconds: 4,
|
|
833
|
+
maxSeconds: 15
|
|
834
|
+
},
|
|
835
|
+
"doubao-seedance-2-0-mini-260615": {
|
|
836
|
+
resolutions: ["480p", "720p"],
|
|
837
|
+
ratios: SEEDANCE_RATIOS,
|
|
838
|
+
minSeconds: 4,
|
|
839
|
+
maxSeconds: 15
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
var COMMON_GEMINI_RATIOS = ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
|
|
843
|
+
var GEMINI_IMAGE_CONSTRAINTS = {
|
|
844
|
+
"gemini-3-pro-image-preview": { aspectRatios: COMMON_GEMINI_RATIOS, imageSizes: ["1K", "2K", "4K"], supportsSampling: false },
|
|
845
|
+
"gemini-3.1-flash-image-preview": { aspectRatios: COMMON_GEMINI_RATIOS, imageSizes: ["1K", "2K", "4K"], supportsSampling: false },
|
|
846
|
+
"gemini-3.1-flash-lite-image-preview": {
|
|
847
|
+
aspectRatios: [...COMMON_GEMINI_RATIOS, "1:4", "4:1", "1:8", "8:1"],
|
|
848
|
+
imageSizes: ["1K"],
|
|
849
|
+
supportsSampling: true
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
function parseSize(size, model) {
|
|
853
|
+
const match = /^(\d+)x(\d+)$/i.exec(size.trim());
|
|
854
|
+
if (!match) {
|
|
855
|
+
throw new ApiError("invalid_request", `${model} size must be WIDTHxHEIGHT (received: ${size})`);
|
|
856
|
+
}
|
|
857
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
858
|
+
}
|
|
859
|
+
function megapixels(width, height) {
|
|
860
|
+
return width * height / 1e6;
|
|
861
|
+
}
|
|
862
|
+
function formatMegapixels(value) {
|
|
863
|
+
return value.toFixed(2).replace(/\.00$/, "");
|
|
864
|
+
}
|
|
865
|
+
function validateImageGeneration(model, input) {
|
|
866
|
+
if (input.responseFormat && input.responseFormat !== "url" && input.responseFormat !== "b64_json") {
|
|
867
|
+
throw new ApiError("invalid_request", `response_format must be url or b64_json (received: ${input.responseFormat})`);
|
|
868
|
+
}
|
|
869
|
+
const constraint = IMAGE_CONSTRAINTS[model.trim()];
|
|
870
|
+
if (!constraint) return;
|
|
871
|
+
if (input.n < 1 || input.n > constraint.maxN) {
|
|
872
|
+
throw new ApiError("invalid_request", `${model} n must be 1-${constraint.maxN} (received: ${input.n})`);
|
|
873
|
+
}
|
|
874
|
+
if (input.imageCount !== void 0 && constraint.maxReferenceImages !== void 0 && input.imageCount > constraint.maxReferenceImages) {
|
|
875
|
+
throw new ApiError("invalid_request", `${model} supports at most ${constraint.maxReferenceImages} reference images`);
|
|
876
|
+
}
|
|
877
|
+
if (input.imageCount !== void 0 && constraint.maxTotalImages !== void 0 && input.imageCount + input.n > constraint.maxTotalImages) {
|
|
878
|
+
throw new ApiError("invalid_request", `${model} supports at most ${constraint.maxTotalImages} input plus generated images`);
|
|
879
|
+
}
|
|
880
|
+
if (input.hasMask && model === "gpt-image-2" && input.imageCount !== 1) {
|
|
881
|
+
throw new ApiError("invalid_request", "gpt-image-2 mask requires exactly one reference image");
|
|
882
|
+
}
|
|
883
|
+
if (input.quality && constraint.qualities && !constraint.qualities.includes(input.quality.toLowerCase())) {
|
|
884
|
+
throw new ApiError("invalid_request", `${model} quality must be one of ${constraint.qualities.join(", ")} (received: ${input.quality})`);
|
|
885
|
+
}
|
|
886
|
+
if (input.background && constraint.backgrounds && !constraint.backgrounds.includes(input.background.toLowerCase())) {
|
|
887
|
+
throw new ApiError("invalid_request", `${model} background must be one of ${constraint.backgrounds.join(", ")} (received: ${input.background})`);
|
|
888
|
+
}
|
|
889
|
+
const { width, height } = parseSize(input.size ?? constraint.defaultSize, model);
|
|
890
|
+
const pixels = megapixels(width, height);
|
|
891
|
+
if (constraint.minEdge && (width < constraint.minEdge || height < constraint.minEdge) || constraint.maxEdge && (width > constraint.maxEdge || height > constraint.maxEdge) || constraint.edgeMultiple && (width % constraint.edgeMultiple !== 0 || height % constraint.edgeMultiple !== 0) || constraint.minMegapixels && pixels < constraint.minMegapixels || constraint.maxMegapixels && pixels > constraint.maxMegapixels) {
|
|
892
|
+
const edge = constraint.minEdge && constraint.maxEdge ? `${constraint.minEdge}-${constraint.maxEdge}px per edge` : "";
|
|
893
|
+
const step = constraint.edgeMultiple ? `, edge multiples of ${constraint.edgeMultiple}` : "";
|
|
894
|
+
const mp = constraint.minMegapixels && constraint.maxMegapixels ? `, ${formatMegapixels(constraint.minMegapixels)}-${formatMegapixels(constraint.maxMegapixels)} MP` : "";
|
|
895
|
+
throw new ApiError("invalid_request", `${model} does not support size=${width}x${height}; supported: ${edge}${step}${mp}`);
|
|
896
|
+
}
|
|
897
|
+
if (constraint.maxAspectRatio) {
|
|
898
|
+
const ratio = Math.max(width, height) / Math.min(width, height);
|
|
899
|
+
if (ratio > constraint.maxAspectRatio) {
|
|
900
|
+
throw new ApiError("invalid_request", `${model} longest-to-shortest edge ratio must not exceed ${constraint.maxAspectRatio}:1 (received: ${width}x${height})`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function validateGeminiImageGeneration(model, input) {
|
|
905
|
+
const constraint = GEMINI_IMAGE_CONSTRAINTS[model.trim()];
|
|
906
|
+
if (!constraint) {
|
|
907
|
+
throw new ApiError("invalid_request", `${model} is not a supported Gemini image model; run focalapi models get ${model} first`);
|
|
908
|
+
}
|
|
909
|
+
if (input.aspectRatio && !constraint.aspectRatios.includes(input.aspectRatio)) {
|
|
910
|
+
throw new ApiError("invalid_request", `${model} aspectRatio must be one of ${constraint.aspectRatios.join(", ")} (received: ${input.aspectRatio})`);
|
|
911
|
+
}
|
|
912
|
+
if (input.imageSize && !constraint.imageSizes.includes(input.imageSize.toUpperCase())) {
|
|
913
|
+
throw new ApiError("invalid_request", `${model} imageSize must be one of ${constraint.imageSizes.join(", ")} (received: ${input.imageSize})`);
|
|
914
|
+
}
|
|
915
|
+
if (input.seed !== void 0 && (!Number.isInteger(input.seed) || input.seed < 0)) {
|
|
916
|
+
throw new ApiError("invalid_request", "seed must be a non-negative integer");
|
|
917
|
+
}
|
|
918
|
+
if (!constraint.supportsSampling && (input.thinkingLevel || input.temperature !== void 0 || input.topP !== void 0)) {
|
|
919
|
+
throw new ApiError("invalid_request", `${model} supports thinkingLevel, temperature, and topP only on gemini-3.1-flash-lite-image-preview`);
|
|
920
|
+
}
|
|
921
|
+
if (input.thinkingLevel && !["MINIMAL", "HIGH"].includes(input.thinkingLevel.toUpperCase())) {
|
|
922
|
+
throw new ApiError("invalid_request", "thinkingLevel must be MINIMAL or HIGH");
|
|
923
|
+
}
|
|
924
|
+
if (input.temperature !== void 0 && (!Number.isFinite(input.temperature) || input.temperature < 0 || input.temperature > 2)) {
|
|
925
|
+
throw new ApiError("invalid_request", "temperature must be between 0 and 2");
|
|
926
|
+
}
|
|
927
|
+
if (input.topP !== void 0 && (!Number.isFinite(input.topP) || input.topP < 0 || input.topP > 1)) {
|
|
928
|
+
throw new ApiError("invalid_request", "topP must be between 0 and 1");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function validateVideoGeneration(model, input) {
|
|
932
|
+
const constraint = VIDEO_CONSTRAINTS[model.trim()];
|
|
933
|
+
if (!constraint) return;
|
|
934
|
+
if (input.seconds !== void 0 && (input.seconds < constraint.minSeconds || input.seconds > constraint.maxSeconds)) {
|
|
935
|
+
throw new ApiError("invalid_request", `${model} seconds must be ${constraint.minSeconds}-${constraint.maxSeconds} (received: ${input.seconds})`);
|
|
936
|
+
}
|
|
937
|
+
if (input.resolution && !constraint.resolutions.includes(input.resolution.toLowerCase())) {
|
|
938
|
+
throw new ApiError("invalid_request", `${model} resolution must be one of ${constraint.resolutions.join(", ")} (received: ${input.resolution})`);
|
|
939
|
+
}
|
|
940
|
+
if (input.ratio && !constraint.ratios.includes(input.ratio)) {
|
|
941
|
+
throw new ApiError("invalid_request", `${model} ratio must be one of ${constraint.ratios.join(", ")} (received: ${input.ratio})`);
|
|
942
|
+
}
|
|
943
|
+
if (input.priority !== void 0 && model !== "doubao-seedance-2-0-260128") {
|
|
944
|
+
throw new ApiError("invalid_request", `${model} does not support priority; only doubao-seedance-2-0-260128 does`);
|
|
945
|
+
}
|
|
946
|
+
if (input.priority !== void 0 && (input.priority < 0 || input.priority > 9)) {
|
|
947
|
+
throw new ApiError("invalid_request", `${model} priority must be 0-9 (received: ${input.priority})`);
|
|
948
|
+
}
|
|
949
|
+
if (input.serviceTier && input.serviceTier !== "default") {
|
|
950
|
+
throw new ApiError("invalid_request", `${model} service_tier must be default (received: ${input.serviceTier})`);
|
|
951
|
+
}
|
|
952
|
+
if (input.executionExpiresAfter !== void 0 && (input.executionExpiresAfter < 3600 || input.executionExpiresAfter > 259200)) {
|
|
953
|
+
throw new ApiError("invalid_request", `${model} execution_expires_after must be 3600-259200 (received: ${input.executionExpiresAfter})`);
|
|
954
|
+
}
|
|
955
|
+
if (input.safetyIdentifier !== void 0 && !/^[\x21-\x7e]{1,64}$/.test(input.safetyIdentifier)) {
|
|
956
|
+
throw new ApiError("invalid_request", `${model} safety_identifier must contain 1-64 printable ASCII characters`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
727
960
|
// src/lib/tasks.ts
|
|
728
961
|
import { createWriteStream } from "fs";
|
|
729
962
|
import { mkdir } from "fs/promises";
|
|
@@ -767,11 +1000,21 @@ function extractProgress(body) {
|
|
|
767
1000
|
return void 0;
|
|
768
1001
|
}
|
|
769
1002
|
async function fetchTask(baseUrl, apiKey, taskId) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1003
|
+
let raw;
|
|
1004
|
+
try {
|
|
1005
|
+
raw = await request({
|
|
1006
|
+
baseUrl,
|
|
1007
|
+
path: `/v1/video/generations/${encodeURIComponent(taskId)}`,
|
|
1008
|
+
apiKey
|
|
1009
|
+
});
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
if (!(err instanceof ApiError) || err.status !== 404) throw err;
|
|
1012
|
+
raw = await request({
|
|
1013
|
+
baseUrl,
|
|
1014
|
+
path: `/v1/images/generations/${encodeURIComponent(taskId)}`,
|
|
1015
|
+
apiKey
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
775
1018
|
const obj = raw;
|
|
776
1019
|
const rawStatus = String(obj?.status ?? obj?.data?.status ?? "");
|
|
777
1020
|
return {
|
|
@@ -843,9 +1086,34 @@ function clampInt(value, min, max, name) {
|
|
|
843
1086
|
}
|
|
844
1087
|
return value;
|
|
845
1088
|
}
|
|
1089
|
+
function parseBooleanOption(value, name) {
|
|
1090
|
+
switch (value.trim().toLowerCase()) {
|
|
1091
|
+
case "true":
|
|
1092
|
+
return true;
|
|
1093
|
+
case "false":
|
|
1094
|
+
return false;
|
|
1095
|
+
default:
|
|
1096
|
+
throw new ApiError("invalid_request", `--${name} must be true or false (received: ${value})`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
async function withProgress(label, operation) {
|
|
1100
|
+
const startedAt = Date.now();
|
|
1101
|
+
info(`${label}\u2026`);
|
|
1102
|
+
const timer = setInterval(() => {
|
|
1103
|
+
const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1e3));
|
|
1104
|
+
info(`${label}\uFF0C\u5DF2\u7B49\u5F85 ${elapsedSeconds} \u79D2\u2026`);
|
|
1105
|
+
}, 1e4);
|
|
1106
|
+
timer.unref();
|
|
1107
|
+
try {
|
|
1108
|
+
return await operation();
|
|
1109
|
+
} finally {
|
|
1110
|
+
clearInterval(timer);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
846
1113
|
async function saveImageItem(item, dir, base, apiKey) {
|
|
847
1114
|
if (item.b64_json) {
|
|
848
|
-
const
|
|
1115
|
+
const ext = item.mime_type?.includes("jpeg") ? ".jpg" : item.mime_type?.includes("webp") ? ".webp" : ".png";
|
|
1116
|
+
const filePath = join3(dir, `${base}${ext}`);
|
|
849
1117
|
await writeFile(filePath, Buffer.from(item.b64_json, "base64"));
|
|
850
1118
|
return filePath;
|
|
851
1119
|
}
|
|
@@ -864,21 +1132,88 @@ async function saveImageItem(item, dir, base, apiKey) {
|
|
|
864
1132
|
}
|
|
865
1133
|
throw new ApiError("bad_response", "\u56FE\u50CF\u7ED3\u679C\u65E2\u6CA1\u6709 url \u4E5F\u6CA1\u6709 b64_json");
|
|
866
1134
|
}
|
|
1135
|
+
function parseGenerationConfig(raw) {
|
|
1136
|
+
if (!raw) return {};
|
|
1137
|
+
try {
|
|
1138
|
+
const parsed = JSON.parse(raw);
|
|
1139
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
1140
|
+
throw new Error("not an object");
|
|
1141
|
+
}
|
|
1142
|
+
return parsed;
|
|
1143
|
+
} catch {
|
|
1144
|
+
throw new ApiError("invalid_request", "--config \u5FC5\u987B\u662F\u5408\u6CD5\u7684 generationConfig JSON \u5BF9\u8C61\u3002");
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function parseJsonArray(raw, option) {
|
|
1148
|
+
try {
|
|
1149
|
+
const parsed = JSON.parse(raw);
|
|
1150
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1151
|
+
return parsed;
|
|
1152
|
+
} catch {
|
|
1153
|
+
throw new ApiError("invalid_request", `--${option} must be a JSON array`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
function geminiImagePart(source) {
|
|
1157
|
+
const dataUri = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(source.trim());
|
|
1158
|
+
if (dataUri) {
|
|
1159
|
+
const [, mimeType = "", data = ""] = dataUri;
|
|
1160
|
+
return { inlineData: { mimeType, data: data.replace(/[\r\n]/g, "") } };
|
|
1161
|
+
}
|
|
1162
|
+
return { fileData: { fileUri: source } };
|
|
1163
|
+
}
|
|
1164
|
+
function extractGeminiImageItems(response) {
|
|
1165
|
+
return (response.candidates ?? []).flatMap(
|
|
1166
|
+
(candidate) => (candidate.content?.parts ?? []).flatMap(
|
|
1167
|
+
(part) => part.inlineData?.data ? [{ b64_json: part.inlineData.data, mime_type: part.inlineData.mimeType }] : []
|
|
1168
|
+
)
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
867
1171
|
function registerGen(program) {
|
|
868
1172
|
const gen = program.command("gen").description("\u56FE\u50CF / \u89C6\u9891\u751F\u6210");
|
|
869
|
-
gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u540C\u6B65\u8FD4\u56DE\
|
|
1173
|
+
gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u9ED8\u8BA4\u540C\u6B65\u8FD4\u56DE\u5E76\u4E0B\u8F7D\uFF1B--no-wait \u8FD4\u56DE\u53EF\u8F6E\u8BE2\u7684\u4EFB\u52A1 ID\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF08focalapi models list \u67E5\u770B\uFF09").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--quality <quality>", "\u56FE\u50CF\u8D28\u91CF\u6863\u4F4D\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--background <background>", "\u80CC\u666F\u6A21\u5F0F\uFF08\u4EC5 gpt-image-2 \u652F\u6301 auto/opaque\uFF09").option("--image <url...>", "\u53C2\u8003\u56FE\u6216\u7F16\u8F91\u56FE URL\uFF0C\u53EF\u591A\u4E2A").option("--mask <url>", "\u7F16\u8F91 mask URL\uFF08gpt-image-2 \u9700\u8981\u5355\u5F20\u53C2\u8003\u56FE\uFF09").option("--response-format <format>", "\u56FE\u50CF\u54CD\u5E94\u683C\u5F0F\uFF1Aurl \u6216 b64_json").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u56FE\u50CF\u751F\u6210\u5B8C\u6210").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
|
|
870
1174
|
const g = cmd.optsWithGlobals();
|
|
871
1175
|
const auth = resolveAuth(g);
|
|
872
1176
|
const n = clampInt(opts.n, 1, MAX_IMAGE_N, "n");
|
|
1177
|
+
validateImageGeneration(opts.model, {
|
|
1178
|
+
n,
|
|
1179
|
+
size: opts.size,
|
|
1180
|
+
quality: opts.quality,
|
|
1181
|
+
background: opts.background,
|
|
1182
|
+
responseFormat: opts.responseFormat,
|
|
1183
|
+
imageCount: opts.image?.length,
|
|
1184
|
+
hasMask: Boolean(opts.mask)
|
|
1185
|
+
});
|
|
1186
|
+
if (opts.wait === false && opts.responseFormat === "b64_json") {
|
|
1187
|
+
throw new ApiError("invalid_request", "--response-format b64_json cannot be used with --no-wait; use url");
|
|
1188
|
+
}
|
|
873
1189
|
const body = { model: opts.model, prompt: promptParts.join(" "), n };
|
|
874
1190
|
if (opts.size) body.size = opts.size;
|
|
875
|
-
|
|
1191
|
+
if (opts.quality) body.quality = opts.quality;
|
|
1192
|
+
if (opts.background) body.background = opts.background;
|
|
1193
|
+
if (opts.image) body.image = opts.image;
|
|
1194
|
+
if (opts.mask) body.mask = opts.mask;
|
|
1195
|
+
if (opts.responseFormat) body.response_format = opts.responseFormat;
|
|
1196
|
+
const res = await withProgress(opts.wait === false ? "\u6B63\u5728\u63D0\u4EA4\u56FE\u50CF\u4EFB\u52A1" : "\u6B63\u5728\u751F\u6210\u56FE\u50CF", () => request({
|
|
876
1197
|
baseUrl: auth.baseUrl,
|
|
877
1198
|
path: "/v1/images/generations",
|
|
878
1199
|
apiKey: auth.apiKey,
|
|
879
1200
|
body,
|
|
1201
|
+
headers: opts.wait === false ? { Prefer: "respond-async" } : void 0,
|
|
880
1202
|
timeoutMs: 6e5
|
|
881
|
-
});
|
|
1203
|
+
}));
|
|
1204
|
+
if (opts.wait === false) {
|
|
1205
|
+
const taskId = extractTaskId(res);
|
|
1206
|
+
if (!taskId) {
|
|
1207
|
+
throw new ApiError("bad_response", "\u5F02\u6B65\u56FE\u50CF\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: res });
|
|
1208
|
+
}
|
|
1209
|
+
if (g.json) {
|
|
1210
|
+
printJson({ task_id: taskId, status: res.status ?? "queued", submitted: true });
|
|
1211
|
+
} else {
|
|
1212
|
+
process.stdout.write(taskId + "\n");
|
|
1213
|
+
info(`\u4EFB\u52A1\u5DF2\u63D0\u4EA4\u3002\u67E5\u8BE2\uFF1Afocalapi task status ${taskId}`);
|
|
1214
|
+
}
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
882
1217
|
const items = res.data ?? [];
|
|
883
1218
|
if (items.length === 0) {
|
|
884
1219
|
throw new ApiError("bad_response", "\u56FE\u50CF\u751F\u6210\u54CD\u5E94\u4E3A\u7A7A", { body: res });
|
|
@@ -896,22 +1231,102 @@ function registerGen(program) {
|
|
|
896
1231
|
for (const f of files) info(`\u2713 ${f}`);
|
|
897
1232
|
}
|
|
898
1233
|
});
|
|
899
|
-
gen.command("
|
|
1234
|
+
gen.command("gemini-image").description("\u4F7F\u7528 Gemini \u539F\u751F generateContent \u63A5\u53E3\u751F\u6210\u56FE\u50CF").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "Gemini \u56FE\u50CF\u6A21\u578B ID\uFF1B\u5148\u7528 focalapi models get \u786E\u8BA4").option("--aspect-ratio <ratio>", "\u753B\u9762\u6BD4\u4F8B\uFF0C\u4F8B\u5982 1:1\u300116:9\u3001auto").option("--image-size <size>", "\u8F93\u51FA\u5C3A\u5BF8\uFF0C\u4F8B\u5982 1K\u30012K\u30014K").option("--config <json>", "\u9644\u52A0 Gemini generationConfig JSON\uFF1B\u547D\u4EE4\u56FA\u5B9A responseFormat.image \u548C\u5355\u5019\u9009").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--image <url...>", "Gemini reference image URL or data URI; repeatable").option("--system <text>", "Gemini systemInstruction text").option("--seed <n>", "Non-negative Gemini generation seed", (v) => Number.parseInt(v, 10)).option("--thinking-level <level>", "Nano Banana 2 Lite: MINIMAL or HIGH").option("--temperature <n>", "Nano Banana 2 Lite: 0 through 2", (v) => Number.parseFloat(v)).option("--top-p <n>", "Nano Banana 2 Lite: 0 through 1", (v) => Number.parseFloat(v)).action(async (promptParts, opts, cmd) => {
|
|
1235
|
+
const g = cmd.optsWithGlobals();
|
|
1236
|
+
const auth = resolveAuth(g);
|
|
1237
|
+
validateGeminiImageGeneration(opts.model, {
|
|
1238
|
+
aspectRatio: opts.aspectRatio,
|
|
1239
|
+
imageSize: opts.imageSize,
|
|
1240
|
+
seed: opts.seed,
|
|
1241
|
+
thinkingLevel: opts.thinkingLevel,
|
|
1242
|
+
temperature: opts.temperature,
|
|
1243
|
+
topP: opts.topP
|
|
1244
|
+
});
|
|
1245
|
+
const suppliedConfig = parseGenerationConfig(opts.config);
|
|
1246
|
+
const suppliedResponseFormat = suppliedConfig.responseFormat;
|
|
1247
|
+
const suppliedImageConfig = suppliedResponseFormat !== null && typeof suppliedResponseFormat === "object" && !Array.isArray(suppliedResponseFormat) ? suppliedResponseFormat.image : void 0;
|
|
1248
|
+
const imageConfig = {
|
|
1249
|
+
...suppliedImageConfig !== null && typeof suppliedImageConfig === "object" && !Array.isArray(suppliedImageConfig) ? suppliedImageConfig : {},
|
|
1250
|
+
...opts.aspectRatio ? { aspectRatio: opts.aspectRatio } : {},
|
|
1251
|
+
...opts.imageSize ? { imageSize: opts.imageSize.toUpperCase() } : {}
|
|
1252
|
+
};
|
|
1253
|
+
const generationConfig = {
|
|
1254
|
+
...suppliedConfig,
|
|
1255
|
+
candidateCount: 1,
|
|
1256
|
+
responseFormat: { image: imageConfig },
|
|
1257
|
+
...opts.seed !== void 0 ? { seed: opts.seed } : {},
|
|
1258
|
+
...opts.thinkingLevel ? { thinkingConfig: { ...suppliedConfig.thinkingConfig ?? {}, thinkingLevel: opts.thinkingLevel.toUpperCase() } } : {},
|
|
1259
|
+
...opts.temperature !== void 0 ? { temperature: opts.temperature } : {},
|
|
1260
|
+
...opts.topP !== void 0 ? { topP: opts.topP } : {}
|
|
1261
|
+
};
|
|
1262
|
+
const res = await withProgress("\u6B63\u5728\u751F\u6210 Gemini \u56FE\u50CF", () => request({
|
|
1263
|
+
baseUrl: auth.baseUrl,
|
|
1264
|
+
path: `/v1beta/models/${encodeURIComponent(opts.model)}:generateContent`,
|
|
1265
|
+
apiKey: auth.apiKey,
|
|
1266
|
+
body: {
|
|
1267
|
+
contents: [{ role: "user", parts: [{ text: promptParts.join(" ") }, ...(opts.image ?? []).map(geminiImagePart)] }],
|
|
1268
|
+
...opts.system ? { systemInstruction: { parts: [{ text: opts.system }] } } : {},
|
|
1269
|
+
generationConfig
|
|
1270
|
+
},
|
|
1271
|
+
timeoutMs: 6e5
|
|
1272
|
+
}));
|
|
1273
|
+
const items = extractGeminiImageItems(res);
|
|
1274
|
+
if (items.length === 0) {
|
|
1275
|
+
throw new ApiError("bad_response", "Gemini \u56FE\u50CF\u54CD\u5E94\u4E2D\u672A\u627E\u5230 inlineData \u56FE\u50CF\u3002", { body: res });
|
|
1276
|
+
}
|
|
1277
|
+
const dir = resolve2(opts.out);
|
|
1278
|
+
await mkdir2(dir, { recursive: true });
|
|
1279
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1280
|
+
const files = [];
|
|
1281
|
+
for (const [i, item] of items.entries()) {
|
|
1282
|
+
files.push(await saveImageItem(item, dir, `gemini-image-${ts}-${i + 1}`, auth.apiKey));
|
|
1283
|
+
}
|
|
1284
|
+
if (g.json) {
|
|
1285
|
+
printJson({ files, count: files.length });
|
|
1286
|
+
} else {
|
|
1287
|
+
for (const file of files) info(`\u2713 ${file}`);
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u4EFB\u52A1\u5236\uFF1A\u9ED8\u8BA4\u8F6E\u8BE2\u81F3\u5B8C\u6210\u5E76\u4E0B\u8F7D\uFF1B--no-wait \u53EA\u53D6 task_id\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF08focalapi models list \u67E5\u770B\uFF09").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF081\u20133600\uFF09", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--resolution <resolution>", "\u539F\u751F\u8F93\u51FA\u5206\u8FA8\u7387\uFF0C\u5982 480p\u3001720p\u30011080p\u30014k").option("--ratio <ratio>", "\u539F\u751F\u5BBD\u9AD8\u6BD4\uFF0C\u5982 16:9\u30019:16\u3001adaptive").option("--image <url...>", "\u56FE\u751F\u89C6\u9891\u7684\u6E90\u56FE\u50CF URL\uFF0C\u53EF\u591A\u4E2A").option("--generate-audio <boolean>", "\u662F\u5426\u751F\u6210\u97F3\u9891\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "generate-audio")).option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--service-tier <tier>", "\u670D\u52A1\u5C42\u7EA7\uFF08Seedance 2.0 \u9ED8\u8BA4 default\uFF09").option("--priority <n>", "\u4EFB\u52A1\u4F18\u5148\u7EA7\uFF08\u4EC5 Seedance 2.0 \u6807\u51C6\u7248\uFF09", (v) => Number.parseInt(v, 10)).option("--callback-url <url>", "\u4EFB\u52A1\u5B8C\u6210\u56DE\u8C03 URL").option("--return-last-frame <boolean>", "\u662F\u5426\u8FD4\u56DE\u6700\u540E\u4E00\u5E27\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "return-last-frame")).option("--execution-expires-after <seconds>", "\u4EFB\u52A1\u8FC7\u671F\u79D2\u6570\uFF083600\u2013259200\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-identifier <identifier>", "Seedance \u5B89\u5168\u6807\u8BC6\u7B26\uFF081\u201364 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--content <json>", "Ark-compatible content JSON array; overrides prompt/image facade fields").action(
|
|
900
1291
|
async (promptParts, opts, cmd) => {
|
|
901
1292
|
const g = cmd.optsWithGlobals();
|
|
902
1293
|
const auth = resolveAuth(g);
|
|
903
1294
|
const body = { model: opts.model, prompt: promptParts.join(" ") };
|
|
1295
|
+
const metadata = {};
|
|
904
1296
|
if (opts.seconds !== void 0) {
|
|
905
|
-
|
|
1297
|
+
const seconds = clampInt(opts.seconds, 1, MAX_TASK_DURATION_SECONDS, "seconds");
|
|
1298
|
+
body.duration = seconds;
|
|
906
1299
|
}
|
|
907
1300
|
if (opts.size) body.size = opts.size;
|
|
908
|
-
|
|
1301
|
+
if (opts.image) body.images = opts.image;
|
|
1302
|
+
if (opts.resolution) metadata.resolution = opts.resolution.toLowerCase();
|
|
1303
|
+
if (opts.ratio) metadata.ratio = opts.ratio;
|
|
1304
|
+
if (opts.generateAudio !== void 0) metadata.generate_audio = opts.generateAudio;
|
|
1305
|
+
if (opts.watermark !== void 0) metadata.watermark = opts.watermark;
|
|
1306
|
+
if (opts.serviceTier) metadata.service_tier = opts.serviceTier;
|
|
1307
|
+
if (opts.priority !== void 0) metadata.priority = opts.priority;
|
|
1308
|
+
if (opts.callbackUrl) metadata.callback_url = opts.callbackUrl;
|
|
1309
|
+
if (opts.returnLastFrame !== void 0) metadata.return_last_frame = opts.returnLastFrame;
|
|
1310
|
+
if (opts.executionExpiresAfter !== void 0) metadata.execution_expires_after = opts.executionExpiresAfter;
|
|
1311
|
+
if (opts.safetyIdentifier) metadata.safety_identifier = opts.safetyIdentifier;
|
|
1312
|
+
if (opts.content) metadata.content = parseJsonArray(opts.content, "content");
|
|
1313
|
+
validateVideoGeneration(opts.model, {
|
|
1314
|
+
seconds: opts.seconds,
|
|
1315
|
+
resolution: opts.resolution,
|
|
1316
|
+
ratio: opts.ratio,
|
|
1317
|
+
serviceTier: opts.serviceTier,
|
|
1318
|
+
priority: opts.priority,
|
|
1319
|
+
executionExpiresAfter: opts.executionExpiresAfter,
|
|
1320
|
+
safetyIdentifier: opts.safetyIdentifier
|
|
1321
|
+
});
|
|
1322
|
+
if (Object.keys(metadata).length > 0) body.metadata = metadata;
|
|
1323
|
+
const created = await withProgress("\u6B63\u5728\u63D0\u4EA4\u89C6\u9891\u4EFB\u52A1", () => request({
|
|
909
1324
|
baseUrl: auth.baseUrl,
|
|
910
1325
|
path: "/v1/video/generations",
|
|
911
1326
|
apiKey: auth.apiKey,
|
|
912
1327
|
body,
|
|
913
1328
|
timeoutMs: 12e4
|
|
914
|
-
});
|
|
1329
|
+
}));
|
|
915
1330
|
const taskId = extractTaskId(created);
|
|
916
1331
|
if (!taskId) {
|
|
917
1332
|
throw new ApiError("bad_response", "\u89C6\u9891\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: created });
|
|
@@ -980,63 +1395,6 @@ function registerTask(program) {
|
|
|
980
1395
|
});
|
|
981
1396
|
}
|
|
982
1397
|
|
|
983
|
-
// src/commands/search.ts
|
|
984
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
985
|
-
function extractResults(raw) {
|
|
986
|
-
if (!raw || typeof raw !== "object") return [];
|
|
987
|
-
const obj = raw;
|
|
988
|
-
const candidates = [obj.results, obj.data, obj.items, obj.output];
|
|
989
|
-
for (const c of candidates) {
|
|
990
|
-
if (!Array.isArray(c)) continue;
|
|
991
|
-
const out = [];
|
|
992
|
-
for (const item of c) {
|
|
993
|
-
if (!item || typeof item !== "object") continue;
|
|
994
|
-
const it = item;
|
|
995
|
-
const url = typeof it.url === "string" ? it.url : typeof it.link === "string" ? it.link : "";
|
|
996
|
-
const title = typeof it.title === "string" ? it.title : url;
|
|
997
|
-
const snippet = typeof it.snippet === "string" ? it.snippet : typeof it.content === "string" ? it.content.slice(0, 200) : void 0;
|
|
998
|
-
if (url || title) out.push({ title, url, ...snippet ? { snippet } : {} });
|
|
999
|
-
}
|
|
1000
|
-
if (out.length > 0) return out;
|
|
1001
|
-
}
|
|
1002
|
-
return [];
|
|
1003
|
-
}
|
|
1004
|
-
function registerSearch(program) {
|
|
1005
|
-
program.command("search").description("\u8054\u7F51\u641C\u7D22\uFF08/v1/alpha/search\uFF0Calpha \u7EA7\u63A5\u53E3\uFF09").argument("<query...>", "\u641C\u7D22\u5185\u5BB9").requiredOption("-m, --model <model>", "\u641C\u7D22\u6A21\u578B ID\uFF08focalapi models list --filter search \u67E5\u770B\uFF09").option("--raw <json|@file>", "\u5B8C\u6574\u81EA\u5B9A\u4E49\u8BF7\u6C42\u4F53\uFF08JSON \u5B57\u7B26\u4E32\u6216 @\u6587\u4EF6\uFF09\uFF0C\u4E0E query \u5408\u5E76").action(async (queryParts, opts, cmd) => {
|
|
1006
|
-
const g = cmd.optsWithGlobals();
|
|
1007
|
-
const auth = resolveAuth(g);
|
|
1008
|
-
let body = { model: opts.model, query: queryParts.join(" ") };
|
|
1009
|
-
if (opts.raw) {
|
|
1010
|
-
const text = opts.raw.startsWith("@") ? readFileSync3(opts.raw.slice(1), "utf-8") : opts.raw;
|
|
1011
|
-
try {
|
|
1012
|
-
body = { ...JSON.parse(text), model: opts.model };
|
|
1013
|
-
} catch {
|
|
1014
|
-
throw new ApiError("invalid_request", "--raw \u4E0D\u662F\u5408\u6CD5 JSON");
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
const res = await request({
|
|
1018
|
-
baseUrl: auth.baseUrl,
|
|
1019
|
-
path: "/v1/alpha/search",
|
|
1020
|
-
apiKey: auth.apiKey,
|
|
1021
|
-
body,
|
|
1022
|
-
timeoutMs: 12e4
|
|
1023
|
-
});
|
|
1024
|
-
if (g.json) {
|
|
1025
|
-
printJson(res);
|
|
1026
|
-
return;
|
|
1027
|
-
}
|
|
1028
|
-
const results = extractResults(res);
|
|
1029
|
-
if (results.length === 0) {
|
|
1030
|
-
printJson(res);
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
printTable(
|
|
1034
|
-
["#", "\u6807\u9898", "\u94FE\u63A5"],
|
|
1035
|
-
results.map((r, i) => [String(i + 1), r.title.slice(0, 60), r.url])
|
|
1036
|
-
);
|
|
1037
|
-
});
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
1398
|
// src/commands/audio.ts
|
|
1041
1399
|
import { writeFile as writeFile2 } from "fs/promises";
|
|
1042
1400
|
import { resolve as resolve3 } from "path";
|
|
@@ -1087,77 +1445,15 @@ function registerAudio(program) {
|
|
|
1087
1445
|
});
|
|
1088
1446
|
}
|
|
1089
1447
|
|
|
1090
|
-
// src/commands/embed.ts
|
|
1091
|
-
function registerEmbed(program) {
|
|
1092
|
-
program.command("embed").description("\u6587\u672C\u5411\u91CF\u5316\uFF08/v1/embeddings\uFF09").argument("[text...]", "\u6587\u672C\uFF1B\u7701\u7565\u4E14 stdin \u4E3A\u7BA1\u9053\u65F6\u4ECE stdin \u8BFB\u53D6").requiredOption("-m, --model <model>", "\u5411\u91CF\u6A21\u578B ID").option("--input <file>", "\u4ECE\u6587\u4EF6\u8BFB\u53D6\u6587\u672C\uFF08@ \u524D\u7F00\u53EF\u9009\uFF09").action(async (textParts, opts, cmd) => {
|
|
1093
|
-
const g = cmd.optsWithGlobals();
|
|
1094
|
-
const auth = resolveAuth(g);
|
|
1095
|
-
let text = textParts.join(" ").trim();
|
|
1096
|
-
if (opts.input) {
|
|
1097
|
-
text = readInputFile(opts.input.replace(/^@/, "")).data.toString("utf-8");
|
|
1098
|
-
} else if (!text && !process.stdin.isTTY) {
|
|
1099
|
-
text = await readStdin();
|
|
1100
|
-
}
|
|
1101
|
-
if (!text) {
|
|
1102
|
-
throw new ApiError("invalid_request", "\u7F3A\u5C11\u5F85\u5411\u91CF\u5316\u6587\u672C", {
|
|
1103
|
-
hint: 'focalapi embed "\u6587\u672C" -m <model>\uFF0C\u6216 focalapi embed -m <model> --input @file.txt\u3002'
|
|
1104
|
-
});
|
|
1105
|
-
}
|
|
1106
|
-
const res = await request({
|
|
1107
|
-
baseUrl: auth.baseUrl,
|
|
1108
|
-
path: "/v1/embeddings",
|
|
1109
|
-
apiKey: auth.apiKey,
|
|
1110
|
-
body: { model: opts.model, input: text },
|
|
1111
|
-
timeoutMs: 12e4
|
|
1112
|
-
});
|
|
1113
|
-
if (g.json) {
|
|
1114
|
-
printJson(res);
|
|
1115
|
-
} else {
|
|
1116
|
-
const vec = res.data?.[0]?.embedding ?? [];
|
|
1117
|
-
info(`\u7EF4\u5EA6\uFF1A${vec.length}\uFF1Btokens\uFF1A${res.usage?.total_tokens ?? "-"}`);
|
|
1118
|
-
info("\u5B8C\u6574\u5411\u91CF\u8BF7\u7528 --json \u8F93\u51FA\u3002");
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// src/commands/rerank.ts
|
|
1124
|
-
import { readFileSync as readFileSync4 } from "fs";
|
|
1125
|
-
function registerRerank(program) {
|
|
1126
|
-
program.command("rerank").description("\u6309\u67E5\u8BE2\u5BF9\u6587\u6863\u91CD\u6392\u5E8F\uFF08/v1/rerank\uFF09").requiredOption("-m, --model <model>", "rerank \u6A21\u578B ID").requiredOption("--query <text>", "\u67E5\u8BE2").requiredOption("--docs <json|@file>", "\u6587\u6863\u6570\u7EC4\uFF08JSON \u5B57\u7B26\u4E32\u6216 @file.json\uFF09").option("--top-n <n>", "\u53EA\u8FD4\u56DE\u524D N \u6761", (v) => Number.parseInt(v, 10)).action(async (opts, cmd) => {
|
|
1127
|
-
const g = cmd.optsWithGlobals();
|
|
1128
|
-
const auth = resolveAuth(g);
|
|
1129
|
-
const text = opts.docs.startsWith("@") ? readFileSync4(opts.docs.slice(1), "utf-8") : opts.docs;
|
|
1130
|
-
let documents;
|
|
1131
|
-
try {
|
|
1132
|
-
documents = JSON.parse(text);
|
|
1133
|
-
} catch {
|
|
1134
|
-
throw new ApiError("invalid_request", "--docs \u4E0D\u662F\u5408\u6CD5 JSON \u6570\u7EC4");
|
|
1135
|
-
}
|
|
1136
|
-
if (!Array.isArray(documents) || documents.length === 0) {
|
|
1137
|
-
throw new ApiError("invalid_request", "--docs \u5FC5\u987B\u662F\u975E\u7A7A JSON \u6570\u7EC4");
|
|
1138
|
-
}
|
|
1139
|
-
const body = { model: opts.model, query: opts.query, documents };
|
|
1140
|
-
if (opts.topN !== void 0) body.top_n = opts.topN;
|
|
1141
|
-
const res = await request({
|
|
1142
|
-
baseUrl: auth.baseUrl,
|
|
1143
|
-
path: "/v1/rerank",
|
|
1144
|
-
apiKey: auth.apiKey,
|
|
1145
|
-
body,
|
|
1146
|
-
timeoutMs: 12e4
|
|
1147
|
-
});
|
|
1148
|
-
if (g.json) {
|
|
1149
|
-
printJson(res);
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
const rows = (res.results ?? []).map((r) => {
|
|
1153
|
-
const doc = typeof r.document === "string" ? r.document : r.document?.text ?? "";
|
|
1154
|
-
return [String(r.index ?? "-"), String(r.relevance_score ?? "-"), doc.slice(0, 60)];
|
|
1155
|
-
});
|
|
1156
|
-
printTable(["\u539F\u6587\u6863\u5E8F\u53F7", "\u76F8\u5173\u5EA6", "\u6587\u6863\u9884\u89C8"], rows);
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
1448
|
// src/commands/usage.ts
|
|
1449
|
+
function formatBillingUsage(billing) {
|
|
1450
|
+
const value = billing.total_usage;
|
|
1451
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1452
|
+
return value.toLocaleString("zh-CN", { maximumFractionDigits: 4 });
|
|
1453
|
+
}
|
|
1454
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
1455
|
+
return "-";
|
|
1456
|
+
}
|
|
1161
1457
|
function defaultStartDate() {
|
|
1162
1458
|
const now = /* @__PURE__ */ new Date();
|
|
1163
1459
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
|
@@ -1191,14 +1487,15 @@ function registerUsage(program) {
|
|
|
1191
1487
|
["\u5DF2\u7528", String(token.total_used)],
|
|
1192
1488
|
["\u5269\u4F59", token.unlimited_quota ? "\u65E0\u9650" : String(token.total_available)],
|
|
1193
1489
|
["\u8FC7\u671F\u65F6\u95F4", token.expires_at > 0 ? new Date(token.expires_at * 1e3).toLocaleString() : "\u6C38\u4E0D\u8FC7\u671F"],
|
|
1194
|
-
[`\u5468\u671F\u7528\u91CF\uFF08${start} ~ ${end}\uFF09`,
|
|
1490
|
+
[`\u5468\u671F\u7528\u91CF\uFF08${start} ~ ${end}\uFF09`, formatBillingUsage(billing)],
|
|
1491
|
+
["\u8D26\u5355\u5BF9\u8C61", typeof billing.object === "string" ? billing.object : "-"]
|
|
1195
1492
|
]
|
|
1196
1493
|
);
|
|
1197
1494
|
});
|
|
1198
1495
|
}
|
|
1199
1496
|
|
|
1200
1497
|
// src/commands/connect.ts
|
|
1201
|
-
import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as
|
|
1498
|
+
import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1202
1499
|
import { homedir as homedir2, platform } from "os";
|
|
1203
1500
|
import { dirname, join as join4 } from "path";
|
|
1204
1501
|
import { fileURLToPath } from "url";
|
|
@@ -1290,7 +1587,7 @@ function readManifest(skillsDir) {
|
|
|
1290
1587
|
const path = manifestPath(skillsDir);
|
|
1291
1588
|
if (!existsSync2(path)) return void 0;
|
|
1292
1589
|
try {
|
|
1293
|
-
return JSON.parse(
|
|
1590
|
+
return JSON.parse(readFileSync3(path, "utf-8"));
|
|
1294
1591
|
} catch {
|
|
1295
1592
|
return void 0;
|
|
1296
1593
|
}
|
|
@@ -1513,16 +1810,13 @@ function registerRequest(program) {
|
|
|
1513
1810
|
// src/cli.ts
|
|
1514
1811
|
function buildProgram() {
|
|
1515
1812
|
const program = new Command();
|
|
1516
|
-
program.name("focalapi").description("\
|
|
1813
|
+
program.name("focalapi").description("\u9762\u5411\u521B\u4F5C\u5DE5\u4F5C\u6D41\u7684 focalapi CLI\uFF1A\u56FE\u50CF\u3001\u89C6\u9891\u3001\u97F3\u9891\u4E0E DeepSeek \u5907\u7528\u6587\u672C\u80FD\u529B\uFF0C\u4E00\u6761\u547D\u4EE4\u76F4\u8FBE").version(VERSION, "-v, --version", "\u663E\u793A\u7248\u672C\u53F7").option("--json", "\u4EE5 JSON \u8F93\u51FA\uFF08\u9762\u5411 Agent \u4E0E\u811A\u672C\uFF0Cstdout \u7EAF\u51C0\uFF09").option("--base-url <url>", "\u8986\u76D6 API \u5730\u5740\uFF08\u9ED8\u8BA4 https://api.focalapi.com\uFF0C\u53EF\u7528 FOCALAPI_BASE_URL\uFF09").option("--key <key>", "\u8986\u76D6 API Key\uFF08\u53EF\u7528 FOCALAPI_API_KEY\uFF09").option("--profile <name>", "\u4F7F\u7528\u6307\u5B9A\u914D\u7F6E\u6863\u6848");
|
|
1517
1814
|
registerAuth(program);
|
|
1518
1815
|
registerModels(program);
|
|
1519
1816
|
registerChat(program);
|
|
1520
1817
|
registerGen(program);
|
|
1521
1818
|
registerTask(program);
|
|
1522
|
-
registerSearch(program);
|
|
1523
1819
|
registerAudio(program);
|
|
1524
|
-
registerEmbed(program);
|
|
1525
|
-
registerRerank(program);
|
|
1526
1820
|
registerUsage(program);
|
|
1527
1821
|
registerDoctor(program);
|
|
1528
1822
|
registerConnect(program);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "focalapi-cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "连接 focalapi
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "连接 focalapi 创作模型与 AI Agent 的命令行工具——图像、视频、音频与 DeepSeek 备用文本能力",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"focalapi": "dist/cli.js"
|
package/skills/focalapi/SKILL.md
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: focalapi
|
|
3
|
-
description: focalapi
|
|
3
|
+
description: focalapi 创作能力总入口。Use when 用户需要生成或处理图像、视频、音频、3D 或其他视觉创作内容;DeepSeek 仅用于提示词、分镜和轻量文本辅助。先读本技能选择对应的子技能或命令。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# focalapi
|
|
6
|
+
# focalapi 创作能力总览
|
|
7
7
|
|
|
8
|
-
focalapi
|
|
8
|
+
focalapi 是面向创作工作流的模型中转服务。`focalapi` CLI 让 Agent 用命令完成图像、视频、音频与其他视觉创作任务;通用文本只将 DeepSeek 作为备用能力。
|
|
9
9
|
|
|
10
10
|
## 前置检查
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
focalapi --version
|
|
14
|
-
focalapi auth status
|
|
15
|
-
focalapi
|
|
13
|
+
focalapi --version
|
|
14
|
+
focalapi auth status
|
|
15
|
+
focalapi models list --json
|
|
16
|
+
# 选定创作模型后,读取支持参数、端点与默认值
|
|
17
|
+
focalapi models get <模型ID> --json
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
模型、参数、并发和价格始终以 `focalapi models list --json` 与控制台模型广场的实际结果为准。命令失败时,先执行 `focalapi doctor` 排查 Key、网络与额度。
|
|
19
21
|
|
|
20
22
|
## 能力 → 命令速查
|
|
21
23
|
|
|
22
24
|
| 需求 | 命令 | 子技能 |
|
|
23
25
|
|---|---|---|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
+
| 生成或编辑图像 | `focalapi gen image`(可 `--no-wait` 提交持久任务) | focalapi-gen |
|
|
27
|
+
| 生成 Gemini 原生图像 | `focalapi gen gemini-image` | focalapi-gen |
|
|
26
28
|
| 生成视频 | `focalapi gen video`(任务制,可 `--no-wait`) | focalapi-gen |
|
|
27
|
-
|
|
|
29
|
+
| 查询任务或下载视频产物 | `focalapi task status` / `focalapi task download` | focalapi-gen |
|
|
28
30
|
| 语音转文字 | `focalapi audio transcribe` | focalapi-chat |
|
|
29
31
|
| 文字转语音 | `focalapi audio speech` | focalapi-chat |
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
5. **非交互**:本 CLI 在非 TTY 环境全自动(无提示无动画),认证用环境变量 `FOCALAPI_API_KEY` 或已保存的配置。
|
|
43
|
-
6. **原始请求**:优先用语义化命令;只有读取尚未封装的端点时才用 `request get` / `request head`。它只允许站内路径和 GET/HEAD,绝不用于写入操作。
|
|
32
|
+
| 提示词、分镜或脚本辅助 | `focalapi chat -m <DeepSeek 模型>` | focalapi-chat |
|
|
33
|
+
| 额度与诊断 | `focalapi usage` / `focalapi doctor` | focalapi-usage |
|
|
34
|
+
| 查询可用模型 | `focalapi models list --json` | focalapi |
|
|
35
|
+
|
|
36
|
+
## 使用约定
|
|
37
|
+
|
|
38
|
+
1. **创作优先**:图像、视频、音频和视觉模型是默认选择。3D 或其他视觉模型是否可用,以模型列表为准。
|
|
39
|
+
2. **文本边界**:仅在提示词、分镜、旁白草稿或轻量文本任务中使用 DeepSeek;不要假设其他文本、编码或聊天模型可用。
|
|
40
|
+
3. **机器可读输出**:命令加 `--json` 时,stdout 只输出 JSON。
|
|
41
|
+
4. **先查模型再调用**:不能猜测模型 ID;先运行 `focalapi models list --json`,选定模型后运行 `focalapi models get <模型ID> --json`,以 `supported_endpoint_types` 和 `supported_params` 为准。
|
|
42
|
+
5. **产物路径**:生成类命令默认写入 `./focalapi-out/`,完成后向用户报告绝对路径。
|
|
43
|
+
6. **原始只读请求**:优先使用语义化命令;只有读取尚未封装的端点时才使用 `request get` / `request head`。
|
|
@@ -1,51 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: focalapi-chat
|
|
3
|
-
description: 用
|
|
3
|
+
description: 用 DeepSeek 作为创作流程的文本备用能力,并通过 focalapi 处理音频转写和语音合成。Use when 用户需要提示词、分镜、脚本草稿、看图辅助、音频转写或文字转语音。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# focalapi
|
|
6
|
+
# focalapi 文本辅助与音频
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## DeepSeek 文本辅助
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
# 基础(模型名先从 focalapi models list 获取;演练用免费 focal-rehearsal-chat)
|
|
12
|
-
focalapi chat "用一句话总结 RAG" -m <model>
|
|
13
|
-
|
|
14
|
-
# 管道输入(处理长文本/文件内容)
|
|
15
|
-
cat report.md | focalapi chat -m <model> --system "你是严谨的技术编辑"
|
|
16
|
-
|
|
17
|
-
# 看图(多模态)
|
|
18
|
-
focalapi chat "描述这张图的 UI 布局" -m <多模态模型> --input @screenshot.png
|
|
19
|
-
|
|
20
|
-
# 机器可读输出(Agent 串联)
|
|
21
|
-
focalapi chat "提取关键日期" -m <model> --json
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
- `--stream` / `--no-stream` 控制流式(TTY 默认流式,`--json` 默认非流式)。
|
|
25
|
-
- `--input` 支持多张图片;txt/md/json 文件会作为文本拼入。
|
|
26
|
-
- 默认模型可用 `FOCALAPI_MODEL` 环境变量固定。
|
|
27
|
-
|
|
28
|
-
## 向量化
|
|
10
|
+
DeepSeek 是 focalapi 唯一对外展示的通用文本备用能力。先查询可用模型,再用于提示词、分镜、旁白与轻量脚本任务:
|
|
29
11
|
|
|
30
12
|
```bash
|
|
31
|
-
focalapi
|
|
32
|
-
focalapi
|
|
13
|
+
focalapi models list --json
|
|
14
|
+
focalapi chat "把这个产品简介写成 6 镜头分镜" -m <DeepSeek模型>
|
|
33
15
|
```
|
|
34
16
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
focalapi rerank -m <rerank模型> --query "用户问题" --docs @docs.json --json
|
|
39
|
-
# docs.json 是字符串数组
|
|
40
|
-
```
|
|
17
|
+
- 不要猜测模型 ID,也不要假设其他文本或编码模型可用。
|
|
18
|
+
- 需要结构化输出时使用 `--json`;长结果可加 `--stream`。
|
|
19
|
+
- `--input @image.png` 可把图片作为辅助输入;图像生成请改用 `focalapi gen image`。
|
|
41
20
|
|
|
42
21
|
## 音频
|
|
43
22
|
|
|
44
23
|
```bash
|
|
45
|
-
focalapi audio transcribe
|
|
46
|
-
focalapi audio speech "
|
|
24
|
+
focalapi audio transcribe interview.mp3 -m <转写模型>
|
|
25
|
+
focalapi audio speech "这里是一段旁白" -m <语音模型> -o narration.mp3
|
|
47
26
|
```
|
|
48
27
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
失败先 `focalapi doctor`;报 model_not_found 时 `focalapi models list --filter <关键字>` 确认模型名。
|
|
28
|
+
- 音频模型、可用音色和格式以 `focalapi models list --json` 为准。
|
|
29
|
+
- 批量任务前先用短样本确认质量和单价。
|
|
@@ -7,9 +7,29 @@ description: 用 focalapi 生成图片和视频(产物自动下载到本地)
|
|
|
7
7
|
|
|
8
8
|
## 图片(同步)
|
|
9
9
|
|
|
10
|
+
调用创作模型前,先读取它的机器可读契约;不要猜测尺寸、时长或原生端点:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
focalapi models get <模型ID> --json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
响应中的 `supported_endpoint_types` 指明应使用的 API 协议,`supported_params` 列出参数、默认值、枚举和边界。CLI 会提前拒绝已知无效参数;应修正参数,不要将同一参数重试给上游。
|
|
17
|
+
|
|
10
18
|
```bash
|
|
11
19
|
focalapi gen image "未来城市夜景海报,赛博朋克风" -m <图像模型> --size 1024x1024 -o ./out
|
|
12
20
|
# 多张:--n 4(上限 128)
|
|
21
|
+
# 参考图/编辑:image 可重复给 URL;response-format 只接受 url 或 b64_json
|
|
22
|
+
focalapi gen image "将背景改为雨夜" -m gpt-image-2 --image https://example.com/source.png \
|
|
23
|
+
--mask https://example.com/mask.png --response-format b64_json -o ./out
|
|
24
|
+
# 长任务编排:--no-wait 要求服务端持久化任务,返回 task_id;图像结果从 task status 的 raw.data 读取 URL
|
|
25
|
+
focalapi gen image "未来城市夜景海报,赛博朋克风" -m gpt-image-2 --size 1024x1024 --no-wait --json
|
|
26
|
+
focalapi task status <task_id> --json
|
|
27
|
+
|
|
28
|
+
# Gemini 图像模型必须走原生 Gemini generateContent 端点,不能使用 gen image
|
|
29
|
+
focalapi gen gemini-image "未来城市夜景海报,赛博朋克风" -m gemini-3.1-flash-image-preview \
|
|
30
|
+
--aspect-ratio 16:9 --image-size 2K -o ./out
|
|
31
|
+
# Gemini named flags cover --image, --system, --seed, --thinking-level, --temperature, and --top-p.
|
|
32
|
+
# For another documented generationConfig field, use --config; the command keeps responseFormat.image and candidateCount=1 authoritative.
|
|
13
33
|
```
|
|
14
34
|
|
|
15
35
|
产物保存到 `-o` 目录(默认 `./focalapi-out/`),stdout/stderr 会打印绝对路径,把路径直接交给用户或后续步骤。
|
|
@@ -20,6 +40,20 @@ focalapi gen image "未来城市夜景海报,赛博朋克风" -m <图像模型
|
|
|
20
40
|
# 方式一:前台等待完成并自动下载(默认)
|
|
21
41
|
focalapi gen video "海浪拍打礁石,电影感" -m <视频模型> --seconds 5 -o ./out
|
|
22
42
|
|
|
43
|
+
# Seedance 2.0:使用模型原生参数。Fast/Mini 只支持 480p 和 720p;所有 Seedance 2.0 时长为 4–15 秒。
|
|
44
|
+
focalapi gen video "海浪拍打礁石,电影感" -m doubao-seedance-2-0-260128 \
|
|
45
|
+
--seconds 5 --resolution 720p --ratio 16:9 --generate-audio true --no-wait --json
|
|
46
|
+
|
|
47
|
+
# 图生视频与 Seedance 的任务控制参数会映射到请求 metadata;布尔参数必须显式为 true 或 false
|
|
48
|
+
focalapi gen video "让海浪缓慢推进" -m doubao-seedance-2-0-260128 \
|
|
49
|
+
--image https://example.com/frame.png --generate-audio false --watermark true \
|
|
50
|
+
--return-last-frame true --callback-url https://example.com/callback \
|
|
51
|
+
--execution-expires-after 7200 --safety-identifier customer-42 --priority 4 --no-wait --json
|
|
52
|
+
|
|
53
|
+
# Ark-compatible metadata.content keeps native text/image_url/video_url/audio_url items and roles unchanged:
|
|
54
|
+
focalapi gen video "ignored when content is supplied" -m doubao-seedance-2-0-260128 \
|
|
55
|
+
--content '[{"type":"text","text":"A cinematic ocean wave."}]' --no-wait --json
|
|
56
|
+
|
|
23
57
|
# 方式二:异步——Agent 做长任务编排时推荐
|
|
24
58
|
focalapi gen video "..." -m <视频模型> --no-wait --json # 立即拿 task_id
|
|
25
59
|
focalapi task status <task_id> --json # 轮询状态
|
|
@@ -33,6 +67,8 @@ focalapi task download <task_id> -o ./out # 完成后取 mp4
|
|
|
33
67
|
## JSON 输出约定(供 Agent 解析)
|
|
34
68
|
|
|
35
69
|
- `gen image --json` → `{"files": ["..."], "count": N}`
|
|
70
|
+
- `gen image --no-wait --json` → `{"task_id": "...", "status": "queued", "submitted": true}`
|
|
71
|
+
- `gen gemini-image --json` → `{"files": ["..."], "count": N}`
|
|
36
72
|
- `gen video --no-wait --json` → `{"task_id": "...", "submitted": true}`
|
|
37
73
|
- `gen video --json`(等待模式)→ `{"task_id": "...", "status": "success", "file": "..."}`
|
|
38
74
|
- `task status --json` → `{"task_id","status":"pending|running|success|failed|unknown","progress","raw"}`
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: focalapi-search
|
|
3
|
-
description: 用 focalapi 联网搜索。Use when 需要实时信息、新闻、资料检索、事实核查,或用户明确要求"搜一下/查一下"。
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# focalapi 联网搜索
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
focalapi search "过去7天 AI 行业重要新闻" -m <搜索模型> --json
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
- 端点为 `/v1/alpha/search`(alpha 级,响应结构由上游定义);`--json` 原样透传,pretty 模式尽力解析为结果表格。
|
|
13
|
-
- 搜索模型名先确认:`focalapi models list --filter search`。
|
|
14
|
-
|
|
15
|
-
## 自定义请求体(逃生门)
|
|
16
|
-
|
|
17
|
-
上游格式变更或需要高级参数时,用 `--raw` 直接控制请求体:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
focalapi search "查询" -m <model> --raw '{"query":"...","search_depth":"advanced"}' --json
|
|
21
|
-
focalapi search x -m <model> --raw @body.json
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## 典型编排
|
|
25
|
-
|
|
26
|
-
搜索 → 把 `--json` 结果交给 `focalapi chat` 做摘要/分析:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
focalapi search "主题" -m <搜索模型> --json > results.json
|
|
30
|
-
cat results.json | focalapi chat -m <对话模型> --system "把输入的搜索结果整理成 5 条要点"
|
|
31
|
-
```
|