openvibers/openvibe.shared Commits
openvibe-shared 4.218.0: Post · Ask · Do, one composer for posting, asking AI and having Actor do it (#493)
Changes
@@@ -1,5 +1,38 @@# Changelog++## 4.218.0 — 2026-11-12++++One composer, three verbs: **Post · Ask · Do** (research-2026-11-12/composer-post-ask-do.md; the owner, 11-12: "make it so++that the composer is able to … integrate directly to have the models list from OpenVibe.Exchange … the search the web++feature, the search engine feature … more cohesive, interconnected", and "Actor should be more of an orchestrator … same++features across all different sites").++++- **`verbs: ['post', 'ask', 'do']`** (a site opts in; the first is where the box starts) draws one switch: on a post or++ a reply beside the name, in a chat or an agent's box at the start of the row; on a phone the chosen verb keeps its++ word and the others are icons. **Ask**: an AI answers you; **Do**: Actor does it for you (the orchestrator, handing++ parts to Codes, Bot, your computers); **Post** (Send, Reply) is unchanged.++- **Ask and Do's chips**, a line of their own above the words:++ - **Model**: a sheet (the whole width on a phone) over the Frame's `/choices?task=`: Auto, then OpenVibe's models++ from Exchange's directory with their price per million and a link to each model's page on openvibe.exchange, your++ keys, your computers, your plans; a find field; the choice is remembered per task;++ - **Sources**: Web, OpenVibe, Mine, This page (when the site says what the page is), as switches;++ - **Depth**: Quick, Balanced, Deep research.++- **The answer card** under the box: the answer streamed from the run's events (AG-UI over++ `/api/v1/runs/:id/events`), its sources as numbered chips, the model and the cost, and three ways on: **Post it**++ (the answer becomes the box's own post or message), **Continue in Vibe**, **Do this**. An Ask never reaches the++ box's onSubmit.++- Without `onAsk` / `onDo` the box asks the Frame (`POST /ask`, `/do` at `frameBase`, default++ https://openvibe.network/api/frame); a site that answers itself gets the same card (`api.card`: append, set, cite,++ done, fail). The api adds `verb()`, `setVerb()`, `model()`, `sources()` and `answer()`.++- **The inline Community thread is the comment preset** (#490): `thread.composer({ preset: 'comment' })` marks the++ server form, and `ov-thread.js` replaces it in place with `OpenVibeComposer.mount(..., { preset: 'comment' })` for++ every `[data-ovc-thread]`: the action, a reply's `parent_id`, files and the no-JS post stay. Video, Pics and a paste's++ comments are the same box, once.++++Tests: `composer-verbs-chrome` (the switch, the chips, the Model sheet from a fake Frame with prices and Exchange++links, find and pick, Sources, an Ask streamed into the card with its sources, Post it, a site's own answer, 390 px),++`inline-composer-chrome` (the inline thread mounts the comment preset, keeps its action and `parent_id`, posts at 390 px).++## 4.217.0 — 2026-11-12The composer gets what the agents portal's own box had: a camera, Try again, and pictures made smaller before they go.@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.217.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.218.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.217.0",++ "version": "4.218.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -58,7 +58,7 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': '3ddb2aa1ca88' };++ const VIBE_REV = { 'ov-composer.js': '203e770ec00f' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -89,7 +89,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': 'f8fca6720b89', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };++ const DOCK_REV = { 'dock-vibe.js': '7869d3d345b4', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -602,7 +602,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': '19f9b0805772', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': '68900e53dead', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -178,14 +178,79 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }.ovc-file-r-t { display: none; }.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r { left: 0; top: 0; width: 100%; height: 150px; border-radius: 15px 15px 0 0; }.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r-t { display: inline; }++/* Post · Ask · Do (4.218.0) */++.ovc-v2 .ovc-verbs--head { margin-left: auto; margin-right: 0; }++.ovc-v2 .ovc-head .ovc-chips + .ovc-verbs--head { margin-left: 6px; }++.ovc-v2 .ovc-verbs { flex: none; display: inline-flex; gap: 2px; padding: 3px; margin-right: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }++.ovc-v2 .ovc-verb { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 10px; border: 0; border-radius: 999px; background: none; color: var(--text-muted, #93a0b4); font: 600 13px/1 inherit; cursor: pointer; }++.ovc-v2 .ovc-verb svg { width: 15px; height: 15px; }++.ovc-v2 .ovc-verb[aria-pressed="true"] { background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); }++.ovc-v2 .ovc-verb[data-verb="ask"][aria-pressed="true"] svg { color: #c084fc; }++.ovc-v2 .ovc-verb[data-verb="do"][aria-pressed="true"] svg { color: #fbbf24; }++@media (max-width: 520px) { .ovc-v2 .ovc-verb:not([aria-pressed="true"]) span { display: none; } .ovc-v2 .ovc-verb:not([aria-pressed="true"]) { width: 34px; padding: 0; justify-content: center; } }++.ovc-v2 .ovc-ask-chips { display: flex; gap: 6px; min-width: 0; overflow-x: auto; scrollbar-width: none; }++.ovc-v2 .ovc-ask-chips::-webkit-scrollbar { display: none; }++.ovc-v2 .ovc-ask-chips > * { flex: none; }++.ovc-v2:not(.is-asking) .ovc-ask-chips { display: none; }++.ovc-v2[data-preset=chat] .ovc-ask-chips { grid-column: 1 / -1; }++.ovc-v2 .ovc-chip--btn { background: transparent; font-family: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }++.ovc-v2 .ovc-chip--btn .ovc-chip-v { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovc-v2 .ovc-chip--btn.is-on svg { color: var(--ovt-accent, var(--accent-light, #60a5fa)); }++.ovc-v2.is-asking { border-color: color-mix(in srgb, #a855f7 45%, var(--ovt-line, transparent)); }++.ovc-v2.is-asking[data-verb="do"] { border-color: color-mix(in srgb, #f59e0b 45%, var(--ovt-line, transparent)); }++.ovc-sheet { box-sizing: border-box; padding: 0; border: 1px solid var(--border, rgba(148, 163, 184, .2)); border-radius: 20px; width: min(520px, 94vw); max-height: 80vh; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); overflow: hidden; display: flex; flex-direction: column; }++.ovc-sheet::backdrop { background: rgba(2, 6, 14, .6); }++.ovc-sheet-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 10px 8px 18px; font-size: 16px; }++.ovc-sheet-b { overflow: auto; padding: 0 10px 14px; display: grid; gap: 4px; }++.ovc-sheet-find:focus { outline: none; border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 25%, transparent); }++.ovc-sheet-find { width: 100%; box-sizing: border-box; height: 42px; margin: 0 0 6px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .2)); border-radius: 12px; background: var(--bg-primary, #0b0f19); color: inherit; font: inherit; font-size: 16px; }++.ovc-sheet h3 { margin: 12px 8px 4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }++.ovc-sheet-note { margin: 8px; color: var(--text-muted, #93a0b4); font-size: 14px; }++.ovc-models { display: grid; gap: 2px; }++.ovc-model { display: flex; align-items: center; gap: 4px; }++.ovc-model[hidden] { display: none; }++.ovc-model-b { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 6px 10px; border: 0; border-radius: 12px; background: none; color: inherit; text-align: left; font: inherit; cursor: pointer; }++.ovc-model-b:hover, .ovc-model-b:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }++.ovc-model-b[aria-pressed="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); }++.ovc-model-m { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 20%, transparent); font-weight: 800; font-size: 14px; }++.ovc-model-t { min-width: 0; display: grid; }++.ovc-model-n { display: flex; align-items: center; gap: 6px; min-width: 0; }++.ovc-model-t b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }++.ovc-model-t small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); font-size: 12.5px; }++.ovc-model-rec { flex: none; padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, #22c55e 18%, transparent); color: #86efac; font: 700 10.5px/1.6 inherit; font-style: normal; vertical-align: 2px; }++.ovc-model-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--text-muted, #93a0b4); }++.ovc-model-x:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }++.ovc-model-x svg, .ovc-sheet-more svg { width: 16px; height: 16px; }++.ovc-sheet-more { display: inline-flex; align-items: center; gap: 6px; margin: 10px 8px 0; color: var(--ovt-accent, var(--accent-light, #60a5fa)); font-size: 14px; font-weight: 600; text-decoration: none; }++.ovc-src { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 6px 10px; border-radius: 12px; cursor: pointer; }++.ovc-src:hover { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }++.ovc-src > svg { flex: none; width: 20px; height: 20px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }++.ovc-src > span { flex: 1; min-width: 0; display: grid; }++.ovc-src small { color: var(--text-muted, #93a0b4); font-size: 12.5px; }++.ovc-src input { flex: none; width: 20px; height: 20px; accent-color: var(--ovt-accent, var(--accent, #3b82f6)); }++@media (max-width: 600px) { .ovc-sheet { width: 100vw; max-width: none; margin: auto 0 0; border-radius: 22px 22px 0 0; max-height: 86vh; padding-bottom: env(safe-area-inset-bottom); } }++.ovc-answer { box-sizing: border-box; margin: 10px 0 0; padding: 12px 14px; border: 1px solid color-mix(in srgb, #a855f7 30%, var(--border, rgba(148, 163, 184, .2))); border-radius: 18px; background: color-mix(in srgb, #a855f7 6%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); }++.ovc-answer header { display: flex; align-items: center; gap: 8px; }++.ovc-answer header > svg { width: 17px; height: 17px; color: #c084fc; }++.ovc-answer header small { color: var(--text-muted, #93a0b4); }++.ovc-answer header .ovc-bar-x { margin-left: auto; }++.ovc-answer-t { margin: 6px 0 0; white-space: pre-wrap; line-height: 1.5; font-size: 15px; }++.ovc-answer-t.is-waiting { color: var(--text-muted, #93a0b4); }++.ovc-answer-t.is-bad { color: #fca5a5; }++.ovc-cites { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }++.ovc-cites a { display: inline-block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); color: var(--text-primary, #e6edf7); font-size: 12.5px; text-decoration: none; }++.ovc-answer footer { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }++.ovc-answer-a { min-height: 36px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 999px; background: none; color: var(--text-primary, #e6edf7); font: 600 13.5px/1 inherit; cursor: pointer; }++.ovc-answer-a:first-child { background: var(--ovt-accent, var(--accent, #3b82f6)); border-color: transparent; color: #fff; }++@media (pointer: coarse) { .ovc-answer-a { min-height: 44px; } }.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }--.ovc-v2 .ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }--.ovc-v2 .ovc-bar-x:hover, .ovc-v2 .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }--.ovc-v2 .ovc-bar-x svg { width: 15px; height: 15px; }--@media (pointer: coarse) { .ovc-v2 .ovc-bar-x { width: 40px; height: 40px; } }++.ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }++.ovc-bar-x:hover, .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }++.ovc-bar-x svg { width: 15px; height: 15px; }++@media (pointer: coarse) { .ovc-bar-x { width: 40px; height: 40px; } }.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }@@@ -293,6 +358,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }camera: 'M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM12 10a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7z',flip: 'M4 9a8 8 0 0 1 14-3l2 2M20 15a8 8 0 0 1-14 3l-2-2M20 4v4h-4M4 20v-4h4',retry: 'M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5',++ bolt: 'M13 3L5 13.5h6L10 21l9-11h-6.2z',++ model: 'M12 3l7.5 4.3v8.4L12 20l-7.5-4.3V7.3zM12 8v4.2M12 12.2l3.6 2.1M12 12.2l-3.6 2.1',++ layers: 'M12 4l8.5 4.5L12 13 3.5 8.5zM3.5 13L12 17.5 20.5 13',++ ext: 'M14 5h5v5M19 5l-8 8M10 6H6a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4',};const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };@@@ -723,6 +792,14 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }// "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),// reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),// clearBar(), editing() }.++ // 4.218.0, Post · Ask · Do (research-2026-11-12/composer-post-ask-do.md): verbs: ['post', 'ask', 'do'] (a site++ // opts in; the first is where the box starts) draws one switch at the start of the row. Ask and Do add three chips:++ // Model (a sheet over the Frame's /choices?task=: Auto, then OpenVibe from Exchange's directory with prices, your++ // keys, your computers, your plans), Sources (web, OpenVibe, mine, this page) and Depth (quick, balanced, deep).++ // ask: { task, context: { site, url, title, kind, id } }, frameBase (default https://openvibe.network/api/frame),++ // onAsk(value, api) / onDo(value, api) to answer it yourself; without them the box asks the Frame (/ask, /do) and++ // streams the run into an answer card under the box (api.answer() gives the card to a site that answers itself).++ // value = { verb, text, files, uploads, model, sources, depth, context, chips }.// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.const PRESET = {@@@ -826,6 +903,31 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);}const initial = (u) => String((u && (u.display_name || u.username)) || '?').trim().charAt(0).toUpperCase() || '?';++ const FRAME = 'https://openvibe.network/api/frame';++ const VERBS = { post: { icon: 'send', hint: 'to people' }, ask: { icon: 'spark', word: 'Ask', hint: 'AI answers you' }, do: { icon: 'bolt', word: 'Do', hint: 'Actor does it for you' } };++ const SOURCES = [['web', 'Web', 'globe'], ['openvibe', 'OpenVibe', 'layers'], ['mine', 'Mine', 'file'], ['page', 'This page', 'photo']];++ const choicesCache = new Map();++ // The Frame's model list for a task, in one shape whatever answered (the Frame's groups, or AI's own options).++ async function loadChoices(frame, task) {++ const key = `${frame}|${task}`;++ if (choicesCache.has(key)) return choicesCache.get(key);++ const job = fetch(`${frame}/choices?task=${encodeURIComponent(task)}`, { credentials: 'include', headers: { Accept: 'application/json' } })++ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Models are unavailable right now.'))))++ .then((d) => {++ if (Array.isArray(d.groups)) return d;++ const o = d.options || {};++ return { recommended: d.recommended, groups: [['openvibe', 'OpenVibe'], ['keys', 'Your keys'], ['machines', 'Your computers']].map(([id, title]) => ({ id, title, rows: o[id] || [] })) };++ });++ choicesCache.set(key, job);++ job.catch(() => choicesCache.delete(key));++ return job;++ }++ const money = (row) => {++ if (row.free || (row.price && row.price.in === 0) || row.price_estimate_usd === 0) return 'Free';++ if (row.price && row.price.in != null) return `$${Number(row.price.in).toFixed(row.price.in < 1 ? 2 : 1)} / M`;++ if (row.price_estimate_usd != null) return `about $${Number(row.price_estimate_usd).toFixed(3)}`;++ return '';++ };function mount(host, o = {}) {if (!host) return null;styles();@@@ -912,12 +1014,52 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }// A post or a reply says who sees it above the box (a phone's row keeps room for the tools); a chat or an agent's// box keeps its chip beside the send.const above = name === 'post' || name === 'comment';++ // Post · Ask · Do: one switch at the start of the row (a site opts in with verbs).++ const verbs = (Array.isArray(o.verbs) ? o.verbs : []).filter((v) => VERBS[v]);++ let verb = verbs.length ? (verbs.includes(o.verb) ? o.verb : verbs[0]) : 'post';++ const frame = o.frameBase || FRAME;++ const askTask = (o.ask && o.ask.task) || (name === 'agent' ? 'agent' : 'chat');++ let model = null;++ try { model = JSON.parse(localStorage.getItem(`ov:composer:model:${askTask}`) || 'null'); } catch { model = null; }++ model = model || { model: 'openvibe/auto', source: 'openvibe', name: 'Auto' };++ const sources = new Set((o.ask && o.ask.sources) || ['web', 'openvibe']);++ let switcher = null;++ if (verbs.length > 1) {++ switcher = el('div', 'ovc-verbs'); switcher.setAttribute('role', 'group'); switcher.setAttribute('aria-label', 'What this box does');++ verbs.forEach((v) => {++ const word = v === 'post' ? (o.submitLabel || p.send || 'Send') : VERBS[v].word;++ const b = el('button', 'ovc-verb', svg(IC[v === 'post' ? (p.send ? 'send' : 'send') : VERBS[v].icon]) + `<span>${word}</span>`);++ b.type = 'button'; b.dataset.verb = v; b.title = `${word}: ${VERBS[v].hint}`; b.setAttribute('aria-label', `${word}: ${VERBS[v].hint}`);++ b.addEventListener('click', () => setVerb(v));++ switcher.appendChild(b);++ });++ if (above && head) { switcher.classList.add('ovc-verbs--head'); head.appendChild(switcher); } else row.appendChild(switcher);++ }++ const askChips = el('div', 'ovc-ask-chips'); askChips.setAttribute('role', 'group'); askChips.setAttribute('aria-label', 'How it is answered');++ const modelChip = el('button', 'ovc-chip ovc-chip--btn', `${svg(IC.model)}<span class="ovc-chip-v"></span>`);++ modelChip.type = 'button'; modelChip.setAttribute('aria-haspopup', 'dialog');++ const paintModel = () => { modelChip.querySelector('.ovc-chip-v').textContent = model.name || model.model; modelChip.setAttribute('aria-label', `Model: ${model.name || model.model}. Change it`); };++ const srcChip = el('button', 'ovc-chip ovc-chip--btn', `${svg(IC.globe)}<span class="ovc-chip-v"></span>`);++ srcChip.type = 'button'; srcChip.setAttribute('aria-haspopup', 'dialog');++ const paintSources = () => {++ const on = SOURCES.filter(([id]) => sources.has(id) && (id !== 'page' || (o.ask && o.ask.context)));++ srcChip.querySelector('.ovc-chip-v').textContent = on.length ? on.map(([, w]) => w).join(' · ') : 'No sources';++ srcChip.classList.toggle('is-on', sources.has('web'));++ srcChip.setAttribute('aria-label', `Sources: ${srcChip.textContent}. Change them`);++ };++ const depthChip = chip({ name: 'depth', label: 'Depth', options: [['quick', 'Quick'], ['balanced', 'Balanced'], ['deep', 'Deep research']], value: (o.ask && o.ask.depth) || 'balanced' }, IC.gauge);++ askChips.append(modelChip, srcChip, depthChip);++ paintModel(); paintSources();++ modelChip.addEventListener('click', () => openModels());++ srcChip.addEventListener('click', () => openSources());row.appendChild(tools);if (!above) row.appendChild(chips);if (ring) row.appendChild(ring);row.appendChild(send);if (above && chips.children.length) head.appendChild(chips);if (head.children.length) body.appendChild(head);++ // Ask and Do's chips: a line of their own above the words (scrolls sideways on a narrow screen, never wider).++ if (verbs.includes('ask') || verbs.includes('do')) body.appendChild(askChips);body.append(ta, row);const note = el('p', 'ovc-note'); note.setAttribute('data-ovt-note', ''); note.setAttribute('role', 'status');form.append(body, note);@@@ -999,6 +1141,176 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }ta.focus();};ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });++ // ── Post · Ask · Do ──++ const postFace = send.innerHTML, postLabel = send.getAttribute('aria-label'), postRound = send.classList.contains('ovt-send--round');++ function setVerb(v) {++ if (!VERBS[v] || (verbs.length && !verbs.includes(v))) return;++ verb = v;++ form.dataset.verb = v;++ form.classList.toggle('is-asking', v !== 'post');++ if (switcher) switcher.querySelectorAll('.ovc-verb').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.verb === v)));++ if (v === 'post') { send.innerHTML = postFace; send.classList.toggle('ovt-send--round', postRound); send.setAttribute('aria-label', postLabel); }++ else { send.innerHTML = svg(IC[VERBS[v].icon]); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', VERBS[v].word); }++ ta.placeholder = v === 'ask' ? 'Ask anything' : v === 'do' ? 'Tell Actor what to do' : (o.placeholder || ta.dataset.ovcPlaceholder || ta.placeholder);++ api.refresh();++ }++ ta.dataset.ovcPlaceholder = ta.placeholder;++ // A sheet (the whole width on a phone) for the model list and the sources.++ function sheet(title, build) {++ const dlg = el('dialog', 'ovc-sheet'); dlg.setAttribute('aria-label', title);++ dlg.innerHTML = `<div class="ovc-sheet-h"><b></b><button type="button" class="ovc-bar-x" aria-label="Close">${svg(IC.x)}</button></div><div class="ovc-sheet-b"></div>`;++ dlg.querySelector('b').textContent = title;++ const close = () => { try { dlg.close(); } catch { /* closed */ } dlg.remove(); };++ dlg.querySelector('.ovc-bar-x').addEventListener('click', close);++ dlg.addEventListener('click', (e) => { if (e.target === dlg) close(); });++ dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); });++ document.body.appendChild(dlg);++ build(dlg.querySelector('.ovc-sheet-b'), close);++ if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');++ return dlg;++ }++ function openModels() {++ sheet('Model', async (b, close) => {++ const find = el('input', 'ovc-sheet-find'); find.type = 'search'; find.placeholder = 'Find a model'; find.setAttribute('aria-label', 'Find a model');++ const list = el('div', 'ovc-models');++ b.append(find, list);++ const pick = (row) => {++ model = { model: row.model, source: row.source || 'openvibe', ...(row.machine ? { machine: row.machine } : {}), name: row.name || row.model };++ try { localStorage.setItem(`ov:composer:model:${askTask}`, JSON.stringify(model)); } catch { /* private mode */ }++ paintModel(); close(); ta.focus();++ };++ const rowEl = (row, rec) => {++ const r = el('div', 'ovc-model');++ const btn = el('button', 'ovc-model-b'); btn.type = 'button';++ btn.setAttribute('aria-pressed', String(row.model === model.model && (row.source || 'openvibe') === (model.source || 'openvibe')));++ const mark = el('span', 'ovc-model-m'); mark.textContent = String(row.maker || row.name || row.model || '?').trim().charAt(0).toUpperCase();++ const t = el('span', 'ovc-model-t'); const line = el('span', 'ovc-model-n'); const n = el('b'); n.textContent = row.name || row.model; line.appendChild(n); const d = el('small');++ d.textContent = [money(row), row.speed ? `${row.speed}` : '', row.maker || ''].filter(Boolean).join(' · ') || row.detail || '';++ if (rec) { const badge = el('em', 'ovc-model-rec'); badge.textContent = 'Recommended'; line.appendChild(badge); }++ t.append(line, d); btn.append(mark, t);++ btn.addEventListener('click', () => pick(row));++ r.appendChild(btn);++ if (row.url) { const a = el('a', 'ovc-model-x', svg(IC.ext)); a.href = row.url; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Compare on OpenVibe.Exchange'; a.setAttribute('aria-label', `${row.name || row.model} on OpenVibe.Exchange`); r.appendChild(a); }++ r.dataset.find = `${row.name || ''} ${row.model || ''} ${row.maker || ''}`.toLowerCase();++ return r;++ };++ const auto = { model: 'openvibe/auto', source: 'openvibe', name: 'Auto', detail: 'OpenVibe picks a model for each question' };++ list.appendChild(rowEl(auto));++ const wait = el('p', 'ovc-sheet-note'); wait.textContent = 'Loading the models…'; list.appendChild(wait);++ try {++ const data = await loadChoices(frame, askTask);++ wait.remove();++ const rec = data.recommended;++ (data.groups || []).forEach((g) => {++ if (!g.rows || !g.rows.length) return;++ const h = el('h3'); h.textContent = g.title || g.id; list.appendChild(h);++ g.rows.forEach((row) => list.appendChild(rowEl(row, rec && rec.model === row.model && (rec.source || 'openvibe') === (row.source || 'openvibe'))));++ });++ const more = el('a', 'ovc-sheet-more', `Every model, compared, on OpenVibe.Exchange ${svg(IC.ext)}`); more.href = 'https://openvibe.exchange/models'; more.target = '_blank'; more.rel = 'noopener';++ list.appendChild(more);++ } catch (err) { wait.textContent = (err && err.message) || 'Models are unavailable right now.'; }++ find.addEventListener('input', () => {++ const q = find.value.trim().toLowerCase();++ list.querySelectorAll('.ovc-model').forEach((r) => { r.hidden = !!q && !r.dataset.find.includes(q); });++ });++ find.focus();++ });++ }++ function openSources() {++ sheet('What the answer may read', (b) => {++ SOURCES.forEach(([id, word, icon]) => {++ if (id === 'page' && !(o.ask && o.ask.context)) return;++ const lab = el('label', 'ovc-src', `${svg(IC[icon])}<span><b></b><small></small></span>`);++ lab.querySelector('b').textContent = word;++ lab.querySelector('small').textContent = id === 'web' ? 'Search the web (OpenVibe Search, then other engines)' : id === 'openvibe' ? 'Posts, streams, videos, wiki and pastes on OpenVibe' : id === 'mine' ? 'Your own posts, files and chats' : `This ${(o.ask.context && o.ask.context.kind) || 'page'}`;++ const box = el('input'); box.type = 'checkbox'; box.checked = sources.has(id); box.setAttribute('role', 'switch');++ box.addEventListener('change', () => { if (box.checked) sources.add(id); else sources.delete(id); paintSources(); });++ lab.appendChild(box);++ b.appendChild(lab);++ });++ });++ }++ // The answer (or the run) under the box: streamed text, its sources, the model and three ways on.++ let card = null;++ function answer(question) {++ if (card) card.remove();++ card = el('section', 'ovc-answer'); card.setAttribute('aria-live', 'polite');++ card.innerHTML = `<header>${svg(IC[verb === 'do' ? 'bolt' : 'spark'])}<b></b><small></small><button type="button" class="ovc-bar-x" aria-label="Close the answer">${svg(IC.x)}</button></header><div class="ovc-answer-t"></div><ol class="ovc-cites" hidden></ol><footer hidden></footer>`;++ card.querySelector('b').textContent = verb === 'do' ? 'Actor' : 'Answer';++ card.querySelector('small').textContent = model.name || '';++ const body = card.querySelector('.ovc-answer-t'), cites = card.querySelector('.ovc-cites'), foot = card.querySelector('footer');++ body.textContent = verb === 'do' ? 'Planning…' : 'Thinking…'; body.classList.add('is-waiting');++ card.querySelector('.ovc-bar-x').addEventListener('click', () => { card.remove(); card = null; });++ form.after(card);++ let started = false;++ const ctl = {++ el: card,++ append(delta) { if (!started) { body.textContent = ''; body.classList.remove('is-waiting'); started = true; } body.textContent += String(delta || ''); },++ set(t) { started = true; body.classList.remove('is-waiting'); body.textContent = String(t || ''); },++ cite(list) {++ cites.replaceChildren();++ (list || []).forEach((c, i) => {++ const li = el('li'); const a = el('a'); a.href = c.url; a.target = '_blank'; a.rel = 'noopener nofollow';++ a.textContent = `${c.n || i + 1}. ${c.title || c.site || c.url}`; if (c.site) a.title = c.site;++ li.appendChild(a); cites.appendChild(li);++ });++ cites.hidden = !cites.children.length;++ },++ done(meta = {}) {++ if (meta.answer != null) ctl.set(meta.answer);++ if (meta.citations) ctl.cite(meta.citations);++ if (meta.cost != null) card.querySelector('small').textContent = `${model.name || ''} · $${Number(meta.cost).toFixed(3)}`;++ foot.replaceChildren();++ const act = (label, fn) => { const b = el('button', 'ovc-answer-a'); b.type = 'button'; b.textContent = label; b.addEventListener('click', fn); foot.appendChild(b); };++ if (verbs.includes('post')) act(name === 'chat' ? 'Send it' : name === 'comment' ? 'Reply with it' : 'Post it', () => { setVerb('post'); ta.value = body.textContent; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); });++ act('Continue in Vibe', () => { form.dispatchEvent(new CustomEvent('ovc:continue', { bubbles: true, detail: { run: meta.run, question } })); if (root.OVDock && typeof root.OVDock.open === 'function') root.OVDock.open('vibe', { run: meta.run, text: question }); });++ if (verb === 'ask' && verbs.includes('do')) act('Do this', () => { setVerb('do'); ta.value = question; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); });++ foot.hidden = false;++ },++ fail(msg) { started = true; body.classList.remove('is-waiting'); body.classList.add('is-bad'); body.textContent = String(msg || 'That did not work. Try again.'); },++ };++ return ctl;++ }++ // Ask or Do through the Frame: start the run, then follow its events (AG-UI) into the card.++ async function viaFrame(value, ctl) {++ const r = await fetch(`${frame}/${value.verb}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(value) });++ const d = await r.json().catch(() => ({}));++ if (!r.ok || !d.run) throw new Error(d.detail || d.error || (r.status === 401 ? 'Sign in to ask.' : 'Could not ask right now.'));++ const events = d.events || `${frame.replace(/\/api\/frame\/?$/, '')}/api/v1/runs/${encodeURIComponent(d.run)}/events`;++ if (typeof EventSource !== 'function') return;++ await new Promise((resolve) => {++ const es = new EventSource(events, { withCredentials: true });++ const end = () => { es.close(); resolve(); };++ es.onmessage = (m) => {++ let ev = null; try { ev = JSON.parse(m.data); } catch { return; }++ if (ev.type === 'TEXT_MESSAGE_CONTENT') ctl.append(ev.delta);++ else if (ev.type === 'CUSTOM' && ev.name === 'citations') ctl.cite(ev.value);++ else if (ev.type === 'RUN_FINISHED') { ctl.done({ run: d.run, ...(ev.result || {}) }); end(); }++ else if (ev.type === 'RUN_ERROR') { ctl.fail(ev.message); end(); }++ };++ es.onerror = () => { if (es.readyState === 2) { ctl.fail('The answer stopped. Try again.'); end(); } };++ });++ }++ form.addEventListener('submit', async (e) => {++ if (verb === 'post' || editing) return;++ e.preventDefault(); e.stopImmediatePropagation();++ if (form.classList.contains('is-busy')) return;++ const text = ta.value.trim();++ if (!text && !api.files().length) return;++ const value = { verb, text, files: api.files(), uploads: api.uploads(), model, sources: [...sources], depth: depthChip ? depthChip.querySelector('select').value : 'balanced', context: (o.ask && o.ask.context) || null, chips: values() };++ const ctl = answer(text);++ setBusy(true);++ try {++ const own = verb === 'ask' ? o.onAsk : o.onDo;++ if (typeof own === 'function') await own(value, Object.assign(api, { card: ctl }));++ else await viaFrame({ ...value, files: undefined }, ctl);++ api.clear();++ } catch (err) { ctl.fail((err && err.message) || String(err)); }++ setBusy(false);++ api.refresh();++ }, true);++ if (verbs.length) setVerb(verb);++// Saving an edit is not a send: it runs first and nothing else hears the submit.form.addEventListener('submit', async (e) => {if (!editing) return;@@@ -1028,7 +1340,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }api.refresh();});}-- return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing });++ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing,++ verb: () => verb, setVerb, model: () => model, sources: () => [...sources], answer });}// ── Your settings, in a sheet: what sends, voice, compact, your own commands.@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': '3ddb2aa1ca88', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': '203e770ec00f', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -37,16 +37,19 @@s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');document.head.appendChild(s);}-- function loadComposer() {-- if (root.OpenVibeComposer) { root.OpenVibeComposer.scan(); return; }++ function loadComposer(after) {++ if (root.OpenVibeComposer) { if (after) after(); else root.OpenVibeComposer.scan(); return; }++ if (after) loadComposer.waiting.push(after);if (loadComposer.on) return;loadComposer.on = true;var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);var s = document.createElement('script');s.async = true;s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-composer.js' + (THREAD_REV['ov-composer.js'] ? '?v=' + THREAD_REV['ov-composer.js'] : '');++ s.addEventListener('load', function () { var list = loadComposer.waiting; loadComposer.waiting = []; list.forEach(function (fn) { try { fn(); } catch (e) { /* one box's failure never holds the page */ } }); }, { once: true });document.head.appendChild(s);}++ loadComposer.waiting = [];function q(el, sel) { return el ? el.querySelector(sel) : null; }function qa(el, sel) { return el ? Array.prototype.slice.call(el.querySelectorAll(sel)) : []; }@@@ -254,7 +257,7 @@function start() {loadCombo();-- qa(document, '[data-ovt]').forEach(function (box) {++ qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {if (box.dataset.ovtReady) return;box.dataset.ovtReady = '1';var head = q(box, '[data-ovt-head]');@@@ -311,41 +314,103 @@});}-- // The composer.-- var form = q(box, '[data-ovt-composer]');-- if (form) loadComposer();-- var text = q(box, '[data-ovt-text]');-- var send = q(box, '[data-ovt-send]');-- function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }-- function syncSend() { if (send && text) send.disabled = !text.value.trim(); }-- if (text) {-- text.addEventListener('input', function () { grow(); syncSend(); });-- text.addEventListener('keydown', function (e) {-- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {-- e.preventDefault();-- if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();-- }-- });-- grow();-- syncSend();++ // The composer. An inline Community thread (thread.renderThread) draws the comment preset: the same++ // picture, box, tools, count and Reply as every site's comments, replacing the server-drawn form in place++ // (without script that form still posts).++ var form = null, text = null, send = null;++ function bootComposer(box) {++ var f = q(box, '[data-ovt-composer]');++ if (!f) return;++ if (!f.closest('[data-ovc-thread]')) {++ if (!root.OpenVibeComposer) loadComposer();++ else if (root.OpenVibeComposer.enhance) root.OpenVibeComposer.enhance(f);++ wireComposer(f);++ return;++ }++ if (!root.OpenVibeComposer) { loadComposer(function () { bootComposer(box); }); return; }++ if (root.OpenVibeComposer.mount) {++ var upgraded = upgradeComposer(f);++ if (upgraded) { wireComposer(upgraded); return; }++ }++ if (root.OpenVibeComposer.enhance) root.OpenVibeComposer.enhance(f);++ wireComposer(f);}-- qa(box, '[data-ovt-starter]').forEach(function (b) {-- b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });-- });---- // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).-- if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {-- text.__ovtEx = true;-- var exI = 0;-- var exTimer = setInterval(function () {-- if (!text.isConnected) { clearInterval(exTimer); return; }-- var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }-- if (!list.length || text.value || document.activeElement === text || document.hidden) return;-- exI = (exI + 1) % list.length;-- text.classList.add('is-ph-out');-- setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);-- }, 3600);++ // The form's own submit stays: mount() draws the box and the server's action, method and hidden fields++ // (a reply's parent_id) go back on it, so the page still posts without a site script.++ function upgradeComposer(f) {++ if (!f || f.__ovtUpgraded || !root.OpenVibeComposer || !root.OpenVibeComposer.mount) return null;++ var oldText = f.querySelector('[data-ovt-text]') || f.querySelector('textarea');++ if (!oldText) return null;++ var action = f.getAttribute('action') || '';++ var method = f.getAttribute('method') || 'post';++ var hidden = qa(f, 'input[type=hidden]').map(function (i) { return [i.name, i.value]; });++ var file = f.querySelector('input[type=file]');++ var note = q(f, '[data-ovt-note]');++ var sendBtn = q(f, '[data-ovt-send]');++ var label = (sendBtn && (sendBtn.getAttribute('aria-label') || sendBtn.textContent.trim())) || 'Reply';++ var preset = f.getAttribute('data-ovt-preset') || 'comment';++ var host = document.createElement('div');++ f.replaceWith(host);++ var c = null;++ try {++ c = root.OpenVibeComposer.mount(host, {++ preset: preset, name: oldText.name || 'message', placeholder: oldText.placeholder || '',++ maxLength: Number(oldText.getAttribute('maxlength')) || 2000, submitLabel: label,++ tools: file ? ['photo', 'emoji'] : ['emoji'], maxFiles: Number(f.getAttribute('data-ovc-max-files')) || 10,++ });++ } catch (e) { c = null; }++ if (!c) { host.replaceWith(f); return null; }++ c.form.action = action; c.form.method = method;++ if (file) {++ c.form.enctype = 'multipart/form-data';++ var field = file.name || 'files';++ qa(c.form, 'input[type=file]').forEach(function (i) { i.name = field; if (file.accept) i.accept = file.accept; });++ }++ hidden.forEach(function (h) { var i = document.createElement('input'); i.type = 'hidden'; i.name = h[0]; i.value = h[1]; c.form.appendChild(i); });++ if (oldText.value) c.text.value = oldText.value;++ if (note && note.textContent) c.note.textContent = note.textContent;++ c.form.__ovtUpgraded = true;++ return c.form;++ }++ function wireComposer(f) {++ if (!f || f.__ovtWired) return;++ form = f;++ text = q(f, '[data-ovt-text]') || q(f, 'textarea');++ send = q(f, '[data-ovt-send]');++ function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }++ function syncSend() { if (send && text) send.disabled = !text.value.trim(); }++ if (text && !text.__ovtKeys) {++ text.__ovtKeys = true;++ text.addEventListener('input', function () { grow(); syncSend(); });++ text.addEventListener('keydown', function (e) {++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {++ e.preventDefault();++ if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();++ }++ });++ grow();++ syncSend();++ }++ qa(box, '[data-ovt-starter]').forEach(function (b) {++ if (b.__ovtStarter) return; b.__ovtStarter = true;++ b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); if (form.__ovc && form.__ovc.ctx) form.__ovc.ctx.set(text.value); grow(); syncSend(); text.focus(); });++ });++ // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).++ if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {++ text.__ovtEx = true;++ var exI = 0;++ var exTimer = setInterval(function () {++ if (!text.isConnected) { clearInterval(exTimer); return; }++ var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }++ if (!list.length || text.value || document.activeElement === text || document.hidden) return;++ exI = (exI + 1) % list.length;++ text.classList.add('is-ph-out');++ setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);++ }, 3600);++ }++ f.__ovtWired = true;}++ bootComposer(box);// Options: one button, the pickers behind it (a <details>, so it opens without script too). A tap outside,// Escape or Done closes it; on a phone it is a sheet and the page behind stays put; a changed picker shows as@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.217.0",++ "version": "4.218.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.217.0",++ "version": "4.218.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.217.0",++ "version": "4.218.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++// Post · Ask · Do (ov-composer.js 4.218.0; research-2026-11-12/composer-post-ask-do.md) in a real headless Chrome at 390 px,++// against a fake Network Frame:++// - a box that opts in shows the three verbs; Ask and Do bring the Model, Sources and Depth chips; Post hides them;++// - the Model sheet lists the Frame's groups (Auto first, OpenVibe with prices and an Exchange link, your keys), finds++// by name, and a pick names the chip and is remembered;++// - Sources are switches; the chip says what is on;++// - Ask posts { verb, text, model, sources, depth, context } to the Frame and streams the run's events into an answer++// card with its sources; "Post it" puts the answer in the box as a post; the box's own onSubmit never hears an Ask;++// - a site can answer itself (onAsk) through the same card.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-verbs-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++++const root = path.join(__dirname, '..');++const asked = [];++const CHOICES = { task: 'chat', recommended: { model: 'deepseek/v4.1-flash', source: 'openvibe' }, groups: [++ { id: 'openvibe', title: 'OpenVibe', rows: [++ { model: 'deepseek/v4.1-flash', source: 'openvibe', name: 'DeepSeek V4.1 Flash', maker: 'DeepSeek', price: { in: 0.27, out: 1.1, unit: 'per million tokens' }, url: 'https://openvibe.exchange/models/deepseek/v4.1-flash' },++ { model: 'qwen/qwen3-32b', source: 'openvibe', name: 'Qwen3 32B', maker: 'Qwen', free: true, url: 'https://openvibe.exchange/models/qwen/qwen3-32b' },++ ] },++ { id: 'keys', title: 'Your keys', rows: [{ model: 'anthropic/claude-sonnet', source: 'key', name: 'Claude Sonnet (your key)', maker: 'Anthropic' }] },++ { id: 'machines', title: 'Your computers', rows: [] },++] };++const server = http.createServer((req, res) => {++ const u = req.url.split('?')[0];++ if (['/ov-composer.js', '/ov-window.js', '/ov-capture.js'].includes(u)) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }++ if (u === '/api/frame/choices') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(CHOICES)); }++ if (u === '/api/frame/ask' && req.method === 'POST') {++ let body = ''; req.on('data', (c) => { body += c; });++ return req.on('end', () => { asked.push(JSON.parse(body)); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ run: 'run_1' })); });++ }++ if (u === '/api/v1/runs/run_1/events') {++ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });++ const send = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);++ send({ type: 'RUN_STARTED' });++ send({ type: 'TEXT_MESSAGE_CONTENT', delta: 'Streams on OpenVibe ' });++ setTimeout(() => {++ send({ type: 'TEXT_MESSAGE_CONTENT', delta: 'can be recorded as VODs [1].' });++ send({ type: 'CUSTOM', name: 'citations', value: [{ n: 1, title: 'Going live in your browser', url: 'https://openvibe.live/docs/go-live-in-your-browser', site: 'openvibe.live' }] });++ send({ type: 'RUN_FINISHED', result: { cost: 0.0012 } });++ res.end();++ }, 150);++ return undefined;++ }++ res.writeHead(200, { 'content-type': 'text/html' });++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">++<body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div id="a"></div><div id="b"></div>++<script src="/ov-composer.js"></script>++<script>++window.posted = []; window.own = [];++const C = OpenVibeComposer;++window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1', user: { display_name: 'goosely' }, verbs: ['post', 'ask', 'do'],++ frameBase: location.origin + '/api/frame', ask: { task: 'chat', context: { site: 'live', kind: 'stream', title: 'Maticus live', url: 'https://openvibe.live/@Maticus' } },++ onSubmit: async (v) => { window.posted.push(v.text); } });++window.chatBox = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', verbs: ['post', 'ask'], frameBase: location.origin + '/api/frame',++ onSubmit: async () => true, onAsk: async (v, api) => { window.own.push(v.text); api.card.set('An answer the site gave itself.'); api.card.done({}); } });++</script>`);++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'composer-verbs-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });++ await page.goto(base + '/', { maxMs: 5000 });++ const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };++ assert.ok(await until(`!!window.chatBox`), 'the boxes mount');++++ // The three verbs; Post first; the Ask chips hidden on Post.++ const shape = await page.evaluate(`(() => { const f = document.getElementById('p1'); return { verbs: [...f.querySelectorAll('.ovc-verb')].map((b) => b.dataset.verb + ':' + b.getAttribute('aria-pressed')), verb: f.dataset.verb, chipsShown: getComputedStyle(f.querySelector('.ovc-ask-chips')).display !== 'none' }; })()`);++ assert.deepEqual(shape, { verbs: ['post:true', 'ask:false', 'do:false'], verb: 'post', chipsShown: false });++ await page.evaluate(`document.querySelector('#p1 .ovc-verb[data-verb="ask"]').click()`);++ const asking = await page.evaluate(`(() => { const f = document.getElementById('p1'); return { verb: f.dataset.verb, send: f.querySelector('[data-ovt-send]').getAttribute('aria-label'), chips: [...f.querySelectorAll('.ovc-ask-chips .ovc-chip-v')].map((c) => c.textContent), placeholder: f.querySelector('textarea').placeholder }; })()`);++ assert.deepEqual(asking, { verb: 'ask', send: 'Ask', chips: ['Auto', 'Web · OpenVibe', 'Balanced'], placeholder: 'Ask anything' });++++ // The Model sheet: Auto, then the Frame's groups with prices and an Exchange link; find; pick.++ await page.evaluate(`document.querySelector('#p1 .ovc-ask-chips .ovc-chip--btn').click()`);++ assert.ok(await until(`document.querySelectorAll('dialog.ovc-sheet .ovc-model').length >= 4`), 'the models are listed');++ const sheet = await page.evaluate(`(() => { const d = document.querySelector('dialog.ovc-sheet'); return { heads: [...d.querySelectorAll('h3')].map((x) => x.textContent), first: d.querySelector('.ovc-model b').textContent, price: [...d.querySelectorAll('.ovc-model small')].map((x) => x.textContent)[1], rec: !!d.querySelector('.ovc-model-rec'), link: d.querySelector('.ovc-model-x').href, width: Math.round(d.getBoundingClientRect().width) }; })()`);++ assert.deepEqual(sheet.heads, ['OpenVibe', 'Your keys'], 'groups with rows only');++ assert.match(sheet.first, /^Auto/);++ assert.match(sheet.price, /\$0\.27 \/ M/, 'a price per million');++ assert.ok(sheet.rec, 'the recommended model is marked');++ assert.match(sheet.link, /openvibe\.exchange\/models\//, 'each OpenVibe model opens on Exchange');++ assert.equal(sheet.width, 390, 'the whole width on a phone');++ await page.evaluate(`(() => { const f = document.querySelector('dialog.ovc-sheet .ovc-sheet-find'); f.value = 'qwen'; f.dispatchEvent(new Event('input')); })()`);++ assert.equal(await page.evaluate(`[...document.querySelectorAll('dialog.ovc-sheet .ovc-model')].filter((r) => !r.hidden).length`), 1, 'find narrows the list');++ await page.evaluate(`[...document.querySelectorAll('dialog.ovc-sheet .ovc-model')].find((r) => !r.hidden).querySelector('.ovc-model-b').click()`);++ assert.equal(await page.evaluate(`document.querySelector('#p1 .ovc-ask-chips .ovc-chip-v').textContent`), 'Qwen3 32B');++ assert.equal(await page.evaluate(`JSON.parse(localStorage.getItem('ov:composer:model:chat')).model`), 'qwen/qwen3-32b', 'remembered');++++ // Sources: switches, and this page because the site gave its context.++ await page.evaluate(`document.querySelectorAll('#p1 .ovc-ask-chips .ovc-chip--btn')[1].click()`);++ assert.ok(await until(`document.querySelectorAll('dialog.ovc-sheet .ovc-src').length === 4`), 'web, OpenVibe, mine and this page');++ await page.evaluate(`(() => { const box = [...document.querySelectorAll('dialog.ovc-sheet .ovc-src')].find((l) => /This/.test(l.textContent)).querySelector('input'); box.click(); document.querySelector('dialog.ovc-sheet .ovc-bar-x').click(); })()`);++ assert.equal(await page.evaluate(`document.querySelectorAll('#p1 .ovc-ask-chips .ovc-chip-v')[1].textContent`), 'Web · OpenVibe · This page');++++ // Ask: to the Frame, streamed into the card with its sources; onSubmit never hears it.++ await page.evaluate(`(() => { const t = post.text; t.value = 'Can I record my stream?'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('#p1 [data-ovt-send]').click(); })()`);++ assert.ok(await until(`!!document.querySelector('.ovc-answer footer:not([hidden])')`), 'the answer finishes');++ const card = await page.evaluate(`(() => { const c = document.querySelector('.ovc-answer'); return { text: c.querySelector('.ovc-answer-t').textContent, cites: [...c.querySelectorAll('.ovc-cites a')].map((a) => a.textContent), meta: c.querySelector('header small').textContent, actions: [...c.querySelectorAll('footer button')].map((b) => b.textContent) }; })()`);++ assert.equal(card.text, 'Streams on OpenVibe can be recorded as VODs [1].');++ assert.deepEqual(card.cites, ['1. Going live in your browser']);++ assert.match(card.meta, /Qwen3 32B · \$0\.001/);++ assert.deepEqual(card.actions, ['Post it', 'Continue in Vibe', 'Do this']);++ assert.equal(asked.length, 1);++ assert.deepEqual({ verb: asked[0].verb, text: asked[0].text, model: asked[0].model.model, sources: asked[0].sources, depth: asked[0].depth, context: asked[0].context.kind },++ { verb: 'ask', text: 'Can I record my stream?', model: 'qwen/qwen3-32b', sources: ['web', 'openvibe', 'page'], depth: 'balanced', context: 'stream' });++ assert.equal(await page.evaluate(`window.posted.length`), 0, 'an Ask is not a post');++ // Post it: the answer becomes the post, on the Post verb.++ await page.evaluate(`document.querySelector('.ovc-answer footer button').click()`);++ assert.deepEqual(await page.evaluate(`({ verb: document.getElementById('p1').dataset.verb, text: post.text.value })`), { verb: 'post', text: 'Streams on OpenVibe can be recorded as VODs [1].' });++++ // A site that answers itself, through the same card.++ await page.evaluate(`(() => { document.querySelector('#c1 .ovc-verb[data-verb="ask"]').click(); const t = chatBox.text; t.value = 'what is up'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('#c1 [data-ovt-send]').click(); })()`);++ assert.ok(await until(`window.own.length === 1 && [...document.querySelectorAll('.ovc-answer .ovc-answer-t')].some((x) => x.textContent === 'An answer the site gave itself.')`), 'onAsk answers through the card');++++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);++ await page.close();++ console.log('composer-verbs-chrome: Post · Ask · Do, the Model sheet from the Frame, Sources, an answer streamed with its sources, Post it, a site\'s own answer');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// The inline Community thread's composer (openvibe-shared 4.215.0, thread.renderThread + ov-thread.js): the++// server-drawn comment form still posts without script; with the script it becomes the shared composer v2++// (form.ovc-v2[data-preset=comment]) in place, keeps the action and the reply's parent_id, and sends the words++// natively. Real headless Chrome at 390 px; skipped without Chrome.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++const thread = require('../thread');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('inline-composer-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const root = path.join(__dirname, '..');++const answer = {++ thread: { id: 'cth_1', access_id: 'cth_1', comment_count: 1, url: 'https://openvibe.community/c/cth_1' },++ comments: [{ id: 1, message: 'A <comment>', created_at: '2026-11-12T00:00:00Z', author: { username: 'alex', display_name: 'Alex' }, reply_count: 0 }],++ viewer: { can_comment: true },++};++const HTML = thread.renderThread({ answer, base: '/p/x', post: '/p/x/comments', remove: '/p/x/comments/remove', signedIn: true, attachmentsAllowed: true, replyId: 1 });++let posted = null, postedRaw = '';++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (req.method === 'POST' && u.pathname === '/p/x/comments') {++ let body = '';++ req.on('data', (d) => { body += d; });++ req.on('end', () => { postedRaw = body; posted = { message: (body.match(/name="message"\r?\n\r?\n([^\r]*)/) || [])[1] || '', parent: (body.match(/name="parent_id"\r?\n\r?\n([^\r]*)/) || [])[1] || '' }; res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>'); });++ return;++ }++ if (u.pathname === '/') {++ res.writeHead(200, { 'content-type': 'text/html' });++ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Comments</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/thread.css"><link rel="stylesheet" href="/thread-inline.css"></head><body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div data-ovt data-ovc-thread>${HTML}</div><script src="/ov-composer.js"></script><script src="/ov-thread.js"></script></body></html>`);++ }++ if (['/thread.css', '/thread-inline.css', '/ov-composer.js', '/ov-thread.js'].includes(u.pathname)) {++ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' });++ return res.end(fs.readFileSync(path.join(root, u.pathname.slice(1))));++ }++ res.writeHead(404); res.end();++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inline-composer-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };++ let page;++ try {++ page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });++ await page.goto(base + '/', { maxMs: 6000 });++ assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`), 'the reply is the shared comment composer');++ assert.equal(await page.evaluate(`document.querySelectorAll('[data-ovt-composer]').length`), 0, 'the server form is replaced, not doubled');++ const shape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); return { action: f.getAttribute('action'), method: f.method, parent: (f.querySelector('input[name=parent_id]') || {}).value, send: f.querySelector('[data-ovt-send]').textContent.trim(), tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')) }; })()`);++ assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['Photos and video', 'Emoji'] });++ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');++ await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);++ const landed = await until(`!!document.getElementById('posted')`);++ assert.ok(landed || posted, 'the native form posts to the page\'s action');++ if (posted) { assert.equal(posted.message, 'Hello from the shared box'); assert.equal(posted.parent, '1'); }++ await page.close();++ console.log('inline-composer-chrome: the comment form mounts preset comment, keeps action and parent, sends, 390 px');++ } finally {++ if (page) await page.close().catch(() => {});++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -286,12 +286,12 @@ function optionsHtml({ html, label = 'Options' }) {* commands: [{ name: 'fix', hint: 'Fix a bug', insert: 'Fix this: ' }] the box's "/" list (ov-composer.js, 4.201.0),* with the person's own commands and the site's mods (files, @, voice and drafts come with it on every box)*/--function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [], commands = [] } = {}) {++function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [], commands = [], preset = null } = {}) {const cmds = (commands || []).filter((c) => c && /^[\w-]{1,30}$/.test(c.name || '')).slice(0, 30).map((c) => ({ name: c.name, hint: String(c.hint || '').slice(0, 120), insert: String(c.insert || '').slice(0, 4000) }));const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('') + (cmds.length ? attr('data-ovt-commands', JSON.stringify(cmds)) : '');const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}--<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}++<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${preset ? attr('data-ovt-preset', preset) : ''}${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label><textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''}${examples && examples.length ? ` data-ovt-examples="${esc(JSON.stringify(examples.slice(0, 12).map((x) => String(x).slice(0, 140))))}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea><div class="ovt-c-row"><div class="ovt-c-tools">${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}</div>@@@ -348,7 +348,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowedconst signIn = `<p class="ovc-signin">${comments.length ? 'Sign in to comment.' : 'Be the first to comment.'} <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;const composer_html = (parent = null) => signedIn ? composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000,sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},-- attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null,++ attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null, preset: 'comment',note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '',}) : signIn;const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
@@ -1,5 +1,38 @@
@@ -1,5 +1,38 @@
# Changelog
# Changelog
+## 4.218.0 — 2026-11-12
+
+One composer, three verbs: **Post · Ask · Do** (research-2026-11-12/composer-post-ask-do.md; the owner, 11-12: "make it so
+that the composer is able to … integrate directly to have the models list from OpenVibe.Exchange … the search the web
+feature, the search engine feature … more cohesive, interconnected", and "Actor should be more of an orchestrator … same
+features across all different sites").
+
+- **`verbs: ['post', 'ask', 'do']`** (a site opts in; the first is where the box starts) draws one switch: on a post or
+ a reply beside the name, in a chat or an agent's box at the start of the row; on a phone the chosen verb keeps its
+ word and the others are icons. **Ask**: an AI answers you; **Do**: Actor does it for you (the orchestrator, handing
+ parts to Codes, Bot, your computers); **Post** (Send, Reply) is unchanged.
+- **Ask and Do's chips**, a line of their own above the words:
+ - **Model**: a sheet (the whole width on a phone) over the Frame's `/choices?task=`: Auto, then OpenVibe's models
+ from Exchange's directory with their price per million and a link to each model's page on openvibe.exchange, your
+ keys, your computers, your plans; a find field; the choice is remembered per task;
+ - **Sources**: Web, OpenVibe, Mine, This page (when the site says what the page is), as switches;
+ - **Depth**: Quick, Balanced, Deep research.
+- **The answer card** under the box: the answer streamed from the run's events (AG-UI over
+ `/api/v1/runs/:id/events`), its sources as numbered chips, the model and the cost, and three ways on: **Post it**
+ (the answer becomes the box's own post or message), **Continue in Vibe**, **Do this**. An Ask never reaches the
+ box's onSubmit.
+- Without `onAsk` / `onDo` the box asks the Frame (`POST /ask`, `/do` at `frameBase`, default
+ https://openvibe.network/api/frame); a site that answers itself gets the same card (`api.card`: append, set, cite,
+ done, fail). The api adds `verb()`, `setVerb()`, `model()`, `sources()` and `answer()`.
+- **The inline Community thread is the comment preset** (#490): `thread.composer({ preset: 'comment' })` marks the
+ server form, and `ov-thread.js` replaces it in place with `OpenVibeComposer.mount(..., { preset: 'comment' })` for
+ every `[data-ovc-thread]`: the action, a reply's `parent_id`, files and the no-JS post stay. Video, Pics and a paste's
+ comments are the same box, once.
+
+Tests: `composer-verbs-chrome` (the switch, the chips, the Model sheet from a fake Frame with prices and Exchange
+links, find and pick, Sources, an Ask streamed into the card with its sources, Post it, a site's own answer, 390 px),
+`inline-composer-chrome` (the inline thread mounts the comment preset, keeps its action and `parent_id`, posts at 390 px).
+
## 4.217.0 — 2026-11-12
## 4.217.0 — 2026-11-12
The composer gets what the agents portal's own box had: a camera, Try again, and pictures made smaller before they go.
The composer gets what the agents portal's own box had: a camera, Try again, and pictures made smaller before they go.
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.217.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.218.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.217.0",
+ "version": "4.218.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -58,7 +58,7 @@
@@ -58,7 +58,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': '3ddb2aa1ca88' };
+ const VIBE_REV = { 'ov-composer.js': '203e770ec00f' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': 'f8fca6720b89', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
+ const DOCK_REV = { 'dock-vibe.js': '7869d3d345b4', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '21e125e3f1e9' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -602,7 +602,7 @@
@@ -602,7 +602,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': '19f9b0805772', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': '642a391c85d5', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'f9f34e3030b0', 'dock.js': '68900e53dead', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '3b6d2e2933de', 'history.js': 'f9fb9e096c65', 'bottom.js': '4bcb219a2624', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '88e9e57c3683', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '22286b70f8f6', 'ov-wallet.css': 'be8daa24c3a8', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': '898af091b3e1', 'soft.js': '98ab9b8080b1' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -178,14 +178,79 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
@@ -178,14 +178,79 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
.ovc-file-r-t { display: none; }
.ovc-file-r-t { display: none; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r { left: 0; top: 0; width: 100%; height: 150px; border-radius: 15px 15px 0 0; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r { left: 0; top: 0; width: 100%; height: 150px; border-radius: 15px 15px 0 0; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r-t { display: inline; }
.ovc-v2[data-preset=post] .ovc-file.ovc-image .ovc-file-r-t, .ovc-v2[data-preset=comment] .ovc-file.ovc-image .ovc-file-r-t { display: inline; }
+/* Post · Ask · Do (4.218.0) */
+.ovc-v2 .ovc-verbs--head { margin-left: auto; margin-right: 0; }
+.ovc-v2 .ovc-head .ovc-chips + .ovc-verbs--head { margin-left: 6px; }
+.ovc-v2 .ovc-verbs { flex: none; display: inline-flex; gap: 2px; padding: 3px; margin-right: 4px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
+.ovc-v2 .ovc-verb { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 10px; border: 0; border-radius: 999px; background: none; color: var(--text-muted, #93a0b4); font: 600 13px/1 inherit; cursor: pointer; }
+.ovc-v2 .ovc-verb svg { width: 15px; height: 15px; }
+.ovc-v2 .ovc-verb[aria-pressed="true"] { background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); box-shadow: 0 1px 3px rgba(0, 0, 0, .3); }
+.ovc-v2 .ovc-verb[data-verb="ask"][aria-pressed="true"] svg { color: #c084fc; }
+.ovc-v2 .ovc-verb[data-verb="do"][aria-pressed="true"] svg { color: #fbbf24; }
+@media (max-width: 520px) { .ovc-v2 .ovc-verb:not([aria-pressed="true"]) span { display: none; } .ovc-v2 .ovc-verb:not([aria-pressed="true"]) { width: 34px; padding: 0; justify-content: center; } }
+.ovc-v2 .ovc-ask-chips { display: flex; gap: 6px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
+.ovc-v2 .ovc-ask-chips::-webkit-scrollbar { display: none; }
+.ovc-v2 .ovc-ask-chips > * { flex: none; }
+.ovc-v2:not(.is-asking) .ovc-ask-chips { display: none; }
+.ovc-v2[data-preset=chat] .ovc-ask-chips { grid-column: 1 / -1; }
+.ovc-v2 .ovc-chip--btn { background: transparent; font-family: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
+.ovc-v2 .ovc-chip--btn .ovc-chip-v { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovc-v2 .ovc-chip--btn.is-on svg { color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
+.ovc-v2.is-asking { border-color: color-mix(in srgb, #a855f7 45%, var(--ovt-line, transparent)); }
+.ovc-v2.is-asking[data-verb="do"] { border-color: color-mix(in srgb, #f59e0b 45%, var(--ovt-line, transparent)); }
+.ovc-sheet { box-sizing: border-box; padding: 0; border: 1px solid var(--border, rgba(148, 163, 184, .2)); border-radius: 20px; width: min(520px, 94vw); max-height: 80vh; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); overflow: hidden; display: flex; flex-direction: column; }
+.ovc-sheet::backdrop { background: rgba(2, 6, 14, .6); }
+.ovc-sheet-h { display: flex; align-items: center; justify-content: space-between; padding: 12px 10px 8px 18px; font-size: 16px; }
+.ovc-sheet-b { overflow: auto; padding: 0 10px 14px; display: grid; gap: 4px; }
+.ovc-sheet-find:focus { outline: none; border-color: var(--ovt-accent, var(--accent, #3b82f6)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 25%, transparent); }
+.ovc-sheet-find { width: 100%; box-sizing: border-box; height: 42px; margin: 0 0 6px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .2)); border-radius: 12px; background: var(--bg-primary, #0b0f19); color: inherit; font: inherit; font-size: 16px; }
+.ovc-sheet h3 { margin: 12px 8px 4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
+.ovc-sheet-note { margin: 8px; color: var(--text-muted, #93a0b4); font-size: 14px; }
+.ovc-models { display: grid; gap: 2px; }
+.ovc-model { display: flex; align-items: center; gap: 4px; }
+.ovc-model[hidden] { display: none; }
+.ovc-model-b { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 6px 10px; border: 0; border-radius: 12px; background: none; color: inherit; text-align: left; font: inherit; cursor: pointer; }
+.ovc-model-b:hover, .ovc-model-b:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
+.ovc-model-b[aria-pressed="true"] { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 16%, transparent); }
+.ovc-model-m { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 20%, transparent); font-weight: 800; font-size: 14px; }
+.ovc-model-t { min-width: 0; display: grid; }
+.ovc-model-n { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.ovc-model-t b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
+.ovc-model-t small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); font-size: 12.5px; }
+.ovc-model-rec { flex: none; padding: 1px 7px; border-radius: 999px; background: color-mix(in srgb, #22c55e 18%, transparent); color: #86efac; font: 700 10.5px/1.6 inherit; font-style: normal; vertical-align: 2px; }
+.ovc-model-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--text-muted, #93a0b4); }
+.ovc-model-x:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
+.ovc-model-x svg, .ovc-sheet-more svg { width: 16px; height: 16px; }
+.ovc-sheet-more { display: inline-flex; align-items: center; gap: 6px; margin: 10px 8px 0; color: var(--ovt-accent, var(--accent-light, #60a5fa)); font-size: 14px; font-weight: 600; text-decoration: none; }
+.ovc-src { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 6px 10px; border-radius: 12px; cursor: pointer; }
+.ovc-src:hover { background: color-mix(in srgb, var(--text-primary, #fff) 5%, transparent); }
+.ovc-src > svg { flex: none; width: 20px; height: 20px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
+.ovc-src > span { flex: 1; min-width: 0; display: grid; }
+.ovc-src small { color: var(--text-muted, #93a0b4); font-size: 12.5px; }
+.ovc-src input { flex: none; width: 20px; height: 20px; accent-color: var(--ovt-accent, var(--accent, #3b82f6)); }
+@media (max-width: 600px) { .ovc-sheet { width: 100vw; max-width: none; margin: auto 0 0; border-radius: 22px 22px 0 0; max-height: 86vh; padding-bottom: env(safe-area-inset-bottom); } }
+.ovc-answer { box-sizing: border-box; margin: 10px 0 0; padding: 12px 14px; border: 1px solid color-mix(in srgb, #a855f7 30%, var(--border, rgba(148, 163, 184, .2))); border-radius: 18px; background: color-mix(in srgb, #a855f7 6%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); }
+.ovc-answer header { display: flex; align-items: center; gap: 8px; }
+.ovc-answer header > svg { width: 17px; height: 17px; color: #c084fc; }
+.ovc-answer header small { color: var(--text-muted, #93a0b4); }
+.ovc-answer header .ovc-bar-x { margin-left: auto; }
+.ovc-answer-t { margin: 6px 0 0; white-space: pre-wrap; line-height: 1.5; font-size: 15px; }
+.ovc-answer-t.is-waiting { color: var(--text-muted, #93a0b4); }
+.ovc-answer-t.is-bad { color: #fca5a5; }
+.ovc-cites { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
+.ovc-cites a { display: inline-block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 4px 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); color: var(--text-primary, #e6edf7); font-size: 12.5px; text-decoration: none; }
+.ovc-answer footer { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.ovc-answer-a { min-height: 36px; padding: 0 14px; border: 1px solid var(--border, rgba(148, 163, 184, .22)); border-radius: 999px; background: none; color: var(--text-primary, #e6edf7); font: 600 13.5px/1 inherit; cursor: pointer; }
+.ovc-answer-a:first-child { background: var(--ovt-accent, var(--accent, #3b82f6)); border-color: transparent; color: #fff; }
+@media (pointer: coarse) { .ovc-answer-a { min-height: 44px; } }
.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }
.ovc-v2 .ovc-bar { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); font-size: 13px; line-height: 1.3; }
.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
.ovc-v2 .ovc-bar > svg { flex: none; width: 15px; height: 15px; color: var(--ovt-accent, var(--accent-light, #60a5fa)); }
.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }
.ovc-v2 .ovc-bar > b { flex: none; font-weight: 700; color: var(--text-primary, #e6edf7); }
.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
.ovc-v2 .ovc-bar > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #93a0b4); }
-.ovc-v2 .ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
-.ovc-v2 .ovc-bar-x:hover, .ovc-v2 .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }
-.ovc-v2 .ovc-bar-x svg { width: 15px; height: 15px; }
-@media (pointer: coarse) { .ovc-v2 .ovc-bar-x { width: 40px; height: 40px; } }
+.ovc-bar-x { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: transparent; color: var(--text-muted, #93a0b4); cursor: pointer; }
+.ovc-bar-x:hover, .ovc-bar-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #fff) 10%, transparent); color: var(--text-primary, #e6edf7); }
+.ovc-bar-x svg { width: 15px; height: 15px; }
+@media (pointer: coarse) { .ovc-bar-x { width: 40px; height: 40px; } }
.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }
.ovc-v2.is-editing { box-shadow: 0 0 0 2px color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 45%, transparent); }
.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
.ovc-v2.is-running .ovt-send { background: color-mix(in srgb, var(--text-primary, #fff) 14%, transparent); color: var(--text-primary, #e6edf7); }
.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
.ovc-v2 .ovc-files { margin: 0; padding: 0 0 2px; }
@@ -293,6 +358,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -293,6 +358,10 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
camera: 'M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM12 10a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7z',
camera: 'M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1zM12 10a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7z',
flip: 'M4 9a8 8 0 0 1 14-3l2 2M20 15a8 8 0 0 1-14 3l-2-2M20 4v4h-4M4 20v-4h4',
flip: 'M4 9a8 8 0 0 1 14-3l2 2M20 15a8 8 0 0 1-14 3l-2-2M20 4v4h-4M4 20v-4h4',
retry: 'M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5',
retry: 'M4 12a8 8 0 1 0 2.3-5.7L4 8.5M4 4v4.5h4.5',
+ bolt: 'M13 3L5 13.5h6L10 21l9-11h-6.2z',
+ model: 'M12 3l7.5 4.3v8.4L12 20l-7.5-4.3V7.3zM12 8v4.2M12 12.2l3.6 2.1M12 12.2l-3.6 2.1',
+ layers: 'M12 4l8.5 4.5L12 13 3.5 8.5zM3.5 13L12 17.5 20.5 13',
+ ext: 'M14 5h5v5M19 5l-8 8M10 6H6a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-4',
};
};
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const svg = (d, cls) => `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
@@ -723,6 +792,14 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -723,6 +792,14 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),
// "Editing your message" bar; Enter saves, Esc or × puts back what was in the box; the send is a check),
// reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),
// reply({ name, text, data, onClear }) (a "Replying to …" bar; onSubmit's value carries reply: data),
// clearBar(), editing() }.
// clearBar(), editing() }.
+ // 4.218.0, Post · Ask · Do (research-2026-11-12/composer-post-ask-do.md): verbs: ['post', 'ask', 'do'] (a site
+ // opts in; the first is where the box starts) draws one switch at the start of the row. Ask and Do add three chips:
+ // Model (a sheet over the Frame's /choices?task=: Auto, then OpenVibe from Exchange's directory with prices, your
+ // keys, your computers, your plans), Sources (web, OpenVibe, mine, this page) and Depth (quick, balanced, deep).
+ // ask: { task, context: { site, url, title, kind, id } }, frameBase (default https://openvibe.network/api/frame),
+ // onAsk(value, api) / onDo(value, api) to answer it yourself; without them the box asks the Frame (/ask, /do) and
+ // streams the run into an answer card under the box (api.answer() gives the card to a site that answers itself).
+ // value = { verb, text, files, uploads, model, sources, depth, context, chips }.
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// A tool is a module: OpenVibeComposer.tool(id, { label, path, accept? | run(ctx, button, form) }) adds or replaces
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
// one for every box; a preset or a site names the ones it shows. Mods (mod()) still add their own buttons.
const PRESET = {
const PRESET = {
@@ -826,6 +903,31 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -826,6 +903,31 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
}
}
const initial = (u) => String((u && (u.display_name || u.username)) || '?').trim().charAt(0).toUpperCase() || '?';
const initial = (u) => String((u && (u.display_name || u.username)) || '?').trim().charAt(0).toUpperCase() || '?';
+ const FRAME = 'https://openvibe.network/api/frame';
+ const VERBS = { post: { icon: 'send', hint: 'to people' }, ask: { icon: 'spark', word: 'Ask', hint: 'AI answers you' }, do: { icon: 'bolt', word: 'Do', hint: 'Actor does it for you' } };
+ const SOURCES = [['web', 'Web', 'globe'], ['openvibe', 'OpenVibe', 'layers'], ['mine', 'Mine', 'file'], ['page', 'This page', 'photo']];
+ const choicesCache = new Map();
+ // The Frame's model list for a task, in one shape whatever answered (the Frame's groups, or AI's own options).
+ async function loadChoices(frame, task) {
+ const key = `${frame}|${task}`;
+ if (choicesCache.has(key)) return choicesCache.get(key);
+ const job = fetch(`${frame}/choices?task=${encodeURIComponent(task)}`, { credentials: 'include', headers: { Accept: 'application/json' } })
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Models are unavailable right now.'))))
+ .then((d) => {
+ if (Array.isArray(d.groups)) return d;
+ const o = d.options || {};
+ return { recommended: d.recommended, groups: [['openvibe', 'OpenVibe'], ['keys', 'Your keys'], ['machines', 'Your computers']].map(([id, title]) => ({ id, title, rows: o[id] || [] })) };
+ });
+ choicesCache.set(key, job);
+ job.catch(() => choicesCache.delete(key));
+ return job;
+ }
+ const money = (row) => {
+ if (row.free || (row.price && row.price.in === 0) || row.price_estimate_usd === 0) return 'Free';
+ if (row.price && row.price.in != null) return `$${Number(row.price.in).toFixed(row.price.in < 1 ? 2 : 1)} / M`;
+ if (row.price_estimate_usd != null) return `about $${Number(row.price_estimate_usd).toFixed(3)}`;
+ return '';
+ };
function mount(host, o = {}) {
function mount(host, o = {}) {
if (!host) return null;
if (!host) return null;
styles();
styles();
@@ -912,12 +1014,52 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -912,12 +1014,52 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
// A post or a reply says who sees it above the box (a phone's row keeps room for the tools); a chat or an agent's
// A post or a reply says who sees it above the box (a phone's row keeps room for the tools); a chat or an agent's
// box keeps its chip beside the send.
// box keeps its chip beside the send.
const above = name === 'post' || name === 'comment';
const above = name === 'post' || name === 'comment';
+ // Post · Ask · Do: one switch at the start of the row (a site opts in with verbs).
+ const verbs = (Array.isArray(o.verbs) ? o.verbs : []).filter((v) => VERBS[v]);
+ let verb = verbs.length ? (verbs.includes(o.verb) ? o.verb : verbs[0]) : 'post';
+ const frame = o.frameBase || FRAME;
+ const askTask = (o.ask && o.ask.task) || (name === 'agent' ? 'agent' : 'chat');
+ let model = null;
+ try { model = JSON.parse(localStorage.getItem(`ov:composer:model:${askTask}`) || 'null'); } catch { model = null; }
+ model = model || { model: 'openvibe/auto', source: 'openvibe', name: 'Auto' };
+ const sources = new Set((o.ask && o.ask.sources) || ['web', 'openvibe']);
+ let switcher = null;
+ if (verbs.length > 1) {
+ switcher = el('div', 'ovc-verbs'); switcher.setAttribute('role', 'group'); switcher.setAttribute('aria-label', 'What this box does');
+ verbs.forEach((v) => {
+ const word = v === 'post' ? (o.submitLabel || p.send || 'Send') : VERBS[v].word;
+ const b = el('button', 'ovc-verb', svg(IC[v === 'post' ? (p.send ? 'send' : 'send') : VERBS[v].icon]) + `<span>${word}</span>`);
+ b.type = 'button'; b.dataset.verb = v; b.title = `${word}: ${VERBS[v].hint}`; b.setAttribute('aria-label', `${word}: ${VERBS[v].hint}`);
+ b.addEventListener('click', () => setVerb(v));
+ switcher.appendChild(b);
+ });
+ if (above && head) { switcher.classList.add('ovc-verbs--head'); head.appendChild(switcher); } else row.appendChild(switcher);
+ }
+ const askChips = el('div', 'ovc-ask-chips'); askChips.setAttribute('role', 'group'); askChips.setAttribute('aria-label', 'How it is answered');
+ const modelChip = el('button', 'ovc-chip ovc-chip--btn', `${svg(IC.model)}<span class="ovc-chip-v"></span>`);
+ modelChip.type = 'button'; modelChip.setAttribute('aria-haspopup', 'dialog');
+ const paintModel = () => { modelChip.querySelector('.ovc-chip-v').textContent = model.name || model.model; modelChip.setAttribute('aria-label', `Model: ${model.name || model.model}. Change it`); };
+ const srcChip = el('button', 'ovc-chip ovc-chip--btn', `${svg(IC.globe)}<span class="ovc-chip-v"></span>`);
+ srcChip.type = 'button'; srcChip.setAttribute('aria-haspopup', 'dialog');
+ const paintSources = () => {
+ const on = SOURCES.filter(([id]) => sources.has(id) && (id !== 'page' || (o.ask && o.ask.context)));
+ srcChip.querySelector('.ovc-chip-v').textContent = on.length ? on.map(([, w]) => w).join(' · ') : 'No sources';
+ srcChip.classList.toggle('is-on', sources.has('web'));
+ srcChip.setAttribute('aria-label', `Sources: ${srcChip.textContent}. Change them`);
+ };
+ const depthChip = chip({ name: 'depth', label: 'Depth', options: [['quick', 'Quick'], ['balanced', 'Balanced'], ['deep', 'Deep research']], value: (o.ask && o.ask.depth) || 'balanced' }, IC.gauge);
+ askChips.append(modelChip, srcChip, depthChip);
+ paintModel(); paintSources();
+ modelChip.addEventListener('click', () => openModels());
+ srcChip.addEventListener('click', () => openSources());
row.appendChild(tools);
row.appendChild(tools);
if (!above) row.appendChild(chips);
if (!above) row.appendChild(chips);
if (ring) row.appendChild(ring);
if (ring) row.appendChild(ring);
row.appendChild(send);
row.appendChild(send);
if (above && chips.children.length) head.appendChild(chips);
if (above && chips.children.length) head.appendChild(chips);
if (head.children.length) body.appendChild(head);
if (head.children.length) body.appendChild(head);
+ // Ask and Do's chips: a line of their own above the words (scrolls sideways on a narrow screen, never wider).
+ if (verbs.includes('ask') || verbs.includes('do')) body.appendChild(askChips);
body.append(ta, row);
body.append(ta, row);
const note = el('p', 'ovc-note'); note.setAttribute('data-ovt-note', ''); note.setAttribute('role', 'status');
const note = el('p', 'ovc-note'); note.setAttribute('data-ovt-note', ''); note.setAttribute('role', 'status');
form.append(body, note);
form.append(body, note);
@@ -999,6 +1141,176 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -999,6 +1141,176 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
ta.focus();
ta.focus();
};
};
ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });
ta.addEventListener('keydown', (e) => { if (e.key === 'Escape' && bar) { e.preventDefault(); e.stopPropagation(); clearBar(); } });
+ // ── Post · Ask · Do ──
+ const postFace = send.innerHTML, postLabel = send.getAttribute('aria-label'), postRound = send.classList.contains('ovt-send--round');
+ function setVerb(v) {
+ if (!VERBS[v] || (verbs.length && !verbs.includes(v))) return;
+ verb = v;
+ form.dataset.verb = v;
+ form.classList.toggle('is-asking', v !== 'post');
+ if (switcher) switcher.querySelectorAll('.ovc-verb').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.verb === v)));
+ if (v === 'post') { send.innerHTML = postFace; send.classList.toggle('ovt-send--round', postRound); send.setAttribute('aria-label', postLabel); }
+ else { send.innerHTML = svg(IC[VERBS[v].icon]); send.classList.add('ovt-send--round'); send.setAttribute('aria-label', VERBS[v].word); }
+ ta.placeholder = v === 'ask' ? 'Ask anything' : v === 'do' ? 'Tell Actor what to do' : (o.placeholder || ta.dataset.ovcPlaceholder || ta.placeholder);
+ api.refresh();
+ }
+ ta.dataset.ovcPlaceholder = ta.placeholder;
+ // A sheet (the whole width on a phone) for the model list and the sources.
+ function sheet(title, build) {
+ const dlg = el('dialog', 'ovc-sheet'); dlg.setAttribute('aria-label', title);
+ dlg.innerHTML = `<div class="ovc-sheet-h"><b></b><button type="button" class="ovc-bar-x" aria-label="Close">${svg(IC.x)}</button></div><div class="ovc-sheet-b"></div>`;
+ dlg.querySelector('b').textContent = title;
+ const close = () => { try { dlg.close(); } catch { /* closed */ } dlg.remove(); };
+ dlg.querySelector('.ovc-bar-x').addEventListener('click', close);
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) close(); });
+ dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
+ document.body.appendChild(dlg);
+ build(dlg.querySelector('.ovc-sheet-b'), close);
+ if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
+ return dlg;
+ }
+ function openModels() {
+ sheet('Model', async (b, close) => {
+ const find = el('input', 'ovc-sheet-find'); find.type = 'search'; find.placeholder = 'Find a model'; find.setAttribute('aria-label', 'Find a model');
+ const list = el('div', 'ovc-models');
+ b.append(find, list);
+ const pick = (row) => {
+ model = { model: row.model, source: row.source || 'openvibe', ...(row.machine ? { machine: row.machine } : {}), name: row.name || row.model };
+ try { localStorage.setItem(`ov:composer:model:${askTask}`, JSON.stringify(model)); } catch { /* private mode */ }
+ paintModel(); close(); ta.focus();
+ };
+ const rowEl = (row, rec) => {
+ const r = el('div', 'ovc-model');
+ const btn = el('button', 'ovc-model-b'); btn.type = 'button';
+ btn.setAttribute('aria-pressed', String(row.model === model.model && (row.source || 'openvibe') === (model.source || 'openvibe')));
+ const mark = el('span', 'ovc-model-m'); mark.textContent = String(row.maker || row.name || row.model || '?').trim().charAt(0).toUpperCase();
+ const t = el('span', 'ovc-model-t'); const line = el('span', 'ovc-model-n'); const n = el('b'); n.textContent = row.name || row.model; line.appendChild(n); const d = el('small');
+ d.textContent = [money(row), row.speed ? `${row.speed}` : '', row.maker || ''].filter(Boolean).join(' · ') || row.detail || '';
+ if (rec) { const badge = el('em', 'ovc-model-rec'); badge.textContent = 'Recommended'; line.appendChild(badge); }
+ t.append(line, d); btn.append(mark, t);
+ btn.addEventListener('click', () => pick(row));
+ r.appendChild(btn);
+ if (row.url) { const a = el('a', 'ovc-model-x', svg(IC.ext)); a.href = row.url; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Compare on OpenVibe.Exchange'; a.setAttribute('aria-label', `${row.name || row.model} on OpenVibe.Exchange`); r.appendChild(a); }
+ r.dataset.find = `${row.name || ''} ${row.model || ''} ${row.maker || ''}`.toLowerCase();
+ return r;
+ };
+ const auto = { model: 'openvibe/auto', source: 'openvibe', name: 'Auto', detail: 'OpenVibe picks a model for each question' };
+ list.appendChild(rowEl(auto));
+ const wait = el('p', 'ovc-sheet-note'); wait.textContent = 'Loading the models…'; list.appendChild(wait);
+ try {
+ const data = await loadChoices(frame, askTask);
+ wait.remove();
+ const rec = data.recommended;
+ (data.groups || []).forEach((g) => {
+ if (!g.rows || !g.rows.length) return;
+ const h = el('h3'); h.textContent = g.title || g.id; list.appendChild(h);
+ g.rows.forEach((row) => list.appendChild(rowEl(row, rec && rec.model === row.model && (rec.source || 'openvibe') === (row.source || 'openvibe'))));
+ });
+ const more = el('a', 'ovc-sheet-more', `Every model, compared, on OpenVibe.Exchange ${svg(IC.ext)}`); more.href = 'https://openvibe.exchange/models'; more.target = '_blank'; more.rel = 'noopener';
+ list.appendChild(more);
+ } catch (err) { wait.textContent = (err && err.message) || 'Models are unavailable right now.'; }
+ find.addEventListener('input', () => {
+ const q = find.value.trim().toLowerCase();
+ list.querySelectorAll('.ovc-model').forEach((r) => { r.hidden = !!q && !r.dataset.find.includes(q); });
+ });
+ find.focus();
+ });
+ }
+ function openSources() {
+ sheet('What the answer may read', (b) => {
+ SOURCES.forEach(([id, word, icon]) => {
+ if (id === 'page' && !(o.ask && o.ask.context)) return;
+ const lab = el('label', 'ovc-src', `${svg(IC[icon])}<span><b></b><small></small></span>`);
+ lab.querySelector('b').textContent = word;
+ lab.querySelector('small').textContent = id === 'web' ? 'Search the web (OpenVibe Search, then other engines)' : id === 'openvibe' ? 'Posts, streams, videos, wiki and pastes on OpenVibe' : id === 'mine' ? 'Your own posts, files and chats' : `This ${(o.ask.context && o.ask.context.kind) || 'page'}`;
+ const box = el('input'); box.type = 'checkbox'; box.checked = sources.has(id); box.setAttribute('role', 'switch');
+ box.addEventListener('change', () => { if (box.checked) sources.add(id); else sources.delete(id); paintSources(); });
+ lab.appendChild(box);
+ b.appendChild(lab);
+ });
+ });
+ }
+ // The answer (or the run) under the box: streamed text, its sources, the model and three ways on.
+ let card = null;
+ function answer(question) {
+ if (card) card.remove();
+ card = el('section', 'ovc-answer'); card.setAttribute('aria-live', 'polite');
+ card.innerHTML = `<header>${svg(IC[verb === 'do' ? 'bolt' : 'spark'])}<b></b><small></small><button type="button" class="ovc-bar-x" aria-label="Close the answer">${svg(IC.x)}</button></header><div class="ovc-answer-t"></div><ol class="ovc-cites" hidden></ol><footer hidden></footer>`;
+ card.querySelector('b').textContent = verb === 'do' ? 'Actor' : 'Answer';
+ card.querySelector('small').textContent = model.name || '';
+ const body = card.querySelector('.ovc-answer-t'), cites = card.querySelector('.ovc-cites'), foot = card.querySelector('footer');
+ body.textContent = verb === 'do' ? 'Planning…' : 'Thinking…'; body.classList.add('is-waiting');
+ card.querySelector('.ovc-bar-x').addEventListener('click', () => { card.remove(); card = null; });
+ form.after(card);
+ let started = false;
+ const ctl = {
+ el: card,
+ append(delta) { if (!started) { body.textContent = ''; body.classList.remove('is-waiting'); started = true; } body.textContent += String(delta || ''); },
+ set(t) { started = true; body.classList.remove('is-waiting'); body.textContent = String(t || ''); },
+ cite(list) {
+ cites.replaceChildren();
+ (list || []).forEach((c, i) => {
+ const li = el('li'); const a = el('a'); a.href = c.url; a.target = '_blank'; a.rel = 'noopener nofollow';
+ a.textContent = `${c.n || i + 1}. ${c.title || c.site || c.url}`; if (c.site) a.title = c.site;
+ li.appendChild(a); cites.appendChild(li);
+ });
+ cites.hidden = !cites.children.length;
+ },
+ done(meta = {}) {
+ if (meta.answer != null) ctl.set(meta.answer);
+ if (meta.citations) ctl.cite(meta.citations);
+ if (meta.cost != null) card.querySelector('small').textContent = `${model.name || ''} · $${Number(meta.cost).toFixed(3)}`;
+ foot.replaceChildren();
+ const act = (label, fn) => { const b = el('button', 'ovc-answer-a'); b.type = 'button'; b.textContent = label; b.addEventListener('click', fn); foot.appendChild(b); };
+ if (verbs.includes('post')) act(name === 'chat' ? 'Send it' : name === 'comment' ? 'Reply with it' : 'Post it', () => { setVerb('post'); ta.value = body.textContent; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); });
+ act('Continue in Vibe', () => { form.dispatchEvent(new CustomEvent('ovc:continue', { bubbles: true, detail: { run: meta.run, question } })); if (root.OVDock && typeof root.OVDock.open === 'function') root.OVDock.open('vibe', { run: meta.run, text: question }); });
+ if (verb === 'ask' && verbs.includes('do')) act('Do this', () => { setVerb('do'); ta.value = question; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); });
+ foot.hidden = false;
+ },
+ fail(msg) { started = true; body.classList.remove('is-waiting'); body.classList.add('is-bad'); body.textContent = String(msg || 'That did not work. Try again.'); },
+ };
+ return ctl;
+ }
+ // Ask or Do through the Frame: start the run, then follow its events (AG-UI) into the card.
+ async function viaFrame(value, ctl) {
+ const r = await fetch(`${frame}/${value.verb}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(value) });
+ const d = await r.json().catch(() => ({}));
+ if (!r.ok || !d.run) throw new Error(d.detail || d.error || (r.status === 401 ? 'Sign in to ask.' : 'Could not ask right now.'));
+ const events = d.events || `${frame.replace(/\/api\/frame\/?$/, '')}/api/v1/runs/${encodeURIComponent(d.run)}/events`;
+ if (typeof EventSource !== 'function') return;
+ await new Promise((resolve) => {
+ const es = new EventSource(events, { withCredentials: true });
+ const end = () => { es.close(); resolve(); };
+ es.onmessage = (m) => {
+ let ev = null; try { ev = JSON.parse(m.data); } catch { return; }
+ if (ev.type === 'TEXT_MESSAGE_CONTENT') ctl.append(ev.delta);
+ else if (ev.type === 'CUSTOM' && ev.name === 'citations') ctl.cite(ev.value);
+ else if (ev.type === 'RUN_FINISHED') { ctl.done({ run: d.run, ...(ev.result || {}) }); end(); }
+ else if (ev.type === 'RUN_ERROR') { ctl.fail(ev.message); end(); }
+ };
+ es.onerror = () => { if (es.readyState === 2) { ctl.fail('The answer stopped. Try again.'); end(); } };
+ });
+ }
+ form.addEventListener('submit', async (e) => {
+ if (verb === 'post' || editing) return;
+ e.preventDefault(); e.stopImmediatePropagation();
+ if (form.classList.contains('is-busy')) return;
+ const text = ta.value.trim();
+ if (!text && !api.files().length) return;
+ const value = { verb, text, files: api.files(), uploads: api.uploads(), model, sources: [...sources], depth: depthChip ? depthChip.querySelector('select').value : 'balanced', context: (o.ask && o.ask.context) || null, chips: values() };
+ const ctl = answer(text);
+ setBusy(true);
+ try {
+ const own = verb === 'ask' ? o.onAsk : o.onDo;
+ if (typeof own === 'function') await own(value, Object.assign(api, { card: ctl }));
+ else await viaFrame({ ...value, files: undefined }, ctl);
+ api.clear();
+ } catch (err) { ctl.fail((err && err.message) || String(err)); }
+ setBusy(false);
+ api.refresh();
+ }, true);
+ if (verbs.length) setVerb(verb);
+
// Saving an edit is not a send: it runs first and nothing else hears the submit.
// Saving an edit is not a send: it runs first and nothing else hears the submit.
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
if (!editing) return;
if (!editing) return;
@@ -1028,7 +1340,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
@@ -1028,7 +1340,8 @@ html[data-ov-motion=off] .ovc-emoji { animation: none; }
api.refresh();
api.refresh();
});
});
}
}
- return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing });
+ return Object.assign(api, { form, text: ta, send, note, setBusy, setRunning, values, edit, reply, clearBar, editing: () => !!editing,
+ verb: () => verb, setVerb, model: () => model, sources: () => [...sources], answer });
}
}
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
// ── Your settings, in a sheet: what sends, voice, compact, your own commands.
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': '3ddb2aa1ca88', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '203e770ec00f', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -37,16 +37,19 @@
@@ -37,16 +37,19 @@
s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');
s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-combo.js' + (THREAD_REV['ov-combo.js'] ? '?v=' + THREAD_REV['ov-combo.js'] : '');
document.head.appendChild(s);
document.head.appendChild(s);
}
}
- function loadComposer() {
- if (root.OpenVibeComposer) { root.OpenVibeComposer.scan(); return; }
+ function loadComposer(after) {
+ if (root.OpenVibeComposer) { if (after) after(); else root.OpenVibeComposer.scan(); return; }
+ if (after) loadComposer.waiting.push(after);
if (loadComposer.on) return;
if (loadComposer.on) return;
loadComposer.on = true;
loadComposer.on = true;
var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
var m = /^(https?:\/\/[^?#]*\/)ov-thread(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
var s = document.createElement('script');
var s = document.createElement('script');
s.async = true;
s.async = true;
s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-composer.js' + (THREAD_REV['ov-composer.js'] ? '?v=' + THREAD_REV['ov-composer.js'] : '');
s.src = (m ? m[1] : 'https://openvibe.network/shared/') + 'ov-composer.js' + (THREAD_REV['ov-composer.js'] ? '?v=' + THREAD_REV['ov-composer.js'] : '');
+ s.addEventListener('load', function () { var list = loadComposer.waiting; loadComposer.waiting = []; list.forEach(function (fn) { try { fn(); } catch (e) { /* one box's failure never holds the page */ } }); }, { once: true });
document.head.appendChild(s);
document.head.appendChild(s);
}
}
+ loadComposer.waiting = [];
function q(el, sel) { return el ? el.querySelector(sel) : null; }
function q(el, sel) { return el ? el.querySelector(sel) : null; }
function qa(el, sel) { return el ? Array.prototype.slice.call(el.querySelectorAll(sel)) : []; }
function qa(el, sel) { return el ? Array.prototype.slice.call(el.querySelectorAll(sel)) : []; }
@@ -254,7 +257,7 @@
@@ -254,7 +257,7 @@
function start() {
function start() {
loadCombo();
loadCombo();
- qa(document, '[data-ovt]').forEach(function (box) {
+ qa(document, '[data-ovt], [data-ovc-thread]').forEach(function (box) {
if (box.dataset.ovtReady) return;
if (box.dataset.ovtReady) return;
box.dataset.ovtReady = '1';
box.dataset.ovtReady = '1';
var head = q(box, '[data-ovt-head]');
var head = q(box, '[data-ovt-head]');
@@ -311,41 +314,103 @@
@@ -311,41 +314,103 @@
});
});
}
}
- // The composer.
- var form = q(box, '[data-ovt-composer]');
- if (form) loadComposer();
- var text = q(box, '[data-ovt-text]');
- var send = q(box, '[data-ovt-send]');
- function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }
- function syncSend() { if (send && text) send.disabled = !text.value.trim(); }
- if (text) {
- text.addEventListener('input', function () { grow(); syncSend(); });
- text.addEventListener('keydown', function (e) {
- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {
- e.preventDefault();
- if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();
- }
- });
- grow();
- syncSend();
+ // The composer. An inline Community thread (thread.renderThread) draws the comment preset: the same
+ // picture, box, tools, count and Reply as every site's comments, replacing the server-drawn form in place
+ // (without script that form still posts).
+ var form = null, text = null, send = null;
+ function bootComposer(box) {
+ var f = q(box, '[data-ovt-composer]');
+ if (!f) return;
+ if (!f.closest('[data-ovc-thread]')) {
+ if (!root.OpenVibeComposer) loadComposer();
+ else if (root.OpenVibeComposer.enhance) root.OpenVibeComposer.enhance(f);
+ wireComposer(f);
+ return;
+ }
+ if (!root.OpenVibeComposer) { loadComposer(function () { bootComposer(box); }); return; }
+ if (root.OpenVibeComposer.mount) {
+ var upgraded = upgradeComposer(f);
+ if (upgraded) { wireComposer(upgraded); return; }
+ }
+ if (root.OpenVibeComposer.enhance) root.OpenVibeComposer.enhance(f);
+ wireComposer(f);
}
}
- qa(box, '[data-ovt-starter]').forEach(function (b) {
- b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); grow(); syncSend(); text.focus(); });
- });
-
- // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).
- if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {
- text.__ovtEx = true;
- var exI = 0;
- var exTimer = setInterval(function () {
- if (!text.isConnected) { clearInterval(exTimer); return; }
- var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }
- if (!list.length || text.value || document.activeElement === text || document.hidden) return;
- exI = (exI + 1) % list.length;
- text.classList.add('is-ph-out');
- setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);
- }, 3600);
+ // The form's own submit stays: mount() draws the box and the server's action, method and hidden fields
+ // (a reply's parent_id) go back on it, so the page still posts without a site script.
+ function upgradeComposer(f) {
+ if (!f || f.__ovtUpgraded || !root.OpenVibeComposer || !root.OpenVibeComposer.mount) return null;
+ var oldText = f.querySelector('[data-ovt-text]') || f.querySelector('textarea');
+ if (!oldText) return null;
+ var action = f.getAttribute('action') || '';
+ var method = f.getAttribute('method') || 'post';
+ var hidden = qa(f, 'input[type=hidden]').map(function (i) { return [i.name, i.value]; });
+ var file = f.querySelector('input[type=file]');
+ var note = q(f, '[data-ovt-note]');
+ var sendBtn = q(f, '[data-ovt-send]');
+ var label = (sendBtn && (sendBtn.getAttribute('aria-label') || sendBtn.textContent.trim())) || 'Reply';
+ var preset = f.getAttribute('data-ovt-preset') || 'comment';
+ var host = document.createElement('div');
+ f.replaceWith(host);
+ var c = null;
+ try {
+ c = root.OpenVibeComposer.mount(host, {
+ preset: preset, name: oldText.name || 'message', placeholder: oldText.placeholder || '',
+ maxLength: Number(oldText.getAttribute('maxlength')) || 2000, submitLabel: label,
+ tools: file ? ['photo', 'emoji'] : ['emoji'], maxFiles: Number(f.getAttribute('data-ovc-max-files')) || 10,
+ });
+ } catch (e) { c = null; }
+ if (!c) { host.replaceWith(f); return null; }
+ c.form.action = action; c.form.method = method;
+ if (file) {
+ c.form.enctype = 'multipart/form-data';
+ var field = file.name || 'files';
+ qa(c.form, 'input[type=file]').forEach(function (i) { i.name = field; if (file.accept) i.accept = file.accept; });
+ }
+ hidden.forEach(function (h) { var i = document.createElement('input'); i.type = 'hidden'; i.name = h[0]; i.value = h[1]; c.form.appendChild(i); });
+ if (oldText.value) c.text.value = oldText.value;
+ if (note && note.textContent) c.note.textContent = note.textContent;
+ c.form.__ovtUpgraded = true;
+ return c.form;
+ }
+ function wireComposer(f) {
+ if (!f || f.__ovtWired) return;
+ form = f;
+ text = q(f, '[data-ovt-text]') || q(f, 'textarea');
+ send = q(f, '[data-ovt-send]');
+ function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, root.innerHeight * 0.4) + 'px'; }
+ function syncSend() { if (send && text) send.disabled = !text.value.trim(); }
+ if (text && !text.__ovtKeys) {
+ text.__ovtKeys = true;
+ text.addEventListener('input', function () { grow(); syncSend(); });
+ text.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine && text.value.trim()) {
+ e.preventDefault();
+ if (form.requestSubmit) form.requestSubmit(send || undefined); else form.submit();
+ }
+ });
+ grow();
+ syncSend();
+ }
+ qa(box, '[data-ovt-starter]').forEach(function (b) {
+ if (b.__ovtStarter) return; b.__ovtStarter = true;
+ b.addEventListener('click', function () { if (!text) return; text.value = b.getAttribute('data-ovt-starter'); if (form.__ovc && form.__ovc.ctx) form.__ovc.ctx.set(text.value); grow(); syncSend(); text.focus(); });
+ });
+ // The examples: while the box is empty and not being typed in, its placeholder walks through them (a fade).
+ if (text && text.getAttribute('data-ovt-examples') && !text.__ovtEx) {
+ text.__ovtEx = true;
+ var exI = 0;
+ var exTimer = setInterval(function () {
+ if (!text.isConnected) { clearInterval(exTimer); return; }
+ var list; try { list = JSON.parse(text.getAttribute('data-ovt-examples') || '[]'); } catch (e) { list = []; }
+ if (!list.length || text.value || document.activeElement === text || document.hidden) return;
+ exI = (exI + 1) % list.length;
+ text.classList.add('is-ph-out');
+ setTimeout(function () { text.placeholder = list[exI]; text.classList.remove('is-ph-out'); }, 220);
+ }, 3600);
+ }
+ f.__ovtWired = true;
}
}
+ bootComposer(box);
// Options: one button, the pickers behind it (a <details>, so it opens without script too). A tap outside,
// Options: one button, the pickers behind it (a <details>, so it opens without script too). A tap outside,
// Escape or Done closes it; on a phone it is a sheet and the page behind stays put; a changed picker shows as
// Escape or Done closes it; on a phone it is a sheet and the page behind stays put; a changed picker shows as
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.217.0",
+ "version": "4.218.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.217.0",
+ "version": "4.218.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.217.0",
+ "version": "4.218.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+// Post · Ask · Do (ov-composer.js 4.218.0; research-2026-11-12/composer-post-ask-do.md) in a real headless Chrome at 390 px,
+// against a fake Network Frame:
+// - a box that opts in shows the three verbs; Ask and Do bring the Model, Sources and Depth chips; Post hides them;
+// - the Model sheet lists the Frame's groups (Auto first, OpenVibe with prices and an Exchange link, your keys), finds
+// by name, and a pick names the chip and is remembered;
+// - Sources are switches; the chip says what is on;
+// - Ask posts { verb, text, model, sources, depth, context } to the Frame and streams the run's events into an answer
+// card with its sources; "Post it" puts the answer in the box as a post; the box's own onSubmit never hears an Ask;
+// - a site can answer itself (onAsk) through the same card.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-verbs-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+
+const root = path.join(__dirname, '..');
+const asked = [];
+const CHOICES = { task: 'chat', recommended: { model: 'deepseek/v4.1-flash', source: 'openvibe' }, groups: [
+ { id: 'openvibe', title: 'OpenVibe', rows: [
+ { model: 'deepseek/v4.1-flash', source: 'openvibe', name: 'DeepSeek V4.1 Flash', maker: 'DeepSeek', price: { in: 0.27, out: 1.1, unit: 'per million tokens' }, url: 'https://openvibe.exchange/models/deepseek/v4.1-flash' },
+ { model: 'qwen/qwen3-32b', source: 'openvibe', name: 'Qwen3 32B', maker: 'Qwen', free: true, url: 'https://openvibe.exchange/models/qwen/qwen3-32b' },
+ ] },
+ { id: 'keys', title: 'Your keys', rows: [{ model: 'anthropic/claude-sonnet', source: 'key', name: 'Claude Sonnet (your key)', maker: 'Anthropic' }] },
+ { id: 'machines', title: 'Your computers', rows: [] },
+] };
+const server = http.createServer((req, res) => {
+ const u = req.url.split('?')[0];
+ if (['/ov-composer.js', '/ov-window.js', '/ov-capture.js'].includes(u)) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
+ if (u === '/api/frame/choices') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(CHOICES)); }
+ if (u === '/api/frame/ask' && req.method === 'POST') {
+ let body = ''; req.on('data', (c) => { body += c; });
+ return req.on('end', () => { asked.push(JSON.parse(body)); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ run: 'run_1' })); });
+ }
+ if (u === '/api/v1/runs/run_1/events') {
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
+ const send = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
+ send({ type: 'RUN_STARTED' });
+ send({ type: 'TEXT_MESSAGE_CONTENT', delta: 'Streams on OpenVibe ' });
+ setTimeout(() => {
+ send({ type: 'TEXT_MESSAGE_CONTENT', delta: 'can be recorded as VODs [1].' });
+ send({ type: 'CUSTOM', name: 'citations', value: [{ n: 1, title: 'Going live in your browser', url: 'https://openvibe.live/docs/go-live-in-your-browser', site: 'openvibe.live' }] });
+ send({ type: 'RUN_FINISHED', result: { cost: 0.0012 } });
+ res.end();
+ }, 150);
+ return undefined;
+ }
+ res.writeHead(200, { 'content-type': 'text/html' });
+ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div id="a"></div><div id="b"></div>
+<script src="/ov-composer.js"></script>
+<script>
+window.posted = []; window.own = [];
+const C = OpenVibeComposer;
+window.post = C.mount(document.getElementById('a'), { preset: 'post', id: 'p1', user: { display_name: 'goosely' }, verbs: ['post', 'ask', 'do'],
+ frameBase: location.origin + '/api/frame', ask: { task: 'chat', context: { site: 'live', kind: 'stream', title: 'Maticus live', url: 'https://openvibe.live/@Maticus' } },
+ onSubmit: async (v) => { window.posted.push(v.text); } });
+window.chatBox = C.mount(document.getElementById('b'), { preset: 'chat', id: 'c1', verbs: ['post', 'ask'], frameBase: location.origin + '/api/frame',
+ onSubmit: async () => true, onAsk: async (v, api) => { window.own.push(v.text); api.card.set('An answer the site gave itself.'); api.card.done({}); } });
+</script>`);
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'composer-verbs-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
+ await page.goto(base + '/', { maxMs: 5000 });
+ const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };
+ assert.ok(await until(`!!window.chatBox`), 'the boxes mount');
+
+ // The three verbs; Post first; the Ask chips hidden on Post.
+ const shape = await page.evaluate(`(() => { const f = document.getElementById('p1'); return { verbs: [...f.querySelectorAll('.ovc-verb')].map((b) => b.dataset.verb + ':' + b.getAttribute('aria-pressed')), verb: f.dataset.verb, chipsShown: getComputedStyle(f.querySelector('.ovc-ask-chips')).display !== 'none' }; })()`);
+ assert.deepEqual(shape, { verbs: ['post:true', 'ask:false', 'do:false'], verb: 'post', chipsShown: false });
+ await page.evaluate(`document.querySelector('#p1 .ovc-verb[data-verb="ask"]').click()`);
+ const asking = await page.evaluate(`(() => { const f = document.getElementById('p1'); return { verb: f.dataset.verb, send: f.querySelector('[data-ovt-send]').getAttribute('aria-label'), chips: [...f.querySelectorAll('.ovc-ask-chips .ovc-chip-v')].map((c) => c.textContent), placeholder: f.querySelector('textarea').placeholder }; })()`);
+ assert.deepEqual(asking, { verb: 'ask', send: 'Ask', chips: ['Auto', 'Web · OpenVibe', 'Balanced'], placeholder: 'Ask anything' });
+
+ // The Model sheet: Auto, then the Frame's groups with prices and an Exchange link; find; pick.
+ await page.evaluate(`document.querySelector('#p1 .ovc-ask-chips .ovc-chip--btn').click()`);
+ assert.ok(await until(`document.querySelectorAll('dialog.ovc-sheet .ovc-model').length >= 4`), 'the models are listed');
+ const sheet = await page.evaluate(`(() => { const d = document.querySelector('dialog.ovc-sheet'); return { heads: [...d.querySelectorAll('h3')].map((x) => x.textContent), first: d.querySelector('.ovc-model b').textContent, price: [...d.querySelectorAll('.ovc-model small')].map((x) => x.textContent)[1], rec: !!d.querySelector('.ovc-model-rec'), link: d.querySelector('.ovc-model-x').href, width: Math.round(d.getBoundingClientRect().width) }; })()`);
+ assert.deepEqual(sheet.heads, ['OpenVibe', 'Your keys'], 'groups with rows only');
+ assert.match(sheet.first, /^Auto/);
+ assert.match(sheet.price, /\$0\.27 \/ M/, 'a price per million');
+ assert.ok(sheet.rec, 'the recommended model is marked');
+ assert.match(sheet.link, /openvibe\.exchange\/models\//, 'each OpenVibe model opens on Exchange');
+ assert.equal(sheet.width, 390, 'the whole width on a phone');
+ await page.evaluate(`(() => { const f = document.querySelector('dialog.ovc-sheet .ovc-sheet-find'); f.value = 'qwen'; f.dispatchEvent(new Event('input')); })()`);
+ assert.equal(await page.evaluate(`[...document.querySelectorAll('dialog.ovc-sheet .ovc-model')].filter((r) => !r.hidden).length`), 1, 'find narrows the list');
+ await page.evaluate(`[...document.querySelectorAll('dialog.ovc-sheet .ovc-model')].find((r) => !r.hidden).querySelector('.ovc-model-b').click()`);
+ assert.equal(await page.evaluate(`document.querySelector('#p1 .ovc-ask-chips .ovc-chip-v').textContent`), 'Qwen3 32B');
+ assert.equal(await page.evaluate(`JSON.parse(localStorage.getItem('ov:composer:model:chat')).model`), 'qwen/qwen3-32b', 'remembered');
+
+ // Sources: switches, and this page because the site gave its context.
+ await page.evaluate(`document.querySelectorAll('#p1 .ovc-ask-chips .ovc-chip--btn')[1].click()`);
+ assert.ok(await until(`document.querySelectorAll('dialog.ovc-sheet .ovc-src').length === 4`), 'web, OpenVibe, mine and this page');
+ await page.evaluate(`(() => { const box = [...document.querySelectorAll('dialog.ovc-sheet .ovc-src')].find((l) => /This/.test(l.textContent)).querySelector('input'); box.click(); document.querySelector('dialog.ovc-sheet .ovc-bar-x').click(); })()`);
+ assert.equal(await page.evaluate(`document.querySelectorAll('#p1 .ovc-ask-chips .ovc-chip-v')[1].textContent`), 'Web · OpenVibe · This page');
+
+ // Ask: to the Frame, streamed into the card with its sources; onSubmit never hears it.
+ await page.evaluate(`(() => { const t = post.text; t.value = 'Can I record my stream?'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('#p1 [data-ovt-send]').click(); })()`);
+ assert.ok(await until(`!!document.querySelector('.ovc-answer footer:not([hidden])')`), 'the answer finishes');
+ const card = await page.evaluate(`(() => { const c = document.querySelector('.ovc-answer'); return { text: c.querySelector('.ovc-answer-t').textContent, cites: [...c.querySelectorAll('.ovc-cites a')].map((a) => a.textContent), meta: c.querySelector('header small').textContent, actions: [...c.querySelectorAll('footer button')].map((b) => b.textContent) }; })()`);
+ assert.equal(card.text, 'Streams on OpenVibe can be recorded as VODs [1].');
+ assert.deepEqual(card.cites, ['1. Going live in your browser']);
+ assert.match(card.meta, /Qwen3 32B · \$0\.001/);
+ assert.deepEqual(card.actions, ['Post it', 'Continue in Vibe', 'Do this']);
+ assert.equal(asked.length, 1);
+ assert.deepEqual({ verb: asked[0].verb, text: asked[0].text, model: asked[0].model.model, sources: asked[0].sources, depth: asked[0].depth, context: asked[0].context.kind },
+ { verb: 'ask', text: 'Can I record my stream?', model: 'qwen/qwen3-32b', sources: ['web', 'openvibe', 'page'], depth: 'balanced', context: 'stream' });
+ assert.equal(await page.evaluate(`window.posted.length`), 0, 'an Ask is not a post');
+ // Post it: the answer becomes the post, on the Post verb.
+ await page.evaluate(`document.querySelector('.ovc-answer footer button').click()`);
+ assert.deepEqual(await page.evaluate(`({ verb: document.getElementById('p1').dataset.verb, text: post.text.value })`), { verb: 'post', text: 'Streams on OpenVibe can be recorded as VODs [1].' });
+
+ // A site that answers itself, through the same card.
+ await page.evaluate(`(() => { document.querySelector('#c1 .ovc-verb[data-verb="ask"]').click(); const t = chatBox.text; t.value = 'what is up'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('#c1 [data-ovt-send]').click(); })()`);
+ assert.ok(await until(`window.own.length === 1 && [...document.querySelectorAll('.ovc-answer .ovc-answer-t')].some((x) => x.textContent === 'An answer the site gave itself.')`), 'onAsk answers through the card');
+
+ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
+ await page.close();
+ console.log('composer-verbs-chrome: Post · Ask · Do, the Model sheet from the Frame, Sources, an answer streamed with its sources, Post it, a site\'s own answer');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// The inline Community thread's composer (openvibe-shared 4.215.0, thread.renderThread + ov-thread.js): the
+// server-drawn comment form still posts without script; with the script it becomes the shared composer v2
+// (form.ovc-v2[data-preset=comment]) in place, keeps the action and the reply's parent_id, and sends the words
+// natively. Real headless Chrome at 390 px; skipped without Chrome.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+const thread = require('../thread');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('inline-composer-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const root = path.join(__dirname, '..');
+const answer = {
+ thread: { id: 'cth_1', access_id: 'cth_1', comment_count: 1, url: 'https://openvibe.community/c/cth_1' },
+ comments: [{ id: 1, message: 'A <comment>', created_at: '2026-11-12T00:00:00Z', author: { username: 'alex', display_name: 'Alex' }, reply_count: 0 }],
+ viewer: { can_comment: true },
+};
+const HTML = thread.renderThread({ answer, base: '/p/x', post: '/p/x/comments', remove: '/p/x/comments/remove', signedIn: true, attachmentsAllowed: true, replyId: 1 });
+let posted = null, postedRaw = '';
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (req.method === 'POST' && u.pathname === '/p/x/comments') {
+ let body = '';
+ req.on('data', (d) => { body += d; });
+ req.on('end', () => { postedRaw = body; posted = { message: (body.match(/name="message"\r?\n\r?\n([^\r]*)/) || [])[1] || '', parent: (body.match(/name="parent_id"\r?\n\r?\n([^\r]*)/) || [])[1] || '' }; res.writeHead(200, { 'content-type': 'text/html' }); res.end('<!doctype html><p id="posted">posted</p>'); });
+ return;
+ }
+ if (u.pathname === '/') {
+ res.writeHead(200, { 'content-type': 'text/html' });
+ return res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Comments</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/thread.css"><link rel="stylesheet" href="/thread-inline.css"></head><body style="margin:0;padding:16px;background:#0b1220;color:#e6edf7;font-family:sans-serif"><div data-ovt data-ovc-thread>${HTML}</div><script src="/ov-composer.js"></script><script src="/ov-thread.js"></script></body></html>`);
+ }
+ if (['/thread.css', '/thread-inline.css', '/ov-composer.js', '/ov-thread.js'].includes(u.pathname)) {
+ res.writeHead(200, { 'content-type': u.pathname.endsWith('.css') ? 'text/css' : 'application/javascript' });
+ return res.end(fs.readFileSync(path.join(root, u.pathname.slice(1))));
+ }
+ res.writeHead(404); res.end();
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'inline-composer-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (expr) => { const end = Date.now() + 5000; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(40); } return false; };
+ let page;
+ try {
+ page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 900 });
+ await page.goto(base + '/', { maxMs: 6000 });
+ assert.ok(await until(`!!document.querySelector('form.ovc-v2[data-preset="comment"]')`), 'the reply is the shared comment composer');
+ assert.equal(await page.evaluate(`document.querySelectorAll('[data-ovt-composer]').length`), 0, 'the server form is replaced, not doubled');
+ const shape = await page.evaluate(`(() => { const f = document.querySelector('form.ovc-v2[data-preset="comment"]'); return { action: f.getAttribute('action'), method: f.method, parent: (f.querySelector('input[name=parent_id]') || {}).value, send: f.querySelector('[data-ovt-send]').textContent.trim(), tools: [...f.querySelectorAll('.ovt-c-tools .ovt-btn')].map((b) => b.getAttribute('aria-label')) }; })()`);
+ assert.deepEqual(shape, { action: '/p/x/comments', method: 'post', parent: '1', send: 'Reply', tools: ['Photos and video', 'Emoji'] });
+ assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390, 'nothing is wider than a phone');
+ await page.evaluate(`(() => { const t = document.querySelector('form.ovc-v2 textarea'); t.value = 'Hello from the shared box'; t.dispatchEvent(new Event('input', { bubbles: true })); document.querySelector('form.ovc-v2 [data-ovt-send]').click(); })()`);
+ const landed = await until(`!!document.getElementById('posted')`);
+ assert.ok(landed || posted, 'the native form posts to the page\'s action');
+ if (posted) { assert.equal(posted.message, 'Hello from the shared box'); assert.equal(posted.parent, '1'); }
+ await page.close();
+ console.log('inline-composer-chrome: the comment form mounts preset comment, keeps action and parent, sends, 390 px');
+ } finally {
+ if (page) await page.close().catch(() => {});
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -286,12 +286,12 @@ function optionsHtml({ html, label = 'Options' }) {
@@ -286,12 +286,12 @@ function optionsHtml({ html, label = 'Options' }) {
* commands: [{ name: 'fix', hint: 'Fix a bug', insert: 'Fix this: ' }] the box's "/" list (ov-composer.js, 4.201.0),
* commands: [{ name: 'fix', hint: 'Fix a bug', insert: 'Fix this: ' }] the box's "/" list (ov-composer.js, 4.201.0),
* with the person's own commands and the site's mods (files, @, voice and drafts come with it on every box)
* with the person's own commands and the site's mods (files, @, voice and drafts come with it on every box)
*/
*/
-function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [], commands = [] } = {}) {
+function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '', options = null, examples = [], commands = [], preset = null } = {}) {
const cmds = (commands || []).filter((c) => c && /^[\w-]{1,30}$/.test(c.name || '')).slice(0, 30).map((c) => ({ name: c.name, hint: String(c.hint || '').slice(0, 120), insert: String(c.insert || '').slice(0, 4000) }));
const cmds = (commands || []).filter((c) => c && /^[\w-]{1,30}$/.test(c.name || '')).slice(0, 30).map((c) => ({ name: c.name, hint: String(c.hint || '').slice(0, 120), insert: String(c.insert || '').slice(0, 4000) }));
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('') + (cmds.length ? attr('data-ovt-commands', JSON.stringify(cmds)) : '');
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('') + (cmds.length ? attr('data-ovt-commands', JSON.stringify(cmds)) : '');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
-<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}
+<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${preset ? attr('data-ovt-preset', preset) : ''}${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}
${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''}${examples && examples.length ? ` data-ovt-examples="${esc(JSON.stringify(examples.slice(0, 12).map((x) => String(x).slice(0, 140))))}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''}${examples && examples.length ? ` data-ovt-examples="${esc(JSON.stringify(examples.slice(0, 12).map((x) => String(x).slice(0, 140))))}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}</div>
<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? attachHtml(attach) : ''}${tools || ''}${options && options.html ? optionsHtml(options) : ''}</div>
@@ -348,7 +348,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
@@ -348,7 +348,7 @@ function renderThread({ answer, base, post, remove, signedIn, attachmentsAllowed
const signIn = `<p class="ovc-signin">${comments.length ? 'Sign in to comment.' : 'Be the first to comment.'} <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;
const signIn = `<p class="ovc-signin">${comments.length ? 'Sign in to comment.' : 'Be the first to comment.'} <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;
const composer_html = (parent = null) => signedIn ? composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000,
const composer_html = (parent = null) => signedIn ? composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', value: draft, maxlength: 2000,
sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},
sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},
- attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null,
+ attach: attachmentsAllowed ? { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' } : null, preset: 'comment',
note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '',
note: attachmentsAllowed ? 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.' : '',
}) : signIn;
}) : signIn;
const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {