dsh-cliproxyapi-pro 1.7.0 → 1.9.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/CHANGELOG.md +42 -0
- package/README.en.md +159 -17
- package/README.md +159 -17
- package/lib/client.js +3429 -888
- package/lib/index.js +2130 -34
- package/lib/media.js +2542 -0
- package/lib/quota.js +114 -12
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -2,15 +2,31 @@
|
|
|
2
2
|
// Uses only Node.js built-ins and Cordis context services (`ctx.settings`, `ctx.credentials`, `ctx.commands`, `ctx.webServer`).
|
|
3
3
|
// It deliberately registers no agent tool: accounts are managed in the Settings
|
|
4
4
|
// page, HTTP routes and slash commands, so nothing adds to model context.
|
|
5
|
+
// Image / video models (lib/media.js) are classified and kept out of the
|
|
6
|
+
// chat routes; their preferences live in media-settings.json.
|
|
5
7
|
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
7
10
|
import { homedir } from "node:os";
|
|
8
|
-
import { dirname, join } from "node:path";
|
|
11
|
+
import { basename, dirname, extname, isAbsolute, join, relative, sep } from "node:path";
|
|
9
12
|
import {
|
|
10
13
|
createPluginUpdater, loadUpdateSettings, saveUpdateSettings, storeUpdatePackage,
|
|
11
14
|
UPDATE_INTERVAL_MS, UpdateError,
|
|
12
15
|
} from "./updater.js";
|
|
13
16
|
import { createQuotaController } from "./quota.js";
|
|
17
|
+
import {
|
|
18
|
+
accountKindOverrides, applyMediaPatch, chooseMediaModel, createMediaSessionStore, detectMediaKind,
|
|
19
|
+
diagnoseMedia, effectiveKind, generateImages, IMAGE_ASPECTS, loadMediaSettings, MAX_REFERENCE_IMAGES,
|
|
20
|
+
mediaAliasTarget, MediaError, mediaModelEnabled, mediaOptions, normalizeMediaSettings, outputDirectory,
|
|
21
|
+
outputStem, parseImageCommand, recentEntry, saveMediaSettings, validCommandId, validSessionId, writeOutputFiles,
|
|
22
|
+
createMediaJobStore, downloadVideo, MAX_VIDEO_BYTES, parseVideoCommand, retrieveVideo, submitVideo, VIDEO_ASPECTS, VIDEO_JOB_TTL_MS,
|
|
23
|
+
fitReferenceImage, REFERENCE_MAX_EDGE,
|
|
24
|
+
EDIT_SOURCE_MAX_SECONDS, EXTEND_SOURCE_SECONDS, parseVideoEditCommand, submitVideoEdit, VIDEO_EDIT_MODEL,
|
|
25
|
+
isMp4, mp4Duration, videoImageBudget, VIDEO_UPLOAD_MAX_BYTES,
|
|
26
|
+
interpretationLabels, interpretationRequest, parseInterpretation,
|
|
27
|
+
agentPolicy, applySessionPolicy, approvalText, chargeSeconds, GENERATE_MEDIA, GENERATE_MEDIA_OUTPUT, GENERATE_MEDIA_TOOL, parseToolArgs,
|
|
28
|
+
sniffImageType, toolCommandLine, toolRunId,
|
|
29
|
+
} from "./media.js";
|
|
14
30
|
|
|
15
31
|
// #region Constants & Config Resolution
|
|
16
32
|
|
|
@@ -557,6 +573,10 @@ export function parseCatalog(payload) {
|
|
|
557
573
|
const inputModalities = stringArray(item.input_modalities ?? item.inputModalities).map((v) =>
|
|
558
574
|
v.toLowerCase()
|
|
559
575
|
);
|
|
576
|
+
const outputModalities = stringArray(
|
|
577
|
+
item.supportedOutputModalities ?? item.output_modalities ?? item.outputModalities
|
|
578
|
+
).map((v) => v.toLowerCase());
|
|
579
|
+
const catalogType = typeof item.type === "string" && item.type.trim() !== "" ? item.type.trim() : undefined;
|
|
560
580
|
const rawLevels = item.supported_reasoning_levels ?? item.reasoning_levels;
|
|
561
581
|
const levels = parseReasoningLevels(rawLevels);
|
|
562
582
|
const reasoningWire = parseReasoningWire(rawLevels);
|
|
@@ -584,9 +604,13 @@ export function parseCatalog(payload) {
|
|
|
584
604
|
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
585
605
|
...(maxTokens === undefined ? {} : { maxTokens }),
|
|
586
606
|
inputModalities: inputModalities.length > 0 ? inputModalities : ["text"],
|
|
607
|
+
...(outputModalities.length === 0 ? {} : { outputModalities }),
|
|
608
|
+
...(catalogType === undefined ? {} : { catalogType }),
|
|
587
609
|
reasoning: item.reasoning === true || levels.some((level) => level !== "off"),
|
|
588
610
|
reasoningLevels: levels,
|
|
589
611
|
...(Object.keys(reasoningWire).length === 0 ? {} : { reasoningWire }),
|
|
612
|
+
// chat | image | video, before user overrides (see lib/media.js).
|
|
613
|
+
detectedKind: detectMediaKind({ id, catalogType, outputModalities }),
|
|
590
614
|
});
|
|
591
615
|
}
|
|
592
616
|
return [...models.values()];
|
|
@@ -608,9 +632,20 @@ async function readBoundedJson(response) {
|
|
|
608
632
|
}
|
|
609
633
|
}
|
|
610
634
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
635
|
+
/**
|
|
636
|
+
* The plain `/v1/models` listing, parsed, by id. It carries models the
|
|
637
|
+
* enriched catalog omits (the registry `type` that marks image models, and
|
|
638
|
+
* media models only this listing knows), so it gets its own deadline instead
|
|
639
|
+
* of the enriched request's remaining time and one retry: a slow first
|
|
640
|
+
* response must never silently hide every image model until the next
|
|
641
|
+
* five-minute refresh.
|
|
642
|
+
* @returns `{ models, ok }`; `ok` is false when the listing could not be read.
|
|
643
|
+
*/
|
|
644
|
+
async function fetchListing(fetchImpl, rootURL, options) {
|
|
645
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
646
|
+
const attempt = async () => {
|
|
647
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
648
|
+
const signal = options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]);
|
|
614
649
|
const response = await fetchImpl(`${rootURL}/v1/models`, {
|
|
615
650
|
method: "GET",
|
|
616
651
|
headers: {
|
|
@@ -621,17 +656,46 @@ async function fetchOwners(fetchImpl, rootURL, options, signal) {
|
|
|
621
656
|
},
|
|
622
657
|
signal,
|
|
623
658
|
});
|
|
624
|
-
if (!response.ok) return
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (id !== "" && owner !== "") owners.set(id, owner);
|
|
630
|
-
}
|
|
659
|
+
if (!response.ok) return { models: new Map(), ok: false };
|
|
660
|
+
return { models: new Map(parseCatalog(await readBoundedJson(response)).map((m) => [m.id, m])), ok: true };
|
|
661
|
+
};
|
|
662
|
+
try {
|
|
663
|
+
return await attempt();
|
|
631
664
|
} catch (error) {
|
|
632
665
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
666
|
+
if (error instanceof Error && /catalog is larger|not valid JSON|does not contain/.test(error.message)) {
|
|
667
|
+
return { models: new Map(), ok: false };
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
return await attempt();
|
|
671
|
+
} catch (retryError) {
|
|
672
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
673
|
+
return { models: new Map(), ok: false };
|
|
674
|
+
}
|
|
633
675
|
}
|
|
634
|
-
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Fill catalog gaps from the plain listing: `owned_by` (native protocol),
|
|
680
|
+
* the registry `type` and output modalities (media kind). A media model that
|
|
681
|
+
* only the plain listing carries is added; plain-only chat models are not.
|
|
682
|
+
*/
|
|
683
|
+
function mergeListing(models, listing) {
|
|
684
|
+
const merged = models.map((m) => {
|
|
685
|
+
const plainEntry = listing.get(m.id);
|
|
686
|
+
if (plainEntry === undefined) return m;
|
|
687
|
+
const next = { ...m };
|
|
688
|
+
for (const field of ["ownedBy", "catalogType", "outputModalities"]) {
|
|
689
|
+
if (next[field] === undefined && plainEntry[field] !== undefined) next[field] = plainEntry[field];
|
|
690
|
+
}
|
|
691
|
+
next.detectedKind = detectMediaKind(next);
|
|
692
|
+
return next;
|
|
693
|
+
});
|
|
694
|
+
const known = new Set(merged.map((m) => m.id));
|
|
695
|
+
for (const entry of listing.values()) {
|
|
696
|
+
if (!known.has(entry.id) && entry.detectedKind !== "chat") merged.push(entry);
|
|
697
|
+
}
|
|
698
|
+
return merged;
|
|
635
699
|
}
|
|
636
700
|
|
|
637
701
|
export async function discoverCpa(options) {
|
|
@@ -643,6 +707,9 @@ export async function discoverCpa(options) {
|
|
|
643
707
|
const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
|
|
644
708
|
const signal =
|
|
645
709
|
options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]);
|
|
710
|
+
// Start the plain listing together with the enriched catalog; both carry
|
|
711
|
+
// different fields, and the listing alone knows some image models.
|
|
712
|
+
const listingPromise = fetchListing(fetchImpl, rootURL, options);
|
|
646
713
|
try {
|
|
647
714
|
const response = await fetchImpl(modelsURL, {
|
|
648
715
|
method: "GET",
|
|
@@ -655,21 +722,18 @@ export async function discoverCpa(options) {
|
|
|
655
722
|
signal,
|
|
656
723
|
});
|
|
657
724
|
if (!response.ok) {
|
|
725
|
+
void listingPromise.catch(() => {});
|
|
658
726
|
failures.push(`${rootURL}: HTTP ${response.status}`);
|
|
659
727
|
continue;
|
|
660
728
|
}
|
|
661
729
|
const models = parseCatalog(await readBoundedJson(response));
|
|
662
730
|
// The enriched catalog omits `owned_by`; the plain listing carries the
|
|
663
|
-
// model family each upstream reports, which decides its native protocol
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
modelsURL,
|
|
668
|
-
models: models.map((m) =>
|
|
669
|
-
m.ownedBy === undefined && owners.has(m.id) ? { ...m, ownedBy: owners.get(m.id) } : m
|
|
670
|
-
),
|
|
671
|
-
};
|
|
731
|
+
// model family each upstream reports, which decides its native protocol,
|
|
732
|
+
// and the registry type that marks image models.
|
|
733
|
+
const listing = await listingPromise;
|
|
734
|
+
return { rootURL, modelsURL, models: mergeListing(models, listing.models), listingOk: listing.ok };
|
|
672
735
|
} catch (error) {
|
|
736
|
+
void listingPromise.catch(() => {});
|
|
673
737
|
if (options.signal?.aborted) throw options.signal.reason;
|
|
674
738
|
failures.push(`${rootURL}: ${error instanceof Error ? error.message : String(error)}`);
|
|
675
739
|
}
|
|
@@ -899,16 +963,23 @@ function toPiAiModelProfile(model, protocol) {
|
|
|
899
963
|
* route key to its profile, in protocol order, and every ordered model
|
|
900
964
|
* carries its `protocol` and `route`.
|
|
901
965
|
*/
|
|
966
|
+
/** Whether a model belongs in chat routes: `kind` (with overrides) or detection says chat. */
|
|
967
|
+
export function isChatModel(model) {
|
|
968
|
+
return (model.kind ?? model.detectedKind ?? "chat") === "chat";
|
|
969
|
+
}
|
|
970
|
+
|
|
902
971
|
export function buildPiAiRoutes(config, discovery, hasApiKey) {
|
|
903
972
|
const allOrdered = sortModelsNewestFirst(discovery.models);
|
|
904
|
-
|
|
973
|
+
// Image / video models are served by CPA's media endpoints, never by chat routes.
|
|
974
|
+
const chatOrdered = allOrdered.filter(isChatModel);
|
|
975
|
+
const filtered = filterModels(chatOrdered, config).map((model) => {
|
|
905
976
|
const effective = applyModelOverride(model, config.modelOverrides?.[model.id]);
|
|
906
977
|
const protocol = modelProtocol(model, config);
|
|
907
978
|
return { ...effective, protocol, route: routeKeyFor(config.provider, protocol) };
|
|
908
979
|
});
|
|
909
980
|
if (filtered.length === 0) {
|
|
910
981
|
throw new Error(
|
|
911
|
-
`dsh-cliproxyapi: none of the selected models matched the ${
|
|
982
|
+
`dsh-cliproxyapi: none of the selected models matched the ${chatOrdered.length} chat model(s) offered by ${discovery.rootURL}. Select models in Settings → CLIProxyAPI → Models.`
|
|
912
983
|
);
|
|
913
984
|
}
|
|
914
985
|
const auth = hasApiKey
|
|
@@ -984,6 +1055,7 @@ function formatModelView(m, selectedSet, config) {
|
|
|
984
1055
|
const effective = applyModelOverride(m, override);
|
|
985
1056
|
const protocol = config === undefined ? nativeProtocol(m) : modelProtocol(m, config);
|
|
986
1057
|
return {
|
|
1058
|
+
kind: m.kind ?? m.detectedKind ?? "chat",
|
|
987
1059
|
protocol,
|
|
988
1060
|
nativeProtocol: nativeProtocol(m),
|
|
989
1061
|
...(m.ownedBy === undefined ? {} : { ownedBy: m.ownedBy }),
|
|
@@ -1016,6 +1088,12 @@ export class CpaRuntime {
|
|
|
1016
1088
|
this.controller = new AbortController();
|
|
1017
1089
|
this.allDiscoveredModels = [];
|
|
1018
1090
|
this.syncedModels = [];
|
|
1091
|
+
// Whether the last successful discovery could read the plain `/v1/models`
|
|
1092
|
+
// listing, which carries models the enriched catalog omits (image models
|
|
1093
|
+
// for some servers). A failed read is retried shortly, not in five minutes.
|
|
1094
|
+
this.listingOk = true;
|
|
1095
|
+
this.listingRetries = 0;
|
|
1096
|
+
this.listingRetryTimer = undefined;
|
|
1019
1097
|
this.currentStatus = {
|
|
1020
1098
|
state: "idle",
|
|
1021
1099
|
modelCount: 0,
|
|
@@ -1023,17 +1101,36 @@ export class CpaRuntime {
|
|
|
1023
1101
|
};
|
|
1024
1102
|
}
|
|
1025
1103
|
|
|
1104
|
+
/**
|
|
1105
|
+
* Models with their effective `kind` under the account's current kind
|
|
1106
|
+
* overrides (read fresh, so an override applies without a new probe).
|
|
1107
|
+
*/
|
|
1108
|
+
withKinds(models = this.allDiscoveredModels) {
|
|
1109
|
+
let overrides = {};
|
|
1110
|
+
try {
|
|
1111
|
+
overrides = this.options.kindOverrides?.() ?? {};
|
|
1112
|
+
} catch {}
|
|
1113
|
+
return models.map((m) => ({ ...m, kind: effectiveKind(m, overrides) }));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Discovered chat models (what the picker, recommendations and seeding use). */
|
|
1117
|
+
chatModels(models = this.allDiscoveredModels) {
|
|
1118
|
+
return this.withKinds(models).filter(isChatModel);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1026
1121
|
status() {
|
|
1027
1122
|
const syncedSet = new Set(this.syncedModels.map((m) => m.id));
|
|
1028
1123
|
const config = this.options.config();
|
|
1029
|
-
const
|
|
1124
|
+
const kinded = this.withKinds();
|
|
1125
|
+
const byId = new Map(kinded.map((m) => [m.id, m]));
|
|
1030
1126
|
return {
|
|
1031
1127
|
...this.currentStatus,
|
|
1128
|
+
listingOk: this.listingOk,
|
|
1032
1129
|
models: this.syncedModels.map((m) => ({
|
|
1033
1130
|
...formatModelView(byId.get(m.id) ?? m, syncedSet, config),
|
|
1034
1131
|
route: m.route,
|
|
1035
1132
|
})),
|
|
1036
|
-
allModels:
|
|
1133
|
+
allModels: kinded.map((m) => formatModelView(m, syncedSet, config)),
|
|
1037
1134
|
};
|
|
1038
1135
|
}
|
|
1039
1136
|
|
|
@@ -1057,6 +1154,7 @@ export class CpaRuntime {
|
|
|
1057
1154
|
dispose() {
|
|
1058
1155
|
this.controller.abort(new Error("dsh-cliproxyapi disposed"));
|
|
1059
1156
|
if (this.hmrRetryTimer !== undefined) clearTimeout(this.hmrRetryTimer);
|
|
1157
|
+
if (this.listingRetryTimer !== undefined) clearTimeout(this.listingRetryTimer);
|
|
1060
1158
|
}
|
|
1061
1159
|
|
|
1062
1160
|
/** Stop syncing and remove every route this account wrote. */
|
|
@@ -1181,6 +1279,28 @@ export class CpaRuntime {
|
|
|
1181
1279
|
}, 3000 * this.hmrRetries);
|
|
1182
1280
|
}
|
|
1183
1281
|
|
|
1282
|
+
/**
|
|
1283
|
+
* A failed plain listing must not leave image models hidden until the next
|
|
1284
|
+
* five-minute refresh. Re-read soon, at most twice per failure streak.
|
|
1285
|
+
*/
|
|
1286
|
+
scheduleListingRetry() {
|
|
1287
|
+
if (this.listingOk !== false) {
|
|
1288
|
+
this.listingRetries = 0;
|
|
1289
|
+
if (this.listingRetryTimer !== undefined) {
|
|
1290
|
+
clearTimeout(this.listingRetryTimer);
|
|
1291
|
+
this.listingRetryTimer = undefined;
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (this.listingRetries >= 2 || this.controller.signal.aborted || this.listingRetryTimer !== undefined) return;
|
|
1296
|
+
this.listingRetries += 1;
|
|
1297
|
+
const schedule = this.options.setTimeout ?? setTimeout;
|
|
1298
|
+
this.listingRetryTimer = schedule(() => {
|
|
1299
|
+
this.listingRetryTimer = undefined;
|
|
1300
|
+
if (!this.controller.signal.aborted) void this.refresh();
|
|
1301
|
+
}, 4000);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1184
1304
|
async syncOnce() {
|
|
1185
1305
|
const config = this.options.config();
|
|
1186
1306
|
const probe = this.probeRequested === true;
|
|
@@ -1207,7 +1327,8 @@ export class CpaRuntime {
|
|
|
1207
1327
|
try {
|
|
1208
1328
|
const { result } = await this.discover(config);
|
|
1209
1329
|
this.allDiscoveredModels = result.models;
|
|
1210
|
-
this.
|
|
1330
|
+
this.listingOk = result.listingOk !== false;
|
|
1331
|
+
this.seedSelection(config, this.chatModels(result.models));
|
|
1211
1332
|
endpoint = result.rootURL;
|
|
1212
1333
|
} catch (error) {
|
|
1213
1334
|
if (this.controller.signal.aborted) throw error;
|
|
@@ -1233,7 +1354,9 @@ export class CpaRuntime {
|
|
|
1233
1354
|
const { result, apiKey, effectiveApiKeyEnv } = await this.discover(config);
|
|
1234
1355
|
// Keep the catalog visible in the panel even if the selection matches nothing.
|
|
1235
1356
|
this.allDiscoveredModels = result.models;
|
|
1236
|
-
|
|
1357
|
+
this.listingOk = result.listingOk !== false;
|
|
1358
|
+
this.scheduleListingRetry();
|
|
1359
|
+
const seeded = this.seedSelection(config, this.chatModels(result.models));
|
|
1237
1360
|
|
|
1238
1361
|
const effectiveConfig = {
|
|
1239
1362
|
...config,
|
|
@@ -1241,7 +1364,11 @@ export class CpaRuntime {
|
|
|
1241
1364
|
apiKeyEnv: effectiveApiKeyEnv,
|
|
1242
1365
|
};
|
|
1243
1366
|
|
|
1244
|
-
const built = buildPiAiRoutes(
|
|
1367
|
+
const built = buildPiAiRoutes(
|
|
1368
|
+
effectiveConfig,
|
|
1369
|
+
{ ...result, models: this.withKinds(result.models) },
|
|
1370
|
+
apiKey !== undefined
|
|
1371
|
+
);
|
|
1245
1372
|
this.syncedModels = built.orderedModels;
|
|
1246
1373
|
|
|
1247
1374
|
// Each managed route keeps unrelated fields a user set by hand (retry
|
|
@@ -1382,6 +1509,8 @@ export function createCpaController(deps) {
|
|
|
1382
1509
|
resolveApiKey: deps.resolveApiKey,
|
|
1383
1510
|
readExistingPiAiRoute: deps.readExistingPiAiRoute,
|
|
1384
1511
|
readDefaultModel: deps.readDefaultModel,
|
|
1512
|
+
// User reclassifications (chat / image / video) from media-settings.json.
|
|
1513
|
+
kindOverrides: () => deps.mediaKindOverrides?.(id) ?? {},
|
|
1385
1514
|
// Persist a first-run selection unless one was saved in the meantime.
|
|
1386
1515
|
seedSelection: (ids) => {
|
|
1387
1516
|
const state = deps.readState();
|
|
@@ -1473,7 +1602,7 @@ export function createCpaController(deps) {
|
|
|
1473
1602
|
source: keyInfo.source ?? null,
|
|
1474
1603
|
},
|
|
1475
1604
|
// One model per family from this account's catalog (the panel's reset button).
|
|
1476
|
-
recommendedModels: recommendModels(runtime.
|
|
1605
|
+
recommendedModels: recommendModels(runtime.chatModels()),
|
|
1477
1606
|
status: st,
|
|
1478
1607
|
};
|
|
1479
1608
|
};
|
|
@@ -1663,6 +1792,80 @@ export function createCpaController(deps) {
|
|
|
1663
1792
|
});
|
|
1664
1793
|
},
|
|
1665
1794
|
|
|
1795
|
+
/**
|
|
1796
|
+
* Media models per account, from the last catalog each runtime saw, with
|
|
1797
|
+
* their effective and detected kind. `chatModelIds` lets the panel mark a
|
|
1798
|
+
* chat model as media by hand. No network access.
|
|
1799
|
+
*/
|
|
1800
|
+
mediaAccounts() {
|
|
1801
|
+
const state = deps.readState();
|
|
1802
|
+
return state.accounts.map((account) => {
|
|
1803
|
+
const config = accountConfig(state, account.id);
|
|
1804
|
+
const runtime = ensureRuntime(account.id);
|
|
1805
|
+
const st = runtime.currentStatus ?? {};
|
|
1806
|
+
const kinded = runtime.withKinds();
|
|
1807
|
+
return {
|
|
1808
|
+
id: account.id,
|
|
1809
|
+
displayName: config.displayName,
|
|
1810
|
+
enabled: config.enabled === true,
|
|
1811
|
+
state: config.enabled === true ? st.state ?? "idle" : "disabled",
|
|
1812
|
+
endpoint: st.endpoint || "",
|
|
1813
|
+
discovered: kinded.length,
|
|
1814
|
+
listingOk: runtime.listingOk !== false,
|
|
1815
|
+
catalogIncomplete: runtime.listingOk === false,
|
|
1816
|
+
models: kinded
|
|
1817
|
+
.filter((m) => !isChatModel(m))
|
|
1818
|
+
.map((m) => ({
|
|
1819
|
+
id: m.id,
|
|
1820
|
+
...(m.name === undefined ? {} : { name: m.name }),
|
|
1821
|
+
kind: m.kind,
|
|
1822
|
+
detectedKind: m.detectedKind ?? "chat",
|
|
1823
|
+
...(mediaAliasTarget(m.id) === undefined ? {} : { aliasOf: mediaAliasTarget(m.id) }),
|
|
1824
|
+
})),
|
|
1825
|
+
chatModelIds: kinded.filter(isChatModel).map((m) => m.id),
|
|
1826
|
+
};
|
|
1827
|
+
});
|
|
1828
|
+
},
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Read-only media diagnostic for one account (see diagnoseMedia in
|
|
1832
|
+
* lib/media.js). Uses the account's key on the host; never returns it.
|
|
1833
|
+
*/
|
|
1834
|
+
/**
|
|
1835
|
+
* The CPA root and key a media request of this account uses: the address
|
|
1836
|
+
* the last sync connected to, else the configured or route address.
|
|
1837
|
+
* Host-only; the key is never returned to a page.
|
|
1838
|
+
* @throws MediaError `no-endpoint`.
|
|
1839
|
+
*/
|
|
1840
|
+
async mediaEndpoint(accountId) {
|
|
1841
|
+
const state = deps.readState();
|
|
1842
|
+
const account = state.accounts.find((a) => a.id === accountId);
|
|
1843
|
+
if (account === undefined) throw new MediaError("invalid-account");
|
|
1844
|
+
const config = accountConfig(state, account.id);
|
|
1845
|
+
const runtime = ensureRuntime(account.id);
|
|
1846
|
+
const known = runtime.currentStatus?.endpoint || config.baseURL || deps.readExistingPiAiRoute?.(config.provider)?.baseURL || "";
|
|
1847
|
+
let rootURL;
|
|
1848
|
+
try {
|
|
1849
|
+
rootURL = normalizeCpaRoot(known);
|
|
1850
|
+
} catch {
|
|
1851
|
+
throw new MediaError("no-endpoint");
|
|
1852
|
+
}
|
|
1853
|
+
return { rootURL, apiKey: await deps.resolveApiKey(effectiveKeyEnv(config)), probeTimeoutMs: state.probeTimeoutMs };
|
|
1854
|
+
},
|
|
1855
|
+
|
|
1856
|
+
async diagnoseMedia(ref) {
|
|
1857
|
+
const state = deps.readState();
|
|
1858
|
+
const account = findAccount(state, ref);
|
|
1859
|
+
const { rootURL, apiKey } = await this.mediaEndpoint(account.id);
|
|
1860
|
+
const diagnostic = await diagnoseMedia({
|
|
1861
|
+
rootURL,
|
|
1862
|
+
apiKey,
|
|
1863
|
+
timeoutMs: Math.max(state.probeTimeoutMs, 8000),
|
|
1864
|
+
...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
|
|
1865
|
+
});
|
|
1866
|
+
return { accountId: account.id, keyConfigured: apiKey !== undefined, ...diagnostic };
|
|
1867
|
+
},
|
|
1868
|
+
|
|
1666
1869
|
dispose() {
|
|
1667
1870
|
for (const runtime of runtimes.values()) runtime.dispose();
|
|
1668
1871
|
runtimes.clear();
|
|
@@ -1688,6 +1891,14 @@ export const ROUTES = {
|
|
|
1688
1891
|
quotaStatus: "/api/dsh-cliproxyapi/quota/status",
|
|
1689
1892
|
quotaRefresh: "/api/dsh-cliproxyapi/quota/refresh",
|
|
1690
1893
|
quotaSave: "/api/dsh-cliproxyapi/quota/save",
|
|
1894
|
+
mediaStatus: "/api/dsh-cliproxyapi/media/status",
|
|
1895
|
+
mediaSave: "/api/dsh-cliproxyapi/media/save",
|
|
1896
|
+
mediaDiagnose: "/api/dsh-cliproxyapi/media/diagnose",
|
|
1897
|
+
mediaSession: "/api/dsh-cliproxyapi/media/session",
|
|
1898
|
+
mediaSessionSave: "/api/dsh-cliproxyapi/media/session/save",
|
|
1899
|
+
mediaFile: "/api/dsh-cliproxyapi/media/file",
|
|
1900
|
+
mediaCommand: "/api/dsh-cliproxyapi/media/command",
|
|
1901
|
+
mediaConfirm: "/api/dsh-cliproxyapi/media/confirm",
|
|
1691
1902
|
};
|
|
1692
1903
|
|
|
1693
1904
|
function sendJson(res, status, payload) {
|
|
@@ -1902,6 +2113,11 @@ function renderAccountStatus(account) {
|
|
|
1902
2113
|
.map((m) => m.id)
|
|
1903
2114
|
.join(", ")}${status.models.length > 12 ? ` ... (+${status.models.length - 12} more)` : ""}`
|
|
1904
2115
|
: undefined;
|
|
2116
|
+
const media = (status.allModels ?? []).filter((m) => m.kind === "image" || m.kind === "video");
|
|
2117
|
+
const mediaLine =
|
|
2118
|
+
media.length > 0
|
|
2119
|
+
? `Media Models (not in the picker): ${media.filter((m) => m.kind === "image").length} image, ${media.filter((m) => m.kind === "video").length} video`
|
|
2120
|
+
: undefined;
|
|
1905
2121
|
const counts = status.protocolCounts ?? {};
|
|
1906
2122
|
const protocolLine =
|
|
1907
2123
|
cfg.protocolMode === "fixed"
|
|
@@ -1922,6 +2138,7 @@ function renderAccountStatus(account) {
|
|
|
1922
2138
|
status.totalDiscovered ? ` (of ${status.totalDiscovered} discovered)` : ""
|
|
1923
2139
|
}`,
|
|
1924
2140
|
...(modelListPreview ? [modelListPreview] : []),
|
|
2141
|
+
...(mediaLine ? [mediaLine] : []),
|
|
1925
2142
|
...(status.refreshedAt ? [`Last Refreshed: ${status.refreshedAt}`] : []),
|
|
1926
2143
|
...(status.error ? [`Error: ${status.error}`] : []),
|
|
1927
2144
|
];
|
|
@@ -1939,7 +2156,11 @@ export function renderQuotaText(snapshot, onlyAccountId) {
|
|
|
1939
2156
|
continue;
|
|
1940
2157
|
}
|
|
1941
2158
|
const creds = Array.isArray(a.credentials) ? a.credentials : [];
|
|
1942
|
-
const state = a.state + (a.errorCode ? ` (${a.errorCode}${a.errorStatus ? ` ${a.errorStatus}` : ""})` : "") +
|
|
2159
|
+
const state = a.state + (a.errorCode ? ` (${a.errorCode}${a.errorStatus ? ` ${a.errorStatus}` : ""})` : "") +
|
|
2160
|
+
(Number.isInteger(a.authFailures) && a.authFailures > 0
|
|
2161
|
+
? `, key rejected ${a.authFailures}/${a.authFailureLimit}${a.authPaused ? ", automatic checks stopped until a new management key is saved" : ""}`
|
|
2162
|
+
: "") +
|
|
2163
|
+
(a.refreshing ? ", refreshing" : "");
|
|
1943
2164
|
lines.push(`- ${a.displayName} [${a.accountId}]: ${state}; ${creds.length} credential(s)${a.fetchedAt ? `; fetched ${a.fetchedAt}` : ""}; routes ${(a.routes ?? []).join(", ") || "none"}`);
|
|
1944
2165
|
for (const m of a.pickerModels ?? []) {
|
|
1945
2166
|
const ids = a.modelIndex?.[m.id] ?? [];
|
|
@@ -2130,15 +2351,41 @@ function applyImpl(ctx, config) {
|
|
|
2130
2351
|
if (timer !== undefined) clearInterval(timer);
|
|
2131
2352
|
timer = outsideHmrTransaction(ctx, () =>
|
|
2132
2353
|
setInterval(() => {
|
|
2133
|
-
void controller.refreshAll();
|
|
2354
|
+
void controller.refreshAll().finally(() => syncAgentTool());
|
|
2134
2355
|
}, readState().refreshIntervalSeconds * 1000)
|
|
2135
2356
|
);
|
|
2136
2357
|
};
|
|
2137
2358
|
|
|
2359
|
+
// Media preferences (lib/media.js) live in their own file next to the
|
|
2360
|
+
// account state, never inside it, so no state migration is involved.
|
|
2361
|
+
const mediaSettingsPath = join(updateDirectory, "media-settings.json");
|
|
2362
|
+
let mediaWarned = false;
|
|
2363
|
+
// Generation is off for a new profile; one that already picked a model or
|
|
2364
|
+
// generated something before the switch existed (session or job file, no
|
|
2365
|
+
// readable settings file) keeps it on.
|
|
2366
|
+
const mediaUsedBefore = () =>
|
|
2367
|
+
existsSync(join(updateDirectory, "media-sessions.json")) || existsSync(join(updateDirectory, "media-jobs.json"));
|
|
2368
|
+
const readMedia = () => {
|
|
2369
|
+
const loaded = loadMediaSettings(mediaSettingsPath);
|
|
2370
|
+
if ((loaded.error !== undefined || !existsSync(mediaSettingsPath)) && mediaUsedBefore()) {
|
|
2371
|
+
loaded.settings = { ...loaded.settings, enabled: true };
|
|
2372
|
+
}
|
|
2373
|
+
if (loaded.error === undefined) {
|
|
2374
|
+
mediaWarned = false;
|
|
2375
|
+
} else if (!mediaWarned) {
|
|
2376
|
+
mediaWarned = true;
|
|
2377
|
+
ctx.logger?.warn?.(
|
|
2378
|
+
`dsh-cliproxyapi: ${mediaSettingsPath} is unreadable; default media settings apply until it is saved again (the damaged file is then kept as *.corrupt-*.json)`
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
return loaded;
|
|
2382
|
+
};
|
|
2383
|
+
|
|
2138
2384
|
const controller = createCpaController({
|
|
2139
2385
|
settings: ctx.settings,
|
|
2140
2386
|
readState,
|
|
2141
2387
|
writeState,
|
|
2388
|
+
mediaKindOverrides: (id) => accountKindOverrides(readMedia().settings, id),
|
|
2142
2389
|
resolveApiKey,
|
|
2143
2390
|
describeApiKey,
|
|
2144
2391
|
setCredential: async (ref, value) => {
|
|
@@ -2161,7 +2408,19 @@ function applyImpl(ctx, config) {
|
|
|
2161
2408
|
|
|
2162
2409
|
// Quota ("额度" tab + composer badge): management keys live in the
|
|
2163
2410
|
// credentials service under their own refs, never in account state.
|
|
2411
|
+
// Consecutive management-key rejections per account (never the key itself),
|
|
2412
|
+
// so a wrong key stays stopped across reloads and restarts.
|
|
2413
|
+
const quotaStatePath = join(updateDirectory, "quota-state.json");
|
|
2164
2414
|
const quota = createQuotaController({
|
|
2415
|
+
authStore: {
|
|
2416
|
+
read: () => (existsSync(quotaStatePath) ? JSON.parse(readFileSync(quotaStatePath, "utf8")) : undefined),
|
|
2417
|
+
write: (value) => {
|
|
2418
|
+
mkdirSync(dirname(quotaStatePath), { recursive: true });
|
|
2419
|
+
const tmp = `${quotaStatePath}.${Date.now().toString(36)}.tmp`;
|
|
2420
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
2421
|
+
renameSync(tmp, quotaStatePath);
|
|
2422
|
+
},
|
|
2423
|
+
},
|
|
2165
2424
|
listAccounts: () => controller.quotaAccounts(),
|
|
2166
2425
|
resolveKey: (ref) => resolveApiKey(ref),
|
|
2167
2426
|
describeKey: (ref) => describeApiKey(ref),
|
|
@@ -2179,15 +2438,1613 @@ function applyImpl(ctx, config) {
|
|
|
2179
2438
|
});
|
|
2180
2439
|
ctx.effect(() => () => quota.dispose(), "cpa: quota");
|
|
2181
2440
|
|
|
2441
|
+
/** Media page payload: preferences (without per-account maps) and each account's media models. */
|
|
2442
|
+
const mediaStatus = () => {
|
|
2443
|
+
const { settings, error } = readMedia();
|
|
2444
|
+
const { accounts: perAccount, ...global } = settings;
|
|
2445
|
+
return {
|
|
2446
|
+
settings: global,
|
|
2447
|
+
...(error === undefined ? {} : { settingsError: error }),
|
|
2448
|
+
accounts: controller.mediaAccounts().map((account) => {
|
|
2449
|
+
const overrides = accountKindOverrides(settings, account.id);
|
|
2450
|
+
return {
|
|
2451
|
+
...account,
|
|
2452
|
+
disabledModels: perAccount[account.id]?.disabledModels ?? [],
|
|
2453
|
+
kindOverrides: overrides,
|
|
2454
|
+
models: account.models.map((m) => ({
|
|
2455
|
+
...m,
|
|
2456
|
+
enabled: mediaModelEnabled(settings, account.id, m.id),
|
|
2457
|
+
overridden: overrides[m.id] !== undefined,
|
|
2458
|
+
})),
|
|
2459
|
+
};
|
|
2460
|
+
}),
|
|
2461
|
+
};
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
const saveMedia = async (body) => {
|
|
2465
|
+
const accountId = body?.account?.accountId;
|
|
2466
|
+
if (accountId !== undefined && !controller.mediaAccounts().some((a) => a.id === accountId)) {
|
|
2467
|
+
throw new MediaError("invalid-account");
|
|
2468
|
+
}
|
|
2469
|
+
const { settings, kindsChanged } = applyMediaPatch(readMedia().settings, body);
|
|
2470
|
+
saveMediaSettings(mediaSettingsPath, settings);
|
|
2471
|
+
// A reclassified model enters or leaves the chat routes on the next sync.
|
|
2472
|
+
if (kindsChanged === undefined) return { ok: true, media: mediaStatus() };
|
|
2473
|
+
await controller.refreshAccount(kindsChanged);
|
|
2474
|
+
return { ok: true, media: mediaStatus(), status: await controller.statusPayload() };
|
|
2475
|
+
};
|
|
2476
|
+
|
|
2477
|
+
// Per-session model picks and recent outputs, in their own file.
|
|
2478
|
+
const mediaSessions = createMediaSessionStore(join(updateDirectory, "media-sessions.json"));
|
|
2479
|
+
|
|
2480
|
+
// With generation switched off nothing is offered: no composer buttons, no
|
|
2481
|
+
// agent tool, and no command can pick a model.
|
|
2482
|
+
const mediaChoices = () => {
|
|
2483
|
+
const { settings } = readMedia();
|
|
2484
|
+
const accounts = controller.mediaAccounts();
|
|
2485
|
+
const on = settings.enabled === true;
|
|
2486
|
+
return {
|
|
2487
|
+
settings,
|
|
2488
|
+
accounts,
|
|
2489
|
+
options: on
|
|
2490
|
+
? { image: mediaOptions(accounts, settings, "image"), video: mediaOptions(accounts, settings, "video") }
|
|
2491
|
+
: { image: [], video: [] },
|
|
2492
|
+
};
|
|
2493
|
+
};
|
|
2494
|
+
|
|
2495
|
+
/**
|
|
2496
|
+
* Choices for a generation command. When a needed kind is not offered and an
|
|
2497
|
+
* enabled account either has not read its catalog yet (just after a start
|
|
2498
|
+
* or reload) or reported a failed plain listing, sync once and look again
|
|
2499
|
+
* instead of failing on a timing race. An incomplete-catalog retry is
|
|
2500
|
+
* limited to once per 30 s so a permanently missing listing is not fetched
|
|
2501
|
+
* on every request.
|
|
2502
|
+
*/
|
|
2503
|
+
let catalogRetryAt = 0;
|
|
2504
|
+
const mediaChoicesFor = async (kind) => {
|
|
2505
|
+
let choices = mediaChoices();
|
|
2506
|
+
if (choices.settings.enabled !== true) return choices;
|
|
2507
|
+
const missing = (k) => choices.options[k].length === 0;
|
|
2508
|
+
const someMissing = kind === "any" ? missing("image") || missing("video") : missing(kind);
|
|
2509
|
+
const cold = choices.accounts.some((a) => a.enabled && a.discovered === 0);
|
|
2510
|
+
const incomplete = choices.accounts.some((a) => a.enabled && a.catalogIncomplete === true);
|
|
2511
|
+
const retryIncomplete = incomplete && Date.now() - catalogRetryAt >= 30_000;
|
|
2512
|
+
if (someMissing && (cold || retryIncomplete)) {
|
|
2513
|
+
catalogRetryAt = Date.now();
|
|
2514
|
+
await controller.refreshAll();
|
|
2515
|
+
choices = mediaChoices();
|
|
2516
|
+
}
|
|
2517
|
+
return choices;
|
|
2518
|
+
};
|
|
2519
|
+
|
|
2520
|
+
/** One line per account for an empty choice: state and catalog counts, never keys or URLs. */
|
|
2521
|
+
const mediaAccountsSummary = (accounts, kind) =>
|
|
2522
|
+
accounts
|
|
2523
|
+
.map((a) => `${a.displayName || a.id}: ${a.enabled ? a.state : "disabled"}, ${a.discovered} model(s), ${a.models.filter((m) => m.kind === kind).length} ${kind}`)
|
|
2524
|
+
.join("; ") || "no account";
|
|
2525
|
+
|
|
2526
|
+
/** What the composer button shows for one session (no network access). */
|
|
2527
|
+
const sessionView = (sessionId) => {
|
|
2528
|
+
const { settings, options, accounts } = mediaChoices();
|
|
2529
|
+
const entry = sessionId === undefined ? { recent: [] } : mediaSessions.get(sessionId);
|
|
2530
|
+
const current = {};
|
|
2531
|
+
for (const kind of ["image", "video"]) {
|
|
2532
|
+
try {
|
|
2533
|
+
const { option, source } = chooseMediaModel({
|
|
2534
|
+
options: options[kind],
|
|
2535
|
+
sessionRef: entry[kind],
|
|
2536
|
+
lastRef: settings.lastSelection[kind],
|
|
2537
|
+
});
|
|
2538
|
+
current[kind] = { ref: option.ref, source };
|
|
2539
|
+
} catch {
|
|
2540
|
+
current[kind] = null;
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
const policy = agentPolicy(settings, entry);
|
|
2544
|
+
return {
|
|
2545
|
+
sessionId: sessionId ?? null,
|
|
2546
|
+
// The Generation tab's master switch; off = the composer shows nothing.
|
|
2547
|
+
generation: settings.enabled === true,
|
|
2548
|
+
options,
|
|
2549
|
+
current,
|
|
2550
|
+
// Compatibility composer fallback; native questions never enter this map.
|
|
2551
|
+
pendingConfirmations: sessionId === undefined ? [] : [...pendingConfirms.entries()]
|
|
2552
|
+
.filter(([, waiting]) => waiting.sessionId === sessionId)
|
|
2553
|
+
.map(([commandId, { kind, en, zh, expiresAt }]) => ({ commandId, kind, en, zh, expiresAt })),
|
|
2554
|
+
// True while an enabled account's last catalog read missed the plain
|
|
2555
|
+
// listing; the composer button retries instead of keeping an old,
|
|
2556
|
+
// image-less list.
|
|
2557
|
+
catalogIncomplete: settings.enabled === true && accounts.some((a) => a.enabled === true && a.catalogIncomplete === true),
|
|
2558
|
+
// The agent tool's rules for this session (shown while the tool is switched on).
|
|
2559
|
+
agent: {
|
|
2560
|
+
enabled: settings.agentTool === true,
|
|
2561
|
+
imageConfirm: policy.imageConfirm,
|
|
2562
|
+
videoConfirm: policy.videoConfirm,
|
|
2563
|
+
videoBudget: policy.budget,
|
|
2564
|
+
used: policy.used,
|
|
2565
|
+
sources: policy.sources,
|
|
2566
|
+
global: { imageConfirm: settings.imageConfirm, videoConfirm: settings.videoConfirm, videoBudget: settings.videoBudget },
|
|
2567
|
+
},
|
|
2568
|
+
recent: entry.recent
|
|
2569
|
+
.filter((r) => existsSync(r.file))
|
|
2570
|
+
.slice(0, 12)
|
|
2571
|
+
.map((r) => ({ id: r.id, kind: r.kind, name: basename(r.file), model: r.model, createdAt: r.createdAt })),
|
|
2572
|
+
};
|
|
2573
|
+
};
|
|
2574
|
+
|
|
2575
|
+
/** Pick a model for one session (and remember it as the last pick); `ref: null` clears the session's pick. */
|
|
2576
|
+
const saveSessionPick = (body) => {
|
|
2577
|
+
// Agent-tool overrides for one session (no model pick in the same request).
|
|
2578
|
+
if (body?.kind === undefined && ["imageConfirm", "videoConfirm", "videoBudget"].some((k) => k in (body ?? {}))) {
|
|
2579
|
+
const sessionId = validSessionId(body.sessionId);
|
|
2580
|
+
if (sessionId === undefined) throw new MediaError("invalid-session");
|
|
2581
|
+
const { settings } = mediaChoices();
|
|
2582
|
+
mediaSessions.update(sessionId, (entry) => applySessionPolicy(entry, body, settings));
|
|
2583
|
+
return { ok: true, session: sessionView(sessionId) };
|
|
2584
|
+
}
|
|
2585
|
+
const kind = body?.kind;
|
|
2586
|
+
if (kind !== "image" && kind !== "video") throw new MediaError("invalid-request");
|
|
2587
|
+
const sessionId = body.sessionId === undefined || body.sessionId === null ? undefined : validSessionId(body.sessionId);
|
|
2588
|
+
if (body.sessionId !== undefined && body.sessionId !== null && sessionId === undefined) throw new MediaError("invalid-session");
|
|
2589
|
+
const ref = body.ref === null ? null : typeof body.ref === "string" ? body.ref : undefined;
|
|
2590
|
+
if (ref === undefined) throw new MediaError("invalid-model");
|
|
2591
|
+
const { settings, options } = mediaChoices();
|
|
2592
|
+
if (ref !== null && !options[kind].some((o) => o.ref === ref)) throw new MediaError("invalid-model");
|
|
2593
|
+
if (sessionId !== undefined) {
|
|
2594
|
+
mediaSessions.update(sessionId, (entry) => {
|
|
2595
|
+
if (ref === null) delete entry[kind];
|
|
2596
|
+
else entry[kind] = ref;
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
if (ref !== null && settings.lastSelection[kind] !== ref) {
|
|
2600
|
+
saveMediaSettings(mediaSettingsPath, normalizeMediaSettings({ ...settings, lastSelection: { ...settings.lastSelection, [kind]: ref } }));
|
|
2601
|
+
}
|
|
2602
|
+
return { ok: true, session: sessionView(sessionId) };
|
|
2603
|
+
};
|
|
2604
|
+
|
|
2605
|
+
const MEDIA_FILE_TYPES = {
|
|
2606
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
|
|
2607
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* One `Range: bytes=a-b` request against a file of `size` bytes: `[start, end]`,
|
|
2612
|
+
* `undefined` without a (usable single) range, `null` when unsatisfiable.
|
|
2613
|
+
*/
|
|
2614
|
+
const byteRange = (header, size) => {
|
|
2615
|
+
if (typeof header !== "string" || header === "") return undefined;
|
|
2616
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
2617
|
+
if (match === null || (match[1] === "" && match[2] === "")) return undefined;
|
|
2618
|
+
let start;
|
|
2619
|
+
let end;
|
|
2620
|
+
if (match[1] === "") {
|
|
2621
|
+
const suffix = Number(match[2]);
|
|
2622
|
+
if (suffix === 0) return null;
|
|
2623
|
+
start = Math.max(0, size - suffix);
|
|
2624
|
+
end = size - 1;
|
|
2625
|
+
} else {
|
|
2626
|
+
start = Number(match[1]);
|
|
2627
|
+
end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
|
|
2628
|
+
}
|
|
2629
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) return null;
|
|
2630
|
+
return [start, end];
|
|
2631
|
+
};
|
|
2632
|
+
// Request bodies carry references base64-encoded and upstreams cap the body
|
|
2633
|
+
// (xAI: "Failed to read request body: length limit exceeded"). Video
|
|
2634
|
+
// requests use the measured `videoImageBudget()`; image edits keep this
|
|
2635
|
+
// unmeasured share.
|
|
2636
|
+
const FIRST_FRAME_MAX_BYTES = 1_000_000;
|
|
2637
|
+
const REFERENCES_MAX_BYTES = 1_400_000;
|
|
2638
|
+
|
|
2639
|
+
/**
|
|
2640
|
+
* One attached image within `maxBytes`: as stored when it fits; a PNG is
|
|
2641
|
+
* re-encoded here (JPEG on white, long edge ≤ 1280); another format asks
|
|
2642
|
+
* DSH's attachment store for a smaller request version.
|
|
2643
|
+
* @throws MediaError `reference-too-large`.
|
|
2644
|
+
*/
|
|
2645
|
+
const readReference = async (store, block, maxBytes, signal) => {
|
|
2646
|
+
const stored = await store.readImage(block.attachment, signal);
|
|
2647
|
+
const image = { mediaType: stored.ref?.mediaType ?? block.attachment?.mediaType, data: stored.data };
|
|
2648
|
+
const fitted = fitReferenceImage(image, maxBytes);
|
|
2649
|
+
if (fitted !== undefined) return { mediaType: fitted.mediaType, data: fitted.data };
|
|
2650
|
+
const width = Number(stored.ref?.width ?? block.attachment?.width);
|
|
2651
|
+
const height = Number(stored.ref?.height ?? block.attachment?.height);
|
|
2652
|
+
if (typeof store.readImageRequest === "function" && width > 0 && height > 0) {
|
|
2653
|
+
const scale = Math.min(1, REFERENCE_MAX_EDGE / Math.max(width, height));
|
|
2654
|
+
try {
|
|
2655
|
+
const smaller = await store.readImageRequest(
|
|
2656
|
+
block.attachment,
|
|
2657
|
+
{ width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), maxBytes },
|
|
2658
|
+
signal
|
|
2659
|
+
);
|
|
2660
|
+
if (smaller?.data?.byteLength <= maxBytes) return { mediaType: smaller.mediaType, data: smaller.data };
|
|
2661
|
+
} catch (error) {
|
|
2662
|
+
if (signal?.aborted) throw error;
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
throw new MediaError("reference-too-large", String(Math.round(maxBytes / 1000)));
|
|
2666
|
+
};
|
|
2667
|
+
|
|
2668
|
+
// #region Parameter interpretation (the session's chat model)
|
|
2669
|
+
const INTERPRET_TIMEOUT_MS = 30_000;
|
|
2670
|
+
const INTERPRET_MAX_TOKENS = 2000;
|
|
2671
|
+
|
|
2672
|
+
/** The provider / model the session's agent last used (else its options, else the default model). */
|
|
2673
|
+
const sessionChatTarget = (agent) => {
|
|
2674
|
+
const valid = (t) => (t && typeof t.provider === "string" && t.provider !== "" && typeof t.model === "string" && t.model !== "" ? { provider: t.provider, model: t.model } : undefined);
|
|
2675
|
+
try {
|
|
2676
|
+
const header = valid(agent?.session?.requestHeader?.()?.config);
|
|
2677
|
+
if (header !== undefined) return header;
|
|
2678
|
+
} catch {
|
|
2679
|
+
// fall through
|
|
2680
|
+
}
|
|
2681
|
+
return valid(agent?.options) ?? valid(ctx.get?.("agentDefaultModel")?.currentSelection?.());
|
|
2682
|
+
};
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* Ask the session's chat model which parameters the prompt states.
|
|
2686
|
+
* Never throws: without an `llm` service or a model it returns undefined;
|
|
2687
|
+
* a failed call returns `{ failed: true, by }`.
|
|
2688
|
+
* @param fixed - fields the rule parser already set (they win).
|
|
2689
|
+
* @returns `{ by, values, prompt, items, failed? }` or undefined.
|
|
2690
|
+
*/
|
|
2691
|
+
const interpretParams = async ({ agent, command, text, images = 0, fixed = [], signal }) => {
|
|
2692
|
+
const llm = ctx.get?.("llm");
|
|
2693
|
+
const target = sessionChatTarget(agent);
|
|
2694
|
+
if (typeof llm?.stream !== "function" || target === undefined) return undefined;
|
|
2695
|
+
const by = `${target.provider}/${target.model}`;
|
|
2696
|
+
const request = interpretationRequest({ command, text, images, fixed });
|
|
2697
|
+
const timeout = AbortSignal.timeout(INTERPRET_TIMEOUT_MS);
|
|
2698
|
+
const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
2699
|
+
try {
|
|
2700
|
+
const parts = new Map();
|
|
2701
|
+
for await (const chunk of llm.stream({
|
|
2702
|
+
provider: target.provider,
|
|
2703
|
+
model: target.model,
|
|
2704
|
+
system: request.system,
|
|
2705
|
+
messages: [{ role: "user", content: [{ type: "text", text: request.user }] }],
|
|
2706
|
+
maxTokens: INTERPRET_MAX_TOKENS,
|
|
2707
|
+
...(agent?.session?.id === undefined ? {} : { sessionId: agent.session.id }),
|
|
2708
|
+
purpose: "cpa-media-params",
|
|
2709
|
+
signal: combined,
|
|
2710
|
+
})) {
|
|
2711
|
+
if (chunk?.type === "text-delta") parts.set(chunk.index, (parts.get(chunk.index) ?? "") + chunk.text);
|
|
2712
|
+
else if (chunk?.type === "block-end" && chunk.block?.type === "text") parts.set(chunk.index, chunk.block.text);
|
|
2713
|
+
}
|
|
2714
|
+
const answer = [...parts.values()].join("");
|
|
2715
|
+
const parsed = parseInterpretation(answer, { command, text, fields: request.fields, images });
|
|
2716
|
+
if (parsed === undefined) throw new Error("no JSON in the answer");
|
|
2717
|
+
return { by, ...parsed, items: interpretationLabels(parsed.values) };
|
|
2718
|
+
} catch (error) {
|
|
2719
|
+
if (signal?.aborted) throw signal.reason;
|
|
2720
|
+
ctx.logger?.warn?.(`cpa: parameter interpretation with ${by} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2721
|
+
return { by, failed: true, values: {}, prompt: text, items: [] };
|
|
2722
|
+
}
|
|
2723
|
+
};
|
|
2724
|
+
|
|
2725
|
+
/** The run-record field describing an interpretation. */
|
|
2726
|
+
const interpretedRecord = (result, original) =>
|
|
2727
|
+
result === undefined
|
|
2728
|
+
? {}
|
|
2729
|
+
: { interpreted: { by: result.by, items: result.items, ...(result.failed ? { failed: true } : {}), ...(result.prompt !== original ? { prompt: true } : {}) } };
|
|
2730
|
+
// #endregion
|
|
2731
|
+
|
|
2732
|
+
/** Plain-English host text for a media failure (slash command output and run records). */
|
|
2733
|
+
const mediaFailureText = (error, kind = "image") => {
|
|
2734
|
+
if (!(error instanceof MediaError)) return error instanceof Error ? error.message : String(error);
|
|
2735
|
+
const detail = error.detail;
|
|
2736
|
+
const video = kind === "video";
|
|
2737
|
+
const noun = video ? "video" : "image";
|
|
2738
|
+
switch (error.code) {
|
|
2739
|
+
case "missing-prompt": return video ? "add a prompt, for example: /cpa-video waves rolling onto a beach at sunset" : "add a prompt, for example: /cpa-image a red fox in the snow";
|
|
2740
|
+
case "invalid-option": return video
|
|
2741
|
+
? `unknown option ${detail}; use -m <model>, --aspect <ratio>, -s <1-15>, -r <480p|720p>`
|
|
2742
|
+
: `unknown option ${detail}; use -m <model>, --aspect <ratio>, -n <1-4>`;
|
|
2743
|
+
case "invalid-aspect": return `--aspect must be one of ${(video ? VIDEO_ASPECTS : IMAGE_ASPECTS).join(", ")}`;
|
|
2744
|
+
case "invalid-count": return "-n must be 1 to 4";
|
|
2745
|
+
case "invalid-video-seconds": return "-s / --seconds must be a whole number from 1 to 15";
|
|
2746
|
+
case "invalid-video-resolution": return "-r / --resolution must be 480p or 720p";
|
|
2747
|
+
case "no-media-model": return `no ${noun} model is available (${detail || "no account"}); check Settings → CLIProxyAPI → Generation, or run /cpa-refresh if a catalog has not been read yet`;
|
|
2748
|
+
case "choose-model": return `several ${noun} models are available (${detail}); pick one with the button next to the composer or pass -m <model>`;
|
|
2749
|
+
case "model-not-found": return `no enabled ${noun} model matches; available: ${detail || "none"}`;
|
|
2750
|
+
case "model-ambiguous": return `several ${noun} models match (${detail}); be more specific`;
|
|
2751
|
+
case "workspace-not-local": return `the session's working directory is not a local folder, so the ${noun} cannot be saved`;
|
|
2752
|
+
case "unsupported-attachment": return detail === "edit"
|
|
2753
|
+
? "attach one MP4 video as the source (images cannot be used here)"
|
|
2754
|
+
: video ? "only image attachments can be used for /cpa-video (first frame, references, first / last frames)" : "only image attachments can be used as references";
|
|
2755
|
+
case "too-many-references": return video ? `attach at most ${detail ?? "14"} images for this mode (more than 2 images are references)` : `attach at most ${MAX_REFERENCE_IMAGES} reference images`;
|
|
2756
|
+
case "frames-need-images": return "--ref / --last / --first-last need attached images: one for a reference or a last frame, two for first and last frame";
|
|
2757
|
+
case "needs-video-15": return "pinned first / last frames need grok-imagine-video-1.5; pick it with -m or the model button";
|
|
2758
|
+
case "invalid-extend-seconds": return "an extension adds 2 to 10 seconds (-s 2-10)";
|
|
2759
|
+
case "no-video-source": return "no source video: attach an MP4, or pass --video <path>, --url <public URL> or --from <command id>";
|
|
2760
|
+
case "video-source-expired": return "that video's file is gone and its short-lived xAI link has expired; attach the MP4 or pass --video <path>";
|
|
2761
|
+
case "one-video-source": return "use one source video: an attachment, --video, --url or --from";
|
|
2762
|
+
case "invalid-video-url": return "--url must be a public http(s) address without a user name or password";
|
|
2763
|
+
case "video-not-found": return `no video file at ${detail}`;
|
|
2764
|
+
case "video-not-mp4": return "the source video must be an MP4 file (H.264, H.265 or AV1)";
|
|
2765
|
+
case "source-too-large": return `the source video is ${detail} MiB; videos up to ${Math.round(VIDEO_UPLOAD_MAX_BYTES / 1024 / 1024)} MiB can be sent inline — shorten or compress it, or pass a public link with --url`;
|
|
2766
|
+
case "request-too-large": return video
|
|
2767
|
+
? `the request is too large for this server (${detail ? `${Math.round(Number(detail) / 1024)} KB` : "body limit"}); attach smaller or fewer images`
|
|
2768
|
+
: "the request is too large for this server";
|
|
2769
|
+
case "route-unsupported": return `this server's upstream does not offer ${detail === "extensions" ? "video extension" : detail === "edits" ? "video editing" : detail} (HTTP 405); CPA may pause the credential for this model for about a minute`;
|
|
2770
|
+
case "edit-source-too-long": return `xAI edits videos of at most ${EDIT_SOURCE_MAX_SECONDS} seconds (this one has ${detail} s); extend it instead`;
|
|
2771
|
+
case "extend-source-length": return `xAI extends videos of ${EXTEND_SOURCE_SECONDS.min} to ${EXTEND_SOURCE_SECONDS.max} seconds (this one has ${detail} s)`;
|
|
2772
|
+
case "reference-too-large": return `an attached image could not be made smaller than ${detail ?? "1000"} KB; attach a smaller image`;
|
|
2773
|
+
case "upstream-error": return `CPA rejected the request (${detail})`;
|
|
2774
|
+
case "timeout": return video ? "CPA did not answer in time" : "CPA did not answer within 3 minutes";
|
|
2775
|
+
case "network": return "could not reach CPA";
|
|
2776
|
+
case "no-image": return "CPA returned no image";
|
|
2777
|
+
case "invalid-image":
|
|
2778
|
+
case "invalid-response": return video ? "CPA returned an answer this plugin does not understand" : "CPA returned data that is not a PNG, JPEG, WebP or GIF image";
|
|
2779
|
+
case "image-too-large": return "an image is larger than 32 MiB";
|
|
2780
|
+
case "video-too-large": return `the video is larger than ${Math.round(MAX_VIDEO_BYTES / 1024 / 1024)} MiB`;
|
|
2781
|
+
case "invalid-video": return "CPA returned data that is not an MP4, MOV or WebM video";
|
|
2782
|
+
case "video-failed": return `the video could not be generated${detail ? ` (${detail})` : ""}`;
|
|
2783
|
+
case "expired": return "CPA no longer knows this video: more than 3 hours have passed since it was submitted";
|
|
2784
|
+
case "download-failed": return `could not download the generated ${noun}`;
|
|
2785
|
+
case "write-failed": return `could not write the ${noun} file${detail ? ` (${detail})` : ""}`;
|
|
2786
|
+
case "no-endpoint": return "this account has no usable server address yet";
|
|
2787
|
+
case "invalid-account": return "the model's account no longer exists";
|
|
2788
|
+
case "interrupted": return "stopped because the plugin was reloaded or DSH closed";
|
|
2789
|
+
default: return error.code;
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
// #region Background generation jobs and the session's "running" state
|
|
2793
|
+
//
|
|
2794
|
+
// `/cpa-image` returns as soon as the request is admitted, so the composer
|
|
2795
|
+
// clears the draft at once; the generation continues here and its chat row
|
|
2796
|
+
// follows it through `media/command`. DSH counts only agent turns as
|
|
2797
|
+
// running, so while a job runs this plugin publishes the host's own
|
|
2798
|
+
// `api-session/status` event for that session (the workspace list shows it
|
|
2799
|
+
// as running, then completed). It is republished every few seconds and
|
|
2800
|
+
// after the agent itself goes idle, because those reset it to idle.
|
|
2801
|
+
|
|
2802
|
+
const mediaJobs = new Map(); // job key → { sessionId, abort }
|
|
2803
|
+
// job key → the promise of a job of this activation, until it settles (the agent tool waits on it).
|
|
2804
|
+
const jobPromises = new Map();
|
|
2805
|
+
const settleWith = (key, promise) => {
|
|
2806
|
+
jobPromises.set(key, promise);
|
|
2807
|
+
void promise.finally(() => {
|
|
2808
|
+
if (jobPromises.get(key) === promise) jobPromises.delete(key);
|
|
2809
|
+
});
|
|
2810
|
+
return promise;
|
|
2811
|
+
};
|
|
2812
|
+
let mediaJobSeq = 0;
|
|
2813
|
+
let mediaHeartbeat;
|
|
2814
|
+
const MEDIA_HEARTBEAT_MS = 5000;
|
|
2815
|
+
const jobSessions = () => new Set([...mediaJobs.values()].map((job) => job.sessionId).filter(Boolean));
|
|
2816
|
+
const agentRunning = (sessionId) => {
|
|
2817
|
+
try {
|
|
2818
|
+
return ctx.get?.("agents")?.get?.(sessionId)?.status === "running";
|
|
2819
|
+
} catch {
|
|
2820
|
+
return false;
|
|
2821
|
+
}
|
|
2822
|
+
};
|
|
2823
|
+
const publishSessionStatus = (sessionId, running) => {
|
|
2824
|
+
if (typeof ctx.emit !== "function") return;
|
|
2825
|
+
try {
|
|
2826
|
+
ctx.emit("api-session/status", sessionId, running);
|
|
2827
|
+
} catch (error) {
|
|
2828
|
+
ctx.logger?.warn?.(`cpa: could not publish the session status: ${error instanceof Error ? error.message : String(error)}`);
|
|
2829
|
+
}
|
|
2830
|
+
};
|
|
2831
|
+
const settleSessionStatus = (sessionId) => {
|
|
2832
|
+
if (sessionId === undefined || jobSessions().has(sessionId) || agentRunning(sessionId)) return;
|
|
2833
|
+
publishSessionStatus(sessionId, false);
|
|
2834
|
+
};
|
|
2835
|
+
const syncHeartbeat = () => {
|
|
2836
|
+
if (mediaJobs.size > 0 && mediaHeartbeat === undefined) {
|
|
2837
|
+
mediaHeartbeat = setInterval(() => {
|
|
2838
|
+
for (const sessionId of jobSessions()) publishSessionStatus(sessionId, true);
|
|
2839
|
+
}, MEDIA_HEARTBEAT_MS);
|
|
2840
|
+
mediaHeartbeat.unref?.();
|
|
2841
|
+
} else if (mediaJobs.size === 0 && mediaHeartbeat !== undefined) {
|
|
2842
|
+
clearInterval(mediaHeartbeat);
|
|
2843
|
+
mediaHeartbeat = undefined;
|
|
2844
|
+
}
|
|
2845
|
+
};
|
|
2846
|
+
ctx.on?.("agent/status", (event) => {
|
|
2847
|
+
const sessionId = event?.agent?.id;
|
|
2848
|
+
if (event?.status === "running" || !jobSessions().has(sessionId)) return;
|
|
2849
|
+
// After the host's own idle notice for this transition.
|
|
2850
|
+
setTimeout(() => {
|
|
2851
|
+
if (jobSessions().has(sessionId)) publishSessionStatus(sessionId, true);
|
|
2852
|
+
}, 0);
|
|
2853
|
+
});
|
|
2854
|
+
ctx.effect(
|
|
2855
|
+
() => () => {
|
|
2856
|
+
const sessions = jobSessions();
|
|
2857
|
+
for (const job of mediaJobs.values()) job.abort.abort(new MediaError("interrupted"));
|
|
2858
|
+
mediaJobs.clear();
|
|
2859
|
+
syncHeartbeat();
|
|
2860
|
+
for (const sessionId of sessions) settleSessionStatus(sessionId);
|
|
2861
|
+
},
|
|
2862
|
+
"cpa: generation jobs"
|
|
2863
|
+
);
|
|
2864
|
+
|
|
2865
|
+
/** Update one run record (and prepend new recent outputs) in the session store; never throws. */
|
|
2866
|
+
const recordRun = (sessionId, commandId, patch, recent = []) => {
|
|
2867
|
+
if (sessionId === undefined) return;
|
|
2868
|
+
try {
|
|
2869
|
+
mediaSessions.update(sessionId, (entry) => {
|
|
2870
|
+
if (recent.length > 0) entry.recent = [...recent, ...entry.recent];
|
|
2871
|
+
if (commandId === undefined) return;
|
|
2872
|
+
const index = entry.runs.findIndex((run) => run.commandId === commandId);
|
|
2873
|
+
if (index >= 0) entry.runs[index] = { ...entry.runs[index], ...patch };
|
|
2874
|
+
else entry.runs = [{ commandId, ...patch }, ...entry.runs];
|
|
2875
|
+
});
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
ctx.logger?.warn?.(`cpa: could not record a generation result: ${error instanceof Error ? error.message : String(error)}`);
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
|
|
2881
|
+
/** How the chat row names the save folder: relative to the working directory when inside it. */
|
|
2882
|
+
const folderLabel = (cwd, dir) => {
|
|
2883
|
+
const rel = relative(cwd, dir);
|
|
2884
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel) ? rel.split(sep).join("/") : basename(dir);
|
|
2885
|
+
};
|
|
2886
|
+
|
|
2887
|
+
/** Run one admitted image request in the background; resolves when it has settled and been recorded. */
|
|
2888
|
+
const startImageJob = ({ sessionId, commandId, option, args: admitted, references, cwd, outputDir, endpoint, interpret }) => {
|
|
2889
|
+
const key = commandId ?? `job-${++mediaJobSeq}`;
|
|
2890
|
+
const abort = new AbortController();
|
|
2891
|
+
mediaJobs.set(key, { sessionId, abort });
|
|
2892
|
+
syncHeartbeat();
|
|
2893
|
+
if (sessionId !== undefined) publishSessionStatus(sessionId, true);
|
|
2894
|
+
const started = Date.now();
|
|
2895
|
+
return settleWith(key, (async () => {
|
|
2896
|
+
try {
|
|
2897
|
+
let args = admitted;
|
|
2898
|
+
if (interpret !== undefined) {
|
|
2899
|
+
if (commandId !== undefined) videoLive.set(commandId, { phase: "interpreting" });
|
|
2900
|
+
const result = await interpret(abort.signal);
|
|
2901
|
+
if (commandId !== undefined) videoLive.delete(commandId);
|
|
2902
|
+
if (result !== undefined) {
|
|
2903
|
+
args = { ...admitted, ...result.values, prompt: result.prompt };
|
|
2904
|
+
recordRun(sessionId, commandId, {
|
|
2905
|
+
prompt: args.prompt,
|
|
2906
|
+
...(args.aspect === undefined ? {} : { aspect: args.aspect }),
|
|
2907
|
+
count: args.count ?? 1,
|
|
2908
|
+
...interpretedRecord(result, admitted.prompt),
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
const images = await generateImages({
|
|
2913
|
+
rootURL: endpoint.rootURL,
|
|
2914
|
+
apiKey: endpoint.apiKey,
|
|
2915
|
+
model: option.modelId,
|
|
2916
|
+
prompt: args.prompt,
|
|
2917
|
+
...(args.aspect === undefined ? {} : { aspect: args.aspect }),
|
|
2918
|
+
count: args.count ?? 1,
|
|
2919
|
+
references,
|
|
2920
|
+
signal: abort.signal,
|
|
2921
|
+
});
|
|
2922
|
+
const dir = outputDirectory(cwd, outputDir);
|
|
2923
|
+
const paths = writeOutputFiles(dir, outputStem(option.modelId, args.prompt), images);
|
|
2924
|
+
const revised = images.find((image) => image.revisedPrompt !== undefined)?.revisedPrompt;
|
|
2925
|
+
recordRun(
|
|
2926
|
+
sessionId,
|
|
2927
|
+
commandId,
|
|
2928
|
+
{
|
|
2929
|
+
status: "done",
|
|
2930
|
+
files: paths.length,
|
|
2931
|
+
folder: folderLabel(cwd, dir),
|
|
2932
|
+
finishedAt: new Date().toISOString(),
|
|
2933
|
+
...(revised === undefined ? {} : { revisedPrompt: revised }),
|
|
2934
|
+
},
|
|
2935
|
+
paths.map((p) => recentEntry("image", p, option.ref, new Date(), commandId))
|
|
2936
|
+
);
|
|
2937
|
+
ctx.logger?.info?.(`cpa: /cpa-image wrote ${paths.length} image(s) with ${option.modelId} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
2938
|
+
} catch (thrown) {
|
|
2939
|
+
const error = abort.signal.aborted ? new MediaError("interrupted") : thrown;
|
|
2940
|
+
recordRun(sessionId, commandId, {
|
|
2941
|
+
status: "error",
|
|
2942
|
+
errorCode: error instanceof MediaError ? error.code : "failed",
|
|
2943
|
+
error: mediaFailureText(error),
|
|
2944
|
+
finishedAt: new Date().toISOString(),
|
|
2945
|
+
});
|
|
2946
|
+
ctx.logger?.warn?.(`cpa: /cpa-image failed: ${mediaFailureText(error)}`);
|
|
2947
|
+
} finally {
|
|
2948
|
+
if (commandId !== undefined) videoLive.delete(commandId);
|
|
2949
|
+
mediaJobs.delete(key);
|
|
2950
|
+
syncHeartbeat();
|
|
2951
|
+
settleSessionStatus(sessionId);
|
|
2952
|
+
}
|
|
2953
|
+
})());
|
|
2954
|
+
};
|
|
2955
|
+
|
|
2956
|
+
// #endregion
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* `/cpa-image`: generate or (with attached images) edit, save into the
|
|
2960
|
+
* session's folder. Checks everything it can up front (options, model,
|
|
2961
|
+
* folder, references, endpoint), then returns while the generation runs as
|
|
2962
|
+
* a background job; the chat row shows its progress and result.
|
|
2963
|
+
*/
|
|
2964
|
+
// Internal callers (the agent tool) add: `literal` (no plain-language
|
|
2965
|
+
// parsing or interpretation), `pickSession` (whose model pick applies) and
|
|
2966
|
+
// `gate(plan)` (confirmation / budget, run after every check and before
|
|
2967
|
+
// anything is recorded or sent; a thrown `gateError` passes through).
|
|
2968
|
+
const runImageCommand = async ({ agent, rawInput, attachments = [], signal, commandId, literal = false, pickSession, gate }) => {
|
|
2969
|
+
try {
|
|
2970
|
+
const args = parseImageCommand(rawInput, { literal });
|
|
2971
|
+
const blocks = Array.isArray(attachments) ? attachments : [];
|
|
2972
|
+
if (blocks.some((block) => block?.type !== "image")) throw new MediaError("unsupported-attachment");
|
|
2973
|
+
if (blocks.length > MAX_REFERENCE_IMAGES) throw new MediaError("too-many-references");
|
|
2974
|
+
const { settings, options, accounts } = await mediaChoicesFor("image");
|
|
2975
|
+
const sessionId = validSessionId(agent?.id);
|
|
2976
|
+
const pickId = validSessionId(pickSession) ?? sessionId;
|
|
2977
|
+
const entry = pickId === undefined ? {} : mediaSessions.get(pickId);
|
|
2978
|
+
let option;
|
|
2979
|
+
try {
|
|
2980
|
+
({ option } = chooseMediaModel({
|
|
2981
|
+
options: options.image,
|
|
2982
|
+
requested: args.model,
|
|
2983
|
+
sessionRef: entry.image,
|
|
2984
|
+
lastRef: settings.lastSelection.image,
|
|
2985
|
+
}));
|
|
2986
|
+
} catch (error) {
|
|
2987
|
+
if (error instanceof MediaError && error.code === "no-media-model") {
|
|
2988
|
+
throw new MediaError("no-media-model", mediaAccountsSummary(accounts, "image"));
|
|
2989
|
+
}
|
|
2990
|
+
throw error;
|
|
2991
|
+
}
|
|
2992
|
+
const workingDirectory = ctx.get?.("workingDirectory");
|
|
2993
|
+
const cwd = agent !== undefined && workingDirectory !== undefined ? await workingDirectory.ensure(agent, signal) : undefined;
|
|
2994
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd) || !existsSync(cwd)) throw new MediaError("workspace-not-local");
|
|
2995
|
+
const references = [];
|
|
2996
|
+
if (blocks.length > 0) {
|
|
2997
|
+
const store = ctx.get?.("attachments");
|
|
2998
|
+
if (store === undefined) throw new MediaError("unsupported-attachment");
|
|
2999
|
+
const each = Math.floor(REFERENCES_MAX_BYTES / blocks.length);
|
|
3000
|
+
for (const block of blocks) references.push(await readReference(store, block, each, signal));
|
|
3001
|
+
}
|
|
3002
|
+
const endpoint = await controller.mediaEndpoint(option.accountId);
|
|
3003
|
+
const id = validCommandId(commandId);
|
|
3004
|
+
const count = args.count ?? 1;
|
|
3005
|
+
if (gate !== undefined) {
|
|
3006
|
+
await gate({ kind: "image", mode: references.length > 0 ? "image-edit" : "image-text", option, prompt: args.prompt, count, references: references.length, aspect: args.aspect, cwd, outputDir: settings.outputDir });
|
|
3007
|
+
}
|
|
3008
|
+
recordRun(sessionId, id, {
|
|
3009
|
+
kind: "image",
|
|
3010
|
+
status: "running",
|
|
3011
|
+
model: option.ref,
|
|
3012
|
+
modelName: option.name || option.modelId,
|
|
3013
|
+
accountName: option.accountName,
|
|
3014
|
+
prompt: args.prompt,
|
|
3015
|
+
...(args.aspect === undefined ? {} : { aspect: args.aspect }),
|
|
3016
|
+
count,
|
|
3017
|
+
references: references.length,
|
|
3018
|
+
files: 0,
|
|
3019
|
+
startedAt: new Date().toISOString(),
|
|
3020
|
+
});
|
|
3021
|
+
void startImageJob({
|
|
3022
|
+
sessionId,
|
|
3023
|
+
commandId: id,
|
|
3024
|
+
option,
|
|
3025
|
+
args,
|
|
3026
|
+
references,
|
|
3027
|
+
cwd,
|
|
3028
|
+
outputDir: settings.outputDir,
|
|
3029
|
+
endpoint,
|
|
3030
|
+
...(literal
|
|
3031
|
+
? {}
|
|
3032
|
+
: {
|
|
3033
|
+
interpret: (jobSignal) =>
|
|
3034
|
+
interpretParams({ agent, command: "image", text: args.prompt, images: references.length, fixed: ["aspect", "count"].filter((k) => args[k] !== undefined), signal: jobSignal }),
|
|
3035
|
+
}),
|
|
3036
|
+
});
|
|
3037
|
+
const dir = outputDirectory(cwd, settings.outputDir);
|
|
3038
|
+
return {
|
|
3039
|
+
kind: "success",
|
|
3040
|
+
text: `${references.length > 0 ? "Editing" : "Generating"} ${count} image(s) with ${option.modelId} (${option.accountName}); files go to ${dir}`,
|
|
3041
|
+
};
|
|
3042
|
+
} catch (error) {
|
|
3043
|
+
if (signal?.aborted || error?.gateError === true) throw error;
|
|
3044
|
+
return { kind: "error", text: `/cpa-image: ${mediaFailureText(error)}` };
|
|
3045
|
+
}
|
|
3046
|
+
};
|
|
3047
|
+
|
|
3048
|
+
// #region Video jobs (/cpa-video)
|
|
3049
|
+
//
|
|
3050
|
+
// A video is submitted once and cannot be cancelled (CPA and xAI have no
|
|
3051
|
+
// cancel route). The job is stored in media-jobs.json as soon as CPA
|
|
3052
|
+
// returns its request id, polled (5 s, then 10 s, then 15 s) and downloaded
|
|
3053
|
+
// through CPA into the session's folder. A reload or restart only pauses
|
|
3054
|
+
// the polling: the next activation resumes every stored job younger than
|
|
3055
|
+
// CPA's 3-hour request binding, and marks older ones expired.
|
|
3056
|
+
|
|
3057
|
+
const mediaJobStore = createMediaJobStore(join(updateDirectory, "media-jobs.json"));
|
|
3058
|
+
// commandId → { phase: "submitting" | "queued" | "running" | "downloading", progress? } for the chat row.
|
|
3059
|
+
const videoLive = new Map();
|
|
3060
|
+
// commandId → { url, accountId, accountName, seconds, sessionId, commandId, at }: the short-lived
|
|
3061
|
+
// result URL of a finished video, for /cpa-video-edit and /cpa-video-extend.
|
|
3062
|
+
// In memory only (never stored, logged or sent to the page); a reload forgets it.
|
|
3063
|
+
const videoSources = new Map();
|
|
3064
|
+
// xAI calls its result URLs ephemeral without a number; offer them for an hour.
|
|
3065
|
+
const VIDEO_SOURCE_TTL_MS = 60 * 60 * 1000;
|
|
3066
|
+
const MAX_VIDEO_SOURCES = 100;
|
|
3067
|
+
const rememberSource = (job, status) => {
|
|
3068
|
+
if (job.commandId === undefined || status.sourceUrl === undefined) return;
|
|
3069
|
+
videoSources.delete(job.commandId);
|
|
3070
|
+
videoSources.set(job.commandId, {
|
|
3071
|
+
url: status.sourceUrl,
|
|
3072
|
+
accountId: job.accountId,
|
|
3073
|
+
seconds: status.seconds ?? job.seconds,
|
|
3074
|
+
sessionId: job.sessionId,
|
|
3075
|
+
commandId: job.commandId,
|
|
3076
|
+
at: Date.now(),
|
|
3077
|
+
});
|
|
3078
|
+
while (videoSources.size > MAX_VIDEO_SOURCES) videoSources.delete(videoSources.keys().next().value);
|
|
3079
|
+
};
|
|
3080
|
+
const usableSource = (commandId) => {
|
|
3081
|
+
const source = videoSources.get(commandId);
|
|
3082
|
+
return source !== undefined && Date.now() - source.at <= VIDEO_SOURCE_TTL_MS ? source : undefined;
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
const sleep = (ms, signal) =>
|
|
3086
|
+
new Promise((resolve, reject) => {
|
|
3087
|
+
if (signal.aborted) return reject(signal.reason);
|
|
3088
|
+
const timer = setTimeout(() => {
|
|
3089
|
+
signal.removeEventListener("abort", onAbort);
|
|
3090
|
+
resolve();
|
|
3091
|
+
}, ms);
|
|
3092
|
+
timer.unref?.();
|
|
3093
|
+
const onAbort = () => {
|
|
3094
|
+
clearTimeout(timer);
|
|
3095
|
+
reject(signal.reason);
|
|
3096
|
+
};
|
|
3097
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3098
|
+
});
|
|
3099
|
+
|
|
3100
|
+
const pollDelay = (elapsedMs) => (elapsedMs < 60_000 ? 5000 : elapsedMs < 180_000 ? 10_000 : 15_000);
|
|
3101
|
+
const MAX_POLL_FAILURES = 12;
|
|
3102
|
+
|
|
3103
|
+
/** Poll, download and record one submitted video; a dispose abort leaves it stored for the next activation. */
|
|
3104
|
+
const followVideoJob = async (job, abort) => {
|
|
3105
|
+
const live = (patch) => {
|
|
3106
|
+
if (job.commandId !== undefined) videoLive.set(job.commandId, { ...(videoLive.get(job.commandId) ?? {}), ...patch });
|
|
3107
|
+
};
|
|
3108
|
+
const finish = (state, runPatch, recent = []) => {
|
|
3109
|
+
mediaJobStore.put({ ...job, state, finishedAt: new Date().toISOString() });
|
|
3110
|
+
recordRun(job.sessionId, job.commandId, { ...runPatch, finishedAt: new Date().toISOString() }, recent);
|
|
3111
|
+
};
|
|
3112
|
+
const fail = (error) => {
|
|
3113
|
+
const code = error instanceof MediaError ? error.code : "failed";
|
|
3114
|
+
finish(code === "expired" ? "expired" : "failed", { status: "error", errorCode: code, error: mediaFailureText(error, "video") });
|
|
3115
|
+
ctx.logger?.warn?.(`cpa: /cpa-video ${job.id} failed: ${mediaFailureText(error, "video")}`);
|
|
3116
|
+
};
|
|
3117
|
+
const submitted = Date.parse(job.submittedAt);
|
|
3118
|
+
let failures = 0;
|
|
3119
|
+
live({ phase: "queued" });
|
|
3120
|
+
try {
|
|
3121
|
+
for (;;) {
|
|
3122
|
+
if (Date.now() - submitted > VIDEO_JOB_TTL_MS) throw new MediaError("expired");
|
|
3123
|
+
let endpoint;
|
|
3124
|
+
let status;
|
|
3125
|
+
try {
|
|
3126
|
+
endpoint = await controller.mediaEndpoint(job.accountId);
|
|
3127
|
+
status = await retrieveVideo({ rootURL: endpoint.rootURL, apiKey: endpoint.apiKey, requestId: job.id, signal: abort.signal });
|
|
3128
|
+
failures = 0;
|
|
3129
|
+
} catch (error) {
|
|
3130
|
+
if (abort.signal.aborted) throw error;
|
|
3131
|
+
// A missing account or a refusal that keeps coming back ends the job;
|
|
3132
|
+
// network trouble and short CPA hiccups are retried.
|
|
3133
|
+
if (error instanceof MediaError && error.code === "invalid-account") throw error;
|
|
3134
|
+
failures += 1;
|
|
3135
|
+
if (failures >= MAX_POLL_FAILURES) throw error;
|
|
3136
|
+
await sleep(pollDelay(Date.now() - submitted), abort.signal);
|
|
3137
|
+
continue;
|
|
3138
|
+
}
|
|
3139
|
+
if (status.state === "failed") throw new MediaError("video-failed", status.error);
|
|
3140
|
+
if (status.state === "done") {
|
|
3141
|
+
live({ phase: "downloading", progress: 100 });
|
|
3142
|
+
const dir = outputDirectory(job.cwd, job.outputDir);
|
|
3143
|
+
const path = await downloadVideo({
|
|
3144
|
+
rootURL: endpoint.rootURL,
|
|
3145
|
+
apiKey: endpoint.apiKey,
|
|
3146
|
+
requestId: job.id,
|
|
3147
|
+
dir,
|
|
3148
|
+
stem: outputStem(job.modelId, job.prompt),
|
|
3149
|
+
signal: abort.signal,
|
|
3150
|
+
});
|
|
3151
|
+
finish("done", { status: "done", files: 1, folder: folderLabel(job.cwd, dir) }, [recentEntry("video", path, job.ref, new Date(), job.commandId)]);
|
|
3152
|
+
rememberSource(job, status);
|
|
3153
|
+
ctx.logger?.info?.(`cpa: /cpa-video ${job.id} saved after ${Math.round((Date.now() - submitted) / 1000)} s`);
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
live({ phase: status.state, ...(status.progress === undefined ? {} : { progress: status.progress }) });
|
|
3157
|
+
await sleep(pollDelay(Date.now() - submitted), abort.signal);
|
|
3158
|
+
}
|
|
3159
|
+
} catch (error) {
|
|
3160
|
+
if (abort.signal.aborted) return; // paused by a reload; resumed by the next activation
|
|
3161
|
+
fail(error);
|
|
3162
|
+
}
|
|
3163
|
+
};
|
|
3164
|
+
|
|
3165
|
+
/** Track one job in the session's running state until it settles. */
|
|
3166
|
+
const trackVideoJob = (key, sessionId, run) => {
|
|
3167
|
+
const abort = new AbortController();
|
|
3168
|
+
mediaJobs.set(key, { sessionId, abort });
|
|
3169
|
+
syncHeartbeat();
|
|
3170
|
+
if (sessionId !== undefined) publishSessionStatus(sessionId, true);
|
|
3171
|
+
return settleWith(key, (async () => {
|
|
3172
|
+
try {
|
|
3173
|
+
await run(abort);
|
|
3174
|
+
} finally {
|
|
3175
|
+
if (mediaJobs.get(key)?.abort === abort) mediaJobs.delete(key);
|
|
3176
|
+
videoLive.delete(key);
|
|
3177
|
+
syncHeartbeat();
|
|
3178
|
+
settleSessionStatus(sessionId);
|
|
3179
|
+
}
|
|
3180
|
+
})());
|
|
3181
|
+
};
|
|
3182
|
+
|
|
3183
|
+
/**
|
|
3184
|
+
* Submit one admitted video through `submit(signal)` (generation, edit or
|
|
3185
|
+
* extension), store the job, then follow it.
|
|
3186
|
+
*/
|
|
3187
|
+
const startVideoJob = ({ sessionId, commandId, accountId, modelId, ref, prompt, seconds, cwd, outputDir, submit, label }) => {
|
|
3188
|
+
const key = commandId ?? `job-${++mediaJobSeq}`;
|
|
3189
|
+
if (commandId !== undefined) videoLive.set(commandId, { phase: "submitting" });
|
|
3190
|
+
return trackVideoJob(key, sessionId, async (abort) => {
|
|
3191
|
+
let requestId;
|
|
3192
|
+
try {
|
|
3193
|
+
// `submit` may settle the final prompt / length first (parameter interpretation).
|
|
3194
|
+
const submitted = await submit(abort.signal);
|
|
3195
|
+
requestId = submitted.requestId;
|
|
3196
|
+
if (typeof submitted.prompt === "string") prompt = submitted.prompt;
|
|
3197
|
+
if (Number.isInteger(submitted.seconds)) seconds = submitted.seconds;
|
|
3198
|
+
} catch (thrown) {
|
|
3199
|
+
// Cut off before CPA answered: whether it was submitted is unknown.
|
|
3200
|
+
const error = abort.signal.aborted ? new MediaError("interrupted") : thrown;
|
|
3201
|
+
recordRun(sessionId, commandId, {
|
|
3202
|
+
status: "error",
|
|
3203
|
+
errorCode: error instanceof MediaError ? error.code : "failed",
|
|
3204
|
+
error: mediaFailureText(error, "video"),
|
|
3205
|
+
finishedAt: new Date().toISOString(),
|
|
3206
|
+
});
|
|
3207
|
+
ctx.logger?.warn?.(`cpa: ${label} was not submitted: ${mediaFailureText(error, "video")}`);
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
const job = mediaJobStore.put({
|
|
3211
|
+
id: requestId,
|
|
3212
|
+
state: "submitted",
|
|
3213
|
+
accountId,
|
|
3214
|
+
modelId,
|
|
3215
|
+
ref,
|
|
3216
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
3217
|
+
...(commandId === undefined ? {} : { commandId }),
|
|
3218
|
+
cwd,
|
|
3219
|
+
outputDir,
|
|
3220
|
+
prompt,
|
|
3221
|
+
seconds,
|
|
3222
|
+
submittedAt: new Date().toISOString(),
|
|
3223
|
+
});
|
|
3224
|
+
recordRun(sessionId, commandId, { videoId: requestId });
|
|
3225
|
+
ctx.logger?.info?.(`cpa: ${label} submitted ${requestId} (${modelId}, ${seconds} s)`);
|
|
3226
|
+
await followVideoJob(job, abort);
|
|
3227
|
+
});
|
|
3228
|
+
};
|
|
3229
|
+
/** Resume every stored job that is still running (after a reload or restart). */
|
|
3230
|
+
const resumeVideoJobs = () => {
|
|
3231
|
+
let jobs;
|
|
3232
|
+
try {
|
|
3233
|
+
jobs = mediaJobStore.list().filter((job) => job.state === "submitted");
|
|
3234
|
+
} catch (error) {
|
|
3235
|
+
ctx.logger?.warn?.(`cpa: could not read media-jobs.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
3238
|
+
for (const job of jobs) {
|
|
3239
|
+
const key = job.commandId ?? `video-${job.id}`;
|
|
3240
|
+
if (mediaJobs.has(key)) continue;
|
|
3241
|
+
void trackVideoJob(key, job.sessionId, (abort) => followVideoJob(job, abort));
|
|
3242
|
+
}
|
|
3243
|
+
};
|
|
3244
|
+
|
|
3245
|
+
/** Grok's 1.5 video family takes more references and pinned first / last frames. */
|
|
3246
|
+
const isVideo15 = (modelId) => /(^|\/)grok-imagine-video-1\.5/i.test(String(modelId ?? ""));
|
|
3247
|
+
|
|
3248
|
+
/**
|
|
3249
|
+
* How attached images are used: none = text only; one = first frame
|
|
3250
|
+
* (`--ref`: a reference, `--last`: the last frame); two = references
|
|
3251
|
+
* (`--first-last` / `--last`: first and last frame); more = references.
|
|
3252
|
+
* @throws MediaError `frames-need-images` / `too-many-references` / `needs-video-15`.
|
|
3253
|
+
*/
|
|
3254
|
+
const videoMode = (count, frames, modelId) => {
|
|
3255
|
+
if (count === 0) {
|
|
3256
|
+
if (frames !== undefined) throw new MediaError("frames-need-images");
|
|
3257
|
+
return "text";
|
|
3258
|
+
}
|
|
3259
|
+
let mode;
|
|
3260
|
+
if (count === 1) mode = frames === "reference" ? "reference" : frames === "last" ? "last-frame" : frames === "first-last" ? undefined : "first-frame";
|
|
3261
|
+
else if (count === 2) mode = frames === "first-last" || frames === "last" ? "first-last" : "reference";
|
|
3262
|
+
else mode = frames === undefined || frames === "reference" ? "reference" : undefined;
|
|
3263
|
+
if (mode === undefined) throw new MediaError(count === 1 ? "frames-need-images" : "too-many-references", count === 1 ? "2" : "2");
|
|
3264
|
+
const maxRefs = isVideo15(modelId) ? 14 : 7;
|
|
3265
|
+
if (mode === "reference" && count > maxRefs) throw new MediaError("too-many-references", String(maxRefs));
|
|
3266
|
+
if ((mode === "first-last" || mode === "last-frame") && !isVideo15(modelId)) throw new MediaError("needs-video-15");
|
|
3267
|
+
return mode;
|
|
3268
|
+
};
|
|
3269
|
+
|
|
3270
|
+
/**
|
|
3271
|
+
* `/cpa-video`: submit a video — text only, from a first frame, from
|
|
3272
|
+
* reference images, or between pinned first / last frames — and return at
|
|
3273
|
+
* once; the chat row follows the background job.
|
|
3274
|
+
*/
|
|
3275
|
+
const runVideoCommand = async ({ agent, rawInput, attachments = [], signal, commandId, literal = false, pickSession, gate }) => {
|
|
3276
|
+
try {
|
|
3277
|
+
const parsed = parseVideoCommand(rawInput, { literal });
|
|
3278
|
+
const blocks = Array.isArray(attachments) ? attachments : [];
|
|
3279
|
+
if (blocks.some((block) => block?.type !== "image")) throw new MediaError("unsupported-attachment");
|
|
3280
|
+
if (blocks.length > 14) throw new MediaError("too-many-references", "14");
|
|
3281
|
+
const { settings, options, accounts } = await mediaChoicesFor("video");
|
|
3282
|
+
const sessionId = validSessionId(agent?.id);
|
|
3283
|
+
const pickId = validSessionId(pickSession) ?? sessionId;
|
|
3284
|
+
const entry = pickId === undefined ? {} : mediaSessions.get(pickId);
|
|
3285
|
+
let option;
|
|
3286
|
+
try {
|
|
3287
|
+
({ option } = chooseMediaModel({ options: options.video, requested: parsed.model, sessionRef: entry.video, lastRef: settings.lastSelection.video }));
|
|
3288
|
+
} catch (error) {
|
|
3289
|
+
if (error instanceof MediaError && error.code === "no-media-model") throw new MediaError("no-media-model", mediaAccountsSummary(accounts, "video"));
|
|
3290
|
+
throw error;
|
|
3291
|
+
}
|
|
3292
|
+
const mode = videoMode(blocks.length, parsed.frames, option.modelId);
|
|
3293
|
+
const workingDirectory = ctx.get?.("workingDirectory");
|
|
3294
|
+
const cwd = agent !== undefined && workingDirectory !== undefined ? await workingDirectory.ensure(agent, signal) : undefined;
|
|
3295
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd) || !existsSync(cwd)) throw new MediaError("workspace-not-local");
|
|
3296
|
+
const images = [];
|
|
3297
|
+
if (blocks.length > 0) {
|
|
3298
|
+
const store = ctx.get?.("attachments");
|
|
3299
|
+
if (store === undefined) throw new MediaError("unsupported-attachment");
|
|
3300
|
+
// The generation route reads about 1 MiB of body (measured), so all images share that.
|
|
3301
|
+
const each = Math.min(FIRST_FRAME_MAX_BYTES, videoImageBudget(parsed.prompt, blocks.length));
|
|
3302
|
+
for (const block of blocks) images.push(await readReference(store, block, each, signal));
|
|
3303
|
+
}
|
|
3304
|
+
const framesFor = (m) =>
|
|
3305
|
+
m === "first-frame" ? { image: images[0] }
|
|
3306
|
+
: m === "last-frame" ? { lastFrame: images[0] }
|
|
3307
|
+
: m === "first-last" ? { image: images[0], lastFrame: images[1] }
|
|
3308
|
+
: m === "reference" ? { references: images }
|
|
3309
|
+
: {};
|
|
3310
|
+
const endpoint = await controller.mediaEndpoint(option.accountId);
|
|
3311
|
+
const args = { ...parsed, seconds: parsed.seconds ?? settings.defaultVideoSeconds, resolution: parsed.resolution ?? settings.defaultVideoResolution };
|
|
3312
|
+
const id = validCommandId(commandId);
|
|
3313
|
+
if (gate !== undefined) {
|
|
3314
|
+
await gate({ kind: "video", mode, option, prompt: args.prompt, seconds: args.seconds, resolution: args.resolution, aspect: args.aspect, references: images.length, cwd, outputDir: settings.outputDir });
|
|
3315
|
+
}
|
|
3316
|
+
recordRun(sessionId, id, {
|
|
3317
|
+
kind: "video",
|
|
3318
|
+
status: "running",
|
|
3319
|
+
mode,
|
|
3320
|
+
model: option.ref,
|
|
3321
|
+
modelName: option.name || option.modelId,
|
|
3322
|
+
accountName: option.accountName,
|
|
3323
|
+
prompt: args.prompt,
|
|
3324
|
+
...(args.aspect === undefined ? {} : { aspect: args.aspect }),
|
|
3325
|
+
seconds: args.seconds,
|
|
3326
|
+
resolution: args.resolution,
|
|
3327
|
+
count: 1,
|
|
3328
|
+
references: images.length,
|
|
3329
|
+
files: 0,
|
|
3330
|
+
startedAt: new Date().toISOString(),
|
|
3331
|
+
});
|
|
3332
|
+
void startVideoJob({
|
|
3333
|
+
sessionId,
|
|
3334
|
+
commandId: id,
|
|
3335
|
+
accountId: option.accountId,
|
|
3336
|
+
modelId: option.modelId,
|
|
3337
|
+
ref: option.ref,
|
|
3338
|
+
prompt: args.prompt,
|
|
3339
|
+
seconds: args.seconds,
|
|
3340
|
+
cwd,
|
|
3341
|
+
outputDir: settings.outputDir,
|
|
3342
|
+
label: "/cpa-video",
|
|
3343
|
+
submit: async (jobSignal) => {
|
|
3344
|
+
// The session's chat model reads what the rules did not (explicit options and recognized words win).
|
|
3345
|
+
if (id !== undefined && !literal) videoLive.set(id, { phase: "interpreting" });
|
|
3346
|
+
const fixed = ["seconds", "resolution", "aspect", "frames"].filter((k) => parsed[k] !== undefined);
|
|
3347
|
+
const result = literal ? undefined : await interpretParams({ agent, command: "video", text: parsed.prompt, images: images.length, fixed, signal: jobSignal });
|
|
3348
|
+
let final = args;
|
|
3349
|
+
let finalMode = mode;
|
|
3350
|
+
if (result !== undefined) {
|
|
3351
|
+
final = { ...args, ...result.values, prompt: result.prompt };
|
|
3352
|
+
if (result.values.frames !== undefined) finalMode = videoMode(images.length, result.values.frames, option.modelId);
|
|
3353
|
+
recordRun(sessionId, id, {
|
|
3354
|
+
mode: finalMode,
|
|
3355
|
+
prompt: final.prompt,
|
|
3356
|
+
...(final.aspect === undefined ? {} : { aspect: final.aspect }),
|
|
3357
|
+
seconds: final.seconds,
|
|
3358
|
+
resolution: final.resolution,
|
|
3359
|
+
...interpretedRecord(result, parsed.prompt),
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
if (id !== undefined) videoLive.set(id, { phase: "submitting" });
|
|
3363
|
+
const submitted = await submitVideo({
|
|
3364
|
+
rootURL: endpoint.rootURL,
|
|
3365
|
+
apiKey: endpoint.apiKey,
|
|
3366
|
+
model: option.modelId,
|
|
3367
|
+
prompt: final.prompt,
|
|
3368
|
+
seconds: final.seconds,
|
|
3369
|
+
...(final.aspect === undefined ? {} : { aspect: final.aspect }),
|
|
3370
|
+
resolution: final.resolution,
|
|
3371
|
+
...framesFor(finalMode),
|
|
3372
|
+
signal: jobSignal,
|
|
3373
|
+
});
|
|
3374
|
+
return { ...submitted, prompt: final.prompt, seconds: final.seconds };
|
|
3375
|
+
},
|
|
3376
|
+
});
|
|
3377
|
+
return {
|
|
3378
|
+
kind: "success",
|
|
3379
|
+
text: `Submitting a ${args.seconds} s ${args.resolution} video (${mode}) to ${option.modelId} (${option.accountName}); a submitted video cannot be cancelled. Files go to ${outputDirectory(cwd, settings.outputDir)}`,
|
|
3380
|
+
};
|
|
3381
|
+
} catch (error) {
|
|
3382
|
+
if (signal?.aborted || error?.gateError === true) throw error;
|
|
3383
|
+
return { kind: "error", text: `/cpa-video: ${mediaFailureText(error, "video")}` };
|
|
3384
|
+
}
|
|
3385
|
+
};
|
|
3386
|
+
|
|
3387
|
+
// `${accountId}/${route}` the upstream answered 405 for in this activation:
|
|
3388
|
+
// asked again only after a reload (CPA pauses the credential after a 405).
|
|
3389
|
+
const unsupportedRoutes = new Set();
|
|
3390
|
+
const editRoute = (mode) => (mode === "extend" ? "extensions" : "edits");
|
|
3391
|
+
|
|
3392
|
+
/** The newest existing video file the session recorded (for one command, or any). */
|
|
3393
|
+
const sessionVideoFile = (sessionId, commandId) => {
|
|
3394
|
+
if (sessionId === undefined) return undefined;
|
|
3395
|
+
return mediaSessions.get(sessionId).recent.find((r) => r.kind === "video" && (commandId === undefined || r.commandId === commandId) && existsSync(r.file));
|
|
3396
|
+
};
|
|
3397
|
+
/** The account a recorded run used (`accountId/modelId`), if it still exists. */
|
|
3398
|
+
const runAccountId = (sessionId, commandId, accounts) => {
|
|
3399
|
+
if (sessionId === undefined || commandId === undefined) return undefined;
|
|
3400
|
+
const run = mediaSessions.get(sessionId).runs.find((r) => r.commandId === commandId);
|
|
3401
|
+
const id = typeof run?.model === "string" ? run.model.split("/", 1)[0] : undefined;
|
|
3402
|
+
return accounts.some((a) => a.id === id) ? id : undefined;
|
|
3403
|
+
};
|
|
3404
|
+
const checkVideoBytes = (data) => {
|
|
3405
|
+
if (!isMp4(data)) throw new MediaError("video-not-mp4");
|
|
3406
|
+
return mp4Duration(data);
|
|
3407
|
+
};
|
|
3408
|
+
const tooLarge = (bytes) => new MediaError("source-too-large", (bytes / 1024 / 1024).toFixed(1));
|
|
3409
|
+
/** A local MP4 read whole (at most {@link VIDEO_UPLOAD_MAX_BYTES}) for an inline upload. */
|
|
3410
|
+
const readLocalVideo = (path, name = basename(path)) => {
|
|
3411
|
+
let info;
|
|
3412
|
+
try {
|
|
3413
|
+
info = statSync(path);
|
|
3414
|
+
} catch {
|
|
3415
|
+
info = undefined;
|
|
3416
|
+
}
|
|
3417
|
+
if (info === undefined || !info.isFile()) throw new MediaError("video-not-found", name);
|
|
3418
|
+
if (info.size > VIDEO_UPLOAD_MAX_BYTES) throw tooLarge(info.size);
|
|
3419
|
+
const data = new Uint8Array(readFileSync(path));
|
|
3420
|
+
return { data, seconds: checkVideoBytes(data), name };
|
|
3421
|
+
};
|
|
3422
|
+
/** An MP4 attached to the command (a DSH file attachment `{ attachmentId, name, bytes }`). */
|
|
3423
|
+
const readAttachedVideo = async (store, ref, signal) => {
|
|
3424
|
+
const name = basename(String(ref?.name ?? "video.mp4"));
|
|
3425
|
+
if (Number(ref?.bytes) > VIDEO_UPLOAD_MAX_BYTES) throw tooLarge(Number(ref.bytes));
|
|
3426
|
+
let hostPath;
|
|
3427
|
+
try {
|
|
3428
|
+
hostPath = store.fileHostPath?.(ref);
|
|
3429
|
+
} catch {
|
|
3430
|
+
hostPath = undefined;
|
|
3431
|
+
}
|
|
3432
|
+
if (typeof hostPath === "string" && existsSync(hostPath)) return readLocalVideo(hostPath, name);
|
|
3433
|
+
if (typeof store.readFileStream !== "function") throw new MediaError("unsupported-attachment", "edit");
|
|
3434
|
+
const chunks = [];
|
|
3435
|
+
let size = 0;
|
|
3436
|
+
for await (const chunk of store.readFileStream(ref, signal)) {
|
|
3437
|
+
size += chunk.byteLength;
|
|
3438
|
+
if (size > VIDEO_UPLOAD_MAX_BYTES) throw tooLarge(size);
|
|
3439
|
+
chunks.push(chunk);
|
|
3440
|
+
}
|
|
3441
|
+
const data = new Uint8Array(Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))));
|
|
3442
|
+
return { data, seconds: checkVideoBytes(data), name };
|
|
3443
|
+
};
|
|
3444
|
+
|
|
3445
|
+
/**
|
|
3446
|
+
* `/cpa-video-edit` and `/cpa-video-extend`: change or continue any MP4.
|
|
3447
|
+
* Sources: an attached MP4, `--video <path>`, `--url <public URL>`,
|
|
3448
|
+
* `--from <command id>`, or by default the session's newest video. A video
|
|
3449
|
+
* this plugin made goes by xAI's own short-lived link while it is
|
|
3450
|
+
* remembered (about an hour, this activation), otherwise its file is sent
|
|
3451
|
+
* inline; local files go inline as a base64 data URI (CPA has no file
|
|
3452
|
+
* upload route; the edit routes read bodies of 20 MiB, measured), a URL is
|
|
3453
|
+
* downloaded by xAI itself.
|
|
3454
|
+
*/
|
|
3455
|
+
const runVideoEditCommand = (mode) => async ({ agent, rawInput, attachments = [], signal, commandId, literal = false, pickSession, gate }) => {
|
|
3456
|
+
const name = mode === "extend" ? "/cpa-video-extend" : "/cpa-video-edit";
|
|
3457
|
+
try {
|
|
3458
|
+
const parsed = parseVideoEditCommand(rawInput, mode, { literal });
|
|
3459
|
+
const blocks = Array.isArray(attachments) ? attachments : [];
|
|
3460
|
+
if (blocks.some((block) => block?.type !== "file")) throw new MediaError("unsupported-attachment", "edit");
|
|
3461
|
+
const named = [parsed.from, parsed.video, parsed.url].filter((v) => v !== undefined).length;
|
|
3462
|
+
if (blocks.length > 1 || (blocks.length === 1 && named > 0)) throw new MediaError("one-video-source");
|
|
3463
|
+
const sessionId = validSessionId(agent?.id);
|
|
3464
|
+
const workingDirectory = ctx.get?.("workingDirectory");
|
|
3465
|
+
const cwd = agent !== undefined && workingDirectory !== undefined ? await workingDirectory.ensure(agent, signal) : undefined;
|
|
3466
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd) || !existsSync(cwd)) throw new MediaError("workspace-not-local");
|
|
3467
|
+
const { settings, options, accounts } = await mediaChoicesFor("video");
|
|
3468
|
+
|
|
3469
|
+
// source: { url? | data?, seconds?, accountId?, commandId?, name? }
|
|
3470
|
+
let source;
|
|
3471
|
+
const linkOf = (id) => {
|
|
3472
|
+
const link = usableSource(id);
|
|
3473
|
+
return link !== undefined && link.sessionId === sessionId ? link : undefined;
|
|
3474
|
+
};
|
|
3475
|
+
const fromLink = (link) => ({ url: link.url, seconds: link.seconds, accountId: link.accountId, commandId: link.commandId });
|
|
3476
|
+
const fromFile = (record) => ({ ...readLocalVideo(record.file), commandId: record.commandId, accountId: runAccountId(sessionId, record.commandId, accounts) });
|
|
3477
|
+
if (blocks.length === 1) {
|
|
3478
|
+
const store = ctx.get?.("attachments");
|
|
3479
|
+
if (store === undefined) throw new MediaError("unsupported-attachment", "edit");
|
|
3480
|
+
source = await readAttachedVideo(store, blocks[0].attachment, signal);
|
|
3481
|
+
} else if (parsed.video !== undefined) {
|
|
3482
|
+
source = readLocalVideo(isAbsolute(parsed.video) ? parsed.video : join(cwd, parsed.video));
|
|
3483
|
+
} else if (parsed.url !== undefined) {
|
|
3484
|
+
source = { url: parsed.url, name: new URL(parsed.url).hostname };
|
|
3485
|
+
} else if (parsed.from !== undefined) {
|
|
3486
|
+
const link = linkOf(parsed.from);
|
|
3487
|
+
const record = link === undefined ? sessionVideoFile(sessionId, parsed.from) : undefined;
|
|
3488
|
+
if (link !== undefined) source = fromLink(link);
|
|
3489
|
+
else if (record !== undefined) source = fromFile(record);
|
|
3490
|
+
else throw new MediaError(videoSources.has(parsed.from) ? "video-source-expired" : "no-video-source");
|
|
3491
|
+
} else {
|
|
3492
|
+
const record = sessionVideoFile(sessionId);
|
|
3493
|
+
const link = record !== undefined ? linkOf(record.commandId) : [...videoSources.values()].reverse().map((s) => linkOf(s.commandId)).find(Boolean);
|
|
3494
|
+
if (link !== undefined) source = fromLink(link);
|
|
3495
|
+
else if (record !== undefined) source = fromFile(record);
|
|
3496
|
+
else throw new MediaError("no-video-source");
|
|
3497
|
+
}
|
|
3498
|
+
if (source.seconds !== undefined) {
|
|
3499
|
+
if (mode === "edit" && source.seconds > EDIT_SOURCE_MAX_SECONDS) throw new MediaError("edit-source-too-long", String(source.seconds));
|
|
3500
|
+
if (mode === "extend" && (source.seconds < EXTEND_SOURCE_SECONDS.min || source.seconds > EXTEND_SOURCE_SECONDS.max)) {
|
|
3501
|
+
throw new MediaError("extend-source-length", String(source.seconds));
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
// The account: the source video's own, unless -m names a model; else the session's video model.
|
|
3506
|
+
let accountId = parsed.model === undefined ? source.accountId : undefined;
|
|
3507
|
+
if (accountId === undefined) {
|
|
3508
|
+
const pickId = validSessionId(pickSession) ?? sessionId;
|
|
3509
|
+
const entry = pickId === undefined ? {} : mediaSessions.get(pickId);
|
|
3510
|
+
try {
|
|
3511
|
+
({ option: { accountId } } = chooseMediaModel({ options: options.video, requested: parsed.model, sessionRef: entry.video, lastRef: settings.lastSelection.video }));
|
|
3512
|
+
} catch (error) {
|
|
3513
|
+
if (error instanceof MediaError && error.code === "no-media-model") throw new MediaError("no-media-model", mediaAccountsSummary(accounts, "video"));
|
|
3514
|
+
throw error;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
const account = accounts.find((a) => a.id === accountId);
|
|
3518
|
+
if (account === undefined) throw new MediaError("invalid-account");
|
|
3519
|
+
const route = editRoute(mode);
|
|
3520
|
+
if (unsupportedRoutes.has(`${accountId}/${route}`)) throw new MediaError("route-unsupported", route);
|
|
3521
|
+
const endpoint = await controller.mediaEndpoint(accountId);
|
|
3522
|
+
const known = source.seconds === undefined ? undefined : Math.max(1, Math.min(15, Math.round(source.seconds)));
|
|
3523
|
+
const seconds = mode === "extend" ? parsed.seconds ?? 6 : known;
|
|
3524
|
+
const id = validCommandId(commandId);
|
|
3525
|
+
const accountName = account.displayName || account.id;
|
|
3526
|
+
if (gate !== undefined) {
|
|
3527
|
+
await gate({
|
|
3528
|
+
kind: "video",
|
|
3529
|
+
mode,
|
|
3530
|
+
option: { ref: `${accountId}/${VIDEO_EDIT_MODEL}`, modelId: VIDEO_EDIT_MODEL, accountId, accountName },
|
|
3531
|
+
prompt: parsed.prompt,
|
|
3532
|
+
// Billed as the new footage: an edit re-renders the source, an extension adds `seconds`.
|
|
3533
|
+
seconds: seconds ?? 6,
|
|
3534
|
+
sourceName: source.name ?? (source.commandId !== undefined ? source.commandId : undefined),
|
|
3535
|
+
references: 0,
|
|
3536
|
+
cwd,
|
|
3537
|
+
outputDir: settings.outputDir,
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
recordRun(sessionId, id, {
|
|
3541
|
+
kind: "video",
|
|
3542
|
+
status: "running",
|
|
3543
|
+
mode,
|
|
3544
|
+
...(source.commandId === undefined ? {} : { sourceCommandId: source.commandId }),
|
|
3545
|
+
...(source.name === undefined ? {} : { sourceName: source.name }),
|
|
3546
|
+
sourceKind: source.data !== undefined ? "upload" : source.commandId !== undefined ? "link" : "url",
|
|
3547
|
+
...(Array.isArray(parsed.ignored) ? { ignored: parsed.ignored } : {}),
|
|
3548
|
+
model: `${accountId}/${VIDEO_EDIT_MODEL}`,
|
|
3549
|
+
modelName: VIDEO_EDIT_MODEL,
|
|
3550
|
+
accountName,
|
|
3551
|
+
prompt: parsed.prompt,
|
|
3552
|
+
...(seconds === undefined ? {} : { seconds }),
|
|
3553
|
+
count: 1,
|
|
3554
|
+
references: 0,
|
|
3555
|
+
files: 0,
|
|
3556
|
+
startedAt: new Date().toISOString(),
|
|
3557
|
+
});
|
|
3558
|
+
const { url: sourceUrl, data: sourceData } = source;
|
|
3559
|
+
void startVideoJob({
|
|
3560
|
+
sessionId,
|
|
3561
|
+
commandId: id,
|
|
3562
|
+
accountId,
|
|
3563
|
+
modelId: VIDEO_EDIT_MODEL,
|
|
3564
|
+
ref: `${accountId}/${VIDEO_EDIT_MODEL}`,
|
|
3565
|
+
prompt: parsed.prompt,
|
|
3566
|
+
// An extension returns the source plus the new part.
|
|
3567
|
+
seconds: mode === "extend" ? Math.min(15, (known ?? 0) + seconds) : seconds ?? 6,
|
|
3568
|
+
cwd,
|
|
3569
|
+
outputDir: settings.outputDir,
|
|
3570
|
+
label: name,
|
|
3571
|
+
submit: async (jobSignal) => {
|
|
3572
|
+
// The session's chat model reads an extension length or other wording the rules missed.
|
|
3573
|
+
if (id !== undefined && !literal) videoLive.set(id, { phase: "interpreting" });
|
|
3574
|
+
const fixed = mode === "extend" && parsed.seconds !== undefined ? ["seconds"] : [];
|
|
3575
|
+
const result = literal ? undefined : await interpretParams({ agent, command: mode, text: parsed.prompt, fixed, signal: jobSignal });
|
|
3576
|
+
let prompt = parsed.prompt;
|
|
3577
|
+
let added = seconds;
|
|
3578
|
+
if (result !== undefined) {
|
|
3579
|
+
prompt = result.prompt;
|
|
3580
|
+
if (mode === "extend" && result.values.seconds !== undefined) added = result.values.seconds;
|
|
3581
|
+
recordRun(sessionId, id, { prompt, ...(added === undefined ? {} : { seconds: added }), ...interpretedRecord(result, parsed.prompt) });
|
|
3582
|
+
}
|
|
3583
|
+
if (id !== undefined) videoLive.set(id, { phase: "submitting" });
|
|
3584
|
+
try {
|
|
3585
|
+
const submitted = await submitVideoEdit({
|
|
3586
|
+
rootURL: endpoint.rootURL,
|
|
3587
|
+
apiKey: endpoint.apiKey,
|
|
3588
|
+
mode,
|
|
3589
|
+
...(sourceData !== undefined ? { sourceData } : { sourceUrl }),
|
|
3590
|
+
prompt,
|
|
3591
|
+
...(mode === "extend" ? { seconds: added } : {}),
|
|
3592
|
+
signal: jobSignal,
|
|
3593
|
+
});
|
|
3594
|
+
return { ...submitted, prompt, ...(mode === "extend" ? { seconds: Math.min(15, (known ?? 0) + added) } : {}) };
|
|
3595
|
+
} catch (error) {
|
|
3596
|
+
if (error instanceof MediaError && error.code === "route-unsupported") unsupportedRoutes.add(`${accountId}/${route}`);
|
|
3597
|
+
throw error;
|
|
3598
|
+
}
|
|
3599
|
+
},
|
|
3600
|
+
});
|
|
3601
|
+
const skipped = Array.isArray(parsed.ignored) && parsed.ignored.length > 0
|
|
3602
|
+
? ` Ignored ${parsed.ignored.join(", ")}: ${mode === "extend" ? "an extension keeps the source's resolution and ratio" : "an edit keeps the source's length, resolution and ratio"}.`
|
|
3603
|
+
: "";
|
|
3604
|
+
const how = sourceData !== undefined ? `uploading ${source.name} (${(sourceData.byteLength / 1024 / 1024).toFixed(1)} MiB)` : source.name !== undefined ? `from ${source.name}` : "from xAI's link";
|
|
3605
|
+
return {
|
|
3606
|
+
kind: "success",
|
|
3607
|
+
text: `${mode === "extend" ? `Extending the video by ${seconds} s` : "Editing the video"} with ${VIDEO_EDIT_MODEL} (${accountName}), ${how}; a submitted video cannot be cancelled. Files go to ${outputDirectory(cwd, settings.outputDir)}.${skipped}`,
|
|
3608
|
+
};
|
|
3609
|
+
} catch (error) {
|
|
3610
|
+
if (signal?.aborted || error?.gateError === true) throw error;
|
|
3611
|
+
return { kind: "error", text: `${name}: ${mediaFailureText(error, "video")}` };
|
|
3612
|
+
}
|
|
3613
|
+
};
|
|
3614
|
+
/** `/cpa-video-status`: this session's video jobs, newest first (text). */
|
|
3615
|
+
const runVideoStatusCommand = async ({ agent }) => {
|
|
3616
|
+
const sessionId = validSessionId(agent?.id);
|
|
3617
|
+
const jobs = mediaJobStore.list().filter((job) => job.sessionId !== undefined && job.sessionId === sessionId).slice(0, 20);
|
|
3618
|
+
if (jobs.length === 0) return { kind: "success", text: "No video jobs in this session." };
|
|
3619
|
+
const lines = jobs.map((job) => {
|
|
3620
|
+
const live = job.commandId === undefined ? undefined : videoLive.get(job.commandId);
|
|
3621
|
+
const state = job.state === "submitted" ? (live?.phase ?? "waiting") + (live?.progress === undefined ? "" : ` ${live.progress}%`) : job.state;
|
|
3622
|
+
const prompt = job.prompt.length > 40 ? `${job.prompt.slice(0, 40)}…` : job.prompt;
|
|
3623
|
+
return `${job.submittedAt.slice(0, 19).replace("T", " ")} ${state.padEnd(14)} ${job.modelId}, ${job.seconds} s ${prompt} (${job.id})`;
|
|
3624
|
+
});
|
|
3625
|
+
return { kind: "success", text: lines.join("\n") };
|
|
3626
|
+
};
|
|
3627
|
+
|
|
3628
|
+
// #endregion
|
|
3629
|
+
|
|
3630
|
+
// #region Agent tool generate_media (off by default; design §5, §7, §8)
|
|
3631
|
+
//
|
|
3632
|
+
// One optional tool, registered only while the Generation tab's switch is
|
|
3633
|
+
// on and some enabled account offers a generation model. A call runs the
|
|
3634
|
+
// same admission as the slash commands (literally: no plain-language
|
|
3635
|
+
// parsing or interpretation), with a gate between admission and the
|
|
3636
|
+
// request that asks for approval and enforces the video seconds budget of
|
|
3637
|
+
// the root session, then waits up to 10 minutes for the background job.
|
|
3638
|
+
const TOOL_WAIT_MS = 10 * 60_000;
|
|
3639
|
+
const TOOL_IMAGE_MAX_BYTES = 32 * 1024 * 1024;
|
|
3640
|
+
const TOOL_PREVIEWS = 4;
|
|
3641
|
+
let toolsCtx;
|
|
3642
|
+
let toolOff;
|
|
3643
|
+
// The generation slash commands (registered only while generation is on).
|
|
3644
|
+
let commandsCtx;
|
|
3645
|
+
let mediaCommandsOff;
|
|
3646
|
+
|
|
3647
|
+
const gateError = (message) => Object.assign(new Error(message), { gateError: true });
|
|
3648
|
+
|
|
3649
|
+
/** The top-level agent a (sub-)agent was created under: its session holds picks and the budget. */
|
|
3650
|
+
const rootAgentOf = (agent) => {
|
|
3651
|
+
const agents = ctx.get?.("agents");
|
|
3652
|
+
let current = agent;
|
|
3653
|
+
if (typeof agents?.list !== "function" || typeof agents.isOwnedBy !== "function") return current;
|
|
3654
|
+
for (let depth = 0; depth < 16; depth += 1) {
|
|
3655
|
+
const owner = agents.list().find((candidate) => candidate !== current && agents.isOwnedBy(current.id, candidate));
|
|
3656
|
+
if (owner === undefined) break;
|
|
3657
|
+
current = owner;
|
|
3658
|
+
}
|
|
3659
|
+
return current;
|
|
3660
|
+
};
|
|
3661
|
+
|
|
3662
|
+
// Legacy fallback only when the native userQuestions service is absent. Cost
|
|
3663
|
+
// confirmation is independent of DSH's execution-approval permission policy.
|
|
3664
|
+
// runId → { sessionId, kind, en, zh, expiresAt, settle(outcome) }, in memory only.
|
|
3665
|
+
const pendingConfirms = new Map();
|
|
3666
|
+
const pendingNativeConfirms = new Set();
|
|
3667
|
+
const CONFIRM_WAIT_MS = 10 * 60_000;
|
|
3668
|
+
const CONFIRM_ALLOW = "Allow / 允许";
|
|
3669
|
+
const CONFIRM_DECLINE = "Decline / 拒绝";
|
|
3670
|
+
const cancelConfirms = () => {
|
|
3671
|
+
for (const cancel of [...pendingNativeConfirms]) cancel();
|
|
3672
|
+
for (const waiting of [...pendingConfirms.values()]) waiting.settle("cancelled");
|
|
3673
|
+
};
|
|
3674
|
+
|
|
3675
|
+
/** Compatibility composer/row consent without userQuestions (`allowed` / `rejected` / `timeout` / `cancelled`). */
|
|
3676
|
+
const askInRow = (exec, runId, sessionId, text, kind) =>
|
|
3677
|
+
new Promise((resolve) => {
|
|
3678
|
+
if (exec.signal?.aborted || toolOff === undefined || toolsCtx === undefined) return resolve("cancelled");
|
|
3679
|
+
const timer = setTimeout(() => settle("timeout"), CONFIRM_WAIT_MS);
|
|
3680
|
+
timer.unref?.();
|
|
3681
|
+
const onAbort = () => settle("cancelled");
|
|
3682
|
+
const settle = (outcome) => {
|
|
3683
|
+
if (pendingConfirms.get(runId)?.settle !== settle) return;
|
|
3684
|
+
pendingConfirms.delete(runId);
|
|
3685
|
+
clearTimeout(timer);
|
|
3686
|
+
exec.signal?.removeEventListener("abort", onAbort);
|
|
3687
|
+
resolve(outcome);
|
|
3688
|
+
};
|
|
3689
|
+
exec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3690
|
+
pendingConfirms.set(runId, { sessionId, kind, en: text.en, zh: text.zh, expiresAt: new Date(Date.now() + CONFIRM_WAIT_MS).toISOString(), settle });
|
|
3691
|
+
});
|
|
3692
|
+
|
|
3693
|
+
/** Anonymous questions block in the native composer, not in a folded tool call. */
|
|
3694
|
+
const askNativeConfirm = (questions, exec, text, kind, runId) =>
|
|
3695
|
+
new Promise((resolve) => {
|
|
3696
|
+
if (exec.signal?.aborted || toolOff === undefined || toolsCtx === undefined) return resolve("cancelled");
|
|
3697
|
+
const controller = new AbortController();
|
|
3698
|
+
const questionId = `media-confirm-${runId}`;
|
|
3699
|
+
let settled = false;
|
|
3700
|
+
const onAbort = () => finish("cancelled");
|
|
3701
|
+
const cancel = () => finish("cancelled");
|
|
3702
|
+
const timer = setTimeout(() => finish("timeout"), CONFIRM_WAIT_MS);
|
|
3703
|
+
timer.unref?.();
|
|
3704
|
+
const finish = (outcome) => {
|
|
3705
|
+
if (settled) return;
|
|
3706
|
+
settled = true;
|
|
3707
|
+
clearTimeout(timer);
|
|
3708
|
+
exec.signal?.removeEventListener("abort", onAbort);
|
|
3709
|
+
pendingNativeConfirms.delete(cancel);
|
|
3710
|
+
// Remove the composer's pending request on every settlement, even if
|
|
3711
|
+
// an answerer ignores cancellation or rejects after the local deadline.
|
|
3712
|
+
controller.abort();
|
|
3713
|
+
resolve(outcome);
|
|
3714
|
+
};
|
|
3715
|
+
pendingNativeConfirms.add(cancel);
|
|
3716
|
+
exec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3717
|
+
Promise.resolve().then(() => {
|
|
3718
|
+
if (settled) return;
|
|
3719
|
+
return questions.ask({
|
|
3720
|
+
agent: rootAgentOf(exec.agent),
|
|
3721
|
+
signal: controller.signal,
|
|
3722
|
+
questions: [{
|
|
3723
|
+
id: questionId,
|
|
3724
|
+
header: kind === "video" ? "Video confirmation / 视频确认" : "Image confirmation / 图像确认",
|
|
3725
|
+
question: `${text.zh}\n\n${text.en}`,
|
|
3726
|
+
options: [{ label: CONFIRM_ALLOW }, { label: CONFIRM_DECLINE }],
|
|
3727
|
+
multiSelect: false,
|
|
3728
|
+
}],
|
|
3729
|
+
// Do not supply wait/callId: anonymous requests publish the native
|
|
3730
|
+
// blocking PendingQuestion instead of a continued tool-call card.
|
|
3731
|
+
});
|
|
3732
|
+
}).then((answer) => {
|
|
3733
|
+
const item = Array.isArray(answer?.answers) && answer.answers.length === 1 ? answer.answers[0] : undefined;
|
|
3734
|
+
const allowed = item?.id === questionId && item.custom === undefined &&
|
|
3735
|
+
Array.isArray(item.selected) && item.selected.length === 1 && item.selected[0] === CONFIRM_ALLOW;
|
|
3736
|
+
finish(allowed ? "allowed" : "rejected");
|
|
3737
|
+
}, () => finish("unavailable"));
|
|
3738
|
+
});
|
|
3739
|
+
|
|
3740
|
+
/** Cost consent is not an execution approval and never falls back after a native error. */
|
|
3741
|
+
const askApproval = async (exec, text, kind, runId, sessionId) => {
|
|
3742
|
+
const questions = ctx.get?.("userQuestions");
|
|
3743
|
+
const what = kind === "video" ? "this video" : "these images";
|
|
3744
|
+
let answer;
|
|
3745
|
+
if (questions === undefined || questions === null) {
|
|
3746
|
+
const ownerId = validSessionId(rootAgentOf(exec.agent)?.id) ?? sessionId;
|
|
3747
|
+
if (ownerId === undefined) throw gateError(`${what} needs the user's confirmation, but this call has no session to ask in`);
|
|
3748
|
+
answer = await askInRow(exec, runId, ownerId, text, kind);
|
|
3749
|
+
} else {
|
|
3750
|
+
answer = await askNativeConfirm(questions, exec, text, kind, runId);
|
|
3751
|
+
}
|
|
3752
|
+
if (answer === "allowed") return;
|
|
3753
|
+
if (answer === "rejected") throw gateError(`the user declined ${what}; do not retry unless the user asks`);
|
|
3754
|
+
if (answer === "timeout") throw gateError(`the user did not answer the confirmation for ${what} within 10 minutes; ask the user before trying again`);
|
|
3755
|
+
if (answer === "cancelled") throw gateError(`the confirmation for ${what} was cancelled`);
|
|
3756
|
+
throw gateError(`the confirmation for ${what} could not be shown; ask the user before trying again`);
|
|
3757
|
+
};
|
|
3758
|
+
|
|
3759
|
+
/** Attached-image blocks for local image paths (saved in DSH's attachment store, like composer images). */
|
|
3760
|
+
const toolImageBlocks = async (paths, cwd, signal) => {
|
|
3761
|
+
if (paths === undefined) return [];
|
|
3762
|
+
const store = ctx.get?.("attachments");
|
|
3763
|
+
if (typeof store?.saveImage !== "function") throw new Error("images cannot be read: DSH's attachment store is not available");
|
|
3764
|
+
const blocks = [];
|
|
3765
|
+
for (const raw of paths) {
|
|
3766
|
+
const path = isAbsolute(raw) ? raw : join(cwd, raw);
|
|
3767
|
+
let info;
|
|
3768
|
+
try {
|
|
3769
|
+
info = statSync(path);
|
|
3770
|
+
} catch {
|
|
3771
|
+
info = undefined;
|
|
3772
|
+
}
|
|
3773
|
+
if (info === undefined || !info.isFile()) throw new Error(`image not found: ${raw}`);
|
|
3774
|
+
if (info.size > TOOL_IMAGE_MAX_BYTES) throw new Error(`image is larger than 32 MiB: ${raw}`);
|
|
3775
|
+
const data = new Uint8Array(readFileSync(path));
|
|
3776
|
+
const mediaType = sniffImageType(data);
|
|
3777
|
+
if (mediaType === undefined) throw new Error(`not a PNG, JPEG, WebP or GIF image: ${raw}`);
|
|
3778
|
+
signal?.throwIfAborted();
|
|
3779
|
+
const ref = await store.saveImage({ data, mediaType, name: basename(path) });
|
|
3780
|
+
blocks.push({ type: "image", attachment: ref });
|
|
3781
|
+
}
|
|
3782
|
+
return blocks;
|
|
3783
|
+
};
|
|
3784
|
+
|
|
3785
|
+
/** Whether the calling route declares image input (as DSH's own read_image checks). */
|
|
3786
|
+
const routeSeesImages = async (agent, signal) => {
|
|
3787
|
+
const target = sessionChatTarget(agent);
|
|
3788
|
+
const llm = ctx.get?.("llm");
|
|
3789
|
+
if (target === undefined || typeof llm?.resolveModelInfo !== "function") return false;
|
|
3790
|
+
try {
|
|
3791
|
+
const info = await llm.resolveModelInfo(target.provider, target.model, signal);
|
|
3792
|
+
return Array.isArray(info?.inputModalities) && info.inputModalities.includes("image");
|
|
3793
|
+
} catch {
|
|
3794
|
+
return false;
|
|
3795
|
+
}
|
|
3796
|
+
};
|
|
3797
|
+
|
|
3798
|
+
/** Smaller copies of generated images for the model (DSH's attachment store normalizes them). */
|
|
3799
|
+
const imagePreviews = async (paths, agent, signal) => {
|
|
3800
|
+
const store = ctx.get?.("attachments");
|
|
3801
|
+
if (paths.length === 0 || typeof store?.saveImage !== "function" || !(await routeSeesImages(agent, signal))) return [];
|
|
3802
|
+
const previews = [];
|
|
3803
|
+
for (const path of paths.slice(0, TOOL_PREVIEWS)) {
|
|
3804
|
+
try {
|
|
3805
|
+
const data = new Uint8Array(readFileSync(path));
|
|
3806
|
+
const mediaType = sniffImageType(data);
|
|
3807
|
+
if (mediaType === undefined) continue;
|
|
3808
|
+
const ref = await store.saveImage({ data, mediaType, name: basename(path) });
|
|
3809
|
+
previews.push({
|
|
3810
|
+
attachmentId: String(ref.attachmentId),
|
|
3811
|
+
mediaType: ref.mediaType,
|
|
3812
|
+
bytes: ref.bytes,
|
|
3813
|
+
width: ref.width,
|
|
3814
|
+
height: ref.height,
|
|
3815
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
3816
|
+
...(ref.originalDimensions === undefined ? {} : { originalDimensions: { width: ref.originalDimensions.width, height: ref.originalDimensions.height } }),
|
|
3817
|
+
});
|
|
3818
|
+
} catch (error) {
|
|
3819
|
+
if (signal?.aborted) throw error;
|
|
3820
|
+
ctx.logger?.warn?.(`cpa: could not attach a preview of ${basename(path)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
return previews;
|
|
3824
|
+
};
|
|
3825
|
+
|
|
3826
|
+
/** Wait for a job of this activation: "settled", "timeout" or "aborted" (the job keeps running). */
|
|
3827
|
+
const waitForJob = (key, signal, ms) => {
|
|
3828
|
+
const promise = jobPromises.get(key);
|
|
3829
|
+
if (promise === undefined) return Promise.resolve("settled");
|
|
3830
|
+
return new Promise((resolve) => {
|
|
3831
|
+
const timer = setTimeout(() => finish("timeout"), ms);
|
|
3832
|
+
timer.unref?.();
|
|
3833
|
+
const onAbort = () => finish("aborted");
|
|
3834
|
+
const finish = (how) => {
|
|
3835
|
+
clearTimeout(timer);
|
|
3836
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3837
|
+
resolve(how);
|
|
3838
|
+
};
|
|
3839
|
+
if (signal?.aborted) return finish("aborted");
|
|
3840
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3841
|
+
promise.then(() => finish("settled"), () => finish("settled"));
|
|
3842
|
+
});
|
|
3843
|
+
};
|
|
3844
|
+
|
|
3845
|
+
// Not submitted (CPA refused it at once): the seconds go back to the budget.
|
|
3846
|
+
const NOT_SUBMITTED = new Set(["upstream-error", "request-too-large", "route-unsupported", "no-endpoint", "invalid-account"]);
|
|
3847
|
+
|
|
3848
|
+
const executeGenerateMedia = async (args, exec) => {
|
|
3849
|
+
const t = parseToolArgs(args);
|
|
3850
|
+
if (exec.agent === undefined) throw new Error("generate_media needs an agent session");
|
|
3851
|
+
const agent = exec.agent;
|
|
3852
|
+
const sessionId = validSessionId(agent.id);
|
|
3853
|
+
const root = rootAgentOf(agent);
|
|
3854
|
+
const rootId = validSessionId(root?.id) ?? sessionId;
|
|
3855
|
+
const runId = toolRunId(exec.callId) ?? `tool-${randomUUID()}`;
|
|
3856
|
+
const workingDirectory = ctx.get?.("workingDirectory");
|
|
3857
|
+
const cwd = workingDirectory === undefined ? undefined : await workingDirectory.ensure(agent, exec.signal);
|
|
3858
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd) || !existsSync(cwd)) throw new Error(mediaFailureText(new MediaError("workspace-not-local"), t.kind));
|
|
3859
|
+
const blocks = await toolImageBlocks(t.images, cwd, exec.signal);
|
|
3860
|
+
const line = toolCommandLine(t);
|
|
3861
|
+
const handler = t.kind === "image" ? runImageCommand : t.mode === "generate" ? runVideoCommand : runVideoEditCommand(t.mode);
|
|
3862
|
+
// The client groups native tool events at turn level; never impersonate
|
|
3863
|
+
// a user slash command. Retain the deprecated output flag for old clients.
|
|
3864
|
+
const commandRow = false;
|
|
3865
|
+
let charged = 0;
|
|
3866
|
+
let planned;
|
|
3867
|
+
const gate = async (plan) => {
|
|
3868
|
+
planned = plan;
|
|
3869
|
+
const { settings } = mediaChoices();
|
|
3870
|
+
const policy = agentPolicy(settings, rootId === undefined ? {} : mediaSessions.get(rootId));
|
|
3871
|
+
if (plan.kind === "video") {
|
|
3872
|
+
if (policy.videoConfirm === "auto" && policy.limit !== undefined && policy.used + plan.seconds > policy.limit) {
|
|
3873
|
+
throw gateError(`this session's agent video budget is ${policy.limit} s and ${policy.used} s are used; this video needs ${plan.seconds} s. Ask the user to raise the limit (the media button next to the composer) or to confirm videos one by one`);
|
|
3874
|
+
}
|
|
3875
|
+
if (policy.videoConfirm === "ask") await askApproval(exec, approvalText(plan, policy), "video", runId, sessionId);
|
|
3876
|
+
if (rootId !== undefined) {
|
|
3877
|
+
mediaSessions.update(rootId, (entry) => chargeSeconds(entry, agentPolicy(settings, entry), plan.seconds));
|
|
3878
|
+
charged = plan.seconds;
|
|
3879
|
+
}
|
|
3880
|
+
} else if (policy.imageConfirm === "ask") {
|
|
3881
|
+
await askApproval(exec, approvalText(plan, policy), "image", runId, sessionId);
|
|
3882
|
+
}
|
|
3883
|
+
};
|
|
3884
|
+
const refund = () => {
|
|
3885
|
+
if (charged === 0 || rootId === undefined) return;
|
|
3886
|
+
const { settings } = mediaChoices();
|
|
3887
|
+
mediaSessions.update(rootId, (entry) => chargeSeconds(entry, agentPolicy(settings, entry), -charged));
|
|
3888
|
+
charged = 0;
|
|
3889
|
+
};
|
|
3890
|
+
const outcome = await handler({ agent, rawInput: line, attachments: blocks, signal: exec.signal, commandId: runId, literal: true, pickSession: rootId, gate });
|
|
3891
|
+
if (outcome?.kind !== "success") {
|
|
3892
|
+
refund();
|
|
3893
|
+
throw new Error(String(outcome?.text ?? "the request was refused").replace(/^\/cpa-[a-z-]+:\s*/, ""));
|
|
3894
|
+
}
|
|
3895
|
+
const waited = await waitForJob(runId, exec.signal, TOOL_WAIT_MS);
|
|
3896
|
+
const entry = sessionId === undefined ? { runs: [], recent: [] } : mediaSessions.get(sessionId);
|
|
3897
|
+
const run = entry.runs.find((r) => r.commandId === runId);
|
|
3898
|
+
const files = entry.recent.filter((r) => r.commandId === runId && existsSync(r.file)).map((r) => r.file);
|
|
3899
|
+
const option = planned?.option;
|
|
3900
|
+
const base = {
|
|
3901
|
+
kind: t.kind,
|
|
3902
|
+
...(t.kind === "video" ? { mode: t.mode } : {}),
|
|
3903
|
+
model: option?.modelId ?? run?.modelName ?? "",
|
|
3904
|
+
...(option?.accountName ? { account: option.accountName } : {}),
|
|
3905
|
+
run: runId,
|
|
3906
|
+
commandRow,
|
|
3907
|
+
files,
|
|
3908
|
+
folder: outputDirectory(cwd, planned?.outputDir ?? mediaChoices().settings.outputDir),
|
|
3909
|
+
...(Number.isInteger(run?.seconds) ? { seconds: run.seconds } : {}),
|
|
3910
|
+
...(typeof run?.resolution === "string" ? { resolution: run.resolution } : {}),
|
|
3911
|
+
...(typeof run?.aspect === "string" ? { aspect: run.aspect } : {}),
|
|
3912
|
+
};
|
|
3913
|
+
if (waited !== "settled" || run?.status === "running") {
|
|
3914
|
+
return {
|
|
3915
|
+
status: "running",
|
|
3916
|
+
...base,
|
|
3917
|
+
note:
|
|
3918
|
+
waited === "aborted"
|
|
3919
|
+
? "Stopped waiting. The generation keeps running in the background (and is billed); the file will be saved in the folder above and shown in this call's row."
|
|
3920
|
+
: "Still generating after 10 minutes. It keeps running in the background; the file will be saved in the folder above and shown in this call's row.",
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
3923
|
+
if (run === undefined || run.status === "error") {
|
|
3924
|
+
if (run === undefined || (run.videoId === undefined && NOT_SUBMITTED.has(run.errorCode))) refund();
|
|
3925
|
+
throw new Error(run?.error ?? "the generation failed");
|
|
3926
|
+
}
|
|
3927
|
+
const previews = t.kind === "image" ? await imagePreviews(files, agent, exec.signal) : [];
|
|
3928
|
+
return {
|
|
3929
|
+
status: "done",
|
|
3930
|
+
...base,
|
|
3931
|
+
...(typeof run.revisedPrompt === "string" ? { revisedPrompt: run.revisedPrompt } : {}),
|
|
3932
|
+
...(previews.length > 0 ? { previews } : {}),
|
|
3933
|
+
};
|
|
3934
|
+
};
|
|
3935
|
+
|
|
3936
|
+
/** The model-facing content: a short summary, then any image previews. */
|
|
3937
|
+
const renderGenerateMedia = (_args, value) => {
|
|
3938
|
+
const lines = [
|
|
3939
|
+
`${value.status === "done" ? "Saved" : "Running"}: ${value.kind}${value.mode && value.mode !== "generate" ? ` (${value.mode})` : ""} with ${value.model}${value.account ? ` (${value.account})` : ""}${value.seconds ? `, ${value.seconds} s` : ""}${value.resolution ? `, ${value.resolution}` : ""}${value.aspect ? `, ${value.aspect}` : ""}`,
|
|
3940
|
+
...value.files.map((file) => file),
|
|
3941
|
+
...(value.files.length === 0 ? [`Folder: ${value.folder}`] : []),
|
|
3942
|
+
...(value.revisedPrompt ? [`Revised prompt: ${value.revisedPrompt}`] : []),
|
|
3943
|
+
...(value.note ? [value.note] : []),
|
|
3944
|
+
];
|
|
3945
|
+
return [
|
|
3946
|
+
{ type: "text", text: lines.join("\n") },
|
|
3947
|
+
...(value.previews ?? []).map((p) => ({
|
|
3948
|
+
type: "image",
|
|
3949
|
+
attachment: {
|
|
3950
|
+
attachmentId: p.attachmentId,
|
|
3951
|
+
mediaType: p.mediaType,
|
|
3952
|
+
bytes: p.bytes,
|
|
3953
|
+
width: p.width,
|
|
3954
|
+
height: p.height,
|
|
3955
|
+
...(p.name === undefined ? {} : { name: p.name }),
|
|
3956
|
+
...(p.originalDimensions === undefined ? {} : { originalDimensions: { ...p.originalDimensions } }),
|
|
3957
|
+
},
|
|
3958
|
+
})),
|
|
3959
|
+
];
|
|
3960
|
+
};
|
|
3961
|
+
|
|
3962
|
+
const generateMediaTool = {
|
|
3963
|
+
name: GENERATE_MEDIA,
|
|
3964
|
+
description: GENERATE_MEDIA_TOOL.description,
|
|
3965
|
+
parameters: GENERATE_MEDIA_TOOL.parameters,
|
|
3966
|
+
output: {
|
|
3967
|
+
schema: GENERATE_MEDIA_OUTPUT,
|
|
3968
|
+
render: renderGenerateMedia,
|
|
3969
|
+
presentationMeta: (_args, value) => ({ run: value.run, status: value.status, commandRow: value.commandRow === true }),
|
|
3970
|
+
},
|
|
3971
|
+
execute: executeGenerateMedia,
|
|
3972
|
+
};
|
|
3973
|
+
|
|
3974
|
+
/** Register or unregister the tool to match the switch and the offered models. */
|
|
3975
|
+
const syncAgentTool = () => {
|
|
3976
|
+
let wanted = false;
|
|
3977
|
+
try {
|
|
3978
|
+
const { settings, options } = mediaChoices();
|
|
3979
|
+
wanted = settings.agentTool === true && options.image.length + options.video.length > 0;
|
|
3980
|
+
} catch {
|
|
3981
|
+
wanted = false;
|
|
3982
|
+
}
|
|
3983
|
+
if (wanted && toolOff === undefined && toolsCtx !== undefined) {
|
|
3984
|
+
try {
|
|
3985
|
+
const off = toolsCtx.tools.register(generateMediaTool);
|
|
3986
|
+
toolOff = typeof off === "function" ? off : () => {};
|
|
3987
|
+
ctx.logger?.info?.("cpa: generate_media tool registered");
|
|
3988
|
+
} catch (error) {
|
|
3989
|
+
ctx.logger?.warn?.(`cpa: could not register generate_media: ${error instanceof Error ? error.message : String(error)}`);
|
|
3990
|
+
}
|
|
3991
|
+
} else if (!wanted && toolOff !== undefined) {
|
|
3992
|
+
cancelConfirms();
|
|
3993
|
+
const off = toolOff;
|
|
3994
|
+
toolOff = undefined;
|
|
3995
|
+
try {
|
|
3996
|
+
off();
|
|
3997
|
+
} catch {
|
|
3998
|
+
// already gone with its scope
|
|
3999
|
+
}
|
|
4000
|
+
ctx.logger?.info?.("cpa: generate_media tool unregistered");
|
|
4001
|
+
}
|
|
4002
|
+
syncMediaCommands();
|
|
4003
|
+
};
|
|
4004
|
+
|
|
4005
|
+
/**
|
|
4006
|
+
* Register or unregister /cpa-image and the /cpa-video* commands to match
|
|
4007
|
+
* the Generation tab's master switch. Jobs already submitted keep running.
|
|
4008
|
+
*/
|
|
4009
|
+
const syncMediaCommands = () => {
|
|
4010
|
+
let wanted = false;
|
|
4011
|
+
try {
|
|
4012
|
+
wanted = readMedia().settings.enabled === true;
|
|
4013
|
+
} catch {
|
|
4014
|
+
wanted = false;
|
|
4015
|
+
}
|
|
4016
|
+
if (wanted && mediaCommandsOff === undefined && commandsCtx !== undefined) {
|
|
4017
|
+
try {
|
|
4018
|
+
mediaCommandsOff = registerMediaCommands(commandsCtx);
|
|
4019
|
+
ctx.logger?.info?.("cpa: generation commands registered");
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
ctx.logger?.warn?.(`cpa: could not register the generation commands: ${error instanceof Error ? error.message : String(error)}`);
|
|
4022
|
+
}
|
|
4023
|
+
} else if (!wanted && mediaCommandsOff !== undefined) {
|
|
4024
|
+
const off = mediaCommandsOff;
|
|
4025
|
+
mediaCommandsOff = undefined;
|
|
4026
|
+
try {
|
|
4027
|
+
off();
|
|
4028
|
+
} catch {
|
|
4029
|
+
// already gone with its scope
|
|
4030
|
+
}
|
|
4031
|
+
ctx.logger?.info?.("cpa: generation commands unregistered");
|
|
4032
|
+
}
|
|
4033
|
+
};
|
|
4034
|
+
// #endregion
|
|
4035
|
+
|
|
2182
4036
|
reschedule();
|
|
4037
|
+
resumeVideoJobs();
|
|
2183
4038
|
startupTimeout = outsideHmrTransaction(ctx, () =>
|
|
2184
4039
|
setTimeout(() => {
|
|
2185
4040
|
startupTimeout = undefined;
|
|
2186
|
-
void controller.refreshAll();
|
|
4041
|
+
void controller.refreshAll().finally(() => syncAgentTool());
|
|
2187
4042
|
}, 600)
|
|
2188
4043
|
);
|
|
2189
4044
|
|
|
2190
4045
|
ctx.on("credentials/reference-updated", (ref) => {
|
|
4046
|
+
// A management key changed (also from the DSH credentials page): start a new failure count.
|
|
4047
|
+
quota.keyChanged(ref);
|
|
2191
4048
|
for (const id of controller.accountsUsingKey(ref)) {
|
|
2192
4049
|
void controller.refreshAccount(id);
|
|
2193
4050
|
}
|
|
@@ -2219,17 +4076,89 @@ function applyImpl(ctx, config) {
|
|
|
2219
4076
|
description: "Probe every CLIProxyAPI account now and refresh the synchronized DSH model catalog.",
|
|
2220
4077
|
handler: async () => {
|
|
2221
4078
|
const payload = await controller.refresh(undefined);
|
|
4079
|
+
syncAgentTool();
|
|
2222
4080
|
const failed = payload.accounts.some((a) => a.status.state === "error");
|
|
2223
4081
|
return { kind: failed ? "error" : "success", text: renderStatusText(payload) };
|
|
2224
4082
|
},
|
|
2225
4083
|
});
|
|
4084
|
+
commandsCtx = cmdCtx;
|
|
4085
|
+
syncMediaCommands();
|
|
2226
4086
|
return () => {
|
|
2227
4087
|
offStatus?.();
|
|
2228
4088
|
offRefresh?.();
|
|
4089
|
+
commandsCtx = undefined;
|
|
4090
|
+
const off = mediaCommandsOff;
|
|
4091
|
+
mediaCommandsOff = undefined;
|
|
4092
|
+
try {
|
|
4093
|
+
off?.();
|
|
4094
|
+
} catch {
|
|
4095
|
+
// disposed with its scope
|
|
4096
|
+
}
|
|
2229
4097
|
};
|
|
2230
4098
|
}, "cpa: commands");
|
|
2231
4099
|
});
|
|
2232
4100
|
|
|
4101
|
+
/** The generation commands, registered only while generation is switched on (see syncMediaCommands). */
|
|
4102
|
+
function registerMediaCommands(cmdCtx) {
|
|
4103
|
+
{
|
|
4104
|
+
const offVideo = cmdCtx.commands.register({
|
|
4105
|
+
name: "cpa-video",
|
|
4106
|
+
description: "Generate a video with a CLIProxyAPI video model: text only, one image as the first frame, reference images (--ref, or 2+ images), or pinned first / last frames (--first-last, --last). A submitted video cannot be cancelled.",
|
|
4107
|
+
input: { hint: "[-m <model>] [--aspect 16:9] [-s 1-15] [-r 480p|720p] [--ref|--first-last|--last] <prompt>", attachments: true },
|
|
4108
|
+
handler: runVideoCommand,
|
|
4109
|
+
});
|
|
4110
|
+
const offVideoEdit = cmdCtx.commands.register({
|
|
4111
|
+
name: "cpa-video-edit",
|
|
4112
|
+
description: "Edit any MP4 of up to 8.7 s with grok-imagine-video: attach it, or pass --video <path>, --url <public URL> or --from <command id> (default: this session's newest video). A submitted video cannot be cancelled.",
|
|
4113
|
+
input: { hint: "[--video <path> | --url <URL> | --from <command id>] [-m <model>] <what to change>", attachments: true },
|
|
4114
|
+
handler: runVideoEditCommand("edit"),
|
|
4115
|
+
});
|
|
4116
|
+
const offVideoExtend = cmdCtx.commands.register({
|
|
4117
|
+
name: "cpa-video-extend",
|
|
4118
|
+
description: "Continue any MP4 of 2-15 s by 2-10 seconds: attach it, or pass --video <path>, --url <public URL> or --from <command id> (default: this session's newest video). A submitted video cannot be cancelled.",
|
|
4119
|
+
input: { hint: "[--video <path> | --url <URL> | --from <command id>] [-m <model>] [-s 2-10] <what happens next>", attachments: true },
|
|
4120
|
+
handler: runVideoEditCommand("extend"),
|
|
4121
|
+
});
|
|
4122
|
+
const offVideoStatus = cmdCtx.commands.register({
|
|
4123
|
+
name: "cpa-video-status",
|
|
4124
|
+
description: "List this session's CLIProxyAPI video jobs and their state.",
|
|
4125
|
+
handler: runVideoStatusCommand,
|
|
4126
|
+
});
|
|
4127
|
+
const offImage = cmdCtx.commands.register({
|
|
4128
|
+
name: "cpa-image",
|
|
4129
|
+
description: "Generate an image with a CLIProxyAPI image model; attach images to edit them. Files go to the session's working directory.",
|
|
4130
|
+
input: { hint: "[-m <model>] [--aspect 16:9] [-n 1-4] <prompt>", attachments: true },
|
|
4131
|
+
handler: runImageCommand,
|
|
4132
|
+
});
|
|
4133
|
+
return () => {
|
|
4134
|
+
offImage?.();
|
|
4135
|
+
offVideo?.();
|
|
4136
|
+
offVideoStatus?.();
|
|
4137
|
+
offVideoEdit?.();
|
|
4138
|
+
offVideoExtend?.();
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
// The optional agent tool (registered only while switched on; see syncAgentTool).
|
|
4144
|
+
ctx.inject(["tools"], (scope) => {
|
|
4145
|
+
scope.effect(() => {
|
|
4146
|
+
toolsCtx = scope;
|
|
4147
|
+
syncAgentTool();
|
|
4148
|
+
return () => {
|
|
4149
|
+
cancelConfirms();
|
|
4150
|
+
const off = toolOff;
|
|
4151
|
+
toolOff = undefined;
|
|
4152
|
+
toolsCtx = undefined;
|
|
4153
|
+
try {
|
|
4154
|
+
off?.();
|
|
4155
|
+
} catch {
|
|
4156
|
+
// disposed with its scope
|
|
4157
|
+
}
|
|
4158
|
+
};
|
|
4159
|
+
}, "cpa: agent tool");
|
|
4160
|
+
});
|
|
4161
|
+
|
|
2233
4162
|
// Register HTTP API Routes on webServer for the Web UI
|
|
2234
4163
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2235
4164
|
webCtx.effect(() => {
|
|
@@ -2241,7 +4170,10 @@ function applyImpl(ctx, config) {
|
|
|
2241
4170
|
if (!admitRequest(ctx, req, res, method)) return;
|
|
2242
4171
|
try {
|
|
2243
4172
|
const body = method === "POST" ? (await readJsonBody(req)) ?? {} : {};
|
|
2244
|
-
|
|
4173
|
+
const result = await run(body, req);
|
|
4174
|
+
// Account, model and Generation-tab changes may switch the agent tool on or off.
|
|
4175
|
+
if (method === "POST") syncAgentTool();
|
|
4176
|
+
sendJson(res, 200, result);
|
|
2245
4177
|
} catch (error) {
|
|
2246
4178
|
sendJson(res, errorStatus, {
|
|
2247
4179
|
ok: false,
|
|
@@ -2314,6 +4246,170 @@ function applyImpl(ctx, config) {
|
|
|
2314
4246
|
},
|
|
2315
4247
|
400
|
|
2316
4248
|
),
|
|
4249
|
+
route(ROUTES.mediaStatus, "GET", async () => ({ ok: true, media: mediaStatus() })),
|
|
4250
|
+
route(ROUTES.mediaSave, "POST", saveMedia, 400),
|
|
4251
|
+
route(
|
|
4252
|
+
ROUTES.mediaDiagnose,
|
|
4253
|
+
"POST",
|
|
4254
|
+
async (body) => {
|
|
4255
|
+
if (typeof body.accountId !== "string" || body.accountId === "") throw new MediaError("invalid-account");
|
|
4256
|
+
return { ok: true, diagnostic: await controller.diagnoseMedia(body.accountId) };
|
|
4257
|
+
},
|
|
4258
|
+
400
|
|
4259
|
+
),
|
|
4260
|
+
route(
|
|
4261
|
+
ROUTES.mediaSession,
|
|
4262
|
+
"GET",
|
|
4263
|
+
async (_body, req) => {
|
|
4264
|
+
const raw = new URL(req.url ?? "/", "http://host").searchParams.get("sessionId");
|
|
4265
|
+
const sessionId = raw === null || raw === "" ? undefined : validSessionId(raw);
|
|
4266
|
+
if (raw !== null && raw !== "" && sessionId === undefined) throw new MediaError("invalid-session");
|
|
4267
|
+
// Right after a start the catalogs may not be read yet; sync once
|
|
4268
|
+
// instead of letting the composer button wait for its next retry.
|
|
4269
|
+
await mediaChoicesFor("any");
|
|
4270
|
+
return { ok: true, session: sessionView(sessionId) };
|
|
4271
|
+
},
|
|
4272
|
+
400
|
|
4273
|
+
),
|
|
4274
|
+
route(ROUTES.mediaSessionSave, "POST", async (body) => saveSessionPick(body), 400),
|
|
4275
|
+
// The user's answer to a generate_media confirmation shown in its chat row.
|
|
4276
|
+
route(
|
|
4277
|
+
ROUTES.mediaConfirm,
|
|
4278
|
+
"POST",
|
|
4279
|
+
async (body) => {
|
|
4280
|
+
const sessionId = validSessionId(body.sessionId);
|
|
4281
|
+
const commandId = validCommandId(body.commandId);
|
|
4282
|
+
if (sessionId === undefined || commandId === undefined || typeof body.allow !== "boolean") throw new MediaError("invalid-request");
|
|
4283
|
+
const waiting = pendingConfirms.get(commandId);
|
|
4284
|
+
if (waiting === undefined || waiting.sessionId !== sessionId) throw new MediaError("confirm-expired");
|
|
4285
|
+
waiting.settle(body.allow ? "allowed" : "rejected");
|
|
4286
|
+
return { ok: true };
|
|
4287
|
+
},
|
|
4288
|
+
400
|
|
4289
|
+
),
|
|
4290
|
+
// One slash-command run's record and files (its chat row shows them).
|
|
4291
|
+
// The absolute paths let the page open or reveal a file through DSH's
|
|
4292
|
+
// own Session Remote (`session.openWorkspacePath`), which re-verifies
|
|
4293
|
+
// them on the Host.
|
|
4294
|
+
route(
|
|
4295
|
+
ROUTES.mediaCommand,
|
|
4296
|
+
"GET",
|
|
4297
|
+
async (_body, req) => {
|
|
4298
|
+
const params = new URL(req.url ?? "/", "http://host").searchParams;
|
|
4299
|
+
const sessionId = validSessionId(params.get("sessionId") ?? "");
|
|
4300
|
+
const commandId = validCommandId(params.get("commandId") ?? "");
|
|
4301
|
+
if (sessionId === undefined || commandId === undefined) throw new MediaError("invalid-request");
|
|
4302
|
+
const entry = mediaSessions.get(sessionId);
|
|
4303
|
+
const files = entry.recent
|
|
4304
|
+
.filter((r) => r.commandId === commandId && existsSync(r.file))
|
|
4305
|
+
.map((r) => ({ id: r.id, kind: r.kind, name: basename(r.file), path: r.file }));
|
|
4306
|
+
let run = entry.runs.find((r) => r.commandId === commandId) ?? null;
|
|
4307
|
+
// A run recorded as running that no job of this activation owns was
|
|
4308
|
+
// cut off by a reload or a restart.
|
|
4309
|
+
if (run?.status === "running" && !mediaJobs.has(commandId)) {
|
|
4310
|
+
run = { ...run, status: "error", errorCode: "interrupted", error: mediaFailureText(new MediaError("interrupted"), run.kind) };
|
|
4311
|
+
} else if (run?.status === "running" && videoLive.has(commandId)) {
|
|
4312
|
+
// A video's phase (submitting / queued / running / downloading) and CPA's progress, if it reports one.
|
|
4313
|
+
run = { ...run, ...videoLive.get(commandId) };
|
|
4314
|
+
}
|
|
4315
|
+
// Which file manager "show in folder" opens, to name the menu item.
|
|
4316
|
+
const desktop = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "finder" : "filemanager";
|
|
4317
|
+
// Edit / extend this video: by xAI's short-lived link while it is
|
|
4318
|
+
// remembered, else by sending its file inline when small enough.
|
|
4319
|
+
// Past results stay visible with generation off, without the edit / extend actions.
|
|
4320
|
+
let actions;
|
|
4321
|
+
if (run?.kind === "video" && run.status === "done" && readMedia().settings.enabled === true) {
|
|
4322
|
+
const source = usableSource(commandId);
|
|
4323
|
+
const video = files.find((f) => f.kind === "video");
|
|
4324
|
+
let size;
|
|
4325
|
+
try {
|
|
4326
|
+
size = video === undefined ? undefined : statSync(video.path).size;
|
|
4327
|
+
} catch {
|
|
4328
|
+
size = undefined;
|
|
4329
|
+
}
|
|
4330
|
+
if (source !== undefined || (size !== undefined && size <= VIDEO_UPLOAD_MAX_BYTES)) {
|
|
4331
|
+
const seconds = source?.seconds ?? run.seconds;
|
|
4332
|
+
const accountId = source?.accountId ?? String(run.model).split("/", 1)[0];
|
|
4333
|
+
actions = {
|
|
4334
|
+
edit: seconds === undefined || seconds <= EDIT_SOURCE_MAX_SECONDS,
|
|
4335
|
+
extend:
|
|
4336
|
+
(seconds === undefined || (seconds >= EXTEND_SOURCE_SECONDS.min && seconds <= EXTEND_SOURCE_SECONDS.max)) &&
|
|
4337
|
+
!unsupportedRoutes.has(`${accountId}/extensions`),
|
|
4338
|
+
...(source === undefined ? {} : { expiresAt: new Date(source.at + VIDEO_SOURCE_TTL_MS).toISOString() }),
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
// A generate_media call waiting for the user's confirmation in this row.
|
|
4343
|
+
const waiting = pendingConfirms.get(commandId);
|
|
4344
|
+
const confirm = waiting !== undefined && waiting.sessionId === sessionId ? { kind: waiting.kind, en: waiting.en, zh: waiting.zh, expiresAt: waiting.expiresAt } : undefined;
|
|
4345
|
+
return {
|
|
4346
|
+
ok: true,
|
|
4347
|
+
run,
|
|
4348
|
+
files,
|
|
4349
|
+
desktop,
|
|
4350
|
+
...(files.length > 0 ? { folderPath: dirname(files[0].path) } : {}),
|
|
4351
|
+
...(actions === undefined ? {} : { actions }),
|
|
4352
|
+
...(confirm === undefined ? {} : { confirm }),
|
|
4353
|
+
};
|
|
4354
|
+
},
|
|
4355
|
+
400
|
|
4356
|
+
),
|
|
4357
|
+
// Serves only files this plugin wrote and recorded for that session.
|
|
4358
|
+
webCtx.webServer.register({
|
|
4359
|
+
kind: "exact",
|
|
4360
|
+
path: ROUTES.mediaFile,
|
|
4361
|
+
handler: async (req, res) => {
|
|
4362
|
+
if (!admitRequest(ctx, req, res, "GET")) return;
|
|
4363
|
+
const params = new URL(req.url ?? "/", "http://host").searchParams;
|
|
4364
|
+
const sessionId = validSessionId(params.get("sessionId") ?? "");
|
|
4365
|
+
const record = sessionId === undefined ? undefined : mediaSessions.get(sessionId).recent.find((r) => r.id === params.get("id"));
|
|
4366
|
+
const type = record === undefined ? undefined : MEDIA_FILE_TYPES[extname(record.file).toLowerCase()];
|
|
4367
|
+
if (record === undefined || type === undefined || !existsSync(record.file)) {
|
|
4368
|
+
sendJson(res, 404, { ok: false, error: "not-found" });
|
|
4369
|
+
return;
|
|
4370
|
+
}
|
|
4371
|
+
try {
|
|
4372
|
+
if (!type.startsWith("video/")) {
|
|
4373
|
+
const bytes = readFileSync(record.file);
|
|
4374
|
+
res.writeHead(200, {
|
|
4375
|
+
"cache-control": "no-store",
|
|
4376
|
+
"content-type": type,
|
|
4377
|
+
"content-length": String(bytes.byteLength),
|
|
4378
|
+
"x-content-type-options": "nosniff",
|
|
4379
|
+
});
|
|
4380
|
+
res.end(bytes);
|
|
4381
|
+
return;
|
|
4382
|
+
}
|
|
4383
|
+
// Videos are streamed, with single byte ranges so <video> can seek.
|
|
4384
|
+
const size = statSync(record.file).size;
|
|
4385
|
+
const range = byteRange(req.headers?.range, size);
|
|
4386
|
+
if (range === null) {
|
|
4387
|
+
res.writeHead(416, { "content-range": `bytes */${size}` });
|
|
4388
|
+
res.end();
|
|
4389
|
+
return;
|
|
4390
|
+
}
|
|
4391
|
+
const [start, end] = range ?? [0, size - 1];
|
|
4392
|
+
res.writeHead(range === undefined ? 200 : 206, {
|
|
4393
|
+
"cache-control": "no-store",
|
|
4394
|
+
"content-type": type,
|
|
4395
|
+
"accept-ranges": "bytes",
|
|
4396
|
+
"content-length": String(size === 0 ? 0 : end - start + 1),
|
|
4397
|
+
...(range === undefined ? {} : { "content-range": `bytes ${start}-${end}/${size}` }),
|
|
4398
|
+
"x-content-type-options": "nosniff",
|
|
4399
|
+
});
|
|
4400
|
+
if (size === 0) {
|
|
4401
|
+
res.end();
|
|
4402
|
+
return;
|
|
4403
|
+
}
|
|
4404
|
+
const stream = createReadStream(record.file, { start, end });
|
|
4405
|
+
stream.on("error", () => res.destroy?.());
|
|
4406
|
+
res.on?.("close", () => stream.destroy());
|
|
4407
|
+
stream.pipe(res);
|
|
4408
|
+
} catch {
|
|
4409
|
+
sendJson(res, 404, { ok: false, error: "not-found" });
|
|
4410
|
+
}
|
|
4411
|
+
},
|
|
4412
|
+
}),
|
|
2317
4413
|
];
|
|
2318
4414
|
return () => {
|
|
2319
4415
|
for (const dispose of disposers) dispose?.();
|