mirror of
https://github.com/echore/vault-autopilot.git
synced 2026-07-22 08:34:00 +00:00
refactor: remove AI providers and auto processing; manual-only
Cut the entire AI execution layer now that analysis happens in Obsidian (Claudian subscription) instead of in the plugin: - delete providers/ (anthropic, gemini-api, openai-compat, cli-base) and drop @anthropic-ai/sdk, @google/generative-ai, openai deps - delete the now-orphaned startup-check.ts and path-detector.ts - routeClip + all clip handlers become manual-only (save frames/thumbnail + SOP template); remove the providers map and 'auto' code paths - remove AI Providers + auto/provider settings UI - drop AIProvider/MultiFrameProvider/ProviderConfig/AnalysisRequest types The clip-rule providerId/processingMode fields are kept for now (vestigial, always manual) pending a separate decision on removing them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b2b5633129
commit
66ba0ea004
18 changed files with 75 additions and 2001 deletions
501
package-lock.json
generated
501
package-lock.json
generated
|
|
@ -7,11 +7,6 @@
|
|||
"": {
|
||||
"name": "vault-autopilot",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@google/generative-ai": "^0.15.0",
|
||||
"openai": "^4.52.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^20.0.0",
|
||||
|
|
@ -21,36 +16,6 @@
|
|||
"typescript": "5.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.26.1.tgz",
|
||||
"integrity": "sha512-HeMJP1bDFfQPQS3XTJAmfXkFBdZ88wvfkE05+vsoA9zGn5dHqEaHOPsqkazf/i0gXYg2XlLxxZrf6rUAarSqzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
|
|
@ -573,31 +538,6 @@
|
|||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
|
|
@ -1000,15 +940,6 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/generative-ai": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.15.0.tgz",
|
||||
"integrity": "sha512-zs37judcTYFJf1U7tnuqnh7gdzF6dcWj9pNRxjA5JTONRoiQ0htrRdbefRFiewOIfXwhun5t9hbd2ray7812eQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
|
@ -2063,21 +1994,12 @@
|
|||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
|
|
@ -2432,30 +2354,6 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
||||
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"humanize-ms": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
|
|
@ -2538,12 +2436,6 @@
|
|||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz",
|
||||
|
|
@ -2751,19 +2643,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
|
|
@ -2971,18 +2850,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
|
@ -3062,15 +2929,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-newline": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||
|
|
@ -3091,20 +2949,6 @@
|
|||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -3149,51 +2993,6 @@
|
|||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.3.tgz",
|
||||
|
|
@ -3267,15 +3066,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
|
|
@ -3395,41 +3185,6 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data-encoder": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
|
||||
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/formdata-node": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "1.0.0",
|
||||
"web-streams-polyfill": "4.0.0-beta.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
|
|
@ -3452,15 +3207,6 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
|
|
@ -3481,30 +3227,6 @@
|
|||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-package-type": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
|
|
@ -3515,19 +3237,6 @@
|
|||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||
|
|
@ -3563,18 +3272,6 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
|
|
@ -3614,45 +3311,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
|
|
@ -3670,15 +3328,6 @@
|
|||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||
|
|
@ -5407,15 +5056,6 @@
|
|||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
|
|
@ -5450,27 +5090,6 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
|
|
@ -5531,6 +5150,7 @@
|
|||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
|
|
@ -5563,46 +5183,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
|
|
@ -5684,51 +5264,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "4.104.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
|
||||
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/node-fetch": "^2.6.4",
|
||||
"abort-controller": "^3.0.0",
|
||||
"agentkeepalive": "^4.2.1",
|
||||
"form-data-encoder": "1.7.2",
|
||||
"formdata-node": "^4.3.2",
|
||||
"node-fetch": "^2.6.7"
|
||||
},
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/openai/node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/openai/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
|
|
@ -6416,12 +5951,6 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.11",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz",
|
||||
|
|
@ -6565,6 +6094,7 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
|
@ -6668,31 +6198,6 @@
|
|||
"makeerror": "1.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "4.0.0-beta.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -7,11 +7,6 @@
|
|||
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@google/generative-ai": "^0.15.0",
|
||||
"openai": "^4.52.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^20.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ClipPayload, HookPayload, KeyframePayload, ScreenshotPayload, ThumbnailPayload } from './server';
|
||||
import { AIProvider, ClipRule, isMultiFrameProvider, MultiFrameRequest, PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types';
|
||||
import { postProcessMarkdown, sanitize, buildVideoEmbed, extractVideoId, detectPlatform, videoKey } from './util';
|
||||
import { ClipRule, PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types';
|
||||
import { sanitize, buildVideoEmbed, extractVideoId, detectPlatform, videoKey } from './util';
|
||||
import { buildAnchor, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta, NewSection } from './video-note';
|
||||
|
||||
export interface VaultOps {
|
||||
|
|
@ -17,17 +17,16 @@ export interface VaultOps {
|
|||
|
||||
export async function routeClip(
|
||||
payload: ClipPayload,
|
||||
providers: Map<string, AIProvider>,
|
||||
clipRules: PluginSettings['clipRules'],
|
||||
vaultOps: VaultOps,
|
||||
): Promise<{ notePath?: string; notice?: string }> {
|
||||
if (payload.mode === 'thumbnail') return handleThumbnail(payload, providers, clipRules.thumbnail, vaultOps);
|
||||
if (payload.mode === 'thumbnail') return handleThumbnail(payload, clipRules.thumbnail, vaultOps);
|
||||
if (payload.mode === 'screenshot') {
|
||||
const normalized = normalizeScreenshot(payload);
|
||||
return handleScreenshot(normalized, providers, clipRules.screenshot, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder);
|
||||
return handleScreenshot(normalized, clipRules.screenshot, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder);
|
||||
}
|
||||
if (payload.mode === 'hook') return handleMultiFrame(payload, providers, clipRules.hook, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder);
|
||||
if (payload.mode === 'keyframe') return handleMultiFrame(payload, providers, clipRules.keyframe, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder);
|
||||
if (payload.mode === 'hook') return handleMultiFrame(payload, clipRules.hook, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder);
|
||||
if (payload.mode === 'keyframe') return handleMultiFrame(payload, clipRules.keyframe, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder);
|
||||
throw new Error('Unknown clip mode');
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +114,6 @@ function buildScreenshotTemplate(payload: ScreenshotPayload, imageNames: string[
|
|||
|
||||
async function handleScreenshot(
|
||||
payload: ScreenshotPayload,
|
||||
providers: Map<string, AIProvider>,
|
||||
rule: ScreenshotClipRule,
|
||||
vaultOps: VaultOps,
|
||||
searchFolder: string,
|
||||
|
|
@ -146,43 +144,14 @@ async function handleScreenshot(
|
|||
const intoVideoNote = !!existing || extractVideoId(payload.url, undefined) != null;
|
||||
const meta: VideoNoteMeta = { platform: detectPlatform(payload.url), videoId: key, videoUrl: payload.url, title: payload.title };
|
||||
|
||||
if (rule.processingMode === 'manual') {
|
||||
const sopContent = readSopSafely(rule.sopPath, vaultOps);
|
||||
if (intoVideoNote) {
|
||||
const r = await upsertVideoNote(meta, screenshotSection(imageNames, sopContent), vaultOps, searchFolder);
|
||||
await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder);
|
||||
return r;
|
||||
}
|
||||
const template = buildScreenshotTemplate(payload, imageNames, sopContent);
|
||||
await vaultOps.create(notePath, template);
|
||||
return { notePath };
|
||||
}
|
||||
|
||||
if (!rule.sopPath || !rule.outputFolder || !rule.providerId) {
|
||||
throw new Error('Screenshot clip rule is not configured');
|
||||
}
|
||||
const provider = providers.get(rule.providerId);
|
||||
if (!provider) throw new Error(`Provider "${rule.providerId}" not found`);
|
||||
if (!isMultiFrameProvider(provider)) {
|
||||
throw new Error(
|
||||
`Provider "${provider.name}" does not support multi-frame analysis. ` +
|
||||
`Use an API provider (Anthropic, OpenAI-compatible, or Gemini).`,
|
||||
);
|
||||
}
|
||||
const frames = payload.images.map((img) => Buffer.from(img, 'base64'));
|
||||
const sopContent = vaultOps.readFileSync(rule.sopPath);
|
||||
const result = await provider.analyzeMultiFrame({
|
||||
frames,
|
||||
sopContent,
|
||||
meta: { url: payload.url },
|
||||
});
|
||||
const markdown = postProcessMarkdown(result);
|
||||
const sopContent = readSopSafely(rule.sopPath, vaultOps);
|
||||
if (intoVideoNote) {
|
||||
const r = await upsertVideoNote(meta, screenshotSection(imageNames, undefined, markdown), vaultOps, searchFolder);
|
||||
const r = await upsertVideoNote(meta, screenshotSection(imageNames, sopContent), vaultOps, searchFolder);
|
||||
await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder);
|
||||
return r;
|
||||
}
|
||||
await vaultOps.create(notePath, markdown);
|
||||
const template = buildScreenshotTemplate(payload, imageNames, sopContent);
|
||||
await vaultOps.create(notePath, template);
|
||||
return { notePath };
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +176,6 @@ async function findNoteByVideoId(
|
|||
|
||||
async function handleThumbnail(
|
||||
payload: ThumbnailPayload,
|
||||
providers: Map<string, AIProvider>,
|
||||
rule: ThumbnailClipRule,
|
||||
vaultOps: VaultOps,
|
||||
): Promise<{ notePath: string; notice?: string }> {
|
||||
|
|
@ -223,19 +191,8 @@ async function handleThumbnail(
|
|||
const imgData = await vaultOps.downloadUrl(payload.thumbnail_url);
|
||||
await vaultOps.createBinary(thumbnailPath, imgData);
|
||||
|
||||
const sopContent = rule.processingMode === 'manual'
|
||||
? readSopSafely(rule.sopPath, vaultOps)
|
||||
: undefined;
|
||||
let aiResult: string | undefined;
|
||||
if (rule.processingMode !== 'manual') {
|
||||
if (!rule.sopPath || !rule.providerId) throw new Error('Thumbnail clip rule is not fully configured (sopPath / providerId missing)');
|
||||
const provider = providers.get(rule.providerId);
|
||||
if (!provider) throw new Error(`Provider "${rule.providerId}" not found`);
|
||||
if (!isMultiFrameProvider(provider)) throw new Error(`Provider "${provider.name}" does not support image analysis. Use an API provider.`);
|
||||
const sop = vaultOps.readFileSync(rule.sopPath);
|
||||
aiResult = postProcessMarkdown(await provider.analyzeMultiFrame({ frames: [Buffer.from(imgData)], sopContent: sop, meta: { video_title: payload.title, channel: payload.channel, url: payload.video_url } }));
|
||||
}
|
||||
const section = coverSection(thumbnailFile, aiResult ?? sopContent);
|
||||
const sopContent = readSopSafely(rule.sopPath, vaultOps);
|
||||
const section = coverSection(thumbnailFile, sopContent);
|
||||
const meta: VideoNoteMeta = {
|
||||
platform: payload.platform,
|
||||
videoId: videoKey(payload.video_url, payload.platform),
|
||||
|
|
@ -248,7 +205,6 @@ async function handleThumbnail(
|
|||
|
||||
async function handleMultiFrame(
|
||||
payload: HookPayload | KeyframePayload,
|
||||
providers: Map<string, AIProvider>,
|
||||
rule: ClipRule,
|
||||
vaultOps: VaultOps,
|
||||
searchFolder: string,
|
||||
|
|
@ -271,70 +227,29 @@ async function handleMultiFrame(
|
|||
channel: payload.mode === 'hook' ? payload.channel : undefined,
|
||||
};
|
||||
|
||||
if (rule.processingMode === 'manual') {
|
||||
const framesDir = rule.framesFolder || rule.outputFolder;
|
||||
await vaultOps.ensureFolder(framesDir);
|
||||
const frameNames: string[] = [];
|
||||
for (let i = 0; i < sampled.length; i++) {
|
||||
const name = `${stem}-f${String(i + 1).padStart(2, '0')}.png`;
|
||||
const bytes = Buffer.from(sampled[i], 'base64');
|
||||
await vaultOps.createBinary(`${framesDir}/${name}`, bytes.buffer as ArrayBuffer);
|
||||
frameNames.push(name);
|
||||
}
|
||||
const sopContent = readSopSafely(rule.sopPath, vaultOps);
|
||||
let section: NewSection;
|
||||
if (payload.mode === 'hook') {
|
||||
section = hookSection(
|
||||
{ url: payload.url, platform, endSeconds: payload.time_range?.end ?? 15, frameNames, transcript: payload.transcript, aiResult: undefined },
|
||||
sopContent,
|
||||
);
|
||||
} else {
|
||||
section = keyframeSection(
|
||||
{ url: payload.url, platform, start: payload.time_range.start, end: payload.time_range.end, frameNames, aiResult: undefined },
|
||||
sopContent,
|
||||
);
|
||||
}
|
||||
const result = await upsertVideoNote(meta, section, vaultOps, searchFolder);
|
||||
await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder);
|
||||
return result;
|
||||
const framesDir = rule.framesFolder || rule.outputFolder;
|
||||
await vaultOps.ensureFolder(framesDir);
|
||||
const frameNames: string[] = [];
|
||||
for (let i = 0; i < sampled.length; i++) {
|
||||
const name = `${stem}-f${String(i + 1).padStart(2, '0')}.png`;
|
||||
const bytes = Buffer.from(sampled[i], 'base64');
|
||||
await vaultOps.createBinary(`${framesDir}/${name}`, bytes.buffer as ArrayBuffer);
|
||||
frameNames.push(name);
|
||||
}
|
||||
|
||||
if (!rule.sopPath || !rule.outputFolder || !rule.providerId) {
|
||||
throw new Error(`Clip rule for "${payload.mode}" is not configured`);
|
||||
}
|
||||
const provider = providers.get(rule.providerId);
|
||||
if (!provider) throw new Error(`Provider "${rule.providerId}" not found`);
|
||||
if (!isMultiFrameProvider(provider)) {
|
||||
throw new Error(
|
||||
`Provider "${payload.mode === 'hook' ? 'hook' : rule.providerId}" does not support multi-frame analysis. ` +
|
||||
`Use an API provider (Anthropic, OpenAI-compatible, or Gemini).`,
|
||||
);
|
||||
}
|
||||
const frames = sampled.map((f) => Buffer.from(f, 'base64'));
|
||||
const sopContent = vaultOps.readFileSync(rule.sopPath);
|
||||
const metaReq: MultiFrameRequest['meta'] = {
|
||||
video_title: payload.video_title,
|
||||
url: payload.url,
|
||||
captured_at: payload.captured_at,
|
||||
...(payload.mode === 'hook'
|
||||
? { channel: payload.channel, platform: payload.platform }
|
||||
: { time_range: payload.time_range }),
|
||||
};
|
||||
const transcript = payload.mode === 'hook' ? payload.transcript : undefined;
|
||||
const result = await provider.analyzeMultiFrame({ frames, transcript, sopContent, meta: metaReq });
|
||||
const aiResult = postProcessMarkdown(result);
|
||||
|
||||
const sopContent = readSopSafely(rule.sopPath, vaultOps);
|
||||
let section: NewSection;
|
||||
if (payload.mode === 'hook') {
|
||||
section = hookSection(
|
||||
{ url: payload.url, platform: payload.platform, endSeconds: payload.time_range?.end ?? 15, frameNames: [], transcript: payload.transcript, aiResult },
|
||||
{ url: payload.url, platform, endSeconds: payload.time_range?.end ?? 15, frameNames, transcript: payload.transcript, aiResult: undefined },
|
||||
sopContent,
|
||||
);
|
||||
} else {
|
||||
section = keyframeSection(
|
||||
{ url: payload.url, platform: 'youtube', start: payload.time_range.start, end: payload.time_range.end, frameNames: [], aiResult },
|
||||
{ url: payload.url, platform, start: payload.time_range.start, end: payload.time_range.end, frameNames, aiResult: undefined },
|
||||
sopContent,
|
||||
);
|
||||
}
|
||||
return upsertVideoNote(meta, section, vaultOps, searchFolder);
|
||||
const result = await upsertVideoNote(meta, section, vaultOps, searchFolder);
|
||||
await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
33
src/main.ts
33
src/main.ts
|
|
@ -2,26 +2,17 @@ import * as http from 'http';
|
|||
import * as fs from 'fs';
|
||||
import { Notice, Plugin, TFile, requestUrl } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, VaultAutopilotSettingTab } from './settings';
|
||||
import { PluginSettings, AIProvider, ProviderConfig } from './types';
|
||||
import { createCLIProvider } from './providers/cli-base';
|
||||
import { createOpenAICompatProvider } from './providers/openai-compat';
|
||||
import { createAnthropicProvider } from './providers/anthropic';
|
||||
import { createGeminiAPIProvider } from './providers/gemini-api';
|
||||
import { detectBinaryPath } from './path-detector';
|
||||
import { PluginSettings } from './types';
|
||||
import { createServer, ClipPayload } from './server';
|
||||
import { routeClip, VaultOps } from './clip-router';
|
||||
import { runStartupChecks } from './startup-check';
|
||||
|
||||
export default class VaultAutopilotPlugin extends Plugin {
|
||||
settings: PluginSettings = DEFAULT_SETTINGS;
|
||||
private server: http.Server | null = null;
|
||||
private providers = new Map<string, AIProvider>();
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new VaultAutopilotSettingTab(this.app, this));
|
||||
this.rebuildProviders();
|
||||
runStartupChecks(this.settings);
|
||||
if (this.settings.httpServer.enabled) this.startServer();
|
||||
}
|
||||
|
||||
|
|
@ -42,13 +33,11 @@ export default class VaultAutopilotPlugin extends Plugin {
|
|||
hook: { ...DEFAULT_SETTINGS.clipRules.hook, ...(loaded?.clipRules?.hook ?? {}) },
|
||||
keyframe: { ...DEFAULT_SETTINGS.clipRules.keyframe, ...(loaded?.clipRules?.keyframe ?? {}) },
|
||||
},
|
||||
providers: loaded?.providers ?? DEFAULT_SETTINGS.providers,
|
||||
};
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
this.rebuildProviders();
|
||||
}
|
||||
|
||||
restartServer(): void {
|
||||
|
|
@ -57,24 +46,6 @@ export default class VaultAutopilotPlugin extends Plugin {
|
|||
if (this.settings.httpServer.enabled) this.startServer();
|
||||
}
|
||||
|
||||
private rebuildProviders(): void {
|
||||
this.providers.clear();
|
||||
for (const config of this.settings.providers) {
|
||||
this.providers.set(config.id, this.buildProvider(config));
|
||||
}
|
||||
}
|
||||
|
||||
private buildProvider(config: ProviderConfig): AIProvider {
|
||||
if (config.type === 'cli') {
|
||||
const resolvedBin = detectBinaryPath(config.cliType, config.bin);
|
||||
return createCLIProvider({ ...config, bin: resolvedBin });
|
||||
}
|
||||
if (config.type === 'openai-compat') return createOpenAICompatProvider(config);
|
||||
if (config.type === 'anthropic') return createAnthropicProvider(config);
|
||||
if (config.type === 'gemini-api') return createGeminiAPIProvider(config);
|
||||
throw new Error(`Unknown provider type: ${(config as any).type}`);
|
||||
}
|
||||
|
||||
private async ensureFolder(folderPath: string): Promise<void> {
|
||||
const parts = folderPath.split('/');
|
||||
let current = '';
|
||||
|
|
@ -122,7 +93,7 @@ export default class VaultAutopilotPlugin extends Plugin {
|
|||
this.server = createServer(
|
||||
port,
|
||||
async (payload) => {
|
||||
const { notePath, notice } = await routeClip(payload, this.providers, this.settings.clipRules, vaultOps);
|
||||
const { notePath, notice } = await routeClip(payload, this.settings.clipRules, vaultOps);
|
||||
const obsidianUrl = notePath
|
||||
? `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(notePath)}`
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const HOME = os.homedir();
|
||||
|
||||
export const KNOWN_PATHS: Record<string, string[]> = {
|
||||
claude: [
|
||||
// Mac - official installer
|
||||
path.join(HOME, '.claude', 'local', 'bin', 'claude'),
|
||||
// Homebrew / manual
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
path.join(HOME, '.local', 'bin', 'claude'),
|
||||
// Windows
|
||||
path.join(process.env['APPDATA'] || '', 'Claude', 'claude.exe'),
|
||||
path.join(process.env['LOCALAPPDATA'] || '', 'Programs', 'Claude', 'claude.exe'),
|
||||
],
|
||||
gemini: [
|
||||
// npm global
|
||||
'/usr/local/bin/gemini',
|
||||
'/opt/homebrew/bin/gemini',
|
||||
path.join(HOME, '.npm-global', 'bin', 'gemini'),
|
||||
path.join(HOME, 'node_modules', '.bin', 'gemini'),
|
||||
// Windows npm global
|
||||
path.join(process.env['APPDATA'] || '', 'npm', 'gemini.cmd'),
|
||||
],
|
||||
codex: [
|
||||
'/usr/local/bin/codex',
|
||||
'/opt/homebrew/bin/codex',
|
||||
path.join(HOME, '.npm-global', 'bin', 'codex'),
|
||||
// Windows
|
||||
path.join(process.env['APPDATA'] || '', 'npm', 'codex.cmd'),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the resolved path for a CLI binary.
|
||||
* Priority: configured path → known installation paths → bare name (rely on PATH at spawn time).
|
||||
*/
|
||||
export function detectBinaryPath(cliType: string, configured: string): string {
|
||||
if (configured && fs.existsSync(configured)) return configured;
|
||||
|
||||
const candidates = KNOWN_PATHS[cliType] || [];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return cliType; // fall back to bare name; spawn will look in PATH
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { AIProvider, AnalysisRequest, AnthropicProviderConfig, MultiFrameRequest, MultiFrameProvider } from '../types';
|
||||
|
||||
function imageMimeType(filePath: string): 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const map: Record<string, 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
||||
};
|
||||
return map[ext] ?? 'image/png';
|
||||
}
|
||||
|
||||
export function createAnthropicProvider(config: AnthropicProviderConfig): MultiFrameProvider {
|
||||
const client = new Anthropic({ apiKey: config.apiKey });
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
name: `Anthropic (${config.model})`,
|
||||
|
||||
async analyze(req: AnalysisRequest): Promise<string> {
|
||||
const userContent: any[] = req.fileType === 'image'
|
||||
? [
|
||||
{ type: 'image', source: { type: 'base64', media_type: imageMimeType(req.filePath), data: req.fileContent.toString('base64') } },
|
||||
{ type: 'text', text: buildContext(req) },
|
||||
]
|
||||
: [{ type: 'text', text: `${buildContext(req)}\n\n${req.fileContent.toString('utf8')}` }];
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: config.model,
|
||||
max_tokens: 4096,
|
||||
system: req.sopContent,
|
||||
messages: [{ role: 'user', content: userContent }],
|
||||
});
|
||||
|
||||
const textBlock = response.content.find((b: any) => b.type === 'text');
|
||||
if (!textBlock || textBlock.type !== 'text') throw new Error('Anthropic returned no text block');
|
||||
return (textBlock as any).text.trim();
|
||||
},
|
||||
|
||||
async analyzeMultiFrame(req: MultiFrameRequest): Promise<string> {
|
||||
const imageBlocks: any[] = req.frames.map((frame) => ({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: frame.toString('base64') },
|
||||
}));
|
||||
const response = await client.messages.create({
|
||||
model: config.model,
|
||||
max_tokens: 4096,
|
||||
system: req.sopContent,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [...imageBlocks, { type: 'text', text: buildMultiFrameContext(req) }],
|
||||
}],
|
||||
});
|
||||
const textBlock = response.content.find((b: any) => b.type === 'text');
|
||||
if (!textBlock || textBlock.type !== 'text') throw new Error('Anthropic returned no text block');
|
||||
return (textBlock as any).text.trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(req: AnalysisRequest): string {
|
||||
const parts: string[] = [];
|
||||
if (req.meta.source_url) parts.push(`Source URL: ${req.meta.source_url}`);
|
||||
if (req.meta.title) parts.push(`Title: ${req.meta.title}`);
|
||||
return parts.join('\n') || 'Analyze the file.';
|
||||
}
|
||||
|
||||
function buildMultiFrameContext(req: MultiFrameRequest): string {
|
||||
const parts: string[] = [];
|
||||
if (req.meta.video_title) parts.push(`Video: ${req.meta.video_title}`);
|
||||
if (req.meta.channel) parts.push(`Channel: ${req.meta.channel}`);
|
||||
if (req.meta.platform) parts.push(`Platform: ${req.meta.platform}`);
|
||||
if (req.meta.url) parts.push(`URL: ${req.meta.url}`);
|
||||
if (req.meta.time_range) parts.push(`Time range: ${req.meta.time_range.start}s–${req.meta.time_range.end}s`);
|
||||
if (req.meta.captured_at) parts.push(`Captured: ${req.meta.captured_at}`);
|
||||
if (req.transcript) parts.push(`\nTranscript:\n${req.transcript}`);
|
||||
return parts.join('\n') || 'Analyze the frames.';
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { AIProvider, AnalysisRequest, CLIProviderConfig } from '../types';
|
||||
|
||||
// Each CLI provider has its own non-interactive flags.
|
||||
// Prompt is passed via stdin to avoid special-character parsing issues with shell arguments.
|
||||
// NOTE: gemini and codex non-interactive flags verified against their CLIs at time of writing.
|
||||
// If a future CLI version changes flags, update CLI_FLAGS here.
|
||||
const CLI_FLAGS: Record<string, string[]> = {
|
||||
claude: ['-p', '--allowedTools', 'Read'],
|
||||
gemini: ['-p'],
|
||||
codex: ['--quiet'],
|
||||
};
|
||||
|
||||
function buildPrompt(req: AnalysisRequest): string {
|
||||
const fileRef = req.fileType === 'image'
|
||||
? `Image file path: ${req.filePath}`
|
||||
: `File content:\n\n${req.fileContent.toString('utf8')}`;
|
||||
|
||||
return [
|
||||
'--- SOP / INSTRUCTIONS ---',
|
||||
req.sopContent,
|
||||
'--- FILE TO ANALYZE ---',
|
||||
fileRef,
|
||||
req.meta.source_url ? `Source URL: ${req.meta.source_url}` : '',
|
||||
req.meta.title ? `Title hint: ${req.meta.title}` : '',
|
||||
'--- OUTPUT INSTRUCTIONS ---',
|
||||
'Output the complete note as a markdown code block (```markdown ... ```).',
|
||||
'Do not add any explanation outside the code block.',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function extractMarkdown(stdout: string): string {
|
||||
const match = stdout.match(/```markdown\r?\n([\s\S]*?)```/);
|
||||
return match ? match[1].trim() : stdout.trim();
|
||||
}
|
||||
|
||||
export function createCLIProvider(config: CLIProviderConfig): AIProvider {
|
||||
const flags = CLI_FLAGS[config.cliType];
|
||||
if (!flags) throw new Error(`Unknown CLI type: ${config.cliType}`);
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
name: `CLI: ${config.cliType} (${config.bin})`,
|
||||
|
||||
analyze(req: AnalysisRequest): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const prompt = buildPrompt(req);
|
||||
const proc = spawn(config.bin, flags, { timeout: 300_000 });
|
||||
|
||||
// Send prompt via stdin — avoids special-character parsing issues with -p argument
|
||||
proc.stdin?.write(prompt, 'utf8');
|
||||
proc.stdin?.end();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
||||
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
proc.on('error', (err) => reject(err));
|
||||
proc.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${config.cliType} timed out after 300s`));
|
||||
} else if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `${config.cliType} exited with code ${code}`));
|
||||
} else {
|
||||
resolve(extractMarkdown(stdout));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { AIProvider, AnalysisRequest, GeminiAPIProviderConfig, MultiFrameRequest, MultiFrameProvider } from '../types';
|
||||
|
||||
function imageMimeType(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const map: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
||||
};
|
||||
return map[ext] ?? 'image/png';
|
||||
}
|
||||
|
||||
export function createGeminiAPIProvider(config: GeminiAPIProviderConfig): MultiFrameProvider {
|
||||
const genAI = new GoogleGenerativeAI(config.apiKey);
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
name: `Gemini API (${config.model})`,
|
||||
|
||||
async analyze(req: AnalysisRequest): Promise<string> {
|
||||
const model = genAI.getGenerativeModel({ model: config.model });
|
||||
const parts: any[] = [{ text: req.sopContent + '\n\n' + buildContext(req) }];
|
||||
|
||||
if (req.fileType === 'image') {
|
||||
parts.push({ inlineData: { mimeType: imageMimeType(req.filePath), data: req.fileContent.toString('base64') } });
|
||||
} else {
|
||||
parts[0].text += `\n\nFile content:\n${req.fileContent.toString('utf8')}`;
|
||||
}
|
||||
|
||||
const result = await model.generateContent(parts);
|
||||
return result.response.text().trim();
|
||||
},
|
||||
|
||||
async analyzeMultiFrame(req: MultiFrameRequest): Promise<string> {
|
||||
const model = genAI.getGenerativeModel({ model: config.model });
|
||||
const parts: any[] = [
|
||||
...req.frames.map((frame) => ({
|
||||
inlineData: { mimeType: 'image/png', data: frame.toString('base64') },
|
||||
})),
|
||||
{ text: req.sopContent + '\n\n' + buildMultiFrameContext(req) },
|
||||
];
|
||||
const result = await model.generateContent(parts);
|
||||
return result.response.text().trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(req: AnalysisRequest): string {
|
||||
const parts: string[] = [];
|
||||
if (req.meta.source_url) parts.push(`Source URL: ${req.meta.source_url}`);
|
||||
if (req.meta.title) parts.push(`Title: ${req.meta.title}`);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function buildMultiFrameContext(req: MultiFrameRequest): string {
|
||||
const parts: string[] = [];
|
||||
if (req.meta.video_title) parts.push(`Video: ${req.meta.video_title}`);
|
||||
if (req.meta.channel) parts.push(`Channel: ${req.meta.channel}`);
|
||||
if (req.meta.platform) parts.push(`Platform: ${req.meta.platform}`);
|
||||
if (req.meta.url) parts.push(`URL: ${req.meta.url}`);
|
||||
if (req.meta.time_range) parts.push(`Time range: ${req.meta.time_range.start}s–${req.meta.time_range.end}s`);
|
||||
if (req.meta.captured_at) parts.push(`Captured: ${req.meta.captured_at}`);
|
||||
if (req.transcript) parts.push(`\nTranscript:\n${req.transcript}`);
|
||||
return parts.join('\n') || 'Analyze the frames.';
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import OpenAI from 'openai';
|
||||
import { AIProvider, AnalysisRequest, OpenAICompatProviderConfig, MultiFrameRequest, MultiFrameProvider } from '../types';
|
||||
|
||||
function imageToDataUrl(buf: Buffer, filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || 'png';
|
||||
const mime: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
|
||||
return `data:${mime[ext] ?? 'image/png'};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function createOpenAICompatProvider(config: OpenAICompatProviderConfig): MultiFrameProvider {
|
||||
const client = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
dangerouslyAllowBrowser: true, // required in Electron environment
|
||||
});
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
name: `${config.label} (${config.model})`,
|
||||
|
||||
async analyze(req: AnalysisRequest): Promise<string> {
|
||||
const systemPrompt = req.sopContent;
|
||||
|
||||
const userContent = req.fileType === 'image'
|
||||
? [
|
||||
{ type: 'text' as const, text: buildTextContext(req) },
|
||||
{ type: 'image_url' as const, image_url: { url: imageToDataUrl(req.fileContent, req.filePath) } },
|
||||
]
|
||||
: buildTextContext(req);
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
max_tokens: 4096,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) throw new Error('API returned no content');
|
||||
return content.trim();
|
||||
},
|
||||
|
||||
async analyzeMultiFrame(req: MultiFrameRequest): Promise<string> {
|
||||
const imageContent = req.frames.map((frame) => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: `data:image/png;base64,${frame.toString('base64')}` },
|
||||
}));
|
||||
const response = await client.chat.completions.create({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{ role: 'system', content: req.sopContent },
|
||||
{ role: 'user', content: [...imageContent, { type: 'text' as const, text: buildMultiFrameContext(req) }] },
|
||||
],
|
||||
max_tokens: 4096,
|
||||
});
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) throw new Error('API returned no content');
|
||||
return content.trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextContext(req: AnalysisRequest): string {
|
||||
const parts: string[] = [];
|
||||
if (req.meta.source_url) parts.push(`Source URL: ${req.meta.source_url}`);
|
||||
if (req.meta.title) parts.push(`Title: ${req.meta.title}`);
|
||||
if (req.fileType === 'text') parts.push(`\nFile content:\n${req.fileContent.toString('utf8')}`);
|
||||
return parts.join('\n') || 'Analyze the attached file.';
|
||||
}
|
||||
|
||||
function buildMultiFrameContext(req: MultiFrameRequest): string {
|
||||
const parts: string[] = [];
|
||||
if (req.meta.video_title) parts.push(`Video: ${req.meta.video_title}`);
|
||||
if (req.meta.channel) parts.push(`Channel: ${req.meta.channel}`);
|
||||
if (req.meta.platform) parts.push(`Platform: ${req.meta.platform}`);
|
||||
if (req.meta.url) parts.push(`URL: ${req.meta.url}`);
|
||||
if (req.meta.time_range) parts.push(`Time range: ${req.meta.time_range.start}s–${req.meta.time_range.end}s`);
|
||||
if (req.meta.captured_at) parts.push(`Captured: ${req.meta.captured_at}`);
|
||||
if (req.transcript) parts.push(`\nTranscript:\n${req.transcript}`);
|
||||
return parts.join('\n') || 'Analyze the frames.';
|
||||
}
|
||||
176
src/settings.ts
176
src/settings.ts
|
|
@ -1,10 +1,8 @@
|
|||
import * as crypto from 'crypto';
|
||||
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import type VaultAutopilotPlugin from './main';
|
||||
import { PluginSettings, ProviderConfig, ScreenshotClipRule, ThumbnailClipRule } from './types';
|
||||
import { PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types';
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
providers: [],
|
||||
httpServer: {
|
||||
enabled: true,
|
||||
port: 27183,
|
||||
|
|
@ -46,44 +44,6 @@ export class VaultAutopilotSettingTab extends PluginSettingTab {
|
|||
if (n > 1024 && n < 65536) { this.plugin.settings.httpServer.port = n; await this.plugin.saveSettings(); }
|
||||
}));
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
new Setting(containerEl).setName('AI Providers').setHeading();
|
||||
new Setting(containerEl)
|
||||
.setName('Add provider')
|
||||
.setDesc('Configure at least one provider before creating rules.')
|
||||
.addDropdown(d => d
|
||||
.addOption('cli-claude', 'CLI: claude (Claude Code subscription)')
|
||||
.addOption('cli-gemini', 'CLI: gemini (Google account, free)')
|
||||
.addOption('cli-codex', 'CLI: codex (ChatGPT Plus subscription)')
|
||||
.addOption('openai-compat', 'API: OpenAI-compatible (OpenRouter / OpenAI / Grok / DeepSeek / Ollama)')
|
||||
.addOption('anthropic', 'API: Anthropic (Claude API)')
|
||||
.addOption('gemini-api', 'API: Google Gemini API')
|
||||
)
|
||||
.addButton(b => b.setButtonText('Add').onClick(async () => {
|
||||
const sel = containerEl.querySelector('select') as HTMLSelectElement;
|
||||
if (!sel) return;
|
||||
const type = sel.value;
|
||||
const id = crypto.randomBytes(4).toString('hex');
|
||||
let newProvider: ProviderConfig;
|
||||
if (type === 'openai-compat') {
|
||||
newProvider = { id, type: 'openai-compat', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', apiKey: '', model: '' };
|
||||
} else if (type === 'anthropic') {
|
||||
newProvider = { id, type: 'anthropic', apiKey: '', model: 'claude-sonnet-4-6' };
|
||||
} else if (type === 'gemini-api') {
|
||||
newProvider = { id, type: 'gemini-api', apiKey: '', model: 'gemini-1.5-flash' };
|
||||
} else {
|
||||
const cliType = type.replace('cli-', '') as 'claude' | 'gemini' | 'codex';
|
||||
newProvider = { id, type: 'cli', cliType, bin: '' };
|
||||
}
|
||||
this.plugin.settings.providers.push(newProvider);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
for (const [i, prov] of this.plugin.settings.providers.entries()) {
|
||||
this.renderProvider(containerEl, prov, i);
|
||||
}
|
||||
|
||||
// ── Clip Rules ──────────────────────────────────────────────────────────────
|
||||
new Setting(containerEl).setName('Clip Rules').setHeading();
|
||||
|
||||
|
|
@ -96,18 +56,6 @@ export class VaultAutopilotSettingTab extends PluginSettingTab {
|
|||
for (const mode of ['hook', 'keyframe'] as const) {
|
||||
const label = mode === 'hook' ? 'Hook Analysis' : 'Keyframe Analysis';
|
||||
new Setting(containerEl).setName(label).setHeading();
|
||||
new Setting(containerEl)
|
||||
.setName('Processing mode')
|
||||
.setDesc('Auto: vault-autopilot calls AI and writes the note. Manual: saves frames + template, you trigger analysis in Obsidian.')
|
||||
.addDropdown(d => d
|
||||
.addOption('manual', 'Manual (save frames + template)')
|
||||
.addOption('auto', 'Auto (call AI provider)')
|
||||
.setValue(this.plugin.settings.clipRules[mode].processingMode)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules[mode].processingMode = v as 'auto' | 'manual';
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName('Max frames to save')
|
||||
.setDesc('How many frames to sample and save (1–20). Default: 5.')
|
||||
|
|
@ -120,17 +68,15 @@ export class VaultAutopilotSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}
|
||||
}));
|
||||
if (this.plugin.settings.clipRules[mode].processingMode === 'manual') {
|
||||
new Setting(containerEl)
|
||||
.setName('Frames folder')
|
||||
.setDesc('Vault-relative path where frame images are saved. Default: Assets/images')
|
||||
.addText(t => t
|
||||
.setValue(this.plugin.settings.clipRules[mode].framesFolder)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules[mode].framesFolder = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
new Setting(containerEl)
|
||||
.setName('Frames folder')
|
||||
.setDesc('Vault-relative path where frame images are saved. Default: Assets/images')
|
||||
.addText(t => t
|
||||
.setValue(this.plugin.settings.clipRules[mode].framesFolder)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules[mode].framesFolder = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName('SOP / prompt path')
|
||||
.setDesc('Absolute path to the markdown SOP file.')
|
||||
|
|
@ -149,38 +95,11 @@ export class VaultAutopilotSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.clipRules[mode].outputFolder = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
if (this.plugin.settings.clipRules[mode].processingMode === 'auto') {
|
||||
new Setting(containerEl)
|
||||
.setName('Provider')
|
||||
.setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).')
|
||||
.addDropdown(d => {
|
||||
this.plugin.settings.providers.forEach(p =>
|
||||
d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type)
|
||||
);
|
||||
d.setValue(this.plugin.settings.clipRules[mode].providerId)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules[mode].providerId = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderThumbnailClipRule(el: HTMLElement): void {
|
||||
const rule: ThumbnailClipRule = this.plugin.settings.clipRules.thumbnail;
|
||||
new Setting(el)
|
||||
.setName('Processing mode')
|
||||
.setDesc('Auto: downloads thumbnail + calls AI to analyze cover. Manual: downloads thumbnail + generates note template.')
|
||||
.addDropdown(d => d
|
||||
.addOption('manual', 'Manual (download + template)')
|
||||
.addOption('auto', 'Auto (download + call AI provider)')
|
||||
.setValue(rule.processingMode)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules.thumbnail.processingMode = v as 'auto' | 'manual';
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
new Setting(el)
|
||||
.setName('Output folder')
|
||||
.setDesc('Vault-relative path for generated notes. Default: Content Creation/Great Videos')
|
||||
|
|
@ -208,37 +127,10 @@ export class VaultAutopilotSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.clipRules.thumbnail.sopPath = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
if (rule.processingMode === 'auto') {
|
||||
new Setting(el)
|
||||
.setName('Provider')
|
||||
.setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).')
|
||||
.addDropdown(d => {
|
||||
this.plugin.settings.providers.forEach(p =>
|
||||
d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type)
|
||||
);
|
||||
d.setValue(rule.providerId)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules.thumbnail.providerId = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderScreenshotClipRule(el: HTMLElement): void {
|
||||
const rule: ScreenshotClipRule = this.plugin.settings.clipRules.screenshot;
|
||||
new Setting(el)
|
||||
.setName('Processing mode')
|
||||
.setDesc('Auto: vault-autopilot calls AI and writes the note. Manual: saves images + template, you trigger analysis in Obsidian.')
|
||||
.addDropdown(d => d
|
||||
.addOption('manual', 'Manual (save images + template)')
|
||||
.addOption('auto', 'Auto (call AI provider)')
|
||||
.setValue(rule.processingMode)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules.screenshot.processingMode = v as 'auto' | 'manual';
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
new Setting(el)
|
||||
.setName('Frames folder')
|
||||
.setDesc('Vault-relative path where screenshot images are saved. Default: Assets/images')
|
||||
|
|
@ -266,51 +158,5 @@ export class VaultAutopilotSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.clipRules.screenshot.outputFolder = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
if (rule.processingMode === 'auto') {
|
||||
new Setting(el)
|
||||
.setName('Provider')
|
||||
.setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).')
|
||||
.addDropdown(d => {
|
||||
this.plugin.settings.providers.forEach(p =>
|
||||
d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type)
|
||||
);
|
||||
d.setValue(rule.providerId)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.clipRules.screenshot.providerId = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderProvider(el: HTMLElement, prov: ProviderConfig, i: number): void {
|
||||
const label = prov.type === 'cli' ? `CLI: ${prov.cliType}` : prov.type === 'openai-compat' ? `API: ${prov.label}` : `API: ${prov.type}`;
|
||||
new Setting(el).setName(label).setHeading();
|
||||
|
||||
if (prov.type === 'cli') {
|
||||
new Setting(el)
|
||||
.setName('Binary path')
|
||||
.setDesc(`Leave empty to auto-detect. Example: /usr/local/bin/${prov.cliType}`)
|
||||
.addText(t => t.setValue(prov.bin).onChange(async v => {
|
||||
(this.plugin.settings.providers[i] as any).bin = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
if (prov.type === 'openai-compat') {
|
||||
new Setting(el).setName('Label').addText(t => t.setValue(prov.label).onChange(async v => { (this.plugin.settings.providers[i] as any).label = v; await this.plugin.saveSettings(); }));
|
||||
new Setting(el).setName('Base URL').setDesc('e.g. https://openrouter.ai/api/v1').addText(t => t.setValue(prov.baseUrl).onChange(async v => { (this.plugin.settings.providers[i] as any).baseUrl = v.trim(); await this.plugin.saveSettings(); }));
|
||||
new Setting(el).setName('API Key').addText(t => t.setValue(prov.apiKey).onChange(async v => { (this.plugin.settings.providers[i] as any).apiKey = v.trim(); await this.plugin.saveSettings(); }));
|
||||
new Setting(el).setName('Model').setDesc('Must support vision for image analysis. e.g. gpt-4o, openai/gpt-4o').addText(t => t.setValue(prov.model).onChange(async v => { (this.plugin.settings.providers[i] as any).model = v.trim(); await this.plugin.saveSettings(); }));
|
||||
}
|
||||
if (prov.type === 'anthropic' || prov.type === 'gemini-api') {
|
||||
new Setting(el).setName('API Key').addText(t => t.setValue(prov.apiKey).onChange(async v => { (this.plugin.settings.providers[i] as any).apiKey = v.trim(); await this.plugin.saveSettings(); }));
|
||||
new Setting(el).setName('Model').addText(t => t.setValue(prov.model).onChange(async v => { (this.plugin.settings.providers[i] as any).model = v.trim(); await this.plugin.saveSettings(); }));
|
||||
}
|
||||
new Setting(el).addButton(b => b.setButtonText('Remove provider').onClick(async () => {
|
||||
this.plugin.settings.providers.splice(i, 1);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import * as fs from 'fs';
|
||||
import { Notice } from 'obsidian';
|
||||
import { detectBinaryPath } from './path-detector';
|
||||
import { PluginSettings } from './types';
|
||||
|
||||
/**
|
||||
* Run on plugin load. Shows a Notice for each misconfiguration found.
|
||||
* Returns list of problem strings (empty = all good).
|
||||
*/
|
||||
export function runStartupChecks(settings: PluginSettings): string[] {
|
||||
const problems: string[] = [];
|
||||
|
||||
// Check each provider
|
||||
for (const prov of settings.providers) {
|
||||
if (prov.type === 'cli') {
|
||||
const resolved = detectBinaryPath(prov.cliType, prov.bin);
|
||||
if (resolved === prov.cliType) {
|
||||
// fell back to bare name — binary not found in known paths
|
||||
problems.push(`CLI provider "${prov.cliType}": binary not found. Open plugin settings and set the full path.`);
|
||||
}
|
||||
} else if (prov.type === 'anthropic' || prov.type === 'gemini-api') {
|
||||
if (!prov.apiKey) problems.push(`Provider "${prov.type}": API key is empty.`);
|
||||
if (!prov.model) problems.push(`Provider "${prov.type}": model is empty.`);
|
||||
} else if (prov.type === 'openai-compat') {
|
||||
if (!prov.apiKey) problems.push(`Provider "${prov.label}": API key is empty.`);
|
||||
if (!prov.model) problems.push(`Provider "${prov.label}": model is empty.`);
|
||||
if (!prov.baseUrl) problems.push(`Provider "${prov.label}": base URL is empty.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show as Notice (each problem gets its own so they don't stack-truncate)
|
||||
for (const p of problems) {
|
||||
new Notice(`Vault Autopilot: ${p}`, 10000);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
84
src/types.ts
84
src/types.ts
|
|
@ -1,90 +1,9 @@
|
|||
export type FileType = 'image' | 'text';
|
||||
|
||||
export interface FileMeta {
|
||||
source_url?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AnalysisRequest {
|
||||
filePath: string; // absolute path to the file on disk
|
||||
fileType: FileType;
|
||||
fileContent: Buffer; // raw bytes
|
||||
sopContent: string; // contents of the SOP/prompt file
|
||||
meta: FileMeta;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
analyze(request: AnalysisRequest): Promise<string>; // returns markdown note content
|
||||
}
|
||||
|
||||
export interface MultiFrameRequest {
|
||||
frames: Buffer[];
|
||||
transcript?: string;
|
||||
sopContent: string;
|
||||
meta: {
|
||||
video_title?: string;
|
||||
channel?: string;
|
||||
platform?: string;
|
||||
url?: string;
|
||||
time_range?: { start: number; end: number };
|
||||
captured_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MultiFrameProvider extends AIProvider {
|
||||
analyzeMultiFrame(req: MultiFrameRequest): Promise<string>;
|
||||
}
|
||||
|
||||
export function isMultiFrameProvider(p: AIProvider): p is MultiFrameProvider {
|
||||
return typeof (p as any).analyzeMultiFrame === 'function';
|
||||
}
|
||||
|
||||
// ── Provider configs ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface CLIProviderConfig {
|
||||
id: string;
|
||||
type: 'cli';
|
||||
cliType: 'claude' | 'gemini' | 'codex';
|
||||
bin: string; // resolved absolute path or command name
|
||||
}
|
||||
|
||||
export interface OpenAICompatProviderConfig {
|
||||
id: string;
|
||||
type: 'openai-compat';
|
||||
label: string; // display name e.g. "OpenRouter", "Grok"
|
||||
baseUrl: string; // e.g. https://openrouter.ai/api/v1
|
||||
apiKey: string;
|
||||
model: string; // e.g. gpt-4o, meta-llama/llama-3.2-90b-vision
|
||||
}
|
||||
|
||||
export interface AnthropicProviderConfig {
|
||||
id: string;
|
||||
type: 'anthropic';
|
||||
apiKey: string;
|
||||
model: string; // e.g. claude-sonnet-4-6
|
||||
}
|
||||
|
||||
export interface GeminiAPIProviderConfig {
|
||||
id: string;
|
||||
type: 'gemini-api';
|
||||
apiKey: string;
|
||||
model: string; // e.g. gemini-1.5-flash
|
||||
}
|
||||
|
||||
export type ProviderConfig =
|
||||
| CLIProviderConfig
|
||||
| OpenAICompatProviderConfig
|
||||
| AnthropicProviderConfig
|
||||
| GeminiAPIProviderConfig;
|
||||
|
||||
// ── Rules ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClipRule {
|
||||
sopPath: string; // absolute path to SOP markdown file
|
||||
outputFolder: string; // vault-relative path
|
||||
providerId: string; // must match a ProviderConfig.id
|
||||
providerId: string;
|
||||
processingMode: 'auto' | 'manual';
|
||||
maxFrames: number;
|
||||
framesFolder: string; // vault-relative path for frame PNGs (e.g. "Assets/images")
|
||||
|
|
@ -114,7 +33,6 @@ export interface HttpServerSettings {
|
|||
}
|
||||
|
||||
export interface PluginSettings {
|
||||
providers: ProviderConfig[];
|
||||
httpServer: HttpServerSettings;
|
||||
clipRules: {
|
||||
thumbnail: ThumbnailClipRule;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,7 @@
|
|||
import { routeClip, VaultOps } from '../src/clip-router';
|
||||
import { AIProvider, ClipRule, ScreenshotClipRule, ThumbnailClipRule, isMultiFrameProvider } from '../src/types';
|
||||
import { ClipRule, ScreenshotClipRule, ThumbnailClipRule } from '../src/types';
|
||||
import { ClipPayload } from '../src/server';
|
||||
|
||||
function makeMultiFrameProvider(id = 'p1') {
|
||||
return {
|
||||
id,
|
||||
name: 'Mock API Provider',
|
||||
analyze: jest.fn(),
|
||||
analyzeMultiFrame: jest.fn().mockResolvedValue('# Analysis\nContent'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSingleProvider(id = 'p2') {
|
||||
return { id, name: 'Mock CLI', analyze: jest.fn() };
|
||||
}
|
||||
|
||||
function makeVaultOps(): jest.Mocked<VaultOps> {
|
||||
return {
|
||||
ensureFolder: jest.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -39,16 +26,14 @@ const screenshotClipRule: ScreenshotClipRule = {
|
|||
};
|
||||
const hookClipRule: ClipRule = {
|
||||
sopPath: '/hook-sop.md', outputFolder: 'Hooks', providerId: 'p1',
|
||||
processingMode: 'auto', maxFrames: 5, framesFolder: 'Assets/images',
|
||||
processingMode: 'manual', maxFrames: 5, framesFolder: 'Assets/images',
|
||||
};
|
||||
const keyframeClipRule: ClipRule = {
|
||||
sopPath: '/kf-sop.md', outputFolder: 'Keyframes', providerId: 'p1',
|
||||
processingMode: 'auto', maxFrames: 5, framesFolder: 'Assets/images',
|
||||
processingMode: 'manual', maxFrames: 5, framesFolder: 'Assets/images',
|
||||
};
|
||||
const clipRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook: hookClipRule, keyframe: keyframeClipRule };
|
||||
|
||||
// ── isMultiFrameProvider ──────────────────────────────────────────────────────
|
||||
|
||||
// ── thumbnail ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('routeClip — thumbnail', () => {
|
||||
|
|
@ -67,7 +52,7 @@ describe('routeClip — thumbnail', () => {
|
|||
|
||||
test('manual: downloads thumbnail and creates note with correct frontmatter', async () => {
|
||||
const vaultOps = makeVaultOps();
|
||||
await routeClip(payload, new Map(), clipRules, vaultOps);
|
||||
await routeClip(payload, clipRules, vaultOps);
|
||||
expect(vaultOps.downloadUrl).toHaveBeenCalledWith(payload.thumbnail_url);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
||||
'Assets/Great Videos/abc123.webp',
|
||||
|
|
@ -86,25 +71,10 @@ describe('routeClip — thumbnail', () => {
|
|||
test('note filename omits the author segment when channel is empty', async () => {
|
||||
const vaultOps = makeVaultOps();
|
||||
const noChannel = { ...payload, channel: '' } as ClipPayload;
|
||||
const result = await routeClip(noChannel, new Map(), clipRules, vaultOps);
|
||||
const result = await routeClip(noChannel, clipRules, vaultOps);
|
||||
expect(result.notePath).toBe('Content Creation/Great Videos/How to Get Rich on Easy Mode.md');
|
||||
});
|
||||
|
||||
test('auto: calls analyzeMultiFrame with thumbnail buffer and writes AI result', async () => {
|
||||
const provider = makeMultiFrameProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', provider as any]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const autoRule: ThumbnailClipRule = { ...thumbnailClipRule, processingMode: 'auto' };
|
||||
await routeClip(payload, providers, { ...clipRules, thumbnail: autoRule }, vaultOps);
|
||||
expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sopContent: '# SOP\nAnalyze this.',
|
||||
meta: expect.objectContaining({ video_title: 'How to Get Rich on Easy Mode' }),
|
||||
}));
|
||||
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
||||
expect(noteContent).toContain('## 🖼️ 封面标题');
|
||||
expect(noteContent).toContain('# Analysis');
|
||||
});
|
||||
|
||||
test('non-YT/Bili platform: no youtube embed, no channel/views in frontmatter, image in cover', async () => {
|
||||
const vaultOps = makeVaultOps();
|
||||
const generic: ClipPayload = {
|
||||
|
|
@ -119,7 +89,7 @@ describe('routeClip — thumbnail', () => {
|
|||
views: null,
|
||||
captured_at: '2026-06-27T00:00:00Z',
|
||||
};
|
||||
await routeClip(generic, new Map(), clipRules, vaultOps);
|
||||
await routeClip(generic, clipRules, vaultOps);
|
||||
const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
||||
expect(noteContent).not.toContain('youtube.com/embed');
|
||||
expect(noteContent).not.toContain('channel: "');
|
||||
|
|
@ -128,15 +98,6 @@ describe('routeClip — thumbnail', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isMultiFrameProvider', () => {
|
||||
test('returns true when analyzeMultiFrame method is present', () => {
|
||||
expect(isMultiFrameProvider(makeMultiFrameProvider())).toBe(true);
|
||||
});
|
||||
test('returns false when analyzeMultiFrame is absent', () => {
|
||||
expect(isMultiFrameProvider(makeSingleProvider())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── screenshot ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('routeClip — screenshot', () => {
|
||||
|
|
@ -148,7 +109,7 @@ describe('routeClip — screenshot', () => {
|
|||
url: 'https://x.com',
|
||||
title: 'My Screenshot',
|
||||
};
|
||||
const result = await routeClip(payload, new Map(), clipRules, vaultOps);
|
||||
const result = await routeClip(payload, clipRules, vaultOps);
|
||||
expect(result.notePath).toMatch(/^Screenshots\/screenshot-.+\.md$/);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledTimes(2);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
||||
|
|
@ -165,32 +126,6 @@ describe('routeClip — screenshot', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('auto: saves images then calls analyzeMultiFrame', async () => {
|
||||
const provider = makeMultiFrameProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', provider as any]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const autoRule: ScreenshotClipRule = { ...screenshotClipRule, processingMode: 'auto' };
|
||||
const payload: ClipPayload = {
|
||||
mode: 'screenshot',
|
||||
images: [Buffer.from('pixels').toString('base64')],
|
||||
url: 'https://x.com',
|
||||
title: 'Auto Shot',
|
||||
};
|
||||
await routeClip(payload, providers, { ...clipRules, screenshot: autoRule }, vaultOps);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Assets/images/'),
|
||||
expect.any(ArrayBuffer),
|
||||
);
|
||||
expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({
|
||||
frames: [Buffer.from('pixels')],
|
||||
sopContent: '# SOP\nAnalyze this.',
|
||||
}));
|
||||
expect(vaultOps.create).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Screenshots\/screenshot-.+\.md/),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
test('backward compat: old image field is normalized to images array', async () => {
|
||||
const vaultOps = makeVaultOps();
|
||||
const payload = {
|
||||
|
|
@ -199,7 +134,7 @@ describe('routeClip — screenshot', () => {
|
|||
url: 'https://x.com',
|
||||
title: 'Old Format',
|
||||
} as unknown as ClipPayload;
|
||||
await routeClip(payload, new Map(), clipRules, vaultOps);
|
||||
await routeClip(payload, clipRules, vaultOps);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledTimes(1);
|
||||
expect(vaultOps.create).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Screenshots\/screenshot-.+\.md/),
|
||||
|
|
@ -208,113 +143,6 @@ describe('routeClip — screenshot', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('routeClip — hook', () => {
|
||||
test('calls analyzeMultiFrame with decoded frames and transcript, creates note in Great Videos', async () => {
|
||||
const provider = makeMultiFrameProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', provider as any]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const payload: ClipPayload = {
|
||||
mode: 'hook',
|
||||
frames: [Buffer.from('frame1').toString('base64'), Buffer.from('frame2').toString('base64')],
|
||||
transcript: 'Hello world',
|
||||
video_title: 'My Hook',
|
||||
url: 'https://yt.com',
|
||||
captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
const result = await routeClip(payload, providers, clipRules, vaultOps);
|
||||
expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({
|
||||
frames: [Buffer.from('frame1'), Buffer.from('frame2')],
|
||||
transcript: 'Hello world',
|
||||
sopContent: '# SOP\nAnalyze this.',
|
||||
}));
|
||||
// Hook now lands in the Great Videos (thumbnail outputFolder) as a merged note
|
||||
expect(result.notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/);
|
||||
expect(vaultOps.create).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Content Creation\/Great Videos\/.+\.md/),
|
||||
expect.stringContaining('## 🎬 内容'),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when hook clip rule has no sopPath configured', async () => {
|
||||
const vaultOps = makeVaultOps();
|
||||
const emptyRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook:{ sopPath: '', outputFolder: 'Hooks', providerId: 'p1', processingMode: 'auto' as const, maxFrames: 5, framesFolder: 'Assets/images' }, keyframe: keyframeClipRule };
|
||||
const payload: ClipPayload = {
|
||||
mode: 'hook', frames: [Buffer.from('f').toString('base64')],
|
||||
video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await expect(routeClip(payload, new Map(), emptyRules, vaultOps))
|
||||
.rejects.toThrow('not configured');
|
||||
});
|
||||
|
||||
test('throws when provider id is not found in providers map', async () => {
|
||||
const vaultOps = makeVaultOps();
|
||||
const payload: ClipPayload = {
|
||||
mode: 'hook', frames: [Buffer.from('f').toString('base64')],
|
||||
video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
// clipRules.hook.providerId is 'p1' but the map is empty
|
||||
await expect(routeClip(payload, new Map(), clipRules, vaultOps))
|
||||
.rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
test('throws when provider does not support multi-frame', async () => {
|
||||
const singleProvider = makeSingleProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', singleProvider]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const payload: ClipPayload = {
|
||||
mode: 'hook', frames: [Buffer.from('f').toString('base64')],
|
||||
video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await expect(routeClip(payload, providers, clipRules, vaultOps))
|
||||
.rejects.toThrow('does not support multi-frame');
|
||||
});
|
||||
|
||||
test('21 raw frames are sampled down to maxFrames before AI call', async () => {
|
||||
const provider = makeMultiFrameProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', provider as any]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const frames = Array(21).fill(Buffer.from('f').toString('base64'));
|
||||
const payload: ClipPayload = {
|
||||
mode: 'hook', frames, video_title: 'V',
|
||||
url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await routeClip(payload, providers, clipRules, vaultOps);
|
||||
const { frames: sentFrames } = (provider.analyzeMultiFrame as jest.Mock).mock.calls[0][0];
|
||||
expect(sentFrames.length).toBeLessThanOrEqual(hookClipRule.maxFrames);
|
||||
});
|
||||
});
|
||||
|
||||
// ── keyframe ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('routeClip — keyframe', () => {
|
||||
test('calls analyzeMultiFrame without transcript and with time_range in meta, creates note in Great Videos', async () => {
|
||||
const provider = makeMultiFrameProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', provider as any]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const payload: ClipPayload = {
|
||||
mode: 'keyframe',
|
||||
frames: [Buffer.from('f1').toString('base64')],
|
||||
video_title: 'My Video',
|
||||
url: 'https://yt.com',
|
||||
time_range: { start: 0, end: 15 },
|
||||
captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
const result = await routeClip(payload, providers, { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook:hookClipRule, keyframe: keyframeClipRule }, vaultOps);
|
||||
expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({
|
||||
transcript: undefined,
|
||||
meta: expect.objectContaining({ time_range: { start: 0, end: 15 } }),
|
||||
}));
|
||||
// Keyframe now lands in the Great Videos (thumbnail outputFolder) as a merged note
|
||||
expect(result.notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/);
|
||||
expect(vaultOps.create).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Content Creation\/Great Videos\/.+\.md/),
|
||||
expect.stringContaining('## ✨ 动效'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── manual mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('routeClip — manual mode (hook)', () => {
|
||||
|
|
@ -331,7 +159,7 @@ describe('routeClip — manual mode (hook)', () => {
|
|||
mode: 'hook', frames, video_title: 'Test Hook',
|
||||
url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await routeClip(payload, new Map(), manualClipRules, vaultOps);
|
||||
await routeClip(payload, manualClipRules, vaultOps);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledTimes(3);
|
||||
expect(vaultOps.createBinary).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Assets\/images\/hook-.+-f01\.png/),
|
||||
|
|
@ -347,7 +175,7 @@ describe('routeClip — manual mode (hook)', () => {
|
|||
video_title: 'My Hook', transcript: 'Hello world',
|
||||
url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await routeClip(payload, new Map(), manualClipRules, vaultOps);
|
||||
await routeClip(payload, manualClipRules, vaultOps);
|
||||
const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
||||
// Hook now goes into the Great Videos (thumbnail outputFolder) as a merged note
|
||||
expect(notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/);
|
||||
|
|
@ -358,19 +186,6 @@ describe('routeClip — manual mode (hook)', () => {
|
|||
expect(noteContent).toContain('![[');
|
||||
expect(noteContent).toContain('Hello world');
|
||||
});
|
||||
|
||||
test('does not call analyzeMultiFrame in manual mode', async () => {
|
||||
const provider = makeMultiFrameProvider('p1');
|
||||
const providers = new Map<string, AIProvider>([['p1', provider as any]]);
|
||||
const vaultOps = makeVaultOps();
|
||||
const payload: ClipPayload = {
|
||||
mode: 'hook',
|
||||
frames: [Buffer.from('f').toString('base64')],
|
||||
video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await routeClip(payload, providers, manualClipRules, vaultOps);
|
||||
expect(provider.analyzeMultiFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeClip — manual mode (keyframe)', () => {
|
||||
|
|
@ -388,7 +203,7 @@ describe('routeClip — manual mode (keyframe)', () => {
|
|||
video_title: 'My Video', url: 'https://youtube.com/watch?v=xyz',
|
||||
time_range: { start: 30, end: 45 }, captured_at: '2026-05-30T18:00:00Z',
|
||||
};
|
||||
await routeClip(payload, new Map(), manualClipRules, vaultOps);
|
||||
await routeClip(payload, manualClipRules, vaultOps);
|
||||
const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0];
|
||||
// Keyframe now goes into Great Videos as a merged note
|
||||
expect(notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/);
|
||||
|
|
@ -426,7 +241,7 @@ describe('routeClip — append to existing Great Videos note', () => {
|
|||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
captured_at: '2026-05-31T00:00:00Z',
|
||||
};
|
||||
await routeClip(payload, new Map(), { ...clipRules, hook: manualHookRule }, vaultOps);
|
||||
await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps);
|
||||
expect(vaultOps.modify).toHaveBeenCalledWith(
|
||||
'Content Creation/Great Videos/note.md',
|
||||
expect.stringContaining('## 🎬 内容'),
|
||||
|
|
@ -450,7 +265,7 @@ describe('routeClip — append to existing Great Videos note', () => {
|
|||
time_range: { start: 10, end: 20 },
|
||||
captured_at: '2026-05-31T00:00:00Z',
|
||||
};
|
||||
await routeClip(payload, new Map(), { ...clipRules, keyframe: manualKeyframeRule }, vaultOps);
|
||||
await routeClip(payload, { ...clipRules, keyframe: manualKeyframeRule }, vaultOps);
|
||||
const [, modifiedContent] = (vaultOps.modify as jest.Mock).mock.calls[0];
|
||||
expect(modifiedContent).toContain('## ✨ 动效');
|
||||
expect(modifiedContent).toContain('dimensions: [封面标题, 动效]');
|
||||
|
|
@ -468,7 +283,7 @@ describe('routeClip — append to existing Great Videos note', () => {
|
|||
url: 'https://www.youtube.com/watch?v=abc123',
|
||||
captured_at: '2026-05-31T00:00:00Z',
|
||||
};
|
||||
const result = await routeClip(payload, new Map(), { ...clipRules, hook: manualHookRule }, vaultOps);
|
||||
const result = await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps);
|
||||
expect(result.notePath).toBe('Content Creation/Great Videos/note.md');
|
||||
});
|
||||
|
||||
|
|
@ -483,7 +298,7 @@ describe('routeClip — append to existing Great Videos note', () => {
|
|||
url: 'https://www.youtube.com/watch?v=newvid',
|
||||
captured_at: '2026-05-31T00:00:00Z',
|
||||
};
|
||||
await routeClip(payload, new Map(), { ...clipRules, hook: manualHookRule }, vaultOps);
|
||||
await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps);
|
||||
expect(vaultOps.modify).not.toHaveBeenCalled();
|
||||
expect(vaultOps.create).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Content Creation/Great Videos/'),
|
||||
|
|
@ -515,8 +330,8 @@ describe('routeClip — unified video note (manual)', () => {
|
|||
|
||||
test('hook then keyframe land in ONE note ordered 内容 before 动效', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
await routeClip(hookPayload, new Map(), manual, v);
|
||||
await routeClip(kfPayload(45, 52), new Map(), manual, v);
|
||||
await routeClip(hookPayload, manual, v);
|
||||
await routeClip(kfPayload(45, 52), manual, v);
|
||||
const paths = Object.keys(store);
|
||||
expect(paths.length).toBe(1);
|
||||
const note = store[paths[0]];
|
||||
|
|
@ -526,8 +341,8 @@ describe('routeClip — unified video note (manual)', () => {
|
|||
|
||||
test('re-capturing hook returns a notice and does not duplicate', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
await routeClip(hookPayload, new Map(), manual, v);
|
||||
const r = await routeClip(hookPayload, new Map(), manual, v);
|
||||
await routeClip(hookPayload, manual, v);
|
||||
const r = await routeClip(hookPayload, manual, v);
|
||||
expect(r.notice).toContain('已存在');
|
||||
const note = store[Object.keys(store)[0]];
|
||||
expect((note.match(/## 🎬 内容/g) || []).length).toBe(1);
|
||||
|
|
@ -536,8 +351,8 @@ describe('routeClip — unified video note (manual)', () => {
|
|||
test('Xiaohongshu hook + keyframe merge into ONE note (link fallback embed)', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
const url = 'https://www.xiaohongshu.com/explore/xhs123abc';
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'XHS', url, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'XHS', url, time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'XHS', url, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
||||
await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'XHS', url, time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
||||
expect(Object.keys(store).length).toBe(1);
|
||||
const note = store[Object.keys(store)[0]];
|
||||
expect(note).toContain('platform: xiaohongshu');
|
||||
|
|
@ -549,8 +364,8 @@ describe('routeClip — unified video note (manual)', () => {
|
|||
|
||||
test('any platform merges by URL: Twitter hook + keyframe → one note', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123?s=20', captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123', time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123?s=20', captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
||||
await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123', time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
||||
expect(Object.keys(store).length).toBe(1);
|
||||
const note = store[Object.keys(store)[0]];
|
||||
expect(note).toContain('video_id: "https://x.com/u/status/123"');
|
||||
|
|
@ -559,45 +374,45 @@ describe('routeClip — unified video note (manual)', () => {
|
|||
|
||||
test('hook with cover_url saves the gallery cover at <video_id>.webp', async () => {
|
||||
const { v } = vaultWithStore();
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, new Map(), manual, v);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, manual, v);
|
||||
expect(v.createBinary).toHaveBeenCalledWith('Assets/Great Videos/abc123.webp', expect.any(ArrayBuffer));
|
||||
});
|
||||
|
||||
test('cover is not re-downloaded when it already exists', async () => {
|
||||
const { v } = vaultWithStore();
|
||||
(v.fileExists as jest.Mock).mockReturnValue(true);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://x/cover.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, new Map(), manual, v);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://x/cover.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, manual, v);
|
||||
expect(v.createBinary).not.toHaveBeenCalledWith('Assets/Great Videos/abc123.webp', expect.anything());
|
||||
});
|
||||
|
||||
test('screenshot folds into the existing video note; otherwise standalone', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
const url = 'https://www.youtube.com/watch?v=abc123';
|
||||
await routeClip(hookPayload, new Map(), manual, v); // creates the video note
|
||||
await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Bee' }, new Map(), manual, v);
|
||||
await routeClip(hookPayload, manual, v); // creates the video note
|
||||
await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Bee' }, manual, v);
|
||||
expect(Object.keys(store).length).toBe(1); // merged, not a new note
|
||||
const note = store[Object.keys(store)[0]];
|
||||
expect(note).toContain('## 📸 截图');
|
||||
expect(note).toContain('dimensions: [内容, 截图]');
|
||||
|
||||
// a screenshot on an un-studied plain webpage stays standalone
|
||||
await routeClip({ mode: 'screenshot', images: ['Zg=='], url: 'https://example.com/random', title: 'Rand' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'screenshot', images: ['Zg=='], url: 'https://example.com/random', title: 'Rand' }, manual, v);
|
||||
expect(Object.keys(store).length).toBe(2);
|
||||
});
|
||||
|
||||
test('two different videos with the same author+title get distinct filenames', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
const base = { mode: 'hook' as const, frames: ['Zg=='], video_title: 'Same Title', channel: 'Ch', captured_at: '2026-06-28T00:00:00Z' };
|
||||
await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid1' } as ClipPayload, new Map(), manual, v);
|
||||
await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid2' } as ClipPayload, new Map(), manual, v);
|
||||
await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid1' } as ClipPayload, manual, v);
|
||||
await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid2' } as ClipPayload, manual, v);
|
||||
expect(Object.keys(store).length).toBe(2); // no overwrite / no crash
|
||||
});
|
||||
|
||||
test('screenshot FIRST on a video page anchors the note (any order)', async () => {
|
||||
const { v, store } = vaultWithStore();
|
||||
const url = 'https://www.youtube.com/watch?v=zzz999';
|
||||
await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Vid - YouTube' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Vid', url, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v);
|
||||
await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Vid - YouTube' }, manual, v);
|
||||
await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Vid', url, captured_at: '2026-06-28T00:00:00Z' }, manual, v);
|
||||
expect(Object.keys(store).length).toBe(1); // screenshot anchored it, hook merged in
|
||||
const note = store[Object.keys(store)[0]];
|
||||
expect(note).toContain('## 📸 截图');
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import * as fs from 'fs';
|
||||
import { detectBinaryPath, KNOWN_PATHS } from '../src/path-detector';
|
||||
|
||||
jest.mock('fs');
|
||||
const mockExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>;
|
||||
|
||||
describe('detectBinaryPath', () => {
|
||||
beforeEach(() => { mockExistsSync.mockReturnValue(false); });
|
||||
|
||||
test('returns configured path if it exists', () => {
|
||||
mockExistsSync.mockImplementation((p) => p === '/custom/claude');
|
||||
expect(detectBinaryPath('claude', '/custom/claude')).toBe('/custom/claude');
|
||||
});
|
||||
|
||||
test('returns first known path that exists when configured path not given', () => {
|
||||
const knownPath = KNOWN_PATHS['claude'][0];
|
||||
mockExistsSync.mockImplementation((p) => p === knownPath);
|
||||
expect(detectBinaryPath('claude', '')).toBe(knownPath);
|
||||
});
|
||||
|
||||
test('returns binary name as fallback when nothing found', () => {
|
||||
expect(detectBinaryPath('claude', '')).toBe('claude');
|
||||
});
|
||||
|
||||
test('KNOWN_PATHS has entries for claude, gemini, codex', () => {
|
||||
expect(KNOWN_PATHS['claude'].length).toBeGreaterThan(0);
|
||||
expect(KNOWN_PATHS['gemini'].length).toBeGreaterThan(0);
|
||||
expect(KNOWN_PATHS['codex'].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { createAnthropicProvider } from '../../src/providers/anthropic';
|
||||
import { AnalysisRequest, MultiFrameRequest } from '../../src/types';
|
||||
|
||||
jest.mock('@anthropic-ai/sdk', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
messages: { create: jest.fn() },
|
||||
})),
|
||||
}));
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
const MockAnthropic = Anthropic as jest.MockedClass<typeof Anthropic>;
|
||||
|
||||
const config = { id: 'a1', type: 'anthropic' as const, apiKey: 'sk-ant-test', model: 'claude-sonnet-4-6' };
|
||||
|
||||
const req: AnalysisRequest = {
|
||||
filePath: '/tmp/test.png', fileType: 'image',
|
||||
fileContent: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
sopContent: 'Analyze the image.', meta: {},
|
||||
};
|
||||
|
||||
describe('analyzeMultiFrame — Anthropic', () => {
|
||||
let mockCreate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreate = jest.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: '# Hook Analysis\nContent' }],
|
||||
});
|
||||
MockAnthropic.mockImplementation(() => ({ messages: { create: mockCreate } }) as any);
|
||||
});
|
||||
|
||||
const multiFrameReq: MultiFrameRequest = {
|
||||
frames: [Buffer.from('frame1'), Buffer.from('frame2')],
|
||||
transcript: 'Hello world',
|
||||
sopContent: 'Analyze the hook.',
|
||||
meta: { video_title: 'My Hook', channel: 'TestChan', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z' },
|
||||
};
|
||||
|
||||
test('returns trimmed text from response', async () => {
|
||||
const p = createAnthropicProvider(config);
|
||||
const result = await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
expect(result).toBe('# Hook Analysis\nContent');
|
||||
});
|
||||
|
||||
test('sends each frame as a separate image content block', async () => {
|
||||
const p = createAnthropicProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userContent = call.messages.find((m: any) => m.role === 'user').content;
|
||||
const imageBlocks = userContent.filter((c: any) => c.type === 'image');
|
||||
expect(imageBlocks).toHaveLength(2);
|
||||
expect(imageBlocks[0].source.data).toBe(Buffer.from('frame1').toString('base64'));
|
||||
expect(imageBlocks[1].source.data).toBe(Buffer.from('frame2').toString('base64'));
|
||||
});
|
||||
|
||||
test('includes transcript in context text block', async () => {
|
||||
const p = createAnthropicProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userContent = call.messages.find((m: any) => m.role === 'user').content;
|
||||
const textBlock = userContent.find((c: any) => c.type === 'text');
|
||||
expect(textBlock.text).toContain('Hello world');
|
||||
});
|
||||
|
||||
test('uses sopContent as system prompt', async () => {
|
||||
const p = createAnthropicProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
expect(call.system).toBe('Analyze the hook.');
|
||||
});
|
||||
|
||||
test('throws when response has no text block', async () => {
|
||||
mockCreate.mockResolvedValue({ content: [] });
|
||||
const p = createAnthropicProvider(config);
|
||||
await expect((p as any).analyzeMultiFrame(multiFrameReq)).rejects.toThrow('no text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAnthropicProvider', () => {
|
||||
let mockCreate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreate = jest.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: '# Note\nContent' }],
|
||||
});
|
||||
MockAnthropic.mockImplementation(() => ({ messages: { create: mockCreate } }) as any);
|
||||
});
|
||||
|
||||
test('returns text from response', async () => {
|
||||
const p = createAnthropicProvider(config);
|
||||
const result = await p.analyze(req);
|
||||
expect(result).toBe('# Note\nContent');
|
||||
});
|
||||
|
||||
test('sends image as base64 source block', async () => {
|
||||
const p = createAnthropicProvider(config);
|
||||
await p.analyze(req);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userMsg = call.messages.find((m: any) => m.role === 'user');
|
||||
expect(userMsg.content.some((c: any) => c.type === 'image')).toBe(true);
|
||||
});
|
||||
|
||||
test('throws when response has no text block', async () => {
|
||||
mockCreate.mockResolvedValue({ content: [] });
|
||||
await expect(createAnthropicProvider(config).analyze(req)).rejects.toThrow('no text');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { createCLIProvider } from '../../src/providers/cli-base';
|
||||
import { AnalysisRequest } from '../../src/types';
|
||||
|
||||
jest.mock('child_process');
|
||||
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
||||
|
||||
function makeMockProc(stdout: string, stderr: string, code: number | null, signal: string | null = null) {
|
||||
const proc: any = {
|
||||
stdin: { write: jest.fn(), end: jest.fn() },
|
||||
stdout: { on: jest.fn() },
|
||||
stderr: { on: jest.fn() },
|
||||
on: jest.fn(),
|
||||
};
|
||||
proc.stdout.on.mockImplementation((ev: string, cb: (d: Buffer) => void) => {
|
||||
if (ev === 'data') cb(Buffer.from(stdout));
|
||||
});
|
||||
proc.stderr.on.mockImplementation((ev: string, cb: (d: Buffer) => void) => {
|
||||
if (ev === 'data') cb(Buffer.from(stderr));
|
||||
});
|
||||
proc.on.mockImplementation((ev: string, cb: (code: number | null, signal: string | null) => void) => {
|
||||
if (ev === 'close') cb(code, signal);
|
||||
});
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
return proc;
|
||||
}
|
||||
|
||||
const baseRequest: AnalysisRequest = {
|
||||
filePath: '/tmp/test.png',
|
||||
fileType: 'image',
|
||||
fileContent: Buffer.from('fake'),
|
||||
sopContent: '# SOP\nAnalyze the image.',
|
||||
meta: { source_url: 'https://example.com', title: 'Test' },
|
||||
};
|
||||
|
||||
describe('createCLIProvider (claude)', () => {
|
||||
const provider = createCLIProvider({
|
||||
id: 'p1', type: 'cli', cliType: 'claude', bin: 'claude',
|
||||
});
|
||||
|
||||
beforeEach(() => mockSpawn.mockClear());
|
||||
|
||||
test('id and name are set', () => {
|
||||
expect(provider.id).toBe('p1');
|
||||
expect(provider.name).toContain('claude');
|
||||
});
|
||||
|
||||
test('resolves with markdown on success', async () => {
|
||||
makeMockProc('some preamble\n```markdown\n# Note\ncontent\n```', '', 0);
|
||||
const result = await provider.analyze(baseRequest);
|
||||
expect(result).toContain('# Note');
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'claude',
|
||||
['-p', '--allowedTools', 'Read'],
|
||||
expect.objectContaining({ timeout: 300_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('writes prompt to stdin', async () => {
|
||||
const proc = makeMockProc('output', '', 0);
|
||||
await provider.analyze(baseRequest);
|
||||
expect(proc.stdin.write).toHaveBeenCalled();
|
||||
expect(proc.stdin.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolves with raw stdout when no markdown block', async () => {
|
||||
makeMockProc('plain output without code block', '', 0);
|
||||
const result = await provider.analyze(baseRequest);
|
||||
expect(result).toBe('plain output without code block');
|
||||
});
|
||||
|
||||
test('throws on non-zero exit', async () => {
|
||||
makeMockProc('', 'auth error', 1);
|
||||
await expect(provider.analyze(baseRequest)).rejects.toThrow('auth error');
|
||||
});
|
||||
|
||||
test('throws timeout message when killed by signal', async () => {
|
||||
makeMockProc('', '', null, 'SIGTERM');
|
||||
await expect(provider.analyze(baseRequest)).rejects.toThrow('claude timed out after 300s');
|
||||
});
|
||||
|
||||
test('throws spawn error when binary not found', async () => {
|
||||
const proc: any = {
|
||||
stdin: { write: jest.fn(), end: jest.fn() },
|
||||
stdout: { on: jest.fn() },
|
||||
stderr: { on: jest.fn() },
|
||||
on: jest.fn(),
|
||||
};
|
||||
proc.stdout.on.mockImplementation(() => {});
|
||||
proc.stderr.on.mockImplementation(() => {});
|
||||
proc.on.mockImplementation((ev: string, cb: (err: Error) => void) => {
|
||||
if (ev === 'error') cb(new Error('spawn claude ENOENT'));
|
||||
});
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
await expect(provider.analyze(baseRequest)).rejects.toThrow('spawn claude ENOENT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCLIProvider (gemini)', () => {
|
||||
const provider = createCLIProvider({
|
||||
id: 'p2', type: 'cli', cliType: 'gemini', bin: 'gemini',
|
||||
});
|
||||
|
||||
beforeEach(() => mockSpawn.mockClear());
|
||||
|
||||
test('uses -p flag', async () => {
|
||||
makeMockProc('gemini output', '', 0);
|
||||
await provider.analyze(baseRequest);
|
||||
const [bin, args] = mockSpawn.mock.calls[0];
|
||||
expect(bin).toBe('gemini');
|
||||
expect(args).toEqual(['-p']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCLIProvider (codex)', () => {
|
||||
const provider = createCLIProvider({
|
||||
id: 'p3', type: 'cli', cliType: 'codex', bin: 'codex',
|
||||
});
|
||||
|
||||
beforeEach(() => mockSpawn.mockClear());
|
||||
|
||||
test('uses --quiet flag', async () => {
|
||||
makeMockProc('codex output', '', 0);
|
||||
await provider.analyze(baseRequest);
|
||||
const [bin, args] = mockSpawn.mock.calls[0];
|
||||
expect(bin).toBe('codex');
|
||||
expect(args).toEqual(['--quiet']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { createGeminiAPIProvider } from '../../src/providers/gemini-api';
|
||||
import { AnalysisRequest, MultiFrameRequest } from '../../src/types';
|
||||
|
||||
jest.mock('@google/generative-ai', () => ({
|
||||
GoogleGenerativeAI: jest.fn().mockImplementation(() => ({
|
||||
getGenerativeModel: jest.fn().mockReturnValue({
|
||||
generateContent: jest.fn(),
|
||||
}),
|
||||
})),
|
||||
HarmCategory: {},
|
||||
HarmBlockThreshold: {},
|
||||
}));
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
const MockGAI = GoogleGenerativeAI as jest.MockedClass<typeof GoogleGenerativeAI>;
|
||||
|
||||
const config = { id: 'g1', type: 'gemini-api' as const, apiKey: 'gai-test', model: 'gemini-1.5-flash' };
|
||||
|
||||
const req: AnalysisRequest = {
|
||||
filePath: '/tmp/img.png', fileType: 'image',
|
||||
fileContent: Buffer.from([0x89, 0x50]),
|
||||
sopContent: 'Analyze.', meta: {},
|
||||
};
|
||||
|
||||
describe('analyzeMultiFrame — Gemini', () => {
|
||||
let mockGenerateContent: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerateContent = jest.fn().mockResolvedValue({
|
||||
response: { text: () => '# Keyframe Analysis\nContent' },
|
||||
});
|
||||
MockGAI.mockImplementation(() => ({
|
||||
getGenerativeModel: jest.fn().mockReturnValue({ generateContent: mockGenerateContent }),
|
||||
}) as any);
|
||||
});
|
||||
|
||||
const multiFrameReq: MultiFrameRequest = {
|
||||
frames: [Buffer.from('frame1'), Buffer.from('frame2')],
|
||||
sopContent: 'Analyze the keyframes.',
|
||||
meta: { video_title: 'My Video', url: 'https://yt.com', time_range: { start: 0, end: 15 }, captured_at: '2026-05-30T18:00:00Z' },
|
||||
};
|
||||
|
||||
test('returns trimmed text from response', async () => {
|
||||
const p = createGeminiAPIProvider(config);
|
||||
const result = await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
expect(result).toBe('# Keyframe Analysis\nContent');
|
||||
});
|
||||
|
||||
test('sends each frame as a separate inlineData part', async () => {
|
||||
const p = createGeminiAPIProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const parts = mockGenerateContent.mock.calls[0][0];
|
||||
const inlineDataParts = parts.filter((p: any) => p.inlineData);
|
||||
expect(inlineDataParts).toHaveLength(2);
|
||||
expect(inlineDataParts[0].inlineData.mimeType).toBe('image/png');
|
||||
expect(inlineDataParts[0].inlineData.data).toBe(Buffer.from('frame1').toString('base64'));
|
||||
});
|
||||
|
||||
test('includes sop and context in the text part', async () => {
|
||||
const p = createGeminiAPIProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const parts = mockGenerateContent.mock.calls[0][0];
|
||||
const textPart = parts.find((p: any) => p.text);
|
||||
expect(textPart.text).toContain('Analyze the keyframes.');
|
||||
expect(textPart.text).toContain('My Video');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGeminiAPIProvider', () => {
|
||||
let mockGenerate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerate = jest.fn().mockResolvedValue({
|
||||
response: { text: () => '# Gemini Note' },
|
||||
});
|
||||
MockGAI.mockImplementation(() => ({
|
||||
getGenerativeModel: jest.fn().mockReturnValue({ generateContent: mockGenerate }),
|
||||
}) as any);
|
||||
});
|
||||
|
||||
test('returns generated text', async () => {
|
||||
const result = await createGeminiAPIProvider(config).analyze(req);
|
||||
expect(result).toBe('# Gemini Note');
|
||||
});
|
||||
|
||||
test('sends image as inline data part', async () => {
|
||||
await createGeminiAPIProvider(config).analyze(req);
|
||||
const parts = mockGenerate.mock.calls[0][0];
|
||||
expect(parts.some((p: any) => p.inlineData)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { createOpenAICompatProvider } from '../../src/providers/openai-compat';
|
||||
import { AnalysisRequest, MultiFrameRequest } from '../../src/types';
|
||||
|
||||
// Mock the openai module
|
||||
jest.mock('openai', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import OpenAI from 'openai';
|
||||
const MockOpenAI = OpenAI as jest.MockedClass<typeof OpenAI>;
|
||||
|
||||
const config = {
|
||||
id: 'p1', type: 'openai-compat' as const,
|
||||
label: 'OpenRouter',
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
apiKey: 'sk-or-test',
|
||||
model: 'openai/gpt-4o',
|
||||
};
|
||||
|
||||
const imageRequest: AnalysisRequest = {
|
||||
filePath: '/tmp/test.png',
|
||||
fileType: 'image',
|
||||
fileContent: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
sopContent: 'Analyze the image.',
|
||||
meta: { source_url: 'https://example.com', title: 'Test' },
|
||||
};
|
||||
|
||||
const textRequest: AnalysisRequest = {
|
||||
filePath: '/tmp/note.md',
|
||||
fileType: 'text',
|
||||
fileContent: Buffer.from('# My Note\nHello world'),
|
||||
sopContent: 'Summarize the text.',
|
||||
meta: {},
|
||||
};
|
||||
|
||||
describe('analyzeMultiFrame — OpenAI-compat', () => {
|
||||
let mockCreate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreate = jest.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: '# Analysis\nContent' } }],
|
||||
});
|
||||
MockOpenAI.mockImplementation(() => ({
|
||||
chat: { completions: { create: mockCreate } },
|
||||
}) as any);
|
||||
});
|
||||
|
||||
const multiFrameReq: MultiFrameRequest = {
|
||||
frames: [Buffer.from('frame1'), Buffer.from('frame2')],
|
||||
transcript: 'Hello',
|
||||
sopContent: 'Analyze frames.',
|
||||
meta: { video_title: 'My Video', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z' },
|
||||
};
|
||||
|
||||
test('returns trimmed text from response', async () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
const result = await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
expect(result).toBe('# Analysis\nContent');
|
||||
});
|
||||
|
||||
test('sends each frame as a separate image_url content block', async () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userMsg = call.messages.find((m: any) => m.role === 'user');
|
||||
const imageBlocks = userMsg.content.filter((c: any) => c.type === 'image_url');
|
||||
expect(imageBlocks).toHaveLength(2);
|
||||
expect(imageBlocks[0].image_url.url).toContain('data:image/png;base64,');
|
||||
expect(imageBlocks[0].image_url.url).toContain(Buffer.from('frame1').toString('base64'));
|
||||
});
|
||||
|
||||
test('includes transcript in context text block', async () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userMsg = call.messages.find((m: any) => m.role === 'user');
|
||||
const textBlock = userMsg.content.find((c: any) => c.type === 'text');
|
||||
expect(textBlock.text).toContain('Hello');
|
||||
});
|
||||
|
||||
test('uses sopContent as system message', async () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
await (p as any).analyzeMultiFrame(multiFrameReq);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const sysMsg = call.messages.find((m: any) => m.role === 'system');
|
||||
expect(sysMsg.content).toBe('Analyze frames.');
|
||||
});
|
||||
|
||||
test('throws when response has no content', async () => {
|
||||
mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] });
|
||||
const p = createOpenAICompatProvider(config);
|
||||
await expect((p as any).analyzeMultiFrame(multiFrameReq)).rejects.toThrow('no content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOpenAICompatProvider', () => {
|
||||
let mockCreate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreate = jest.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: '# Generated Note\nContent here' } }],
|
||||
});
|
||||
MockOpenAI.mockImplementation(() => ({ chat: { completions: { create: mockCreate } } }) as any);
|
||||
});
|
||||
|
||||
test('id and name are set', () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
expect(p.id).toBe('p1');
|
||||
expect(p.name).toContain('OpenRouter');
|
||||
});
|
||||
|
||||
test('image request sends base64 image_url message', async () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
const result = await p.analyze(imageRequest);
|
||||
expect(result).toBe('# Generated Note\nContent here');
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userContent = call.messages.find((m: any) => m.role === 'user').content;
|
||||
expect(Array.isArray(userContent)).toBe(true);
|
||||
expect(userContent.some((c: any) => c.type === 'image_url')).toBe(true);
|
||||
});
|
||||
|
||||
test('text request sends content as text', async () => {
|
||||
const p = createOpenAICompatProvider(config);
|
||||
await p.analyze(textRequest);
|
||||
const call = mockCreate.mock.calls[0][0];
|
||||
const userContent = call.messages.find((m: any) => m.role === 'user').content;
|
||||
expect(typeof userContent === 'string' || userContent.some((c: any) => c.type === 'text')).toBe(true);
|
||||
});
|
||||
|
||||
test('throws when API returns no content', async () => {
|
||||
mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] });
|
||||
const p = createOpenAICompatProvider(config);
|
||||
await expect(p.analyze(imageRequest)).rejects.toThrow('no content');
|
||||
});
|
||||
|
||||
test('initialises OpenAI client with correct baseURL', () => {
|
||||
createOpenAICompatProvider(config);
|
||||
expect(MockOpenAI).toHaveBeenCalledWith(expect.objectContaining({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: 'sk-or-test',
|
||||
}));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue