@mengruo/dsh-vision-toolkit 0.1.5 → 0.1.6-beta.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/assets/1.mp4 +0 -0
- package/assets/skill/SKILL.md +37 -5
- package/docs/plan-per-tool-visibility.md +82 -0
- package/lib/client.js +53 -3
- package/lib/client.js.map +1 -1
- package/lib/config.js +17 -0
- package/lib/config.js.map +1 -1
- package/lib/exposure.js +35 -8
- package/lib/exposure.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/paths.js +9 -0
- package/lib/paths.js.map +1 -1
- package/lib/runtime.js +175 -1
- package/lib/runtime.js.map +1 -1
- package/lib/tools.js +116 -4
- package/lib/tools.js.map +1 -1
- package/lib/types/client/index.d.ts +24 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +26 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/exposure.d.ts +14 -2
- package/lib/types/exposure.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/paths.d.ts +7 -0
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +38 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/tools.d.ts +22 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/video.d.ts +78 -0
- package/lib/types/video.d.ts.map +1 -0
- package/lib/types/web.d.ts +1 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/video.js +169 -0
- package/lib/video.js.map +1 -0
- package/lib/web.js +47 -4
- package/lib/web.js.map +1 -1
- package/package.json +2 -1
- package/src/client/index.tsx +72 -1
- package/src/config.ts +43 -0
- package/src/exposure.ts +34 -9
- package/src/index.ts +10 -5
- package/src/paths.ts +11 -0
- package/src/runtime.ts +196 -0
- package/src/tools.ts +144 -3
- package/src/video.ts +222 -0
- package/src/web.ts +53 -5
package/lib/runtime.js
CHANGED
|
@@ -16,12 +16,15 @@ import { BUILT_IN_FREE_VISION_KEY } from "./defaults.js";
|
|
|
16
16
|
import { evidenceRuntimeFingerprint } from "./evidence-cache.js";
|
|
17
17
|
import { VisionToolkitError } from "./errors.js";
|
|
18
18
|
import { ObjectStorageClient, splitObjectStorageCredential } from "./object-storage.js";
|
|
19
|
-
import { assertDistinctOutput, commitStagedDirectory, commitStagedOutput, createPathPolicy, createStagedDirectory, createStagedOutput, isWithin, resolveHtmlFile, resolveInputFile, resolveOutputDirectory, resolveOutputFile, seedStagedDirectory, } from "./paths.js";
|
|
19
|
+
import { assertDistinctOutput, commitStagedDirectory, commitStagedOutput, createPathPolicy, createStagedDirectory, createStagedOutput, isWithin, resolveHtmlFile, resolveInputFile, resolveInputVideo, resolveOutputDirectory, resolveOutputFile, seedStagedDirectory, } from "./paths.js";
|
|
20
|
+
import { extractChatAnswer, ffprobeBinaryPath, parseFfprobeOutput, videoMediaType, } from "./video.js";
|
|
20
21
|
import { parseCropOutput, parseDominantColorsOutput, parseExtractForegroundOutput, parseHtmlScreenshotOutput, parseLocationOutput, parsePixelDiffOutput, parseTraceOutput, UpstreamAdapter, } from "./upstream.js";
|
|
21
22
|
import { PLUGIN_VERSION } from "./version.js";
|
|
22
23
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
23
24
|
const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url));
|
|
24
25
|
const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.';
|
|
26
|
+
const VISION_MODEL_TEST_VIDEO = fileURLToPath(new URL('../assets/1.mp4', import.meta.url));
|
|
27
|
+
const VISION_MODEL_TEST_VIDEO_PROMPT = '这个视频内容是什么?';
|
|
25
28
|
/** Bump when the Pillow compression ladder changes so stale cache entries are ignored. */
|
|
26
29
|
const COMPRESSED_IMAGE_CACHE_VERSION = 'v2';
|
|
27
30
|
/** Cache keys carry 64-bit digests so Windows paths stay below MAX_PATH; the full file sha256 is computed on read and compared against this prefix. */
|
|
@@ -372,6 +375,16 @@ export class VisionToolkitRuntime {
|
|
|
372
375
|
get storageDirectory() {
|
|
373
376
|
return this.config.storageDir;
|
|
374
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* Whether at least one enabled OpenAI-compatible provider has video support
|
|
380
|
+
* turned on. A capability probe only: tool visibility is governed by the
|
|
381
|
+
* Settings tool-visibility video bucket, not by this flag. `videoUnderstand`
|
|
382
|
+
* re-checks it at call time and reports "video understanding unavailable"
|
|
383
|
+
* when it is false.
|
|
384
|
+
*/
|
|
385
|
+
get videoSupportEnabled() {
|
|
386
|
+
return this.videoProvider() !== undefined;
|
|
387
|
+
}
|
|
375
388
|
/** Stable identity for persisted image descriptions produced by this runtime. */
|
|
376
389
|
get evidenceFingerprint() {
|
|
377
390
|
return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim());
|
|
@@ -492,6 +505,10 @@ export class VisionToolkitRuntime {
|
|
|
492
505
|
get primaryProvider() {
|
|
493
506
|
return this.config.providers.find(provider => provider.enabled) ?? this.config.providers[0];
|
|
494
507
|
}
|
|
508
|
+
/** Highest-priority enabled OpenAI provider with video support enabled. */
|
|
509
|
+
videoProvider() {
|
|
510
|
+
return this.config.providers.find(provider => provider.enabled && provider.videoSupport && provider.protocol === 'openai');
|
|
511
|
+
}
|
|
495
512
|
/** Build the upstream environment for one resolved provider. */
|
|
496
513
|
providerEnv(provider, resolved) {
|
|
497
514
|
const sslVerify = process.env.VISION_SSL_VERIFY?.trim();
|
|
@@ -520,6 +537,7 @@ export class VisionToolkitRuntime {
|
|
|
520
537
|
userAgent: provider.userAgent,
|
|
521
538
|
stream: provider.stream,
|
|
522
539
|
uploadViaUrl: provider.uploadViaUrl,
|
|
540
|
+
videoSupport: provider.videoSupport,
|
|
523
541
|
})
|
|
524
542
|
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
525
543
|
: await this.ctx.credentials.resolve(provider.credential);
|
|
@@ -850,6 +868,162 @@ export class VisionToolkitRuntime {
|
|
|
850
868
|
}
|
|
851
869
|
return client.test();
|
|
852
870
|
}
|
|
871
|
+
/**
|
|
872
|
+
* Settings "test video call" probe: upload the bundled diagnostic video to
|
|
873
|
+
* object storage and send one OpenAI-compatible (Aliyun Qwen) video request.
|
|
874
|
+
* The wire content uses the Qwen video block shape (`video_url` + `fps`) plus
|
|
875
|
+
* the fixed test question. The object is deleted afterwards, best-effort.
|
|
876
|
+
*/
|
|
877
|
+
async testVideoCall(options, provider) {
|
|
878
|
+
return this.runOperation('vision_toolkit_video_test', options, async (operation) => {
|
|
879
|
+
const target = provider ?? this.primaryProvider;
|
|
880
|
+
if (target.protocol !== 'openai') {
|
|
881
|
+
throw new VisionToolkitError('config', 'video support is only available for OpenAI-compatible providers');
|
|
882
|
+
}
|
|
883
|
+
const entry = await this.resolveProviderEnv(target);
|
|
884
|
+
if (entry === undefined) {
|
|
885
|
+
throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`);
|
|
886
|
+
}
|
|
887
|
+
const client = await this.resolveObjectStorageClient();
|
|
888
|
+
if (client === undefined) {
|
|
889
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)');
|
|
890
|
+
}
|
|
891
|
+
const uploaded = await client.uploadImage(VISION_MODEL_TEST_VIDEO, 'video/mp4');
|
|
892
|
+
try {
|
|
893
|
+
const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, VISION_MODEL_TEST_VIDEO_PROMPT, 2, operation);
|
|
894
|
+
const snippet = answer.trim().slice(0, 160);
|
|
895
|
+
return {
|
|
896
|
+
detail: snippet.length === 0
|
|
897
|
+
? `Video call completed against ${target.model}`
|
|
898
|
+
: `Video call completed against ${target.model}: ${snippet}`,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
await client.deleteObject(uploaded.key);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
/** Run the bundled ffprobe binary once and parse its JSON metadata. */
|
|
907
|
+
async runFfprobe(videoPath, operation) {
|
|
908
|
+
const binary = ffprobeBinaryPath();
|
|
909
|
+
if (binary === null) {
|
|
910
|
+
throw new VisionToolkitError('runtime', 'ffprobe is unavailable; the ffprobe-static package must be installed alongside the plugin');
|
|
911
|
+
}
|
|
912
|
+
const handle = this.ctx.subprocess.spawn({
|
|
913
|
+
argv: [binary, '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', videoPath],
|
|
914
|
+
cwd: process.cwd(),
|
|
915
|
+
stdio: {
|
|
916
|
+
stdin: 'ignore',
|
|
917
|
+
stdout: { maxBytes: 512 * 1024 },
|
|
918
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
919
|
+
},
|
|
920
|
+
graceMs: 2000,
|
|
921
|
+
signal: operation.signal,
|
|
922
|
+
});
|
|
923
|
+
const outcome = await handle.done;
|
|
924
|
+
const stdout = handle.collected.stdout?.readFrom(0);
|
|
925
|
+
const stderr = handle.collected.stderr?.readFrom(0);
|
|
926
|
+
if (outcome.exitCode !== 0) {
|
|
927
|
+
throw new VisionToolkitError('input', `cannot read video metadata: ${(stderr?.text ?? '').trim() || 'ffprobe failed'}`);
|
|
928
|
+
}
|
|
929
|
+
if (stdout?.lossy === true) {
|
|
930
|
+
throw new VisionToolkitError('output', 'video metadata output exceeded the capture limit');
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
return parseFfprobeOutput(stdout?.text ?? '');
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
throw new VisionToolkitError('output', 'video metadata output is invalid', { cause: error });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* One OpenAI-compatible (Aliyun Qwen) video chat-completions request. The
|
|
941
|
+
* content array carries the video_url block (with `fps`) plus the prompt text.
|
|
942
|
+
*/
|
|
943
|
+
async requestVideoAnswer(target, apiKey, videoUrl, prompt, fps, operation) {
|
|
944
|
+
operation.metrics.usedVisionService = true;
|
|
945
|
+
const started = Date.now();
|
|
946
|
+
const response = await fetch(`${target.baseUrl}/chat/completions`, {
|
|
947
|
+
method: 'POST',
|
|
948
|
+
headers: {
|
|
949
|
+
'Content-Type': 'application/json',
|
|
950
|
+
Authorization: `Bearer ${apiKey}`,
|
|
951
|
+
'User-Agent': target.userAgent,
|
|
952
|
+
},
|
|
953
|
+
body: JSON.stringify({
|
|
954
|
+
model: target.model,
|
|
955
|
+
messages: [{
|
|
956
|
+
role: 'user',
|
|
957
|
+
content: [
|
|
958
|
+
{ type: 'video_url', video_url: { url: videoUrl }, fps },
|
|
959
|
+
{ type: 'text', text: prompt },
|
|
960
|
+
],
|
|
961
|
+
}],
|
|
962
|
+
}),
|
|
963
|
+
signal: operation.signal,
|
|
964
|
+
});
|
|
965
|
+
operation.metrics.upstreamMs += Date.now() - started;
|
|
966
|
+
const body = await response.text().catch(() => '');
|
|
967
|
+
if (!response.ok) {
|
|
968
|
+
throw new VisionToolkitError('service', `video request failed with HTTP ${response.status}: ${body.slice(0, 300)}`);
|
|
969
|
+
}
|
|
970
|
+
const answer = extractChatAnswer(body);
|
|
971
|
+
if (answer.trim().length === 0) {
|
|
972
|
+
throw new VisionToolkitError('output', 'vision API returned an empty answer');
|
|
973
|
+
}
|
|
974
|
+
return answer;
|
|
975
|
+
}
|
|
976
|
+
/** Local video basic-info probe: ffprobe metadata, no API or credential. */
|
|
977
|
+
async videoInfo(request, options) {
|
|
978
|
+
return this.runOperation('vision_video_info', options, async (operation) => {
|
|
979
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
980
|
+
const resolved = await resolveInputVideo(request.video, policy);
|
|
981
|
+
const metadata = await this.runFfprobe(resolved.path, operation);
|
|
982
|
+
return { path: resolved.path, bytes: resolved.bytes, ...metadata };
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Video understanding: upload the video to object storage and send it plus a
|
|
987
|
+
* prompt to the first enabled OpenAI provider with video support. Whether the
|
|
988
|
+
* tool appears in an Agent is governed by the Settings tool-visibility video
|
|
989
|
+
* bucket, not by provider capability; this method re-checks capability at
|
|
990
|
+
* call time and returns a "video understanding unavailable" config error when
|
|
991
|
+
* no enabled provider supports video.
|
|
992
|
+
*/
|
|
993
|
+
async videoUnderstand(request, options) {
|
|
994
|
+
return this.runOperation('vision_video_understand', options, async (operation) => {
|
|
995
|
+
const target = this.videoProvider();
|
|
996
|
+
if (target === undefined) {
|
|
997
|
+
throw new VisionToolkitError('config', 'no enabled vision service has video support; enable it in Settings first');
|
|
998
|
+
}
|
|
999
|
+
const entry = await this.resolveProviderEnv(target);
|
|
1000
|
+
if (entry === undefined) {
|
|
1001
|
+
throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`);
|
|
1002
|
+
}
|
|
1003
|
+
const prompt = request.prompt.trim();
|
|
1004
|
+
if (prompt.length === 0) {
|
|
1005
|
+
throw new VisionToolkitError('input', 'prompt must not be empty');
|
|
1006
|
+
}
|
|
1007
|
+
const fps = request.fps ?? 2;
|
|
1008
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 120) {
|
|
1009
|
+
throw new VisionToolkitError('input', 'fps must be an integer between 1 and 120');
|
|
1010
|
+
}
|
|
1011
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
1012
|
+
const resolved = await resolveInputVideo(request.video, policy);
|
|
1013
|
+
const client = await this.resolveObjectStorageClient();
|
|
1014
|
+
if (client === undefined) {
|
|
1015
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)');
|
|
1016
|
+
}
|
|
1017
|
+
const uploaded = await client.uploadImage(resolved.path, videoMediaType(extname(resolved.path).toLowerCase()));
|
|
1018
|
+
try {
|
|
1019
|
+
const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, prompt, fps, operation);
|
|
1020
|
+
return { path: resolved.path, answer };
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
await client.deleteObject(uploaded.key);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
853
1027
|
/** Stable gate key for one provider's in-flight request cap. */
|
|
854
1028
|
providerGate(provider) {
|
|
855
1029
|
const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`;
|