@uniai-test-1/cli 0.1.0
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/LICENSE +201 -0
- package/README.md +170 -0
- package/dist/cli.mjs +3690 -0
- package/dist/mcp-server.bundle.mjs +26399 -0
- package/install.md +167 -0
- package/package.json +58 -0
- package/skill/SKILL.md +85 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,3690 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/init.ts
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/lib/pat.ts
|
|
7
|
+
import * as readline from "node:readline";
|
|
8
|
+
import { Writable } from "node:stream";
|
|
9
|
+
|
|
10
|
+
// src/lib/messages.ts
|
|
11
|
+
var MSG = {
|
|
12
|
+
// PAT
|
|
13
|
+
patMissingNonTty: "No PAT provided. Pass --token <PAT>, set the UNIAI_TOKEN env var, or run in an interactive terminal.",
|
|
14
|
+
patHowTo: "Generate a PAT in the UniAI web console (log in, then Personal Center -> Security tab -> Personal Access Tokens -> Generate).",
|
|
15
|
+
patInvalidFormat: 'Invalid token format: a UniAI PAT must start with "uap_".',
|
|
16
|
+
patPrompt: "Paste your UniAI PAT (starts with uap_): ",
|
|
17
|
+
// node / env
|
|
18
|
+
nodeUnavailable: (p) => `Could not locate a usable Node.js executable (process.execPath = "${p}").`,
|
|
19
|
+
// config
|
|
20
|
+
configWriteFailed: (p) => `Failed to write config at "${p}"; restored the previous version from backup.`,
|
|
21
|
+
// bundle
|
|
22
|
+
bundleSourceMissing: (p) => `Bundled MCP server not found in the package at "${p}". The @uniai/cli build may be incomplete.`,
|
|
23
|
+
bundleCopyFailed: (p) => `Failed to copy the MCP server bundle to "${p}".`,
|
|
24
|
+
// results
|
|
25
|
+
initDone: "UniAI MCP server installed. Restart Codex CLI to start using the tools.",
|
|
26
|
+
uninstallDone: "UniAI MCP server removed. Your other Codex configuration was left untouched.",
|
|
27
|
+
notInstalled: "UniAI MCP server is not installed.",
|
|
28
|
+
// runtime failures (direct commands)
|
|
29
|
+
noTokenForDirect: "No UniAI token. Run `uniai auth login --token <PAT>`, set UNIAI_TOKEN, or pass --token <PAT>.",
|
|
30
|
+
authFailed: "Authentication failed \u2014 your PAT may be invalid or expired. Generate a new one in the UniAI web console, then run `uniai auth login --token <PAT>`.",
|
|
31
|
+
accessForbidden: "Access forbidden \u2014 your PAT may be missing a required scope for this command (e.g. `read:credits` for `uniai usage`). Review or recreate the token with the needed scopes in the UniAI web console.",
|
|
32
|
+
insufficientCredits: "Out of credits \u2014 top up in the UniAI web console, then retry.",
|
|
33
|
+
networkUnreachable: "Cannot reach the UniAI API. Check your network, or pass --api-base <url> for a custom/on-prem endpoint.",
|
|
34
|
+
requestTimeout: "The request timed out \u2014 the service may be busy. Try again in a moment.",
|
|
35
|
+
diskPermission: "Permission denied writing to disk \u2014 check the target file/directory permissions."
|
|
36
|
+
};
|
|
37
|
+
function humanizeError(raw) {
|
|
38
|
+
const s = raw.toLowerCase();
|
|
39
|
+
if (/(?<![\w-])403(?![\w-])|forbidden/.test(s)) return MSG.accessForbidden;
|
|
40
|
+
if (/(?<![\w-])401(?![\w-])|unauthorized|invalid token|invalid .*pat/.test(s)) return MSG.authFailed;
|
|
41
|
+
if (/(?<![\w-])(402|40201)(?![\w-])|insufficient|not enough credit|余额/.test(s)) return MSG.insufficientCredits;
|
|
42
|
+
if (/econnrefused|enotfound|eai_again|getaddrinfo|fetch failed|network|socket hang up/.test(s))
|
|
43
|
+
return MSG.networkUnreachable;
|
|
44
|
+
if (/timed out|timeout|aborted|operation was aborted/.test(s)) return MSG.requestTimeout;
|
|
45
|
+
if (/eacces|permission denied|eperm/.test(s)) return MSG.diskPermission;
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/lib/pat.ts
|
|
50
|
+
var PAT_PREFIX = "uap_";
|
|
51
|
+
var PAT_FORMAT = /^uap_[A-Za-z0-9_-]{24,}$/;
|
|
52
|
+
function validatePatFormat(token) {
|
|
53
|
+
const t = token.trim();
|
|
54
|
+
if (t.length === 0) return { ok: false, reason: "empty" };
|
|
55
|
+
if (!t.startsWith(PAT_PREFIX)) return { ok: false, reason: "missing-prefix" };
|
|
56
|
+
if (!PAT_FORMAT.test(t)) return { ok: false, reason: "bad-format" };
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
async function resolvePat(opts) {
|
|
60
|
+
const fromFlag = opts.tokenFlag?.trim();
|
|
61
|
+
if (fromFlag) return ensureValid(fromFlag);
|
|
62
|
+
const fromEnv = opts.envToken?.trim();
|
|
63
|
+
if (fromEnv) return ensureValid(fromEnv);
|
|
64
|
+
if (!opts.isTty) throw new Error(MSG.patMissingNonTty);
|
|
65
|
+
const entered = (await opts.promptHidden()).trim();
|
|
66
|
+
return ensureValid(entered);
|
|
67
|
+
}
|
|
68
|
+
function ensureValid(token) {
|
|
69
|
+
if (!validatePatFormat(token).ok) throw new Error(MSG.patInvalidFormat);
|
|
70
|
+
return token;
|
|
71
|
+
}
|
|
72
|
+
function promptHiddenStdin(query) {
|
|
73
|
+
return new Promise((resolve2, reject) => {
|
|
74
|
+
let queryShown = false;
|
|
75
|
+
const muted = new Writable({
|
|
76
|
+
write(chunk, _enc, cb) {
|
|
77
|
+
if (!queryShown && chunk.toString().includes(query)) {
|
|
78
|
+
process.stdout.write(query);
|
|
79
|
+
queryShown = true;
|
|
80
|
+
}
|
|
81
|
+
cb();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const rl = readline.createInterface({
|
|
85
|
+
input: process.stdin,
|
|
86
|
+
output: muted,
|
|
87
|
+
terminal: true
|
|
88
|
+
});
|
|
89
|
+
rl.on("error", reject);
|
|
90
|
+
rl.question(query, (answer) => {
|
|
91
|
+
rl.close();
|
|
92
|
+
process.stdout.write("\n");
|
|
93
|
+
resolve2(answer);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/lib/paths.ts
|
|
99
|
+
import { homedir } from "node:os";
|
|
100
|
+
import { join } from "node:path";
|
|
101
|
+
function codexHome() {
|
|
102
|
+
const override = process.env.CODEX_HOME?.trim();
|
|
103
|
+
return override && override.length > 0 ? override : join(homedir(), ".codex");
|
|
104
|
+
}
|
|
105
|
+
function codexConfigPath() {
|
|
106
|
+
return join(codexHome(), "config.toml");
|
|
107
|
+
}
|
|
108
|
+
function uniaiHome() {
|
|
109
|
+
return join(homedir(), ".uniai");
|
|
110
|
+
}
|
|
111
|
+
function uniaiBundlePath() {
|
|
112
|
+
return join(uniaiHome(), "mcp-server.bundle.mjs");
|
|
113
|
+
}
|
|
114
|
+
function uniaiConfigPath() {
|
|
115
|
+
return join(uniaiHome(), "config.json");
|
|
116
|
+
}
|
|
117
|
+
function agentsSkillsDir() {
|
|
118
|
+
const override = process.env.AGENTS_SKILLS_DIR?.trim();
|
|
119
|
+
return override && override.length > 0 ? override : join(homedir(), ".agents", "skills");
|
|
120
|
+
}
|
|
121
|
+
function projectSkillsDir(cwd = process.cwd()) {
|
|
122
|
+
return join(cwd, ".agents", "skills");
|
|
123
|
+
}
|
|
124
|
+
function resolveSkillsDir(global) {
|
|
125
|
+
return global ? agentsSkillsDir() : projectSkillsDir();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ../codex-shared/src/codex-config.ts
|
|
129
|
+
function tomlEscape(s) {
|
|
130
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
131
|
+
}
|
|
132
|
+
function tomlInlineStringArray(items) {
|
|
133
|
+
return `[${items.map((s) => `"${tomlEscape(s)}"`).join(", ")}]`;
|
|
134
|
+
}
|
|
135
|
+
function tomlInlineTable(record) {
|
|
136
|
+
const keys = Object.keys(record).sort();
|
|
137
|
+
if (keys.length === 0) return "{}";
|
|
138
|
+
const pairs = keys.map((k) => `${k} = "${tomlEscape(record[k])}"`);
|
|
139
|
+
return `{ ${pairs.join(", ")} }`;
|
|
140
|
+
}
|
|
141
|
+
function isSectionHeaderLine(line) {
|
|
142
|
+
return /^\s*\[/.test(line);
|
|
143
|
+
}
|
|
144
|
+
function splitTopLevelBlocks(text) {
|
|
145
|
+
const blocks = [];
|
|
146
|
+
let current = null;
|
|
147
|
+
let floating = [];
|
|
148
|
+
const isCommentOrBlank = (l) => l.trim().length === 0 || l.trim().startsWith("#");
|
|
149
|
+
for (const line of text.split("\n")) {
|
|
150
|
+
if (isSectionHeaderLine(line)) {
|
|
151
|
+
if (current) blocks.push(current);
|
|
152
|
+
current = { header: line.trim(), lines: [...floating, line] };
|
|
153
|
+
floating = [];
|
|
154
|
+
} else if (isCommentOrBlank(line)) {
|
|
155
|
+
floating.push(line);
|
|
156
|
+
} else {
|
|
157
|
+
if (current === null) {
|
|
158
|
+
current = { header: null, lines: [...floating, line] };
|
|
159
|
+
} else {
|
|
160
|
+
current.lines.push(...floating, line);
|
|
161
|
+
}
|
|
162
|
+
floating = [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (current) {
|
|
166
|
+
current.lines.push(...floating);
|
|
167
|
+
blocks.push(current);
|
|
168
|
+
} else if (floating.some((l) => l.trim().length > 0)) {
|
|
169
|
+
blocks.push({ header: null, lines: floating });
|
|
170
|
+
}
|
|
171
|
+
return blocks;
|
|
172
|
+
}
|
|
173
|
+
function extractNativeSections(existing, isManaged, markerLines) {
|
|
174
|
+
const kept = [];
|
|
175
|
+
for (const block of splitTopLevelBlocks(existing)) {
|
|
176
|
+
if (block.header !== null && isManaged(block.header)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const cleaned = block.lines.filter((l) => !markerLines.has(l.trim()));
|
|
180
|
+
let start = 0;
|
|
181
|
+
let end = cleaned.length;
|
|
182
|
+
while (start < end && cleaned[start].trim().length === 0) start++;
|
|
183
|
+
while (end > start && cleaned[end - 1].trim().length === 0) end--;
|
|
184
|
+
if (end > start) {
|
|
185
|
+
kept.push(cleaned.slice(start, end).join("\n"));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return kept.join("\n\n");
|
|
189
|
+
}
|
|
190
|
+
function mergeManagedConfigToml(existing, managedBlock, isManaged, markerLines) {
|
|
191
|
+
const native = existing ? extractNativeSections(existing, isManaged, markerLines) : "";
|
|
192
|
+
const managed = managedBlock.replace(/\s+$/, "");
|
|
193
|
+
if (!managed && !native) return "";
|
|
194
|
+
if (!managed) return `${native}
|
|
195
|
+
`;
|
|
196
|
+
if (!native) return `${managed}
|
|
197
|
+
`;
|
|
198
|
+
return `${managed}
|
|
199
|
+
|
|
200
|
+
${native}
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/lib/config-merge.ts
|
|
205
|
+
var UNIAI_CLI_MARKER = "# === @uniai/cli managed MCP server \u2014 do not edit by hand ===";
|
|
206
|
+
var MANAGED_MARKER_LINES = /* @__PURE__ */ new Set([UNIAI_CLI_MARKER]);
|
|
207
|
+
function isManagedUniaiSection(headerLine) {
|
|
208
|
+
return headerLine.trim() === "[mcp_servers.uniai]";
|
|
209
|
+
}
|
|
210
|
+
function mergeManagedConfigToml2(existing, managedBlock) {
|
|
211
|
+
return mergeManagedConfigToml(
|
|
212
|
+
existing,
|
|
213
|
+
managedBlock,
|
|
214
|
+
isManagedUniaiSection,
|
|
215
|
+
MANAGED_MARKER_LINES
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
function buildUniaiBlock(opts) {
|
|
219
|
+
const envInline = tomlInlineTable({
|
|
220
|
+
UNIAI_API_BASE: opts.uniaiApiBase,
|
|
221
|
+
UNIAI_TOKEN: opts.uniaiToken
|
|
222
|
+
});
|
|
223
|
+
return [
|
|
224
|
+
UNIAI_CLI_MARKER,
|
|
225
|
+
"[mcp_servers.uniai]",
|
|
226
|
+
`command = "${tomlEscape(opts.nodeAbsPath)}"`,
|
|
227
|
+
`args = ${tomlInlineStringArray([opts.bundleAbsPath])}`,
|
|
228
|
+
`env = ${envInline}`,
|
|
229
|
+
"enabled = true",
|
|
230
|
+
"startup_timeout_sec = 30",
|
|
231
|
+
"tool_timeout_sec = 300"
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/lib/install.ts
|
|
236
|
+
import {
|
|
237
|
+
existsSync,
|
|
238
|
+
mkdirSync,
|
|
239
|
+
copyFileSync,
|
|
240
|
+
readFileSync,
|
|
241
|
+
writeFileSync,
|
|
242
|
+
rmSync,
|
|
243
|
+
renameSync
|
|
244
|
+
} from "node:fs";
|
|
245
|
+
import { dirname } from "node:path";
|
|
246
|
+
function readConfigOrNull(configPath) {
|
|
247
|
+
try {
|
|
248
|
+
return readFileSync(configPath, "utf8");
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function installBundle(srcBundle, destBundle) {
|
|
254
|
+
if (!existsSync(srcBundle)) {
|
|
255
|
+
throw new Error(`bundle-source-missing:${srcBundle}`);
|
|
256
|
+
}
|
|
257
|
+
mkdirSync(dirname(destBundle), { recursive: true });
|
|
258
|
+
copyFileSync(srcBundle, destBundle);
|
|
259
|
+
}
|
|
260
|
+
function backupConfig(configPath) {
|
|
261
|
+
if (!existsSync(configPath)) return null;
|
|
262
|
+
const bak = `${configPath}.bak`;
|
|
263
|
+
copyFileSync(configPath, bak);
|
|
264
|
+
return bak;
|
|
265
|
+
}
|
|
266
|
+
function writeConfigAtomic(configPath, content, bakPath) {
|
|
267
|
+
const tmp = `${configPath}.tmp`;
|
|
268
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
269
|
+
try {
|
|
270
|
+
writeFileSync(tmp, content, { mode: 420 });
|
|
271
|
+
renameSync(tmp, configPath);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
try {
|
|
274
|
+
rmSync(tmp, { force: true });
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
if (bakPath && existsSync(bakPath)) {
|
|
278
|
+
try {
|
|
279
|
+
copyFileSync(bakPath, configPath);
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function removeBundle(destBundle) {
|
|
287
|
+
if (existsSync(destBundle)) rmSync(destBundle, { force: true });
|
|
288
|
+
try {
|
|
289
|
+
rmSync(dirname(destBundle), { recursive: false });
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function nodeAvailable() {
|
|
294
|
+
return typeof process.execPath === "string" && existsSync(process.execPath);
|
|
295
|
+
}
|
|
296
|
+
function collectStatus(configPath, bundlePath) {
|
|
297
|
+
const cfg = readConfigOrNull(configPath);
|
|
298
|
+
const apiBaseMatch = cfg?.match(/UNIAI_API_BASE\s*=\s*"([^"]*)"/);
|
|
299
|
+
return {
|
|
300
|
+
configPath,
|
|
301
|
+
configExists: cfg !== null,
|
|
302
|
+
uniaiSectionPresent: cfg !== null && cfg.includes("[mcp_servers.uniai]"),
|
|
303
|
+
apiBase: apiBaseMatch?.[1] ?? null,
|
|
304
|
+
bundlePath,
|
|
305
|
+
bundleExists: existsSync(bundlePath),
|
|
306
|
+
nodePath: process.execPath,
|
|
307
|
+
nodeOk: nodeAvailable()
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/lib/config.ts
|
|
312
|
+
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
313
|
+
var DEFAULT_API_BASE = "https://www.uniai.ai/api/v1";
|
|
314
|
+
var DEFAULT_SKILL_REMOTE = "Clion-a/uniai-skills";
|
|
315
|
+
async function readConfig() {
|
|
316
|
+
try {
|
|
317
|
+
const raw = await readFile(uniaiConfigPath(), "utf8");
|
|
318
|
+
const parsed = JSON.parse(raw);
|
|
319
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
320
|
+
} catch {
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function writeConfig(patch) {
|
|
325
|
+
const current = await readConfig();
|
|
326
|
+
const next = { ...current, ...patch };
|
|
327
|
+
await mkdir(uniaiHome(), { recursive: true, mode: 448 });
|
|
328
|
+
await writeFile(uniaiConfigPath(), JSON.stringify(next, null, 2) + "\n", { mode: 384 });
|
|
329
|
+
}
|
|
330
|
+
function maskToken(t) {
|
|
331
|
+
return t.length <= 14 ? "***" : `${t.slice(0, 8)}\u2026${t.slice(-4)}`;
|
|
332
|
+
}
|
|
333
|
+
async function clearConfig() {
|
|
334
|
+
await rm(uniaiConfigPath(), { force: true });
|
|
335
|
+
}
|
|
336
|
+
async function unsetConfigKeys(keys) {
|
|
337
|
+
const current = await readConfig();
|
|
338
|
+
for (const k of keys) delete current[k];
|
|
339
|
+
if (Object.keys(current).length === 0) {
|
|
340
|
+
await clearConfig();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await mkdir(uniaiHome(), { recursive: true, mode: 448 });
|
|
344
|
+
await writeFile(uniaiConfigPath(), JSON.stringify(current, null, 2) + "\n", { mode: 384 });
|
|
345
|
+
}
|
|
346
|
+
var str = (v) => typeof v === "string" && v.trim().length > 0 ? v.trim() : void 0;
|
|
347
|
+
async function resolveEnv(flags) {
|
|
348
|
+
const cfg = await readConfig();
|
|
349
|
+
const token = str(flags.token) ?? str(process.env.UNIAI_TOKEN) ?? cfg.token;
|
|
350
|
+
if (!token) {
|
|
351
|
+
throw new Error(MSG.noTokenForDirect);
|
|
352
|
+
}
|
|
353
|
+
const baseUrl = (str(flags["api-base"]) ?? str(process.env.UNIAI_API_BASE) ?? cfg.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
354
|
+
return { baseUrl, token };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/commands/init.ts
|
|
358
|
+
function bundledServerPath() {
|
|
359
|
+
return fileURLToPath(new URL("./mcp-server.bundle.mjs", import.meta.url));
|
|
360
|
+
}
|
|
361
|
+
async function runInit(flags) {
|
|
362
|
+
if (!nodeAvailable()) {
|
|
363
|
+
console.error(MSG.nodeUnavailable(process.execPath));
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
let token;
|
|
367
|
+
try {
|
|
368
|
+
token = await resolvePat({
|
|
369
|
+
tokenFlag: flags.token ?? null,
|
|
370
|
+
envToken: process.env.UNIAI_TOKEN,
|
|
371
|
+
isTty: Boolean(process.stdin.isTTY),
|
|
372
|
+
promptHidden: () => promptHiddenStdin(MSG.patPrompt)
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
376
|
+
console.error(MSG.patHowTo);
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
const apiBase = (flags.apiBase ?? "").trim() || DEFAULT_API_BASE;
|
|
380
|
+
const bundleSrc = bundledServerPath();
|
|
381
|
+
const bundleDest = uniaiBundlePath();
|
|
382
|
+
try {
|
|
383
|
+
installBundle(bundleSrc, bundleDest);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
386
|
+
if (errMsg.startsWith("bundle-source-missing:")) {
|
|
387
|
+
console.error(MSG.bundleSourceMissing(bundleSrc));
|
|
388
|
+
} else {
|
|
389
|
+
console.error(MSG.bundleCopyFailed(bundleDest));
|
|
390
|
+
}
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
const configPath = codexConfigPath();
|
|
394
|
+
const existing = readConfigOrNull(configPath);
|
|
395
|
+
const block = buildUniaiBlock({
|
|
396
|
+
uniaiApiBase: apiBase,
|
|
397
|
+
uniaiToken: token,
|
|
398
|
+
bundleAbsPath: bundleDest,
|
|
399
|
+
nodeAbsPath: process.execPath
|
|
400
|
+
});
|
|
401
|
+
const merged = mergeManagedConfigToml2(existing, block);
|
|
402
|
+
const bak = backupConfig(configPath);
|
|
403
|
+
try {
|
|
404
|
+
writeConfigAtomic(configPath, merged, bak);
|
|
405
|
+
} catch {
|
|
406
|
+
console.error(MSG.configWriteFailed(configPath));
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
const after = readConfigOrNull(configPath);
|
|
410
|
+
if (after === null || !after.includes("[mcp_servers.uniai]")) {
|
|
411
|
+
console.error(MSG.configWriteFailed(configPath));
|
|
412
|
+
return 1;
|
|
413
|
+
}
|
|
414
|
+
console.log(MSG.initDone);
|
|
415
|
+
console.log(` config : ${configPath}`);
|
|
416
|
+
console.log(` bundle : ${bundleDest}`);
|
|
417
|
+
console.log(` api : ${apiBase}`);
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/commands/uninstall.ts
|
|
422
|
+
async function runUninstall() {
|
|
423
|
+
const configPath = codexConfigPath();
|
|
424
|
+
const existing = readConfigOrNull(configPath);
|
|
425
|
+
if (existing !== null && existing.includes("[mcp_servers.uniai]")) {
|
|
426
|
+
const merged = mergeManagedConfigToml2(existing, "");
|
|
427
|
+
const bak = backupConfig(configPath);
|
|
428
|
+
try {
|
|
429
|
+
writeConfigAtomic(configPath, merged, bak);
|
|
430
|
+
} catch {
|
|
431
|
+
console.error(MSG.configWriteFailed(configPath));
|
|
432
|
+
return 1;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
removeBundle(uniaiBundlePath());
|
|
436
|
+
console.log(MSG.uninstallDone);
|
|
437
|
+
console.log(` config : ${configPath}`);
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/commands/status.ts
|
|
442
|
+
function tokenReadiness() {
|
|
443
|
+
const env = process.env.UNIAI_TOKEN?.trim();
|
|
444
|
+
if (env && env.length > 0) return "ready (from env UNIAI_TOKEN)";
|
|
445
|
+
return "not set in env (config-embedded token used at runtime if installed)";
|
|
446
|
+
}
|
|
447
|
+
async function runStatus() {
|
|
448
|
+
const s = collectStatus(codexConfigPath(), uniaiBundlePath());
|
|
449
|
+
console.log("UniAI CLI status");
|
|
450
|
+
console.log(` config.toml : ${s.configPath} [${s.configExists ? "present" : "not found"}]`);
|
|
451
|
+
console.log(` uniai section : ${s.uniaiSectionPresent ? "installed" : "not installed"}`);
|
|
452
|
+
console.log(` bundle : ${s.bundlePath} [${s.bundleExists ? "present" : "missing"}]`);
|
|
453
|
+
console.log(` node : ${s.nodePath} (${process.version})`);
|
|
454
|
+
const apiBaseLine = s.apiBase ? `${s.apiBase} (from config)` : `${DEFAULT_API_BASE} (default; override per-install via --api-base)`;
|
|
455
|
+
console.log(` api-base : ${apiBaseLine}`);
|
|
456
|
+
console.log(` token : ${tokenReadiness()}`);
|
|
457
|
+
if (!s.uniaiSectionPresent) {
|
|
458
|
+
console.log("");
|
|
459
|
+
console.log(MSG.notInstalled + " Run `uniai init` to install.");
|
|
460
|
+
}
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/commands/update.ts
|
|
465
|
+
import { spawn } from "node:child_process";
|
|
466
|
+
|
|
467
|
+
// src/lib/version.ts
|
|
468
|
+
var VERSION = true ? "0.1.0" : "0.0.0-dev";
|
|
469
|
+
|
|
470
|
+
// src/commands/update.ts
|
|
471
|
+
var PKG = "@uniai/cli";
|
|
472
|
+
var REGISTRY_URL = `https://registry.npmjs.org/${PKG}/latest`;
|
|
473
|
+
function cmpVersion(a, b) {
|
|
474
|
+
const pa = a.split("-")[0].split(".").map(Number);
|
|
475
|
+
const pb = b.split("-")[0].split(".").map(Number);
|
|
476
|
+
for (let i = 0; i < 3; i++) {
|
|
477
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
478
|
+
if (d !== 0) return d;
|
|
479
|
+
}
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
482
|
+
async function fetchLatest() {
|
|
483
|
+
const res = await fetch(REGISTRY_URL, { headers: { accept: "application/json" } });
|
|
484
|
+
if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status}`);
|
|
485
|
+
const body = await res.json();
|
|
486
|
+
if (!body.version) throw new Error("npm registry response missing version field");
|
|
487
|
+
return body.version;
|
|
488
|
+
}
|
|
489
|
+
function npmInstallLatest() {
|
|
490
|
+
return new Promise((resolve2) => {
|
|
491
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
492
|
+
const child = spawn(npm, ["install", "-g", `${PKG}@latest`], { stdio: "inherit" });
|
|
493
|
+
child.on("error", () => resolve2(1));
|
|
494
|
+
child.on("close", (code) => resolve2(code ?? 1));
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async function runUpdate(_rest, flags) {
|
|
498
|
+
const asJson = flags.json === true || flags.json === "true";
|
|
499
|
+
const checkOnly = flags.check === true;
|
|
500
|
+
let latest;
|
|
501
|
+
try {
|
|
502
|
+
latest = await fetchLatest();
|
|
503
|
+
} catch (err) {
|
|
504
|
+
const m = `Could not check for updates: ${err instanceof Error ? err.message : String(err)}`;
|
|
505
|
+
if (asJson) process.stdout.write(JSON.stringify({ ok: false, error: m }) + "\n");
|
|
506
|
+
else process.stderr.write(m + "\n");
|
|
507
|
+
return 1;
|
|
508
|
+
}
|
|
509
|
+
const updateAvailable = cmpVersion(latest, VERSION) > 0;
|
|
510
|
+
if (!updateAvailable) {
|
|
511
|
+
if (asJson) {
|
|
512
|
+
process.stdout.write(JSON.stringify({ ok: true, current: VERSION, latest, updateAvailable: false, updated: false }) + "\n");
|
|
513
|
+
} else {
|
|
514
|
+
process.stdout.write(`@uniai/cli is up to date (${VERSION}).
|
|
515
|
+
`);
|
|
516
|
+
}
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
if (checkOnly) {
|
|
520
|
+
if (asJson) {
|
|
521
|
+
process.stdout.write(JSON.stringify({ ok: true, current: VERSION, latest, updateAvailable: true, updated: false }) + "\n");
|
|
522
|
+
} else {
|
|
523
|
+
process.stdout.write(`A newer @uniai/cli is available: ${VERSION} -> ${latest}. Run \`uniai update\` (or \`npm i -g ${PKG}@latest\`).
|
|
524
|
+
`);
|
|
525
|
+
}
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
if (!asJson) process.stderr.write(`Updating @uniai/cli ${VERSION} -> ${latest} ...
|
|
529
|
+
`);
|
|
530
|
+
const code = await npmInstallLatest();
|
|
531
|
+
if (asJson) {
|
|
532
|
+
process.stdout.write(JSON.stringify({ ok: code === 0, current: VERSION, latest, updateAvailable: true, updated: code === 0 }) + "\n");
|
|
533
|
+
} else if (code === 0) {
|
|
534
|
+
process.stdout.write(`Updated to ${latest}.
|
|
535
|
+
`);
|
|
536
|
+
} else {
|
|
537
|
+
process.stderr.write(`Update failed (npm exited ${code}). Try \`npm i -g ${PKG}@latest\` manually.
|
|
538
|
+
`);
|
|
539
|
+
}
|
|
540
|
+
return code === 0 ? 0 : 1;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ../uniai-core/src/lib/uniai-client.ts
|
|
544
|
+
var POLL_MAX_MS = 27e4;
|
|
545
|
+
function isTransientNetworkError(err) {
|
|
546
|
+
if (!(err instanceof Error)) return false;
|
|
547
|
+
const code = err.cause?.code;
|
|
548
|
+
if (typeof code === "string" && [
|
|
549
|
+
"ECONNRESET",
|
|
550
|
+
"ECONNREFUSED",
|
|
551
|
+
"ETIMEDOUT",
|
|
552
|
+
"ENOTFOUND",
|
|
553
|
+
"EAI_AGAIN",
|
|
554
|
+
"EPIPE",
|
|
555
|
+
"UND_ERR_SOCKET",
|
|
556
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
557
|
+
].includes(code)) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
return err.name === "TypeError" && /fetch failed/i.test(err.message);
|
|
561
|
+
}
|
|
562
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
563
|
+
async function callUniAI(env, method, path, body, opts) {
|
|
564
|
+
const timeoutMs = opts?.timeoutMs ?? 3e4;
|
|
565
|
+
const maxAttempts = Math.max(1, opts?.attempts ?? 3);
|
|
566
|
+
let url = `${env.baseUrl}${path}`;
|
|
567
|
+
if (opts?.query) {
|
|
568
|
+
const params = new URLSearchParams();
|
|
569
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
570
|
+
if (v !== void 0 && v !== null) params.set(k, String(v));
|
|
571
|
+
}
|
|
572
|
+
const qs = params.toString();
|
|
573
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
574
|
+
}
|
|
575
|
+
let lastErr;
|
|
576
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
577
|
+
const controller = new AbortController();
|
|
578
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
579
|
+
let res;
|
|
580
|
+
try {
|
|
581
|
+
res = await fetch(url, {
|
|
582
|
+
method,
|
|
583
|
+
headers: {
|
|
584
|
+
Authorization: `Bearer ${env.token}`,
|
|
585
|
+
"Content-Type": "application/json",
|
|
586
|
+
Accept: "application/json"
|
|
587
|
+
},
|
|
588
|
+
body: body !== void 0 && method !== "GET" ? JSON.stringify(body) : void 0,
|
|
589
|
+
signal: controller.signal
|
|
590
|
+
});
|
|
591
|
+
} catch (err) {
|
|
592
|
+
clearTimeout(timer);
|
|
593
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
594
|
+
throw new Error(`UniAI ${method} ${path} timed out after ${timeoutMs}ms`);
|
|
595
|
+
}
|
|
596
|
+
lastErr = err;
|
|
597
|
+
if (isTransientNetworkError(err) && attempt < maxAttempts) {
|
|
598
|
+
await sleep(attempt * 400);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
throw err;
|
|
602
|
+
}
|
|
603
|
+
clearTimeout(timer);
|
|
604
|
+
if (!res.ok) {
|
|
605
|
+
let bodyText = "";
|
|
606
|
+
try {
|
|
607
|
+
bodyText = (await res.text()).slice(0, 200);
|
|
608
|
+
} catch {
|
|
609
|
+
bodyText = "<failed to read body>";
|
|
610
|
+
}
|
|
611
|
+
throw new Error(`UniAI ${method} ${path} -> HTTP ${res.status}: ${bodyText}`);
|
|
612
|
+
}
|
|
613
|
+
if (res.status === 204) return void 0;
|
|
614
|
+
return await res.json();
|
|
615
|
+
}
|
|
616
|
+
throw lastErr instanceof Error ? lastErr : new Error(`UniAI ${method} ${path} failed`);
|
|
617
|
+
}
|
|
618
|
+
var PollTimeoutError = class extends Error {
|
|
619
|
+
lastSnapshot;
|
|
620
|
+
constructor(path, maxMs, lastSnapshot) {
|
|
621
|
+
super(
|
|
622
|
+
`UniAI poll ${path} timed out after ${maxMs}ms; last snapshot: ${lastSnapshot ? JSON.stringify(lastSnapshot).slice(0, 200) : "<none>"}`
|
|
623
|
+
);
|
|
624
|
+
this.name = "PollTimeoutError";
|
|
625
|
+
this.lastSnapshot = lastSnapshot;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
async function pollTask(env, path, opts) {
|
|
629
|
+
const deadline = Date.now() + opts.maxMs;
|
|
630
|
+
let lastSnapshot;
|
|
631
|
+
while (Date.now() < deadline) {
|
|
632
|
+
try {
|
|
633
|
+
const snap = await callUniAI(env, "GET", path, void 0, {
|
|
634
|
+
// 单次 GET 用更短超时, 避免把整体 budget 耗在一次卡住的请求上
|
|
635
|
+
timeoutMs: Math.min(15e3, deadline - Date.now() + 1e3)
|
|
636
|
+
});
|
|
637
|
+
lastSnapshot = snap;
|
|
638
|
+
const verdict = opts.isTerminal(snap);
|
|
639
|
+
if (verdict === "done") return { status: "done", snapshot: snap };
|
|
640
|
+
if (verdict === "failed") return { status: "failed", snapshot: snap };
|
|
641
|
+
} catch (err) {
|
|
642
|
+
console.error(
|
|
643
|
+
`[uniai-mcp] poll ${path} transient error: ${err instanceof Error ? err.message : String(err)}`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
await new Promise((r) => setTimeout(r, opts.intervalMs));
|
|
647
|
+
}
|
|
648
|
+
throw new PollTimeoutError(path, opts.maxMs, lastSnapshot);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ../uniai-core/src/lib/file-input.ts
|
|
652
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
653
|
+
import { basename } from "node:path";
|
|
654
|
+
var EXT_CONTENT_TYPE = {
|
|
655
|
+
png: "image/png",
|
|
656
|
+
jpg: "image/jpeg",
|
|
657
|
+
jpeg: "image/jpeg",
|
|
658
|
+
webp: "image/webp",
|
|
659
|
+
gif: "image/gif",
|
|
660
|
+
mp3: "audio/mpeg",
|
|
661
|
+
wav: "audio/wav",
|
|
662
|
+
webm: "audio/webm",
|
|
663
|
+
ogg: "audio/ogg",
|
|
664
|
+
m4a: "audio/mp4",
|
|
665
|
+
flac: "audio/flac",
|
|
666
|
+
aac: "audio/aac"
|
|
667
|
+
};
|
|
668
|
+
function extensionToContentType(filename) {
|
|
669
|
+
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
|
|
670
|
+
const ext = m ? m[1].toLowerCase() : "";
|
|
671
|
+
return EXT_CONTENT_TYPE[ext] ?? "application/octet-stream";
|
|
672
|
+
}
|
|
673
|
+
var ACCEPTED_UPLOAD_MIMES = {
|
|
674
|
+
image: ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"],
|
|
675
|
+
video: ["video/mp4", "video/webm", "video/quicktime"],
|
|
676
|
+
audio: ["audio/mpeg", "audio/wav", "audio/ogg", "audio/webm", "audio/aac", "audio/mp4", "audio/x-m4a"]
|
|
677
|
+
};
|
|
678
|
+
var UPLOAD_EXT_CONTENT_TYPE = {
|
|
679
|
+
image: { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", svg: "image/svg+xml" },
|
|
680
|
+
video: { mp4: "video/mp4", m4v: "video/mp4", mov: "video/quicktime", qt: "video/quicktime", webm: "video/webm" },
|
|
681
|
+
audio: { mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", webm: "audio/webm", m4a: "audio/mp4", aac: "audio/aac" }
|
|
682
|
+
};
|
|
683
|
+
var UPLOAD_FALLBACK_MIME = {
|
|
684
|
+
image: "image/png",
|
|
685
|
+
video: "video/mp4",
|
|
686
|
+
audio: "audio/mpeg"
|
|
687
|
+
};
|
|
688
|
+
function resolveUploadContentType(filename, currentContentType, kind) {
|
|
689
|
+
if (ACCEPTED_UPLOAD_MIMES[kind].includes(currentContentType)) return currentContentType;
|
|
690
|
+
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
|
|
691
|
+
const ext = m ? m[1].toLowerCase() : "";
|
|
692
|
+
return UPLOAD_EXT_CONTENT_TYPE[kind][ext] ?? UPLOAD_FALLBACK_MIME[kind];
|
|
693
|
+
}
|
|
694
|
+
function classifyFileInput(input) {
|
|
695
|
+
const s = input.trim();
|
|
696
|
+
if (/^data:/i.test(s)) return "base64";
|
|
697
|
+
if (/^https?:\/\//i.test(s)) return "url";
|
|
698
|
+
return "path";
|
|
699
|
+
}
|
|
700
|
+
function parseDataUrl(dataUrl) {
|
|
701
|
+
const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(dataUrl.trim());
|
|
702
|
+
if (!m) throw new Error("invalid data URL");
|
|
703
|
+
const contentType = m[1] || "application/octet-stream";
|
|
704
|
+
const isBase64 = !!m[2];
|
|
705
|
+
const data = m[3] ?? "";
|
|
706
|
+
const buffer = isBase64 ? Buffer.from(data, "base64") : Buffer.from(decodeURIComponent(data), "utf-8");
|
|
707
|
+
if (buffer.length === 0) throw new Error("data URL has empty payload");
|
|
708
|
+
return { buffer, contentType };
|
|
709
|
+
}
|
|
710
|
+
function extFromContentType(contentType) {
|
|
711
|
+
for (const [ext, ct] of Object.entries(EXT_CONTENT_TYPE)) {
|
|
712
|
+
if (ct === contentType) return ext;
|
|
713
|
+
}
|
|
714
|
+
return "bin";
|
|
715
|
+
}
|
|
716
|
+
async function resolveFileInput(input) {
|
|
717
|
+
const kind = classifyFileInput(input);
|
|
718
|
+
if (kind === "base64") {
|
|
719
|
+
const { buffer: buffer2, contentType } = parseDataUrl(input);
|
|
720
|
+
return { buffer: buffer2, filename: `upload.${extFromContentType(contentType)}`, contentType };
|
|
721
|
+
}
|
|
722
|
+
if (kind === "url") {
|
|
723
|
+
const res = await fetch(input.trim());
|
|
724
|
+
if (!res.ok) {
|
|
725
|
+
throw new Error(`failed to download ${input}: HTTP ${res.status}`);
|
|
726
|
+
}
|
|
727
|
+
const arrayBuf = await res.arrayBuffer();
|
|
728
|
+
const buffer2 = Buffer.from(arrayBuf);
|
|
729
|
+
let filename2 = "download";
|
|
730
|
+
try {
|
|
731
|
+
filename2 = basename(new URL(input.trim()).pathname) || "download";
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
const headerType = res.headers.get("content-type")?.split(";")[0]?.trim();
|
|
735
|
+
const contentType = headerType || extensionToContentType(filename2);
|
|
736
|
+
return { buffer: buffer2, filename: filename2, contentType };
|
|
737
|
+
}
|
|
738
|
+
const buffer = await readFile2(input);
|
|
739
|
+
const filename = basename(input);
|
|
740
|
+
return { buffer, filename, contentType: extensionToContentType(filename) };
|
|
741
|
+
}
|
|
742
|
+
async function postMultipart(env, path, fileField, file, extra, opts) {
|
|
743
|
+
const timeoutMs = opts?.timeoutMs ?? 6e4;
|
|
744
|
+
const url = `${env.baseUrl}${path}`;
|
|
745
|
+
const form = new FormData();
|
|
746
|
+
const blob = new Blob([file.buffer], { type: file.contentType });
|
|
747
|
+
form.append(fileField, blob, file.filename);
|
|
748
|
+
if (extra) {
|
|
749
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
750
|
+
if (v !== void 0) form.append(k, v);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const controller = new AbortController();
|
|
754
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
755
|
+
try {
|
|
756
|
+
const res = await fetch(url, {
|
|
757
|
+
method: "POST",
|
|
758
|
+
headers: {
|
|
759
|
+
Authorization: `Bearer ${env.token}`,
|
|
760
|
+
// 不设 Content-Type,让 fetch 按 FormData 自动带 multipart boundary
|
|
761
|
+
Accept: "application/json"
|
|
762
|
+
},
|
|
763
|
+
body: form,
|
|
764
|
+
signal: controller.signal
|
|
765
|
+
});
|
|
766
|
+
if (!res.ok) {
|
|
767
|
+
let bodyText = "";
|
|
768
|
+
try {
|
|
769
|
+
bodyText = (await res.text()).slice(0, 200);
|
|
770
|
+
} catch {
|
|
771
|
+
bodyText = "<failed to read body>";
|
|
772
|
+
}
|
|
773
|
+
throw new Error(`UniAI POST ${path} -> HTTP ${res.status}: ${bodyText}`);
|
|
774
|
+
}
|
|
775
|
+
if (res.status === 204) return void 0;
|
|
776
|
+
return await res.json();
|
|
777
|
+
} catch (err) {
|
|
778
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
779
|
+
throw new Error(`UniAI POST ${path} timed out after ${timeoutMs}ms`);
|
|
780
|
+
}
|
|
781
|
+
throw err;
|
|
782
|
+
} finally {
|
|
783
|
+
clearTimeout(timer);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ../uniai-core/src/lib/ensure-image-url.ts
|
|
788
|
+
var UPLOAD_ENDPOINT = {
|
|
789
|
+
image: "/storage/upload/image",
|
|
790
|
+
// 50MB,JPEG/PNG/GIF/WebP/SVG,内部 auto-compress
|
|
791
|
+
video: "/storage/upload/video",
|
|
792
|
+
// 100MB,MP4/WebM/MOV
|
|
793
|
+
audio: "/storage/upload/audio"
|
|
794
|
+
// 50MB,MP3/WAV/OGG/M4A/FLAC/AAC(Seedance omni 参考音频走这条)
|
|
795
|
+
};
|
|
796
|
+
async function ensureMediaUrl(env, input, kind) {
|
|
797
|
+
const trimmed = input.trim();
|
|
798
|
+
if (trimmed === "") {
|
|
799
|
+
throw new Error(`${kind} input is empty (expected a file path, http(s) URL, or data URL)`);
|
|
800
|
+
}
|
|
801
|
+
const inputKind = classifyFileInput(trimmed);
|
|
802
|
+
if (inputKind === "url") {
|
|
803
|
+
return trimmed;
|
|
804
|
+
}
|
|
805
|
+
let file;
|
|
806
|
+
try {
|
|
807
|
+
file = await resolveFileInput(trimmed);
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const msg2 = err instanceof Error ? err.message : String(err);
|
|
810
|
+
if (inputKind === "path" && /ENOENT|no such file|not found/i.test(msg2)) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`Reference ${kind} file not found or not readable: "${trimmed}". Do NOT download a remote or generated ${kind} to a local file and then pass that path \u2014 pass the ${kind} URL directly instead (e.g. the URL returned by generate_image/generate_video). If it is a file the user uploaded, use the exact absolute path printed in the upload message, unchanged.`
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
throw err;
|
|
816
|
+
}
|
|
817
|
+
file.contentType = resolveUploadContentType(file.filename, file.contentType, kind);
|
|
818
|
+
const res = await postMultipart(env, UPLOAD_ENDPOINT[kind], "file", file);
|
|
819
|
+
const url = res.url;
|
|
820
|
+
if (typeof url !== "string" || url.trim() === "") {
|
|
821
|
+
throw new Error(`${kind} upload succeeded but no URL was returned`);
|
|
822
|
+
}
|
|
823
|
+
return url;
|
|
824
|
+
}
|
|
825
|
+
async function ensureImageUrl(env, input) {
|
|
826
|
+
return ensureMediaUrl(env, input, "image");
|
|
827
|
+
}
|
|
828
|
+
function isStableSignedR2Url(url) {
|
|
829
|
+
return /[?&]X-Amz-(Signature|Credential)=/i.test(url);
|
|
830
|
+
}
|
|
831
|
+
async function stabilizeMediaUrl(env, input, kind) {
|
|
832
|
+
const trimmed = input.trim();
|
|
833
|
+
if (trimmed === "" || classifyFileInput(trimmed) !== "url") return input;
|
|
834
|
+
if (isStableSignedR2Url(trimmed)) return trimmed;
|
|
835
|
+
try {
|
|
836
|
+
const file = await resolveFileInput(trimmed);
|
|
837
|
+
file.contentType = resolveUploadContentType(file.filename, file.contentType, kind);
|
|
838
|
+
const res = await postMultipart(env, UPLOAD_ENDPOINT[kind], "file", file);
|
|
839
|
+
const url = res.url;
|
|
840
|
+
return typeof url === "string" && url.trim() !== "" ? url.trim() : trimmed;
|
|
841
|
+
} catch {
|
|
842
|
+
return trimmed;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ../uniai-core/src/lib/model-catalog.ts
|
|
847
|
+
function asArray(raw) {
|
|
848
|
+
if (Array.isArray(raw)) return raw;
|
|
849
|
+
if (raw && typeof raw === "object") {
|
|
850
|
+
const o = raw;
|
|
851
|
+
if (Array.isArray(o.models)) return o.models;
|
|
852
|
+
if (Array.isArray(o.data)) return o.data;
|
|
853
|
+
if (Array.isArray(o.items)) return o.items;
|
|
854
|
+
}
|
|
855
|
+
return [];
|
|
856
|
+
}
|
|
857
|
+
async function fetchImageModels(env) {
|
|
858
|
+
try {
|
|
859
|
+
const raw = await callUniAI(env, "GET", "/art/models", void 0, {
|
|
860
|
+
timeoutMs: 1e4
|
|
861
|
+
});
|
|
862
|
+
return asArray(raw).filter(
|
|
863
|
+
(m) => m && typeof m.modelId === "string" && m.modelId.trim() !== ""
|
|
864
|
+
);
|
|
865
|
+
} catch (err) {
|
|
866
|
+
console.error(
|
|
867
|
+
`[uniai-mcp] fetchImageModels failed (degrading to defaults): ${err instanceof Error ? err.message : String(err)}`
|
|
868
|
+
);
|
|
869
|
+
return [];
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
async function fetchVideoModels(env) {
|
|
873
|
+
try {
|
|
874
|
+
const raw = await callUniAI(env, "GET", "/video-agent-v2/models", void 0, {
|
|
875
|
+
timeoutMs: 1e4
|
|
876
|
+
});
|
|
877
|
+
return asArray(raw).filter(
|
|
878
|
+
(m) => m && typeof m.id === "string" && m.id.trim() !== ""
|
|
879
|
+
);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
console.error(
|
|
882
|
+
`[uniai-mcp] fetchVideoModels failed (degrading to defaults): ${err instanceof Error ? err.message : String(err)}`
|
|
883
|
+
);
|
|
884
|
+
return [];
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ../uniai-core/src/lib/generation-confirm.ts
|
|
889
|
+
function makeStep(key, message, field) {
|
|
890
|
+
return {
|
|
891
|
+
key,
|
|
892
|
+
message,
|
|
893
|
+
schema: { type: "object", properties: { [key]: field }, required: [key] }
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function enumField(title, options, def, labels) {
|
|
897
|
+
const base = labels ? { type: "string", title, oneOf: options.map((v, i) => ({ const: v, title: labels[i] ?? v })) } : { type: "string", title, enum: options };
|
|
898
|
+
return def !== void 0 ? { ...base, default: def } : base;
|
|
899
|
+
}
|
|
900
|
+
function pickEnum(wanted, allowed, preferred) {
|
|
901
|
+
const list2 = allowed && allowed.length > 0 ? allowed : void 0;
|
|
902
|
+
if (wanted && (!list2 || list2.includes(wanted))) return wanted;
|
|
903
|
+
if (!list2) return preferred;
|
|
904
|
+
return list2.includes(preferred) ? preferred : list2[0];
|
|
905
|
+
}
|
|
906
|
+
function clampInt(v, min, max, dflt) {
|
|
907
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.round(v) : dflt;
|
|
908
|
+
return Math.min(max, Math.max(min, n));
|
|
909
|
+
}
|
|
910
|
+
function modelLabel(name, credits) {
|
|
911
|
+
return typeof credits === "number" && credits > 0 ? `${name} (~${credits} credits)` : name;
|
|
912
|
+
}
|
|
913
|
+
function clampToModelEnum(value, allowed, preferred) {
|
|
914
|
+
if (value === void 0) return void 0;
|
|
915
|
+
if (!allowed || allowed.length === 0) return value;
|
|
916
|
+
if (allowed.includes(value)) return value;
|
|
917
|
+
return preferred && allowed.includes(preferred) ? preferred : allowed[0];
|
|
918
|
+
}
|
|
919
|
+
function snapDurationToModel(value, model) {
|
|
920
|
+
const min = model?.minDuration ?? 2;
|
|
921
|
+
const max = model?.maxDuration ?? 15;
|
|
922
|
+
let d = Math.min(max, Math.max(min, Math.round(value)));
|
|
923
|
+
const steps = model?.durationSteps;
|
|
924
|
+
if (steps && steps.length > 0) {
|
|
925
|
+
d = steps.reduce((best, s) => Math.abs(s - d) < Math.abs(best - d) ? s : best, steps[0]);
|
|
926
|
+
}
|
|
927
|
+
return d;
|
|
928
|
+
}
|
|
929
|
+
function recommendImageModel(models, args, pinnedModel) {
|
|
930
|
+
if (models.length === 0) return null;
|
|
931
|
+
const byId = (id) => id ? models.find((m) => m.modelId === id) : void 0;
|
|
932
|
+
return byId(args.model) ?? byId(pinnedModel) ?? byId("gpt-image-2") ?? models[0];
|
|
933
|
+
}
|
|
934
|
+
function imageParamsFor(model, args) {
|
|
935
|
+
const ars = model.supportedAspectRatios ?? ["1:1", "16:9", "9:16", "4:3", "3:4"];
|
|
936
|
+
const ress = model.supportedResolutions ?? ["1K", "2K", "4K"];
|
|
937
|
+
const quals = model.supportedQualities ?? [];
|
|
938
|
+
const maxCount = model.maxCount ?? 4;
|
|
939
|
+
const aspectRatio = pickEnum(args.aspectRatio, ars, "1:1");
|
|
940
|
+
const resolution = ress.length > 0 ? pickEnum(args.resolution, ress, "1K") : void 0;
|
|
941
|
+
const quality = quals.length > 0 ? pickEnum(args.quality, quals, "low") : void 0;
|
|
942
|
+
const count = clampInt(args.count, 1, maxCount, 1);
|
|
943
|
+
const defaults = {
|
|
944
|
+
aspectRatio,
|
|
945
|
+
count,
|
|
946
|
+
...resolution !== void 0 ? { resolution } : {},
|
|
947
|
+
...quality !== void 0 ? { quality } : {}
|
|
948
|
+
};
|
|
949
|
+
const steps = [];
|
|
950
|
+
if (ars.length >= 2) {
|
|
951
|
+
steps.push(makeStep("aspectRatio", `\u9009\u62E9\u753B\u9762\u6BD4\u4F8B\uFF08\u63A8\u8350\uFF1A${aspectRatio}\uFF09`, enumField("Aspect ratio", ars, aspectRatio)));
|
|
952
|
+
}
|
|
953
|
+
if (ress.length >= 2 && resolution !== void 0) {
|
|
954
|
+
steps.push(makeStep("resolution", `\u9009\u62E9\u5206\u8FA8\u7387\uFF08\u66F4\u9AD8\u66F4\u8D39\u79EF\u5206\uFF0C\u63A8\u8350\uFF1A${resolution}\uFF09`, enumField("Resolution", ress, resolution)));
|
|
955
|
+
}
|
|
956
|
+
if (quals.length >= 2 && quality !== void 0) {
|
|
957
|
+
steps.push(makeStep("quality", `\u9009\u62E9\u753B\u8D28\uFF08\u66F4\u9AD8\u753B\u8D28\u66F4\u8D39\u79EF\u5206\uFF0C\u63A8\u8350\uFF1A${quality}\uFF09`, enumField("Quality", quals, quality)));
|
|
958
|
+
}
|
|
959
|
+
if (maxCount > 1) {
|
|
960
|
+
steps.push(
|
|
961
|
+
makeStep("count", `\u9009\u62E9\u751F\u6210\u5F20\u6570\uFF081-${maxCount}\uFF0C\u6BCF\u591A\u4E00\u5F20\u66F4\u8D39\u79EF\u5206\uFF0C\u63A8\u8350\uFF1A${count}\uFF09`, {
|
|
962
|
+
type: "integer",
|
|
963
|
+
title: "Count",
|
|
964
|
+
minimum: 1,
|
|
965
|
+
maximum: maxCount,
|
|
966
|
+
default: count
|
|
967
|
+
})
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
return { steps, defaults };
|
|
971
|
+
}
|
|
972
|
+
function planImageConfirmation(models, args, opts) {
|
|
973
|
+
const pinned = opts?.pinnedModel && models.some((m) => m.modelId === opts.pinnedModel) ? opts.pinnedModel : void 0;
|
|
974
|
+
const rec = recommendImageModel(models, args, pinned);
|
|
975
|
+
if (!rec) return null;
|
|
976
|
+
const refNote = args.referenceImageCount && args.referenceImageCount > 0 ? `\uFF08\u542B ${args.referenceImageCount} \u5F20\u53C2\u8003\u56FE\uFF09` : "";
|
|
977
|
+
const recLabel = modelLabel(rec.displayName || rec.modelId, rec.estimatedCredits);
|
|
978
|
+
const labelOf = (id) => {
|
|
979
|
+
const m = models.find((x) => x.modelId === id);
|
|
980
|
+
return m ? modelLabel(m.displayName || m.modelId, m.estimatedCredits) : id;
|
|
981
|
+
};
|
|
982
|
+
const modelStepFor = (preselect, message) => makeStep(
|
|
983
|
+
"model",
|
|
984
|
+
message,
|
|
985
|
+
enumField(
|
|
986
|
+
"Model",
|
|
987
|
+
models.map((m) => m.modelId),
|
|
988
|
+
preselect,
|
|
989
|
+
models.map((m) => modelLabel(m.displayName || m.modelId, m.estimatedCredits))
|
|
990
|
+
)
|
|
991
|
+
);
|
|
992
|
+
const requested = typeof args.model === "string" ? models.find((m) => m.modelId === args.model)?.modelId : void 0;
|
|
993
|
+
let modelStep;
|
|
994
|
+
let fixedModelId;
|
|
995
|
+
if (opts?.enforceModelConfirm) {
|
|
996
|
+
if (requested && pinned && requested === pinned) {
|
|
997
|
+
modelStep = null;
|
|
998
|
+
fixedModelId = pinned;
|
|
999
|
+
} else if (!requested && pinned) {
|
|
1000
|
+
modelStep = null;
|
|
1001
|
+
fixedModelId = pinned;
|
|
1002
|
+
} else if (requested && pinned && requested !== pinned) {
|
|
1003
|
+
modelStep = modelStepFor(
|
|
1004
|
+
requested,
|
|
1005
|
+
`\u786E\u8BA4\u66F4\u6362\u56FE\u7247\u751F\u6210\u6A21\u578B${refNote}\uFF08\u4F60\u6B64\u524D\u786E\u8BA4\u7684\u662F\u300C${labelOf(pinned)}\u300D\uFF0C\u672C\u6B21\u8BF7\u6C42\u6362\u6210\u300C${labelOf(requested)}\u300D\uFF0C\u786E\u8BA4\u6216\u6539\u56DE\uFF09`
|
|
1006
|
+
);
|
|
1007
|
+
fixedModelId = void 0;
|
|
1008
|
+
} else {
|
|
1009
|
+
modelStep = modelStepFor(requested ?? rec.modelId, `\u786E\u8BA4\u56FE\u7247\u751F\u6210\u6A21\u578B${refNote}\uFF08\u63A8\u8350\uFF1A${recLabel}\uFF09`);
|
|
1010
|
+
fixedModelId = void 0;
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
modelStep = requested ? null : modelStepFor(rec.modelId, `\u786E\u8BA4\u56FE\u7247\u751F\u6210\u6A21\u578B${refNote}\uFF08\u63A8\u8350\uFF1A${recLabel}\uFF09`);
|
|
1014
|
+
fixedModelId = requested;
|
|
1015
|
+
}
|
|
1016
|
+
const buildParams = (modelId) => {
|
|
1017
|
+
const m = models.find((x) => x.modelId === modelId) ?? rec;
|
|
1018
|
+
return imageParamsFor(m, args);
|
|
1019
|
+
};
|
|
1020
|
+
const recommended = {
|
|
1021
|
+
model: rec.modelId,
|
|
1022
|
+
...imageParamsFor(rec, args).defaults
|
|
1023
|
+
};
|
|
1024
|
+
return { modelStep, fixedModelId, buildParams, recommended };
|
|
1025
|
+
}
|
|
1026
|
+
var MODE_FOR_VIDEO_TYPE = {
|
|
1027
|
+
text2video: "text",
|
|
1028
|
+
image2video: "first_frame",
|
|
1029
|
+
start_end_frame: "first_frame",
|
|
1030
|
+
reference_to_video: "reference",
|
|
1031
|
+
video_to_video: "video_edit",
|
|
1032
|
+
video_continuation: "video_continuation"
|
|
1033
|
+
};
|
|
1034
|
+
function videoModelSupportsType(model, type) {
|
|
1035
|
+
if (type === "text2video") return true;
|
|
1036
|
+
const inputs = model.inputs;
|
|
1037
|
+
if (!inputs || typeof inputs !== "object" || Object.keys(inputs).length === 0) {
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
if (type === "start_end_frame") {
|
|
1041
|
+
const ffSlots = inputs["first_frame"]?.slots ?? [];
|
|
1042
|
+
return ffSlots.some((s) => s.role === "last_frame");
|
|
1043
|
+
}
|
|
1044
|
+
const mode = MODE_FOR_VIDEO_TYPE[type] ?? type;
|
|
1045
|
+
if (inputs[mode]) return true;
|
|
1046
|
+
if (type === "video_to_video" && inputs["video_reference"]) return true;
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
function slotCapacityForType(slots, type) {
|
|
1050
|
+
let total = 0;
|
|
1051
|
+
const seenGroups = /* @__PURE__ */ new Set();
|
|
1052
|
+
for (const s of slots) {
|
|
1053
|
+
if (s.type !== type) continue;
|
|
1054
|
+
if (s.groupKey) {
|
|
1055
|
+
if (seenGroups.has(s.groupKey)) continue;
|
|
1056
|
+
seenGroups.add(s.groupKey);
|
|
1057
|
+
total += Number(s.groupMax ?? s.max ?? 0);
|
|
1058
|
+
} else {
|
|
1059
|
+
total += Number(s.max ?? 0);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return total;
|
|
1063
|
+
}
|
|
1064
|
+
function videoModelCanAcceptContent(model, type, content) {
|
|
1065
|
+
if (!videoModelSupportsType(model, type)) return false;
|
|
1066
|
+
const { image, video, audio } = content;
|
|
1067
|
+
if (image + video + audio === 0) return true;
|
|
1068
|
+
const mode = MODE_FOR_VIDEO_TYPE[type] ?? type;
|
|
1069
|
+
const slots = model.inputs?.[mode]?.slots ?? [];
|
|
1070
|
+
if (slots.length === 0) return true;
|
|
1071
|
+
if (image > 0 && slotCapacityForType(slots, "image") < image) return false;
|
|
1072
|
+
if (video > 0 && slotCapacityForType(slots, "video") < video) return false;
|
|
1073
|
+
if (audio > 0 && slotCapacityForType(slots, "audio") < audio) return false;
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
function recommendVideo(models, type, args, pinnedModel, content) {
|
|
1077
|
+
if (models.length === 0) return null;
|
|
1078
|
+
const counts = content ?? { image: 0, video: 0, audio: 0 };
|
|
1079
|
+
const usable = models.filter((m) => videoModelCanAcceptContent(m, type, counts));
|
|
1080
|
+
const pool = usable.length > 0 ? usable : models;
|
|
1081
|
+
const byId = (id) => id ? pool.find((m) => m.id === id) : void 0;
|
|
1082
|
+
const rec = byId(args.model) ?? byId(pinnedModel) ?? pool.find((m) => m.isDefault) ?? pool[0];
|
|
1083
|
+
const aspectRatio = pickEnum(args.aspectRatio, rec?.supportedAspectRatios, "16:9");
|
|
1084
|
+
const wantDuration = typeof args.duration === "number" && Number.isFinite(args.duration) ? args.duration : 5;
|
|
1085
|
+
const duration = snapDurationToModel(wantDuration, rec);
|
|
1086
|
+
const resolution = rec?.supportedResolutions && rec.supportedResolutions.length > 0 ? pickEnum(args.resolution, rec.supportedResolutions, rec.defaultResolution ?? "720p") : void 0;
|
|
1087
|
+
return {
|
|
1088
|
+
recommendedModelId: rec?.id,
|
|
1089
|
+
usableModels: pool,
|
|
1090
|
+
aspectRatio,
|
|
1091
|
+
duration,
|
|
1092
|
+
resolution,
|
|
1093
|
+
model: rec
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
function videoParamsFor(model, args, type) {
|
|
1097
|
+
const ars = model?.supportedAspectRatios ?? [];
|
|
1098
|
+
const ress = model?.supportedResolutions ?? [];
|
|
1099
|
+
const durSteps = model?.durationSteps ?? [];
|
|
1100
|
+
const min = model?.minDuration ?? 2;
|
|
1101
|
+
const max = model?.maxDuration ?? 15;
|
|
1102
|
+
const aspectRatio = pickEnum(args.aspectRatio, ars, "16:9");
|
|
1103
|
+
const wantDuration = typeof args.duration === "number" && Number.isFinite(args.duration) ? args.duration : 5;
|
|
1104
|
+
const duration = snapDurationToModel(wantDuration, model);
|
|
1105
|
+
const resolution = ress.length > 0 ? pickEnum(args.resolution, ress, model?.defaultResolution ?? "720p") : void 0;
|
|
1106
|
+
const audioApplicable = model?.supportsAudio === true && type !== "video_to_video";
|
|
1107
|
+
const defaults = {
|
|
1108
|
+
aspectRatio,
|
|
1109
|
+
duration,
|
|
1110
|
+
// 数字;若离散 step 被选中会被字符串覆盖,工具侧统一 Number() 还原
|
|
1111
|
+
...resolution !== void 0 ? { resolution } : {},
|
|
1112
|
+
...audioApplicable ? { audio: "on" } : {}
|
|
1113
|
+
};
|
|
1114
|
+
const steps = [];
|
|
1115
|
+
if (ars.length >= 2) {
|
|
1116
|
+
steps.push(makeStep("aspectRatio", `\u9009\u62E9\u753B\u9762\u6BD4\u4F8B\uFF08\u63A8\u8350\uFF1A${aspectRatio}\uFF09`, enumField("Aspect ratio", ars, aspectRatio)));
|
|
1117
|
+
}
|
|
1118
|
+
if (durSteps.length >= 2) {
|
|
1119
|
+
steps.push(
|
|
1120
|
+
makeStep(
|
|
1121
|
+
"duration",
|
|
1122
|
+
`\u9009\u62E9\u65F6\u957F\uFF08\u63A8\u8350\uFF1A${duration}s\uFF09`,
|
|
1123
|
+
enumField("Duration", durSteps.map(String), String(duration), durSteps.map((s) => `${s}s`))
|
|
1124
|
+
)
|
|
1125
|
+
);
|
|
1126
|
+
} else if (max > min) {
|
|
1127
|
+
steps.push(
|
|
1128
|
+
makeStep("duration", `\u9009\u62E9\u65F6\u957F\u79D2\u6570\uFF08${min}-${max}\uFF0C\u8D8A\u957F\u8D8A\u8D39\u79EF\u5206\uFF0C\u63A8\u8350\uFF1A${duration}\uFF09`, {
|
|
1129
|
+
type: "integer",
|
|
1130
|
+
title: "Duration (seconds)",
|
|
1131
|
+
minimum: min,
|
|
1132
|
+
maximum: max,
|
|
1133
|
+
default: duration
|
|
1134
|
+
})
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
if (ress.length >= 2 && resolution !== void 0) {
|
|
1138
|
+
steps.push(makeStep("resolution", `\u9009\u62E9\u5206\u8FA8\u7387\uFF08\u66F4\u9AD8\u66F4\u8D39\u79EF\u5206\uFF0C\u63A8\u8350\uFF1A${resolution}\uFF09`, enumField("Resolution", ress, resolution)));
|
|
1139
|
+
}
|
|
1140
|
+
if (audioApplicable) {
|
|
1141
|
+
steps.push(
|
|
1142
|
+
makeStep("audio", `\u662F\u5426\u751F\u6210\u58F0\u97F3\uFF08\u6709\u58F0\u66F4\u8D39\u79EF\u5206\uFF0C\u63A8\u8350\uFF1A\u6709\u58F0\uFF09`, enumField("Audio", ["on", "off"], "on", ["\u6709\u58F0\uFF08\u80CC\u666F\u97F3/\u97F3\u6548\uFF09", "\u9759\u97F3"]))
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
return { steps, defaults };
|
|
1146
|
+
}
|
|
1147
|
+
function planVideoConfirmation(models, type, args, opts) {
|
|
1148
|
+
const content = opts?.content;
|
|
1149
|
+
const usable = models.filter(
|
|
1150
|
+
(m) => videoModelCanAcceptContent(m, type, content ?? { image: 0, video: 0, audio: 0 })
|
|
1151
|
+
);
|
|
1152
|
+
const pinned = opts?.pinnedModel && (usable.length > 0 ? usable : models).some((m) => m.id === opts.pinnedModel) ? opts.pinnedModel : void 0;
|
|
1153
|
+
const rec = recommendVideo(models, type, args, pinned, content);
|
|
1154
|
+
if (!rec) return null;
|
|
1155
|
+
const vLabel = (m) => modelLabel(m.nameZh || m.name || m.id);
|
|
1156
|
+
const recLabel = rec.model ? vLabel(rec.model) : "\u5E73\u53F0\u9ED8\u8BA4";
|
|
1157
|
+
const labelOf = (id) => {
|
|
1158
|
+
const m = rec.usableModels.find((x) => x.id === id);
|
|
1159
|
+
return m ? vLabel(m) : id;
|
|
1160
|
+
};
|
|
1161
|
+
const platformPick = rec.usableModels.find((m) => m.isDefault)?.id ?? rec.usableModels[0]?.id;
|
|
1162
|
+
const modelStepFor = (preselect, message) => makeStep(
|
|
1163
|
+
"model",
|
|
1164
|
+
message,
|
|
1165
|
+
enumField(
|
|
1166
|
+
"Model",
|
|
1167
|
+
rec.usableModels.map((m) => m.id),
|
|
1168
|
+
preselect,
|
|
1169
|
+
rec.usableModels.map(vLabel)
|
|
1170
|
+
)
|
|
1171
|
+
);
|
|
1172
|
+
const requested = typeof args.model === "string" ? rec.usableModels.find((m) => m.id === args.model)?.id : void 0;
|
|
1173
|
+
let modelStep;
|
|
1174
|
+
let fixedModelId;
|
|
1175
|
+
if (opts?.enforceModelConfirm) {
|
|
1176
|
+
if (requested && pinned && requested === pinned) {
|
|
1177
|
+
modelStep = null;
|
|
1178
|
+
fixedModelId = pinned;
|
|
1179
|
+
} else if (!requested && pinned) {
|
|
1180
|
+
if (type === "text2video") {
|
|
1181
|
+
modelStep = null;
|
|
1182
|
+
fixedModelId = pinned;
|
|
1183
|
+
} else {
|
|
1184
|
+
modelStep = modelStepFor(
|
|
1185
|
+
platformPick ?? pinned,
|
|
1186
|
+
`\u786E\u8BA4\u89C6\u9891\u751F\u6210\u6A21\u578B\uFF08\u6A21\u5F0F ${type}\uFF0C\u5DF2\u4E0A\u4F20\u7D20\u6750\uFF1B\u63A8\u8350\u300C${labelOf(platformPick ?? pinned)}\u300D\uFF0C\u53EF\u6539\u9009\uFF09`
|
|
1187
|
+
);
|
|
1188
|
+
fixedModelId = void 0;
|
|
1189
|
+
}
|
|
1190
|
+
} else if (requested && pinned && requested !== pinned) {
|
|
1191
|
+
modelStep = modelStepFor(
|
|
1192
|
+
requested,
|
|
1193
|
+
`\u786E\u8BA4\u66F4\u6362\u89C6\u9891\u751F\u6210\u6A21\u578B\uFF08\u6A21\u5F0F ${type}\uFF1B\u4F60\u6B64\u524D\u786E\u8BA4\u7684\u662F\u300C${labelOf(pinned)}\u300D\uFF0C\u672C\u6B21\u8BF7\u6C42\u6362\u6210\u300C${labelOf(requested)}\u300D\uFF0C\u786E\u8BA4\u6216\u6539\u56DE\uFF09`
|
|
1194
|
+
);
|
|
1195
|
+
fixedModelId = void 0;
|
|
1196
|
+
} else {
|
|
1197
|
+
modelStep = modelStepFor(requested ?? rec.recommendedModelId, `\u786E\u8BA4\u89C6\u9891\u751F\u6210\u6A21\u578B\uFF08\u6A21\u5F0F ${type}\uFF0C\u63A8\u8350\uFF1A${recLabel}\uFF09`);
|
|
1198
|
+
fixedModelId = void 0;
|
|
1199
|
+
}
|
|
1200
|
+
} else {
|
|
1201
|
+
modelStep = requested ? null : modelStepFor(rec.recommendedModelId, `\u786E\u8BA4\u89C6\u9891\u751F\u6210\u6A21\u578B\uFF08\u6A21\u5F0F ${type}\uFF0C\u63A8\u8350\uFF1A${recLabel}\uFF09`);
|
|
1202
|
+
fixedModelId = requested;
|
|
1203
|
+
}
|
|
1204
|
+
const buildParams = (modelId) => {
|
|
1205
|
+
const m = rec.usableModels.find((x) => x.id === modelId) ?? rec.model;
|
|
1206
|
+
return videoParamsFor(m, args, type);
|
|
1207
|
+
};
|
|
1208
|
+
const recommended = {
|
|
1209
|
+
...rec.recommendedModelId ? { model: rec.recommendedModelId } : {},
|
|
1210
|
+
...videoParamsFor(rec.model, args, type).defaults
|
|
1211
|
+
};
|
|
1212
|
+
return { modelStep, fixedModelId, buildParams, recommended };
|
|
1213
|
+
}
|
|
1214
|
+
function sanitizeContent(content, schema) {
|
|
1215
|
+
if (!content) return {};
|
|
1216
|
+
const out = {};
|
|
1217
|
+
for (const key of Object.keys(schema.properties)) {
|
|
1218
|
+
const v = content[key];
|
|
1219
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
1220
|
+
const propType = schema.properties[key]?.type;
|
|
1221
|
+
if ((propType === "integer" || propType === "number") && typeof v !== "number") continue;
|
|
1222
|
+
if (propType === "string" && typeof v !== "string") continue;
|
|
1223
|
+
out[key] = v;
|
|
1224
|
+
}
|
|
1225
|
+
return out;
|
|
1226
|
+
}
|
|
1227
|
+
async function elicitStep(elicit, step) {
|
|
1228
|
+
try {
|
|
1229
|
+
return await elicit({ message: step.message, requestedSchema: step.schema });
|
|
1230
|
+
} catch {
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
async function resolveConfirmation(plan, opts) {
|
|
1235
|
+
const { elicit } = opts;
|
|
1236
|
+
if (opts.unattended) {
|
|
1237
|
+
return { status: "proceed", values: plan.recommended, confirmed: false };
|
|
1238
|
+
}
|
|
1239
|
+
const unavailable = (values2, confirmed) => opts.requireConfirm ? { status: "cancelled" } : { status: "proceed", values: values2, confirmed };
|
|
1240
|
+
if (!elicit) {
|
|
1241
|
+
return unavailable(plan.recommended, false);
|
|
1242
|
+
}
|
|
1243
|
+
const recModel = typeof plan.recommended.model === "string" ? plan.recommended.model : "";
|
|
1244
|
+
let chosenModelId;
|
|
1245
|
+
if (plan.modelStep) {
|
|
1246
|
+
const modelRes = await elicitStep(elicit, plan.modelStep);
|
|
1247
|
+
if (modelRes === null) {
|
|
1248
|
+
return unavailable(plan.recommended, false);
|
|
1249
|
+
}
|
|
1250
|
+
if (modelRes.action !== "accept") {
|
|
1251
|
+
return { status: "cancelled" };
|
|
1252
|
+
}
|
|
1253
|
+
const clean = sanitizeContent(modelRes.content, plan.modelStep.schema);
|
|
1254
|
+
chosenModelId = typeof clean.model === "string" && clean.model || plan.fixedModelId || recModel;
|
|
1255
|
+
} else {
|
|
1256
|
+
chosenModelId = plan.fixedModelId || recModel;
|
|
1257
|
+
}
|
|
1258
|
+
const { steps, defaults } = plan.buildParams(chosenModelId);
|
|
1259
|
+
const values = { ...defaults };
|
|
1260
|
+
if (chosenModelId) values.model = chosenModelId;
|
|
1261
|
+
for (const step of steps) {
|
|
1262
|
+
const res = await elicitStep(elicit, step);
|
|
1263
|
+
if (res === null) {
|
|
1264
|
+
return unavailable(values, true);
|
|
1265
|
+
}
|
|
1266
|
+
if (res.action !== "accept") {
|
|
1267
|
+
return { status: "cancelled" };
|
|
1268
|
+
}
|
|
1269
|
+
const clean = sanitizeContent(res.content, step.schema);
|
|
1270
|
+
if (clean[step.key] !== void 0) values[step.key] = clean[step.key];
|
|
1271
|
+
}
|
|
1272
|
+
return { status: "proceed", values, confirmed: plan.modelStep !== null || steps.length > 0 };
|
|
1273
|
+
}
|
|
1274
|
+
function cancelledResult(mediaKind, adjustable) {
|
|
1275
|
+
return {
|
|
1276
|
+
content: [
|
|
1277
|
+
{
|
|
1278
|
+
type: "text",
|
|
1279
|
+
// 文案刻意不写「然后重试」——那会诱导 agent 立刻重发(重弹确认,用户体验=拒绝后一直弹)。
|
|
1280
|
+
// 取消是用户的明确决定:停手、问用户、等其改了请求或明确要求重试再说,不要自动重发。
|
|
1281
|
+
text: `${mediaKind} generation was cancelled by the user at the confirmation step. Nothing was generated and no credits were charged. Do NOT re-issue this generation on your own. Ask the user how they want to adjust the request (${adjustable}) and wait for their reply; only generate again if they change the request or explicitly tell you to retry.`
|
|
1282
|
+
}
|
|
1283
|
+
]
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// ../uniai-core/src/tools/types.ts
|
|
1288
|
+
var AGENT_GENERATION_SOURCE = "codex";
|
|
1289
|
+
function toMcpError(err, prefix) {
|
|
1290
|
+
const msg2 = err instanceof Error ? err.message : String(err);
|
|
1291
|
+
return {
|
|
1292
|
+
isError: true,
|
|
1293
|
+
content: [{ type: "text", text: `${prefix}: ${msg2}` }]
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// ../uniai-core/src/tools/generate-image.ts
|
|
1298
|
+
var STYLE_MAP = {
|
|
1299
|
+
auto: "auto",
|
|
1300
|
+
photography: "<photography>",
|
|
1301
|
+
portrait: "<portrait>",
|
|
1302
|
+
anime: "<anime>",
|
|
1303
|
+
oil_painting: "<oil painting>",
|
|
1304
|
+
watercolor: "<watercolor>",
|
|
1305
|
+
sketch: "<sketch>",
|
|
1306
|
+
cartoon: "<cartoon>",
|
|
1307
|
+
flat: "<flat illustration>"
|
|
1308
|
+
};
|
|
1309
|
+
function mapImageStyle(friendly) {
|
|
1310
|
+
return STYLE_MAP[friendly.trim().toLowerCase()];
|
|
1311
|
+
}
|
|
1312
|
+
var generateImageTool = {
|
|
1313
|
+
name: "generate_image",
|
|
1314
|
+
description: "Generate an image from a text prompt using UniAI image models \u2014 optionally guided by reference images (image-to-image: variation, style or character consistency). Use when the user asks for an image, picture, illustration, photo, or any visual creation from a description, or wants a new image based on an existing/uploaded one. The image model is chosen by the platform automatically. Returns a URL to the generated image (not inline image bytes; fetch via curl if you need the file).",
|
|
1315
|
+
inputSchema: {
|
|
1316
|
+
type: "object",
|
|
1317
|
+
properties: {
|
|
1318
|
+
prompt: {
|
|
1319
|
+
type: "string",
|
|
1320
|
+
description: "Image description in natural language. Required."
|
|
1321
|
+
},
|
|
1322
|
+
model: {
|
|
1323
|
+
type: "string",
|
|
1324
|
+
description: 'Optional UniAI image model id. Defaults to "gpt-image-2" (strong typography + precise semantics). Leave unset unless the user names a different model.'
|
|
1325
|
+
},
|
|
1326
|
+
aspectRatio: {
|
|
1327
|
+
type: "string",
|
|
1328
|
+
description: `Aspect ratio. gpt-image-2 supports "1:1" (default), "16:9", "9:16", "4:3", "3:4", "21:9", "3:1". Defaults to "1:1". Supported ratios are per-model; the confirmation popup offers the selected model's ratios.`,
|
|
1329
|
+
default: "1:1"
|
|
1330
|
+
},
|
|
1331
|
+
resolution: {
|
|
1332
|
+
type: "string",
|
|
1333
|
+
description: 'Output resolution \u2014 PER-MODEL. gpt-image-2 is 1K only; nano-banana supports "1K"/"2K"/"4K" (and nano-banana-2 also "0.5K"). Leave unset to use the model default; the confirmation popup offers the selected model\'s supported resolutions. (Models with quality tiers like gpt-image-2 use `quality` instead of resolution.)'
|
|
1334
|
+
},
|
|
1335
|
+
quality: {
|
|
1336
|
+
type: "string",
|
|
1337
|
+
description: 'Render quality for gpt-image-2: "low" (default \u2014 fast, cheapest), "medium", or "high". Cost rises steeply (high \u2248 20\xD7 low). Use "low" for drafts/iterations, "medium" for normal final assets, and "high" only for dense text/typography, identity-sensitive work, or when the user explicitly asks for top quality.',
|
|
1338
|
+
enum: ["low", "medium", "high"],
|
|
1339
|
+
default: "low"
|
|
1340
|
+
},
|
|
1341
|
+
count: {
|
|
1342
|
+
type: "number",
|
|
1343
|
+
description: "How many images to generate, 1-4 (default 1). Only use >1 when the user explicitly wants multiple options \u2014 each extra image costs additional credits.",
|
|
1344
|
+
minimum: 1,
|
|
1345
|
+
maximum: 4,
|
|
1346
|
+
default: 1
|
|
1347
|
+
},
|
|
1348
|
+
negativePrompt: {
|
|
1349
|
+
type: "string",
|
|
1350
|
+
description: 'What to AVOID in the image (e.g. "blurry, low quality, extra fingers, watermark, text"). Optional.'
|
|
1351
|
+
},
|
|
1352
|
+
style: {
|
|
1353
|
+
type: "string",
|
|
1354
|
+
description: 'Preset visual style. Default "auto" (let the model decide). Use only when the user wants a specific look; otherwise describe the style in the prompt instead.',
|
|
1355
|
+
enum: ["auto", "photography", "portrait", "anime", "oil_painting", "watercolor", "sketch", "cartoon", "flat"]
|
|
1356
|
+
},
|
|
1357
|
+
seed: {
|
|
1358
|
+
type: "number",
|
|
1359
|
+
description: "Random seed for reproducible results \u2014 reuse the same seed + prompt to regenerate the same image (e.g. to tweak a prompt while keeping composition). Optional."
|
|
1360
|
+
},
|
|
1361
|
+
referenceImages: {
|
|
1362
|
+
type: "array",
|
|
1363
|
+
items: { type: "string" },
|
|
1364
|
+
description: "Image-to-image reference(s): each a local file path, an http(s) URL, or a base64 data URL. Guides generation toward the reference (variation, style or character consistency). For images the user uploaded, pass their local file paths \u2014 the tool uploads them for you; never invent a URL. Optional; up to 5 (gpt-image-2). Each reference adds credits."
|
|
1365
|
+
},
|
|
1366
|
+
resumeTaskId: {
|
|
1367
|
+
type: "string",
|
|
1368
|
+
description: 'Resume an existing image task by its task id (the one returned in a "still generating" message) instead of starting a new one. When set, ALL other parameters are ignored \u2014 the tool only continues polling that task and returns its result once ready. It does NOT create a new image and does NOT charge extra credits. Leave unset for a normal new generation.'
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
required: ["prompt"]
|
|
1372
|
+
},
|
|
1373
|
+
handler: async (args, env, ctx) => {
|
|
1374
|
+
try {
|
|
1375
|
+
const resumeId = typeof args.resumeTaskId === "string" && args.resumeTaskId.trim() !== "" ? args.resumeTaskId.trim() : void 0;
|
|
1376
|
+
if (resumeId) {
|
|
1377
|
+
return await pollImageTask(env, resumeId, void 0, false);
|
|
1378
|
+
}
|
|
1379
|
+
const prompt = args.prompt;
|
|
1380
|
+
if (typeof prompt !== "string" || prompt.trim() === "") {
|
|
1381
|
+
return toMcpError(
|
|
1382
|
+
new Error("prompt must be a non-empty string"),
|
|
1383
|
+
"Image generation failed"
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
if (prompt.length > 1e3) {
|
|
1387
|
+
return toMcpError(
|
|
1388
|
+
new Error(
|
|
1389
|
+
`prompt is ${prompt.length} characters; the limit is 1000. Make it more concise (merge details into fewer, denser sentences) and retry.`
|
|
1390
|
+
),
|
|
1391
|
+
"Image generation failed"
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
const imageModels = await fetchImageModels(env);
|
|
1395
|
+
const refCount = Array.isArray(args.referenceImages) ? args.referenceImages.filter((x) => typeof x === "string" && x.trim() !== "").length : 0;
|
|
1396
|
+
const memory = ctx?.confirmMemory;
|
|
1397
|
+
let confirmed = {};
|
|
1398
|
+
if (imageModels.length > 0) {
|
|
1399
|
+
const plan = planImageConfirmation(
|
|
1400
|
+
imageModels,
|
|
1401
|
+
{
|
|
1402
|
+
model: typeof args.model === "string" ? args.model : void 0,
|
|
1403
|
+
aspectRatio: typeof args.aspectRatio === "string" ? args.aspectRatio : void 0,
|
|
1404
|
+
quality: typeof args.quality === "string" ? args.quality : void 0,
|
|
1405
|
+
resolution: typeof args.resolution === "string" ? args.resolution : void 0,
|
|
1406
|
+
count: typeof args.count === "number" ? args.count : void 0,
|
|
1407
|
+
referenceImageCount: refCount
|
|
1408
|
+
},
|
|
1409
|
+
memory ? { pinnedModel: memory.lastModel.image, enforceModelConfirm: true } : void 0
|
|
1410
|
+
);
|
|
1411
|
+
if (plan) {
|
|
1412
|
+
const outcome = await resolveConfirmation(plan, {
|
|
1413
|
+
unattended: env.unattended,
|
|
1414
|
+
elicit: ctx?.elicit,
|
|
1415
|
+
// 交互态收敛掉原生审批后(env.confirmRequired),本表单是唯一花费闸 → 机制失败时 fail-closed 取消。
|
|
1416
|
+
requireConfirm: env.confirmRequired
|
|
1417
|
+
});
|
|
1418
|
+
if (outcome.status === "cancelled") {
|
|
1419
|
+
return cancelledResult("Image", "model / aspect ratio / quality / count");
|
|
1420
|
+
}
|
|
1421
|
+
confirmed = outcome.values;
|
|
1422
|
+
if (memory && outcome.confirmed && typeof outcome.values.model === "string") {
|
|
1423
|
+
memory.lastModel.image = outcome.values.model;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const a = { ...args, ...confirmed };
|
|
1428
|
+
const chosenModel = typeof a.model === "string" ? imageModels.find((m) => m.modelId === a.model) : void 0;
|
|
1429
|
+
const aspectRatio = clampToModelEnum(
|
|
1430
|
+
typeof a.aspectRatio === "string" && a.aspectRatio.trim() !== "" ? a.aspectRatio : "1:1",
|
|
1431
|
+
chosenModel?.supportedAspectRatios
|
|
1432
|
+
) ?? "1:1";
|
|
1433
|
+
const body = { prompt, aspectRatio };
|
|
1434
|
+
body.source = AGENT_GENERATION_SOURCE;
|
|
1435
|
+
body.model = typeof a.model === "string" && a.model.trim() !== "" ? a.model : "gpt-image-2";
|
|
1436
|
+
{
|
|
1437
|
+
const q = typeof a.quality === "string" ? a.quality.trim().toLowerCase() : "";
|
|
1438
|
+
const supportsQuality = !chosenModel || (chosenModel.supportedQualities?.length ?? 0) > 0;
|
|
1439
|
+
if (supportsQuality) body.quality = q === "medium" || q === "high" ? q : "low";
|
|
1440
|
+
}
|
|
1441
|
+
if (typeof a.negativePrompt === "string" && a.negativePrompt.trim() !== "") {
|
|
1442
|
+
if (a.negativePrompt.length > 1e3) {
|
|
1443
|
+
return toMcpError(
|
|
1444
|
+
new Error(`negativePrompt is ${a.negativePrompt.length} characters; the limit is 1000. Shorten it and retry.`),
|
|
1445
|
+
"Image generation failed"
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
body.negativePrompt = a.negativePrompt;
|
|
1449
|
+
}
|
|
1450
|
+
if (typeof a.count === "number" && Number.isFinite(a.count)) {
|
|
1451
|
+
const maxCount = chosenModel?.maxCount ?? 4;
|
|
1452
|
+
body.count = Math.min(maxCount, Math.max(1, Math.round(a.count)));
|
|
1453
|
+
}
|
|
1454
|
+
if (typeof a.resolution === "string" && a.resolution.trim() !== "") {
|
|
1455
|
+
const clampedRes = clampToModelEnum(a.resolution, chosenModel?.supportedResolutions, "1K");
|
|
1456
|
+
if (clampedRes) body.resolution = clampedRes;
|
|
1457
|
+
}
|
|
1458
|
+
if (typeof a.style === "string" && a.style.trim() !== "") {
|
|
1459
|
+
const mapped = mapImageStyle(a.style);
|
|
1460
|
+
if (mapped) body.style = mapped;
|
|
1461
|
+
}
|
|
1462
|
+
if (typeof a.seed === "number" && Number.isFinite(a.seed)) {
|
|
1463
|
+
body.seed = Math.round(a.seed);
|
|
1464
|
+
}
|
|
1465
|
+
if (Array.isArray(a.referenceImages) && a.referenceImages.length > 0) {
|
|
1466
|
+
const cap = chosenModel?.maxReferenceImages && chosenModel.maxReferenceImages > 0 ? chosenModel.maxReferenceImages : 5;
|
|
1467
|
+
const inputs = a.referenceImages.filter((x) => typeof x === "string" && x.trim() !== "").slice(0, cap);
|
|
1468
|
+
if (inputs.length > 0) {
|
|
1469
|
+
body.referenceImageUrls = await Promise.all(
|
|
1470
|
+
inputs.map((i) => ensureImageUrl(env, i))
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
const created = await callUniAI(
|
|
1475
|
+
env,
|
|
1476
|
+
"POST",
|
|
1477
|
+
"/image/generate",
|
|
1478
|
+
body
|
|
1479
|
+
);
|
|
1480
|
+
if (created.status === "completed" && created.images?.length) {
|
|
1481
|
+
return successResult(await stabilizeImages(env, created.images), created.model);
|
|
1482
|
+
}
|
|
1483
|
+
if (created.status === "failed") {
|
|
1484
|
+
return toMcpError(
|
|
1485
|
+
new Error(created.error || "unknown upstream error"),
|
|
1486
|
+
"Image generation failed"
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
return await pollImageTask(env, created.taskId, created.model);
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
return toMcpError(err, "Image generation failed");
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
async function pollImageTask(env, taskId, modelHint, stabilize = true) {
|
|
1496
|
+
let polled;
|
|
1497
|
+
try {
|
|
1498
|
+
polled = await pollTask(
|
|
1499
|
+
env,
|
|
1500
|
+
`/image/task/${encodeURIComponent(taskId)}`,
|
|
1501
|
+
{
|
|
1502
|
+
maxMs: POLL_MAX_MS,
|
|
1503
|
+
intervalMs: 5e3,
|
|
1504
|
+
isTerminal: (snap) => {
|
|
1505
|
+
const s = snap?.status;
|
|
1506
|
+
if (s === "completed") return "done";
|
|
1507
|
+
if (s === "failed") return "failed";
|
|
1508
|
+
return "continue";
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
);
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
if (err instanceof PollTimeoutError) {
|
|
1514
|
+
return stillProcessingResult(taskId);
|
|
1515
|
+
}
|
|
1516
|
+
throw err;
|
|
1517
|
+
}
|
|
1518
|
+
if (polled.status === "failed") {
|
|
1519
|
+
return toMcpError(
|
|
1520
|
+
new Error(polled.snapshot.error || "task failed"),
|
|
1521
|
+
"Image generation failed"
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
const urls = polled.snapshot.images ?? [];
|
|
1525
|
+
if (urls.length === 0) {
|
|
1526
|
+
return toMcpError(
|
|
1527
|
+
new Error("task completed but no images returned"),
|
|
1528
|
+
"Image generation failed"
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
return successResult(
|
|
1532
|
+
stabilize ? await stabilizeImages(env, urls) : urls,
|
|
1533
|
+
polled.snapshot.model ?? modelHint
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
async function stabilizeImages(env, urls) {
|
|
1537
|
+
return Promise.all(urls.map((u) => stabilizeMediaUrl(env, u, "image")));
|
|
1538
|
+
}
|
|
1539
|
+
function stillProcessingResult(taskId) {
|
|
1540
|
+
const lines = [
|
|
1541
|
+
"The image is still being generated \u2014 this is NORMAL for high-quality or image-to-image (reference) requests, which can take several minutes. The task did NOT fail; it keeps running on the server.",
|
|
1542
|
+
`Task id: ${taskId}`,
|
|
1543
|
+
`To get the result, call generate_image again with ONLY { "resumeTaskId": "${taskId}" }. This resumes polling the SAME task \u2014 it does NOT start a new generation and does NOT charge extra credits. Repeat until it returns image URLs.`
|
|
1544
|
+
];
|
|
1545
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1546
|
+
}
|
|
1547
|
+
function successResult(urls, model) {
|
|
1548
|
+
const lines = [
|
|
1549
|
+
`Image generated successfully${model ? ` (model: ${model})` : ""}.`,
|
|
1550
|
+
`URLs:`,
|
|
1551
|
+
...urls.map((u, i) => ` ${i + 1}. ${u}`)
|
|
1552
|
+
];
|
|
1553
|
+
return {
|
|
1554
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// ../uniai-core/src/tools/edit-image.ts
|
|
1559
|
+
var editImageTool = {
|
|
1560
|
+
name: "edit_image",
|
|
1561
|
+
description: "Remove the background from an image, producing a transparent (or solid-color) cutout of the main subject. Use when the user asks to remove a background, cut out a subject, isolate an object, or make a transparent PNG from an existing image. Accepts a local file path, an http(s) URL, or a base64 data URL, and returns a URL to the processed image.",
|
|
1562
|
+
inputSchema: {
|
|
1563
|
+
type: "object",
|
|
1564
|
+
properties: {
|
|
1565
|
+
imageUrl: {
|
|
1566
|
+
type: "string",
|
|
1567
|
+
description: "Source image \u2014 a local file path, an http(s) URL (e.g. a generate_image output), or a base64 data URL. For an image the user uploaded, pass its local file path; do not invent a URL. Required."
|
|
1568
|
+
},
|
|
1569
|
+
outputFormat: {
|
|
1570
|
+
type: "string",
|
|
1571
|
+
description: 'Output image format. "png" (default, supports transparency), "jpg", or "webp".',
|
|
1572
|
+
enum: ["png", "jpg", "webp"],
|
|
1573
|
+
default: "png"
|
|
1574
|
+
},
|
|
1575
|
+
edgeType: {
|
|
1576
|
+
type: "string",
|
|
1577
|
+
description: 'Edge handling. "sharp" (default, crisp edges) or "feather" (soft edges).',
|
|
1578
|
+
enum: ["sharp", "feather"],
|
|
1579
|
+
default: "sharp"
|
|
1580
|
+
},
|
|
1581
|
+
resumeTaskId: {
|
|
1582
|
+
type: "string",
|
|
1583
|
+
description: 'Resume an existing background-removal task by its task id (the one returned in a "still processing" message) instead of starting a new one. When set, ALL other parameters are ignored \u2014 the tool only continues polling that task and returns its result once ready. It does NOT reprocess the image and does NOT charge extra credits. Leave unset for a normal new edit.'
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
required: ["imageUrl"]
|
|
1587
|
+
},
|
|
1588
|
+
handler: async (args, env) => {
|
|
1589
|
+
try {
|
|
1590
|
+
const resumeId = typeof args.resumeTaskId === "string" && args.resumeTaskId.trim() !== "" ? args.resumeTaskId.trim() : void 0;
|
|
1591
|
+
if (resumeId) {
|
|
1592
|
+
return await pollBgRemovalTask(env, resumeId);
|
|
1593
|
+
}
|
|
1594
|
+
const imageUrl = args.imageUrl;
|
|
1595
|
+
if (typeof imageUrl !== "string" || imageUrl.trim() === "") {
|
|
1596
|
+
return toMcpError(
|
|
1597
|
+
new Error("imageUrl must be a non-empty string (file path, URL, or data URL)"),
|
|
1598
|
+
"Image edit failed"
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
const body = { imageUrl: await ensureImageUrl(env, imageUrl) };
|
|
1602
|
+
if (typeof args.outputFormat === "string") {
|
|
1603
|
+
body.outputFormat = args.outputFormat;
|
|
1604
|
+
}
|
|
1605
|
+
if (typeof args.edgeType === "string") {
|
|
1606
|
+
body.edgeType = args.edgeType;
|
|
1607
|
+
}
|
|
1608
|
+
const created = await callUniAI(
|
|
1609
|
+
env,
|
|
1610
|
+
"POST",
|
|
1611
|
+
"/image/remove-background",
|
|
1612
|
+
body
|
|
1613
|
+
);
|
|
1614
|
+
if (created.status === "completed" && created.result?.imageUrl) {
|
|
1615
|
+
return successResult2(created.result.imageUrl);
|
|
1616
|
+
}
|
|
1617
|
+
if (created.status === "failed") {
|
|
1618
|
+
return toMcpError(
|
|
1619
|
+
new Error(created.error || "unknown upstream error"),
|
|
1620
|
+
"Image edit failed"
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
return await pollBgRemovalTask(env, created.taskId);
|
|
1624
|
+
} catch (err) {
|
|
1625
|
+
return toMcpError(err, "Image edit failed");
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
async function pollBgRemovalTask(env, taskId) {
|
|
1630
|
+
let polled;
|
|
1631
|
+
try {
|
|
1632
|
+
polled = await pollTask(
|
|
1633
|
+
env,
|
|
1634
|
+
`/image/processing/task/${encodeURIComponent(taskId)}`,
|
|
1635
|
+
{
|
|
1636
|
+
maxMs: POLL_MAX_MS,
|
|
1637
|
+
intervalMs: 5e3,
|
|
1638
|
+
isTerminal: (snap) => {
|
|
1639
|
+
const s = snap?.status;
|
|
1640
|
+
if (s === "completed") return "done";
|
|
1641
|
+
if (s === "failed") return "failed";
|
|
1642
|
+
return "continue";
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
);
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
if (err instanceof PollTimeoutError) {
|
|
1648
|
+
return stillProcessingResult2(taskId);
|
|
1649
|
+
}
|
|
1650
|
+
throw err;
|
|
1651
|
+
}
|
|
1652
|
+
if (polled.status === "failed") {
|
|
1653
|
+
return toMcpError(
|
|
1654
|
+
new Error(polled.snapshot.error || "task failed"),
|
|
1655
|
+
"Image edit failed"
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
const url = polled.snapshot.result?.imageUrl;
|
|
1659
|
+
if (!url) {
|
|
1660
|
+
return toMcpError(
|
|
1661
|
+
new Error("task completed but no result.imageUrl returned"),
|
|
1662
|
+
"Image edit failed"
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
return successResult2(url);
|
|
1666
|
+
}
|
|
1667
|
+
function stillProcessingResult2(taskId) {
|
|
1668
|
+
const lines = [
|
|
1669
|
+
"Background removal is still processing \u2014 the task did NOT fail; it keeps running on the server.",
|
|
1670
|
+
`Task id: ${taskId}`,
|
|
1671
|
+
`To get the result, call edit_image again with ONLY { "resumeTaskId": "${taskId}" }. This resumes polling the SAME task \u2014 it does NOT reprocess the image and does NOT charge extra credits. Repeat until it returns a URL.`
|
|
1672
|
+
];
|
|
1673
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1674
|
+
}
|
|
1675
|
+
function successResult2(imageUrl) {
|
|
1676
|
+
return {
|
|
1677
|
+
content: [
|
|
1678
|
+
{
|
|
1679
|
+
type: "text",
|
|
1680
|
+
text: `Background removed successfully.
|
|
1681
|
+
URL: ${imageUrl}`
|
|
1682
|
+
}
|
|
1683
|
+
]
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/lib/output.ts
|
|
1688
|
+
import { writeFile as writeFile2 } from "node:fs/promises";
|
|
1689
|
+
function extractUrl(text) {
|
|
1690
|
+
const m = /https?:\/\/[^\s)\]]+/.exec(text);
|
|
1691
|
+
if (!m) return null;
|
|
1692
|
+
return m[0].replace(/[.,;:)\]}'"]+$/, "");
|
|
1693
|
+
}
|
|
1694
|
+
async function emit(opts) {
|
|
1695
|
+
const { text, isError, asJson, download } = opts;
|
|
1696
|
+
if (download) {
|
|
1697
|
+
const url = extractUrl(text);
|
|
1698
|
+
if (!url) {
|
|
1699
|
+
throw new Error(
|
|
1700
|
+
"No URL found in the result, nothing to download. (Use this command without --download to see the text output.)"
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
const res = await fetch(url);
|
|
1704
|
+
if (!res.ok) {
|
|
1705
|
+
throw new Error(`Failed to download ${url}: HTTP ${res.status}`);
|
|
1706
|
+
}
|
|
1707
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1708
|
+
await writeFile2(download, buf);
|
|
1709
|
+
process.stderr.write(`Downloaded ${buf.length} bytes to ${download}
|
|
1710
|
+
`);
|
|
1711
|
+
}
|
|
1712
|
+
if (asJson) {
|
|
1713
|
+
const url = extractUrl(text);
|
|
1714
|
+
const payload = {
|
|
1715
|
+
ok: !isError,
|
|
1716
|
+
text
|
|
1717
|
+
};
|
|
1718
|
+
if (url) payload.url = url;
|
|
1719
|
+
process.stdout.write(JSON.stringify(payload) + "\n");
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
process.stdout.write(text + (text.endsWith("\n") ? "" : "\n"));
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/lib/run-tool.ts
|
|
1726
|
+
async function runTool(tool, args, flags) {
|
|
1727
|
+
const asJson = flags.json === true || flags.json === "true";
|
|
1728
|
+
const download = typeof flags.download === "string" ? flags.download : null;
|
|
1729
|
+
try {
|
|
1730
|
+
const env = await resolveEnv(flags);
|
|
1731
|
+
const result = await tool.handler(args, env);
|
|
1732
|
+
const content = Array.isArray(result.content) ? result.content : [];
|
|
1733
|
+
const text = content.map(
|
|
1734
|
+
(c) => c && typeof c === "object" && typeof c.text === "string" ? c.text : ""
|
|
1735
|
+
).filter((s) => s.length > 0).join("\n");
|
|
1736
|
+
if (result.isError) return fail(text, asJson);
|
|
1737
|
+
await emit({ text, asJson, download });
|
|
1738
|
+
return 0;
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
return fail(err instanceof Error ? err.message : String(err), asJson);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
function fail(rawMsg, asJson) {
|
|
1744
|
+
if (asJson) {
|
|
1745
|
+
process.stdout.write(JSON.stringify({ ok: false, error: rawMsg }) + "\n");
|
|
1746
|
+
} else {
|
|
1747
|
+
process.stderr.write(humanizeError(rawMsg) + "\n");
|
|
1748
|
+
}
|
|
1749
|
+
return 1;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// src/commands/image.ts
|
|
1753
|
+
var num = (v) => typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : void 0;
|
|
1754
|
+
function sizeToAspectRatio(size) {
|
|
1755
|
+
return { "1024x1024": "1:1", "1280x720": "16:9", "720x1280": "9:16" }[size.trim()];
|
|
1756
|
+
}
|
|
1757
|
+
async function runImage(rest, flags) {
|
|
1758
|
+
const verb = rest[0];
|
|
1759
|
+
const asJson = flags.json === true || flags.json === "true";
|
|
1760
|
+
if (verb === "generate") {
|
|
1761
|
+
const prompt = rest.slice(1).join(" ").trim();
|
|
1762
|
+
if (!prompt) {
|
|
1763
|
+
console.error(
|
|
1764
|
+
'usage: uniai image generate "<prompt>" [--model <id>] [--aspect-ratio 1:1|16:9|9:16|4:3|3:4|21:9|3:1] [--count <n>] [--quality low|medium|high] [--resolution 1K|2K|4K] [--negative-prompt <text>] [--seed <n>] [--reference <path|url>[,...]] [--download <file>] [--json]'
|
|
1765
|
+
);
|
|
1766
|
+
console.error(
|
|
1767
|
+
"note: --reference does image-to-image (local file path / URL / data URL; local files are uploaded for you, up to 5). Run `uniai image models` to list models. Anything you do not pass uses the platform-recommended default."
|
|
1768
|
+
);
|
|
1769
|
+
return 2;
|
|
1770
|
+
}
|
|
1771
|
+
const refRaw = typeof flags.reference === "string" ? flags.reference : typeof flags.image === "string" ? flags.image : void 0;
|
|
1772
|
+
const referenceImages = refRaw ? refRaw.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
|
|
1773
|
+
const aspectRatio = typeof flags["aspect-ratio"] === "string" ? flags["aspect-ratio"] : typeof flags.size === "string" ? sizeToAspectRatio(flags.size) : void 0;
|
|
1774
|
+
return runTool(
|
|
1775
|
+
generateImageTool,
|
|
1776
|
+
{
|
|
1777
|
+
prompt,
|
|
1778
|
+
model: flags.model,
|
|
1779
|
+
...aspectRatio ? { aspectRatio } : {},
|
|
1780
|
+
...typeof flags.quality === "string" ? { quality: flags.quality } : {},
|
|
1781
|
+
...typeof flags.resolution === "string" ? { resolution: flags.resolution } : {},
|
|
1782
|
+
...num(flags.count) !== void 0 ? { count: num(flags.count) } : {},
|
|
1783
|
+
...typeof flags["negative-prompt"] === "string" ? { negativePrompt: flags["negative-prompt"] } : {},
|
|
1784
|
+
...typeof flags.style === "string" ? { style: flags.style } : {},
|
|
1785
|
+
...num(flags.seed) !== void 0 ? { seed: num(flags.seed) } : {},
|
|
1786
|
+
...referenceImages && referenceImages.length > 0 ? { referenceImages } : {}
|
|
1787
|
+
},
|
|
1788
|
+
flags
|
|
1789
|
+
);
|
|
1790
|
+
}
|
|
1791
|
+
if (verb === "models") {
|
|
1792
|
+
try {
|
|
1793
|
+
const env = await resolveEnv(flags);
|
|
1794
|
+
const models = await fetchImageModels(env);
|
|
1795
|
+
if (asJson) {
|
|
1796
|
+
process.stdout.write(JSON.stringify({ ok: true, models }) + "\n");
|
|
1797
|
+
return 0;
|
|
1798
|
+
}
|
|
1799
|
+
if (models.length === 0) {
|
|
1800
|
+
process.stderr.write(
|
|
1801
|
+
"No image models returned (the catalog may be temporarily unavailable; the platform recommends one automatically when you generate).\n"
|
|
1802
|
+
);
|
|
1803
|
+
return 0;
|
|
1804
|
+
}
|
|
1805
|
+
for (const m of models) {
|
|
1806
|
+
const bits = [
|
|
1807
|
+
m.supportedAspectRatios?.length ? `ratios:${m.supportedAspectRatios.join("/")}` : "",
|
|
1808
|
+
m.supportedQualities?.length ? `quality:${m.supportedQualities.join("/")}` : "",
|
|
1809
|
+
m.maxCount ? `max:${m.maxCount}` : "",
|
|
1810
|
+
m.maxReferenceImages ? `refs:${m.maxReferenceImages}` : ""
|
|
1811
|
+
].filter(Boolean).join(" ");
|
|
1812
|
+
process.stdout.write(`${m.modelId} ${m.displayName}${bits ? " " + bits : ""}
|
|
1813
|
+
`);
|
|
1814
|
+
}
|
|
1815
|
+
return 0;
|
|
1816
|
+
} catch (err) {
|
|
1817
|
+
return fail(err instanceof Error ? err.message : String(err), asJson);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
if (verb === "edit") {
|
|
1821
|
+
const imageUrl = typeof flags.image === "string" ? flags.image : void 0;
|
|
1822
|
+
if (!imageUrl) {
|
|
1823
|
+
console.error("usage: uniai image edit --image <url> [--output-format png|jpg|webp] [--edge-type sharp|feather] [--download <file>]");
|
|
1824
|
+
console.error('note: --image must be a URL (e.g. a URL returned by `uniai image generate`); local-file upload is not supported yet. For "change clothing / new pose from my photo", use `image generate --reference` instead.');
|
|
1825
|
+
return 2;
|
|
1826
|
+
}
|
|
1827
|
+
return runTool(
|
|
1828
|
+
editImageTool,
|
|
1829
|
+
{ imageUrl, outputFormat: flags["output-format"], edgeType: flags["edge-type"] },
|
|
1830
|
+
flags
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
console.error("usage: uniai image generate|edit|models ...");
|
|
1834
|
+
return 2;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// ../uniai-core/src/tools/generate-video.ts
|
|
1838
|
+
var PROMPT_MAX = 5e3;
|
|
1839
|
+
var NEGATIVE_PROMPT_MAX = 500;
|
|
1840
|
+
var VIDEO_TYPES = [
|
|
1841
|
+
"text2video",
|
|
1842
|
+
"image2video",
|
|
1843
|
+
"start_end_frame",
|
|
1844
|
+
"reference_to_video",
|
|
1845
|
+
"video_to_video",
|
|
1846
|
+
"video_continuation"
|
|
1847
|
+
];
|
|
1848
|
+
function asStr(v) {
|
|
1849
|
+
return typeof v === "string" && v.trim() !== "" ? v : void 0;
|
|
1850
|
+
}
|
|
1851
|
+
function asStrArray(v, max) {
|
|
1852
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "").slice(0, max) : [];
|
|
1853
|
+
}
|
|
1854
|
+
function deriveVideoType(args) {
|
|
1855
|
+
const refCount = asStrArray(args.referenceImages, 9).length + asStrArray(args.referenceVideos, 3).length + asStrArray(args.referenceAudios, 3).length;
|
|
1856
|
+
if (asStr(args.continuationVideo)) return "video_continuation";
|
|
1857
|
+
if (asStr(args.sourceVideo)) return "video_to_video";
|
|
1858
|
+
if (refCount > 0) return "reference_to_video";
|
|
1859
|
+
if (asStr(args.firstFrame) && asStr(args.lastFrame)) return "start_end_frame";
|
|
1860
|
+
if (asStr(args.firstFrame)) return "image2video";
|
|
1861
|
+
return "text2video";
|
|
1862
|
+
}
|
|
1863
|
+
function contentCountsFor(type, args) {
|
|
1864
|
+
switch (type) {
|
|
1865
|
+
case "reference_to_video":
|
|
1866
|
+
return {
|
|
1867
|
+
image: asStrArray(args.referenceImages, 9).length,
|
|
1868
|
+
video: asStrArray(args.referenceVideos, 3).length,
|
|
1869
|
+
audio: asStrArray(args.referenceAudios, 3).length
|
|
1870
|
+
};
|
|
1871
|
+
case "image2video":
|
|
1872
|
+
return { image: asStr(args.firstFrame) ? 1 : 0, video: 0, audio: 0 };
|
|
1873
|
+
case "start_end_frame":
|
|
1874
|
+
return { image: (asStr(args.firstFrame) ? 1 : 0) + (asStr(args.lastFrame) ? 1 : 0), video: 0, audio: 0 };
|
|
1875
|
+
case "video_to_video":
|
|
1876
|
+
return { image: 0, video: asStr(args.sourceVideo) ? 1 : 0, audio: 0 };
|
|
1877
|
+
case "video_continuation":
|
|
1878
|
+
return { image: 0, video: asStr(args.continuationVideo) ? 1 : 0, audio: 0 };
|
|
1879
|
+
default:
|
|
1880
|
+
return { image: 0, video: 0, audio: 0 };
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
var generateVideoTool = {
|
|
1884
|
+
name: "generate_video",
|
|
1885
|
+
description: "Generate a short video clip with UniAI video models. The generation mode is AUTO-DERIVED from the media you attach \u2014 just put the user's uploaded material on the right field and the tool figures out the rest: firstFrame (animate an image), firstFrame+lastFrame (interpolate first\u2192last), referenceImages / referenceVideos / referenceAudios (omni reference \u2014 guide the video with reference images, clips, and/or audio; e.g. Seedance 2.0), sourceVideo (edit/restyle a clip), continuationVideo (extend a clip), or none (text-to-video). Media inputs accept a local file path, an http(s) URL, or a base64 data URL \u2014 for files the user uploaded, pass their local paths. The tool narrows the selectable models to those that can actually consume what you attached (e.g. image+video+audio reference \u2192 only omni models like Seedance 2.0) and defaults to the platform-recommended one. Returns a URL to the generated video. IMPORTANT: video generation is slow and frequently takes longer than a single call can wait \u2014 when it does, this tool returns a task id and you MUST call it again with resumeTaskId=<that id> to keep waiting (this resumes the SAME job; it does not re-generate or re-charge). Resuming is the normal happy path for video, not an error.",
|
|
1886
|
+
inputSchema: {
|
|
1887
|
+
type: "object",
|
|
1888
|
+
properties: {
|
|
1889
|
+
prompt: {
|
|
1890
|
+
type: "string",
|
|
1891
|
+
description: "Scene / motion description in natural language. Required."
|
|
1892
|
+
},
|
|
1893
|
+
type: {
|
|
1894
|
+
type: "string",
|
|
1895
|
+
description: "Optional hint only \u2014 the actual mode is AUTO-DERIVED from the media you provide, so usually you do not set this. Just attach the user's uploaded material to the right field and the tool picks the mode + the models that can handle it: firstFrame \u2192 image2video; firstFrame+lastFrame \u2192 start_end_frame; any of referenceImages/referenceVideos/referenceAudios \u2192 reference_to_video (omni); sourceVideo \u2192 video_to_video; continuationVideo \u2192 video_continuation; nothing \u2192 text2video.",
|
|
1896
|
+
enum: [...VIDEO_TYPES],
|
|
1897
|
+
default: "text2video"
|
|
1898
|
+
},
|
|
1899
|
+
model: {
|
|
1900
|
+
type: "string",
|
|
1901
|
+
description: "Optional UniAI video model id. Leave empty to use the platform default (recommended; currently Seedance 2.0, which supports all modes). Only set if you know a valid model id."
|
|
1902
|
+
},
|
|
1903
|
+
aspectRatio: {
|
|
1904
|
+
type: "string",
|
|
1905
|
+
description: 'Video aspect ratio. "16:9" (default), "9:16", "1:1", "4:3", "3:4", "3:2", "2:3", "21:9", or "adaptive" (match the input image/video).',
|
|
1906
|
+
enum: ["21:9", "16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16", "adaptive"],
|
|
1907
|
+
default: "16:9"
|
|
1908
|
+
},
|
|
1909
|
+
duration: {
|
|
1910
|
+
type: "number",
|
|
1911
|
+
description: "Video duration in seconds, 2-15 (default 5). Longer videos cost more credits; not all models support every length.",
|
|
1912
|
+
minimum: 2,
|
|
1913
|
+
maximum: 15,
|
|
1914
|
+
default: 5
|
|
1915
|
+
},
|
|
1916
|
+
resolution: {
|
|
1917
|
+
type: "string",
|
|
1918
|
+
description: `Output resolution \u2014 PER-MODEL: "480p" (cheapest, e.g. Seedance 2.0), "720p" (default) or "1080p" (full HD, most expensive). Supported tiers vary by model; the tool clamps to the selected model's supported resolutions (a value the model does not support falls back to its default). When omitted, sends "720p".`,
|
|
1919
|
+
enum: ["480p", "720p", "1080p"],
|
|
1920
|
+
default: "720p"
|
|
1921
|
+
},
|
|
1922
|
+
negativePrompt: {
|
|
1923
|
+
type: "string",
|
|
1924
|
+
description: "What to avoid in the video. Optional (only some models support it; ignored otherwise)."
|
|
1925
|
+
},
|
|
1926
|
+
seed: {
|
|
1927
|
+
type: "number",
|
|
1928
|
+
description: "Random seed for reproducible results. Optional."
|
|
1929
|
+
},
|
|
1930
|
+
firstFrame: {
|
|
1931
|
+
type: "string",
|
|
1932
|
+
description: 'First-frame image for "image2video" / "start_end_frame": a local file path, an http(s) URL, or a base64 data URL. For an uploaded image, pass its local path; never invent a URL.'
|
|
1933
|
+
},
|
|
1934
|
+
lastFrame: {
|
|
1935
|
+
type: "string",
|
|
1936
|
+
description: 'Last-frame image for "start_end_frame": a local file path, an http(s) URL, or a base64 data URL.'
|
|
1937
|
+
},
|
|
1938
|
+
referenceImages: {
|
|
1939
|
+
type: "array",
|
|
1940
|
+
items: { type: "string" },
|
|
1941
|
+
description: "Reference images for reference-to-video (style / subject / character guidance); each a local file path, an http(s) URL, or a base64 data URL. Up to 9."
|
|
1942
|
+
},
|
|
1943
|
+
referenceVideos: {
|
|
1944
|
+
type: "array",
|
|
1945
|
+
items: { type: "string" },
|
|
1946
|
+
description: "Reference videos for reference-to-video (omni reference, e.g. Seedance 2.0 \u2014 use a clip as cinematic/subject reference); each a local file path, an http(s) URL, or a base64 data URL. Up to 3. Only omni models accept these; the tool narrows the model list to those that can."
|
|
1947
|
+
},
|
|
1948
|
+
referenceAudios: {
|
|
1949
|
+
type: "array",
|
|
1950
|
+
items: { type: "string" },
|
|
1951
|
+
description: "Reference audios for reference-to-video (omni reference, e.g. Seedance 2.0 \u2014 drive the generated video with this audio); each a local file path, an http(s) URL, or a base64 data URL (mp3 / wav, up to ~15s each). Up to 3. Only omni models accept these; the tool narrows the model list to those that can."
|
|
1952
|
+
},
|
|
1953
|
+
sourceVideo: {
|
|
1954
|
+
type: "string",
|
|
1955
|
+
description: 'Source video for "video_to_video" (the clip to edit/restyle): a local file path, an http(s) URL, or a base64 data URL.'
|
|
1956
|
+
},
|
|
1957
|
+
v2vEndpoint: {
|
|
1958
|
+
type: "string",
|
|
1959
|
+
description: 'For "video_to_video" only: "edit" (default \u2014 modify the source, keep its length) or "reference" (generate a new scene using the source as cinematic reference). Model-specific (Kling).',
|
|
1960
|
+
enum: ["edit", "reference"]
|
|
1961
|
+
},
|
|
1962
|
+
continuationVideo: {
|
|
1963
|
+
type: "string",
|
|
1964
|
+
description: 'Video to extend for "video_continuation": a local file path, an http(s) URL, or a base64 data URL. The new clip continues from this video.'
|
|
1965
|
+
},
|
|
1966
|
+
resumeTaskId: {
|
|
1967
|
+
type: "string",
|
|
1968
|
+
description: 'Resume an existing video task by its task id (the one returned in a "still processing" message) instead of starting a new one. When set, ALL other parameters are ignored \u2014 the tool only continues polling that task and returns its result once ready. It does NOT start a new render and does NOT charge extra credits. This is the normal way to keep waiting for a long video; leave unset for a new generation.'
|
|
1969
|
+
}
|
|
1970
|
+
},
|
|
1971
|
+
required: ["prompt"]
|
|
1972
|
+
},
|
|
1973
|
+
handler: async (args, env, ctx) => {
|
|
1974
|
+
try {
|
|
1975
|
+
const resumeId = asStr(args.resumeTaskId);
|
|
1976
|
+
if (resumeId) {
|
|
1977
|
+
return await pollVideoTask(env, resumeId);
|
|
1978
|
+
}
|
|
1979
|
+
const prompt = args.prompt;
|
|
1980
|
+
if (typeof prompt !== "string" || prompt.trim() === "") {
|
|
1981
|
+
return toMcpError(new Error("prompt must be a non-empty string"), "Video generation failed");
|
|
1982
|
+
}
|
|
1983
|
+
if (prompt.length > PROMPT_MAX) {
|
|
1984
|
+
return toMcpError(
|
|
1985
|
+
new Error(`prompt is ${prompt.length} characters; the limit is ${PROMPT_MAX}. Shorten it and retry.`),
|
|
1986
|
+
"Video generation failed"
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
const negativePrompt = asStr(args.negativePrompt);
|
|
1990
|
+
if (negativePrompt && negativePrompt.length > NEGATIVE_PROMPT_MAX) {
|
|
1991
|
+
return toMcpError(
|
|
1992
|
+
new Error(`negativePrompt is ${negativePrompt.length} characters; the limit is ${NEGATIVE_PROMPT_MAX}. Shorten it and retry.`),
|
|
1993
|
+
"Video generation failed"
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
const type = deriveVideoType(args);
|
|
1997
|
+
const content = contentCountsFor(type, args);
|
|
1998
|
+
const videoModels = await fetchVideoModels(env);
|
|
1999
|
+
const memory = ctx?.confirmMemory;
|
|
2000
|
+
let confirmed = {};
|
|
2001
|
+
if (videoModels.length > 0) {
|
|
2002
|
+
const plan = planVideoConfirmation(
|
|
2003
|
+
videoModels,
|
|
2004
|
+
type,
|
|
2005
|
+
{
|
|
2006
|
+
model: asStr(args.model),
|
|
2007
|
+
aspectRatio: asStr(args.aspectRatio),
|
|
2008
|
+
duration: typeof args.duration === "number" ? args.duration : void 0,
|
|
2009
|
+
resolution: asStr(args.resolution)
|
|
2010
|
+
},
|
|
2011
|
+
memory ? { pinnedModel: memory.lastModel.video, enforceModelConfirm: true, content } : { content }
|
|
2012
|
+
);
|
|
2013
|
+
if (plan) {
|
|
2014
|
+
const outcome = await resolveConfirmation(plan, {
|
|
2015
|
+
unattended: env.unattended,
|
|
2016
|
+
elicit: ctx?.elicit,
|
|
2017
|
+
// 交互态收敛掉原生审批后(env.confirmRequired),本表单是唯一花费闸 → 机制失败时 fail-closed 取消。
|
|
2018
|
+
requireConfirm: env.confirmRequired
|
|
2019
|
+
});
|
|
2020
|
+
if (outcome.status === "cancelled") {
|
|
2021
|
+
return cancelledResult("Video", "model / aspect ratio / duration / resolution");
|
|
2022
|
+
}
|
|
2023
|
+
confirmed = outcome.values;
|
|
2024
|
+
if (memory && outcome.confirmed && typeof outcome.values.model === "string") {
|
|
2025
|
+
memory.lastModel.video = outcome.values.model;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const a = { ...args, ...confirmed };
|
|
2030
|
+
const modelArg = asStr(a.model);
|
|
2031
|
+
const chosenVideoModel = modelArg ? videoModels.find((m) => m.id === modelArg) : void 0;
|
|
2032
|
+
const aspectRatio = clampToModelEnum(asStr(a.aspectRatio) ?? "16:9", chosenVideoModel?.supportedAspectRatios) ?? "16:9";
|
|
2033
|
+
const durNum = Number(a.duration);
|
|
2034
|
+
const duration = snapDurationToModel(Number.isFinite(durNum) ? durNum : 5, chosenVideoModel);
|
|
2035
|
+
const body = { type, prompt, aspectRatio, duration };
|
|
2036
|
+
body.source = AGENT_GENERATION_SOURCE;
|
|
2037
|
+
if (modelArg) body.model = modelArg;
|
|
2038
|
+
body.resolution = clampToModelEnum(
|
|
2039
|
+
asStr(a.resolution) ?? "720p",
|
|
2040
|
+
chosenVideoModel?.supportedResolutions,
|
|
2041
|
+
chosenVideoModel?.defaultResolution ?? "720p"
|
|
2042
|
+
) ?? "720p";
|
|
2043
|
+
if (negativePrompt) body.negativePrompt = negativePrompt;
|
|
2044
|
+
if (typeof a.seed === "number" && Number.isFinite(a.seed)) body.seed = Math.round(a.seed);
|
|
2045
|
+
if (chosenVideoModel?.supportsAudio === true && type !== "video_to_video") {
|
|
2046
|
+
body.generateAudio = a.audio !== "off";
|
|
2047
|
+
}
|
|
2048
|
+
const fail2 = (msg2) => toMcpError(new Error(msg2), "Video generation failed");
|
|
2049
|
+
if (type === "image2video") {
|
|
2050
|
+
const f = asStr(args.firstFrame);
|
|
2051
|
+
if (!f) return fail2("firstFrame is required for image2video (image file path, URL, or data URL)");
|
|
2052
|
+
body.startFrameUrl = await ensureMediaUrl(env, f, "image");
|
|
2053
|
+
} else if (type === "start_end_frame") {
|
|
2054
|
+
const f = asStr(args.firstFrame);
|
|
2055
|
+
const l = asStr(args.lastFrame);
|
|
2056
|
+
if (!f || !l) return fail2("start_end_frame requires both firstFrame and lastFrame (image file path, URL, or data URL)");
|
|
2057
|
+
const [su, eu] = await Promise.all([
|
|
2058
|
+
ensureMediaUrl(env, f, "image"),
|
|
2059
|
+
ensureMediaUrl(env, l, "image")
|
|
2060
|
+
]);
|
|
2061
|
+
body.startFrameUrl = su;
|
|
2062
|
+
body.endFrameUrl = eu;
|
|
2063
|
+
} else if (type === "reference_to_video") {
|
|
2064
|
+
const refImgs = asStrArray(args.referenceImages, 9);
|
|
2065
|
+
const refVids = asStrArray(args.referenceVideos, 3);
|
|
2066
|
+
const refAuds = asStrArray(args.referenceAudios, 3);
|
|
2067
|
+
if (refImgs.length + refVids.length + refAuds.length === 0) {
|
|
2068
|
+
return fail2("reference_to_video requires at least one reference image, video, or audio (file path, http(s) URL, or data URL)");
|
|
2069
|
+
}
|
|
2070
|
+
const [imgUrls, vidUrls, audUrls] = await Promise.all([
|
|
2071
|
+
Promise.all(refImgs.map((r) => ensureMediaUrl(env, r, "image"))),
|
|
2072
|
+
Promise.all(refVids.map((r) => ensureMediaUrl(env, r, "video"))),
|
|
2073
|
+
Promise.all(refAuds.map((r) => ensureMediaUrl(env, r, "audio")))
|
|
2074
|
+
]);
|
|
2075
|
+
if (imgUrls.length > 0) body.elements = imgUrls.map((u) => ({ frontal_image_url: u }));
|
|
2076
|
+
if (vidUrls.length > 0) body.referenceVideoUrls = vidUrls;
|
|
2077
|
+
if (audUrls.length > 0) body.referenceAudioUrls = audUrls;
|
|
2078
|
+
} else if (type === "video_to_video") {
|
|
2079
|
+
const v = asStr(args.sourceVideo);
|
|
2080
|
+
if (!v) return fail2("video_to_video requires sourceVideo (video file path, URL, or data URL)");
|
|
2081
|
+
body.referenceVideoUrl = await ensureMediaUrl(env, v, "video");
|
|
2082
|
+
if (args.v2vEndpoint === "reference") {
|
|
2083
|
+
body.type = "video_reference_to_video";
|
|
2084
|
+
}
|
|
2085
|
+
} else if (type === "video_continuation") {
|
|
2086
|
+
const v = asStr(args.continuationVideo);
|
|
2087
|
+
if (!v) return fail2("video_continuation requires continuationVideo (video file path, URL, or data URL)");
|
|
2088
|
+
body.referenceVideoUrls = [await ensureMediaUrl(env, v, "video")];
|
|
2089
|
+
}
|
|
2090
|
+
let created;
|
|
2091
|
+
try {
|
|
2092
|
+
created = await callUniAI(
|
|
2093
|
+
env,
|
|
2094
|
+
"POST",
|
|
2095
|
+
"/video-agent-v2/generate",
|
|
2096
|
+
body
|
|
2097
|
+
);
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
const msg2 = err instanceof Error ? err.message : String(err);
|
|
2100
|
+
if (msg2.includes("\u7D20\u6750\u5BA1\u6838")) {
|
|
2101
|
+
return await handleVideoComplianceGate(env, body, modelArg);
|
|
2102
|
+
}
|
|
2103
|
+
throw err;
|
|
2104
|
+
}
|
|
2105
|
+
if (created.status === "completed" && created.videoUrl) {
|
|
2106
|
+
return successResult3(created.videoUrl, created.coverUrl, modelArg);
|
|
2107
|
+
}
|
|
2108
|
+
if (created.status === "failed") {
|
|
2109
|
+
return toMcpError(
|
|
2110
|
+
new Error(created.error || "unknown upstream error"),
|
|
2111
|
+
"Video generation failed"
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
return await pollVideoTask(env, created.taskId, modelArg);
|
|
2115
|
+
} catch (err) {
|
|
2116
|
+
return toMcpError(err, "Video generation failed");
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
async function pollVideoTask(env, taskId, modelHint, maxMs = POLL_MAX_MS) {
|
|
2121
|
+
let polled;
|
|
2122
|
+
try {
|
|
2123
|
+
polled = await pollTask(
|
|
2124
|
+
env,
|
|
2125
|
+
`/video-agent-v2/task/${encodeURIComponent(taskId)}`,
|
|
2126
|
+
{
|
|
2127
|
+
maxMs,
|
|
2128
|
+
intervalMs: 1e4,
|
|
2129
|
+
isTerminal: (snap) => {
|
|
2130
|
+
const s = snap?.status;
|
|
2131
|
+
if (s === "completed") return "done";
|
|
2132
|
+
if (s === "failed") return "failed";
|
|
2133
|
+
return "continue";
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
);
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
if (err instanceof PollTimeoutError) {
|
|
2139
|
+
return stillProcessingResult3(taskId);
|
|
2140
|
+
}
|
|
2141
|
+
throw err;
|
|
2142
|
+
}
|
|
2143
|
+
if (polled.status === "failed") {
|
|
2144
|
+
return toMcpError(
|
|
2145
|
+
new Error(polled.snapshot.error || "task failed"),
|
|
2146
|
+
"Video generation failed"
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
if (!polled.snapshot.videoUrl) {
|
|
2150
|
+
return toMcpError(
|
|
2151
|
+
new Error("task completed but no videoUrl returned"),
|
|
2152
|
+
"Video generation failed"
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
return successResult3(polled.snapshot.videoUrl, polled.snapshot.coverUrl, modelHint);
|
|
2156
|
+
}
|
|
2157
|
+
var COMPLIANCE_SETTLE_MAX_MS = 2e5;
|
|
2158
|
+
var COMPLIANCE_RETRY_INTERVAL_MS = 5e3;
|
|
2159
|
+
async function stabilizeVideoBodyMedia(env, body) {
|
|
2160
|
+
if (typeof body.startFrameUrl === "string") {
|
|
2161
|
+
body.startFrameUrl = await stabilizeMediaUrl(env, body.startFrameUrl, "image");
|
|
2162
|
+
}
|
|
2163
|
+
if (typeof body.endFrameUrl === "string") {
|
|
2164
|
+
body.endFrameUrl = await stabilizeMediaUrl(env, body.endFrameUrl, "image");
|
|
2165
|
+
}
|
|
2166
|
+
if (typeof body.referenceVideoUrl === "string") {
|
|
2167
|
+
body.referenceVideoUrl = await stabilizeMediaUrl(env, body.referenceVideoUrl, "video");
|
|
2168
|
+
}
|
|
2169
|
+
if (Array.isArray(body.referenceVideoUrls)) {
|
|
2170
|
+
body.referenceVideoUrls = await Promise.all(
|
|
2171
|
+
body.referenceVideoUrls.map((u) => typeof u === "string" ? stabilizeMediaUrl(env, u, "video") : u)
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
if (Array.isArray(body.referenceAudioUrls)) {
|
|
2175
|
+
body.referenceAudioUrls = await Promise.all(
|
|
2176
|
+
body.referenceAudioUrls.map((u) => typeof u === "string" ? stabilizeMediaUrl(env, u, "audio") : u)
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
if (Array.isArray(body.elements)) {
|
|
2180
|
+
body.elements = await Promise.all(
|
|
2181
|
+
body.elements.map(async (el) => {
|
|
2182
|
+
const e = el;
|
|
2183
|
+
if (e && typeof e.frontal_image_url === "string") {
|
|
2184
|
+
return { ...e, frontal_image_url: await stabilizeMediaUrl(env, e.frontal_image_url, "image") };
|
|
2185
|
+
}
|
|
2186
|
+
return el;
|
|
2187
|
+
})
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
async function handleVideoComplianceGate(env, body, modelHint) {
|
|
2192
|
+
const rejected = (msg2) => {
|
|
2193
|
+
const m = /(([^)]+))/.exec(msg2) ?? /\(([^)]+)\)/.exec(msg2);
|
|
2194
|
+
const reason = m?.[1]?.trim();
|
|
2195
|
+
const isDimension = !!reason && /(width|height|too\s*small|too\s*large|must be between|\bpx\b|尺寸|分辨率|像素)/i.test(reason);
|
|
2196
|
+
const tail = isDimension ? "\u8BE5\u53C2\u8003\u56FE\u7684\u5C3A\u5BF8\u4E0D\u5728\u89C6\u9891\u5E73\u53F0\u5141\u8BB8\u8303\u56F4\u5185\uFF08\u901A\u5E38\u9700\u5BBD/\u9AD8\u5728\u7EA6 300\u20136000px \u4E4B\u95F4\uFF09\u3002\u7CFB\u7EDF\u5DF2\u81EA\u52A8\u653E\u5927\u8FC7\u5C0F\u7684\u53C2\u8003\u56FE\uFF0C\u6545\u6B64\u9519\u591A\u534A\u662F\u56FE\u7247\u3010\u8FC7\u5927\u3011\u6216\u6BD4\u4F8B\u8D85\u9650\u3002\u8BF7\u6539\u7528\u4E00\u5F20\u5C3A\u5BF8\u66F4\u5408\u9002\u7684\u53C2\u8003\u56FE\u540E\u91CD\u8BD5\u2014\u2014\u4E0D\u8981\u7528\u6A21\u578B\u91CD\u65B0\u751F\u6210\u6216\u8D85\u5206\u4E00\u5F20\u66F4\u5927\u7684\u56FE\u3002" : "\u8BE5\u7D20\u6750\u65E0\u6CD5\u7528\u4E8E\u751F\u6210\u89C6\u9891\uFF08\u53EF\u80FD\u5185\u5BB9\u4E0D\u5408\u89C4\uFF0C\u6216\u7D20\u6750\u94FE\u63A5\u5BA1\u6838\u670D\u52A1\u65E0\u6CD5\u8BFB\u53D6\uFF09\u3002\u8BF7\u66F4\u6362\u7D20\u6750\u540E\u91CD\u8BD5\u3002";
|
|
2197
|
+
return toMcpError(
|
|
2198
|
+
new Error(`\u7D20\u6750\u5BA1\u6838\u672A\u901A\u8FC7${reason ? `\uFF1A${reason}` : ""}\u3002${tail}`),
|
|
2199
|
+
"Video generation failed"
|
|
2200
|
+
);
|
|
2201
|
+
};
|
|
2202
|
+
await stabilizeVideoBodyMedia(env, body);
|
|
2203
|
+
const reactiveStart = Date.now();
|
|
2204
|
+
while (Date.now() - reactiveStart < COMPLIANCE_SETTLE_MAX_MS) {
|
|
2205
|
+
await new Promise((r) => setTimeout(r, COMPLIANCE_RETRY_INTERVAL_MS));
|
|
2206
|
+
let created;
|
|
2207
|
+
try {
|
|
2208
|
+
created = await callUniAI(env, "POST", "/video-agent-v2/generate", body);
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
2211
|
+
if (m.includes("\u7D20\u6750\u5BA1\u6838\u5931\u8D25")) return rejected(m);
|
|
2212
|
+
if (m.includes("\u7D20\u6750\u5BA1\u6838")) continue;
|
|
2213
|
+
return toMcpError(e, "Video generation failed");
|
|
2214
|
+
}
|
|
2215
|
+
if (created.status === "completed" && created.videoUrl) {
|
|
2216
|
+
return successResult3(created.videoUrl, created.coverUrl, modelHint);
|
|
2217
|
+
}
|
|
2218
|
+
if (created.status === "failed") {
|
|
2219
|
+
return toMcpError(new Error(created.error || "unknown upstream error"), "Video generation failed");
|
|
2220
|
+
}
|
|
2221
|
+
const remaining = Math.max(2e4, POLL_MAX_MS - (Date.now() - reactiveStart));
|
|
2222
|
+
return await pollVideoTask(env, created.taskId, modelHint, remaining);
|
|
2223
|
+
}
|
|
2224
|
+
return stillModeratingResult();
|
|
2225
|
+
}
|
|
2226
|
+
function stillModeratingResult() {
|
|
2227
|
+
const lines = [
|
|
2228
|
+
"The uploaded reference material is still under platform review (\u7D20\u6750\u5BA1\u6838) \u2014 this is REQUIRED for Seedance 2.0 and usually completes within a few minutes. The task did NOT fail and NO credits were charged; the review keeps running on the server.",
|
|
2229
|
+
"Tell the user their material is being reviewed and that they can ask you to generate the video again shortly. When you retry, use the SAME request (same model + same reference material) \u2014 the review will have progressed and the generation will proceed automatically once it is approved. Do NOT switch to text-only as a workaround."
|
|
2230
|
+
];
|
|
2231
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
2232
|
+
}
|
|
2233
|
+
function stillProcessingResult3(taskId) {
|
|
2234
|
+
const lines = [
|
|
2235
|
+
"The video is still being generated \u2014 this is NORMAL and expected; video rendering routinely takes several minutes (sometimes much longer). The task did NOT fail; it keeps running on the server.",
|
|
2236
|
+
`Task id: ${taskId}`,
|
|
2237
|
+
`To get the result, call generate_video again with ONLY { "resumeTaskId": "${taskId}" }. This resumes polling the SAME task \u2014 it does NOT start a new render and does NOT charge extra credits. Repeat until it returns a video URL.`
|
|
2238
|
+
];
|
|
2239
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
2240
|
+
}
|
|
2241
|
+
function successResult3(videoUrl, coverUrl, model) {
|
|
2242
|
+
const modelNote = model ? ` (model: ${model})` : "";
|
|
2243
|
+
const lines = [`Video generated successfully${modelNote}.`, `URL: ${videoUrl}`];
|
|
2244
|
+
if (coverUrl) lines.push(`Cover: ${coverUrl}`);
|
|
2245
|
+
return {
|
|
2246
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// src/commands/video.ts
|
|
2251
|
+
var num2 = (v) => typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : void 0;
|
|
2252
|
+
var str2 = (v) => typeof v === "string" && v.trim() !== "" ? v : void 0;
|
|
2253
|
+
var list = (v) => {
|
|
2254
|
+
if (typeof v !== "string") return void 0;
|
|
2255
|
+
const arr = v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2256
|
+
return arr.length > 0 ? arr : void 0;
|
|
2257
|
+
};
|
|
2258
|
+
async function runVideo(rest, flags) {
|
|
2259
|
+
const verb = rest[0];
|
|
2260
|
+
const asJson = flags.json === true || flags.json === "true";
|
|
2261
|
+
if (verb === "models") {
|
|
2262
|
+
try {
|
|
2263
|
+
const env = await resolveEnv(flags);
|
|
2264
|
+
const models = await fetchVideoModels(env);
|
|
2265
|
+
if (asJson) {
|
|
2266
|
+
process.stdout.write(JSON.stringify({ ok: true, models }) + "\n");
|
|
2267
|
+
return 0;
|
|
2268
|
+
}
|
|
2269
|
+
if (models.length === 0) {
|
|
2270
|
+
process.stderr.write(
|
|
2271
|
+
"No video models returned (the catalog may be temporarily unavailable; the platform recommends one automatically when you generate).\n"
|
|
2272
|
+
);
|
|
2273
|
+
return 0;
|
|
2274
|
+
}
|
|
2275
|
+
for (const m of models) {
|
|
2276
|
+
const dur = m.durationSteps?.length ? `dur:${m.durationSteps.join("/")}s` : m.minDuration || m.maxDuration ? `dur:${m.minDuration ?? "?"}-${m.maxDuration ?? "?"}s` : "";
|
|
2277
|
+
const bits = [
|
|
2278
|
+
m.supportedAspectRatios?.length ? `ratios:${m.supportedAspectRatios.join("/")}` : "",
|
|
2279
|
+
m.supportedResolutions?.length ? `res:${m.supportedResolutions.join("/")}` : "",
|
|
2280
|
+
dur,
|
|
2281
|
+
m.supportsAudio ? "audio" : "",
|
|
2282
|
+
m.maxReferenceImages ? `refs:${m.maxReferenceImages}` : "",
|
|
2283
|
+
m.inputs ? `modes:${Object.keys(m.inputs).join("/")}` : ""
|
|
2284
|
+
].filter(Boolean).join(" ");
|
|
2285
|
+
process.stdout.write(`${m.id} ${m.name ?? m.id}${bits ? " " + bits : ""}
|
|
2286
|
+
`);
|
|
2287
|
+
}
|
|
2288
|
+
return 0;
|
|
2289
|
+
} catch (err) {
|
|
2290
|
+
return fail(err instanceof Error ? err.message : String(err), asJson);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
if (verb !== "generate") {
|
|
2294
|
+
console.error('usage: uniai video generate "<prompt>" [...] | uniai video models');
|
|
2295
|
+
return 2;
|
|
2296
|
+
}
|
|
2297
|
+
const prompt = rest.slice(1).join(" ").trim();
|
|
2298
|
+
if (!prompt) {
|
|
2299
|
+
console.error(
|
|
2300
|
+
'usage: uniai video generate "<prompt>" [--model <id>] [--aspect-ratio 16:9|9:16|1:1|4:3|3:4|21:9|adaptive] [--duration <2-15>] [--resolution 480p|720p|1080p] [--first-frame <path>] [--last-frame <path>] [--reference <path,...>] [--reference-video <path,...>] [--reference-audio <path,...>] [--source-video <path>] [--continuation-video <path>] [--negative-prompt <text>] [--seed <n>] [--download <file>] [--json]'
|
|
2301
|
+
);
|
|
2302
|
+
console.error(
|
|
2303
|
+
"note: the MODE is auto-derived from the media you attach \u2014 --first-frame = animate an image; --first-frame + --last-frame = first\u2192last frame; --reference(/-video/-audio) = reference-to-video (omni); --source-video = edit/restyle a clip; --continuation-video = extend; none = text-to-video. Local files are uploaded for you. A prompt is always required. Run `uniai video models` for the model list."
|
|
2304
|
+
);
|
|
2305
|
+
return 2;
|
|
2306
|
+
}
|
|
2307
|
+
return runTool(
|
|
2308
|
+
generateVideoTool,
|
|
2309
|
+
{
|
|
2310
|
+
prompt,
|
|
2311
|
+
model: flags.model,
|
|
2312
|
+
...str2(flags["aspect-ratio"]) ? { aspectRatio: flags["aspect-ratio"] } : {},
|
|
2313
|
+
...num2(flags.duration) !== void 0 ? { duration: num2(flags.duration) } : {},
|
|
2314
|
+
...str2(flags.resolution) ? { resolution: flags.resolution } : {},
|
|
2315
|
+
...str2(flags["negative-prompt"]) ? { negativePrompt: flags["negative-prompt"] } : {},
|
|
2316
|
+
...num2(flags.seed) !== void 0 ? { seed: num2(flags.seed) } : {},
|
|
2317
|
+
...str2(flags["first-frame"]) ? { firstFrame: flags["first-frame"] } : {},
|
|
2318
|
+
...str2(flags["last-frame"]) ? { lastFrame: flags["last-frame"] } : {},
|
|
2319
|
+
...list(flags.reference) ? { referenceImages: list(flags.reference) } : {},
|
|
2320
|
+
...list(flags["reference-video"]) ? { referenceVideos: list(flags["reference-video"]) } : {},
|
|
2321
|
+
...list(flags["reference-audio"]) ? { referenceAudios: list(flags["reference-audio"]) } : {},
|
|
2322
|
+
...str2(flags["source-video"]) ? { sourceVideo: flags["source-video"] } : {},
|
|
2323
|
+
...str2(flags["continuation-video"]) ? { continuationVideo: flags["continuation-video"] } : {},
|
|
2324
|
+
...str2(flags["v2v-endpoint"]) ? { v2vEndpoint: flags["v2v-endpoint"] } : {}
|
|
2325
|
+
},
|
|
2326
|
+
flags
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// ../uniai-core/src/tools/text-to-speech.ts
|
|
2331
|
+
var textToSpeechTool = {
|
|
2332
|
+
name: "text_to_speech",
|
|
2333
|
+
description: "Convert text into natural-sounding spoken audio (text-to-speech / TTS). Use when the user asks to read text aloud, create a voiceover, generate narration, or turn text into speech. Returns a URL to the generated audio.",
|
|
2334
|
+
inputSchema: {
|
|
2335
|
+
type: "object",
|
|
2336
|
+
properties: {
|
|
2337
|
+
text: {
|
|
2338
|
+
type: "string",
|
|
2339
|
+
description: "The text to synthesize into speech (up to 5000 chars). Required."
|
|
2340
|
+
},
|
|
2341
|
+
voice: {
|
|
2342
|
+
type: "string",
|
|
2343
|
+
description: "Voice id to use. Optional; omit unless you know a valid voice id (a default voice is used otherwise)."
|
|
2344
|
+
},
|
|
2345
|
+
format: {
|
|
2346
|
+
type: "string",
|
|
2347
|
+
description: 'Audio format. "mp3" (default), "wav", "pcm", "opus", "aac", or "flac".',
|
|
2348
|
+
enum: ["mp3", "wav", "pcm", "opus", "aac", "flac"],
|
|
2349
|
+
default: "mp3"
|
|
2350
|
+
},
|
|
2351
|
+
rate: {
|
|
2352
|
+
type: "number",
|
|
2353
|
+
description: "Speaking rate multiplier from 0.5 (slow) to 2.0 (fast). Default 1.0.",
|
|
2354
|
+
minimum: 0.5,
|
|
2355
|
+
maximum: 2,
|
|
2356
|
+
default: 1
|
|
2357
|
+
}
|
|
2358
|
+
},
|
|
2359
|
+
required: ["text"]
|
|
2360
|
+
},
|
|
2361
|
+
handler: async (args, env) => {
|
|
2362
|
+
try {
|
|
2363
|
+
const text = args.text;
|
|
2364
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
2365
|
+
return toMcpError(
|
|
2366
|
+
new Error("text must be a non-empty string"),
|
|
2367
|
+
"Text-to-speech failed"
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
const body = { text };
|
|
2371
|
+
if (typeof args.voice === "string" && args.voice.trim() !== "") {
|
|
2372
|
+
body.voice = args.voice;
|
|
2373
|
+
}
|
|
2374
|
+
if (typeof args.format === "string") {
|
|
2375
|
+
body.format = args.format;
|
|
2376
|
+
}
|
|
2377
|
+
if (typeof args.rate === "number") {
|
|
2378
|
+
body.rate = args.rate;
|
|
2379
|
+
}
|
|
2380
|
+
const created = await callUniAI(env, "POST", "/speech/tts", body);
|
|
2381
|
+
if (created.status === "completed" && created.audioUrl) {
|
|
2382
|
+
return successResult4(created.audioUrl, created.duration);
|
|
2383
|
+
}
|
|
2384
|
+
if (created.status === "failed") {
|
|
2385
|
+
return toMcpError(
|
|
2386
|
+
new Error(created.error || "unknown upstream error"),
|
|
2387
|
+
"Text-to-speech failed"
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
const polled = await pollTask(
|
|
2391
|
+
env,
|
|
2392
|
+
`/speech/task/${encodeURIComponent(created.taskId)}`,
|
|
2393
|
+
{
|
|
2394
|
+
maxMs: 6e4,
|
|
2395
|
+
intervalMs: 3e3,
|
|
2396
|
+
isTerminal: (snap) => {
|
|
2397
|
+
const s = snap?.status;
|
|
2398
|
+
if (s === "completed") return "done";
|
|
2399
|
+
if (s === "failed") return "failed";
|
|
2400
|
+
return "continue";
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
);
|
|
2404
|
+
if (polled.status === "failed") {
|
|
2405
|
+
return toMcpError(
|
|
2406
|
+
new Error(polled.snapshot.error || "task failed"),
|
|
2407
|
+
"Text-to-speech failed"
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
if (!polled.snapshot.audioUrl) {
|
|
2411
|
+
return toMcpError(
|
|
2412
|
+
new Error("task completed but no audioUrl returned"),
|
|
2413
|
+
"Text-to-speech failed"
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
return successResult4(polled.snapshot.audioUrl, polled.snapshot.duration);
|
|
2417
|
+
} catch (err) {
|
|
2418
|
+
return toMcpError(err, "Text-to-speech failed");
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
function successResult4(audioUrl, duration) {
|
|
2423
|
+
const lines = ["Speech synthesized successfully.", `URL: ${audioUrl}`];
|
|
2424
|
+
if (typeof duration === "number") lines.push(`Duration: ${duration}s`);
|
|
2425
|
+
return {
|
|
2426
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// ../uniai-core/src/tools/speech-to-text.ts
|
|
2431
|
+
var STT_LANGUAGES = ["auto", "zh", "en", "ja", "ko"];
|
|
2432
|
+
var speechToTextTool = {
|
|
2433
|
+
name: "speech_to_text",
|
|
2434
|
+
description: "Transcribe spoken audio into written text (speech-to-text / STT). Use when the user wants to transcribe a recording, voice memo, voice message, or audio file into text. Accepts a local file path, an http(s) URL, or a base64 data URL (MP3/WAV/WebM/OGG/M4A, up to 10MB). Returns the transcript text.",
|
|
2435
|
+
inputSchema: {
|
|
2436
|
+
type: "object",
|
|
2437
|
+
properties: {
|
|
2438
|
+
audio: {
|
|
2439
|
+
type: "string",
|
|
2440
|
+
description: "The audio to transcribe: a local file path, an http(s) URL, or a base64 data URL. MP3/WAV/WebM/OGG/M4A, up to 10MB. Required."
|
|
2441
|
+
},
|
|
2442
|
+
language: {
|
|
2443
|
+
type: "string",
|
|
2444
|
+
description: 'Spoken-language hint. "auto" (default, auto-detect), "zh", "en", "ja", or "ko".',
|
|
2445
|
+
enum: STT_LANGUAGES,
|
|
2446
|
+
default: "auto"
|
|
2447
|
+
}
|
|
2448
|
+
},
|
|
2449
|
+
required: ["audio"]
|
|
2450
|
+
},
|
|
2451
|
+
handler: async (args, env) => {
|
|
2452
|
+
try {
|
|
2453
|
+
const audio = args.audio;
|
|
2454
|
+
if (typeof audio !== "string" || audio.trim() === "") {
|
|
2455
|
+
return toMcpError(
|
|
2456
|
+
new Error("audio must be a non-empty string (file path, URL, or data URL)"),
|
|
2457
|
+
"Speech-to-text failed"
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
const extra = {};
|
|
2461
|
+
if (typeof args.language === "string" && STT_LANGUAGES.includes(args.language)) {
|
|
2462
|
+
extra.language = args.language;
|
|
2463
|
+
}
|
|
2464
|
+
const file = await resolveFileInput(audio);
|
|
2465
|
+
const res = await postMultipart(
|
|
2466
|
+
env,
|
|
2467
|
+
"/speech/stt/realtime",
|
|
2468
|
+
"audio",
|
|
2469
|
+
file,
|
|
2470
|
+
extra,
|
|
2471
|
+
{ timeoutMs: POLL_MAX_MS }
|
|
2472
|
+
);
|
|
2473
|
+
if (typeof res.text !== "string") {
|
|
2474
|
+
return toMcpError(
|
|
2475
|
+
new Error("speech-to-text returned no text field"),
|
|
2476
|
+
"Speech-to-text failed"
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
const text = res.text.trim() === "" ? "(no speech detected in audio)" : res.text;
|
|
2480
|
+
return { content: [{ type: "text", text }] };
|
|
2481
|
+
} catch (err) {
|
|
2482
|
+
return toMcpError(err, "Speech-to-text failed");
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
};
|
|
2486
|
+
|
|
2487
|
+
// src/commands/speech.ts
|
|
2488
|
+
var num3 = (v) => typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : void 0;
|
|
2489
|
+
async function runSpeech(rest, flags) {
|
|
2490
|
+
const verb = rest[0];
|
|
2491
|
+
if (verb === "synthesize") {
|
|
2492
|
+
const text = rest.slice(1).join(" ").trim();
|
|
2493
|
+
if (!text) {
|
|
2494
|
+
console.error('usage: uniai speech synthesize "<text>" [--voice <id>] [--format mp3|wav|pcm|opus|aac|flac] [--rate 0.5-2.0] [--download <file>]');
|
|
2495
|
+
return 2;
|
|
2496
|
+
}
|
|
2497
|
+
return runTool(
|
|
2498
|
+
textToSpeechTool,
|
|
2499
|
+
{ text, voice: flags.voice, format: flags.format, rate: num3(flags.rate) },
|
|
2500
|
+
flags
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
if (verb === "recognize") {
|
|
2504
|
+
const audio = rest[1];
|
|
2505
|
+
if (!audio) {
|
|
2506
|
+
console.error("usage: uniai speech recognize <audio: path|url> [--language auto|zh|en|ja|ko] [--json]");
|
|
2507
|
+
return 2;
|
|
2508
|
+
}
|
|
2509
|
+
return runTool(speechToTextTool, { audio, language: flags.language }, flags);
|
|
2510
|
+
}
|
|
2511
|
+
console.error("usage: uniai speech synthesize|recognize ...");
|
|
2512
|
+
return 2;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// ../uniai-core/src/lib/sse-merge.ts
|
|
2516
|
+
function parseSseEvents(buffer) {
|
|
2517
|
+
let delta = "";
|
|
2518
|
+
let done = false;
|
|
2519
|
+
let error;
|
|
2520
|
+
const lastNl = buffer.lastIndexOf("\n");
|
|
2521
|
+
if (lastNl === -1) {
|
|
2522
|
+
return { delta: "", done: false, rest: buffer };
|
|
2523
|
+
}
|
|
2524
|
+
const complete = buffer.slice(0, lastNl);
|
|
2525
|
+
const rest = buffer.slice(lastNl + 1);
|
|
2526
|
+
for (const rawLine of complete.split("\n")) {
|
|
2527
|
+
const line = rawLine.trim();
|
|
2528
|
+
if (line === "" || !line.startsWith("data:")) continue;
|
|
2529
|
+
const payload = line.slice("data:".length).trim();
|
|
2530
|
+
if (payload === "") continue;
|
|
2531
|
+
if (payload === "[DONE]") {
|
|
2532
|
+
done = true;
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
try {
|
|
2536
|
+
const obj = JSON.parse(payload);
|
|
2537
|
+
if (typeof obj.content === "string") {
|
|
2538
|
+
delta += obj.content;
|
|
2539
|
+
} else if (typeof obj.error === "string") {
|
|
2540
|
+
error = obj.error;
|
|
2541
|
+
} else if (obj.error && typeof obj.error === "object" && typeof obj.error.message === "string") {
|
|
2542
|
+
error = obj.error.message;
|
|
2543
|
+
}
|
|
2544
|
+
} catch {
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
return { delta, done, error, rest };
|
|
2548
|
+
}
|
|
2549
|
+
async function mergeSse(env, path, body, opts) {
|
|
2550
|
+
const timeoutMs = opts?.timeoutMs ?? 12e4;
|
|
2551
|
+
const url = `${env.baseUrl}${path}`;
|
|
2552
|
+
const controller = new AbortController();
|
|
2553
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2554
|
+
try {
|
|
2555
|
+
const res = await fetch(url, {
|
|
2556
|
+
method: "POST",
|
|
2557
|
+
headers: {
|
|
2558
|
+
Authorization: `Bearer ${env.token}`,
|
|
2559
|
+
"Content-Type": "application/json",
|
|
2560
|
+
Accept: "text/event-stream"
|
|
2561
|
+
},
|
|
2562
|
+
body: JSON.stringify(body),
|
|
2563
|
+
signal: controller.signal
|
|
2564
|
+
});
|
|
2565
|
+
if (!res.ok) {
|
|
2566
|
+
let bodyText = "";
|
|
2567
|
+
try {
|
|
2568
|
+
bodyText = (await res.text()).slice(0, 200);
|
|
2569
|
+
} catch {
|
|
2570
|
+
bodyText = "<failed to read body>";
|
|
2571
|
+
}
|
|
2572
|
+
throw new Error(`UniAI POST ${path} -> HTTP ${res.status}: ${bodyText}`);
|
|
2573
|
+
}
|
|
2574
|
+
if (!res.body) {
|
|
2575
|
+
throw new Error(`UniAI POST ${path}: no SSE response body`);
|
|
2576
|
+
}
|
|
2577
|
+
const reader = res.body.getReader();
|
|
2578
|
+
const decoder = new TextDecoder();
|
|
2579
|
+
let buffer = "";
|
|
2580
|
+
let result = "";
|
|
2581
|
+
while (true) {
|
|
2582
|
+
const { done: streamDone, value } = await reader.read();
|
|
2583
|
+
if (value) {
|
|
2584
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2585
|
+
const parsed = parseSseEvents(buffer);
|
|
2586
|
+
result += parsed.delta;
|
|
2587
|
+
buffer = parsed.rest;
|
|
2588
|
+
if (parsed.error) {
|
|
2589
|
+
throw new Error(parsed.error);
|
|
2590
|
+
}
|
|
2591
|
+
if (parsed.done) {
|
|
2592
|
+
return result;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
if (streamDone) break;
|
|
2596
|
+
}
|
|
2597
|
+
if (buffer.trim() !== "") {
|
|
2598
|
+
const parsed = parseSseEvents(buffer + "\n");
|
|
2599
|
+
result += parsed.delta;
|
|
2600
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
2601
|
+
}
|
|
2602
|
+
return result;
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
2605
|
+
throw new Error(`UniAI POST ${path} timed out after ${timeoutMs}ms`);
|
|
2606
|
+
}
|
|
2607
|
+
throw err;
|
|
2608
|
+
} finally {
|
|
2609
|
+
clearTimeout(timer);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// ../uniai-core/src/tools/chat.ts
|
|
2614
|
+
var chatTool = {
|
|
2615
|
+
name: "chat",
|
|
2616
|
+
description: "Ask UniAI's configured chat model a question and get a single text answer. Use ONLY when the user explicitly wants a response from the UniAI platform's own chat model (which may use a different model than you), e.g. to compare answers or route to a specifically configured model. For ordinary conversation, answer directly instead of calling this. Returns the model's full reply as text.",
|
|
2617
|
+
inputSchema: {
|
|
2618
|
+
type: "object",
|
|
2619
|
+
properties: {
|
|
2620
|
+
message: {
|
|
2621
|
+
type: "string",
|
|
2622
|
+
description: "The message or question to send to the UniAI chat model. Required."
|
|
2623
|
+
}
|
|
2624
|
+
},
|
|
2625
|
+
required: ["message"]
|
|
2626
|
+
},
|
|
2627
|
+
handler: async (args, env) => {
|
|
2628
|
+
try {
|
|
2629
|
+
const message = args.message;
|
|
2630
|
+
if (typeof message !== "string" || message.trim() === "") {
|
|
2631
|
+
return toMcpError(
|
|
2632
|
+
new Error("message must be a non-empty string"),
|
|
2633
|
+
"Chat failed"
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
const text = await mergeSse(env, "/chat/stream", { message }, { timeoutMs: 12e4 });
|
|
2637
|
+
if (text.trim() === "") {
|
|
2638
|
+
return toMcpError(
|
|
2639
|
+
new Error("chat returned an empty response"),
|
|
2640
|
+
"Chat failed"
|
|
2641
|
+
);
|
|
2642
|
+
}
|
|
2643
|
+
return { content: [{ type: "text", text }] };
|
|
2644
|
+
} catch (err) {
|
|
2645
|
+
return toMcpError(err, "Chat failed");
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
};
|
|
2649
|
+
|
|
2650
|
+
// src/commands/chat.ts
|
|
2651
|
+
async function runChat(rest, flags) {
|
|
2652
|
+
const message = rest.join(" ").trim();
|
|
2653
|
+
if (!message) {
|
|
2654
|
+
console.error('usage: uniai chat "<message>" [--json]');
|
|
2655
|
+
return 2;
|
|
2656
|
+
}
|
|
2657
|
+
return runTool(chatTool, { message }, flags);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// ../uniai-core/src/tools/generate-code.ts
|
|
2661
|
+
var CODE_LANGUAGES = [
|
|
2662
|
+
"javascript",
|
|
2663
|
+
"typescript",
|
|
2664
|
+
"python",
|
|
2665
|
+
"java",
|
|
2666
|
+
"cpp",
|
|
2667
|
+
"c",
|
|
2668
|
+
"csharp",
|
|
2669
|
+
"go",
|
|
2670
|
+
"rust",
|
|
2671
|
+
"php",
|
|
2672
|
+
"ruby",
|
|
2673
|
+
"swift",
|
|
2674
|
+
"kotlin",
|
|
2675
|
+
"sql",
|
|
2676
|
+
"html",
|
|
2677
|
+
"css",
|
|
2678
|
+
"shell",
|
|
2679
|
+
"other"
|
|
2680
|
+
];
|
|
2681
|
+
var generateCodeTool = {
|
|
2682
|
+
name: "generate_code",
|
|
2683
|
+
description: "Generate code in a specific programming language from a natural-language description, using UniAI's configured code model. Use when the user explicitly asks to generate/write a standalone function, script, or snippet in a named language and wants it produced by the platform's code model. Returns the generated code as text.",
|
|
2684
|
+
inputSchema: {
|
|
2685
|
+
type: "object",
|
|
2686
|
+
properties: {
|
|
2687
|
+
description: {
|
|
2688
|
+
type: "string",
|
|
2689
|
+
description: "What the code should do, in natural language. Required."
|
|
2690
|
+
},
|
|
2691
|
+
language: {
|
|
2692
|
+
type: "string",
|
|
2693
|
+
description: "Target programming language. Required.",
|
|
2694
|
+
enum: CODE_LANGUAGES
|
|
2695
|
+
},
|
|
2696
|
+
context: {
|
|
2697
|
+
type: "string",
|
|
2698
|
+
description: "Optional extra context such as existing code, constraints, or environment."
|
|
2699
|
+
},
|
|
2700
|
+
requirements: {
|
|
2701
|
+
type: "array",
|
|
2702
|
+
items: { type: "string" },
|
|
2703
|
+
description: "Optional list of specific requirements the code must satisfy."
|
|
2704
|
+
}
|
|
2705
|
+
},
|
|
2706
|
+
required: ["description", "language"]
|
|
2707
|
+
},
|
|
2708
|
+
handler: async (args, env) => {
|
|
2709
|
+
try {
|
|
2710
|
+
const description = args.description;
|
|
2711
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
2712
|
+
return toMcpError(
|
|
2713
|
+
new Error("description must be a non-empty string"),
|
|
2714
|
+
"Code generation failed"
|
|
2715
|
+
);
|
|
2716
|
+
}
|
|
2717
|
+
const language = args.language;
|
|
2718
|
+
if (typeof language !== "string" || !CODE_LANGUAGES.includes(language)) {
|
|
2719
|
+
return toMcpError(
|
|
2720
|
+
new Error(`language must be one of: ${CODE_LANGUAGES.join(", ")}`),
|
|
2721
|
+
"Code generation failed"
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
const body = { description, language };
|
|
2725
|
+
if (typeof args.context === "string" && args.context.trim() !== "") {
|
|
2726
|
+
body.context = args.context;
|
|
2727
|
+
}
|
|
2728
|
+
if (Array.isArray(args.requirements)) {
|
|
2729
|
+
const reqs = args.requirements.filter(
|
|
2730
|
+
(r) => typeof r === "string" && r.trim() !== ""
|
|
2731
|
+
);
|
|
2732
|
+
if (reqs.length > 0) body.requirements = reqs;
|
|
2733
|
+
}
|
|
2734
|
+
const res = await callUniAI(
|
|
2735
|
+
env,
|
|
2736
|
+
"POST",
|
|
2737
|
+
"/code/generate",
|
|
2738
|
+
body,
|
|
2739
|
+
{ timeoutMs: POLL_MAX_MS }
|
|
2740
|
+
);
|
|
2741
|
+
if (!res.code) {
|
|
2742
|
+
return toMcpError(
|
|
2743
|
+
new Error("task completed but no code returned"),
|
|
2744
|
+
"Code generation failed"
|
|
2745
|
+
);
|
|
2746
|
+
}
|
|
2747
|
+
return { content: [{ type: "text", text: res.code }] };
|
|
2748
|
+
} catch (err) {
|
|
2749
|
+
return toMcpError(err, "Code generation failed");
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
};
|
|
2753
|
+
|
|
2754
|
+
// src/commands/code.ts
|
|
2755
|
+
async function runCode(rest, flags) {
|
|
2756
|
+
const description = rest.join(" ").trim();
|
|
2757
|
+
const language = typeof flags.language === "string" ? flags.language : void 0;
|
|
2758
|
+
if (!description || !language) {
|
|
2759
|
+
console.error('usage: uniai code "<description>" --language <python|typescript|...> [--json]');
|
|
2760
|
+
if (description && !language) console.error("note: --language is required.");
|
|
2761
|
+
return 2;
|
|
2762
|
+
}
|
|
2763
|
+
return runTool(generateCodeTool, { description, language }, flags);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// ../uniai-core/src/tools/web-search.ts
|
|
2767
|
+
var webSearchTool = {
|
|
2768
|
+
name: "web_search",
|
|
2769
|
+
description: "Search the web for up-to-date information and return a summary plus a list of result titles, URLs, and snippets. Use when the user asks to look something up, find current/recent information, research a topic, check the latest news, or verify a fact that may have changed. Prefer this over guessing when the answer depends on recent or external data.",
|
|
2770
|
+
inputSchema: {
|
|
2771
|
+
type: "object",
|
|
2772
|
+
properties: {
|
|
2773
|
+
query: {
|
|
2774
|
+
type: "string",
|
|
2775
|
+
description: "Search query or keywords in natural language (max 200 characters). Required.",
|
|
2776
|
+
maxLength: 200
|
|
2777
|
+
},
|
|
2778
|
+
limit: {
|
|
2779
|
+
type: "number",
|
|
2780
|
+
description: "Maximum number of results to return (1-20). Default 10.",
|
|
2781
|
+
minimum: 1,
|
|
2782
|
+
maximum: 20,
|
|
2783
|
+
default: 10
|
|
2784
|
+
}
|
|
2785
|
+
},
|
|
2786
|
+
required: ["query"]
|
|
2787
|
+
},
|
|
2788
|
+
handler: async (args, env) => {
|
|
2789
|
+
try {
|
|
2790
|
+
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
2791
|
+
if (query === "") {
|
|
2792
|
+
return toMcpError(
|
|
2793
|
+
new Error("query must be a non-empty string"),
|
|
2794
|
+
"Web search failed"
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
if (query.length > 200) {
|
|
2798
|
+
return toMcpError(
|
|
2799
|
+
new Error(`query is ${query.length} characters; the limit is 200. Use shorter keywords.`),
|
|
2800
|
+
"Web search failed"
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
const body = { query };
|
|
2804
|
+
if (typeof args.limit === "number") {
|
|
2805
|
+
body.limit = args.limit;
|
|
2806
|
+
}
|
|
2807
|
+
const res = await callUniAI(env, "POST", "/search/web", body, {
|
|
2808
|
+
timeoutMs: 6e4
|
|
2809
|
+
});
|
|
2810
|
+
const results = res.results ?? [];
|
|
2811
|
+
const summary = res.summary ?? "";
|
|
2812
|
+
if (results.length === 0 && summary === "") {
|
|
2813
|
+
return toMcpError(new Error("no results returned"), "Web search failed");
|
|
2814
|
+
}
|
|
2815
|
+
const lines = [];
|
|
2816
|
+
if (res.simulated) {
|
|
2817
|
+
lines.push(
|
|
2818
|
+
"(Note: live web search was unavailable; the results below are AI-approximated and may be inaccurate.)",
|
|
2819
|
+
""
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
if (summary) {
|
|
2823
|
+
lines.push(summary, "");
|
|
2824
|
+
}
|
|
2825
|
+
results.forEach((r, i) => {
|
|
2826
|
+
lines.push(`${i + 1}. ${r.title}`);
|
|
2827
|
+
lines.push(` ${r.url}`);
|
|
2828
|
+
if (r.snippet) lines.push(` ${r.snippet}`);
|
|
2829
|
+
});
|
|
2830
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
2831
|
+
} catch (err) {
|
|
2832
|
+
return toMcpError(err, "Web search failed");
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
// src/commands/search.ts
|
|
2838
|
+
var num4 = (v) => typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : void 0;
|
|
2839
|
+
async function runSearch(rest, flags) {
|
|
2840
|
+
const query = rest.join(" ").trim();
|
|
2841
|
+
if (!query) {
|
|
2842
|
+
console.error('usage: uniai search "<query>" [--limit 1-20] [--json]');
|
|
2843
|
+
return 2;
|
|
2844
|
+
}
|
|
2845
|
+
return runTool(webSearchTool, { query, limit: num4(flags.limit) }, flags);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// ../uniai-core/src/tools/ocr.ts
|
|
2849
|
+
var ocrTool = {
|
|
2850
|
+
name: "ocr",
|
|
2851
|
+
description: "Extract text from an image (OCR / optical character recognition). Use when the user wants to read or pull text out of a picture, screenshot, scanned document, receipt, or photo. Accepts a local file path, an http(s) URL, or a base64 data URL (PNG/JPEG/WebP, up to 10MB). Returns the recognized text.",
|
|
2852
|
+
inputSchema: {
|
|
2853
|
+
type: "object",
|
|
2854
|
+
properties: {
|
|
2855
|
+
image: {
|
|
2856
|
+
type: "string",
|
|
2857
|
+
description: "The image to read text from: a local file path, an http(s) URL, or a base64 data URL. PNG/JPEG/WebP, up to 10MB. Required."
|
|
2858
|
+
}
|
|
2859
|
+
},
|
|
2860
|
+
required: ["image"]
|
|
2861
|
+
},
|
|
2862
|
+
handler: async (args, env) => {
|
|
2863
|
+
try {
|
|
2864
|
+
const image = args.image;
|
|
2865
|
+
if (typeof image !== "string" || image.trim() === "") {
|
|
2866
|
+
return toMcpError(
|
|
2867
|
+
new Error("image must be a non-empty string (file path, URL, or data URL)"),
|
|
2868
|
+
"OCR failed"
|
|
2869
|
+
);
|
|
2870
|
+
}
|
|
2871
|
+
const file = await resolveFileInput(image);
|
|
2872
|
+
const res = await postMultipart(env, "/ocr", "image", file);
|
|
2873
|
+
if (typeof res.text !== "string") {
|
|
2874
|
+
return toMcpError(new Error("OCR returned no text field"), "OCR failed");
|
|
2875
|
+
}
|
|
2876
|
+
const text = res.text.trim() === "" ? "(no text detected in image)" : res.text;
|
|
2877
|
+
const lines = [text];
|
|
2878
|
+
if (res.detectedLanguage) {
|
|
2879
|
+
lines.push("", `(detected language: ${res.detectedLanguage})`);
|
|
2880
|
+
}
|
|
2881
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
2882
|
+
} catch (err) {
|
|
2883
|
+
return toMcpError(err, "OCR failed");
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
|
|
2888
|
+
// src/commands/ocr.ts
|
|
2889
|
+
async function runOcr(rest, flags) {
|
|
2890
|
+
const image = rest[0];
|
|
2891
|
+
if (!image) {
|
|
2892
|
+
console.error("usage: uniai ocr <image: path|url> [--json]");
|
|
2893
|
+
return 2;
|
|
2894
|
+
}
|
|
2895
|
+
return runTool(ocrTool, { image }, flags);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// src/commands/usage.ts
|
|
2899
|
+
async function runUsage(_rest, flags) {
|
|
2900
|
+
const asJson = flags.json === true || flags.json === "true";
|
|
2901
|
+
try {
|
|
2902
|
+
const env = await resolveEnv(flags);
|
|
2903
|
+
const { credits } = await callUniAI(env, "GET", "/credits");
|
|
2904
|
+
const text = `Credits available: ${credits.total} (floor: ${credits.creditFloor})`;
|
|
2905
|
+
if (asJson) {
|
|
2906
|
+
process.stdout.write(
|
|
2907
|
+
JSON.stringify({
|
|
2908
|
+
ok: true,
|
|
2909
|
+
text,
|
|
2910
|
+
credits: { total: credits.total, creditFloor: credits.creditFloor }
|
|
2911
|
+
}) + "\n"
|
|
2912
|
+
);
|
|
2913
|
+
} else {
|
|
2914
|
+
process.stdout.write(text + "\n");
|
|
2915
|
+
}
|
|
2916
|
+
return 0;
|
|
2917
|
+
} catch (err) {
|
|
2918
|
+
return fail(err instanceof Error ? err.message : String(err), asJson);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// src/commands/config.ts
|
|
2923
|
+
var KEY_ALIASES = {
|
|
2924
|
+
token: "token",
|
|
2925
|
+
pat: "token",
|
|
2926
|
+
"api-key": "token",
|
|
2927
|
+
api_key: "token",
|
|
2928
|
+
apikey: "token",
|
|
2929
|
+
apibase: "apiBase",
|
|
2930
|
+
"api-base": "apiBase",
|
|
2931
|
+
api_base: "apiBase",
|
|
2932
|
+
base_url: "apiBase",
|
|
2933
|
+
baseurl: "apiBase"
|
|
2934
|
+
};
|
|
2935
|
+
var CANONICAL_KEYS = ["token", "apiBase"];
|
|
2936
|
+
async function runConfig(rest, flags) {
|
|
2937
|
+
const verb = rest[0];
|
|
2938
|
+
if (verb === "show") {
|
|
2939
|
+
const cfg = await readConfig();
|
|
2940
|
+
const view = {
|
|
2941
|
+
token: cfg.token ? maskToken(cfg.token) : "(unset)",
|
|
2942
|
+
apiBase: cfg.apiBase ?? "(default)"
|
|
2943
|
+
};
|
|
2944
|
+
process.stdout.write(JSON.stringify(view, null, 2) + "\n");
|
|
2945
|
+
return 0;
|
|
2946
|
+
}
|
|
2947
|
+
if (verb === "set") {
|
|
2948
|
+
const key = typeof flags.key === "string" ? flags.key : void 0;
|
|
2949
|
+
const value = typeof flags.value === "string" ? flags.value : void 0;
|
|
2950
|
+
if (!key || value === void 0) {
|
|
2951
|
+
console.error("usage: uniai config set --key <token|apiBase> --value <v>");
|
|
2952
|
+
return 2;
|
|
2953
|
+
}
|
|
2954
|
+
const canonical = KEY_ALIASES[key.toLowerCase()];
|
|
2955
|
+
if (!canonical) {
|
|
2956
|
+
console.error(
|
|
2957
|
+
`unknown config key: ${key}. Allowed: ${CANONICAL_KEYS.join(", ")} (aliases: base_url / api-base / api_base -> apiBase, api_key / pat -> token)`
|
|
2958
|
+
);
|
|
2959
|
+
return 2;
|
|
2960
|
+
}
|
|
2961
|
+
await writeConfig({ [canonical]: value });
|
|
2962
|
+
process.stderr.write(`set ${canonical}
|
|
2963
|
+
`);
|
|
2964
|
+
return 0;
|
|
2965
|
+
}
|
|
2966
|
+
if (verb === "reset") {
|
|
2967
|
+
await clearConfig();
|
|
2968
|
+
process.stderr.write("cleared ~/.uniai/config.json\n");
|
|
2969
|
+
return 0;
|
|
2970
|
+
}
|
|
2971
|
+
console.error("usage: uniai config show | set --key <k> --value <v> | reset");
|
|
2972
|
+
return 2;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// src/commands/auth.ts
|
|
2976
|
+
async function runAuth(rest, flags) {
|
|
2977
|
+
const verb = rest[0];
|
|
2978
|
+
if (verb === "login") return login(flags);
|
|
2979
|
+
if (verb === "logout") {
|
|
2980
|
+
await unsetConfigKeys(["token"]);
|
|
2981
|
+
process.stderr.write("logged out (removed stored token from ~/.uniai/config.json)\n");
|
|
2982
|
+
return 0;
|
|
2983
|
+
}
|
|
2984
|
+
if (verb === "status") {
|
|
2985
|
+
const cfg = await readConfig();
|
|
2986
|
+
const view = {
|
|
2987
|
+
loggedIn: Boolean(cfg.token),
|
|
2988
|
+
token: cfg.token ? maskToken(cfg.token) : "(unset)",
|
|
2989
|
+
apiBase: cfg.apiBase ?? "(default)"
|
|
2990
|
+
};
|
|
2991
|
+
process.stdout.write(JSON.stringify(view, null, 2) + "\n");
|
|
2992
|
+
return 0;
|
|
2993
|
+
}
|
|
2994
|
+
console.error("usage: uniai auth login --token <PAT> [--api-base <url>] | logout | status");
|
|
2995
|
+
return 2;
|
|
2996
|
+
}
|
|
2997
|
+
async function login(flags) {
|
|
2998
|
+
const token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
2999
|
+
if (!token) {
|
|
3000
|
+
console.error("usage: uniai auth login --token <PAT> [--api-base <url>]");
|
|
3001
|
+
console.error("note: token also accepted via UNIAI_TOKEN env.");
|
|
3002
|
+
return 2;
|
|
3003
|
+
}
|
|
3004
|
+
const check = validatePatFormat(token);
|
|
3005
|
+
if (!check.ok) {
|
|
3006
|
+
console.error(`invalid PAT format (${check.reason}). Expected uap_\u2026 personal access token.`);
|
|
3007
|
+
return 1;
|
|
3008
|
+
}
|
|
3009
|
+
const apiBase = typeof flags["api-base"] === "string" ? flags["api-base"] : void 0;
|
|
3010
|
+
await writeConfig(apiBase ? { token, apiBase } : { token });
|
|
3011
|
+
process.stderr.write("saved credentials to ~/.uniai/config.json\n");
|
|
3012
|
+
return 0;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/commands/skills.ts
|
|
3016
|
+
import { join as join3 } from "node:path";
|
|
3017
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3018
|
+
|
|
3019
|
+
// src/lib/skills-install.ts
|
|
3020
|
+
import {
|
|
3021
|
+
existsSync as existsSync2,
|
|
3022
|
+
mkdirSync as mkdirSync2,
|
|
3023
|
+
readdirSync,
|
|
3024
|
+
readFileSync as readFileSync2,
|
|
3025
|
+
statSync,
|
|
3026
|
+
rmSync as rmSync2,
|
|
3027
|
+
cpSync,
|
|
3028
|
+
symlinkSync,
|
|
3029
|
+
lstatSync,
|
|
3030
|
+
readlinkSync
|
|
3031
|
+
} from "node:fs";
|
|
3032
|
+
import { execFileSync } from "node:child_process";
|
|
3033
|
+
import { tmpdir, homedir as homedir2 } from "node:os";
|
|
3034
|
+
import { join as join2, basename as basename2, isAbsolute, resolve } from "node:path";
|
|
3035
|
+
var SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,98}[a-z0-9]$|^[a-z0-9]$/;
|
|
3036
|
+
function extractScalar(fm, key) {
|
|
3037
|
+
const m = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(fm);
|
|
3038
|
+
if (!m) return "";
|
|
3039
|
+
return m[1].trim().replace(/^["']|["']$/g, "");
|
|
3040
|
+
}
|
|
3041
|
+
function parseSkillMeta(skillMd) {
|
|
3042
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMd);
|
|
3043
|
+
if (!block) return { name: "", description: "" };
|
|
3044
|
+
return {
|
|
3045
|
+
name: extractScalar(block[1], "name"),
|
|
3046
|
+
description: extractScalar(block[1], "description")
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
function sanitizeSlug(raw) {
|
|
3050
|
+
return raw.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 100);
|
|
3051
|
+
}
|
|
3052
|
+
function discoverSkills(root) {
|
|
3053
|
+
const found = [];
|
|
3054
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3055
|
+
const consider = (dir, fallbackName) => {
|
|
3056
|
+
const skillMd = join2(dir, "SKILL.md");
|
|
3057
|
+
if (!existsSync2(skillMd)) return;
|
|
3058
|
+
let meta = { name: "", description: "" };
|
|
3059
|
+
try {
|
|
3060
|
+
meta = parseSkillMeta(readFileSync2(skillMd, "utf8"));
|
|
3061
|
+
} catch {
|
|
3062
|
+
}
|
|
3063
|
+
const slug = sanitizeSlug(meta.name || fallbackName);
|
|
3064
|
+
if (!SLUG_REGEX.test(slug) || seen.has(slug)) return;
|
|
3065
|
+
seen.add(slug);
|
|
3066
|
+
found.push({ dir, slug, name: meta.name || slug, description: meta.description });
|
|
3067
|
+
};
|
|
3068
|
+
consider(root, basename2(root));
|
|
3069
|
+
for (const sub of [".", "skills"]) {
|
|
3070
|
+
const base = sub === "." ? root : join2(root, sub);
|
|
3071
|
+
let entries;
|
|
3072
|
+
try {
|
|
3073
|
+
entries = readdirSync(base);
|
|
3074
|
+
} catch {
|
|
3075
|
+
continue;
|
|
3076
|
+
}
|
|
3077
|
+
for (const name of entries) {
|
|
3078
|
+
if (name.startsWith(".")) continue;
|
|
3079
|
+
const dir = join2(base, name);
|
|
3080
|
+
try {
|
|
3081
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
3082
|
+
} catch {
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
consider(dir, name);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
return found;
|
|
3089
|
+
}
|
|
3090
|
+
function toGitUrl(source) {
|
|
3091
|
+
if (/^https?:\/\//.test(source) || source.startsWith("git@")) return source;
|
|
3092
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(source)) return `https://github.com/${source}.git`;
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
function resolveSource(source, ref) {
|
|
3096
|
+
const localPath = isAbsolute(source) ? source : resolve(process.cwd(), source);
|
|
3097
|
+
if (existsSync2(localPath) && statSync(localPath).isDirectory()) {
|
|
3098
|
+
return { dir: localPath, cleanup: () => {
|
|
3099
|
+
} };
|
|
3100
|
+
}
|
|
3101
|
+
const url = toGitUrl(source);
|
|
3102
|
+
if (!url) {
|
|
3103
|
+
throw new Error(
|
|
3104
|
+
`Cannot resolve source "${source}" \u2014 not a local directory, "owner/repo", or git URL.`
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
const tmp = join2(tmpdir(), `uniai-skills-${Date.now()}-${process.pid}`);
|
|
3108
|
+
const args = ["clone", "--depth", "1"];
|
|
3109
|
+
if (ref) args.push("--branch", ref);
|
|
3110
|
+
args.push(url, tmp);
|
|
3111
|
+
try {
|
|
3112
|
+
execFileSync("git", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
3113
|
+
} catch (err) {
|
|
3114
|
+
throw new Error(
|
|
3115
|
+
`git clone failed for ${url}. Is git installed and the repo accessible? ${err instanceof Error ? err.message : String(err)}`
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
return {
|
|
3119
|
+
dir: tmp,
|
|
3120
|
+
cleanup: () => {
|
|
3121
|
+
try {
|
|
3122
|
+
rmSync2(tmp, { recursive: true, force: true });
|
|
3123
|
+
} catch {
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
function installSkill(skill, destRoot, force) {
|
|
3129
|
+
if (!SLUG_REGEX.test(skill.slug)) throw new Error(`unsafe slug rejected: ${skill.slug}`);
|
|
3130
|
+
const dest = join2(destRoot, skill.slug);
|
|
3131
|
+
if (existsSync2(dest)) {
|
|
3132
|
+
if (!force) return "skipped";
|
|
3133
|
+
rmSync2(dest, { recursive: true, force: true });
|
|
3134
|
+
}
|
|
3135
|
+
mkdirSync2(destRoot, { recursive: true });
|
|
3136
|
+
cpSync(skill.dir, dest, { recursive: true });
|
|
3137
|
+
return "installed";
|
|
3138
|
+
}
|
|
3139
|
+
function listInstalled(destRoot) {
|
|
3140
|
+
let entries;
|
|
3141
|
+
try {
|
|
3142
|
+
entries = readdirSync(destRoot);
|
|
3143
|
+
} catch {
|
|
3144
|
+
return [];
|
|
3145
|
+
}
|
|
3146
|
+
const out = [];
|
|
3147
|
+
for (const name of entries.sort()) {
|
|
3148
|
+
if (name.startsWith(".") || !SLUG_REGEX.test(name)) continue;
|
|
3149
|
+
const skillMd = join2(destRoot, name, "SKILL.md");
|
|
3150
|
+
if (!existsSync2(skillMd)) continue;
|
|
3151
|
+
let meta = { name: "", description: "" };
|
|
3152
|
+
try {
|
|
3153
|
+
meta = parseSkillMeta(readFileSync2(skillMd, "utf8"));
|
|
3154
|
+
} catch {
|
|
3155
|
+
}
|
|
3156
|
+
out.push({ slug: name, name: meta.name || name, description: meta.description });
|
|
3157
|
+
}
|
|
3158
|
+
return out;
|
|
3159
|
+
}
|
|
3160
|
+
var KNOWN_AGENT_APPS = [
|
|
3161
|
+
{ app: "Claude Code", segs: [".claude", "skills"] },
|
|
3162
|
+
{ app: "TRAE", segs: [".trae", "skills"] },
|
|
3163
|
+
{ app: "TRAE CN", segs: [".trae-cn", "skills"] },
|
|
3164
|
+
{ app: "Cursor", segs: [".cursor", "skills"] },
|
|
3165
|
+
{ app: "Qwen Code", segs: [".qwen", "skills"] },
|
|
3166
|
+
{ app: "Lingma", segs: [".lingma", "skills"] },
|
|
3167
|
+
{ app: "CodeBuddy", segs: [".codebuddy", "skills"] },
|
|
3168
|
+
{ app: "Qoder", segs: [".qoder", "skills"] },
|
|
3169
|
+
{ app: "Qoder CN", segs: [".qoder-cn", "skills"] },
|
|
3170
|
+
{ app: "iFlow CLI", segs: [".iflow", "skills"] },
|
|
3171
|
+
{ app: "CodeArts Doer", segs: [".codeartsdoer", "skills"] },
|
|
3172
|
+
{ app: "Windsurf", segs: [".codeium", "windsurf", "skills"] },
|
|
3173
|
+
{ app: "Roo Code", segs: [".roo", "skills"] },
|
|
3174
|
+
{ app: "Kilo Code", segs: [".kilocode", "skills"] },
|
|
3175
|
+
{ app: "Continue", segs: [".continue", "skills"] },
|
|
3176
|
+
{ app: "Augment", segs: [".augment", "skills"] },
|
|
3177
|
+
{ app: "Junie", segs: [".junie", "skills"] },
|
|
3178
|
+
{ app: "Kiro", segs: [".kiro", "skills"] },
|
|
3179
|
+
{ app: "OpenHands", segs: [".openhands", "skills"] },
|
|
3180
|
+
{ app: "Factory", segs: [".factory", "skills"] },
|
|
3181
|
+
{ app: "Rovo Dev", segs: [".rovodev", "skills"] },
|
|
3182
|
+
{ app: "Zencoder", segs: [".zencoder", "skills"] },
|
|
3183
|
+
{ app: "Tabnine", segs: [".tabnine", "agent", "skills"] },
|
|
3184
|
+
{ app: "Snowflake Cortex", segs: [".snowflake", "cortex", "skills"] },
|
|
3185
|
+
{ app: "Devin", segs: [".config", "devin", "skills"] },
|
|
3186
|
+
{ app: "Goose", segs: [".config", "goose", "skills"] },
|
|
3187
|
+
{ app: "Crush", segs: [".config", "crush", "skills"] },
|
|
3188
|
+
{ app: "AiderDesk", segs: [".aider-desk", "skills"] },
|
|
3189
|
+
{ app: "AstrBot", segs: [".astrbot", "data", "skills"] },
|
|
3190
|
+
{ app: "Autohand Code CLI", segs: [".autohand", "skills"] },
|
|
3191
|
+
{ app: "IBM Bob", segs: [".bob", "skills"] },
|
|
3192
|
+
{ app: "Forge", segs: [".forge", "skills"] },
|
|
3193
|
+
{ app: "Hermes", segs: [".hermes", "skills"] },
|
|
3194
|
+
{ app: "CommandCode", segs: [".commandcode", "skills"] },
|
|
3195
|
+
{ app: "CodeMaker", segs: [".codemaker", "skills"] },
|
|
3196
|
+
{ app: "CodeStudio", segs: [".codestudio", "skills"] },
|
|
3197
|
+
{ app: "inference.sh", segs: [".inferencesh", "skills"] },
|
|
3198
|
+
{ app: "Jazz", segs: [".jazz", "skills"] },
|
|
3199
|
+
{ app: "Kode", segs: [".kode", "skills"] },
|
|
3200
|
+
{ app: "MCPJam", segs: [".mcpjam", "skills"] },
|
|
3201
|
+
{ app: "Moxby", segs: [".moxby", "skills"] },
|
|
3202
|
+
{ app: "Mux", segs: [".mux", "skills"] },
|
|
3203
|
+
{ app: "Neovate", segs: [".neovate", "skills"] },
|
|
3204
|
+
{ app: "Ona", segs: [".ona", "skills"] },
|
|
3205
|
+
{ app: "OpenClaw", segs: [".openclaw", "skills"] },
|
|
3206
|
+
{ app: "Pochi", segs: [".pochi", "skills"] },
|
|
3207
|
+
{ app: "Reasonix", segs: [".reasonix", "skills"] },
|
|
3208
|
+
{ app: "TerraMind", segs: [".terramind", "skills"] },
|
|
3209
|
+
{ app: "TinyCloud", segs: [".tinycloud", "skills"] },
|
|
3210
|
+
{ app: "Vibe", segs: [".vibe", "skills"] },
|
|
3211
|
+
{ app: "Adal", segs: [".adal", "skills"] },
|
|
3212
|
+
{ app: "Pi", segs: [".pi", "agent", "skills"] }
|
|
3213
|
+
];
|
|
3214
|
+
function knownAgentSkillDirs() {
|
|
3215
|
+
const override = process.env.AGENT_SKILL_DIRS?.trim();
|
|
3216
|
+
if (override) {
|
|
3217
|
+
return override.split(":").filter(Boolean).map((d) => ({ app: d, dir: d }));
|
|
3218
|
+
}
|
|
3219
|
+
return KNOWN_AGENT_APPS.map(({ app, segs }) => ({ app, dir: join2(homedir2(), ...segs) }));
|
|
3220
|
+
}
|
|
3221
|
+
function bridgeToAgentApps(slug, canonicalDir) {
|
|
3222
|
+
if (!SLUG_REGEX.test(slug)) return [];
|
|
3223
|
+
const bridged = [];
|
|
3224
|
+
for (const { app, dir } of knownAgentSkillDirs()) {
|
|
3225
|
+
try {
|
|
3226
|
+
mkdirSync2(dir, { recursive: true });
|
|
3227
|
+
const link = join2(dir, slug);
|
|
3228
|
+
let st = null;
|
|
3229
|
+
try {
|
|
3230
|
+
st = lstatSync(link);
|
|
3231
|
+
} catch {
|
|
3232
|
+
st = null;
|
|
3233
|
+
}
|
|
3234
|
+
if (st) {
|
|
3235
|
+
if (st.isSymbolicLink()) {
|
|
3236
|
+
try {
|
|
3237
|
+
if (readlinkSync(link) === canonicalDir) bridged.push(app);
|
|
3238
|
+
} catch {
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
continue;
|
|
3242
|
+
}
|
|
3243
|
+
symlinkSync(canonicalDir, link, "dir");
|
|
3244
|
+
bridged.push(app);
|
|
3245
|
+
} catch {
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
return bridged;
|
|
3249
|
+
}
|
|
3250
|
+
function unbridgeFromAgentApps(slug, canonicalDir) {
|
|
3251
|
+
if (!SLUG_REGEX.test(slug)) return;
|
|
3252
|
+
for (const { dir } of knownAgentSkillDirs()) {
|
|
3253
|
+
try {
|
|
3254
|
+
const link = join2(dir, slug);
|
|
3255
|
+
const st = lstatSync(link);
|
|
3256
|
+
if (st.isSymbolicLink() && readlinkSync(link) === canonicalDir) {
|
|
3257
|
+
rmSync2(link, { force: true });
|
|
3258
|
+
}
|
|
3259
|
+
} catch {
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
function removeInstalled(destRoot, slug) {
|
|
3264
|
+
if (!SLUG_REGEX.test(slug)) throw new Error(`unsafe slug rejected: ${slug}`);
|
|
3265
|
+
const dest = join2(destRoot, slug);
|
|
3266
|
+
if (!existsSync2(dest)) return false;
|
|
3267
|
+
rmSync2(dest, { recursive: true, force: true });
|
|
3268
|
+
return true;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
// src/commands/skills.ts
|
|
3272
|
+
var SKILLS_HELP = `uniai skills \u2014 install / list / remove Agent Skills (~/.agents/skills)
|
|
3273
|
+
|
|
3274
|
+
uniai skills add ${DEFAULT_SKILL_REMOTE} --ref release -g recommended: install the UniAI skill from the remote source (latest published)
|
|
3275
|
+
uniai skills add <source> [--all] [--skill <a,b>] [--ref <branch>] [-g|--global] [-f|--force] [--json]
|
|
3276
|
+
<source> = local directory | owner/repo | git URL
|
|
3277
|
+
uniai skills add --self [-g|--global] offline fallback: install the UniAI skill bundled in this package
|
|
3278
|
+
uniai skills list [-g|--global] [--json]
|
|
3279
|
+
uniai skills remove <slug> [-g|--global] [--json]
|
|
3280
|
+
|
|
3281
|
+
-g / --global target ~/.agents/skills (default: <cwd>/.agents/skills)
|
|
3282
|
+
--self install the bundled UniAI skill (offline fallback for \`${DEFAULT_SKILL_REMOTE}\`; teaches agents to use \`uniai\`)
|
|
3283
|
+
--all install every skill found in <source>
|
|
3284
|
+
--skill <a,b> install only these skill names/slugs (comma-separated)
|
|
3285
|
+
--ref <branch> clone a specific branch/tag (e.g. --ref release to get published content)
|
|
3286
|
+
--force overwrite if already installed
|
|
3287
|
+
--json machine-readable output (envelope on stdout; progress stays on stderr)`;
|
|
3288
|
+
function msg(e) {
|
|
3289
|
+
return e instanceof Error ? e.message : String(e);
|
|
3290
|
+
}
|
|
3291
|
+
function outJson(obj) {
|
|
3292
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
3293
|
+
}
|
|
3294
|
+
function failSkills(message, asJson) {
|
|
3295
|
+
if (asJson) outJson({ ok: false, error: message });
|
|
3296
|
+
else process.stderr.write(message + "\n");
|
|
3297
|
+
return 1;
|
|
3298
|
+
}
|
|
3299
|
+
async function runSkills(rest, flags) {
|
|
3300
|
+
const asJson = flags.json === true || flags.json === "true";
|
|
3301
|
+
const opts = {
|
|
3302
|
+
global: flags.global === true,
|
|
3303
|
+
all: flags.all === true,
|
|
3304
|
+
force: flags.force === true
|
|
3305
|
+
};
|
|
3306
|
+
const args = [];
|
|
3307
|
+
for (const tok of rest) {
|
|
3308
|
+
if (tok === "-g") opts.global = true;
|
|
3309
|
+
else if (tok === "-a") opts.all = true;
|
|
3310
|
+
else if (tok === "-f") opts.force = true;
|
|
3311
|
+
else args.push(tok);
|
|
3312
|
+
}
|
|
3313
|
+
const sub = args[0];
|
|
3314
|
+
switch (sub) {
|
|
3315
|
+
case "add":
|
|
3316
|
+
return addCmd(args.slice(1), opts, flags, asJson);
|
|
3317
|
+
case "list":
|
|
3318
|
+
case "ls":
|
|
3319
|
+
return listCmd(opts, asJson);
|
|
3320
|
+
case "remove":
|
|
3321
|
+
case "rm":
|
|
3322
|
+
return removeCmd(args.slice(1), opts, asJson);
|
|
3323
|
+
default:
|
|
3324
|
+
if (sub) return failSkills(`unknown skills subcommand: ${sub}`, asJson);
|
|
3325
|
+
console.log(SKILLS_HELP);
|
|
3326
|
+
return 0;
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
function bundledSkillDir() {
|
|
3330
|
+
return fileURLToPath2(new URL("../skill", import.meta.url));
|
|
3331
|
+
}
|
|
3332
|
+
function addCmd(args, opts, flags, asJson) {
|
|
3333
|
+
const source = flags.self === true ? bundledSkillDir() : args[0];
|
|
3334
|
+
if (!source) {
|
|
3335
|
+
return failSkills(
|
|
3336
|
+
"skills add: missing <source> (local dir | owner/repo | git URL), or pass --self for the bundled UniAI skill",
|
|
3337
|
+
asJson
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
const destRoot = resolveSkillsDir(opts.global);
|
|
3341
|
+
const ref = typeof flags.ref === "string" ? flags.ref : void 0;
|
|
3342
|
+
const skillFilter = typeof flags.skill === "string" ? new Set(
|
|
3343
|
+
flags.skill.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
3344
|
+
) : null;
|
|
3345
|
+
let resolved;
|
|
3346
|
+
let usedBundledFallback = false;
|
|
3347
|
+
try {
|
|
3348
|
+
resolved = resolveSource(source, ref);
|
|
3349
|
+
} catch (e) {
|
|
3350
|
+
if (flags.self !== true && source === DEFAULT_SKILL_REMOTE) {
|
|
3351
|
+
if (!asJson)
|
|
3352
|
+
process.stderr.write(
|
|
3353
|
+
`Remote ${source} unreachable (${msg(e)}); falling back to the bundled UniAI skill.
|
|
3354
|
+
`
|
|
3355
|
+
);
|
|
3356
|
+
try {
|
|
3357
|
+
resolved = resolveSource(bundledSkillDir());
|
|
3358
|
+
usedBundledFallback = true;
|
|
3359
|
+
} catch (e2) {
|
|
3360
|
+
return failSkills(msg(e2), asJson);
|
|
3361
|
+
}
|
|
3362
|
+
} else {
|
|
3363
|
+
return failSkills(msg(e), asJson);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
try {
|
|
3367
|
+
const discovered = discoverSkills(resolved.dir);
|
|
3368
|
+
if (discovered.length === 0) {
|
|
3369
|
+
return failSkills(`No SKILL.md found in "${source}".`, asJson);
|
|
3370
|
+
}
|
|
3371
|
+
let selected;
|
|
3372
|
+
if (skillFilter) {
|
|
3373
|
+
selected = discovered.filter(
|
|
3374
|
+
(s) => skillFilter.has(s.slug) || skillFilter.has(s.name.toLowerCase())
|
|
3375
|
+
);
|
|
3376
|
+
if (selected.length === 0) {
|
|
3377
|
+
const available = discovered.map((s) => s.slug);
|
|
3378
|
+
if (asJson) {
|
|
3379
|
+
outJson({ ok: false, error: "none of --skill matched", available });
|
|
3380
|
+
return 1;
|
|
3381
|
+
}
|
|
3382
|
+
process.stderr.write(`None of --skill matched. Available in "${source}":
|
|
3383
|
+
`);
|
|
3384
|
+
for (const s of discovered) process.stderr.write(` - ${s.slug}
|
|
3385
|
+
`);
|
|
3386
|
+
return 1;
|
|
3387
|
+
}
|
|
3388
|
+
} else if (opts.all || discovered.length === 1) {
|
|
3389
|
+
selected = discovered;
|
|
3390
|
+
} else {
|
|
3391
|
+
const available = discovered.map((s) => s.slug);
|
|
3392
|
+
if (asJson) {
|
|
3393
|
+
outJson({ ok: false, error: `found ${discovered.length} skills; pass --all or --skill <name>`, available });
|
|
3394
|
+
return 1;
|
|
3395
|
+
}
|
|
3396
|
+
process.stderr.write(
|
|
3397
|
+
`Found ${discovered.length} skills in "${source}". Use --all or --skill <name>:
|
|
3398
|
+
`
|
|
3399
|
+
);
|
|
3400
|
+
for (const s of discovered) process.stderr.write(` - ${s.slug}
|
|
3401
|
+
`);
|
|
3402
|
+
return 1;
|
|
3403
|
+
}
|
|
3404
|
+
const installed = [];
|
|
3405
|
+
const skipped = [];
|
|
3406
|
+
const failed = [];
|
|
3407
|
+
for (const s of selected) {
|
|
3408
|
+
try {
|
|
3409
|
+
const r = installSkill(s, destRoot, opts.force);
|
|
3410
|
+
if (r === "installed") {
|
|
3411
|
+
installed.push(s.slug);
|
|
3412
|
+
if (!asJson) process.stderr.write(` \u2713 ${s.slug}
|
|
3413
|
+
`);
|
|
3414
|
+
} else {
|
|
3415
|
+
skipped.push(s.slug);
|
|
3416
|
+
if (!asJson) process.stderr.write(` \u2022 ${s.slug} (already installed; use --force to overwrite)
|
|
3417
|
+
`);
|
|
3418
|
+
}
|
|
3419
|
+
} catch (e) {
|
|
3420
|
+
failed.push({ slug: s.slug, error: msg(e) });
|
|
3421
|
+
if (!asJson) process.stderr.write(` \u2717 ${s.slug}: ${msg(e)}
|
|
3422
|
+
`);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
if (!asJson) {
|
|
3426
|
+
process.stderr.write(
|
|
3427
|
+
`Installed ${installed.length} skill(s) to ${destRoot}${skipped.length ? `, skipped ${skipped.length}` : ""}.
|
|
3428
|
+
`
|
|
3429
|
+
);
|
|
3430
|
+
}
|
|
3431
|
+
const linkedApps = [];
|
|
3432
|
+
if (opts.global) {
|
|
3433
|
+
const apps = /* @__PURE__ */ new Set();
|
|
3434
|
+
for (const s of selected) {
|
|
3435
|
+
for (const app of bridgeToAgentApps(s.slug, join3(destRoot, s.slug))) apps.add(app);
|
|
3436
|
+
}
|
|
3437
|
+
linkedApps.push(...apps);
|
|
3438
|
+
if (!asJson && apps.size > 0) {
|
|
3439
|
+
const names = [...apps];
|
|
3440
|
+
const head = names.slice(0, 5).join(", ");
|
|
3441
|
+
const more = names.length > 5 ? ` +${names.length - 5} more` : "";
|
|
3442
|
+
process.stderr.write(`Linked into: ${head}${more} (+ any tool scanning ~/.agents/skills).
|
|
3443
|
+
`);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
if (asJson) {
|
|
3447
|
+
outJson({
|
|
3448
|
+
ok: failed.length === 0,
|
|
3449
|
+
dir: destRoot,
|
|
3450
|
+
installed,
|
|
3451
|
+
skipped,
|
|
3452
|
+
failed,
|
|
3453
|
+
linkedApps,
|
|
3454
|
+
// signal when the install came from the package-bundled copy (remote unreachable) rather than the live remote
|
|
3455
|
+
...usedBundledFallback ? { source: "bundled-fallback" } : {}
|
|
3456
|
+
});
|
|
3457
|
+
} else {
|
|
3458
|
+
for (const s of selected) process.stdout.write(`${s.slug}
|
|
3459
|
+
`);
|
|
3460
|
+
}
|
|
3461
|
+
return 0;
|
|
3462
|
+
} finally {
|
|
3463
|
+
resolved.cleanup();
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
function listCmd(opts, asJson) {
|
|
3467
|
+
const destRoot = resolveSkillsDir(opts.global);
|
|
3468
|
+
const skills = listInstalled(destRoot);
|
|
3469
|
+
if (asJson) {
|
|
3470
|
+
outJson({ ok: true, dir: destRoot, skills: skills.map((s) => ({ slug: s.slug, name: s.name })) });
|
|
3471
|
+
return 0;
|
|
3472
|
+
}
|
|
3473
|
+
if (skills.length === 0) {
|
|
3474
|
+
process.stderr.write(`No skills installed in ${destRoot}.
|
|
3475
|
+
`);
|
|
3476
|
+
return 0;
|
|
3477
|
+
}
|
|
3478
|
+
process.stderr.write(`${skills.length} skill(s) in ${destRoot}:
|
|
3479
|
+
`);
|
|
3480
|
+
for (const s of skills) process.stdout.write(`${s.slug} ${s.name}
|
|
3481
|
+
`);
|
|
3482
|
+
return 0;
|
|
3483
|
+
}
|
|
3484
|
+
function removeCmd(args, opts, asJson) {
|
|
3485
|
+
const slug = args[0];
|
|
3486
|
+
if (!slug) {
|
|
3487
|
+
return failSkills("skills remove: missing <slug>", asJson);
|
|
3488
|
+
}
|
|
3489
|
+
const destRoot = resolveSkillsDir(opts.global);
|
|
3490
|
+
try {
|
|
3491
|
+
if (opts.global) unbridgeFromAgentApps(slug, join3(destRoot, slug));
|
|
3492
|
+
const ok = removeInstalled(destRoot, slug);
|
|
3493
|
+
if (ok) {
|
|
3494
|
+
if (asJson) outJson({ ok: true, dir: destRoot, removed: slug });
|
|
3495
|
+
else process.stderr.write(`Removed ${slug} from ${destRoot}.
|
|
3496
|
+
`);
|
|
3497
|
+
return 0;
|
|
3498
|
+
}
|
|
3499
|
+
return failSkills(`${slug} not found in ${destRoot}.`, asJson);
|
|
3500
|
+
} catch (e) {
|
|
3501
|
+
return failSkills(msg(e), asJson);
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
// src/lib/args.ts
|
|
3506
|
+
function parseArgs(argv) {
|
|
3507
|
+
const flags = {};
|
|
3508
|
+
const positionals = [];
|
|
3509
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3510
|
+
const a = argv[i];
|
|
3511
|
+
if (a.startsWith("--")) {
|
|
3512
|
+
const eq = a.indexOf("=");
|
|
3513
|
+
if (eq !== -1) {
|
|
3514
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
3515
|
+
} else {
|
|
3516
|
+
const key = a.slice(2);
|
|
3517
|
+
const next = argv[i + 1];
|
|
3518
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
3519
|
+
flags[key] = next;
|
|
3520
|
+
i++;
|
|
3521
|
+
} else {
|
|
3522
|
+
flags[key] = true;
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
} else {
|
|
3526
|
+
positionals.push(a);
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
return { positionals, flags };
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
// src/cli.ts
|
|
3533
|
+
var HELP = `uniai \u2014 UniAI platform CLI (install MCP tools into Codex, or call features directly)
|
|
3534
|
+
|
|
3535
|
+
Setup / installer:
|
|
3536
|
+
uniai init [--token <PAT>] [--api-base <url>] Install UniAI MCP server into ~/.codex/config.toml
|
|
3537
|
+
uniai uninstall Remove it (keeps your other config intact)
|
|
3538
|
+
uniai status Show install status (read-only)
|
|
3539
|
+
uniai update [--check] Update @uniai/cli to the latest npm version
|
|
3540
|
+
uniai auth login --token <PAT> [--api-base <url>] Save credentials for direct commands (~/.uniai/config.json)
|
|
3541
|
+
uniai auth logout | status Clear / show stored credentials
|
|
3542
|
+
uniai config show | set --key <token|apiBase> --value <v> | reset
|
|
3543
|
+
|
|
3544
|
+
Direct commands (call UniAI platform features from the terminal):
|
|
3545
|
+
uniai image generate "<prompt>" [--model] [--aspect-ratio] [--count] [--quality] [--reference <path|url>] [--download <file>]
|
|
3546
|
+
uniai image models List available image models (so you can pick --model / --aspect-ratio / etc.)
|
|
3547
|
+
uniai image edit --image <url> [--output-format png|jpg|webp] [--edge-type sharp|feather] [--download <file>]
|
|
3548
|
+
uniai video generate "<prompt>" [--model] [--aspect-ratio] [--duration] [--first-frame <img>] [--reference <img,..>] [--source-video <vid>] [--download <file>]
|
|
3549
|
+
uniai video models List available video models (modes, durations, resolutions, audio support)
|
|
3550
|
+
uniai speech synthesize "<text>" [--voice] [--format] [--rate 0.5-2.0] [--download <file>]
|
|
3551
|
+
uniai speech recognize <audio: path|url> [--language auto|zh|en|ja|ko]
|
|
3552
|
+
uniai chat "<message>"
|
|
3553
|
+
uniai code "<description>" --language <python|typescript|...>
|
|
3554
|
+
uniai search "<query>" [--limit 1-20]
|
|
3555
|
+
uniai ocr <image: path|url>
|
|
3556
|
+
uniai usage Show your credit balance \u2014 check budget before spending (alias: quota)
|
|
3557
|
+
|
|
3558
|
+
Agent Skills (install UniAI / community skills into ~/.agents/skills, shared across agent apps):
|
|
3559
|
+
uniai skills add ${DEFAULT_SKILL_REMOTE} --ref release -g recommended: the UniAI skill (latest published)
|
|
3560
|
+
uniai skills add <source> [--all] [--skill <a,b>] [--ref <branch>] [-g|--global] [-f|--force]
|
|
3561
|
+
<source> = local directory | owner/repo | git URL (--self = offline bundled fallback)
|
|
3562
|
+
uniai skills list [-g|--global]
|
|
3563
|
+
uniai skills remove <slug> [-g|--global]
|
|
3564
|
+
|
|
3565
|
+
Common flags: --token <PAT> / --api-base <url> (override env & config),
|
|
3566
|
+
--download <file> (save returned media), --json (machine-readable output)
|
|
3567
|
+
Credentials priority: --token > UNIAI_TOKEN env > ~/.uniai/config.json
|
|
3568
|
+
|
|
3569
|
+
Machine-readable output (for AI agents & scripts):
|
|
3570
|
+
--json emits a JSON envelope instead of text:
|
|
3571
|
+
success: {"ok":true,"text":"...","url":"<media url, when present>"}
|
|
3572
|
+
failure: {"ok":false,"error":"..."}
|
|
3573
|
+
Exit codes: 0 success \xB7 1 runtime / auth / network error \xB7 2 invalid usage
|
|
3574
|
+
|
|
3575
|
+
uniai <command> --help Show help for a single command
|
|
3576
|
+
uniai --help | --version`;
|
|
3577
|
+
var COMMAND_HELP = {
|
|
3578
|
+
image: `uniai image \u2014 generate or edit images, or list models
|
|
3579
|
+
uniai image generate "<prompt>" [--model <id>] [--aspect-ratio 1:1|16:9|9:16|4:3|3:4|21:9|3:1] [--count <n>] [--quality low|medium|high] [--resolution 1K|2K|4K] [--negative-prompt <text>] [--seed <n>] [--reference <path|url>[,...]] [--download <file>] [--json]
|
|
3580
|
+
pass only what the user specified; anything omitted uses the platform-recommended default. Param support is per-model \u2014 run \`uniai image models\`.
|
|
3581
|
+
--reference = image-to-image (variation / style / character consistency). Each ref is a local file path, http(s) URL, or data URL;
|
|
3582
|
+
local files are uploaded for you. Up to 5, comma-separated. e.g. uniai image generate "full body, black dress" --reference ./person.webp
|
|
3583
|
+
uniai image models list the platform's live image models + each one's supported aspect ratios / quality tiers / max count / reference-image limit
|
|
3584
|
+
uniai image edit --image <url> [--output-format png|jpg|webp] [--edge-type sharp|feather] [--download <file>] [--json]
|
|
3585
|
+
--image (edit) = sharpen/feather edges or process background; URL only. For "change clothing / new pose from my photo", use \`generate --reference\` instead.`,
|
|
3586
|
+
video: `uniai video \u2014 generate a video, or list models
|
|
3587
|
+
uniai video generate "<prompt>" [--model <id>] [--aspect-ratio 16:9|9:16|1:1|4:3|3:4|21:9|adaptive] [--duration <2-15>] [--resolution 480p|720p|1080p] [--negative-prompt <text>] [--seed <n>] [--download <file>] [--json]
|
|
3588
|
+
media inputs (the MODE is auto-derived from what you attach; local files are uploaded for you):
|
|
3589
|
+
--first-frame <img> animate an image (image-to-video)
|
|
3590
|
+
--first-frame <img> --last-frame <img> first\u2192last frame interpolation
|
|
3591
|
+
--reference <img,..> [--reference-video <vid,..>] [--reference-audio <aud,..>] reference-to-video (omni)
|
|
3592
|
+
--source-video <vid> [--v2v-endpoint edit|reference] edit / restyle a clip (video-to-video)
|
|
3593
|
+
--continuation-video <vid> extend a clip
|
|
3594
|
+
(none) text-to-video
|
|
3595
|
+
a prompt is always required. pass only what the user specified; the rest uses recommended defaults.
|
|
3596
|
+
uniai video models list the platform's live video models + each one's modes / durations / resolutions / aspect ratios / audio support`,
|
|
3597
|
+
speech: `uniai speech \u2014 text-to-speech (synthesize) and speech-to-text (recognize)
|
|
3598
|
+
uniai speech synthesize "<text>" [--voice <id>] [--format mp3|wav|pcm|opus|aac|flac] [--rate 0.5-2.0] [--download <file>] [--json]
|
|
3599
|
+
uniai speech recognize <audio: path|url> [--language auto|zh|en|ja|ko] [--json]`,
|
|
3600
|
+
chat: `uniai chat \u2014 ask the configured chat model; returns a single text response
|
|
3601
|
+
uniai chat "<message>" [--json]`,
|
|
3602
|
+
code: `uniai code \u2014 generate code (--language is required)
|
|
3603
|
+
uniai code "<description>" --language <python|typescript|...> [--json]`,
|
|
3604
|
+
search: `uniai search \u2014 web search
|
|
3605
|
+
uniai search "<query>" [--limit 1-20] [--json]`,
|
|
3606
|
+
ocr: `uniai ocr \u2014 extract text from an image (optical character recognition)
|
|
3607
|
+
uniai ocr <image: path|url|dataURL> [--json]`,
|
|
3608
|
+
usage: `uniai usage \u2014 show your credit balance, so you can check the budget before spending
|
|
3609
|
+
uniai usage [--json] (alias: uniai quota)`,
|
|
3610
|
+
quota: `uniai quota \u2014 alias of \`uniai usage\`: show your credit balance
|
|
3611
|
+
uniai quota [--json]`,
|
|
3612
|
+
config: `uniai config \u2014 view or change stored credentials/config (~/.uniai/config.json)
|
|
3613
|
+
uniai config show
|
|
3614
|
+
uniai config set --key <token|apiBase> --value <value> (key aliases: base_url / api-base -> apiBase, api_key / pat -> token)
|
|
3615
|
+
uniai config reset`,
|
|
3616
|
+
auth: `uniai auth \u2014 manage credentials for direct commands
|
|
3617
|
+
uniai auth login --token <PAT> [--api-base <url>]
|
|
3618
|
+
uniai auth status
|
|
3619
|
+
uniai auth logout`,
|
|
3620
|
+
skills: `uniai skills \u2014 install Agent Skills into ~/.agents/skills (shared across agent apps)
|
|
3621
|
+
uniai skills add ${DEFAULT_SKILL_REMOTE} --ref release -g recommended: the UniAI skill from the remote source (latest published)
|
|
3622
|
+
uniai skills add <source> [--all] [--skill <a,b>] [--ref <branch>] [-g|--global] [-f|--force] [--json]
|
|
3623
|
+
<source> = local directory | owner/repo | git URL
|
|
3624
|
+
uniai skills add --self [-g|--global] offline fallback: the UniAI skill bundled in this package (auto-used when the remote is unreachable)
|
|
3625
|
+
uniai skills list [-g|--global] [--json]
|
|
3626
|
+
uniai skills remove <slug> [-g|--global] [--json]`,
|
|
3627
|
+
update: `uniai update \u2014 update @uniai/cli to the latest version published on npm
|
|
3628
|
+
uniai update [--check] [--json] --check only reports; default installs @uniai/cli@latest`,
|
|
3629
|
+
init: `uniai init \u2014 install the UniAI MCP server into ~/.codex/config.toml
|
|
3630
|
+
uniai init [--token <PAT>] [--api-base <url>]`,
|
|
3631
|
+
uninstall: `uniai uninstall \u2014 remove the UniAI MCP server (keeps your other Codex config intact)`,
|
|
3632
|
+
status: `uniai status \u2014 show install status (read-only)`
|
|
3633
|
+
};
|
|
3634
|
+
var DIRECT = {
|
|
3635
|
+
image: runImage,
|
|
3636
|
+
video: runVideo,
|
|
3637
|
+
speech: runSpeech,
|
|
3638
|
+
chat: runChat,
|
|
3639
|
+
code: runCode,
|
|
3640
|
+
search: runSearch,
|
|
3641
|
+
ocr: runOcr,
|
|
3642
|
+
usage: runUsage,
|
|
3643
|
+
quota: runUsage,
|
|
3644
|
+
config: runConfig,
|
|
3645
|
+
auth: runAuth,
|
|
3646
|
+
skills: runSkills
|
|
3647
|
+
};
|
|
3648
|
+
async function main() {
|
|
3649
|
+
const raw = process.argv.slice(2);
|
|
3650
|
+
if (raw.includes("-v") || raw.includes("--version")) {
|
|
3651
|
+
console.log(VERSION);
|
|
3652
|
+
return 0;
|
|
3653
|
+
}
|
|
3654
|
+
const { positionals, flags } = parseArgs(raw);
|
|
3655
|
+
const cmd = positionals[0];
|
|
3656
|
+
const rest = positionals.slice(1);
|
|
3657
|
+
const helpRequested = raw.includes("-h") || raw.includes("--help");
|
|
3658
|
+
if (helpRequested && cmd && cmd in COMMAND_HELP) {
|
|
3659
|
+
console.log(COMMAND_HELP[cmd]);
|
|
3660
|
+
return 0;
|
|
3661
|
+
}
|
|
3662
|
+
if (helpRequested || cmd === "help" || raw.length === 0) {
|
|
3663
|
+
console.log(HELP);
|
|
3664
|
+
return 0;
|
|
3665
|
+
}
|
|
3666
|
+
if (cmd && cmd in DIRECT) {
|
|
3667
|
+
return DIRECT[cmd](rest, flags);
|
|
3668
|
+
}
|
|
3669
|
+
switch (cmd) {
|
|
3670
|
+
case "init":
|
|
3671
|
+
return runInit({
|
|
3672
|
+
token: typeof flags.token === "string" ? flags.token : null,
|
|
3673
|
+
apiBase: typeof flags["api-base"] === "string" ? flags["api-base"] : null
|
|
3674
|
+
});
|
|
3675
|
+
case "uninstall":
|
|
3676
|
+
return runUninstall();
|
|
3677
|
+
case "status":
|
|
3678
|
+
return runStatus();
|
|
3679
|
+
case "update":
|
|
3680
|
+
return runUpdate(rest, flags);
|
|
3681
|
+
default:
|
|
3682
|
+
console.error(`Unknown command: ${String(cmd)}`);
|
|
3683
|
+
console.log(HELP);
|
|
3684
|
+
return 1;
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
3688
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
3689
|
+
process.exit(1);
|
|
3690
|
+
});
|