openvibers/openvibe.vision Commits
Read, Find and Watch on Kit 2: take a photo on a phone, a reading the moment it is chosen, and receipts (#6)
Changes
@@@ -243,3 +243,9 @@ For private index, watch and moments, a service capability alone does not assertPrivate embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a mandatory `owner` filter. Vision has no pgvector migration or extension requirement. The v1 search cap is 5,000 items per owner.Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.++++<!-- versions:start -->++- openvibe-contracts: v0.235.0++- openvibe-sdk: v0.70.0++- openvibe-shared: v4.36.1++<!-- versions:end -->@@@ -8,13 +8,13 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)","port": "5130","domain": "openvibe.vision",-- "contracts": "openvibe-contracts v0.229.0",++ "contracts": "openvibe-contracts v0.235.0","sdk": "openvibe-sdk v0.70.0",-- "shared": "openvibe-shared v4.31.0",++ "shared": "openvibe-shared v4.36.1","packages": [-- "openvibe-contracts v0.229.0",++ "openvibe-contracts v0.235.0","openvibe-sdk v0.70.0",-- "openvibe-shared v4.31.0"++ "openvibe-shared v4.36.1"],"capabilities": ["vision.ask",@@@ -77,5 +77,5 @@"deploy files"],"deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-vision.service and deploy/nginx/openvibe.vision.conf",-- "updated": "2026-10-28"++ "updated": "2026-10-29"}@@@ -20,7 +20,7 @@"onnxruntime-node": "^1.31.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1","pg": "^8.23.0","sharp": "^0.33.5"},@@@ -31,6 +31,12 @@"node": ">=22.0.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.13","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",@@@ -1277,6 +1283,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/boolean": {"version": "3.2.0","resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",@@@ -1447,6 +1466,39 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -1533,6 +1585,73 @@"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==","license": "MIT"},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -1583,6 +1702,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=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",@@@ -1914,6 +2045,110 @@"url": "https://github.com/sponsors/EvanHahn"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -2071,6 +2306,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.defaults": {"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",@@@ -2269,6 +2528,22 @@"node": ">= 0.6"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -2394,12 +2669,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.31.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",-- "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",++ "version": "4.36.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",++ "integrity": "sha512-XlEoj5JkYbDAMdtmPAPQMf2ygGbXq9NqY3XwoqQzUiZ9mpX0xdC4LuEKEDB967x5auW9CZzy3Ssa/XoKdFEBzA==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -3006,6 +3282,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/undici-types": {"version": "8.9.0","resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",@@@ -30,7 +30,7 @@"onnxruntime-node": "^1.31.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1","pg": "^8.23.0","sharp": "^0.33.5"},@@@ -7,7 +7,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }.vi { display: block; }.vi a { color: var(--room-strong); }--.vi .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }++:where(.vi) .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }.vi-narrow { max-width: 620px; }.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }ddeleted file mode 100644@@@ -1,49 +0,0 @@--/* OpenVibe.Vision enhancement layer. Every form is complete without JavaScript; this only makes the drop zone-- accept a dragged file and shows the chosen filename. No result is fetched or rendered here. */--(function () {-- 'use strict';-- if (!document.querySelectorAll) return;-- var zones = document.querySelectorAll('[data-vi-drop]');-- for (var i = 0; i < zones.length; i++) {-- (function (zone) {-- var input = zone.querySelector('input[type=file]');-- var name = zone.querySelector('[data-vi-file]');-- if (!input) return;-- function show() {-- var has = input.files && input.files.length;-- if (has) {-- if (name) name.textContent = input.files[0].name;-- zone.classList.add('is-chosen');-- } else {-- if (name) name.textContent = '';-- zone.classList.remove('is-chosen');-- }-- }-- input.addEventListener('change', show);-- function over(event) {-- event.preventDefault();-- event.stopPropagation();-- zone.classList.add('is-drag');-- }-- function out(event) {-- event.preventDefault();-- event.stopPropagation();-- zone.classList.remove('is-drag');-- }-- zone.addEventListener('dragenter', over);-- zone.addEventListener('dragover', over);-- zone.addEventListener('dragleave', out);-- zone.addEventListener('dragend', out);-- zone.addEventListener('drop', function (event) {-- over(event);-- zone.classList.remove('is-drag');-- var transfer = event.dataTransfer;-- if (!transfer || !transfer.files || !transfer.files.length) return;-- try {-- input.files = transfer.files;-- show();-- } catch (e) { /* older browsers keep the picker as the only way in */ }-- });-- })(zones[i]);-- }--})();@@@ -90,7 +90,7 @@ ${appIcon.headTags({ site: 'vision' })}<link rel="stylesheet" href="${ovServe.url('showcase.css')}"><link rel="stylesheet" href="${asset('css/vision.css')}"><script src="${ovServe.url('motion.js')}" defer></script>--<script src="${asset('js/vision.js')}" defer></script>++<script src="${ovServe.url('kit-web.js')}" defer></script>${render(scripts)}<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}"><script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,@@@ -2,7 +2,7 @@/*** The Vision room's pages (plan 2026-10-28 §5.8). Server-rendered and whole without JavaScript: every action is-- * a plain form or link. public/css/vision.css and public/js/vision.js are one-comment placeholders the main++ * a plain form or link. public/css/vision.css is the site's own stylesheet; the kit (kit.css, kit-web.js) does the rest, and the main* session replaces; the forms and results work without them. Ids and classes start with `vi-`.*/const { esc, time } = require('../render/html');@@@ -4,13 +4,12 @@* The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).** Each page is one job: one input or drop zone, the result directly under it, and examples a person can tap.-- * Everything is a plain server-rendered form, so it works without JavaScript; vision.js only adds drag-and-drop and-- * a filename on the drop zone. Power options sit inside a <details> one tap down, and the API notes live at the++ * Everything is a plain server-rendered form, so it works without JavaScript. The drop zone is the kit's (kit.drop):++ * kit-web.js adds drag and drop, paste and, on a phone, "Take a photo"; /read reads a picture as soon as it is chosen. Power options sit inside a <details> one tap down, and the API notes live at the* bottom of the page instead of above the tool.*/const { esc } = require('../render/html');----const UPLOAD_MARK = `<svg class="vi-drop-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/></svg>`;++const kit = require('openvibe-shared/kit');function n(value, fallback = 0) {const x = Number(value);@@@ -34,6 +33,12 @@ function confidence(value) {return `${(x <= 1 ? x * 100 : x).toFixed(1)}%`;}++/** What an answer took, for kit.receipt: the engine, the time, the cost (free when it was), and what is kept. */++function took(receipt, kept) {++ const cost = n(receipt.cost_usd);++ return [['Read by', receipt.model || receipt.engine || 'local'], ['Time', receipt.ms != null ? kit.num(receipt.ms, 'ms') : null], ['Cost', cost > 0 ? kit.num(cost, 'money') : 'free'], ['Kept', kept]];++}++function facts(items) {return `<dl class="vi-facts vi-tool-facts">${items.filter((x) => x && x[1] != null && x[1] !== '').map(([dt, dd]) => `<div><dt>${esc(dt)}</dt><dd>${dd}</dd></div>`).join('')}</dl>`;}@@@ -42,15 +47,10 @@ function header({ kicker, title, lede }) {return `<header class="vi-page-h vi-tool-h"><p class="vi-kicker">${esc(kicker)}</p><h1>${esc(title)}</h1><p class="vi-lede">${esc(lede)}</p></header>`;}--function dropZone({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file' }) {-- return `<label class="vi-drop" for="${esc(id)}" data-vi-drop>--<span class="vi-drop-art">${UPLOAD_MARK}</span>--<b>${esc(label)}</b>--<span class="vi-drop-hint">${esc(hint)}</span>--<span class="vi-drop-file" data-vi-file></span>--<input class="vi-sr" id="${esc(id)}" type="file" name="${esc(name)}" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif">--</label>`;--}++const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif';++/** The picture's way in (kit.drop): choose, drop, paste, or take a photo on a phone. auto: send the form at once. */++const dropZone = ({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file', auto = false }) =>++ kit.drop({ id, name, accept: ACCEPT, label, hint, camera: true, auto });function examples(links) {return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;@@@ -90,16 +90,15 @@ function find({ signedIn = false, q = '', result = null, problem = null, byImage} else if (result) {const rows = Array.isArray(result.results) ? result.results : [];const receipt = result.receipt || {};-- resultHtml = `<section class="vi-result-tool" id="vi-find-result" aria-labelledby="vi-find-result-h">--<div class="vi-result-head"><h2 id="vi-find-result-h">${rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'}` : 'No matches yet'}</h2><span class="vi-status${rows.length ? ' is-on' : ''}">${esc(byImage ? 'by picture' : 'by words')}</span></div>--${rows.length ? `<ol class="vi-matches">${rows.slice(0, 30).map((m, i) => `<li class="vi-match">--<span class="vi-match-rank" aria-hidden="true">${i + 1}</span>--<span class="vi-match-main">${m.result_url ? `<a href="${esc(m.result_url)}" rel="noopener">Open this picture</a>` : `<b>Picture ${esc(m.item_id)}</b>`}<small>${esc(m.item_id)}</small></span>--<span class="vi-match-score">${Math.round(n(m.score) * 100)}%</span>--</li>`).join('')}</ol>` : `<p>Nothing in your pictures matches those words yet. Add pictures from <a href="https://openvibe.media">Pics or Media</a>, then search again.</p><p class="vi-fine">Your matches stay private; index entries are tied to your account.</p>`}--${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}--<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>--</section>`;++ resultHtml = `<div id="vi-find-result">${kit.receipt({++ title: rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'} ${byImage ? 'by picture' : 'by words'}` : 'No matches yet',++ body: `\${rows.length ? \`<ol class="vi-matches">\${rows.slice(0, 30).map((m, i) => \`<li class="vi-match">++<span class="vi-match-rank" aria-hidden="true">\${i + 1}</span>++<span class="vi-match-main">\${m.result_url ? \`<a href="\${esc(m.result_url)}" rel="noopener">Open this picture</a>\` : \`<b>Picture \${esc(m.item_id)}</b>\`}<small>\${esc(m.item_id)}</small></span>++<span class="vi-match-score">\${Math.round(n(m.score) * 100)}%</span>++</li>\`).join('')}</ol>\` : \`<p>Nothing in your pictures matches those words yet. Add pictures from <a href="https://openvibe.media">Pics or Media</a>, then search again.</p><p class="vi-fine">Your matches stay private; index entries are tied to your account.</p>\`}<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>`,++ facts: took(receipt, 'not stored'),++ })}</div>`;} else {resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty"><h2>Your matches appear here</h2>@@@ -132,24 +131,31 @@ function readView({ signedIn = false, ai = false, problem = null, result = null,let resultHtml;if (result) {const conf = confidence(result.confidence);-- resultHtml = `<section class="vi-result-tool" id="vi-read-result" aria-labelledby="vi-read-result-h">--<div class="vi-result-head"><h2 id="vi-read-result-h">What it says</h2><span class="vi-status is-on">${esc(result.engine === 'ai' ? 'AI fallback' : 'local OCR')}</span></div>--<pre class="vi-ocr-text">${esc(result.text || 'No text was found.')}</pre>--${facts([['Engine', esc(/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5'))], ['Confidence', conf ? esc(conf) : null], ['Time', esc(when(result.latency_ms || result.ms))], ['Cost', esc(money(result.cost_usd))]])}--<p class="vi-fine">${esc(/[.!?]$/.test(String(result.reason || '')) ? result.reason : `${result.reason || 'The local reader answered first'}.`)} ${signedIn && result.id ? `Saved to <a href="/a/${encodeURIComponent(result.id)}">your answers</a>.` : 'This reading is shown here and not saved.'}</p>--</section>`;++ // The reading first, then one quiet line of what it took (kit.receipt); a copy button when there is text.++ const pct = result.confidence == null ? null : Number(result.confidence) <= 1 ? Number(result.confidence) * 100 : Number(result.confidence);++ const cost = n(result.cost_usd);++ const said = String(result.reason || 'The local reader answered first');++ const reason = `${said.charAt(0).toUpperCase()}${said.slice(1)}${/[.!?]$/.test(said) ? '' : '.'}`;++ const copy = result.text ? `<button type="button" class="ovk-icon-btn" data-ovk-copy="#vi-ocr-text" aria-label="Copy the text" title="Copy the text" hidden>${kit.icon('copy')}</button>` : '';++ resultHtml = `<div id="vi-read-result">${kit.receipt({++ title: 'What it says', actions: [copy],++ body: `<pre class="vi-ocr-text" id="vi-ocr-text" tabindex="0">${esc(result.text || 'No text was found in this picture.')}</pre><p class="vi-fine">${esc(reason)}</p>`,++ facts: [++ ['Read by', `${/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5')}${result.engine === 'ai' ? ' (AI fallback)' : ''}`],++ ['Confidence', conf && Number.isFinite(pct) ? { html: `${kit.meter({ value: pct, label: 'Confidence', text: conf })}${esc(conf)}` } : null],++ ['Time', kit.num(result.latency_ms || result.ms, 'ms')],++ ['Cost', cost > 0 ? kit.num(cost, 'money') : 'free'],++ ['Kept', signedIn && result.id ? { html: `<a href="/a/${encodeURIComponent(result.id)}">in your answers</a>` } : 'not stored'],++ ],++ })}</div>`;} else {-- resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-read-empty">--<h2>Your reading appears here</h2>--<p>Choose a photo above and press <b>Read it</b>. The words come back in reading order, with the engine, the confidence and the time.</p>--<ul class="vi-steps"><li>Receipts: store, lines and total.</li><li>Signs, labels and screenshots: the words as written.</li><li>Nothing is sent to a model unless you ask.</li></ul>--</section>`;++ resultHtml = '';}return `<div class="vi-wrap vi-tool" id="vi-read">--${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels, read on this host. Free, and the picture is not stored.' })}++${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels: the words come back in reading order. Read on this host, free, and the picture is not stored.' })}${problemNote(problem)}--<form class="vi-tool-form" id="vi-read-form" method="post" action="/read" enctype="multipart/form-data">--${dropZone({ id: 'vi-read-file', label: 'Drop a picture to read', hint: 'or choose a file' })}++<form class="vi-tool-form" id="vi-read-form" method="post" action="/read#vi-read-result" enctype="multipart/form-data">++${dropZone({ id: 'vi-read-file', label: 'Choose a picture to read', hint: 'or drop or paste one · read as soon as it is chosen', auto: true })}${examples([`<button class="vi-example" type="submit" name="example" value="receipt">A shop receipt</button>`, `<button class="vi-example" type="submit" name="example" value="sign">A station sign</button>`])}<div class="vi-actions"><button class="vi-btn" type="submit">Read it</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask about it instead</a></div><details class="vi-options"><summary>Language and AI fallback</summary>@@@ -179,12 +185,11 @@ function watchView({ problem = null, result = null, values = {} } = {}) {if (result) {const list = Array.isArray(result.detections) ? result.detections : [];const receipt = result.receipt || {};-- resultHtml = `<section class="vi-result-tool" id="vi-watch-result" aria-labelledby="vi-watch-result-h">--<div class="vi-result-head"><h2 id="vi-watch-result-h">${list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list'}</h2><span class="vi-status${list.length ? ' is-on' : ''}">on this host</span></div>--${list.length ? `<ul class="vi-findings-list">${list.map((d) => `<li><b>${esc(d.class)}</b><span>${confidence(d.confidence) ? `${esc(confidence(d.confidence))} confidence` : 'detected'}</span>${d.box ? `<small>box ${Math.round(Number(d.box.xmin) || 0)}, ${Math.round(Number(d.box.ymin) || 0)} to ${Math.round(Number(d.box.xmax) || 0)}, ${Math.round(Number(d.box.ymax) || 0)}</small>` : ''}</li>`).join('')}</ul>` : '<p>No watched object was found in this still. Try a closer picture, or add another thing to watch for.</p>'}--${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}--<p class="vi-fine">The still is not stored. Vision finds the classes you chose; it does not identify people.</p>--</section>`;++ resultHtml = `<div id="vi-watch-result">${kit.receipt({++ title: list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list',++ body: `\${list.length ? \`<ul class="vi-findings-list">\${list.map((d) => \`<li><b>\${esc(d.class)}</b><span>\${confidence(d.confidence) ? \`\${esc(confidence(d.confidence))} confidence\` : 'detected'}</span>\${d.box ? \`<small>box \${Math.round(Number(d.box.xmin) || 0)}, \${Math.round(Number(d.box.ymin) || 0)} to \${Math.round(Number(d.box.xmax) || 0)}, \${Math.round(Number(d.box.ymax) || 0)}</small>\` : ''}</li>\`).join('')}</ul>\` : '<p>No watched object was found in this still. Try a closer picture, or add another thing to watch for.</p>'}<p class="vi-fine">Vision finds the classes you chose; it does not identify people.</p>`,++ facts: took(receipt, 'not stored'),++ })}</div>`;} else {resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-watch-empty"><h2>What Vision sees appears here</h2>@@@ -195,7 +200,7 @@ ${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', ereturn `<div class="vi-wrap vi-tool" id="vi-watch">${header({ kicker: 'Watch · local', title: 'Watch a camera still', lede: 'Drop a still and see what is there: a delivery, a package, a person or a car. Runs on this host, and the picture is not stored.' })}${problemNote(problem)}--<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch" enctype="multipart/form-data">++<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch#vi-watch-result" enctype="multipart/form-data">${dropZone({ id: 'vi-watch-file', label: 'Drop a camera still', hint: 'a photo or screenshot' })}<fieldset class="vi-choices"><legend>What are you watching for?</legend>${choices}</fieldset><div class="vi-actions"><button class="vi-btn" type="submit">Check this still</button><a class="vi-btn vi-btn-quiet" href="/read">Read it instead</a></div>
@@ -243,3 +243,9 @@ For private index, watch and moments, a service capability alone does not assert
@@ -243,3 +243,9 @@ For private index, watch and moments, a service capability alone does not assert
Private embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a mandatory `owner` filter. Vision has no pgvector migration or extension requirement. The v1 search cap is 5,000 items per owner.
Private embeddings are JSON vectors in PostgreSQL, scored in JavaScript after a mandatory `owner` filter. Vision has no pgvector migration or extension requirement. The v1 search cap is 5,000 items per owner.
Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.
Offer discovery is configured by `VISION_DEALS_SEARCH_URL` and `VISION_SHOES_SEARCH_URL`, both fixed operator URLs returning JSON `{offers:[{title,url,price?}]}` or `{results:[...]}`. Deals currently exposes public `GET /api/v1/offers` without a query filter, and its `/search?q=` is an HTML page; open.shoes has no implemented API in this workspace yet. Until those rooms expose a structured search endpoint, `/product` returns the description and embedding with an empty `offers` list and a warning. It does not fabricate matches.
+
+<!-- versions:start -->
+- openvibe-contracts: v0.235.0
+- openvibe-sdk: v0.70.0
+- openvibe-shared: v4.36.1
+<!-- versions:end -->
@@ -8,13 +8,13 @@
@@ -8,13 +8,13 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp, onnxruntime-node (YuNet/SFace), Transformers.js (CLIP/DETR)",
"port": "5130",
"port": "5130",
"domain": "openvibe.vision",
"domain": "openvibe.vision",
- "contracts": "openvibe-contracts v0.229.0",
+ "contracts": "openvibe-contracts v0.235.0",
"sdk": "openvibe-sdk v0.70.0",
"sdk": "openvibe-sdk v0.70.0",
- "shared": "openvibe-shared v4.31.0",
+ "shared": "openvibe-shared v4.36.1",
"packages": [
"packages": [
- "openvibe-contracts v0.229.0",
+ "openvibe-contracts v0.235.0",
"openvibe-sdk v0.70.0",
"openvibe-sdk v0.70.0",
- "openvibe-shared v4.31.0"
+ "openvibe-shared v4.36.1"
],
],
"capabilities": [
"capabilities": [
"vision.ask",
"vision.ask",
@@ -77,5 +77,5 @@
@@ -77,5 +77,5 @@
"deploy files"
"deploy files"
],
],
"deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-vision.service and deploy/nginx/openvibe.vision.conf",
"deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-vision.service and deploy/nginx/openvibe.vision.conf",
- "updated": "2026-10-28"
+ "updated": "2026-10-29"
}
}
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"onnxruntime-node": "^1.31.1",
"onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -31,6 +31,12 @@
@@ -31,6 +31,12 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.13",
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
@@ -1277,6 +1283,19 @@
@@ -1277,6 +1283,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/boolean": {
"node_modules/boolean": {
"version": "3.2.0",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -1447,6 +1466,39 @@
@@ -1447,6 +1466,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1533,6 +1585,73 @@
@@ -1533,6 +1585,73 @@
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1583,6 +1702,18 @@
@@ -1583,6 +1702,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1914,6 +2045,110 @@
@@ -1914,6 +2045,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2071,6 +2306,30 @@
@@ -2071,6 +2306,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.defaults": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -2269,6 +2528,22 @@
@@ -2269,6 +2528,22 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2394,12 +2669,13 @@
@@ -2394,12 +2669,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.31.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
- "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
+ "version": "4.36.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",
+ "integrity": "sha512-XlEoj5JkYbDAMdtmPAPQMf2ygGbXq9NqY3XwoqQzUiZ9mpX0xdC4LuEKEDB967x5auW9CZzy3Ssa/XoKdFEBzA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -3006,6 +3282,12 @@
@@ -3006,6 +3282,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/undici-types": {
"node_modules/undici-types": {
"version": "8.9.0",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"onnxruntime-node": "^1.31.1",
"onnxruntime-node": "^1.31.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.36.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.5"
"sharp": "^0.33.5"
},
},
@@ -7,7 +7,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -7,7 +7,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
.vi { display: block; }
.vi { display: block; }
.vi a { color: var(--room-strong); }
.vi a { color: var(--room-strong); }
-.vi .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+:where(.vi) .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
.vi-narrow { max-width: 620px; }
.vi-narrow { max-width: 620px; }
.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }
.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }
deleted file mode 100644
deleted file mode 100644
@@ -1,49 +0,0 @@
@@ -1,49 +0,0 @@
-/* OpenVibe.Vision enhancement layer. Every form is complete without JavaScript; this only makes the drop zone
- accept a dragged file and shows the chosen filename. No result is fetched or rendered here. */
-(function () {
- 'use strict';
- if (!document.querySelectorAll) return;
- var zones = document.querySelectorAll('[data-vi-drop]');
- for (var i = 0; i < zones.length; i++) {
- (function (zone) {
- var input = zone.querySelector('input[type=file]');
- var name = zone.querySelector('[data-vi-file]');
- if (!input) return;
- function show() {
- var has = input.files && input.files.length;
- if (has) {
- if (name) name.textContent = input.files[0].name;
- zone.classList.add('is-chosen');
- } else {
- if (name) name.textContent = '';
- zone.classList.remove('is-chosen');
- }
- }
- input.addEventListener('change', show);
- function over(event) {
- event.preventDefault();
- event.stopPropagation();
- zone.classList.add('is-drag');
- }
- function out(event) {
- event.preventDefault();
- event.stopPropagation();
- zone.classList.remove('is-drag');
- }
- zone.addEventListener('dragenter', over);
- zone.addEventListener('dragover', over);
- zone.addEventListener('dragleave', out);
- zone.addEventListener('dragend', out);
- zone.addEventListener('drop', function (event) {
- over(event);
- zone.classList.remove('is-drag');
- var transfer = event.dataTransfer;
- if (!transfer || !transfer.files || !transfer.files.length) return;
- try {
- input.files = transfer.files;
- show();
- } catch (e) { /* older browsers keep the picker as the only way in */ }
- });
- })(zones[i]);
- }
-})();
@@ -90,7 +90,7 @@ ${appIcon.headTags({ site: 'vision' })}
@@ -90,7 +90,7 @@ ${appIcon.headTags({ site: 'vision' })}
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/vision.css')}">
<link rel="stylesheet" href="${asset('css/vision.css')}">
<script src="${ovServe.url('motion.js')}" defer></script>
<script src="${ovServe.url('motion.js')}" defer></script>
-<script src="${asset('js/vision.js')}" defer></script>
+<script src="${ovServe.url('kit-web.js')}" defer></script>
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* The Vision room's pages (plan 2026-10-28 §5.8). Server-rendered and whole without JavaScript: every action is
* The Vision room's pages (plan 2026-10-28 §5.8). Server-rendered and whole without JavaScript: every action is
- * a plain form or link. public/css/vision.css and public/js/vision.js are one-comment placeholders the main
+ * a plain form or link. public/css/vision.css is the site's own stylesheet; the kit (kit.css, kit-web.js) does the rest, and the main
* session replaces; the forms and results work without them. Ids and classes start with `vi-`.
* session replaces; the forms and results work without them. Ids and classes start with `vi-`.
*/
*/
const { esc, time } = require('../render/html');
const { esc, time } = require('../render/html');
@@ -4,13 +4,12 @@
@@ -4,13 +4,12 @@
* The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).
* The three task pages: /find, /read and /watch (plan 2026-10-28 §5.8/§5.8b).
*
*
* Each page is one job: one input or drop zone, the result directly under it, and examples a person can tap.
* Each page is one job: one input or drop zone, the result directly under it, and examples a person can tap.
- * Everything is a plain server-rendered form, so it works without JavaScript; vision.js only adds drag-and-drop and
- * a filename on the drop zone. Power options sit inside a <details> one tap down, and the API notes live at the
+ * Everything is a plain server-rendered form, so it works without JavaScript. The drop zone is the kit's (kit.drop):
+ * kit-web.js adds drag and drop, paste and, on a phone, "Take a photo"; /read reads a picture as soon as it is chosen. Power options sit inside a <details> one tap down, and the API notes live at the
* bottom of the page instead of above the tool.
* bottom of the page instead of above the tool.
*/
*/
const { esc } = require('../render/html');
const { esc } = require('../render/html');
-
-const UPLOAD_MARK = `<svg class="vi-drop-mark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/></svg>`;
+const kit = require('openvibe-shared/kit');
function n(value, fallback = 0) {
function n(value, fallback = 0) {
const x = Number(value);
const x = Number(value);
@@ -34,6 +33,12 @@ function confidence(value) {
@@ -34,6 +33,12 @@ function confidence(value) {
return `${(x <= 1 ? x * 100 : x).toFixed(1)}%`;
return `${(x <= 1 ? x * 100 : x).toFixed(1)}%`;
}
}
+/** What an answer took, for kit.receipt: the engine, the time, the cost (free when it was), and what is kept. */
+function took(receipt, kept) {
+ const cost = n(receipt.cost_usd);
+ return [['Read by', receipt.model || receipt.engine || 'local'], ['Time', receipt.ms != null ? kit.num(receipt.ms, 'ms') : null], ['Cost', cost > 0 ? kit.num(cost, 'money') : 'free'], ['Kept', kept]];
+}
+
function facts(items) {
function facts(items) {
return `<dl class="vi-facts vi-tool-facts">${items.filter((x) => x && x[1] != null && x[1] !== '').map(([dt, dd]) => `<div><dt>${esc(dt)}</dt><dd>${dd}</dd></div>`).join('')}</dl>`;
return `<dl class="vi-facts vi-tool-facts">${items.filter((x) => x && x[1] != null && x[1] !== '').map(([dt, dd]) => `<div><dt>${esc(dt)}</dt><dd>${dd}</dd></div>`).join('')}</dl>`;
}
}
@@ -42,15 +47,10 @@ function header({ kicker, title, lede }) {
@@ -42,15 +47,10 @@ function header({ kicker, title, lede }) {
return `<header class="vi-page-h vi-tool-h"><p class="vi-kicker">${esc(kicker)}</p><h1>${esc(title)}</h1><p class="vi-lede">${esc(lede)}</p></header>`;
return `<header class="vi-page-h vi-tool-h"><p class="vi-kicker">${esc(kicker)}</p><h1>${esc(title)}</h1><p class="vi-lede">${esc(lede)}</p></header>`;
}
}
-function dropZone({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file' }) {
- return `<label class="vi-drop" for="${esc(id)}" data-vi-drop>
-<span class="vi-drop-art">${UPLOAD_MARK}</span>
-<b>${esc(label)}</b>
-<span class="vi-drop-hint">${esc(hint)}</span>
-<span class="vi-drop-file" data-vi-file></span>
-<input class="vi-sr" id="${esc(id)}" type="file" name="${esc(name)}" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif">
-</label>`;
-}
+const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif';
+/** The picture's way in (kit.drop): choose, drop, paste, or take a photo on a phone. auto: send the form at once. */
+const dropZone = ({ id, name = 'image', label = 'Drop a picture here', hint = 'or choose a file', auto = false }) =>
+ kit.drop({ id, name, accept: ACCEPT, label, hint, camera: true, auto });
function examples(links) {
function examples(links) {
return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
return `<div class="vi-examples"><span class="vi-examples-label">Try an example</span>${links.join('')}</div>`;
@@ -90,16 +90,15 @@ function find({ signedIn = false, q = '', result = null, problem = null, byImage
@@ -90,16 +90,15 @@ function find({ signedIn = false, q = '', result = null, problem = null, byImage
} else if (result) {
} else if (result) {
const rows = Array.isArray(result.results) ? result.results : [];
const rows = Array.isArray(result.results) ? result.results : [];
const receipt = result.receipt || {};
const receipt = result.receipt || {};
- resultHtml = `<section class="vi-result-tool" id="vi-find-result" aria-labelledby="vi-find-result-h">
-<div class="vi-result-head"><h2 id="vi-find-result-h">${rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'}` : 'No matches yet'}</h2><span class="vi-status${rows.length ? ' is-on' : ''}">${esc(byImage ? 'by picture' : 'by words')}</span></div>
-${rows.length ? `<ol class="vi-matches">${rows.slice(0, 30).map((m, i) => `<li class="vi-match">
-<span class="vi-match-rank" aria-hidden="true">${i + 1}</span>
-<span class="vi-match-main">${m.result_url ? `<a href="${esc(m.result_url)}" rel="noopener">Open this picture</a>` : `<b>Picture ${esc(m.item_id)}</b>`}<small>${esc(m.item_id)}</small></span>
-<span class="vi-match-score">${Math.round(n(m.score) * 100)}%</span>
-</li>`).join('')}</ol>` : `<p>Nothing in your pictures matches those words yet. Add pictures from <a href="https://openvibe.media">Pics or Media</a>, then search again.</p><p class="vi-fine">Your matches stay private; index entries are tied to your account.</p>`}
-${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}
-<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>
-</section>`;
+ resultHtml = `<div id="vi-find-result">${kit.receipt({
+ title: rows.length ? `${rows.length} ${rows.length === 1 ? 'match' : 'matches'} ${byImage ? 'by picture' : 'by words'}` : 'No matches yet',
+ body: `\${rows.length ? \`<ol class="vi-matches">\${rows.slice(0, 30).map((m, i) => \`<li class="vi-match">
+<span class="vi-match-rank" aria-hidden="true">\${i + 1}</span>
+<span class="vi-match-main">\${m.result_url ? \`<a href="\${esc(m.result_url)}" rel="noopener">Open this picture</a>\` : \`<b>Picture \${esc(m.item_id)}</b>\`}<small>\${esc(m.item_id)}</small></span>
+<span class="vi-match-score">\${Math.round(n(m.score) * 100)}%</span>
+</li>\`).join('')}</ol>\` : \`<p>Nothing in your pictures matches those words yet. Add pictures from <a href="https://openvibe.media">Pics or Media</a>, then search again.</p><p class="vi-fine">Your matches stay private; index entries are tied to your account.</p>\`}<p class="vi-fine">${esc(receipt.retention || 'Only your own indexed pictures are searched; the query is not stored.')}</p>`,
+ facts: took(receipt, 'not stored'),
+ })}</div>`;
} else {
} else {
resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">
resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-find-empty">
<h2>Your matches appear here</h2>
<h2>Your matches appear here</h2>
@@ -132,24 +131,31 @@ function readView({ signedIn = false, ai = false, problem = null, result = null,
@@ -132,24 +131,31 @@ function readView({ signedIn = false, ai = false, problem = null, result = null,
let resultHtml;
let resultHtml;
if (result) {
if (result) {
const conf = confidence(result.confidence);
const conf = confidence(result.confidence);
- resultHtml = `<section class="vi-result-tool" id="vi-read-result" aria-labelledby="vi-read-result-h">
-<div class="vi-result-head"><h2 id="vi-read-result-h">What it says</h2><span class="vi-status is-on">${esc(result.engine === 'ai' ? 'AI fallback' : 'local OCR')}</span></div>
-<pre class="vi-ocr-text">${esc(result.text || 'No text was found.')}</pre>
-${facts([['Engine', esc(/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5'))], ['Confidence', conf ? esc(conf) : null], ['Time', esc(when(result.latency_ms || result.ms))], ['Cost', esc(money(result.cost_usd))]])}
-<p class="vi-fine">${esc(/[.!?]$/.test(String(result.reason || '')) ? result.reason : `${result.reason || 'The local reader answered first'}.`)} ${signedIn && result.id ? `Saved to <a href="/a/${encodeURIComponent(result.id)}">your answers</a>.` : 'This reading is shown here and not saved.'}</p>
-</section>`;
+ // The reading first, then one quiet line of what it took (kit.receipt); a copy button when there is text.
+ const pct = result.confidence == null ? null : Number(result.confidence) <= 1 ? Number(result.confidence) * 100 : Number(result.confidence);
+ const cost = n(result.cost_usd);
+ const said = String(result.reason || 'The local reader answered first');
+ const reason = `${said.charAt(0).toUpperCase()}${said.slice(1)}${/[.!?]$/.test(said) ? '' : '.'}`;
+ const copy = result.text ? `<button type="button" class="ovk-icon-btn" data-ovk-copy="#vi-ocr-text" aria-label="Copy the text" title="Copy the text" hidden>${kit.icon('copy')}</button>` : '';
+ resultHtml = `<div id="vi-read-result">${kit.receipt({
+ title: 'What it says', actions: [copy],
+ body: `<pre class="vi-ocr-text" id="vi-ocr-text" tabindex="0">${esc(result.text || 'No text was found in this picture.')}</pre><p class="vi-fine">${esc(reason)}</p>`,
+ facts: [
+ ['Read by', `${/tesseract/i.test(String(result.model || '')) ? 'Tesseract 5' : (result.model || result.engine || 'Tesseract 5')}${result.engine === 'ai' ? ' (AI fallback)' : ''}`],
+ ['Confidence', conf && Number.isFinite(pct) ? { html: `${kit.meter({ value: pct, label: 'Confidence', text: conf })}${esc(conf)}` } : null],
+ ['Time', kit.num(result.latency_ms || result.ms, 'ms')],
+ ['Cost', cost > 0 ? kit.num(cost, 'money') : 'free'],
+ ['Kept', signedIn && result.id ? { html: `<a href="/a/${encodeURIComponent(result.id)}">in your answers</a>` } : 'not stored'],
+ ],
+ })}</div>`;
} else {
} else {
- resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-read-empty">
-<h2>Your reading appears here</h2>
-<p>Choose a photo above and press <b>Read it</b>. The words come back in reading order, with the engine, the confidence and the time.</p>
-<ul class="vi-steps"><li>Receipts: store, lines and total.</li><li>Signs, labels and screenshots: the words as written.</li><li>Nothing is sent to a model unless you ask.</li></ul>
-</section>`;
+ resultHtml = '';
}
}
return `<div class="vi-wrap vi-tool" id="vi-read">
return `<div class="vi-wrap vi-tool" id="vi-read">
-${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels, read on this host. Free, and the picture is not stored.' })}
+${header({ kicker: 'Read · local first', title: 'Read the words in a picture', lede: 'Receipts, signs, screenshots and labels: the words come back in reading order. Read on this host, free, and the picture is not stored.' })}
${problemNote(problem)}
${problemNote(problem)}
-<form class="vi-tool-form" id="vi-read-form" method="post" action="/read" enctype="multipart/form-data">
-${dropZone({ id: 'vi-read-file', label: 'Drop a picture to read', hint: 'or choose a file' })}
+<form class="vi-tool-form" id="vi-read-form" method="post" action="/read#vi-read-result" enctype="multipart/form-data">
+${dropZone({ id: 'vi-read-file', label: 'Choose a picture to read', hint: 'or drop or paste one · read as soon as it is chosen', auto: true })}
${examples([`<button class="vi-example" type="submit" name="example" value="receipt">A shop receipt</button>`, `<button class="vi-example" type="submit" name="example" value="sign">A station sign</button>`])}
${examples([`<button class="vi-example" type="submit" name="example" value="receipt">A shop receipt</button>`, `<button class="vi-example" type="submit" name="example" value="sign">A station sign</button>`])}
<div class="vi-actions"><button class="vi-btn" type="submit">Read it</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask about it instead</a></div>
<div class="vi-actions"><button class="vi-btn" type="submit">Read it</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask about it instead</a></div>
<details class="vi-options"><summary>Language and AI fallback</summary>
<details class="vi-options"><summary>Language and AI fallback</summary>
@@ -179,12 +185,11 @@ function watchView({ problem = null, result = null, values = {} } = {}) {
@@ -179,12 +185,11 @@ function watchView({ problem = null, result = null, values = {} } = {}) {
if (result) {
if (result) {
const list = Array.isArray(result.detections) ? result.detections : [];
const list = Array.isArray(result.detections) ? result.detections : [];
const receipt = result.receipt || {};
const receipt = result.receipt || {};
- resultHtml = `<section class="vi-result-tool" id="vi-watch-result" aria-labelledby="vi-watch-result-h">
-<div class="vi-result-head"><h2 id="vi-watch-result-h">${list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list'}</h2><span class="vi-status${list.length ? ' is-on' : ''}">on this host</span></div>
-${list.length ? `<ul class="vi-findings-list">${list.map((d) => `<li><b>${esc(d.class)}</b><span>${confidence(d.confidence) ? `${esc(confidence(d.confidence))} confidence` : 'detected'}</span>${d.box ? `<small>box ${Math.round(Number(d.box.xmin) || 0)}, ${Math.round(Number(d.box.ymin) || 0)} to ${Math.round(Number(d.box.xmax) || 0)}, ${Math.round(Number(d.box.ymax) || 0)}</small>` : ''}</li>`).join('')}</ul>` : '<p>No watched object was found in this still. Try a closer picture, or add another thing to watch for.</p>'}
-${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', esc(when(receipt.ms))], ['Cost', esc(money(receipt.cost_usd))]])}
-<p class="vi-fine">The still is not stored. Vision finds the classes you chose; it does not identify people.</p>
-</section>`;
+ resultHtml = `<div id="vi-watch-result">${kit.receipt({
+ title: list.length ? `${list.length} ${list.length === 1 ? 'thing' : 'things'} found` : 'Nothing from your list',
+ body: `\${list.length ? \`<ul class="vi-findings-list">\${list.map((d) => \`<li><b>\${esc(d.class)}</b><span>\${confidence(d.confidence) ? \`\${esc(confidence(d.confidence))} confidence\` : 'detected'}</span>\${d.box ? \`<small>box \${Math.round(Number(d.box.xmin) || 0)}, \${Math.round(Number(d.box.ymin) || 0)} to \${Math.round(Number(d.box.xmax) || 0)}, \${Math.round(Number(d.box.ymax) || 0)}</small>\` : ''}</li>\`).join('')}</ul>\` : '<p>No watched object was found in this still. Try a closer picture, or add another thing to watch for.</p>'}<p class="vi-fine">Vision finds the classes you chose; it does not identify people.</p>`,
+ facts: took(receipt, 'not stored'),
+ })}</div>`;
} else {
} else {
resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-watch-empty">
resultHtml = `<section class="vi-result-tool vi-result-empty" id="vi-watch-empty">
<h2>What Vision sees appears here</h2>
<h2>What Vision sees appears here</h2>
@@ -195,7 +200,7 @@ ${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', e
@@ -195,7 +200,7 @@ ${facts([['Engine', esc(receipt.model || receipt.engine || 'local')], ['Time', e
return `<div class="vi-wrap vi-tool" id="vi-watch">
return `<div class="vi-wrap vi-tool" id="vi-watch">
${header({ kicker: 'Watch · local', title: 'Watch a camera still', lede: 'Drop a still and see what is there: a delivery, a package, a person or a car. Runs on this host, and the picture is not stored.' })}
${header({ kicker: 'Watch · local', title: 'Watch a camera still', lede: 'Drop a still and see what is there: a delivery, a package, a person or a car. Runs on this host, and the picture is not stored.' })}
${problemNote(problem)}
${problemNote(problem)}
-<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch" enctype="multipart/form-data">
+<form class="vi-tool-form" id="vi-watch-form" method="post" action="/watch#vi-watch-result" enctype="multipart/form-data">
${dropZone({ id: 'vi-watch-file', label: 'Drop a camera still', hint: 'a photo or screenshot' })}
${dropZone({ id: 'vi-watch-file', label: 'Drop a camera still', hint: 'a photo or screenshot' })}
<fieldset class="vi-choices"><legend>What are you watching for?</legend>${choices}</fieldset>
<fieldset class="vi-choices"><legend>What are you watching for?</legend>${choices}</fieldset>
<div class="vi-actions"><button class="vi-btn" type="submit">Check this still</button><a class="vi-btn vi-btn-quiet" href="/read">Read it instead</a></div>
<div class="vi-actions"><button class="vi-btn" type="submit">Check this still</button><a class="vi-btn vi-btn-quiet" href="/read">Read it instead</a></div>