openvibers/openvibe.actor Commits
Actor's chat is the one place you use Actor: composer v2, Settings, Now strip and run cards (#447)
Changes
@@@ -2,6 +2,24 @@What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.++## Unreleased — the chat is the one place you use Actor (one agent system, stage 1)++++- **Composer v2** (openvibe-shared 4.215.0, the `agent` preset): the chat mounts `OpenVibeComposer.mount` with Attach,++ Speak, the mode chip (Fast, Balanced, Best, Cheapest, Private) and the round send; files go through Actor's existing++ path (`POST /chat` with the file as data) and their chips show the read's progress. The dashed "Attach files" box and++ the separate model/mode controls are gone; the model lives at `/chat/model`.++- **Settings** (`/chat/settings`): the run defaults ("New runs: Only me … Apply to all my existing runs") moved off the++ chat. The route (`POST /chat/defaults`) and its behaviour are unchanged.++- **Four suggestion chips** on an empty chat, one line each, instead of the wall of starter cards; a chat with messages++ shows none.++- **The Console is folded in**: `/console` answers 301 to `/chat` (carrying `?ask=`/`?from=`), the bottom tab bar is++ Home, Chat, Agents, and the running tasks the Console listed are the chat's "Now" strip — one line each, Stop on each,++ hidden when nothing runs. `/me/spend` and the approvals, agents, computers and robots APIs and pages stay.++- **A running task is a card in the conversation** (title, state, the last steps, Stop), refreshed in place from++ `GET /chat/turn/:id`; stopping goes through `POST /chat/:id/cancel` and returns to the chat.++- Migration-free. Tests: `test/one-page.test.js`, plus the updated chat, console, model, sharing, allowance and++ self-host suites; screenshots at 390 and 1280: `test/one-page-shots.js`.++## Unreleased — skills that run code, sandboxed (HO8 step 3)- **`actor.skill@2`: code in a sandbox** (plan HO8, ADR-060; contracts 0.343.0): a sandbox skill carries one to five@@@ -121,7 +121,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- **OpenVibe.Search** (`ACTOR_SEARCH_URL`), through the Search Fabric (`/api/v1/query`): content across the network for the runtime's `search_openvibe` tool (anonymous), and the web for `search_web` (cited results; asked as Actor's service principal with the grant `search.query.delegate`, naming the task's person in `X-OV-Subject`, so the paid steps past the open index count against them).- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.--- **The Console** (`/console`, plan T17 layer 3): one page for a signed-in person. It has a command box (the ordinary task form) and their running and recent tasks; it refreshes itself while something runs, never while they type. It shows what waits for them: their approvals, and approvals waiting on their own computers. It also shows today's spend against the free tier, their agents, and their computers and robots, read from OpenVibe.Bot as them with a 4 s bound, so a slow Bot never holds the page. Links go to Services, Watch and Codes for the rest. It works without JavaScript (`server/http/console-pages.js`, `test/console.test.js`).++- **The chat is the one page** (one-agent system, stage 1): the Console is folded into it. `/console` answers 301 to `/chat`; the running tasks the Console listed are the "Now" strip at the top of the chat (one line each, Stop on each, hidden when nothing runs), and a running task is a card in its conversation that updates in place. The run settings ("New runs: Only me … Apply to all my existing runs") live in `/chat/settings`; the composer is openvibe-shared's composer v2, agent preset (Attach, Speak, the Fast/Balanced/Best mode chip, the round send, progress on each file). The empty chat shows four compact suggestion chips. `/me/spend` stays (`server/http/console-pages.js`, `server/http/chat-pages.js`, `test/one-page.test.js`).- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Link's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`). `computer_screen` (when an OpenAI key is set) takes a picture of the machine's screen through Bot (1280 px wide) and has OpenAI's vision model answer the agent's question about it. The runtime model gets that answer, never the picture; the picture is not stored; secrets visible on screen are named, not repeated; and each look is metered on the task like any model call (refused once the task's budget is spent). `computer_input` (offered with `computer_screen`; contracts 0.171.0) uses the mouse and keyboard: a click, double or right click or move at a position in the last screen picture (its width and height are passed, and the Link scales them to the real screen), a scroll, typed text, or one key or chord. It is an acting command, so at level `ask` each one waits for the person's approval on the machine; the runtime is told to look first, act in small steps, look again, and never type a secret the task did not give it.- `computer_browser` (contracts 0.174.0, Link v0.10.0) drives a browser of the agent's own on the machine. It is aprofile of its own, so it never sees the person's sessions.@@@ -16,7 +16,7 @@ docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b```Open http://localhost:4950/auth/login and sign in with the password `selfhost:init` printed. It is shown only once.--Then give Actor a task on `/console`, or call the API with your token:++Then give Actor a task on `/chat`, or call the API with your token:```shcurl -s http://localhost:4950/api/v1/tasks -H "Authorization: Bearer $ACTOR_OWNER_TOKEN" \@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0","pg": "^8.23.0","sharp": "^0.34.5"},@@@ -1756,9 +1756,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.214.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",-- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",++ "version": "4.215.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",++ "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -30,7 +30,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0","pg": "^8.23.0","sharp": "^0.34.5"},@@@ -24,15 +24,13 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-appbox-shadow: 0 10px 40px color-mix(in srgb, var(--accent, #f97316) 30%, transparent); animation: ct-breathe 3.2s ease-in-out infinite; }.ct-hello h2 { margin: 14px 0 6px; font-size: 1.6rem; }.ct-hello > p { margin: 0 auto 20px; max-width: 46ch; color: var(--text-secondary, #a8b3c4); }--.ct-starters { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; text-align: left; }--.ct-starters button { display: grid; gap: 3px; width: 100%; height: 100%; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-align: left; }--.ct-starters button:hover { border-color: var(--accent, #f97316); }--.ct-starters b { font-size: 14px; }--.ct-starters span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #a8b3c4); }--.ct-starters button { grid-template-columns: 22px minmax(0, 1fr); column-gap: 10px; align-items: start; }--.ct-starters button svg { grid-row: span 2; margin-top: 1px; color: var(--accent, #f97316); }--.ct-starters span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }--@media (max-width: 560px) { .ct-starters { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .ct-starters button { padding: 10px 11px; grid-template-columns: minmax(0, 1fr); } .ct-starters button svg { grid-row: auto; } .ct-starters span { font-size: 12.5px; } }++/* Four compact suggestion chips above the composer, one line each (the owner, 11-12: the wall of cards was "an++ absolute atrocious mess"); a chat with messages shows none. */++.ct-suggest { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }++.ct-suggest-chip { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font: inherit; font-size: 13.5px; white-space: nowrap; cursor: pointer; }++.ct-suggest-chip:hover, .ct-suggest-chip:focus-visible { border-color: var(--accent, #f97316); }++.ct-suggest-chip svg { flex: none; color: var(--accent, #f97316); }++@media (max-width: 560px) { .ct-suggest { gap: 6px; } .ct-suggest-chip { min-height: 44px; font-size: 13px; } }/* The composer's own tools: the mode, the live note, a problem line. */.cc-mode select { max-width: 150px; min-height: 34px; padding: 0 26px 0 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-secondary, #b4c0d4);@@@ -182,3 +180,77 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app.cc-canvas-phone .cc-canvas-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }@media (min-width: 1180px) { .cc-canvas-phone { display: none !important; } }@media (max-width: 1179px) { .cc-canvas-wide { display: none !important; } }++++++/* ── The Now strip (the Console's "Now" card, folded into the chat): one row per running task, Stop on each, hidden++ when nothing runs. One line on a phone: the last step folds away and the title truncates. */++.ct-now { position: sticky; top: calc(var(--ovt-top, 56px) + 52px); z-index: 18; margin: 0; padding: 6px 16px; background: var(--bg-primary, #0a0f1c); }++.ct-now[hidden] { display: none; }++.ct-now-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.ct-now-item { display: grid; grid-template-columns: auto minmax(0, .9fr) minmax(0, 1.1fr) auto auto; align-items: center; gap: 8px; min-height: 44px; padding: 4px 6px 4px 10px; border: 1px solid var(--line); border-radius: 12px; background: color-mix(in srgb, var(--accent, #f97316) 4%, var(--bg-card, #131c2e)); }++.ct-now-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #f97316); animation: ct-breathe 1.4s ease-in-out infinite; }++.ct-now-task { display: flex; align-items: center; min-width: 0; min-height: 44px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 13.5px; }++.ct-now-task b { font-weight: 650; }++.ct-now-step { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #a8b3c7); font-size: 12.5px; }++.ct-now-state { flex: none; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #f97316) 14%, transparent); color: var(--accent, #f97316); font-size: 11.5px; font-weight: 700; white-space: nowrap; }++.ct-now-stop { margin: 0; }++.ct-now-stop button { min-width: 60px; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); font: inherit; font-weight: 650; cursor: pointer; }++.ct-now-stop button:hover, .ct-now-stop button:focus-visible { border-color: var(--accent, #f97316); }++@media (max-width: 720px) {++ .ct-now { padding: 6px 10px; }++ .ct-now-item { grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 6px; }++ .ct-now-step { display: none; }++}++++/* ── A running task in the conversation is a card, not a separate panel: title, state, the last steps and Stop. The++ shared thread's card and button look; js/chat.js refreshes it in place. */++.ct-run { display: grid; gap: 6px; border-left: 3px solid var(--accent, #f97316); }++.ct-run-title { flex: 1; min-width: 0; overflow-wrap: anywhere; font-weight: 650; color: var(--text-primary, #e6edf7); }++.ct-run-state { flex: none; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #f97316) 14%, transparent); color: var(--accent, #f97316); font-size: 11.5px; font-weight: 700; white-space: nowrap; }++.ct-run-live { margin: 0; color: var(--text-secondary, #a8b3c7); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ct-run-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }++.ct-run-steps > li { font-size: 13px; color: var(--text-secondary, #a8b3c7); overflow-wrap: anywhere; }++.ct-run-steps .ovt-w-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }++.ct-run-steps .ovt-w-in { color: var(--text-muted, #7386a3); font-size: 12.5px; }++.ct-run-steps .ovt-w-step span, .ct-run-steps .ovt-w-note p, .ct-run-steps .ovt-w-you p { margin: 0; }++.ct-run-stop { margin: 0; display: flex; justify-content: flex-end; }++.ct-run-stop .ovt-card-btn { min-width: 60px; min-height: 44px; }++++/* ── Composer v2 in the chat: the allowance is one quiet line under the box; the server's fallback form is replaced by++ the mounted composer. Nothing on a phone is under 44 px. */++.ct-composer-wrap { width: 100%; max-width: 860px; margin: 0 auto; }++.cc-allowance { margin: 6px 4px 0; text-align: center; color: var(--text-muted, #7386a3); font-size: 12px; line-height: 1.45; }++.ct-composer-fallback { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 18px; background: var(--bg-card, #131c2e); }++.ct-composer-fallback textarea { width: 100%; min-height: 52px; box-sizing: border-box; padding: 8px 4px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; font-size: 16px; }++.ct-composer-fallback-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }++.ct-composer-mode { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary, #a8b3c7); font-size: 13px; }++.ct-composer-mode select { min-height: 44px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: inherit; font: inherit; }++.ct-composer-fallback button { min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent, #f97316); color: var(--on-accent, #fff); font: inherit; font-weight: 700; }++/* The mode chip is the select's own look: full words, one pill, 44 px on a phone (the native select stays behind it). */++.cc-composer .ovc-chip { flex: none; max-width: none; height: 44px; }++.cc-composer .ovc-chip-v { overflow: visible; text-overflow: clip; }++.cc-composer .ovc-chip select { min-height: 44px; opacity: 0; }++@media (max-width: 640px) {++ /* The shared composer's own sizes are 40 px: a thumb needs 44. */++ .cc-composer .ovc-v2 .ovt-btn, .cc-composer .ovc-v2 .ovt-send { width: 44px; height: 44px; min-width: 44px; min-height: 44px; }++ .cc-composer .ovc-v2 .ovc-chip { min-width: 44px; min-height: 44px; }++ .cc-composer .ovc-chip select { min-height: 44px; }++ .ct-run-stop .ovt-card-btn { min-height: 44px; }++}++++/* ── Actor's Settings: the run defaults that used to sit under the composer, and the model. */++.ct-settings { max-width: 760px; margin: 0 auto; padding: 18px 16px 40px; }++.ct-settings h1 { margin: 4px 0 6px; font-size: clamp(24px, 4vw, 30px); }++.ct-settings-lede { margin: 0 0 16px; color: var(--text-secondary, #a8b3c7); line-height: 1.55; }++.ct-settings-card { margin: 0 0 16px; padding: 16px; border: 1px solid var(--line); border-radius: 16px; background: var(--bg-card, #131c2e); }++.ct-settings-card h2 { margin: 0 0 6px; font-size: 18px; }++.ct-settings-card p { margin: 0 0 12px; color: var(--text-secondary, #a8b3c7); font-size: 14px; line-height: 1.5; }++.ct-settings-foot { margin: 10px 0 0 !important; font-size: 13px !important; }++.ct-settings-foot a { display: inline-flex; align-items: center; min-height: 44px; }++.ct-share-defaults { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }++.ct-share-defaults label { font-size: 14px; color: var(--text-primary, #e6edf7); }++.ct-share-defaults select { min-height: 44px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: inherit; font: inherit; }++.ct-share-defaults .ct-check { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }++.ct-share-defaults input[type=checkbox] { width: 18px; height: 18px; }++.ct-share-defaults .sc-btn { min-height: 44px; }++.ct-share-defaults .muted { flex-basis: 100%; font-size: 12.5px; line-height: 1.45; }@@@ -25,12 +25,22 @@var chat = document.querySelector('.chat');if (!chat || chat.dataset.ready) return;chat.dataset.ready = '1';-- var form = chat.querySelector('[data-ovt-composer]');-- var text = chat.querySelector('[data-ovt-text]');-- var send = chat.querySelector('[data-ovt-send]');-- var note = chat.querySelector('[data-cc-note]');++ // Composer v2 (openvibe-shared 4.215.0, the agent preset): the server renders a plain form for a browser without++ // script; with script it is replaced by the mounted composer, whose chips show each file's progress as Actor's++ // own file path reads it. The mount happens here, before ov-thread.js's DOMContentLoaded start, so the shared++ // thread binds to it too.++ var host = chat.querySelector('[data-cc-composer]');++ var form = null, text = null, send = null, note = null, composer = null;++ var uploaded = new Map();++ var uploadSeq = 0;var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';++ if (host) mountComposer();++ else {++ form = chat.querySelector('[data-ovt-composer]');++ text = chat.querySelector('[data-ovt-text]');++ send = chat.querySelector('[data-ovt-send]');++ }// ── The canvas (plan HO9). The document rides in an inert template; openvibe-shared's ov-canvas.js draws it// into the wide panel and the phone fold from the same data. A button's label goes to the agent as the@@@ -131,84 +141,116 @@// ── The thread stays at the bottom while you are there.function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }-- function toBottom(smooth) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }++ function toBottom(smooth) {++ // A short chat has nothing to scroll: don't hide the Now strip (or the start of the thread) for no reason.++ if (document.documentElement.scrollHeight <= window.innerHeight + 2) return;++ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' });++ }// ── The composer: ov-thread.js grows it, sends on Enter and fills starters. An input event lets it re-measure after// this file changes the words.function grow() { if (text) text.dispatchEvent(new Event('input')); }if (text) text.addEventListener('input', function () { syncSend(); });-- // ── Files: the shared picker owns the drop zone, chips and removal.-- var uploadRoot = chat.querySelector('[data-cc-upload]');-- var picked = [];-- var meters = new Map();-- var picker = uploadRoot && window.OVUpload && OVUpload.picker(uploadRoot, {-- label: 'Attach files', accept: uploadRoot.dataset.accept, multiple: true,-- onFile: function (file, row) {-- var allowed = /^text\//.test(file.type) || /^application\/(pdf|json|xml|x-yaml|yaml|javascript|sql)$/.test(file.type) ||-- /^image\/(png|jpeg|webp|gif)$/.test(file.type) ||-- /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|c|h|cpp|hpp|cs|php|sh|sql|log|ini|env\.example|srt|vtt|pdf)$/i.test(file.name);-- var total = picked.reduce(function (sum, item) { return sum + item.size; }, 0) + file.size;-- if (!allowed || picked.length >= 4 || file.size > 6 * 1048576 || total > 12 * 1048576) {-- row.dispatchEvent(new Event('remove')); row.remove();-- problem(!allowed ? 'Actor reads text, code, CSV, JSON, PDFs and PNG, JPEG, WebP or GIF images.' :-- picked.length >= 4 ? 'Attach at most 4 files to one message.' :-- file.size > 6 * 1048576 ? file.name + ' is over 6 MB.' : 'The files together are over 12 MB.');-- return;-- }-- problem(null);-- picked.push(file);-- var meterRoot = document.createElement('div'); row.append(meterRoot);-- var meter = OVUpload.progress(meterRoot, { compact: true, onRetry: function () { form.requestSubmit(); } });-- meter.update({ state: 'waiting', total: file.size });-- meters.set(file, meter);-- syncSend();-- },-- onRemove: function (file) {-- picked = picked.filter(function (item) { return item !== file; });-- if (meters.has(file)) { meters.get(file).destroy(); meters.delete(file); }-- syncSend();++ // ── Composer v2: mount the agent preset (Attach, Speak, the mode chip, the round send) and wire it to Actor's++ // own send: JSON with the files as data URLs (server/tasks/inputs.js), and while Actor works, its steer route.++ function mountComposer() {++ if (!window.OpenVibeComposer) return;++ host.replaceChildren();++ var parent = host.getAttribute('data-parent') || '';++ var workingId = host.getAttribute('data-working') || '';++ var mode = host.getAttribute('data-mode') || 'balanced';++ composer = window.OpenVibeComposer.mount(host, {++ preset: 'agent', id: 'cc-composer',++ placeholder: workingId ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something',++ label: 'Message Actor', maxLength: 20000,++ maxFiles: Number(host.getAttribute('data-max-files')) || 4,++ maxMb: Number(host.getAttribute('data-max-mb')) || 6,++ accept: host.getAttribute('data-accept') || '',++ mode: { name: 'mode', label: 'How Actor works', value: mode,++ options: [['fastest', 'Fast'], ['balanced', 'Balanced'], ['best', 'Best'], ['cheapest', 'Cheapest'], ['private', 'Private']] },++ upload: function (file, onProgress) {++ return new Promise(function (ok, no) {++ var rd = new FileReader();++ rd.onprogress = function (e) { if (e.lengthComputable) onProgress(e.total ? e.loaded / e.total : 0); };++ rd.onload = function () {++ var data = String(rd.result || '').split(',')[1] || '';++ if (!data) { no(new Error('Could not read ' + file.name)); return; }++ uploaded.set(file, { name: file.name, type: file.type || '', data: data });++ ok({ id: 'up_' + (++uploadSeq) });++ };++ rd.onerror = function () { no(new Error('Could not read ' + file.name)); };++ rd.readAsDataURL(file);++ });++ },++ beforeSend: function (ctx) {++ var bad = (ctx.files() || []).find(function (f) { return !allowedFile(f); });++ if (bad) { problem('Actor reads text, code, CSV, JSON, PDFs and PNG, JPEG, WebP or GIF images.'); return false; }++ return true;++ },++ onSubmit: function (v) { return submit(v); },++ });++ form = composer.form; text = composer.text; send = composer.send; note = composer.note;++ // The mode select lives under the chip's own look: keep the browser's select (hidden behind the chip), never++ // ov-forms' box, which would draw a second pill inside it.++ var modeSelect = form.querySelector('select[name="mode"]');++ if (modeSelect) modeSelect.setAttribute('data-ov-native', '');++ var parentInput = document.createElement('input');++ parentInput.type = 'hidden'; parentInput.name = 'parent'; parentInput.value = parent;++ form.appendChild(parentInput);++ if (host.getAttribute('data-text')) {++ text.value = host.getAttribute('data-text');++ text.dispatchEvent(new Event('input'));}-- });-- function clearPicked() {-- picked = [];-- meters.forEach(function (meter) { meter.destroy(); }); meters.clear();-- if (picker) picker.rows.replaceChildren();-- }-- function readAll(files) {-- return Promise.all(files.map(function (f) {-- return new Promise(function (ok, no) {-- var rd = new FileReader();-- rd.onload = function () { ok({ name: f.name, type: f.type || '', data: String(rd.result).split(',')[1] || '' }); };-- rd.onerror = function () { no(new Error('read')); };-- rd.readAsDataURL(f);++ if (host.getAttribute('data-busy')) text.placeholder = 'Tell Actor something while it works';++ // A starter chip fills the mounted box (ov-thread.js bound the plain one before this existed).++ chat.querySelectorAll('[data-ovt-starter]').forEach(function (b) {++ b.addEventListener('click', function () {++ text.value = b.getAttribute('data-ovt-starter') || '';++ text.dispatchEvent(new Event('input'));++ text.focus();});-- }));++ });++ }++ function allowedFile(file) {++ return /^text\//.test(file.type) || /^application\/(pdf|json|xml|x-yaml|yaml|javascript|sql)$/.test(file.type) ||++ /^image\/(png|jpeg|webp|gif)$/.test(file.type) ||++ /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|c|h|cpp|hpp|cs|php|sh|sql|log|ini|env\.example|srt|vtt|pdf)$/i.test(file.name);++ }++ // The file's data, read once by the composer's upload (for its progress chip) or here when a browser had none.++ function filePayload(file) {++ var ready = uploaded.get(file);++ if (ready) return Promise.resolve(ready);++ return new Promise(function (ok, no) {++ var rd = new FileReader();++ rd.onload = function () { ok({ name: file.name, type: file.type || '', data: String(rd.result || '').split(',')[1] || '' }); };++ rd.onerror = function () { no(new Error('Could not read ' + file.name)); };++ rd.readAsDataURL(file);++ });}--function openTurn() { var t = chat.querySelectorAll('.ovt-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }var stopBtn = chat.querySelector('[data-ovt-stop]');function working() { var t = openTurn(); return t && t.hasAttribute('data-open') ? t : null; }function syncSend() {-- if (!send) return;++ if (!send || !text) return;var w = working();if (stopBtn) { stopBtn.hidden = !w; stopBtn.type = 'button'; stopBtn.removeAttribute('formaction'); }-- form.setAttribute('action', w ? '/chat/' + w.getAttribute('data-task') + '/steer' : '/chat');-- if (text) text.placeholder = w ? 'Tell Actor something while it works' : (form.querySelector('input[name=parent]') ? 'Reply to Actor' : 'Ask Actor to do something');-- send.disabled = !(text && text.value.trim());-- if (picker) { picker.input.disabled = !!w; uploadRoot.querySelector('.ov-upload__picker').disabled = !!w; }++ text.placeholder = w ? 'Tell Actor something while it works' : (host && host.getAttribute('data-parent') ? 'Reply to Actor' : 'Ask Actor to do something');}syncSend();function problem(msg) {-- var p = form.querySelector('.cc-problem');++ var root = form || host;++ if (!root) return;++ var p = root.querySelector('.cc-problem');if (!msg) { if (p) p.remove(); return; }-- if (!p) { p = document.createElement('p'); p.className = 'cc-problem'; p.setAttribute('role', 'alert'); form.insertBefore(p, form.firstChild); }++ if (!p) { p = document.createElement('p'); p.className = 'cc-problem'; p.setAttribute('role', 'alert'); root.insertBefore(p, root.firstChild); }p.textContent = msg;}function setParent(id) {-- var input = form.querySelector('input[name=parent]');-- if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'parent'; form.appendChild(input); }-- input.value = id;++ if (host) host.setAttribute('data-parent', id);++ var input = form && form.querySelector('input[name=parent]');++ if (!input && form) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'parent'; form.appendChild(input); }++ if (input) input.value = id;if (text) text.placeholder = 'Reply to Actor';}@@@ -219,27 +261,64 @@xhr.setRequestHeader('accept', 'application/json');xhr.setRequestHeader('content-type', 'application/json');xhr.upload.onprogress = function (event) {-- if (!event.lengthComputable) return;-- var fraction = event.total ? event.loaded / event.total : 0;-- meters.forEach(function (meter, file) {-- meter.update({ state: 'sending', done: Math.round(file.size * fraction), total: file.size });-- });-- };-- xhr.upload.onload = function () {-- meters.forEach(function (meter, file) { meter.update({ state: 'processing', done: file.size, total: file.size, message: 'Actor is reading the file' }); });++ if (!event.lengthComputable || !note) return;++ note.textContent = 'Sending… ' + Math.round((event.total ? event.loaded / event.total : 0) * 100) + '%';};++ xhr.upload.onload = function () { if (note) note.textContent = 'Actor is reading the file…'; };xhr.onload = function () {var data;try { data = JSON.parse(xhr.responseText); } catch (e) { data = { error: 'That could not be sent. Try again.' }; }-- if (xhr.status >= 200 && xhr.status < 300) meters.forEach(function (meter, file) { meter.update({ state: 'done', done: file.size, total: file.size }); });-- else meters.forEach(function (meter) { meter.update({ state: 'failed', message: data.error || 'That could not be sent. Try again.' }); });resolve({ ok: xhr.status >= 200 && xhr.status < 300, j: data });};-- xhr.onerror = function () { meters.forEach(function (meter) { meter.update({ state: 'failed', message: 'No connection. Try again.' }); }); reject(new Error('No connection')); };++ xhr.onerror = function () { reject(new Error('No connection')); };xhr.send(JSON.stringify(fields));});}++ // What composer v2's submit calls: steer the open turn, or send a new message (files and all) and draw its turn.++ function submit(v) {++ problem(null);++ if (!v.text) return false;++ var w = working();++ if (w) {++ return steer(w, v.text).then(function () { return undefined; }, function () { return false; });++ }++ var fields = { task: v.text, mode: v.mode || 'balanced' };++ var parent = host && host.getAttribute('data-parent');++ if (parent) fields.parent = parent;++ return Promise.all((v.files || []).map(filePayload))++ .then(function (files) {++ if (files.length) fields.files = files;++ return sendChat(fields);++ })++ .then(function (res) {++ if (!res.ok || !res.j.turn) {++ problem(res.j.error || 'That could not be sent. Try again.');++ if (note) note.textContent = '';++ return false;++ }++ if (note) note.textContent = '';++ uploaded.clear();++ var thread = chat.querySelector('[data-ovt-thread]');++ if (thread && !thread.querySelector(':scope > li')) {++ var title = chat.querySelector('.ovt-title'); if (title) title.textContent = v.text.slice(0, 60);++ try { history.pushState({}, '', res.j.url); } catch (err) { /* */ }++ addChat(res.j.id, v.text);++ }++ var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;++ setParent(res.j.id);++ if (turn) follow(turn);++ syncSend();++ refreshNow();++ return undefined;++ })++ .catch(function () {++ problem('No connection. Your message is still in the box.');++ if (note) note.textContent = '';++ return false;++ });++ }++if (stopBtn) stopBtn.addEventListener('click', function () {var w = working();if (!w) return;@@@ -248,57 +327,26 @@fetch('/tasks/' + encodeURIComponent(w.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' }).catch(function () {}).then(function () { stopBtn.disabled = false; });});-- // Steering: the words join the working turn's list at once, and the run hears them at its next step.-- function steer(w) {-- var said = text.value.trim();++ // Steering: the words join the working card at once, and the run hears them at its next step.++ function steer(w, said) {var row = document.createElement('li'); row.className = 'ovt-w-you is-sending';row.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0"/></svg><p></p>';row.querySelector('p').textContent = said;var list = w.querySelector('[data-ovt-work]');if (list) { var fold = list.closest('details'); if (fold) fold.open = true; list.appendChild(row); }-- text.value = ''; grow(); syncSend();-- var body = new URLSearchParams({ task: said });-- fetch('/chat/' + encodeURIComponent(w.getAttribute('data-task')) + '/steer', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() })++ return fetch('/chat/' + encodeURIComponent(w.getAttribute('data-task')) + '/steer', {++ method: 'POST', credentials: 'same-origin',++ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ task: said }).toString(),++ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, j: j }; }); }).then(function (res) {row.classList.remove('is-sending');-- if (!res.ok) { row.remove(); text.value = said; grow(); syncSend(); problem(res.j.error || 'Actor could not take that just now.'); }++ if (!res.ok) { row.remove(); problem(res.j.error || 'Actor could not take that just now.'); throw new Error('steer'); }})-- .catch(function () { row.remove(); text.value = said; grow(); syncSend(); problem('No connection. Your message is still in the box.'); });++ .catch(function () { row.remove(); problem('No connection. Your message is still in the box.'); throw new Error('steer'); });}-- if (form) form.addEventListener('submit', function (e) {-- if (!window.fetch || !text || !text.value.trim()) return;-- var w = working();-- if (w) { e.preventDefault(); problem(null); steer(w); return; }-- e.preventDefault();-- problem(null);-- send.disabled = true;-- var fields = {};-- new FormData(form).forEach(function (v, k) { if (typeof v === 'string') fields[k] = v; });-- (picked.length ? readAll(picked) : Promise.resolve([]))-- .then(function (files) {-- if (files.length) fields.files = files;-- return sendChat(fields);-- })-- .then(function (res) {-- if (!res.ok || !res.j.turn) { problem(res.j.error || 'That could not be sent. Try again.'); syncSend(); return; }-- var thread = chat.querySelector('[data-ovt-thread]');-- if (thread && !thread.querySelector(':scope > li')) {-- var title = chat.querySelector('.ovt-title'); if (title) title.textContent = text.value.trim().slice(0, 60);-- try { history.pushState({}, '', res.j.url); } catch (err) { /* */ }-- addChat(res.j.id, text.value.trim());-- }-- var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;-- text.value = ''; grow();-- clearPicked();-- setParent(res.j.id);-- if (turn) follow(turn);-- syncSend();-- })-- .catch(function () { problem('No connection. Your message is still in the box.'); syncSend(); });-- });--// A new chat joins the top of the list.function addChat(id, title) {var list = chat.querySelector('.ovt-list');@@@ -443,6 +491,9 @@list.innerHTML = fresh.innerHTML;sending.forEach(function (r) { list.appendChild(r); });} else if (fresh) turn.querySelector('.ovt-reply').innerHTML = next.querySelector('.ovt-reply').innerHTML;++ // The card's state (Starting → Working → Checking) updates in place too.++ var stateNow = turn.querySelector('[data-ct-run-state]'), stateFresh = next.querySelector('[data-ct-run-state]');++ if (stateNow && stateFresh && stateNow.outerHTML !== stateFresh.outerHTML) stateNow.replaceWith(stateFresh);// The screen appears (or its live link changes) when the run opens a desk.var scr = turn.querySelector('[data-screen]'), scr2 = next.querySelector('[data-screen]');if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }@@@ -459,6 +510,7 @@var dot = chat.querySelector('.ovt-list a[aria-current] .ovt-dot'); if (dot) dot.remove();if (note) note.textContent = '';syncSend();++ refreshNow();if (text && fine) text.focus();}if (stay) toBottom(true);@@@ -466,6 +518,27 @@}).catch(function () {});}++ // The "Now" strip (the Console's Now card, in the chat): refreshed from GET /chat/now while something runs.++ var nowTimer = 0;++ function paintNow() {++ return fetch('/chat/now', { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.text() : null; })++ .then(function (h) {++ if (h == null) return;++ var box = chat.querySelector('[data-ct-now]');++ if (!box) return;++ var tmp = document.createElement('div'); tmp.innerHTML = h;++ var fresh = tmp.firstElementChild;++ if (fresh) box.replaceWith(fresh);++ })++ .catch(function () {});++ }++ function refreshNow() { if (nowTimer) return; nowTimer = setTimeout(function () { nowTimer = 0; paintNow(); }, 350); }++ setInterval(function () {++ if (document.hidden) return;++ if (chat.querySelector('[data-ct-run]') || chat.querySelector('[data-ct-now]:not([hidden])')) paintNow();++ }, 5000);++// What Actor sees: the last picture, fetched every two seconds while the turn works (only when it changed).var screens = {};// The live desk (plan L55 C1b): when the turn works on one of the person's own desks, the server asks Bot for a@@@ -507,7 +580,9 @@var framed = turn.querySelector('[data-screen][data-framed="yes"]');if (framed) return;var fig = turn.querySelector('[data-screen]');-- if (!fig) return;++ // Nothing to look at yet: the hidden placeholder is filled by a later turn refresh, and only then is there a++ // picture to fetch (no request every two seconds for a run that never shows a screen).++ if (!fig || fig.hidden) return;var busyLoad = false;screens[id] = setInterval(function () {if (busyLoad || document.hidden) return;@@@ -563,66 +638,6 @@}chat.querySelectorAll('.ovt-turn[data-open]').forEach(function (t) { follow(t); });-- modelChip();-- }---- // ── The model chip (plan L55 C3): what this person's tasks run on. Opening it asks which of their own keys Actor-- // may use (names only, from OpenVibe.AI); a choice saves at once. Escape or a click elsewhere closes it.-- function modelChip() {-- var box = document.querySelector('[data-cc-model]');-- if (!box || box.dataset.wired) return;-- box.dataset.wired = '1';-- var label = box.querySelector('[data-ccm-label]');-- var fine = box.querySelector('[data-ccm-fine]');-- var ready = null;-- function mark() {-- box.querySelectorAll('[data-ccm-provider]').forEach(function (li) {-- var ok = !!(ready && ready.indexOf(li.getAttribute('data-ccm-provider')) !== -1);-- li.classList.toggle('is-ready', ok);-- var setup = li.querySelector('[data-ccm-setup]');-- if (setup) setup.hidden = ok;-- });-- }-- box.addEventListener('toggle', function () {-- if (!box.open) return;-- fetch('/api/v1/me/model', { credentials: 'same-origin', headers: { Accept: 'application/json' } })-- .then(function (r) { return r.ok ? r.json() : null; })-- .then(function (j) { if (j) { ready = Array.isArray(j.keys) ? j.keys : []; mark(); } })-- .catch(function () {});-- });-- box.addEventListener('click', function (e) {-- var b = e.target.closest('[data-ccm-choice]');-- if (!b) return;-- var choice = b.getAttribute('data-ccm-choice');-- b.disabled = true;-- fetch('/api/v1/me/model', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ choice: choice }) })-- .then(function (r) { return r.ok ? r.json() : r.json().then(function (j) { throw new Error((j && j.detail) || 'not saved'); }, function () { throw new Error('not saved'); }); })-- .then(function (j) {-- box.querySelectorAll('[data-ccm-choice]').forEach(function (x) {-- var on = x.getAttribute('data-ccm-choice') === choice;-- x.classList.toggle('is-on', on);-- x.setAttribute('aria-pressed', on ? 'true' : 'false');-- });-- var p = j && j.provider;-- var name = p ? (b.querySelector('b') || {}).textContent : '';-- if (label) label.textContent = p === 'node' ? 'Your computer' : p ? 'Your ' + name + ' key' : 'Auto';-- // Chosen but not allowed yet: say what is left to do instead of letting the next task fail.-- if (p && ready && ready.indexOf(p) === -1 && fine) {-- fine.textContent = '';-- fine.append(p === 'node' ? 'Your next task runs on your computer once you add its model and allow Actor: ' : 'Your next task runs on your ' + name + ' key once you allow Actor to use it: ');-- var a = document.createElement('a');-- a.href = 'https://ai.openvibe.services/you?allow=actor&provider=' + encodeURIComponent(p);-- a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'allow it at OpenVibe.AI';-- fine.append(a, '.');-- } else {-- box.open = false;-- }-- })-- .catch(function (err) { if (fine) fine.textContent = 'That did not save: ' + err.message; })-- .then(function () { b.disabled = false; });-- });-- document.addEventListener('click', function (e) { if (box.open && !box.contains(e.target)) box.open = false; });-- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.open) { box.open = false; box.querySelector('summary').focus(); } });}function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }@@@ -19,7 +19,7 @@const express = require('express');const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');--const { html, raw } = require('../render/html');++const { html, raw, esc } = require('../render/html');const { send } = require('../render/layout');const { markdown } = require('../render/markdown');const store = require('../tasks/store');@@@ -50,8 +50,11 @@ const TRIGGER_ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;const LAST_RUN = { ok: 'went fine', failed: 'failed', needs_you: 'needs you', running: 'is running' };const OPEN = ['queued', 'running', 'verifying'];const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };--const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };const LIVE_TEXT = { queued: 'Starting', running: 'Working', verifying: 'Checking the answer' };++// The Now strip is what is running (a queued task is not yet; it has no steps).++const NOW_STATES = ['running', 'verifying'];++// Composer v2's mode chip: the same modes as the router, in the owner's words (11-12: "Fast/Balanced/Best").++const MODE_CHOICES = [['fastest', 'Fast'], ['balanced', 'Balanced'], ['best', 'Best'], ['cheapest', 'Cheapest'], ['private', 'Private']];const SIDEBAR_ROWS = 400; // the person's newest tasks the sidebar groups into chatsconst CHAIN_MAX = 60; // turns one conversation showsconst EVENTS_MAX = 600; // events per turn the work fold reads@@@ -134,13 +137,13 @@ function createChatPages(ctx, { computers = null } = {}) {* "New runs: Only me", with "Apply to all my existing runs" (design rule 1 and 2). The Share button on a run* changes just that one; this is only what the next one starts as.*/-- async function defaultsHtml(requester) {++ async function defaultsHtml(requester, { from = '' } = {}) {if (!sharing) return '';const current = await sharing.defaultOf(requester).catch(() => 'private');const options = ['private', 'people', 'link', 'public'].map((a) => html`<option value="${a}"${a === current ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);return html`<form class="ct-share-defaults" method="post" action="/chat/defaults">--<label for="ct-share-default">New runs</label><select id="ct-share-default" name="audience">${options}</select>++${from ? html`<input type="hidden" name="from" value="${from}">` : ''}<label for="ct-share-default">New runs</label><select id="ct-share-default" name="audience">${options}</select><label class="ct-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing runs</label><button class="sc-btn" type="submit">Save</button><span class="muted small">Only me unless you choose otherwise. Each run's Share button changes just that run.</span>@@@ -237,6 +240,59 @@ function createChatPages(ctx, { computers = null } = {}) {return raw(thread.work({ open, live: LIVE_TEXT[row.state] || 'Working', took: tookOf(row) || null, rows: workRows(lines) }));}++ /**++ * A turn that is running is a card in the conversation (one-agent system, stage 1: the owner, 11-12, "a more++ * modular system… reused in a shared modular way"), not a separate console panel: its title, its state, the last++ * steps it took and Stop. js/chat.js refreshes the card in place from GET /chat/turn/:id; the finished turn becomes++ * the ordinary work fold and answer.++ */++ function runCard(row, events, readOnly = false) {++ const rows = workRows(taskEvents.lines(events.slice(-EVENTS_MAX), { final: false, state: row.state })).slice(-5);++ const last = [...rows].reverse().find((r) => r.kind === 'tool' || r.kind === 'step' || r.kind === 'note');++ const live = !last ? `${LIVE_TEXT[row.state] || 'Working'}…`++ : last.kind === 'tool' ? `Using ${last.name}${last.input ? `: ${clip(last.input, 90)}` : ''}…`++ : clip(last.text, 120);++ const item = (r) => {++ if (r.kind === 'tool') {++ const st = r.running ? 'is-run' : r.ok === false ? 'is-bad' : 'is-ok';++ return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row"><b>${esc(r.name)}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${r.running ? '<span class="ovt-dot" aria-hidden="true"></span>' : ''}</span></div></li>`;++ }++ if (r.kind === 'you') return `<li class="ovt-w-you"><p>${esc(clip(r.text, 300))}</p></li>`;++ if (r.kind === 'note') return `<li class="ovt-w-note"><p>${esc(clip(r.text, 300))}</p></li>`;++ return `<li class="ovt-w-step"><span>${esc(clip(r.text, 200))}</span></li>`;++ };++ return html`<article class="ovt-card ct-run" data-ct-run="${row.id}">++<p class="ovt-card-h"><span class="ct-run-title">${clip(row.task, 160)}</span><span class="ct-run-state" data-ct-run-state>${LIVE_TEXT[row.state] || 'Working'}…</span></p>++<p class="ct-run-live" data-ovt-live>${live}</p>++<ol class="ct-run-steps" data-ovt-work>${raw(rows.map(item).join(''))}</ol>++${readOnly ? '' : html`<form class="ct-run-stop" method="post" action="/chat/${row.id}/cancel"><button class="ovt-card-btn is-quiet" type="submit">Stop</button></form>`}++</article>`;++ }++++ /**++ * The "Now" strip at the top of the chat (the Console's "Now" card, folded in): one row for each task running for++ * this person, with the last thing it did and Stop. Hidden when nothing runs; js/chat.js refreshes it from++ * GET /chat/now while a turn is open.++ */++ function nowStrip(live) {++ if (!live.length) return html`<section class="ct-now" data-ct-now hidden></section>`;++ const stepOf = (row, event) => {++ if (!event) return '';++ const rows = workRows(taskEvents.lines([event], { final: false, state: row.state }));++ const r = [...rows].reverse().find((x) => x.kind === 'tool' || x.kind === 'step' || x.kind === 'note');++ if (!r) return '';++ return r.kind === 'tool' ? `Using ${r.name}${r.input ? `: ${clip(r.input, 80)}` : ''}` : clip(r.text, 100);++ };++ return html`<section class="ct-now" data-ct-now aria-label="Now">++<ul class="ct-now-list">${live.map(({ row, event }) => html`<li class="ct-now-item">++<span class="ct-now-dot" aria-hidden="true"></span>++<a class="ct-now-task" href="/chat/${row.id}"><b>${clip(row.task, 120)}</b></a>++<span class="ct-now-step">${stepOf(row, event)}</span>++<span class="ct-now-state">${LIVE_TEXT[row.state] || 'Working'}…</span>++<form class="ct-now-stop" method="post" action="/chat/${row.id}/cancel"><button type="submit">Stop</button></form>++</li>`)}</ul></section>`;++ }++function answerBlock(row, t) {const { chipify, modelName, usd } = pageKit();const result = t.result || {};@@@ -475,9 +531,10 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t, readOnly)}`;else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;else {-- // A turn parked on a wait says what it waits for (plan A2); otherwise it is working.++ // A running turn is a card in the conversation (one-agent system, stage 1). A turn parked on a wait still++ // says what it waits for (plan A2).const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;-- reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;++ reply = html`${runCard(row, evs, readOnly)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;}// A routine the turn made is its card, after whatever the turn said (plan §12 A-4).const routines = await triggerBlocks(row, evs, readOnly);@@@ -491,7 +548,7 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;}// ── The page ───────────────────────────────────────────────────────────────────────────────────────────────────-- function sidebar(chats, currentId) {++ function sidebar(chats, currentId, { settings = false } = {}) {return raw(thread.sidebar({newHref: '/chat',groups: thread.group(chats, (c) => c.last, now()).map((g) => ({@@@ -499,8 +556,9 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;items: g.items.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === currentId, live: c.open })),})),findAction: '/chat',-- // Actor's places as the network sidebar's rows, as Codes' are (kit.sidebar, plan L35).-- nav: [{ href: '/console', label: 'Console', icon: 'grid' }, { href: '/me/agents', label: 'Your agents', icon: 'robot' }, { href: '/memory', label: 'Memory', icon: 'book' }],++ // Actor's places as the network sidebar's rows, as Codes' are (kit.sidebar, plan L35). The Console is++ // folded into this chat (one-agent system, stage 1), so Settings takes its row.++ nav: [{ href: '/chat/settings', label: 'Settings', icon: 'sliders', here: settings }, { href: '/me/agents', label: 'Your agents', icon: 'robot' }, { href: '/memory', label: 'Memory', icon: 'book' }],}));}@@@ -549,24 +607,23 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;}const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';-- function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null, allowance = null } = {}) {-- const tools = html`${modelChip(model)}--<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>--<span class="cc-note" data-cc-note></span>`;-- return raw(thread.composer({-- action: working ? `/chat/${working}/steer` : '/chat',-- name: 'task',-- maxlength: 20000,-- label: 'Message Actor',-- placeholder: working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something',-- value: text,-- busy,-- stopAction: working ? `/tasks/${working}/cancel` : '',-- hidden: { parent },-- top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<div data-cc-upload data-accept="${ATTACH_ACCEPT}"></div><p class="cc-note">Text, code, CSV, JSON, PDF or PNG, JPEG, WebP, GIF images · up to 4 files, 6 MB each, 12 MB total.</p>`),-- tools: String(tools),-- note: `Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}`,-- }));++++ /**++ * The composer, on openvibe-shared's composer v2 (4.215.0) with the agent preset: Attach and Speak on the left, the++ * mode chip and the round send on the right, the files above the box with their progress. js/chat.js mounts it; this++ * server-rendered form is what a browser without script gets, and the host it is replaced in.++ *++ * The mode chip is the router's modes (11-12: "the current Fast/Balanced/Best modes"); the model is no longer a++ * second control here, it is in Settings (/chat/model). The allowance is one quiet line under the box.++ */++ function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', allowance = null } = {}) {++ const placeholder = working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something';++ const fallback = html`<form class="ct-composer-fallback" method="post" action="${working ? `/chat/${working}/steer` : '/chat'}">++${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}<textarea name="task" rows="2" maxlength="20000" required placeholder="${placeholder}" aria-label="Message Actor">${text}</textarea>++<div class="ct-composer-fallback-row"><label class="ct-composer-mode">Mode <select name="mode">${MODE_CHOICES.map(([v, label]) => html`<option value="${v}"${v === mode ? raw(' selected') : ''}>${label}</option>`)}</select></label><button type="submit">Send</button></div>++</form>`;++ return raw(`<div class="ct-composer-wrap">${problem ? String(html`<p class="cc-problem" role="alert">${problem}</p>`) : ''}<div class="cc-composer" id="cc-composer" data-cc-composer data-action="${working ? `/chat/${working}/steer` : '/chat'}" data-parent="${parent || ''}" data-mode="${mode}" data-working="${working || ''}" data-max-files="4" data-max-mb="6" data-accept="${ATTACH_ACCEPT}"${text ? ` data-text="${esc(text)}"` : ''}${busy ? ' data-busy="1"' : ''}>${String(fallback)}</div>++<p class="cc-allowance">Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}</p></div>`);}// What people really hand agents like this (the owner, 2026-11-01: "I hate the current examples… search what@@@ -584,16 +641,24 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],];++ /** The tasks running for this person, with their newest event (the "Now" strip's last step). */++ async function liveFor(requester) {++ const rows = await s.db.many('SELECT * FROM tasks WHERE requester = $1 AND state = ANY($2) ORDER BY id DESC LIMIT 20', [requester, NOW_STATES]);++ const events = await Promise.all(rows.map((row) => store.lastEvent(s, row.id).catch(() => null)));++ return rows.map((row, i) => ({ row, event: events[i] }));++ }++/*** A run's page, or the same conversation read-only for somebody it was shared with: no sidebar, no composer and* no turn that asks or retries. The Share button is its owner's; a reader gets Send it only.*/async function render(req, res, { row = null, problem = null, text = '', status = 200, shared = false } = {}) {const requester = requesterOf(req);-- const [chats, turns, today] = await Promise.all([++ const [chats, turns, today, live] = await Promise.all([shared ? Promise.resolve([]) : chatsFor(requester),row ? chainOf(row) : [],shared ? Promise.resolve({ usd: 0 }) : store.spendToday(s, requester),++ shared ? Promise.resolve([]) : liveFor(requester),]);const first = turns[0] || null;const lastTurn = turns[turns.length - 1] || null;@@@ -601,28 +666,27 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;const fileMap = await inputs.list(s, turns.map((t) => t.id));const canvasRow = row && !shared && first ? await canvases.forThread(s, first.id) : null;const canvasDoc = canvasRow ? canvases.docOf(canvasRow) : null;-- const model = shared ? null : modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [], readOnly: shared })));const title = first ? clip(first.task, 60) : 'New chat';-- const actions = row ? String(shareButton(row, !shared)) : '';-- // Our own markup (its values escaped by html``): drawn with raw(), never printed as text (11-08: it was).-- const defaults = !shared && !row ? String(await defaultsHtml(requester)) : '';++ // The run settings are in Settings now (one-agent system, stage 1); the chat only carries the model in its head.++ const actions = `${row ? String(shareButton(row, !shared)) : ''}${shared ? '' : `<a class="ovt-btn" href="/chat/settings" title="Settings" aria-label="Settings">${glyphs.svg('gear', { size: 18 })}</a>`}`;return send(res, status, {viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,-- // The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).++ // The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js); composer++ // v2 draws the box (ov-composer.js, 4.215.0).css: ['css/chat.css', 'css/skill-draft.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css', 'ov-upload.css'],-- sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js', 'ov-canvas.js', 'ov-upload.js'],++ sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-composer.js', 'ov-share.js', 'ov-canvas.js', 'ov-upload.js'],body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),-- head: thread.head({ title, newHref: shared ? null : (first ? '/chat' : null), actions }),++ head: String(thread.head({ title, newHref: shared ? null : (first ? '/chat' : null), actions })) + (shared ? '' : String(nowStrip(live))),turns: turnHtmls.map(String),empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>--<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),-- composer: shared ? '' : String(html`${canvasDoc ? canvasPhone(canvasDoc) : ''}${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })}`),++<ul class="ct-suggest">${STARTERS.slice(0, 4).map(([icon, k, v]) => html`<li><button type="button" class="ct-suggest-chip" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 16 }))}<span>${k}</span></button></li>`)}</ul></div>`),++ composer: shared ? '' : String(html`${canvasDoc ? canvasPhone(canvasDoc) : ''}${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, allowance: leftLine(config.allowance, today) })}`),panel: canvasDoc ? String(canvasWide(canvasDoc)) : '',label: first ? 'Conversation' : 'New chat',-- }))}${raw(defaults)}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,++ }))}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,});}@@@ -637,7 +701,50 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';await sharing.setDefault(requesterOf(req), audience, { applyAll: String(body.apply_all || '') === '1' }).catch((e) => ctx.log.warn('[Actor] the run default was not saved:', e.message));-- return res.redirect(303, '/chat');++ return res.redirect(303, body.from === 'settings' ? '/chat/settings?saved=1' : '/chat');++ });++++ /**++ * Actor's Settings (one-agent system, stage 1): the run defaults moved off the chat ("New runs: Only me … Apply to++ * all my existing runs"), the model and where it runs, and what has been spent. The same route (/chat/defaults)++ * and the same behaviour; it is simply a page of its own now, reached from the chat's head and its sidebar.++ */++ r.get('/chat/settings', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, loginTo(req));++ const requester = requesterOf(req);++ const chats = await chatsFor(requester);++ const current = sharing ? await sharing.defaultOf(requester).catch(() => 'private') : 'private';++ const saved = req.query.saved === '1';++ const body = html`<div class="ct-settings">++<h1>Settings</h1>++<p class="ct-settings-lede">How your runs start and what they run on. Every run's own Share button still changes just that run.</p>++${saved ? html`<p class="cm-ok" role="status">Saved. Your next run starts as ${AUDIENCE_TEXT[current] || 'Only me'}.</p>` : ''}++<section class="ct-settings-card" aria-labelledby="ct-set-runs"><h2 id="ct-set-runs">Your runs</h2>++<p>Who can see the runs you start from now on. "Apply to all my existing runs" moves the ones you already have.</p>++${raw(await defaultsHtml(requester, { from: 'settings' }))}++<p class="ct-settings-foot"><a href="/me/spend">What you have spent</a> · <a href="/tasks">Your tasks</a></p></section>++<section class="ct-settings-card" aria-labelledby="ct-set-model"><h2 id="ct-set-model">The model your tasks run on</h2>++<p>OpenVibe's own keys, your own key for a provider, or a model you run on your own computer. The model and where it runs are one page.</p>++<p><a class="sc-btn" href="/chat/model">Open the model settings</a></p></section>++</div>`;++ return send(res, 200, {++ viewer: req.viewer, config, path: req.originalUrl, title: 'Settings', app: true, noReferrer: true,++ css: ['css/chat.css'], scripts: [], styles: ['kit.css', 'thread.css'],++ body: raw(thread.page({++ sidebar: String(sidebar(chats, null, { settings: true })),++ head: thread.head({ title: 'Settings', newHref: '/chat' }),++ main: String(body),++ label: 'Settings',++ })),++ });++ });++++ // The "Now" strip, refreshed in place by js/chat.js while a turn is open. Its owner only; empty when nothing runs.++ r.get('/chat/now', async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ if (!signedIn(req)) return res.status(401).type('text/html').send('');++ return res.type('text/html').send(String(nowStrip(await liveFor(requesterOf(req)))));});r.get('/chat/model', async (req, res) => {@@@ -764,6 +871,20 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs onreturn render(req, res, { row });});++ // Stop a running turn from its card in the conversation (or the Now strip), and come back to the chat. The task++ // page keeps its own /tasks/:id/cancel; this is the same cancel with the chat as where you were.++ r.post('/chat/:id/cancel', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const wantsJson = String(req.get('accept') || '').includes('application/json');++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A cancel must come from openvibe.actor itself.');++ const row = await store.getTask(s, req.params.id);++ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');++ if (OPEN.includes(row.state)) await engine.cancel(row.id, requesterOf(req));++ if (wantsJson) return res.json({ ok: true });++ return res.redirect(303, `/chat/${row.id}`);++ });++// Words for the turn that is working (the composer while Actor works): the run hears them at its next step.r.post('/chat/:id/steer', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));@@@ -1,63 +1,33 @@'use strict';/**-- * The Console (plan T17 layer 3): one page where a signed-in person sees what their agents, tasks, computers and robots-- * are doing, and tells them what to do next. It is the command interface, not an authority: tasks, agents and approvals-- * are Actor's own; computers and robots are read from OpenVibe.Bot as the person (agents/computers.js, agents/robots.js),-- * and everything else links to the service that owns it (OpenVibe.Services for resources, Watch, Codes).++ * The old Console page, folded into the chat (one-agent system, stage 1: the owner, 11-12, "having this thing on the++ * console doesn't make any sense … you should just chat with it"). /console now answers 301 to the chat: what runs now++ * is the "Now" strip at the top of /chat, and the command box is the chat's composer. Its "Ask Actor" prefill (?ask= and++ * ?from=, put on it by the OpenVibe Frame) is carried into the chat's composer, so a link from any site still lands++ * with the words in the box and nothing running.*-- * It works without JavaScript: the command box is the ordinary task form (POST /tasks), and while a task is running the-- * page refreshes itself every 10 seconds (a <noscript> refresh; public/js/console.js does it with JavaScript, never-- * while the person is typing a task).++ * /me/spend stays here: it is the console's one page that is not a conversation, and it is still linked from the chat's++ * Settings and the home page.*/const cache = require('openvibe-shared/cache-policy');const { asyncRouter } = require('./router');--const { html, raw, time } = require('../render/html');--const kit = require('openvibe-shared/kit');++const { html } = require('../render/html');const { send } = require('../render/layout');const store = require('../tasks/store');--const { leftLine } = require('../tasks/allowance');--const personal = require('../agents/personal');--const approvals = require('../agents/approvals');--const catalog = require('../agents/catalog');--const { MODES } = require('../agents/router');--const { createRobots } = require('../agents/robots');--const { createComputers } = require('../agents/computers');--const feed = require('../agents/feed');----const LIVE = new Set(['queued', 'running', 'verifying']);--const STATE_TEXT = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };--const LEVEL_TEXT = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };--const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };--const BOT_TIMEOUT_MS = 4000;const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);--const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);--const pct = (a, b) => (b > 0 ? Math.max(0, Math.min(100, Math.round((a / b) * 100))) : 0);--function createConsolePages(ctx, { robots = null, computers = null } = {}) {++function createConsolePages(ctx) {const { config, s } = ctx;const r = asyncRouter();-- const bot = { robots: robots || createRobots({ config }), computers: computers || createComputers({ config }) };const tier = config.allowance;const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, styles: ['kit.css'], css: ['css/console.css'], ...o });-- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;-- const pages = config.services.botPublicUrl || 'https://openvibe.bot';-- const linkPages = config.services.linkPublicUrl || 'https://openvibe.link';---- /** One Bot read, bounded so a slow Bot never holds the page; { ok, value } or { ok: false, why }. */-- async function fromBot(fn) {-- try {-- const value = await fn({ signal: AbortSignal.timeout(BOT_TIMEOUT_MS) });-- if (value && !Array.isArray(value) && value.error) return { ok: false, why: value.error };-- return { ok: true, value };-- } catch { return { ok: false, why: 'OpenVibe.Bot did not answer in time.' }; }-- }/**-- * What the command box starts with: ?ask= (the OpenVibe Frame's "Ask Actor", any site: the text the person had-- * selected), at most 2000 characters, and ?from= (the page they were on: an https address on an OpenVibe site)-- * as a line after it. Only ever text in the box: nothing runs until they press Run it.++ * What the old command box started with: ?ask= (the OpenVibe Frame's "Ask Actor", any site: the text the person had++ * selected), at most 2000 characters, and ?from= (the page they were on: an https address on an OpenVibe site) as a++ * line after it. Only ever text in the composer: nothing runs until they send it.*/function prefill(req) {const ask = typeof req.query.ask === 'string' ? req.query.ask.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').trim().slice(0, 2000) : '';@@@ -72,24 +42,12 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {return from ? `${ask}${ask ? '\n\n' : ''}(On this page: ${from})` : ask;}-- function commandBox(start = '', today = null) {-- return html`<form class="cn-command" method="post" action="/tasks" aria-labelledby="cn-command-h">--<h2 id="cn-command-h" class="cn-sr">Give Actor a task</h2>--<label class="cn-sr" for="cn-task">What should Actor do?</label>--${today ? html`<p class="cn-note" data-allowance>${leftLine(tier, today)}</p>` : ''}--<textarea id="cn-task" name="task" rows="${start ? '4' : '2'}" maxlength="20000" required${start ? raw(' autofocus') : ''} placeholder="Tell Actor what to do: “check my site's certificate”, “what changed in my repo today?”, “drive my rover to the door”">${start}</textarea>--<div class="cn-command-row"><label class="cn-mode">Mode <select name="mode">${MODES.map((m) => html`<option value="${m}"${m === 'balanced' ? raw(' selected') : ''}>${MODE_LABEL[m]}</option>`)}</select></label>--<button class="cn-run" type="submit">Run it</button></div>--</form>`;-- }---- const taskRow = (t) => {-- const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;-- const agent = result ? ((catalog.byId(result.agent) || {}).name || result.agent) : null;-- return html`<li class="cn-task"><span class="pill pill-${t.state}">${STATE_TEXT[t.state] || t.state}</span>--<a href="/tasks/${t.id}">${clip(t.task, 110)}</a>--<span class="cn-meta">${agent ? html`${agent} · ` : ''}${t.cost_usd == null ? '' : html`${usd(Number(t.cost_usd))} · `}${time(t.created_at)}</span></li>`;-- };++ // 301, not a page: the Console is gone and every link, bookmark and the Frame's "Ask Actor" land in the chat.++ r.get('/console', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const ask = prefill(req);++ return res.redirect(301, `/chat${ask ? `?ask=${encodeURIComponent(ask)}` : ''}`);++ });/*** Days of spend as bars (a table for screen readers), tallest day = full height. A week labels each day; a month@@@ -104,142 +62,28 @@ ${today ? html`<p class="cn-note" data-allowance>${leftLine(tier, today)}</p>` :: date(d).toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' }).slice(0, 2));const name = (d) => date(d).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });return html`<figure class="cn-week${month ? ' cn-month' : ''}"><figcaption>${caption}: <b>${usd(total)}</b>, ${String(days.reduce((n, d) => n + d.tasks, 0))} tasks</figcaption>--<div class="cn-bars" aria-hidden="true"${month ? raw(` style="grid-template-columns:repeat(${days.length},1fr)"`) : ''}>${days.map((d, i) => html`<span class="cn-bar" title="${name(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d, i)}</small></span>`)}</div>++<div class="cn-bars" aria-hidden="true"${month ? html` style="grid-template-columns:repeat(${days.length},1fr)"` : ''}>${days.map((d, i) => html`<span class="cn-bar" title="${name(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d, i)}</small></span>`)}</div><table class="cn-sr"><caption>Spend by day</caption><tbody>${days.map((d) => html`<tr><th scope="row">${d.day}</th><td>${usd(d.usd)}</td><td>${String(d.tasks)} tasks</td></tr>`)}</tbody></table></figure>`;}-- /** One line of the events feed. */-- const eventRow = (e) => html`<li class="cn-event cn-event-${e.kind}"><span class="cn-ev-dot cn-ev-${e.state}" aria-hidden="true"></span>--<a href="${e.href}">${clip(e.text, 120)}</a>--<span class="cn-meta">${e.by ? html`${e.by} · ` : ''}${e.cost != null ? html`${usd(e.cost)} · ` : ''}${time(e.at)}</span></li>`;---- function computersCard(result) {-- const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`-- : !result.ok ? html`<p class="cn-empty">${result.why}</p>`-- : !result.value.computers.length ? raw(kit.empty({ icon: 'server', title: 'No computer is open to your agent', text: 'Add one on openvibe.link/machines with --computer ask, then turn on “Let my agent use this computer”.', action: { href: `${linkPages}/machines`, label: 'Add a computer', icon: 'plus' } }))-- : html`<ul class="cn-list">${result.value.computers.map((c) => html`<li><span class="cn-dot${c.online ? ' on' : ''}" aria-hidden="true"></span><b>${c.name || c.id}</b>--<span class="cn-meta">${c.online ? 'Online' : 'Offline'} · ${LEVEL_TEXT[c.level] || 'On'}${c.can.length ? html` · ${c.can.join(', ')}` : ''}</span>--${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiting_for_approval)} waiting on the machine</span>` : ''}--${c.online && c.can.includes('screen') ? html`<a class="cn-link" href="${linkPages}/machines/${encodeURIComponent(c.id)}/screen">Watch the screen</a>` : ''}</li>`)}</ul>`;-- return html`<section class="cn-card" aria-labelledby="cn-computers"><h2 id="cn-computers">Your computers</h2>${body}--<p class="cn-more"><a href="${linkPages}/machines">Machines on OpenVibe Link</a></p></section>`;-- }---- function robotsCard(result) {-- const body = !bot.robots.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`-- : !result.ok ? html`<p class="cn-empty">${result.why}</p>`-- : !result.value.length ? raw(kit.empty({ icon: 'robot', title: 'No robot yet', text: 'Try one in your browser, or add your own on openvibe.bot.', action: { href: `${pages}/try`, label: 'Try one in your browser', icon: 'play' } }))-- : html`<ul class="cn-list">${result.value.map((x) => html`<li><span class="cn-dot${x.online ? ' on' : ''}" aria-hidden="true"></span><a href="${x.panel}"><b>${x.name}</b></a>--<span class="cn-meta">${x.agent_control ? (x.online ? 'Online' : 'Offline') : 'Not open to your agent'}${x.estop && x.estop.latched ? ' · e-stop on' : ''}${typeof x.battery === 'number' ? ` · battery ${Math.round(x.battery * 100)}%` : ''}</span></li>`)}</ul>`;-- return html`<section class="cn-card" aria-labelledby="cn-robots"><h2 id="cn-robots">Your robots</h2>${body}--<p class="cn-more"><a href="${pages}/robots">Robots on OpenVibe.Bot</a></p></section>`;-- }---- /**-- * Signed out, the console's own cards say what each will hold, so the page shows what it is instead of a sign-in-- * button over empty space (11-03, the phone review). No numbers and no sample rows: nothing that could read as-- * someone's data. Each card links to the public page there is for it.-- */-- function previewGrid() {-- const cards = [-- ['Now', 'Every task your agents are running: what each has done so far and what it cost. The page refreshes while something runs.', null, true],-- ['Waiting for you', 'What an agent asks before it acts. Approve or decline here, in Link\'s tray, or from a notification.', null, false],-- ['Today', `How many of your ${String(tier.tasksPerDay)} free tasks a day you have used and what they cost, with the last 7 days.`, null, false],-- ['Your agents', 'Tasks you set up once, with a memory and a schedule.', ['/agents', 'See the agents'], false],-- ['Your computers', 'Your own machines running jobs for your agents.', [`${linkPages}/machines`, 'Machines on OpenVibe Link'], false],-- ['Your robots', 'Robots your agents may drive, with their battery and e-stop.', [`${pages}/robots`, 'Robots on OpenVibe.Bot'], false],-- ];-- return html`<div class="cn-grid cn-preview" aria-label="What the console shows">${cards.map(([title, what, link, wide], i) => html`<section class="cn-card${wide ? ' cn-wide' : ''}" aria-labelledby="cn-p${String(i)}"><h2 id="cn-p${String(i)}">${title}</h2>--<p class="cn-empty">${what}</p>${link ? html`<p class="cn-more"><a href="${link[0]}">${link[1]}</a></p>` : ''}</section>`)}</div>`;-- }---- r.get('/console', async (req, res) => {-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- if (!signedIn(req)) {-- return page(req, res, {-- title: 'Console', crumbs: [{ label: 'Console' }],-- body: html`<header class="cn-head"><h1>Console</h1>--<p class="cn-lede">One place for everything your agents do: the tasks running now, what waits for your approval, your agents, your own computers and robots, and what it all cost today.</p></header>--<p><a class="cn-run" href="/auth/login?next=${encodeURIComponent(req.originalUrl.startsWith('/console') && req.originalUrl.length <= 500 ? req.originalUrl : '/console')}">Sign in to open your console</a></p>--${previewGrid()}`,-- });-- }-- const requester = `user:${req.viewer.subject}`;-- const subject = req.viewer.subject;-- const [tasks, today, week, asked, agents, pcs, rovers] = await Promise.all([-- store.listTasks(s, requester, { limit: 30 }),-- store.spendToday(s, requester),-- store.spendDays(s, requester, 7),-- approvals.list(s, requester),-- personal.list(s, requester),-- bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },-- bot.robots.enabled ? fromBot((o) => bot.robots.mine(subject, o)) : { ok: false },-- ]);-- const live = tasks.rows.filter((t) => LIVE.has(t.state));-- const events = await feed.lately(s, requester, { approvals: asked, agents, limit: 8 });-- const waiting = asked.filter((a) => a.state === 'pending');-- const onMachines = pcs.ok ? pcs.value.computers.reduce((n, c) => n + (c.waiting_for_approval || 0), 0) : 0;-- const nWaiting = waiting.length + onMachines;-- page(req, res, {-- title: 'Console', crumbs: [{ label: 'Console' }],-- scripts: live.length ? ['js/console.js'] : [],-- body: html`${live.length ? raw('<noscript><meta http-equiv="refresh" content="10"></noscript>') : ''}<header class="cn-head"${live.length ? raw(' data-live="10"') : ''}><h1>Console</h1>--<p class="cn-lede">What your agents, computers and robots are doing, and one box to tell them what to do next.</p></header>--${commandBox(prefill(req), today)}--<div class="cn-grid">--<section class="cn-card cn-wide" aria-labelledby="cn-now"><h2 id="cn-now">Now${live.length ? html` <span class="cn-count">${String(live.length)}</span>` : ''}</h2>--${live.length ? html`<ul class="cn-tasks">${live.map(taskRow)}</ul><p class="cn-note">This page refreshes every 10 seconds while something runs.</p>` : raw(kit.empty({ icon: 'play', title: 'Nothing is running', text: 'A task you give above appears here while it works.', action: { href: '#cn-task', label: 'Give Actor a task', icon: 'plus' } }))}</section>--<section class="cn-card${nWaiting ? ' cn-attn' : ''}" aria-labelledby="cn-waiting"><h2 id="cn-waiting">Waiting for you${nWaiting ? html` <span class="cn-count">${String(nWaiting)}</span>` : ''}</h2>--${waiting.length ? html`<ul class="cn-list">${waiting.slice(0, 4).map((a) => html`<li><a href="/me/approvals/${a.id}"><b>${clip(a.summary, 80)}</b></a><span class="cn-meta">Asked ${time(a.created_at)}</span></li>`)}</ul>` : ''}--${onMachines ? html`<p class="cn-note">${String(onMachines)} on your computers: answer them there, in the Link's tray, with a notification's Approve button, or with <code>openvibe-link approvals</code>.</p>` : ''}--${nWaiting ? '' : raw(kit.empty({ icon: 'check', title: 'Nothing waits for you', text: 'An agent that needs your answer asks here first.' }))}--<p class="cn-more"><a href="/me/approvals">All approvals</a></p></section>--<section class="cn-card" aria-labelledby="cn-today"><h2 id="cn-today">Today</h2>--<p class="cn-stat"><b>${String(today.tasks)}</b> of ${String(tier.tasksPerDay)} free tasks</p>--<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>--<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)} · ${leftLine(tier, today)}</p>--<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>--${spendChart(week, { caption: 'Last 7 days' })}--<p class="cn-more"><a href="/me/spend">Last 30 days</a> · <a href="/tasks">Your tasks</a></p></section>--<section class="cn-card cn-wide" aria-labelledby="cn-lately"><h2 id="cn-lately">Lately</h2>--${events.length ? html`<ul class="cn-events">${events.map(eventRow)}</ul>` : raw(kit.empty({ icon: 'list', title: 'Nothing has happened yet', text: 'Your tasks and approvals appear here as they happen.', action: { href: '#cn-task', label: 'Give Actor a task', icon: 'plus' } }))}--<p class="cn-more"><a href="/tasks">All your tasks</a> · <a href="/me/approvals">All approvals</a></p></section>--<section class="cn-card" aria-labelledby="cn-agents"><h2 id="cn-agents">Your agents</h2>--${agents.length ? html`<ul class="cn-list">${agents.slice(0, 5).map((a) => html`<li><span class="cn-dot${a.status === 'paused' ? '' : ' on'}" aria-hidden="true"></span><a href="/me/agents/${a.id}"><b>${a.name}</b></a>--<span class="cn-meta">${a.status === 'paused' ? 'Paused' : a.schedule === 'off' ? 'Runs when you run it' : `Every ${a.schedule === 'hourly' ? 'hour' : a.schedule === 'daily' ? 'day' : 'week'}`}${a.last_run_at ? html` · last ran ${time(a.last_run_at)}` : ''}</span></li>`)}</ul>`-- : raw(kit.empty({ icon: 'robot', title: 'No agent yet', text: 'An agent is a task you set up once, with a memory and a schedule.', action: { href: '/me/agents', label: 'Create an agent', icon: 'plus' } }))}--<p class="cn-more"><a href="/me/agents">${agents.length ? 'All your agents' : 'Create an agent'}</a></p></section>--${computersCard(pcs)}--${robotsCard(rovers)}--<section class="cn-card cn-full" aria-labelledby="cn-elsewhere"><h2 id="cn-elsewhere">Across OpenVibe</h2>--<ul class="cn-links">--<li><a href="https://openvibe.services/projects">Your projects and resources</a><span class="cn-meta">OpenVibe.Services: stop, delete, suspend or resume what your projects hold</span></li>--<li><a href="https://openvibe.services/projects/mine">What you own yourself</a><span class="cn-meta">OpenVibe.Services: stop or resume a robot, archive a chat room, suspend a stream, delete a paste</span></li>--<li><a href="https://openvibe.watch">Your watches</a><span class="cn-meta">OpenVibe.Watch: wake an agent when something changes</span></li>--<li><a href="https://openvibe.codes/run">Coding on your machine</a><span class="cn-meta">OpenVibe.Codes: tasks on your own computer</span></li>--</ul></section>--</div>`,-- });-- });--// ── Costs over time: the last 30 days (what Actor keeps) ──r.get('/me/spend', async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));-- if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Fme%2Fspend');++ if (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(302, '/auth/login?next=%2Fme%2Fspend');const requester = `user:${req.viewer.subject}`;const days = await store.spendDays(s, requester, store.RETENTION_DAYS);const used = days.filter((d) => d.tasks > 0 || d.usd > 0);const total = days.reduce((n, d) => n + d.usd, 0);const busiest = used.reduce((b, d) => (!b || d.usd > b.usd ? d : b), null);page(req, res, {-- title: 'Spend', crumbs: [{ label: 'Console', href: '/console' }, { label: 'Spend' }],++ title: 'Spend', crumbs: [{ label: 'Chat', href: '/chat' }, { label: 'Spend' }],body: html`<header class="cn-head"><h1>Spend</h1><p class="cn-lede">What your tasks and agents cost, day by day, over the last ${String(store.RETENTION_DAYS)} days (Actor keeps no more).</p></header><section class="cn-card" aria-labelledby="cn-spend-h"><h2 id="cn-spend-h">Last ${String(store.RETENTION_DAYS)} days</h2>--${spendChart(days, { caption: `Last ${store.RETENTION_DAYS} days`, month: true })}++${spendChart(days, { caption: `Last ${String(store.RETENTION_DAYS)} days`, month: true })}<p class="cn-stat">${used.length ? html`<b>${usd(total / used.length)}</b> on an average day with tasks · busiest ${busiest.day}, <b>${usd(busiest.usd)}</b>` : 'Nothing has run in this time.'}</p><p class="cn-note">Today's limit is ${usd(tier.perDayUsd)} and ${String(tier.tasksPerDay)} tasks on the free tier.</p>--<p class="cn-more"><a href="/console">Back to the console</a> · <a href="/tasks">Your tasks</a></p></section>`,++<p class="cn-more"><a href="/chat">Back to the chat</a> · <a href="/tasks">Your tasks</a></p></section>`,});});@@@ -198,7 +198,7 @@ ${diaries.length ? html`<section class="mem-diaries" aria-labelledby="h-diary"><<ul class="mem-list">${diaries.map((d) => diaryEntry(d, nameOf(d.agent_id), '/memory'))}</ul></section>` : ''}${changes.length ? html`<section class="mem-changes" aria-labelledby="h-changes"><h2 id="h-changes">What the last nights changed</h2><ul class="mem-list">${changes.map((c) => changeRow(c, nameOf(c.agent_id)))}</ul></section>` : ''}--<p class="mem-foot"><a href="/me/agents">Your agents</a> · <a href="/tasks">Your tasks</a> · <a href="/console">Console</a></p>`);++<p class="mem-foot"><a href="/me/agents">Your agents</a> · <a href="/tasks">Your tasks</a> · <a href="/chat/settings">Settings</a></p>`);});r.post('/memory', async (req, res) => {@@@ -471,7 +471,7 @@ ${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</peyebrow: 'OpenVibe.Actor · alpha',title: 'Your own AI.', accent: 'It works for you while you watch.',lede: 'Tell it what you want: find you work, earn from a gig, grow your channel, brief you every morning, build something. It works on a screen you can watch from any OpenVibe page, uses your own accounts, asks before it spends, posts or applies, and runs on the model you pick.',-- actions: signed ? [{ label: 'Give it a job', href: '#task', primary: true }, { label: 'Your Actor', href: '/console' }] : [{ label: 'Start your Actor', href: '/auth/login?next=%2F', primary: true }, { label: 'What it can do', href: '#templates' }],++ actions: signed ? [{ label: 'Give it a job', href: '#task', primary: true }, { label: 'Your Actor', href: '/chat' }] : [{ label: 'Start your Actor', href: '/auth/login?next=%2F', primary: true }, { label: 'What it can do', href: '#templates' }],note: `Open source (AGPL-3.0). Free: ${tier.tasksPerDay} tasks a day and ${usd(tier.perDayUsd)} of model use, no card.`,aside: { html: String(nowCard()) },});@@@ -504,7 +504,7 @@ ${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</p[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],[html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.link/machines">openvibe.link/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Link with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.link/machines">openvibe.link/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],-- [html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],++ [html`<a href="/chat">The chat</a>: what runs now in a "Now" strip, your conversations, your agents, and one composer to tell them what to do next`, 'Live, for signed-in people.'],[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],[html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],];@@@ -26,9 +26,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.const NAV = [{ label: 'Chat', href: '/chat', icon: 'fa-comments' },-- { label: 'Console', href: '/console', icon: 'fa-gauge-high' },{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' },{ label: 'Agents', href: '/agents', icon: 'fa-robot' },++ { label: 'Settings', href: '/chat/settings', icon: 'fa-gear' },{ label: 'API', href: '/docs', icon: 'fa-code', more: true },];@@@ -61,7 +61,7 @@ function renderPage(o) {service: 'actor',apiBase: NETWORK_URL,links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Console', href: '/console', icon: 'fa-gauge-high' }, { label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20): Home, Chat, Agentshistory: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -72,7 +72,7 @@ function renderPage(o) {};// This site's own account links live in the shared navbar's account menu (the page's account// bar below is only for visitors without JavaScript).-- if (signedIn) nav.menu = { before: [{ label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }] };++ if (signedIn) nav.menu = { before: [{ label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }, { label: 'Settings', href: '/chat/settings', icon: 'fa-gear' }] };const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };const account = signedIn? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`@@@ -314,10 +314,16 @@ const SAMPLE = {assert.strictEqual(decision.ask, true, 'the floor asks whatever the rules say');});-- await check('the decision is in the person\'s feed too', async () => {-- const feed = await t.get('/console', { as: ada });-- assert.strictEqual(feed.status, 200);-- assert.match(feed.text, /You approved/, 'the person sees what they approved');++ await check('the decision is on the person\'s approvals page too (the Console is folded into the chat)', async () => {++ const rows = await approvals.list(t.ctx.s, owner);++ const approved = rows.find((r) => r.state === 'approved');++ assert.ok(approved, 'an approved request is kept for the person to read');++ const card = await t.get(`/me/approvals/${approved.id}`, { as: ada });++ assert.strictEqual(card.status, 200);++ assert.match(card.text, /You approved/, 'the person sees what they approved');++ const inbox = await t.get('/me/approvals', { as: ada });++ assert.strictEqual(inbox.status, 200);++ assert.match(inbox.text, /Approved/);});} finally {await t.close();@@@ -15,31 +15,29 @@ const SAME = { 'sec-fetch-site': 'same-origin' };const kim = t.network.addUser('kim');const bo = t.network.addUser('bo');try {-- await check('the chip says Auto by default, and its menu offers OpenVibe\'s and each provider\'s own key', async () => {++ await check('the model control is in Settings now, not a chip under the composer', async () => {const page = await t.get('/chat', { as: kim });assert.strictEqual(page.status, 200);-- assert.match(page.text, /<details class="cc-model" data-cc-model>/);-- assert.match(page.text, /<span data-ccm-label>Auto<\/span>/);-- assert.match(page.text, /data-ccm-choice="auto" aria-pressed="true"/);++ assert.ok(!/data-cc-model/.test(page.text), 'no model chip on the chat');++ const settings = await t.get('/chat/settings', { as: kim });++ assert.match(settings.text, /href="\/chat\/model"/, 'Settings links the model form');++ const model = await t.get('/chat/model', { as: kim });++ assert.match(model.text, /<input type="radio" name="choice" value="auto" checked>/);['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'].forEach((p) => {-- assert.ok(page.text.includes(`data-ccm-choice="own:${p}" aria-pressed="false"`), p);-- assert.ok(page.text.includes(`https://ai.openvibe.services/you?allow=actor&provider=${p}`), `${p}: set up at OpenVibe.AI`);++ assert.ok(model.text.includes(`value="own:${p}"`), p);++ assert.ok(model.text.includes(`https://ai.openvibe.services/you?allow=actor&provider=${p}`), `${p}: set up at OpenVibe.AI`);});-- assert.ok(page.text.includes('href="/chat/model"'), 'the plain form, for no script');-- // Their own computer (L55 C3): its own group, set up and allowed at OpenVibe.AI.-- assert.ok(page.text.includes('<p class="ccm-head">Your computer</p>'));-- assert.ok(page.text.includes('data-ccm-choice="own:link" aria-pressed="false"'));-- assert.ok(page.text.includes('https://ai.openvibe.services/you?allow=actor&provider=link'));++ assert.ok(model.text.includes('value="own:link"'), 'their own computer');++ assert.ok(model.text.includes('https://ai.openvibe.services/you?allow=actor&provider=link'));});-- await check('a tap saves through the API from the page; the chip says the person\'s own key after', async () => {++ await check('the API saves the choice; the model form shows it, and another person keeps Auto', async () => {const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });assert.strictEqual(put.status, 200, put.text);assert.strictEqual(put.json().provider, 'openai');-- const page = await t.get('/chat', { as: kim });-- assert.match(page.text, /<span data-ccm-label>Your OpenAI key<\/span>/);-- assert.match(page.text, /data-ccm-choice="own:openai" aria-pressed="true"/);-- assert.match((await t.get('/chat', { as: bo })).text, /<span data-ccm-label>Auto<\/span>/, 'another person\'s choice is theirs');++ const page = await t.get('/chat/model', { as: kim });++ assert.match(page.text, /<input type="radio" name="choice" value="own:openai" checked>/);++ assert.match((await t.get('/chat/model', { as: bo })).text, /<input type="radio" name="choice" value="auto" checked>/, 'another person\'s choice is theirs');assert.strictEqual((await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:nope' }, headers: SAME })).status, 422);});@@@ -62,15 +60,14 @@ const SAME = { 'sec-fetch-site': 'same-origin' };const pcPage = await t.get('/chat/model?saved=1', { as: kim });assert.match(pcPage.text, /Saved\. Your next task runs on Your computer\./);assert.match(pcPage.text, /<input type="radio" name="choice" value="own:link" checked>/);-- assert.match((await t.get('/chat', { as: kim })).text, /<span data-ccm-label>Your computer<\/span>/);++ assert.match((await t.get('/chat/model?saved=1', { as: kim })).text, /<input type="radio" name="choice" value="own:link" checked>/);});-- await check('the script: the menu asks which keys are ready, saves in one request, closes on Escape', async () => {++ await check('the chat no longer mounts a model control; the model page keeps the API', async () => {const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');-- assert.match(js, /fetch\('\/api\/v1\/me\/model', \{ credentials: 'same-origin'/);-- assert.match(js, /fetch\('\/api\/v1\/me\/model', \{ method: 'PUT', credentials: 'same-origin'/);-- assert.match(js, /e\.key === 'Escape' && box\.open/);-- assert.match(js, /allow it at OpenVibe\.AI/, 'chosen but not allowed: it says what is left to do');++ assert.ok(!/data-cc-model|modelChip/.test(js), 'no model chip in the chat script');++ assert.match(js, /data-cc-composer/, 'the chat script mounts composer v2');++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).status, 200, 'the model API is still there');});} finally {await done(t);@@@ -49,22 +49,26 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;assert.match(r.headers.get('location'), /^\/auth\/login\?next=%2Fchat/);});-- await check('a new chat: one question, eight starters from what people use agents for, the composer, an empty list', async () => {++ await check('a new chat: one question, four compact suggestion chips, the composer v2 host, an empty list', async () => {const r = await t.get('/chat', { as: kim });assert.strictEqual(r.status, 200);assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);-- // The new runs' share default is a form on the page, never its markup printed as text (11-08, the owner's screenshot).-- assert.match(r.text, /<form class="ct-share-defaults" method="post" action="\/chat\/defaults">/);-- assert.ok(!/<form class="ct-share-defaults/.test(r.text), 'the share-defaults form is not escaped into text');-- assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 8, 'the starters fill the kit\'s composer');-- ['Find a flight', 'Is this true?', 'Best deal', 'Watch for it', 'Plan a trip', 'Make a list'].forEach((k) => assert.ok(r.text.includes(`<b>${k}</b>`), k));++ // The run defaults moved to Settings (one-agent system, stage 1): nothing of them under the composer.++ assert.ok(!/ct-share-defaults/.test(r.text), 'the run settings are not on the chat');++ assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 4, 'four suggestion chips, not the card grid');++ assert.strictEqual((r.text.match(/class="ct-suggest-chip"/g) || []).length, 4, 'one line each');++ ['Find a flight', 'Is this true?', 'Best deal', 'Watch for it'].forEach((k) => assert.ok(r.text.includes(`<span>${k}</span>`), k));++ assert.ok(!/class="ct-starters"/.test(r.text), 'not the wall of starter cards');assert.ok(!/Who handles the mail/.test(r.text), 'not the old DNS example (the owner: "people aren\'t gonna use it for that")');-- assert.match(r.text, /<form class="ovt-composer" method="post" action="\/chat" data-ovt-composer>/);++ assert.match(r.text, /<div class="cc-composer" id="cc-composer" data-cc-composer data-action="\/chat"/);++ assert.match(r.text, /<form class="ct-composer-fallback" method="post" action="\/chat">/, 'a browser without script still sends');++ assert.match(r.text, /<script src="\/shared\/ov-composer\.js(\?v=[0-9a-f]+)?" defer><\/script>/, 'composer v2 (4.215.0) is loaded');assert.match(r.text, /<link rel="stylesheet" href="\/shared\/thread\.css(\?v=[0-9a-f]+)?">/, 'the network\'s conversation UI (L55 C4)');assert.match(r.text, /<script src="\/shared\/ov-thread\.js(\?v=[0-9a-f]+)?" defer><\/script>/);-- assert.match(r.text, /\/shared\/ov-upload\.css/);-- assert.match(r.text, /\/shared\/ov-upload\.js/);-- assert.match(r.text, /data-cc-upload data-accept=/);++ assert.ok(!/data-cc-upload data-accept=/.test(r.text), 'the dashed "Attach files" box is gone (the composer attaches)');++ assert.ok(!/data-cc-model/.test(r.text) && !/data-cc-mode/.test(r.text), 'no separate model or mode control');++ assert.match(r.text, /<p class="cc-allowance">Actor checks each answer with a second model\./, 'one quiet allowance line under the composer');++ assert.match(r.text, /href="\/chat\/settings" title="Settings"/, 'Settings is in the chat head');assert.match(r.text, /Your chats show here\./);assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');@@@ -86,7 +90,8 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;assert.match(page.text, /<div class="ovt-meta"><span>Balanced/);assert.match(page.text, new RegExp(`<input type="hidden" name="parent" value="${first}">`), 'the composer replies to the last turn');assert.match(page.text, new RegExp(`<a class="ovk-sidebar-item is-here" href="/chat/${first}" aria-current="page"`), 'the chat is in the list (the network\'s sidebar), marked as the one open');-- assert.match(page.text, /<nav class="ovk-sidebar-nav"[^>]*>[\s\S]*Console[\s\S]*Your agents[\s\S]*Memory/, 'Actor\'s places as rows, as Codes\' are');++ assert.match(page.text, /<nav class="ovk-sidebar-nav"[^>]*>[\s\S]*Settings[\s\S]*Your agents[\s\S]*Memory/, 'Actor\'s places as rows, as Codes\' are; the Console is gone');++ assert.ok(!/href="\/console"/.test(page.text), 'no Console row or link');});await check('a reply continues the chat: both turns, in order, one chat in the list, with the turn before in mind', async () => {@@@ -248,9 +253,12 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);const page = await t.get(`/chat/${last}`, { as: kim });await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);-- assert.match(page.text, new RegExp(`<form class="ovt-composer is-busy" method="post" action="/chat/${last}/steer" data-ovt-composer>`));++ assert.match(page.text, new RegExp(`<div class="cc-composer" id="cc-composer" data-cc-composer data-action="/chat/${last}/steer"`), 'the mounted composer steers the open turn');++ assert.match(page.text, new RegExp(`data-working="${last}"`), 'the composer knows which turn is open');assert.match(page.text, /placeholder="Tell Actor something while it works"/);-- assert.match(page.text, new RegExp(`<button type="submit" class="ovt-stop" formaction="/tasks/${last}/cancel" formnovalidate data-ovt-stop>`));++ assert.match(page.text, new RegExp(`<article class="ovt-card ct-run" data-ct-run="${last}">`), 'the running task is a card in the conversation');++ assert.match(page.text, /data-ct-run-state>Working…</);++ assert.match(page.text, new RegExp(`<form class="ct-run-stop" method="post" action="/chat/${last}/cancel"><button class="ovt-card-btn is-quiet" type="submit">Stop</button></form>`), 'Stop is on the card');assert.match(page.text, /<figure class="ct-screen" data-screen hidden><\/figure>/, 'no picture yet: the screen is there, hidden, for the script to fill');});@@@ -1,163 +1,66 @@'use strict';/**-- * The Console (server/http/console-pages.js, plan T17 layer 3): signed out it explains itself; signed in it shows the-- * person's running and recent tasks, what waits for them (their approvals, and approvals waiting on their own-- * computers), today's spend, their agents, their computers and robots read from OpenVibe.Bot as them, and links to the-- * services that own the rest. A slow or failing Bot never breaks the page. Only the person's own rows appear.++ * The Console folded into the chat (one-agent system, stage 1: the owner, 11-12, "having this thing on the console++ * doesn't make any sense … you should just chat with it"). /console answers 301 to /chat, carrying the Frame's "Ask++ * Actor" prefill (?ask= and ?from=) into the composer; its one non-conversation page, /me/spend, stays. The APIs and++ * pages the console linked to (approvals, your agents, computers and robots) are kept.*/const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const store = require('../server/tasks/store');--const personal = require('../server/agents/personal');--const approvals = require('../server/agents/approvals');(async () => {-- const asked = [];-- let botMode = 'ok';-- const bot = {-- computers: {-- enabled: true,-- mine: async (subject) => {-- asked.push(['computers', subject]);-- if (botMode === 'fail') return { error: 'Bot answered 503 for your computers' };-- return { computers: [{ id: 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'desk-pc', online: true, level: 'ask', can: ['terminal', 'files', 'screen'], folders: ['/home/ada'], waiting_for_approval: 2 },-- { id: 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZA', name: 'old-laptop', online: false, level: 'observe', can: ['files', 'screen'], folders: [] }] };-- },-- },-- robots: {-- enabled: true,-- mine: async (subject, { signal }) => {-- asked.push(['robots', subject]);-- if (botMode === 'fail') return await new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted'))));-- return [{ id: 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'Desk rover', agent_control: true, online: true, estop: { latched: false }, battery: 0.8, panel: 'https://openvibe.bot/panel/rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9' }];-- },-- },-- };-- const t = await boot({ bot });++ const t = await boot();try {const ada = t.network.addUser('ada');const bo = t.network.addUser('bo');const owner = `user:${ada.subject}`;-- const task = async (who, text, state) => {-- const id = t.ctx.s.newId('tsk');-- await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: t.ctx.s.iso() });-- if (state !== 'queued') await store.updateTask(t.ctx.s, id, { state, ...(state === 'succeeded' ? { cost_usd: 0.0012, finished_at: t.ctx.s.iso() } : {}) });-- return id;-- };-- await check('signed out, the console says what it is and how to open it', async () => {-- const r = await t.get('/console');-- assert.strictEqual(r.status, 200);-- assert.match(r.text, /Sign in to open your console/);-- // What each card will hold, instead of empty space under the button; no numbers or rows of anyone's.-- for (const h of ['Now', 'Waiting for you', 'Today', 'Your agents', 'Your computers', 'Your robots']) assert.ok(r.text.includes(`>${h}</h2>`), h);-- assert.match(r.text, /class="cn-grid cn-preview" aria-label="What the console shows"/);-- assert.ok(!/cn-count|cn-meter|cn-tasks/.test(r.text), 'no counts, meters or task rows signed out');-- assert.ok(!asked.length, 'nothing asked of Bot for nobody');-- });---- await check('signed in: running and recent tasks, what waits, today, agents, computers, robots and the rest', async () => {-- const running = await task(owner, 'Check the certificate of example.com', 'running');-- await task(owner, 'Summarise the Node.js release notes', 'succeeded');-- await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');-- await store.addSpend(t.ctx.s, owner, 0.0012);-- await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });-- await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });-- const r = await t.get('/console', { as: ada });-- assert.strictEqual(r.status, 200);-- const h = r.text;-- assert.match(h, /<h1>Console<\/h1>/);-- assert.match(h, /<form class="cn-command" method="post" action="\/tasks"/);-- assert.ok(h.includes(`href="/tasks/${running}"`) && h.includes('Check the certificate of example.com'));-- assert.match(h, /data-live="10"/, 'refreshes while something runs');-- assert.match(h, /Summarise the Node\.js release notes/);-- assert.ok(!h.includes('Bo's private task') && !h.includes("Bo's private task"), 'only the person\'s own tasks');-- assert.match(h, /Fix the failing build in ada\/site/);-- assert.match(h, /2 on your computers/);-- assert.match(h, /cn-count">3</, 'one approval here and two on the machine');-- assert.match(h, /Morning briefing/);-- assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files, screen/);-- assert.match(h, /href="https:\/\/[^"]+\/machines\/dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9\/screen">Watch the screen</, 'an online computer with a screen links its live screen on Bot');-- assert.ok(!h.includes('dev_01M4W5JBFDG2TJ8C4FRDMCWBZA/screen'), 'never for one that is offline');-- assert.match(h, /Desk rover[\s\S]*battery 80%/);-- assert.match(h, /openvibe\.services\/projects/);-- assert.match(h, /Last 7 days: <b>\$0\.0012<\/b>, 2 tasks/, 'the week: what the two tasks spent today');-- assert.strictEqual((h.match(/class="cn-bar"/g) || []).length, 7);-- assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');-- });---- await check('a failing or slow Bot leaves the rest of the page working', async () => {-- botMode = 'fail';-- const started = Date.now();-- const r = await t.get('/console', { as: ada });-- assert.strictEqual(r.status, 200);-- assert.match(r.text, /Bot answered 503 for your computers/);-- assert.match(r.text, /OpenVibe\.Bot did not answer in time/);-- assert.match(r.text, /Morning briefing/);-- assert.ok(Date.now() - started < 8000, 'bounded by the Bot timeout');-- botMode = 'ok';++ await check('/console answers 301 to the chat, signed in or not, and keeps no page of its own', async () => {++ const out = await t.get('/console');++ assert.strictEqual(out.status, 301, 'a permanent redirect');++ assert.strictEqual(out.headers.get('location'), '/chat');++ assert.ok(!/<h1>Console<\/h1>/.test(out.text), 'no console markup');++ const mine = await t.get('/console', { as: ada });++ assert.strictEqual(mine.status, 301);++ assert.strictEqual(mine.headers.get('location'), '/chat');});-- await check('Lately: finished tasks with the agent that ran them, approvals asked and answered, newest first', async () => {-- const dee = t.network.addUser('dee');-- const who = `user:${dee.subject}`;-- const agent = await personal.create(t.ctx.s, t.config, who, { name: 'Price watcher', instructions: 'Check prices.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'off', status: 'active' });-- const at = (min) => new Date(Date.now() - min * 60_000).toISOString();-- const ins = async (text, state, finished, via = null) => {-- const id = t.ctx.s.newId('tsk');-- await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: at(finished + 1) });-- await store.updateTask(t.ctx.s, id, { state, finished_at: at(finished), cost_usd: 0.003, ...(via ? { via } : {}) });-- };-- await ins('Old check', 'failed', 30);-- await ins('Compare the three laptop prices', 'succeeded', 5, `agent:${agent.row.id}`);-- const asked = await approvals.request(t.ctx.s, { owner: who, kind: 'codes.task', summary: 'Open a pull request on dee/site', payload: { task: 'pr' } });-- await t.ctx.s.db.query("UPDATE actor_approvals SET state = 'declined', decided_at = $2, created_at = $3 WHERE id = $1", [asked.id || asked.approval.id, at(1), at(10)]);-- const r = await t.get('/console', { as: dee });-- const h = r.text;-- assert.match(h, /<h2 id="cn-lately">Lately<\/h2>/);-- const order = ['You declined: Open a pull request on dee/site', 'Done: Compare the three laptop prices', 'Asked for your approval: Open a pull request', 'Didn't finish: Old check'].map((x) => h.indexOf(x));-- assert.ok(order.every((i) => i > 0), JSON.stringify(order));-- assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'newest first');-- assert.match(h, /Price watcher · \$0\.0030/, 'the agent that ran it, and what it cost');-- assert.ok(!h.includes('Check the certificate of example.com'), 'only this person\'s');++ await check('?ask= and ?from= go to the chat composer, escaped, and nothing runs; another site\'s address is dropped', async () => {++ const before = (await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n;++ const q = `?ask=${encodeURIComponent('Summarise <this> & explain')}&from=${encodeURIComponent('https://openvibe.wiki/s/openvibe/actor')}`;++ const r = await t.get(`/console${q}`, { as: ada });++ assert.strictEqual(r.status, 301);++ assert.strictEqual(r.headers.get('location'), `/chat?ask=${encodeURIComponent('Summarise <this> & explain\n\n(On this page: https://openvibe.wiki/s/openvibe/actor)')}`);++ const chat = await t.get(r.headers.get('location'), { as: ada });++ assert.strictEqual(chat.status, 200);++ assert.ok(chat.text.includes('>Summarise <this> & explain\n\n(On this page: https://openvibe.wiki/s/openvibe/actor)</textarea>'), 'escaped, in the fallback box and data-text');++ const foreign = await t.get(`/console?ask=hi&from=${encodeURIComponent('https://evil.example/x')}`);++ assert.strictEqual(foreign.headers.get('location'), '/chat?ask=hi', 'another site\'s address is not carried');++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n, before, 'nothing ran');});-- await check('/me/spend: the last 30 days as bars and a table, for the signed-in person only', async () => {++ await check('/me/spend: the last 30 days as bars and a table, signed in only; it is linked from Settings', async () => {++ await store.addSpend(t.ctx.s, owner, 0.0012, { tasks: 2 });const r = await t.get('/me/spend', { as: ada });assert.strictEqual(r.status, 200);assert.match(r.text, /<h1>Spend<\/h1>/);assert.match(r.text, /Last 30 days: <b>\$0\.0012<\/b>, 2 tasks/);assert.strictEqual((r.text.match(/class="cn-bar"/g) || []).length, 30);assert.strictEqual((await t.get('/me/spend')).status, 302, 'signed out: sign in first');-- assert.match((await t.get('/console', { as: ada })).text, /href="\/me\/spend"/, 'the console links it');++ assert.match((await t.get('/chat/settings', { as: ada })).text, /href="\/me\/spend"/, 'Settings links it');});-- await check('?ask= and ?from= fill the command box (the Frame\'s "Ask Actor"); nothing runs; other sites\' addresses are dropped', async () => {-- const before = (await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n;-- const q = `?ask=${encodeURIComponent('Summarise <this> & explain')}&from=${encodeURIComponent('https://openvibe.wiki/s/openvibe/actor')}`;-- const r = await t.get(`/console${q}`, { as: ada });-- assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('autofocus placeholder=') || r.text.includes(' autofocus '), 'the box takes the focus');-- assert.ok(r.text.includes('>Summarise <this> & explain\n\n(On this page: https://openvibe.wiki/s/openvibe/actor)</textarea>'), 'escaped, with the page it came from');-- const foreign = await t.get(`/console?ask=hi&from=${encodeURIComponent('https://evil.example/x')}`, { as: ada });-- assert.ok(foreign.text.includes('>hi</textarea>'), 'another site\'s address is not carried');-- assert.ok((await t.get('/console', { as: ada })).text.includes('"></textarea>'), 'without ?ask= the box is empty');-- assert.strictEqual((await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n, before, 'nothing ran');-- const out = await t.get(`/console?ask=${encodeURIComponent('hello there')}`);-- assert.ok(out.text.includes(`/auth/login?next=${encodeURIComponent('/console?ask=hello%20there')}`), 'signed out: sign-in comes back to the filled box');-- });---- await check('nothing running: no refresh; nothing waiting says so', async () => {-- const r = await t.get('/console', { as: bo });-- assert.strictEqual(r.status, 200);-- assert.match(r.text, /data-live="10"/, 'Bo has a running task');-- const cy = t.network.addUser('cy');-- const quiet = await t.get('/console', { as: cy });-- assert.doesNotMatch(quiet.text, /data-live=/);-- assert.match(quiet.text, /Nothing is running/);-- assert.match(quiet.text, /No agent yet/);-- assert.match(quiet.text, /class="ovk-empty"/, 'the empty console lists draw the kit empty state');-- assert.match(quiet.text, /class="ovk-btn[^"]*" href="#cn-task"/, 'the empty state points at the task box');++ await check('the pages and modules the console used are still here (approvals, agents, computers, robots)', async () => {++ assert.strictEqual(typeof require('../server/http/console-pages').createConsolePages, 'function');++ assert.strictEqual(typeof require('../server/http/memory-pages').createMemoryPages, 'function');++ for (const mod of ['../server/agents/computers', '../server/agents/robots', '../server/agents/approvals', '../server/agents/personal']) {++ assert.ok(Object.keys(require(mod)).length, `${mod} still exports its API`);++ }++ assert.strictEqual((await t.get('/me/approvals', { as: ada })).status, 200);++ assert.strictEqual((await t.get('/me/agents', { as: ada })).status, 200);++ assert.match((await t.get('/memory', { as: ada })).text, /href="\/chat\/settings"/, 'the old Console link is Settings');++ assert.ok(!(await t.get('/chat', { as: bo })).text.includes('ada'), 'the Now strip is the person\'s own');});} finally { await t.close(); }done();@@@ -38,11 +38,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };await store.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: who, task: 'open', mode: 'balanced', budget_task: 0.02, budget_day: 0.05, created_at: t.ctx.s.iso() });today = await store.spendToday(t.ctx.s, who);assert.strictEqual(today.reserved, 0.02);-- // The console is where a person starts a run: $0.05 - $0.01 spent - $0.02 held = $0.02 left.-- assert.strictEqual(typeof require('../server/http/console-pages').createConsolePages, 'function');-- const consolePage = await t.get('/console', { as: u });-- assert.strictEqual(consolePage.status, 200);-- assert.match(consolePage.text, /\$0\.02 of the \$0\.05 free allowance left today \(\$0\.02 held by runs in progress\)/);++ // The chat composer is where a person starts a run: $0.05 - $0.01 spent - $0.02 held = $0.02 left.++ const chatPage = await t.get('/chat', { as: u });++ assert.strictEqual(chatPage.status, 200);++ assert.match(chatPage.text, /\$0\.02 of the \$0\.05 free allowance left today \(\$0\.02 held by runs in progress\)/);await t.ctx.s.db.query("UPDATE tasks SET state = 'cancelled' WHERE requester = $1", [who]);});nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// Screenshots for one-agent system, stage 1 (not run by test/run.js: only *.test.js are): the empty chat, a chat with a++// running task, and Settings, at 390 and 1280, with the overflow and 44 px checks printed for the report.++// Usage: node test/one-page-shots.js <outdir>++const fs = require('fs');++const path = require('path');++const h = require('openvibe-shared/browser-harness');++const { boot } = require('./helpers/boot');++const store = require('../server/tasks/store');++++(async () => {++ const out = process.argv[2] || path.join(__dirname, '..', '..', 'research-2026-11-12', 'actor-shots');++ fs.mkdirSync(out, { recursive: true });++ const t = await boot();++ const kim = t.network.addUser('kim');++ const nora = t.network.addUser('nora');++ const s = t.ctx.s;++ const runId = s.newId('tsk');++ await store.insertTask(s, { id: runId, requester: `user:${kim.subject}`, task: 'Check the certificate of example.com and tell me what expires first', mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: s.iso() });++ await store.updateTask(s, runId, { state: 'running' });++ await store.appendEvent(s, runId, { kind: 'output', step: 'plan', text: 'Step 1 of 3 · Read the certificate', at: s.iso() });++ await store.appendEvent(s, runId, { kind: 'output', step: 'tool_call', tool: 'read_page', input: 'https://example.com', at: s.iso() });++ await store.appendEvent(s, runId, { kind: 'output', step: 'tool_call', tool: 'web_search', input: 'example.com certificate expiry', at: s.iso() });++ await store.appendEvent(s, runId, { kind: 'output', step: 'text', text: 'The page is read; checking the certificate dates now.', at: s.iso() });++++ const browser = await h.launch({});++ const pages = [['empty', '/chat', nora], ['running', `/chat/${runId}`, kim], ['settings', '/chat/settings', nora]];++ try {++ for (const [name, url, user] of pages) {++ for (const width of [390, 1280]) {++ const page = await h.openPage(browser, await browser.newContext(), { width, height: 1500 });++ await page.send('Network.setCookie', { name: 'actor_at', value: t.signIn(user).replace(/^actor_at=/, ''), url: t.base, path: '/' });++ await page.goto(t.base + url, { maxMs: 10000 });++ await new Promise((r) => setTimeout(r, 1400));++ const shot = path.join(out, `${name}-${width}.png`);++ const png = await page.send('Page.captureScreenshot', { format: 'png' });++ fs.writeFileSync(shot, Buffer.from(png.data, 'base64'));++ const read = await page.evaluate(`(() => {++ const seen = (e) => e.closest('.cc-composer, .ct-composer-wrap, .ct-now, .ct-run, .ct-suggest, .ct-settings');++ const small = [...document.querySelectorAll('button, a, input, select, textarea')].filter((e) => {++ const r = e.getBoundingClientRect(), st = getComputedStyle(e);++ return seen(e) && st.display !== 'none' && st.visibility !== 'hidden' && r.width > 0 && r.height > 0 && !e.hidden && e.type !== 'hidden' && (r.height < 44 || r.width < 44);++ }).slice(0, 8).map((e) => { const r = e.getBoundingClientRect(); return e.tagName.toLowerCase() + '.' + String(e.className || '').split(' ')[0] + ' ' + Math.round(r.width) + 'x' + Math.round(r.height); });++ const wide = [...document.querySelectorAll('main *')].filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && (r.right > innerWidth + 1 || r.left < -1); })++ .slice(0, 6).map((e) => e.tagName.toLowerCase() + '.' + String(e.className || '').split(' ')[0]);++ return { sw: document.documentElement.scrollWidth, iw: innerWidth, wide, small };++ })()`);++ process.stdout.write(`${shot} scrollWidth=${read.sw}/${read.iw}${read.wide.length ? ` WIDE ${read.wide.join(', ')}` : ''}${read.small.length ? ` SMALL ${read.small.join(', ')}` : ''}\n`);++ await page.close();++ }++ }++ } finally {++ await browser.close();++ await t.close();++ }++ process.exit(0);++})().catch((e) => { process.stderr.write(String(e && e.stack)); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++/**++ * One agent system, stage 1: Actor's chat is the one place you use Actor.++ * - the empty chat shows four compact suggestion chips, not a wall of cards; a chat with messages shows none;++ * - the run settings ("New runs: Only me … Apply to all my existing runs") are in Settings, not under the composer;++ * - /console is 301 to /chat, and the phone tab bar is Home, Chat, Agents;++ * - a running task is a card in the conversation (title, state, the last steps, Stop) that updates in place, and the++ * "Now" strip at the top of the chat has one line per running task, Stop on each, hidden when nothing runs.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const store = require('../server/tasks/store');++++(async () => {++ const t = await boot();++ const s = t.ctx.s;++ const SAME = { 'sec-fetch-site': 'same-origin' };++ try {++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const owner = `user:${kim.subject}`;++ const task = async (text, state = 'running', who = owner) => {++ const id = s.newId('tsk');++ await store.insertTask(s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: s.iso() });++ if (state !== 'queued') await store.updateTask(s, id, { state });++ return id;++ };++ const event = (body) => store.appendEvent(s, running, { kind: 'output', at: s.iso(), ...body });++ const running = await task('Check the certificate of example.com');++++ await check('an empty chat: four compact suggestion chips, one line each, and no run-settings form', async () => {++ const r = await t.get('/chat', { as: kim });++ assert.strictEqual(r.status, 200);++ assert.strictEqual((r.text.match(/class="ct-suggest-chip"/g) || []).length, 4);++ assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 4, 'one starter per chip, no more');++ assert.ok(!/class="ct-starters"/.test(r.text), 'the card grid is gone');++ assert.ok(!/ct-share-defaults/.test(r.text), 'the run settings are off the chat');++ assert.match(r.text, /data-cc-composer/, 'composer v2 host');++ });++++ await check('a chat with messages shows no suggestion chips', async () => {++ const r = await t.get(`/chat/${running}`, { as: kim });++ assert.strictEqual(r.status, 200);++ assert.ok(!/data-ovt-starter=/.test(r.text), 'no suggestions once there is a conversation');++ });++++ await check('the run settings live in Settings, with the same route and behaviour', async () => {++ const page = await t.get('/chat/settings', { as: kim });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<h1>Settings<\/h1>/);++ assert.match(page.text, /<form class="ct-share-defaults" method="post" action="\/chat\/defaults">/);++ assert.match(page.text, /name="from" value="settings"/);++ assert.match(page.text, /Apply to all my existing runs/);++ assert.match(page.text, /href="\/chat\/model"/, 'the model settings are one link away');++ const saved = await t.get('/chat/defaults', { as: kim, form: { audience: 'private', from: 'settings' }, headers: SAME });++ assert.strictEqual(saved.status, 303);++ assert.strictEqual(saved.headers.get('location'), '/chat/settings?saved=1');++ assert.match((await t.get('/chat/settings?saved=1', { as: kim })).text, /Saved\. Your next run starts as Only me/);++ assert.strictEqual((await t.get('/chat/settings')).status, 303, 'signed out: sign in first');++ });++++ await check('a running task is a card in the conversation: title, state, last steps, Stop', async () => {++ await event({ step: 'tool_call', tool: 'web_search', input: 'example.com certificate' });++ await event({ step: 'text', text: 'Reading the certificate page' });++ const page = await t.get(`/chat/${running}`, { as: kim });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, new RegExp(`<article class="ovt-card ct-run" data-ct-run="${running}">`));++ assert.match(page.text, /Check the certificate of example\.com/);++ assert.match(page.text, /data-ct-run-state>Working…</);++ assert.match(page.text, /data-ovt-live>Reading the certificate page</, 'the last step, not a whole panel');++ assert.match(page.text, /data-ovt-work>/, 'the card keeps the steps for the live refresh');++ assert.match(page.text, new RegExp(`<form class="ct-run-stop" method="post" action="/chat/${running}/cancel">`));++ });++++ await check('the card updates in place: the fragment has the new step, then the finished turn has no card', async () => {++ await event({ step: 'tool_call', tool: 'read_page', input: 'https://example.com' });++ const open = await t.get(`/chat/turn/${running}`, { as: kim });++ assert.strictEqual(open.status, 200);++ assert.match(open.text, /Read a page/, 'the newest step is in the refresh');++ await store.updateTask(s, running, { state: 'verifying' });++ const checking = await t.get(`/chat/turn/${running}`, { as: kim });++ assert.match(checking.text, /data-ct-run-state>Checking the answer…</);++ await store.updateTask(s, running, { state: 'succeeded', finished_at: s.iso(), result: { answer: 'It is valid.' } });++ const finished = await t.get(`/chat/turn/${running}`, { as: kim });++ assert.ok(!/data-ct-run=/.test(finished.text), 'a finished turn is the ordinary answer');++ assert.match(finished.text, /It is valid\./);++ });++++ await check('the Now strip: one line per running task with Stop, hidden when nothing runs', async () => {++ const live = await task('Read the news for me');++ const queued = await task('Summarise the release notes', 'queued');++ const page = await t.get('/chat', { as: kim });++ assert.strictEqual((page.text.match(/class="ct-now-item"/g) || []).length, 1, 'only what is running now');++ const strip = page.text.slice(page.text.indexOf('<section class="ct-now"'), page.text.indexOf('</section>', page.text.indexOf('<section class="ct-now"')));++ assert.match(strip, /Read the news for me/);++ assert.ok(!/Summarise the release notes/.test(strip), 'a queued task is not running yet');++ assert.match(page.text, /class="ct-now-stop" method="post" action="\/chat\/tsk_[0-9A-Z]{26}\/cancel"/);++ const now = await t.get('/chat/now', { as: kim });++ assert.strictEqual(now.status, 200);++ assert.match(now.text, /Read the news for me/);++ const empty = await t.get('/chat', { as: bo });++ assert.match(empty.text, /<section class="ct-now" data-ct-now hidden><\/section>/, 'nothing runs: no strip');++ assert.strictEqual((await t.get('/chat/now')).status, 401);++ await store.updateTask(s, live, { state: 'cancelled' });++ await store.updateTask(s, queued, { state: 'cancelled' });++ });++++ await check('Stop on the card (and on the strip) cancels the run and comes back to the chat', async () => {++ const stop = await task('Watch this page');++ const before = t.ctx.engine.cancel;++ const cancelled = [];++ t.ctx.engine.cancel = async (id) => { cancelled.push(id); await store.updateTask(s, id, { state: 'cancelled' }); };++ try {++ const card = await t.get(`/chat/${stop}/cancel`, { as: kim, form: { do: 'stop' }, headers: SAME });++ assert.strictEqual(card.status, 303);++ assert.strictEqual(card.headers.get('location'), `/chat/${stop}`);++ assert.deepStrictEqual(cancelled, [stop], 'the run was stopped');++ assert.strictEqual((await store.getTask(s, stop)).state, 'cancelled');++ const theirs = await t.get(`/chat/${stop}/cancel`, { as: bo, form: { do: 'stop' }, headers: SAME });++ assert.deepStrictEqual([theirs.status, theirs.headers.get('location')], [303, '/chat'], 'another person: nothing');++ assert.strictEqual((await t.get(`/chat/${stop}/cancel`, { as: kim, form: { do: 'stop' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);++ } finally { t.ctx.engine.cancel = before; }++ });++++ await check('/console is 301 to the chat, and the phone tab bar is Home, Chat, Agents', async () => {++ const out = await t.get('/console', { as: kim });++ assert.strictEqual(out.status, 301);++ assert.strictEqual(out.headers.get('location'), '/chat');++ const page = await t.get('/chat', { as: kim });++ const bar = /"appBar":\[(.*?)\]/.exec(page.text);++ assert.ok(bar, 'the app bar is in the page config');++ assert.deepStrictEqual([...bar[1].matchAll(/"label":"([^"]+)"/g)].map((m) => m[1]), ['Home', 'Chat', 'Agents']);++ assert.ok(!/"label":"Console"/.test(page.text), 'no Console anywhere in the navigation');++ });++++ await check('phone first: the controls the change adds are at least 44 px and the layout can shrink', async () => {++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');++ assert.match(css, /@media \(max-width: 640px\)[\s\S]*\.cc-composer \.ovc-v2 \.ovt-btn, \.cc-composer \.ovc-v2 \.ovt-send \{ width: 44px; height: 44px; min-width: 44px; min-height: 44px; \}/);++ assert.match(css, /\.ct-now-stop button \{[^}]*min-height: 44px/);++ assert.match(css, /\.ct-now-task \{[^}]*min-height: 44px/);++ assert.match(css, /\.ct-run-stop \.ovt-card-btn \{[^}]*min-height: 44px/);++ assert.match(css, /\.ct-suggest-chip \{[^}]*min-height: 44px/);++ assert.match(css, /\.ct-now-item \{[^}]*minmax\(0,/, 'every track can shrink: nothing is wider than the phone');++ const page = await t.get('/chat', { as: kim });++ assert.match(page.text, /<meta name="viewport" content="width=device-width, initial-scale=1">/);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -38,22 +38,22 @@ const SECRET = 's'.repeat(43);const t = await boot({ env: { ...base, ACTOR_RUNTIME_URL: runtimeUrl, ACTOR_RUNTIME_MODEL: 'local-model', ACTOR_DEEPSEEK_API_KEY: null, ACTOR_OPENAI_API_KEY: null, ACTOR_LOCAL_LLM_URL: null, ACTOR_LOCAL_LLM_MODEL: null, ACTOR_MODEL_FAST: null, ACTOR_MODEL_SMART: null, ACTOR_MODEL_BEST: null, ACTOR_MODEL_CHECK: null, ACTOR_MODEL_VISION: null } });await check('the owner signs in with the password; a wrong one is refused', async () => {-- const form = await t.get('/auth/login?next=/console');++ const form = await t.get('/auth/login?next=/chat');assert.strictEqual(form.status, 200);assert.match(form.text, /Sign in to your Actor/);-- assert.match(form.text, /name="next" value="\/console"/);-- const wrong = await t.get('/auth/login', { form: { password: 'nope', next: '/console' } });++ assert.match(form.text, /name="next" value="\/chat"/);++ const wrong = await t.get('/auth/login', { form: { password: 'nope', next: '/chat' } });assert.strictEqual(wrong.status, 401);assert.ok(!wrong.headers.get('set-cookie'));-- const ok = await t.get('/auth/login', { form: { password: PASSWORD, next: '/console' } });-- assert.deepStrictEqual([ok.status, ok.headers.get('location')], [303, '/console']);++ const ok = await t.get('/auth/login', { form: { password: PASSWORD, next: '/chat' } });++ assert.deepStrictEqual([ok.status, ok.headers.get('location')], [303, '/chat']);const cookie = ok.headers.get('set-cookie').split(';')[0];assert.match(cookie, /^actor_local=\d{13}\.[A-Za-z0-9_-]{43}$/);const me = await t.get('/auth/me', { cookie });assert.deepStrictEqual(me.json().user, { username: 'kai', display_name: 'kai', subject_id: SUBJECT });const forged = await t.get('/auth/me', { cookie: `actor_local=9999999999999.${'A'.repeat(43)}` });assert.strictEqual(forged.json().user, null, 'a cookie not signed with the session secret is nobody');-- assert.strictEqual((await t.get('/console', { cookie })).status, 200);++ assert.strictEqual((await t.get('/chat', { cookie })).status, 200);});await check('the API takes the owner\'s token only: never a Network token, never a wrong one', async () => {@@@ -101,8 +101,9 @@ const SAME = { 'sec-fetch-site': 'same-origin' };const next = await say('And the second cheapest?');const nextId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(next.headers.get('location'))[1];assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });-- const start = await t.get('/chat', { as: kim });-- assert.ok(start.text.includes('New runs'), 'the default is on the product page');++ const settings = await t.get('/chat/settings', { as: kim });++ assert.ok(settings.text.includes('New runs'), 'the default is in Settings now');++ assert.ok(!(await t.get('/chat', { as: kim })).text.includes('New runs'), 'not under the composer');});} finally {await t.close();
@@ -2,6 +2,24 @@
@@ -2,6 +2,24 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
+## Unreleased — the chat is the one place you use Actor (one agent system, stage 1)
+
+- **Composer v2** (openvibe-shared 4.215.0, the `agent` preset): the chat mounts `OpenVibeComposer.mount` with Attach,
+ Speak, the mode chip (Fast, Balanced, Best, Cheapest, Private) and the round send; files go through Actor's existing
+ path (`POST /chat` with the file as data) and their chips show the read's progress. The dashed "Attach files" box and
+ the separate model/mode controls are gone; the model lives at `/chat/model`.
+- **Settings** (`/chat/settings`): the run defaults ("New runs: Only me … Apply to all my existing runs") moved off the
+ chat. The route (`POST /chat/defaults`) and its behaviour are unchanged.
+- **Four suggestion chips** on an empty chat, one line each, instead of the wall of starter cards; a chat with messages
+ shows none.
+- **The Console is folded in**: `/console` answers 301 to `/chat` (carrying `?ask=`/`?from=`), the bottom tab bar is
+ Home, Chat, Agents, and the running tasks the Console listed are the chat's "Now" strip — one line each, Stop on each,
+ hidden when nothing runs. `/me/spend` and the approvals, agents, computers and robots APIs and pages stay.
+- **A running task is a card in the conversation** (title, state, the last steps, Stop), refreshed in place from
+ `GET /chat/turn/:id`; stopping goes through `POST /chat/:id/cancel` and returns to the chat.
+- Migration-free. Tests: `test/one-page.test.js`, plus the updated chat, console, model, sharing, allowance and
+ self-host suites; screenshots at 390 and 1280: `test/one-page-shots.js`.
+
## Unreleased — skills that run code, sandboxed (HO8 step 3)
## Unreleased — skills that run code, sandboxed (HO8 step 3)
- **`actor.skill@2`: code in a sandbox** (plan HO8, ADR-060; contracts 0.343.0): a sandbox skill carries one to five
- **`actor.skill@2`: code in a sandbox** (plan HO8, ADR-060; contracts 0.343.0): a sandbox skill carries one to five
@@ -121,7 +121,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -121,7 +121,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`), through the Search Fabric (`/api/v1/query`): content across the network for the runtime's `search_openvibe` tool (anonymous), and the web for `search_web` (cited results; asked as Actor's service principal with the grant `search.query.delegate`, naming the task's person in `X-OV-Subject`, so the paid steps past the open index count against them).
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`), through the Search Fabric (`/api/v1/query`): content across the network for the runtime's `search_openvibe` tool (anonymous), and the web for `search_web` (cited results; asked as Actor's service principal with the grant `search.query.delegate`, naming the task's person in `X-OV-Subject`, so the paid steps past the open index count against them).
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
-- **The Console** (`/console`, plan T17 layer 3): one page for a signed-in person. It has a command box (the ordinary task form) and their running and recent tasks; it refreshes itself while something runs, never while they type. It shows what waits for them: their approvals, and approvals waiting on their own computers. It also shows today's spend against the free tier, their agents, and their computers and robots, read from OpenVibe.Bot as them with a 4 s bound, so a slow Bot never holds the page. Links go to Services, Watch and Codes for the rest. It works without JavaScript (`server/http/console-pages.js`, `test/console.test.js`).
+- **The chat is the one page** (one-agent system, stage 1): the Console is folded into it. `/console` answers 301 to `/chat`; the running tasks the Console listed are the "Now" strip at the top of the chat (one line each, Stop on each, hidden when nothing runs), and a running task is a card in its conversation that updates in place. The run settings ("New runs: Only me … Apply to all my existing runs") live in `/chat/settings`; the composer is openvibe-shared's composer v2, agent preset (Attach, Speak, the Fast/Balanced/Best mode chip, the round send, progress on each file). The empty chat shows four compact suggestion chips. `/me/spend` stays (`server/http/console-pages.js`, `server/http/chat-pages.js`, `test/one-page.test.js`).
- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Link's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`). `computer_screen` (when an OpenAI key is set) takes a picture of the machine's screen through Bot (1280 px wide) and has OpenAI's vision model answer the agent's question about it. The runtime model gets that answer, never the picture; the picture is not stored; secrets visible on screen are named, not repeated; and each look is metered on the task like any model call (refused once the task's budget is spent). `computer_input` (offered with `computer_screen`; contracts 0.171.0) uses the mouse and keyboard: a click, double or right click or move at a position in the last screen picture (its width and height are passed, and the Link scales them to the real screen), a scroll, typed text, or one key or chord. It is an acting command, so at level `ask` each one waits for the person's approval on the machine; the runtime is told to look first, act in small steps, look again, and never type a secret the task did not give it.
- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Link's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`). `computer_screen` (when an OpenAI key is set) takes a picture of the machine's screen through Bot (1280 px wide) and has OpenAI's vision model answer the agent's question about it. The runtime model gets that answer, never the picture; the picture is not stored; secrets visible on screen are named, not repeated; and each look is metered on the task like any model call (refused once the task's budget is spent). `computer_input` (offered with `computer_screen`; contracts 0.171.0) uses the mouse and keyboard: a click, double or right click or move at a position in the last screen picture (its width and height are passed, and the Link scales them to the real screen), a scroll, typed text, or one key or chord. It is an acting command, so at level `ask` each one waits for the person's approval on the machine; the runtime is told to look first, act in small steps, look again, and never type a secret the task did not give it.
- `computer_browser` (contracts 0.174.0, Link v0.10.0) drives a browser of the agent's own on the machine. It is a
- `computer_browser` (contracts 0.174.0, Link v0.10.0) drives a browser of the agent's own on the machine. It is a
profile of its own, so it never sees the person's sessions.
profile of its own, so it never sees the person's sessions.
@@ -16,7 +16,7 @@ docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b
@@ -16,7 +16,7 @@ docker compose -f docker-compose.selfhost.yml exec ollama ollama pull qwen2.5:7b
```
```
Open http://localhost:4950/auth/login and sign in with the password `selfhost:init` printed. It is shown only once.
Open http://localhost:4950/auth/login and sign in with the password `selfhost:init` printed. It is shown only once.
-Then give Actor a task on `/console`, or call the API with your token:
+Then give Actor a task on `/chat`, or call the API with your token:
```sh
```sh
curl -s http://localhost:4950/api/v1/tasks -H "Authorization: Bearer $ACTOR_OWNER_TOKEN" \
curl -s http://localhost:4950/api/v1/tasks -H "Authorization: Bearer $ACTOR_OWNER_TOKEN" \
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.34.5"
"sharp": "^0.34.5"
},
},
@@ -1756,9 +1756,9 @@
@@ -1756,9 +1756,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.214.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",
+ "version": "4.215.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
+ "integrity": "sha512-2hXCSsMQieDuYtdcr4KEhBmrM0rzw10T8TT/edGSsoXXQCf4bmqMWp7iPBR3NYaJgiGATcnNFdW931AbtGhfAw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.215.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.34.5"
"sharp": "^0.34.5"
},
},
@@ -24,15 +24,13 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -24,15 +24,13 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
box-shadow: 0 10px 40px color-mix(in srgb, var(--accent, #f97316) 30%, transparent); animation: ct-breathe 3.2s ease-in-out infinite; }
box-shadow: 0 10px 40px color-mix(in srgb, var(--accent, #f97316) 30%, transparent); animation: ct-breathe 3.2s ease-in-out infinite; }
.ct-hello h2 { margin: 14px 0 6px; font-size: 1.6rem; }
.ct-hello h2 { margin: 14px 0 6px; font-size: 1.6rem; }
.ct-hello > p { margin: 0 auto 20px; max-width: 46ch; color: var(--text-secondary, #a8b3c4); }
.ct-hello > p { margin: 0 auto 20px; max-width: 46ch; color: var(--text-secondary, #a8b3c4); }
-.ct-starters { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; text-align: left; }
-.ct-starters button { display: grid; gap: 3px; width: 100%; height: 100%; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-align: left; }
-.ct-starters button:hover { border-color: var(--accent, #f97316); }
-.ct-starters b { font-size: 14px; }
-.ct-starters span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #a8b3c4); }
-.ct-starters button { grid-template-columns: 22px minmax(0, 1fr); column-gap: 10px; align-items: start; }
-.ct-starters button svg { grid-row: span 2; margin-top: 1px; color: var(--accent, #f97316); }
-.ct-starters span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
-@media (max-width: 560px) { .ct-starters { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .ct-starters button { padding: 10px 11px; grid-template-columns: minmax(0, 1fr); } .ct-starters button svg { grid-row: auto; } .ct-starters span { font-size: 12.5px; } }
+/* Four compact suggestion chips above the composer, one line each (the owner, 11-12: the wall of cards was "an
+ absolute atrocious mess"); a chat with messages shows none. */
+.ct-suggest { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
+.ct-suggest-chip { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font: inherit; font-size: 13.5px; white-space: nowrap; cursor: pointer; }
+.ct-suggest-chip:hover, .ct-suggest-chip:focus-visible { border-color: var(--accent, #f97316); }
+.ct-suggest-chip svg { flex: none; color: var(--accent, #f97316); }
+@media (max-width: 560px) { .ct-suggest { gap: 6px; } .ct-suggest-chip { min-height: 44px; font-size: 13px; } }
/* The composer's own tools: the mode, the live note, a problem line. */
/* The composer's own tools: the mode, the live note, a problem line. */
.cc-mode select { max-width: 150px; min-height: 34px; padding: 0 26px 0 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-secondary, #b4c0d4);
.cc-mode select { max-width: 150px; min-height: 34px; padding: 0 26px 0 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-secondary, #b4c0d4);
@@ -182,3 +180,77 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -182,3 +180,77 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.cc-canvas-phone .cc-canvas-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
.cc-canvas-phone .cc-canvas-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
@media (min-width: 1180px) { .cc-canvas-phone { display: none !important; } }
@media (min-width: 1180px) { .cc-canvas-phone { display: none !important; } }
@media (max-width: 1179px) { .cc-canvas-wide { display: none !important; } }
@media (max-width: 1179px) { .cc-canvas-wide { display: none !important; } }
+
+
+/* ── The Now strip (the Console's "Now" card, folded into the chat): one row per running task, Stop on each, hidden
+ when nothing runs. One line on a phone: the last step folds away and the title truncates. */
+.ct-now { position: sticky; top: calc(var(--ovt-top, 56px) + 52px); z-index: 18; margin: 0; padding: 6px 16px; background: var(--bg-primary, #0a0f1c); }
+.ct-now[hidden] { display: none; }
+.ct-now-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.ct-now-item { display: grid; grid-template-columns: auto minmax(0, .9fr) minmax(0, 1.1fr) auto auto; align-items: center; gap: 8px; min-height: 44px; padding: 4px 6px 4px 10px; border: 1px solid var(--line); border-radius: 12px; background: color-mix(in srgb, var(--accent, #f97316) 4%, var(--bg-card, #131c2e)); }
+.ct-now-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #f97316); animation: ct-breathe 1.4s ease-in-out infinite; }
+.ct-now-task { display: flex; align-items: center; min-width: 0; min-height: 44px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 13.5px; }
+.ct-now-task b { font-weight: 650; }
+.ct-now-step { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary, #a8b3c7); font-size: 12.5px; }
+.ct-now-state { flex: none; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #f97316) 14%, transparent); color: var(--accent, #f97316); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
+.ct-now-stop { margin: 0; }
+.ct-now-stop button { min-width: 60px; min-height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); font: inherit; font-weight: 650; cursor: pointer; }
+.ct-now-stop button:hover, .ct-now-stop button:focus-visible { border-color: var(--accent, #f97316); }
+@media (max-width: 720px) {
+ .ct-now { padding: 6px 10px; }
+ .ct-now-item { grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 6px; }
+ .ct-now-step { display: none; }
+}
+
+/* ── A running task in the conversation is a card, not a separate panel: title, state, the last steps and Stop. The
+ shared thread's card and button look; js/chat.js refreshes it in place. */
+.ct-run { display: grid; gap: 6px; border-left: 3px solid var(--accent, #f97316); }
+.ct-run-title { flex: 1; min-width: 0; overflow-wrap: anywhere; font-weight: 650; color: var(--text-primary, #e6edf7); }
+.ct-run-state { flex: none; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #f97316) 14%, transparent); color: var(--accent, #f97316); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
+.ct-run-live { margin: 0; color: var(--text-secondary, #a8b3c7); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ct-run-steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }
+.ct-run-steps > li { font-size: 13px; color: var(--text-secondary, #a8b3c7); overflow-wrap: anywhere; }
+.ct-run-steps .ovt-w-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
+.ct-run-steps .ovt-w-in { color: var(--text-muted, #7386a3); font-size: 12.5px; }
+.ct-run-steps .ovt-w-step span, .ct-run-steps .ovt-w-note p, .ct-run-steps .ovt-w-you p { margin: 0; }
+.ct-run-stop { margin: 0; display: flex; justify-content: flex-end; }
+.ct-run-stop .ovt-card-btn { min-width: 60px; min-height: 44px; }
+
+/* ── Composer v2 in the chat: the allowance is one quiet line under the box; the server's fallback form is replaced by
+ the mounted composer. Nothing on a phone is under 44 px. */
+.ct-composer-wrap { width: 100%; max-width: 860px; margin: 0 auto; }
+.cc-allowance { margin: 6px 4px 0; text-align: center; color: var(--text-muted, #7386a3); font-size: 12px; line-height: 1.45; }
+.ct-composer-fallback { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 18px; background: var(--bg-card, #131c2e); }
+.ct-composer-fallback textarea { width: 100%; min-height: 52px; box-sizing: border-box; padding: 8px 4px; border: 0; outline: 0; resize: vertical; background: transparent; color: inherit; font: inherit; font-size: 16px; }
+.ct-composer-fallback-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
+.ct-composer-mode { display: inline-flex; align-items: center; gap: 6px; color: var(--text-secondary, #a8b3c7); font-size: 13px; }
+.ct-composer-mode select { min-height: 44px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: inherit; font: inherit; }
+.ct-composer-fallback button { min-height: 44px; padding: 0 16px; border: 0; border-radius: 12px; background: var(--accent, #f97316); color: var(--on-accent, #fff); font: inherit; font-weight: 700; }
+/* The mode chip is the select's own look: full words, one pill, 44 px on a phone (the native select stays behind it). */
+.cc-composer .ovc-chip { flex: none; max-width: none; height: 44px; }
+.cc-composer .ovc-chip-v { overflow: visible; text-overflow: clip; }
+.cc-composer .ovc-chip select { min-height: 44px; opacity: 0; }
+@media (max-width: 640px) {
+ /* The shared composer's own sizes are 40 px: a thumb needs 44. */
+ .cc-composer .ovc-v2 .ovt-btn, .cc-composer .ovc-v2 .ovt-send { width: 44px; height: 44px; min-width: 44px; min-height: 44px; }
+ .cc-composer .ovc-v2 .ovc-chip { min-width: 44px; min-height: 44px; }
+ .cc-composer .ovc-chip select { min-height: 44px; }
+ .ct-run-stop .ovt-card-btn { min-height: 44px; }
+}
+
+/* ── Actor's Settings: the run defaults that used to sit under the composer, and the model. */
+.ct-settings { max-width: 760px; margin: 0 auto; padding: 18px 16px 40px; }
+.ct-settings h1 { margin: 4px 0 6px; font-size: clamp(24px, 4vw, 30px); }
+.ct-settings-lede { margin: 0 0 16px; color: var(--text-secondary, #a8b3c7); line-height: 1.55; }
+.ct-settings-card { margin: 0 0 16px; padding: 16px; border: 1px solid var(--line); border-radius: 16px; background: var(--bg-card, #131c2e); }
+.ct-settings-card h2 { margin: 0 0 6px; font-size: 18px; }
+.ct-settings-card p { margin: 0 0 12px; color: var(--text-secondary, #a8b3c7); font-size: 14px; line-height: 1.5; }
+.ct-settings-foot { margin: 10px 0 0 !important; font-size: 13px !important; }
+.ct-settings-foot a { display: inline-flex; align-items: center; min-height: 44px; }
+.ct-share-defaults { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
+.ct-share-defaults label { font-size: 14px; color: var(--text-primary, #e6edf7); }
+.ct-share-defaults select { min-height: 44px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: inherit; font: inherit; }
+.ct-share-defaults .ct-check { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }
+.ct-share-defaults input[type=checkbox] { width: 18px; height: 18px; }
+.ct-share-defaults .sc-btn { min-height: 44px; }
+.ct-share-defaults .muted { flex-basis: 100%; font-size: 12.5px; line-height: 1.45; }
@@ -25,12 +25,22 @@
@@ -25,12 +25,22 @@
var chat = document.querySelector('.chat');
var chat = document.querySelector('.chat');
if (!chat || chat.dataset.ready) return;
if (!chat || chat.dataset.ready) return;
chat.dataset.ready = '1';
chat.dataset.ready = '1';
- var form = chat.querySelector('[data-ovt-composer]');
- var text = chat.querySelector('[data-ovt-text]');
- var send = chat.querySelector('[data-ovt-send]');
- var note = chat.querySelector('[data-cc-note]');
+ // Composer v2 (openvibe-shared 4.215.0, the agent preset): the server renders a plain form for a browser without
+ // script; with script it is replaced by the mounted composer, whose chips show each file's progress as Actor's
+ // own file path reads it. The mount happens here, before ov-thread.js's DOMContentLoaded start, so the shared
+ // thread binds to it too.
+ var host = chat.querySelector('[data-cc-composer]');
+ var form = null, text = null, send = null, note = null, composer = null;
+ var uploaded = new Map();
+ var uploadSeq = 0;
var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
+ if (host) mountComposer();
+ else {
+ form = chat.querySelector('[data-ovt-composer]');
+ text = chat.querySelector('[data-ovt-text]');
+ send = chat.querySelector('[data-ovt-send]');
+ }
// ── The canvas (plan HO9). The document rides in an inert template; openvibe-shared's ov-canvas.js draws it
// ── The canvas (plan HO9). The document rides in an inert template; openvibe-shared's ov-canvas.js draws it
// into the wide panel and the phone fold from the same data. A button's label goes to the agent as the
// into the wide panel and the phone fold from the same data. A button's label goes to the agent as the
@@ -131,84 +141,116 @@
@@ -131,84 +141,116 @@
// ── The thread stays at the bottom while you are there.
// ── The thread stays at the bottom while you are there.
function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }
function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }
- function toBottom(smooth) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }
+ function toBottom(smooth) {
+ // A short chat has nothing to scroll: don't hide the Now strip (or the start of the thread) for no reason.
+ if (document.documentElement.scrollHeight <= window.innerHeight + 2) return;
+ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' });
+ }
// ── The composer: ov-thread.js grows it, sends on Enter and fills starters. An input event lets it re-measure after
// ── The composer: ov-thread.js grows it, sends on Enter and fills starters. An input event lets it re-measure after
// this file changes the words.
// this file changes the words.
function grow() { if (text) text.dispatchEvent(new Event('input')); }
function grow() { if (text) text.dispatchEvent(new Event('input')); }
if (text) text.addEventListener('input', function () { syncSend(); });
if (text) text.addEventListener('input', function () { syncSend(); });
- // ── Files: the shared picker owns the drop zone, chips and removal.
- var uploadRoot = chat.querySelector('[data-cc-upload]');
- var picked = [];
- var meters = new Map();
- var picker = uploadRoot && window.OVUpload && OVUpload.picker(uploadRoot, {
- label: 'Attach files', accept: uploadRoot.dataset.accept, multiple: true,
- onFile: function (file, row) {
- var allowed = /^text\//.test(file.type) || /^application\/(pdf|json|xml|x-yaml|yaml|javascript|sql)$/.test(file.type) ||
- /^image\/(png|jpeg|webp|gif)$/.test(file.type) ||
- /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|c|h|cpp|hpp|cs|php|sh|sql|log|ini|env\.example|srt|vtt|pdf)$/i.test(file.name);
- var total = picked.reduce(function (sum, item) { return sum + item.size; }, 0) + file.size;
- if (!allowed || picked.length >= 4 || file.size > 6 * 1048576 || total > 12 * 1048576) {
- row.dispatchEvent(new Event('remove')); row.remove();
- problem(!allowed ? 'Actor reads text, code, CSV, JSON, PDFs and PNG, JPEG, WebP or GIF images.' :
- picked.length >= 4 ? 'Attach at most 4 files to one message.' :
- file.size > 6 * 1048576 ? file.name + ' is over 6 MB.' : 'The files together are over 12 MB.');
- return;
- }
- problem(null);
- picked.push(file);
- var meterRoot = document.createElement('div'); row.append(meterRoot);
- var meter = OVUpload.progress(meterRoot, { compact: true, onRetry: function () { form.requestSubmit(); } });
- meter.update({ state: 'waiting', total: file.size });
- meters.set(file, meter);
- syncSend();
- },
- onRemove: function (file) {
- picked = picked.filter(function (item) { return item !== file; });
- if (meters.has(file)) { meters.get(file).destroy(); meters.delete(file); }
- syncSend();
+ // ── Composer v2: mount the agent preset (Attach, Speak, the mode chip, the round send) and wire it to Actor's
+ // own send: JSON with the files as data URLs (server/tasks/inputs.js), and while Actor works, its steer route.
+ function mountComposer() {
+ if (!window.OpenVibeComposer) return;
+ host.replaceChildren();
+ var parent = host.getAttribute('data-parent') || '';
+ var workingId = host.getAttribute('data-working') || '';
+ var mode = host.getAttribute('data-mode') || 'balanced';
+ composer = window.OpenVibeComposer.mount(host, {
+ preset: 'agent', id: 'cc-composer',
+ placeholder: workingId ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something',
+ label: 'Message Actor', maxLength: 20000,
+ maxFiles: Number(host.getAttribute('data-max-files')) || 4,
+ maxMb: Number(host.getAttribute('data-max-mb')) || 6,
+ accept: host.getAttribute('data-accept') || '',
+ mode: { name: 'mode', label: 'How Actor works', value: mode,
+ options: [['fastest', 'Fast'], ['balanced', 'Balanced'], ['best', 'Best'], ['cheapest', 'Cheapest'], ['private', 'Private']] },
+ upload: function (file, onProgress) {
+ return new Promise(function (ok, no) {
+ var rd = new FileReader();
+ rd.onprogress = function (e) { if (e.lengthComputable) onProgress(e.total ? e.loaded / e.total : 0); };
+ rd.onload = function () {
+ var data = String(rd.result || '').split(',')[1] || '';
+ if (!data) { no(new Error('Could not read ' + file.name)); return; }
+ uploaded.set(file, { name: file.name, type: file.type || '', data: data });
+ ok({ id: 'up_' + (++uploadSeq) });
+ };
+ rd.onerror = function () { no(new Error('Could not read ' + file.name)); };
+ rd.readAsDataURL(file);
+ });
+ },
+ beforeSend: function (ctx) {
+ var bad = (ctx.files() || []).find(function (f) { return !allowedFile(f); });
+ if (bad) { problem('Actor reads text, code, CSV, JSON, PDFs and PNG, JPEG, WebP or GIF images.'); return false; }
+ return true;
+ },
+ onSubmit: function (v) { return submit(v); },
+ });
+ form = composer.form; text = composer.text; send = composer.send; note = composer.note;
+ // The mode select lives under the chip's own look: keep the browser's select (hidden behind the chip), never
+ // ov-forms' box, which would draw a second pill inside it.
+ var modeSelect = form.querySelector('select[name="mode"]');
+ if (modeSelect) modeSelect.setAttribute('data-ov-native', '');
+ var parentInput = document.createElement('input');
+ parentInput.type = 'hidden'; parentInput.name = 'parent'; parentInput.value = parent;
+ form.appendChild(parentInput);
+ if (host.getAttribute('data-text')) {
+ text.value = host.getAttribute('data-text');
+ text.dispatchEvent(new Event('input'));
}
}
- });
- function clearPicked() {
- picked = [];
- meters.forEach(function (meter) { meter.destroy(); }); meters.clear();
- if (picker) picker.rows.replaceChildren();
- }
- function readAll(files) {
- return Promise.all(files.map(function (f) {
- return new Promise(function (ok, no) {
- var rd = new FileReader();
- rd.onload = function () { ok({ name: f.name, type: f.type || '', data: String(rd.result).split(',')[1] || '' }); };
- rd.onerror = function () { no(new Error('read')); };
- rd.readAsDataURL(f);
+ if (host.getAttribute('data-busy')) text.placeholder = 'Tell Actor something while it works';
+ // A starter chip fills the mounted box (ov-thread.js bound the plain one before this existed).
+ chat.querySelectorAll('[data-ovt-starter]').forEach(function (b) {
+ b.addEventListener('click', function () {
+ text.value = b.getAttribute('data-ovt-starter') || '';
+ text.dispatchEvent(new Event('input'));
+ text.focus();
});
});
- }));
+ });
+ }
+ function allowedFile(file) {
+ return /^text\//.test(file.type) || /^application\/(pdf|json|xml|x-yaml|yaml|javascript|sql)$/.test(file.type) ||
+ /^image\/(png|jpeg|webp|gif)$/.test(file.type) ||
+ /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|c|h|cpp|hpp|cs|php|sh|sql|log|ini|env\.example|srt|vtt|pdf)$/i.test(file.name);
+ }
+ // The file's data, read once by the composer's upload (for its progress chip) or here when a browser had none.
+ function filePayload(file) {
+ var ready = uploaded.get(file);
+ if (ready) return Promise.resolve(ready);
+ return new Promise(function (ok, no) {
+ var rd = new FileReader();
+ rd.onload = function () { ok({ name: file.name, type: file.type || '', data: String(rd.result || '').split(',')[1] || '' }); };
+ rd.onerror = function () { no(new Error('Could not read ' + file.name)); };
+ rd.readAsDataURL(file);
+ });
}
}
-
function openTurn() { var t = chat.querySelectorAll('.ovt-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }
function openTurn() { var t = chat.querySelectorAll('.ovt-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }
var stopBtn = chat.querySelector('[data-ovt-stop]');
var stopBtn = chat.querySelector('[data-ovt-stop]');
function working() { var t = openTurn(); return t && t.hasAttribute('data-open') ? t : null; }
function working() { var t = openTurn(); return t && t.hasAttribute('data-open') ? t : null; }
function syncSend() {
function syncSend() {
- if (!send) return;
+ if (!send || !text) return;
var w = working();
var w = working();
if (stopBtn) { stopBtn.hidden = !w; stopBtn.type = 'button'; stopBtn.removeAttribute('formaction'); }
if (stopBtn) { stopBtn.hidden = !w; stopBtn.type = 'button'; stopBtn.removeAttribute('formaction'); }
- form.setAttribute('action', w ? '/chat/' + w.getAttribute('data-task') + '/steer' : '/chat');
- if (text) text.placeholder = w ? 'Tell Actor something while it works' : (form.querySelector('input[name=parent]') ? 'Reply to Actor' : 'Ask Actor to do something');
- send.disabled = !(text && text.value.trim());
- if (picker) { picker.input.disabled = !!w; uploadRoot.querySelector('.ov-upload__picker').disabled = !!w; }
+ text.placeholder = w ? 'Tell Actor something while it works' : (host && host.getAttribute('data-parent') ? 'Reply to Actor' : 'Ask Actor to do something');
}
}
syncSend();
syncSend();
function problem(msg) {
function problem(msg) {
- var p = form.querySelector('.cc-problem');
+ var root = form || host;
+ if (!root) return;
+ var p = root.querySelector('.cc-problem');
if (!msg) { if (p) p.remove(); return; }
if (!msg) { if (p) p.remove(); return; }
- if (!p) { p = document.createElement('p'); p.className = 'cc-problem'; p.setAttribute('role', 'alert'); form.insertBefore(p, form.firstChild); }
+ if (!p) { p = document.createElement('p'); p.className = 'cc-problem'; p.setAttribute('role', 'alert'); root.insertBefore(p, root.firstChild); }
p.textContent = msg;
p.textContent = msg;
}
}
function setParent(id) {
function setParent(id) {
- var input = form.querySelector('input[name=parent]');
- if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'parent'; form.appendChild(input); }
- input.value = id;
+ if (host) host.setAttribute('data-parent', id);
+ var input = form && form.querySelector('input[name=parent]');
+ if (!input && form) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'parent'; form.appendChild(input); }
+ if (input) input.value = id;
if (text) text.placeholder = 'Reply to Actor';
if (text) text.placeholder = 'Reply to Actor';
}
}
@@ -219,27 +261,64 @@
@@ -219,27 +261,64 @@
xhr.setRequestHeader('accept', 'application/json');
xhr.setRequestHeader('accept', 'application/json');
xhr.setRequestHeader('content-type', 'application/json');
xhr.setRequestHeader('content-type', 'application/json');
xhr.upload.onprogress = function (event) {
xhr.upload.onprogress = function (event) {
- if (!event.lengthComputable) return;
- var fraction = event.total ? event.loaded / event.total : 0;
- meters.forEach(function (meter, file) {
- meter.update({ state: 'sending', done: Math.round(file.size * fraction), total: file.size });
- });
- };
- xhr.upload.onload = function () {
- meters.forEach(function (meter, file) { meter.update({ state: 'processing', done: file.size, total: file.size, message: 'Actor is reading the file' }); });
+ if (!event.lengthComputable || !note) return;
+ note.textContent = 'Sending… ' + Math.round((event.total ? event.loaded / event.total : 0) * 100) + '%';
};
};
+ xhr.upload.onload = function () { if (note) note.textContent = 'Actor is reading the file…'; };
xhr.onload = function () {
xhr.onload = function () {
var data;
var data;
try { data = JSON.parse(xhr.responseText); } catch (e) { data = { error: 'That could not be sent. Try again.' }; }
try { data = JSON.parse(xhr.responseText); } catch (e) { data = { error: 'That could not be sent. Try again.' }; }
- if (xhr.status >= 200 && xhr.status < 300) meters.forEach(function (meter, file) { meter.update({ state: 'done', done: file.size, total: file.size }); });
- else meters.forEach(function (meter) { meter.update({ state: 'failed', message: data.error || 'That could not be sent. Try again.' }); });
resolve({ ok: xhr.status >= 200 && xhr.status < 300, j: data });
resolve({ ok: xhr.status >= 200 && xhr.status < 300, j: data });
};
};
- xhr.onerror = function () { meters.forEach(function (meter) { meter.update({ state: 'failed', message: 'No connection. Try again.' }); }); reject(new Error('No connection')); };
+ xhr.onerror = function () { reject(new Error('No connection')); };
xhr.send(JSON.stringify(fields));
xhr.send(JSON.stringify(fields));
});
});
}
}
+ // What composer v2's submit calls: steer the open turn, or send a new message (files and all) and draw its turn.
+ function submit(v) {
+ problem(null);
+ if (!v.text) return false;
+ var w = working();
+ if (w) {
+ return steer(w, v.text).then(function () { return undefined; }, function () { return false; });
+ }
+ var fields = { task: v.text, mode: v.mode || 'balanced' };
+ var parent = host && host.getAttribute('data-parent');
+ if (parent) fields.parent = parent;
+ return Promise.all((v.files || []).map(filePayload))
+ .then(function (files) {
+ if (files.length) fields.files = files;
+ return sendChat(fields);
+ })
+ .then(function (res) {
+ if (!res.ok || !res.j.turn) {
+ problem(res.j.error || 'That could not be sent. Try again.');
+ if (note) note.textContent = '';
+ return false;
+ }
+ if (note) note.textContent = '';
+ uploaded.clear();
+ var thread = chat.querySelector('[data-ovt-thread]');
+ if (thread && !thread.querySelector(':scope > li')) {
+ var title = chat.querySelector('.ovt-title'); if (title) title.textContent = v.text.slice(0, 60);
+ try { history.pushState({}, '', res.j.url); } catch (err) { /* */ }
+ addChat(res.j.id, v.text);
+ }
+ var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;
+ setParent(res.j.id);
+ if (turn) follow(turn);
+ syncSend();
+ refreshNow();
+ return undefined;
+ })
+ .catch(function () {
+ problem('No connection. Your message is still in the box.');
+ if (note) note.textContent = '';
+ return false;
+ });
+ }
+
if (stopBtn) stopBtn.addEventListener('click', function () {
if (stopBtn) stopBtn.addEventListener('click', function () {
var w = working();
var w = working();
if (!w) return;
if (!w) return;
@@ -248,57 +327,26 @@
@@ -248,57 +327,26 @@
fetch('/tasks/' + encodeURIComponent(w.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
fetch('/tasks/' + encodeURIComponent(w.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' })
.catch(function () {}).then(function () { stopBtn.disabled = false; });
.catch(function () {}).then(function () { stopBtn.disabled = false; });
});
});
- // Steering: the words join the working turn's list at once, and the run hears them at its next step.
- function steer(w) {
- var said = text.value.trim();
+ // Steering: the words join the working card at once, and the run hears them at its next step.
+ function steer(w, said) {
var row = document.createElement('li'); row.className = 'ovt-w-you is-sending';
var row = document.createElement('li'); row.className = 'ovt-w-you is-sending';
row.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0"/></svg><p></p>';
row.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0"/></svg><p></p>';
row.querySelector('p').textContent = said;
row.querySelector('p').textContent = said;
var list = w.querySelector('[data-ovt-work]');
var list = w.querySelector('[data-ovt-work]');
if (list) { var fold = list.closest('details'); if (fold) fold.open = true; list.appendChild(row); }
if (list) { var fold = list.closest('details'); if (fold) fold.open = true; list.appendChild(row); }
- text.value = ''; grow(); syncSend();
- var body = new URLSearchParams({ task: said });
- fetch('/chat/' + encodeURIComponent(w.getAttribute('data-task')) + '/steer', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() })
+ return fetch('/chat/' + encodeURIComponent(w.getAttribute('data-task')) + '/steer', {
+ method: 'POST', credentials: 'same-origin',
+ headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ task: said }).toString(),
+ })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
.then(function (res) {
row.classList.remove('is-sending');
row.classList.remove('is-sending');
- if (!res.ok) { row.remove(); text.value = said; grow(); syncSend(); problem(res.j.error || 'Actor could not take that just now.'); }
+ if (!res.ok) { row.remove(); problem(res.j.error || 'Actor could not take that just now.'); throw new Error('steer'); }
})
})
- .catch(function () { row.remove(); text.value = said; grow(); syncSend(); problem('No connection. Your message is still in the box.'); });
+ .catch(function () { row.remove(); problem('No connection. Your message is still in the box.'); throw new Error('steer'); });
}
}
- if (form) form.addEventListener('submit', function (e) {
- if (!window.fetch || !text || !text.value.trim()) return;
- var w = working();
- if (w) { e.preventDefault(); problem(null); steer(w); return; }
- e.preventDefault();
- problem(null);
- send.disabled = true;
- var fields = {};
- new FormData(form).forEach(function (v, k) { if (typeof v === 'string') fields[k] = v; });
- (picked.length ? readAll(picked) : Promise.resolve([]))
- .then(function (files) {
- if (files.length) fields.files = files;
- return sendChat(fields);
- })
- .then(function (res) {
- if (!res.ok || !res.j.turn) { problem(res.j.error || 'That could not be sent. Try again.'); syncSend(); return; }
- var thread = chat.querySelector('[data-ovt-thread]');
- if (thread && !thread.querySelector(':scope > li')) {
- var title = chat.querySelector('.ovt-title'); if (title) title.textContent = text.value.trim().slice(0, 60);
- try { history.pushState({}, '', res.j.url); } catch (err) { /* */ }
- addChat(res.j.id, text.value.trim());
- }
- var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;
- text.value = ''; grow();
- clearPicked();
- setParent(res.j.id);
- if (turn) follow(turn);
- syncSend();
- })
- .catch(function () { problem('No connection. Your message is still in the box.'); syncSend(); });
- });
-
// A new chat joins the top of the list.
// A new chat joins the top of the list.
function addChat(id, title) {
function addChat(id, title) {
var list = chat.querySelector('.ovt-list');
var list = chat.querySelector('.ovt-list');
@@ -443,6 +491,9 @@
@@ -443,6 +491,9 @@
list.innerHTML = fresh.innerHTML;
list.innerHTML = fresh.innerHTML;
sending.forEach(function (r) { list.appendChild(r); });
sending.forEach(function (r) { list.appendChild(r); });
} else if (fresh) turn.querySelector('.ovt-reply').innerHTML = next.querySelector('.ovt-reply').innerHTML;
} else if (fresh) turn.querySelector('.ovt-reply').innerHTML = next.querySelector('.ovt-reply').innerHTML;
+ // The card's state (Starting → Working → Checking) updates in place too.
+ var stateNow = turn.querySelector('[data-ct-run-state]'), stateFresh = next.querySelector('[data-ct-run-state]');
+ if (stateNow && stateFresh && stateNow.outerHTML !== stateFresh.outerHTML) stateNow.replaceWith(stateFresh);
// The screen appears (or its live link changes) when the run opens a desk.
// The screen appears (or its live link changes) when the run opens a desk.
var scr = turn.querySelector('[data-screen]'), scr2 = next.querySelector('[data-screen]');
var scr = turn.querySelector('[data-screen]'), scr2 = next.querySelector('[data-screen]');
if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
@@ -459,6 +510,7 @@
@@ -459,6 +510,7 @@
var dot = chat.querySelector('.ovt-list a[aria-current] .ovt-dot'); if (dot) dot.remove();
var dot = chat.querySelector('.ovt-list a[aria-current] .ovt-dot'); if (dot) dot.remove();
if (note) note.textContent = '';
if (note) note.textContent = '';
syncSend();
syncSend();
+ refreshNow();
if (text && fine) text.focus();
if (text && fine) text.focus();
}
}
if (stay) toBottom(true);
if (stay) toBottom(true);
@@ -466,6 +518,27 @@
@@ -466,6 +518,27 @@
})
})
.catch(function () {});
.catch(function () {});
}
}
+ // The "Now" strip (the Console's Now card, in the chat): refreshed from GET /chat/now while something runs.
+ var nowTimer = 0;
+ function paintNow() {
+ return fetch('/chat/now', { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.text() : null; })
+ .then(function (h) {
+ if (h == null) return;
+ var box = chat.querySelector('[data-ct-now]');
+ if (!box) return;
+ var tmp = document.createElement('div'); tmp.innerHTML = h;
+ var fresh = tmp.firstElementChild;
+ if (fresh) box.replaceWith(fresh);
+ })
+ .catch(function () {});
+ }
+ function refreshNow() { if (nowTimer) return; nowTimer = setTimeout(function () { nowTimer = 0; paintNow(); }, 350); }
+ setInterval(function () {
+ if (document.hidden) return;
+ if (chat.querySelector('[data-ct-run]') || chat.querySelector('[data-ct-now]:not([hidden])')) paintNow();
+ }, 5000);
+
// What Actor sees: the last picture, fetched every two seconds while the turn works (only when it changed).
// What Actor sees: the last picture, fetched every two seconds while the turn works (only when it changed).
var screens = {};
var screens = {};
// The live desk (plan L55 C1b): when the turn works on one of the person's own desks, the server asks Bot for a
// The live desk (plan L55 C1b): when the turn works on one of the person's own desks, the server asks Bot for a
@@ -507,7 +580,9 @@
@@ -507,7 +580,9 @@
var framed = turn.querySelector('[data-screen][data-framed="yes"]');
var framed = turn.querySelector('[data-screen][data-framed="yes"]');
if (framed) return;
if (framed) return;
var fig = turn.querySelector('[data-screen]');
var fig = turn.querySelector('[data-screen]');
- if (!fig) return;
+ // Nothing to look at yet: the hidden placeholder is filled by a later turn refresh, and only then is there a
+ // picture to fetch (no request every two seconds for a run that never shows a screen).
+ if (!fig || fig.hidden) return;
var busyLoad = false;
var busyLoad = false;
screens[id] = setInterval(function () {
screens[id] = setInterval(function () {
if (busyLoad || document.hidden) return;
if (busyLoad || document.hidden) return;
@@ -563,66 +638,6 @@
@@ -563,66 +638,6 @@
}
}
chat.querySelectorAll('.ovt-turn[data-open]').forEach(function (t) { follow(t); });
chat.querySelectorAll('.ovt-turn[data-open]').forEach(function (t) { follow(t); });
- modelChip();
- }
-
- // ── The model chip (plan L55 C3): what this person's tasks run on. Opening it asks which of their own keys Actor
- // may use (names only, from OpenVibe.AI); a choice saves at once. Escape or a click elsewhere closes it.
- function modelChip() {
- var box = document.querySelector('[data-cc-model]');
- if (!box || box.dataset.wired) return;
- box.dataset.wired = '1';
- var label = box.querySelector('[data-ccm-label]');
- var fine = box.querySelector('[data-ccm-fine]');
- var ready = null;
- function mark() {
- box.querySelectorAll('[data-ccm-provider]').forEach(function (li) {
- var ok = !!(ready && ready.indexOf(li.getAttribute('data-ccm-provider')) !== -1);
- li.classList.toggle('is-ready', ok);
- var setup = li.querySelector('[data-ccm-setup]');
- if (setup) setup.hidden = ok;
- });
- }
- box.addEventListener('toggle', function () {
- if (!box.open) return;
- fetch('/api/v1/me/model', { credentials: 'same-origin', headers: { Accept: 'application/json' } })
- .then(function (r) { return r.ok ? r.json() : null; })
- .then(function (j) { if (j) { ready = Array.isArray(j.keys) ? j.keys : []; mark(); } })
- .catch(function () {});
- });
- box.addEventListener('click', function (e) {
- var b = e.target.closest('[data-ccm-choice]');
- if (!b) return;
- var choice = b.getAttribute('data-ccm-choice');
- b.disabled = true;
- fetch('/api/v1/me/model', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ choice: choice }) })
- .then(function (r) { return r.ok ? r.json() : r.json().then(function (j) { throw new Error((j && j.detail) || 'not saved'); }, function () { throw new Error('not saved'); }); })
- .then(function (j) {
- box.querySelectorAll('[data-ccm-choice]').forEach(function (x) {
- var on = x.getAttribute('data-ccm-choice') === choice;
- x.classList.toggle('is-on', on);
- x.setAttribute('aria-pressed', on ? 'true' : 'false');
- });
- var p = j && j.provider;
- var name = p ? (b.querySelector('b') || {}).textContent : '';
- if (label) label.textContent = p === 'node' ? 'Your computer' : p ? 'Your ' + name + ' key' : 'Auto';
- // Chosen but not allowed yet: say what is left to do instead of letting the next task fail.
- if (p && ready && ready.indexOf(p) === -1 && fine) {
- fine.textContent = '';
- fine.append(p === 'node' ? 'Your next task runs on your computer once you add its model and allow Actor: ' : 'Your next task runs on your ' + name + ' key once you allow Actor to use it: ');
- var a = document.createElement('a');
- a.href = 'https://ai.openvibe.services/you?allow=actor&provider=' + encodeURIComponent(p);
- a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'allow it at OpenVibe.AI';
- fine.append(a, '.');
- } else {
- box.open = false;
- }
- })
- .catch(function (err) { if (fine) fine.textContent = 'That did not save: ' + err.message; })
- .then(function () { b.disabled = false; });
- });
- document.addEventListener('click', function (e) { if (box.open && !box.contains(e.target)) box.open = false; });
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.open) { box.open = false; box.querySelector('summary').focus(); } });
}
}
function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }
function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
const express = require('express');
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
-const { html, raw } = require('../render/html');
+const { html, raw, esc } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
const store = require('../tasks/store');
const store = require('../tasks/store');
@@ -50,8 +50,11 @@ const TRIGGER_ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -50,8 +50,11 @@ const TRIGGER_ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;
const LAST_RUN = { ok: 'went fine', failed: 'failed', needs_you: 'needs you', running: 'is running' };
const LAST_RUN = { ok: 'went fine', failed: 'failed', needs_you: 'needs you', running: 'is running' };
const OPEN = ['queued', 'running', 'verifying'];
const OPEN = ['queued', 'running', 'verifying'];
const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
-const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };
const LIVE_TEXT = { queued: 'Starting', running: 'Working', verifying: 'Checking the answer' };
const LIVE_TEXT = { queued: 'Starting', running: 'Working', verifying: 'Checking the answer' };
+// The Now strip is what is running (a queued task is not yet; it has no steps).
+const NOW_STATES = ['running', 'verifying'];
+// Composer v2's mode chip: the same modes as the router, in the owner's words (11-12: "Fast/Balanced/Best").
+const MODE_CHOICES = [['fastest', 'Fast'], ['balanced', 'Balanced'], ['best', 'Best'], ['cheapest', 'Cheapest'], ['private', 'Private']];
const SIDEBAR_ROWS = 400; // the person's newest tasks the sidebar groups into chats
const SIDEBAR_ROWS = 400; // the person's newest tasks the sidebar groups into chats
const CHAIN_MAX = 60; // turns one conversation shows
const CHAIN_MAX = 60; // turns one conversation shows
const EVENTS_MAX = 600; // events per turn the work fold reads
const EVENTS_MAX = 600; // events per turn the work fold reads
@@ -134,13 +137,13 @@ function createChatPages(ctx, { computers = null } = {}) {
@@ -134,13 +137,13 @@ function createChatPages(ctx, { computers = null } = {}) {
* "New runs: Only me", with "Apply to all my existing runs" (design rule 1 and 2). The Share button on a run
* "New runs: Only me", with "Apply to all my existing runs" (design rule 1 and 2). The Share button on a run
* changes just that one; this is only what the next one starts as.
* changes just that one; this is only what the next one starts as.
*/
*/
- async function defaultsHtml(requester) {
+ async function defaultsHtml(requester, { from = '' } = {}) {
if (!sharing) return '';
if (!sharing) return '';
const current = await sharing.defaultOf(requester).catch(() => 'private');
const current = await sharing.defaultOf(requester).catch(() => 'private');
const options = ['private', 'people', 'link', 'public']
const options = ['private', 'people', 'link', 'public']
.map((a) => html`<option value="${a}"${a === current ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);
.map((a) => html`<option value="${a}"${a === current ? raw(' selected') : ''}>${AUDIENCE_TEXT[a]}</option>`);
return html`<form class="ct-share-defaults" method="post" action="/chat/defaults">
return html`<form class="ct-share-defaults" method="post" action="/chat/defaults">
-<label for="ct-share-default">New runs</label><select id="ct-share-default" name="audience">${options}</select>
+${from ? html`<input type="hidden" name="from" value="${from}">` : ''}<label for="ct-share-default">New runs</label><select id="ct-share-default" name="audience">${options}</select>
<label class="ct-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing runs</label>
<label class="ct-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing runs</label>
<button class="sc-btn" type="submit">Save</button>
<button class="sc-btn" type="submit">Save</button>
<span class="muted small">Only me unless you choose otherwise. Each run's Share button changes just that run.</span>
<span class="muted small">Only me unless you choose otherwise. Each run's Share button changes just that run.</span>
@@ -237,6 +240,59 @@ function createChatPages(ctx, { computers = null } = {}) {
@@ -237,6 +240,59 @@ function createChatPages(ctx, { computers = null } = {}) {
return raw(thread.work({ open, live: LIVE_TEXT[row.state] || 'Working', took: tookOf(row) || null, rows: workRows(lines) }));
return raw(thread.work({ open, live: LIVE_TEXT[row.state] || 'Working', took: tookOf(row) || null, rows: workRows(lines) }));
}
}
+ /**
+ * A turn that is running is a card in the conversation (one-agent system, stage 1: the owner, 11-12, "a more
+ * modular system… reused in a shared modular way"), not a separate console panel: its title, its state, the last
+ * steps it took and Stop. js/chat.js refreshes the card in place from GET /chat/turn/:id; the finished turn becomes
+ * the ordinary work fold and answer.
+ */
+ function runCard(row, events, readOnly = false) {
+ const rows = workRows(taskEvents.lines(events.slice(-EVENTS_MAX), { final: false, state: row.state })).slice(-5);
+ const last = [...rows].reverse().find((r) => r.kind === 'tool' || r.kind === 'step' || r.kind === 'note');
+ const live = !last ? `${LIVE_TEXT[row.state] || 'Working'}…`
+ : last.kind === 'tool' ? `Using ${last.name}${last.input ? `: ${clip(last.input, 90)}` : ''}…`
+ : clip(last.text, 120);
+ const item = (r) => {
+ if (r.kind === 'tool') {
+ const st = r.running ? 'is-run' : r.ok === false ? 'is-bad' : 'is-ok';
+ return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row"><b>${esc(r.name)}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${r.running ? '<span class="ovt-dot" aria-hidden="true"></span>' : ''}</span></div></li>`;
+ }
+ if (r.kind === 'you') return `<li class="ovt-w-you"><p>${esc(clip(r.text, 300))}</p></li>`;
+ if (r.kind === 'note') return `<li class="ovt-w-note"><p>${esc(clip(r.text, 300))}</p></li>`;
+ return `<li class="ovt-w-step"><span>${esc(clip(r.text, 200))}</span></li>`;
+ };
+ return html`<article class="ovt-card ct-run" data-ct-run="${row.id}">
+<p class="ovt-card-h"><span class="ct-run-title">${clip(row.task, 160)}</span><span class="ct-run-state" data-ct-run-state>${LIVE_TEXT[row.state] || 'Working'}…</span></p>
+<p class="ct-run-live" data-ovt-live>${live}</p>
+<ol class="ct-run-steps" data-ovt-work>${raw(rows.map(item).join(''))}</ol>
+${readOnly ? '' : html`<form class="ct-run-stop" method="post" action="/chat/${row.id}/cancel"><button class="ovt-card-btn is-quiet" type="submit">Stop</button></form>`}
+</article>`;
+ }
+
+ /**
+ * The "Now" strip at the top of the chat (the Console's "Now" card, folded in): one row for each task running for
+ * this person, with the last thing it did and Stop. Hidden when nothing runs; js/chat.js refreshes it from
+ * GET /chat/now while a turn is open.
+ */
+ function nowStrip(live) {
+ if (!live.length) return html`<section class="ct-now" data-ct-now hidden></section>`;
+ const stepOf = (row, event) => {
+ if (!event) return '';
+ const rows = workRows(taskEvents.lines([event], { final: false, state: row.state }));
+ const r = [...rows].reverse().find((x) => x.kind === 'tool' || x.kind === 'step' || x.kind === 'note');
+ if (!r) return '';
+ return r.kind === 'tool' ? `Using ${r.name}${r.input ? `: ${clip(r.input, 80)}` : ''}` : clip(r.text, 100);
+ };
+ return html`<section class="ct-now" data-ct-now aria-label="Now">
+<ul class="ct-now-list">${live.map(({ row, event }) => html`<li class="ct-now-item">
+<span class="ct-now-dot" aria-hidden="true"></span>
+<a class="ct-now-task" href="/chat/${row.id}"><b>${clip(row.task, 120)}</b></a>
+<span class="ct-now-step">${stepOf(row, event)}</span>
+<span class="ct-now-state">${LIVE_TEXT[row.state] || 'Working'}…</span>
+<form class="ct-now-stop" method="post" action="/chat/${row.id}/cancel"><button type="submit">Stop</button></form>
+</li>`)}</ul></section>`;
+ }
+
function answerBlock(row, t) {
function answerBlock(row, t) {
const { chipify, modelName, usd } = pageKit();
const { chipify, modelName, usd } = pageKit();
const result = t.result || {};
const result = t.result || {};
@@ -475,9 +531,10 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -475,9 +531,10 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t, readOnly)}`;
else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t, readOnly)}`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
else {
else {
- // A turn parked on a wait says what it waits for (plan A2); otherwise it is working.
+ // A running turn is a card in the conversation (one-agent system, stage 1). A turn parked on a wait still
+ // says what it waits for (plan A2).
const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
- reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;
+ reply = html`${runCard(row, evs, readOnly)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;
}
}
// A routine the turn made is its card, after whatever the turn said (plan §12 A-4).
// A routine the turn made is its card, after whatever the turn said (plan §12 A-4).
const routines = await triggerBlocks(row, evs, readOnly);
const routines = await triggerBlocks(row, evs, readOnly);
@@ -491,7 +548,7 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -491,7 +548,7 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
}
}
// ── The page ───────────────────────────────────────────────────────────────────────────────────────────────────
// ── The page ───────────────────────────────────────────────────────────────────────────────────────────────────
- function sidebar(chats, currentId) {
+ function sidebar(chats, currentId, { settings = false } = {}) {
return raw(thread.sidebar({
return raw(thread.sidebar({
newHref: '/chat',
newHref: '/chat',
groups: thread.group(chats, (c) => c.last, now()).map((g) => ({
groups: thread.group(chats, (c) => c.last, now()).map((g) => ({
@@ -499,8 +556,9 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -499,8 +556,9 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
items: g.items.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === currentId, live: c.open })),
items: g.items.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === currentId, live: c.open })),
})),
})),
findAction: '/chat',
findAction: '/chat',
- // Actor's places as the network sidebar's rows, as Codes' are (kit.sidebar, plan L35).
- nav: [{ href: '/console', label: 'Console', icon: 'grid' }, { href: '/me/agents', label: 'Your agents', icon: 'robot' }, { href: '/memory', label: 'Memory', icon: 'book' }],
+ // Actor's places as the network sidebar's rows, as Codes' are (kit.sidebar, plan L35). The Console is
+ // folded into this chat (one-agent system, stage 1), so Settings takes its row.
+ nav: [{ href: '/chat/settings', label: 'Settings', icon: 'sliders', here: settings }, { href: '/me/agents', label: 'Your agents', icon: 'robot' }, { href: '/memory', label: 'Memory', icon: 'book' }],
}));
}));
}
}
@@ -549,24 +607,23 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -549,24 +607,23 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
}
}
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
- function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null, allowance = null } = {}) {
- const tools = html`${modelChip(model)}
-<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>
-<span class="cc-note" data-cc-note></span>`;
- return raw(thread.composer({
- action: working ? `/chat/${working}/steer` : '/chat',
- name: 'task',
- maxlength: 20000,
- label: 'Message Actor',
- placeholder: working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something',
- value: text,
- busy,
- stopAction: working ? `/tasks/${working}/cancel` : '',
- hidden: { parent },
- top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<div data-cc-upload data-accept="${ATTACH_ACCEPT}"></div><p class="cc-note">Text, code, CSV, JSON, PDF or PNG, JPEG, WebP, GIF images · up to 4 files, 6 MB each, 12 MB total.</p>`),
- tools: String(tools),
- note: `Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}`,
- }));
+
+ /**
+ * The composer, on openvibe-shared's composer v2 (4.215.0) with the agent preset: Attach and Speak on the left, the
+ * mode chip and the round send on the right, the files above the box with their progress. js/chat.js mounts it; this
+ * server-rendered form is what a browser without script gets, and the host it is replaced in.
+ *
+ * The mode chip is the router's modes (11-12: "the current Fast/Balanced/Best modes"); the model is no longer a
+ * second control here, it is in Settings (/chat/model). The allowance is one quiet line under the box.
+ */
+ function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', allowance = null } = {}) {
+ const placeholder = working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something';
+ const fallback = html`<form class="ct-composer-fallback" method="post" action="${working ? `/chat/${working}/steer` : '/chat'}">
+${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}<textarea name="task" rows="2" maxlength="20000" required placeholder="${placeholder}" aria-label="Message Actor">${text}</textarea>
+<div class="ct-composer-fallback-row"><label class="ct-composer-mode">Mode <select name="mode">${MODE_CHOICES.map(([v, label]) => html`<option value="${v}"${v === mode ? raw(' selected') : ''}>${label}</option>`)}</select></label><button type="submit">Send</button></div>
+</form>`;
+ return raw(`<div class="ct-composer-wrap">${problem ? String(html`<p class="cc-problem" role="alert">${problem}</p>`) : ''}<div class="cc-composer" id="cc-composer" data-cc-composer data-action="${working ? `/chat/${working}/steer` : '/chat'}" data-parent="${parent || ''}" data-mode="${mode}" data-working="${working || ''}" data-max-files="4" data-max-mb="6" data-accept="${ATTACH_ACCEPT}"${text ? ` data-text="${esc(text)}"` : ''}${busy ? ' data-busy="1"' : ''}>${String(fallback)}</div>
+<p class="cc-allowance">Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}</p></div>`);
}
}
// What people really hand agents like this (the owner, 2026-11-01: "I hate the current examples… search what
// What people really hand agents like this (the owner, 2026-11-01: "I hate the current examples… search what
@@ -584,16 +641,24 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -584,16 +641,24 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],
['globe', 'Use a website', 'Open openvibe.live, find a channel that is live right now, and tell me what is happening on it.'],
];
];
+ /** The tasks running for this person, with their newest event (the "Now" strip's last step). */
+ async function liveFor(requester) {
+ const rows = await s.db.many('SELECT * FROM tasks WHERE requester = $1 AND state = ANY($2) ORDER BY id DESC LIMIT 20', [requester, NOW_STATES]);
+ const events = await Promise.all(rows.map((row) => store.lastEvent(s, row.id).catch(() => null)));
+ return rows.map((row, i) => ({ row, event: events[i] }));
+ }
+
/**
/**
* A run's page, or the same conversation read-only for somebody it was shared with: no sidebar, no composer and
* A run's page, or the same conversation read-only for somebody it was shared with: no sidebar, no composer and
* no turn that asks or retries. The Share button is its owner's; a reader gets Send it only.
* no turn that asks or retries. The Share button is its owner's; a reader gets Send it only.
*/
*/
async function render(req, res, { row = null, problem = null, text = '', status = 200, shared = false } = {}) {
async function render(req, res, { row = null, problem = null, text = '', status = 200, shared = false } = {}) {
const requester = requesterOf(req);
const requester = requesterOf(req);
- const [chats, turns, today] = await Promise.all([
+ const [chats, turns, today, live] = await Promise.all([
shared ? Promise.resolve([]) : chatsFor(requester),
shared ? Promise.resolve([]) : chatsFor(requester),
row ? chainOf(row) : [],
row ? chainOf(row) : [],
shared ? Promise.resolve({ usd: 0 }) : store.spendToday(s, requester),
shared ? Promise.resolve({ usd: 0 }) : store.spendToday(s, requester),
+ shared ? Promise.resolve([]) : liveFor(requester),
]);
]);
const first = turns[0] || null;
const first = turns[0] || null;
const lastTurn = turns[turns.length - 1] || null;
const lastTurn = turns[turns.length - 1] || null;
@@ -601,28 +666,27 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -601,28 +666,27 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
const fileMap = await inputs.list(s, turns.map((t) => t.id));
const fileMap = await inputs.list(s, turns.map((t) => t.id));
const canvasRow = row && !shared && first ? await canvases.forThread(s, first.id) : null;
const canvasRow = row && !shared && first ? await canvases.forThread(s, first.id) : null;
const canvasDoc = canvasRow ? canvases.docOf(canvasRow) : null;
const canvasDoc = canvasRow ? canvases.docOf(canvasRow) : null;
- const model = shared ? null : modelPrefs.wire(await modelPrefs.get(s, requester).catch(() => null));
const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [], readOnly: shared })));
const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t, { files: fileMap.get(t.id) || [], readOnly: shared })));
const title = first ? clip(first.task, 60) : 'New chat';
const title = first ? clip(first.task, 60) : 'New chat';
- const actions = row ? String(shareButton(row, !shared)) : '';
- // Our own markup (its values escaped by html``): drawn with raw(), never printed as text (11-08: it was).
- const defaults = !shared && !row ? String(await defaultsHtml(requester)) : '';
+ // The run settings are in Settings now (one-agent system, stage 1); the chat only carries the model in its head.
+ const actions = `${row ? String(shareButton(row, !shared)) : ''}${shared ? '' : `<a class="ovt-btn" href="/chat/settings" title="Settings" aria-label="Settings">${glyphs.svg('gear', { size: 18 })}</a>`}`;
return send(res, status, {
return send(res, status, {
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
- // The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).
+ // The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js); composer
+ // v2 draws the box (ov-composer.js, 4.215.0).
css: ['css/chat.css', 'css/skill-draft.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css', 'ov-upload.css'],
css: ['css/chat.css', 'css/skill-draft.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css', 'ov-upload.css'],
- sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js', 'ov-canvas.js', 'ov-upload.js'],
+ sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-composer.js', 'ov-share.js', 'ov-canvas.js', 'ov-upload.js'],
body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
- head: thread.head({ title, newHref: shared ? null : (first ? '/chat' : null), actions }),
+ head: String(thread.head({ title, newHref: shared ? null : (first ? '/chat' : null), actions })) + (shared ? '' : String(nowStrip(live))),
turns: turnHtmls.map(String),
turns: turnHtmls.map(String),
empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
-<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),
- composer: shared ? '' : String(html`${canvasDoc ? canvasPhone(canvasDoc) : ''}${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model, allowance: leftLine(config.allowance, today) })}`),
+<ul class="ct-suggest">${STARTERS.slice(0, 4).map(([icon, k, v]) => html`<li><button type="button" class="ct-suggest-chip" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 16 }))}<span>${k}</span></button></li>`)}</ul></div>`),
+ composer: shared ? '' : String(html`${canvasDoc ? canvasPhone(canvasDoc) : ''}${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, allowance: leftLine(config.allowance, today) })}`),
panel: canvasDoc ? String(canvasWide(canvasDoc)) : '',
panel: canvasDoc ? String(canvasWide(canvasDoc)) : '',
label: first ? 'Conversation' : 'New chat',
label: first ? 'Conversation' : 'New chat',
- }))}${raw(defaults)}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,
+ }))}${shared ? html`<p class="ct-shared-note">This run was shared with you. Only its owner can carry it on.</p>` : ''}</div>`,
});
});
}
}
@@ -637,7 +701,50 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -637,7 +701,50 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
await sharing.setDefault(requesterOf(req), audience, { applyAll: String(body.apply_all || '') === '1' })
await sharing.setDefault(requesterOf(req), audience, { applyAll: String(body.apply_all || '') === '1' })
.catch((e) => ctx.log.warn('[Actor] the run default was not saved:', e.message));
.catch((e) => ctx.log.warn('[Actor] the run default was not saved:', e.message));
- return res.redirect(303, '/chat');
+ return res.redirect(303, body.from === 'settings' ? '/chat/settings?saved=1' : '/chat');
+ });
+
+ /**
+ * Actor's Settings (one-agent system, stage 1): the run defaults moved off the chat ("New runs: Only me … Apply to
+ * all my existing runs"), the model and where it runs, and what has been spent. The same route (/chat/defaults)
+ * and the same behaviour; it is simply a page of its own now, reached from the chat's head and its sidebar.
+ */
+ r.get('/chat/settings', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, loginTo(req));
+ const requester = requesterOf(req);
+ const chats = await chatsFor(requester);
+ const current = sharing ? await sharing.defaultOf(requester).catch(() => 'private') : 'private';
+ const saved = req.query.saved === '1';
+ const body = html`<div class="ct-settings">
+<h1>Settings</h1>
+<p class="ct-settings-lede">How your runs start and what they run on. Every run's own Share button still changes just that run.</p>
+${saved ? html`<p class="cm-ok" role="status">Saved. Your next run starts as ${AUDIENCE_TEXT[current] || 'Only me'}.</p>` : ''}
+<section class="ct-settings-card" aria-labelledby="ct-set-runs"><h2 id="ct-set-runs">Your runs</h2>
+<p>Who can see the runs you start from now on. "Apply to all my existing runs" moves the ones you already have.</p>
+${raw(await defaultsHtml(requester, { from: 'settings' }))}
+<p class="ct-settings-foot"><a href="/me/spend">What you have spent</a> · <a href="/tasks">Your tasks</a></p></section>
+<section class="ct-settings-card" aria-labelledby="ct-set-model"><h2 id="ct-set-model">The model your tasks run on</h2>
+<p>OpenVibe's own keys, your own key for a provider, or a model you run on your own computer. The model and where it runs are one page.</p>
+<p><a class="sc-btn" href="/chat/model">Open the model settings</a></p></section>
+</div>`;
+ return send(res, 200, {
+ viewer: req.viewer, config, path: req.originalUrl, title: 'Settings', app: true, noReferrer: true,
+ css: ['css/chat.css'], scripts: [], styles: ['kit.css', 'thread.css'],
+ body: raw(thread.page({
+ sidebar: String(sidebar(chats, null, { settings: true })),
+ head: thread.head({ title: 'Settings', newHref: '/chat' }),
+ main: String(body),
+ label: 'Settings',
+ })),
+ });
+ });
+
+ // The "Now" strip, refreshed in place by js/chat.js while a turn is open. Its owner only; empty when nothing runs.
+ r.get('/chat/now', async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ if (!signedIn(req)) return res.status(401).type('text/html').send('');
+ return res.type('text/html').send(String(nowStrip(await liveFor(requesterOf(req)))));
});
});
r.get('/chat/model', async (req, res) => {
r.get('/chat/model', async (req, res) => {
@@ -764,6 +871,20 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -764,6 +871,20 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
return render(req, res, { row });
return render(req, res, { row });
});
});
+ // Stop a running turn from its card in the conversation (or the Now strip), and come back to the chat. The task
+ // page keeps its own /tasks/:id/cancel; this is the same cancel with the chat as where you were.
+ r.post('/chat/:id/cancel', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const wantsJson = String(req.get('accept') || '').includes('application/json');
+ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A cancel must come from openvibe.actor itself.');
+ const row = await store.getTask(s, req.params.id);
+ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');
+ if (OPEN.includes(row.state)) await engine.cancel(row.id, requesterOf(req));
+ if (wantsJson) return res.json({ ok: true });
+ return res.redirect(303, `/chat/${row.id}`);
+ });
+
// Words for the turn that is working (the composer while Actor works): the run hears them at its next step.
// Words for the turn that is working (the composer while Actor works): the run hears them at its next step.
r.post('/chat/:id/steer', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
r.post('/chat/:id/steer', express.urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
@@ -1,63 +1,33 @@
@@ -1,63 +1,33 @@
'use strict';
'use strict';
/**
/**
- * The Console (plan T17 layer 3): one page where a signed-in person sees what their agents, tasks, computers and robots
- * are doing, and tells them what to do next. It is the command interface, not an authority: tasks, agents and approvals
- * are Actor's own; computers and robots are read from OpenVibe.Bot as the person (agents/computers.js, agents/robots.js),
- * and everything else links to the service that owns it (OpenVibe.Services for resources, Watch, Codes).
+ * The old Console page, folded into the chat (one-agent system, stage 1: the owner, 11-12, "having this thing on the
+ * console doesn't make any sense … you should just chat with it"). /console now answers 301 to the chat: what runs now
+ * is the "Now" strip at the top of /chat, and the command box is the chat's composer. Its "Ask Actor" prefill (?ask= and
+ * ?from=, put on it by the OpenVibe Frame) is carried into the chat's composer, so a link from any site still lands
+ * with the words in the box and nothing running.
*
*
- * It works without JavaScript: the command box is the ordinary task form (POST /tasks), and while a task is running the
- * page refreshes itself every 10 seconds (a <noscript> refresh; public/js/console.js does it with JavaScript, never
- * while the person is typing a task).
+ * /me/spend stays here: it is the console's one page that is not a conversation, and it is still linked from the chat's
+ * Settings and the home page.
*/
*/
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
-const { html, raw, time } = require('../render/html');
-const kit = require('openvibe-shared/kit');
+const { html } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const store = require('../tasks/store');
const store = require('../tasks/store');
-const { leftLine } = require('../tasks/allowance');
-const personal = require('../agents/personal');
-const approvals = require('../agents/approvals');
-const catalog = require('../agents/catalog');
-const { MODES } = require('../agents/router');
-const { createRobots } = require('../agents/robots');
-const { createComputers } = require('../agents/computers');
-const feed = require('../agents/feed');
-
-const LIVE = new Set(['queued', 'running', 'verifying']);
-const STATE_TEXT = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };
-const LEVEL_TEXT = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
-const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
-const BOT_TIMEOUT_MS = 4000;
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
-const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
-const pct = (a, b) => (b > 0 ? Math.max(0, Math.min(100, Math.round((a / b) * 100))) : 0);
-function createConsolePages(ctx, { robots = null, computers = null } = {}) {
+function createConsolePages(ctx) {
const { config, s } = ctx;
const { config, s } = ctx;
const r = asyncRouter();
const r = asyncRouter();
- const bot = { robots: robots || createRobots({ config }), computers: computers || createComputers({ config }) };
const tier = config.allowance;
const tier = config.allowance;
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, styles: ['kit.css'], css: ['css/console.css'], ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, styles: ['kit.css'], css: ['css/console.css'], ...o });
- const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
- const pages = config.services.botPublicUrl || 'https://openvibe.bot';
- const linkPages = config.services.linkPublicUrl || 'https://openvibe.link';
-
- /** One Bot read, bounded so a slow Bot never holds the page; { ok, value } or { ok: false, why }. */
- async function fromBot(fn) {
- try {
- const value = await fn({ signal: AbortSignal.timeout(BOT_TIMEOUT_MS) });
- if (value && !Array.isArray(value) && value.error) return { ok: false, why: value.error };
- return { ok: true, value };
- } catch { return { ok: false, why: 'OpenVibe.Bot did not answer in time.' }; }
- }
/**
/**
- * What the command box starts with: ?ask= (the OpenVibe Frame's "Ask Actor", any site: the text the person had
- * selected), at most 2000 characters, and ?from= (the page they were on: an https address on an OpenVibe site)
- * as a line after it. Only ever text in the box: nothing runs until they press Run it.
+ * What the old command box started with: ?ask= (the OpenVibe Frame's "Ask Actor", any site: the text the person had
+ * selected), at most 2000 characters, and ?from= (the page they were on: an https address on an OpenVibe site) as a
+ * line after it. Only ever text in the composer: nothing runs until they send it.
*/
*/
function prefill(req) {
function prefill(req) {
const ask = typeof req.query.ask === 'string' ? req.query.ask.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').trim().slice(0, 2000) : '';
const ask = typeof req.query.ask === 'string' ? req.query.ask.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').trim().slice(0, 2000) : '';
@@ -72,24 +42,12 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
@@ -72,24 +42,12 @@ function createConsolePages(ctx, { robots = null, computers = null } = {}) {
return from ? `${ask}${ask ? '\n\n' : ''}(On this page: ${from})` : ask;
return from ? `${ask}${ask ? '\n\n' : ''}(On this page: ${from})` : ask;
}
}
- function commandBox(start = '', today = null) {
- return html`<form class="cn-command" method="post" action="/tasks" aria-labelledby="cn-command-h">
-<h2 id="cn-command-h" class="cn-sr">Give Actor a task</h2>
-<label class="cn-sr" for="cn-task">What should Actor do?</label>
-${today ? html`<p class="cn-note" data-allowance>${leftLine(tier, today)}</p>` : ''}
-<textarea id="cn-task" name="task" rows="${start ? '4' : '2'}" maxlength="20000" required${start ? raw(' autofocus') : ''} placeholder="Tell Actor what to do: “check my site's certificate”, “what changed in my repo today?”, “drive my rover to the door”">${start}</textarea>
-<div class="cn-command-row"><label class="cn-mode">Mode <select name="mode">${MODES.map((m) => html`<option value="${m}"${m === 'balanced' ? raw(' selected') : ''}>${MODE_LABEL[m]}</option>`)}</select></label>
-<button class="cn-run" type="submit">Run it</button></div>
-</form>`;
- }
-
- const taskRow = (t) => {
- const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;
- const agent = result ? ((catalog.byId(result.agent) || {}).name || result.agent) : null;
- return html`<li class="cn-task"><span class="pill pill-${t.state}">${STATE_TEXT[t.state] || t.state}</span>
-<a href="/tasks/${t.id}">${clip(t.task, 110)}</a>
-<span class="cn-meta">${agent ? html`${agent} · ` : ''}${t.cost_usd == null ? '' : html`${usd(Number(t.cost_usd))} · `}${time(t.created_at)}</span></li>`;
- };
+ // 301, not a page: the Console is gone and every link, bookmark and the Frame's "Ask Actor" land in the chat.
+ r.get('/console', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const ask = prefill(req);
+ return res.redirect(301, `/chat${ask ? `?ask=${encodeURIComponent(ask)}` : ''}`);
+ });
/**
/**
* Days of spend as bars (a table for screen readers), tallest day = full height. A week labels each day; a month
* Days of spend as bars (a table for screen readers), tallest day = full height. A week labels each day; a month
@@ -104,142 +62,28 @@ ${today ? html`<p class="cn-note" data-allowance>${leftLine(tier, today)}</p>` :
@@ -104,142 +62,28 @@ ${today ? html`<p class="cn-note" data-allowance>${leftLine(tier, today)}</p>` :
: date(d).toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' }).slice(0, 2));
: date(d).toLocaleDateString('en', { weekday: 'short', timeZone: 'UTC' }).slice(0, 2));
const name = (d) => date(d).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
const name = (d) => date(d).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
return html`<figure class="cn-week${month ? ' cn-month' : ''}"><figcaption>${caption}: <b>${usd(total)}</b>, ${String(days.reduce((n, d) => n + d.tasks, 0))} tasks</figcaption>
return html`<figure class="cn-week${month ? ' cn-month' : ''}"><figcaption>${caption}: <b>${usd(total)}</b>, ${String(days.reduce((n, d) => n + d.tasks, 0))} tasks</figcaption>
-<div class="cn-bars" aria-hidden="true"${month ? raw(` style="grid-template-columns:repeat(${days.length},1fr)"`) : ''}>${days.map((d, i) => html`<span class="cn-bar" title="${name(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d, i)}</small></span>`)}</div>
+<div class="cn-bars" aria-hidden="true"${month ? html` style="grid-template-columns:repeat(${days.length},1fr)"` : ''}>${days.map((d, i) => html`<span class="cn-bar" title="${name(d)}: ${usd(d.usd)}, ${String(d.tasks)} tasks"><i style="height:${String(top > 0 ? Math.max(4, Math.round((d.usd / top) * 100)) : 4)}%"></i><small>${label(d, i)}</small></span>`)}</div>
<table class="cn-sr"><caption>Spend by day</caption><tbody>${days.map((d) => html`<tr><th scope="row">${d.day}</th><td>${usd(d.usd)}</td><td>${String(d.tasks)} tasks</td></tr>`)}</tbody></table></figure>`;
<table class="cn-sr"><caption>Spend by day</caption><tbody>${days.map((d) => html`<tr><th scope="row">${d.day}</th><td>${usd(d.usd)}</td><td>${String(d.tasks)} tasks</td></tr>`)}</tbody></table></figure>`;
}
}
- /** One line of the events feed. */
- const eventRow = (e) => html`<li class="cn-event cn-event-${e.kind}"><span class="cn-ev-dot cn-ev-${e.state}" aria-hidden="true"></span>
-<a href="${e.href}">${clip(e.text, 120)}</a>
-<span class="cn-meta">${e.by ? html`${e.by} · ` : ''}${e.cost != null ? html`${usd(e.cost)} · ` : ''}${time(e.at)}</span></li>`;
-
- function computersCard(result) {
- const body = !bot.computers.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
- : !result.ok ? html`<p class="cn-empty">${result.why}</p>`
- : !result.value.computers.length ? raw(kit.empty({ icon: 'server', title: 'No computer is open to your agent', text: 'Add one on openvibe.link/machines with --computer ask, then turn on “Let my agent use this computer”.', action: { href: `${linkPages}/machines`, label: 'Add a computer', icon: 'plus' } }))
- : html`<ul class="cn-list">${result.value.computers.map((c) => html`<li><span class="cn-dot${c.online ? ' on' : ''}" aria-hidden="true"></span><b>${c.name || c.id}</b>
-<span class="cn-meta">${c.online ? 'Online' : 'Offline'} · ${LEVEL_TEXT[c.level] || 'On'}${c.can.length ? html` · ${c.can.join(', ')}` : ''}</span>
-${c.waiting_for_approval ? html`<span class="pill pill-running">${String(c.waiting_for_approval)} waiting on the machine</span>` : ''}
-${c.online && c.can.includes('screen') ? html`<a class="cn-link" href="${linkPages}/machines/${encodeURIComponent(c.id)}/screen">Watch the screen</a>` : ''}</li>`)}</ul>`;
- return html`<section class="cn-card" aria-labelledby="cn-computers"><h2 id="cn-computers">Your computers</h2>${body}
-<p class="cn-more"><a href="${linkPages}/machines">Machines on OpenVibe Link</a></p></section>`;
- }
-
- function robotsCard(result) {
- const body = !bot.robots.enabled ? html`<p class="cn-empty">Not connected to OpenVibe.Bot on this server.</p>`
- : !result.ok ? html`<p class="cn-empty">${result.why}</p>`
- : !result.value.length ? raw(kit.empty({ icon: 'robot', title: 'No robot yet', text: 'Try one in your browser, or add your own on openvibe.bot.', action: { href: `${pages}/try`, label: 'Try one in your browser', icon: 'play' } }))
- : html`<ul class="cn-list">${result.value.map((x) => html`<li><span class="cn-dot${x.online ? ' on' : ''}" aria-hidden="true"></span><a href="${x.panel}"><b>${x.name}</b></a>
-<span class="cn-meta">${x.agent_control ? (x.online ? 'Online' : 'Offline') : 'Not open to your agent'}${x.estop && x.estop.latched ? ' · e-stop on' : ''}${typeof x.battery === 'number' ? ` · battery ${Math.round(x.battery * 100)}%` : ''}</span></li>`)}</ul>`;
- return html`<section class="cn-card" aria-labelledby="cn-robots"><h2 id="cn-robots">Your robots</h2>${body}
-<p class="cn-more"><a href="${pages}/robots">Robots on OpenVibe.Bot</a></p></section>`;
- }
-
- /**
- * Signed out, the console's own cards say what each will hold, so the page shows what it is instead of a sign-in
- * button over empty space (11-03, the phone review). No numbers and no sample rows: nothing that could read as
- * someone's data. Each card links to the public page there is for it.
- */
- function previewGrid() {
- const cards = [
- ['Now', 'Every task your agents are running: what each has done so far and what it cost. The page refreshes while something runs.', null, true],
- ['Waiting for you', 'What an agent asks before it acts. Approve or decline here, in Link\'s tray, or from a notification.', null, false],
- ['Today', `How many of your ${String(tier.tasksPerDay)} free tasks a day you have used and what they cost, with the last 7 days.`, null, false],
- ['Your agents', 'Tasks you set up once, with a memory and a schedule.', ['/agents', 'See the agents'], false],
- ['Your computers', 'Your own machines running jobs for your agents.', [`${linkPages}/machines`, 'Machines on OpenVibe Link'], false],
- ['Your robots', 'Robots your agents may drive, with their battery and e-stop.', [`${pages}/robots`, 'Robots on OpenVibe.Bot'], false],
- ];
- return html`<div class="cn-grid cn-preview" aria-label="What the console shows">${cards.map(([title, what, link, wide], i) => html`<section class="cn-card${wide ? ' cn-wide' : ''}" aria-labelledby="cn-p${String(i)}"><h2 id="cn-p${String(i)}">${title}</h2>
-<p class="cn-empty">${what}</p>${link ? html`<p class="cn-more"><a href="${link[0]}">${link[1]}</a></p>` : ''}</section>`)}</div>`;
- }
-
- r.get('/console', async (req, res) => {
- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- if (!signedIn(req)) {
- return page(req, res, {
- title: 'Console', crumbs: [{ label: 'Console' }],
- body: html`<header class="cn-head"><h1>Console</h1>
-<p class="cn-lede">One place for everything your agents do: the tasks running now, what waits for your approval, your agents, your own computers and robots, and what it all cost today.</p></header>
-<p><a class="cn-run" href="/auth/login?next=${encodeURIComponent(req.originalUrl.startsWith('/console') && req.originalUrl.length <= 500 ? req.originalUrl : '/console')}">Sign in to open your console</a></p>
-${previewGrid()}`,
- });
- }
- const requester = `user:${req.viewer.subject}`;
- const subject = req.viewer.subject;
- const [tasks, today, week, asked, agents, pcs, rovers] = await Promise.all([
- store.listTasks(s, requester, { limit: 30 }),
- store.spendToday(s, requester),
- store.spendDays(s, requester, 7),
- approvals.list(s, requester),
- personal.list(s, requester),
- bot.computers.enabled ? fromBot((o) => bot.computers.mine(subject, o)) : { ok: false },
- bot.robots.enabled ? fromBot((o) => bot.robots.mine(subject, o)) : { ok: false },
- ]);
- const live = tasks.rows.filter((t) => LIVE.has(t.state));
- const events = await feed.lately(s, requester, { approvals: asked, agents, limit: 8 });
- const waiting = asked.filter((a) => a.state === 'pending');
- const onMachines = pcs.ok ? pcs.value.computers.reduce((n, c) => n + (c.waiting_for_approval || 0), 0) : 0;
- const nWaiting = waiting.length + onMachines;
- page(req, res, {
- title: 'Console', crumbs: [{ label: 'Console' }],
- scripts: live.length ? ['js/console.js'] : [],
- body: html`${live.length ? raw('<noscript><meta http-equiv="refresh" content="10"></noscript>') : ''}<header class="cn-head"${live.length ? raw(' data-live="10"') : ''}><h1>Console</h1>
-<p class="cn-lede">What your agents, computers and robots are doing, and one box to tell them what to do next.</p></header>
-${commandBox(prefill(req), today)}
-<div class="cn-grid">
-<section class="cn-card cn-wide" aria-labelledby="cn-now"><h2 id="cn-now">Now${live.length ? html` <span class="cn-count">${String(live.length)}</span>` : ''}</h2>
-${live.length ? html`<ul class="cn-tasks">${live.map(taskRow)}</ul><p class="cn-note">This page refreshes every 10 seconds while something runs.</p>` : raw(kit.empty({ icon: 'play', title: 'Nothing is running', text: 'A task you give above appears here while it works.', action: { href: '#cn-task', label: 'Give Actor a task', icon: 'plus' } }))}</section>
-<section class="cn-card${nWaiting ? ' cn-attn' : ''}" aria-labelledby="cn-waiting"><h2 id="cn-waiting">Waiting for you${nWaiting ? html` <span class="cn-count">${String(nWaiting)}</span>` : ''}</h2>
-${waiting.length ? html`<ul class="cn-list">${waiting.slice(0, 4).map((a) => html`<li><a href="/me/approvals/${a.id}"><b>${clip(a.summary, 80)}</b></a><span class="cn-meta">Asked ${time(a.created_at)}</span></li>`)}</ul>` : ''}
-${onMachines ? html`<p class="cn-note">${String(onMachines)} on your computers: answer them there, in the Link's tray, with a notification's Approve button, or with <code>openvibe-link approvals</code>.</p>` : ''}
-${nWaiting ? '' : raw(kit.empty({ icon: 'check', title: 'Nothing waits for you', text: 'An agent that needs your answer asks here first.' }))}
-<p class="cn-more"><a href="/me/approvals">All approvals</a></p></section>
-<section class="cn-card" aria-labelledby="cn-today"><h2 id="cn-today">Today</h2>
-<p class="cn-stat"><b>${String(today.tasks)}</b> of ${String(tier.tasksPerDay)} free tasks</p>
-<div class="cn-meter" role="progressbar" aria-label="Free tasks used today" aria-valuemin="0" aria-valuemax="${String(tier.tasksPerDay)}" aria-valuenow="${String(today.tasks)}"><span style="width:${String(pct(today.tasks, tier.tasksPerDay))}%"></span></div>
-<p class="cn-stat"><b>${usd(today.usd)}</b> of ${usd(tier.perDayUsd)} · ${leftLine(tier, today)}</p>
-<div class="cn-meter" role="progressbar" aria-label="Spent today" aria-valuemin="0" aria-valuemax="${String(tier.perDayUsd)}" aria-valuenow="${String(today.usd)}"><span style="width:${String(pct(today.usd, tier.perDayUsd))}%"></span></div>
-${spendChart(week, { caption: 'Last 7 days' })}
-<p class="cn-more"><a href="/me/spend">Last 30 days</a> · <a href="/tasks">Your tasks</a></p></section>
-<section class="cn-card cn-wide" aria-labelledby="cn-lately"><h2 id="cn-lately">Lately</h2>
-${events.length ? html`<ul class="cn-events">${events.map(eventRow)}</ul>` : raw(kit.empty({ icon: 'list', title: 'Nothing has happened yet', text: 'Your tasks and approvals appear here as they happen.', action: { href: '#cn-task', label: 'Give Actor a task', icon: 'plus' } }))}
-<p class="cn-more"><a href="/tasks">All your tasks</a> · <a href="/me/approvals">All approvals</a></p></section>
-<section class="cn-card" aria-labelledby="cn-agents"><h2 id="cn-agents">Your agents</h2>
-${agents.length ? html`<ul class="cn-list">${agents.slice(0, 5).map((a) => html`<li><span class="cn-dot${a.status === 'paused' ? '' : ' on'}" aria-hidden="true"></span><a href="/me/agents/${a.id}"><b>${a.name}</b></a>
-<span class="cn-meta">${a.status === 'paused' ? 'Paused' : a.schedule === 'off' ? 'Runs when you run it' : `Every ${a.schedule === 'hourly' ? 'hour' : a.schedule === 'daily' ? 'day' : 'week'}`}${a.last_run_at ? html` · last ran ${time(a.last_run_at)}` : ''}</span></li>`)}</ul>`
- : raw(kit.empty({ icon: 'robot', title: 'No agent yet', text: 'An agent is a task you set up once, with a memory and a schedule.', action: { href: '/me/agents', label: 'Create an agent', icon: 'plus' } }))}
-<p class="cn-more"><a href="/me/agents">${agents.length ? 'All your agents' : 'Create an agent'}</a></p></section>
-${computersCard(pcs)}
-${robotsCard(rovers)}
-<section class="cn-card cn-full" aria-labelledby="cn-elsewhere"><h2 id="cn-elsewhere">Across OpenVibe</h2>
-<ul class="cn-links">
-<li><a href="https://openvibe.services/projects">Your projects and resources</a><span class="cn-meta">OpenVibe.Services: stop, delete, suspend or resume what your projects hold</span></li>
-<li><a href="https://openvibe.services/projects/mine">What you own yourself</a><span class="cn-meta">OpenVibe.Services: stop or resume a robot, archive a chat room, suspend a stream, delete a paste</span></li>
-<li><a href="https://openvibe.watch">Your watches</a><span class="cn-meta">OpenVibe.Watch: wake an agent when something changes</span></li>
-<li><a href="https://openvibe.codes/run">Coding on your machine</a><span class="cn-meta">OpenVibe.Codes: tasks on your own computer</span></li>
-</ul></section>
-</div>`,
- });
- });
-
// ── Costs over time: the last 30 days (what Actor keeps) ──
// ── Costs over time: the last 30 days (what Actor keeps) ──
r.get('/me/spend', async (req, res) => {
r.get('/me/spend', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Fme%2Fspend');
+ if (!req.viewer || req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(302, '/auth/login?next=%2Fme%2Fspend');
const requester = `user:${req.viewer.subject}`;
const requester = `user:${req.viewer.subject}`;
const days = await store.spendDays(s, requester, store.RETENTION_DAYS);
const days = await store.spendDays(s, requester, store.RETENTION_DAYS);
const used = days.filter((d) => d.tasks > 0 || d.usd > 0);
const used = days.filter((d) => d.tasks > 0 || d.usd > 0);
const total = days.reduce((n, d) => n + d.usd, 0);
const total = days.reduce((n, d) => n + d.usd, 0);
const busiest = used.reduce((b, d) => (!b || d.usd > b.usd ? d : b), null);
const busiest = used.reduce((b, d) => (!b || d.usd > b.usd ? d : b), null);
page(req, res, {
page(req, res, {
- title: 'Spend', crumbs: [{ label: 'Console', href: '/console' }, { label: 'Spend' }],
+ title: 'Spend', crumbs: [{ label: 'Chat', href: '/chat' }, { label: 'Spend' }],
body: html`<header class="cn-head"><h1>Spend</h1>
body: html`<header class="cn-head"><h1>Spend</h1>
<p class="cn-lede">What your tasks and agents cost, day by day, over the last ${String(store.RETENTION_DAYS)} days (Actor keeps no more).</p></header>
<p class="cn-lede">What your tasks and agents cost, day by day, over the last ${String(store.RETENTION_DAYS)} days (Actor keeps no more).</p></header>
<section class="cn-card" aria-labelledby="cn-spend-h"><h2 id="cn-spend-h">Last ${String(store.RETENTION_DAYS)} days</h2>
<section class="cn-card" aria-labelledby="cn-spend-h"><h2 id="cn-spend-h">Last ${String(store.RETENTION_DAYS)} days</h2>
-${spendChart(days, { caption: `Last ${store.RETENTION_DAYS} days`, month: true })}
+${spendChart(days, { caption: `Last ${String(store.RETENTION_DAYS)} days`, month: true })}
<p class="cn-stat">${used.length ? html`<b>${usd(total / used.length)}</b> on an average day with tasks · busiest ${busiest.day}, <b>${usd(busiest.usd)}</b>` : 'Nothing has run in this time.'}</p>
<p class="cn-stat">${used.length ? html`<b>${usd(total / used.length)}</b> on an average day with tasks · busiest ${busiest.day}, <b>${usd(busiest.usd)}</b>` : 'Nothing has run in this time.'}</p>
<p class="cn-note">Today's limit is ${usd(tier.perDayUsd)} and ${String(tier.tasksPerDay)} tasks on the free tier.</p>
<p class="cn-note">Today's limit is ${usd(tier.perDayUsd)} and ${String(tier.tasksPerDay)} tasks on the free tier.</p>
-<p class="cn-more"><a href="/console">Back to the console</a> · <a href="/tasks">Your tasks</a></p></section>`,
+<p class="cn-more"><a href="/chat">Back to the chat</a> · <a href="/tasks">Your tasks</a></p></section>`,
});
});
});
});
@@ -198,7 +198,7 @@ ${diaries.length ? html`<section class="mem-diaries" aria-labelledby="h-diary"><
@@ -198,7 +198,7 @@ ${diaries.length ? html`<section class="mem-diaries" aria-labelledby="h-diary"><
<ul class="mem-list">${diaries.map((d) => diaryEntry(d, nameOf(d.agent_id), '/memory'))}</ul></section>` : ''}
<ul class="mem-list">${diaries.map((d) => diaryEntry(d, nameOf(d.agent_id), '/memory'))}</ul></section>` : ''}
${changes.length ? html`<section class="mem-changes" aria-labelledby="h-changes"><h2 id="h-changes">What the last nights changed</h2>
${changes.length ? html`<section class="mem-changes" aria-labelledby="h-changes"><h2 id="h-changes">What the last nights changed</h2>
<ul class="mem-list">${changes.map((c) => changeRow(c, nameOf(c.agent_id)))}</ul></section>` : ''}
<ul class="mem-list">${changes.map((c) => changeRow(c, nameOf(c.agent_id)))}</ul></section>` : ''}
-<p class="mem-foot"><a href="/me/agents">Your agents</a> · <a href="/tasks">Your tasks</a> · <a href="/console">Console</a></p>`);
+<p class="mem-foot"><a href="/me/agents">Your agents</a> · <a href="/tasks">Your tasks</a> · <a href="/chat/settings">Settings</a></p>`);
});
});
r.post('/memory', async (req, res) => {
r.post('/memory', async (req, res) => {
@@ -471,7 +471,7 @@ ${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</p
@@ -471,7 +471,7 @@ ${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</p
eyebrow: 'OpenVibe.Actor · alpha',
eyebrow: 'OpenVibe.Actor · alpha',
title: 'Your own AI.', accent: 'It works for you while you watch.',
title: 'Your own AI.', accent: 'It works for you while you watch.',
lede: 'Tell it what you want: find you work, earn from a gig, grow your channel, brief you every morning, build something. It works on a screen you can watch from any OpenVibe page, uses your own accounts, asks before it spends, posts or applies, and runs on the model you pick.',
lede: 'Tell it what you want: find you work, earn from a gig, grow your channel, brief you every morning, build something. It works on a screen you can watch from any OpenVibe page, uses your own accounts, asks before it spends, posts or applies, and runs on the model you pick.',
- actions: signed ? [{ label: 'Give it a job', href: '#task', primary: true }, { label: 'Your Actor', href: '/console' }] : [{ label: 'Start your Actor', href: '/auth/login?next=%2F', primary: true }, { label: 'What it can do', href: '#templates' }],
+ actions: signed ? [{ label: 'Give it a job', href: '#task', primary: true }, { label: 'Your Actor', href: '/chat' }] : [{ label: 'Start your Actor', href: '/auth/login?next=%2F', primary: true }, { label: 'What it can do', href: '#templates' }],
note: `Open source (AGPL-3.0). Free: ${tier.tasksPerDay} tasks a day and ${usd(tier.perDayUsd)} of model use, no card.`,
note: `Open source (AGPL-3.0). Free: ${tier.tasksPerDay} tasks a day and ${usd(tier.perDayUsd)} of model use, no card.`,
aside: { html: String(nowCard()) },
aside: { html: String(nowCard()) },
});
});
@@ -504,7 +504,7 @@ ${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</p
@@ -504,7 +504,7 @@ ${today ? html`<p class="task-allowance muted small">${leftLine(tier, today)}</p
[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
[html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.link/machines">openvibe.link/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
[html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.link/machines">openvibe.link/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Link with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.link/machines">openvibe.link/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
[html`Your own computers, through <a href="https://openvibe.bot/docs/computers">OpenVibe.Bot</a>: list and read files, run programs and write files there, as you, at the level you set on the computer`, html`Live: install OpenVibe Link with <code>--computer ask</code> and turn on "Let my agent use this computer" on <a href="https://openvibe.link/machines">openvibe.link/machines</a>. At <code>ask</code>, you approve each run or write on the computer.`],
- [html`<a href="/console">The Console</a>: what runs now, what waits for you, today's spend, your agents, computers and robots, and one box to start a task`, 'Live, for signed-in people.'],
+ [html`<a href="/chat">The chat</a>: what runs now in a "Now" strip, your conversations, your agents, and one composer to tell them what to do next`, 'Live, for signed-in people.'],
[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
[html`Ask in chat: write <code>@actor</code> and your question in OpenVibe's global chat or your own channel, and the answer comes back in the room`, 'Live: public tools only (never your computers, robots or code), 5 an hour, on your free tier.'],
[html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],
[html`Run your own: Actor on your server with your own model (<a href="https://github.com/OpenVibers/OpenVibe.Actor/blob/main/docs/self-host.md">how</a>), signed in with your password, data on your disk`, 'Live: Docker or plain Node, Ollama or any OpenAI-compatible model.'],
];
];
@@ -26,9 +26,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -26,9 +26,9 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
const NAV = [
const NAV = [
{ label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ label: 'Chat', href: '/chat', icon: 'fa-comments' },
- { label: 'Console', href: '/console', icon: 'fa-gauge-high' },
{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' },
{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
+ { label: 'Settings', href: '/chat/settings', icon: 'fa-gear' },
{ label: 'API', href: '/docs', icon: 'fa-code', more: true },
{ label: 'API', href: '/docs', icon: 'fa-code', more: true },
];
];
@@ -61,7 +61,7 @@ function renderPage(o) {
@@ -61,7 +61,7 @@ function renderPage(o) {
service: 'actor',
service: 'actor',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Console', href: '/console', icon: 'fa-gauge-high' }, { label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
+ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20): Home, Chat, Agents
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -72,7 +72,7 @@ function renderPage(o) {
@@ -72,7 +72,7 @@ function renderPage(o) {
};
};
// This site's own account links live in the shared navbar's account menu (the page's account
// This site's own account links live in the shared navbar's account menu (the page's account
// bar below is only for visitors without JavaScript).
// bar below is only for visitors without JavaScript).
- if (signedIn) nav.menu = { before: [{ label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }] };
+ if (signedIn) nav.menu = { before: [{ label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }, { label: 'Settings', href: '/chat/settings', icon: 'fa-gear' }] };
const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const account = signedIn
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
@@ -314,10 +314,16 @@ const SAMPLE = {
@@ -314,10 +314,16 @@ const SAMPLE = {
assert.strictEqual(decision.ask, true, 'the floor asks whatever the rules say');
assert.strictEqual(decision.ask, true, 'the floor asks whatever the rules say');
});
});
- await check('the decision is in the person\'s feed too', async () => {
- const feed = await t.get('/console', { as: ada });
- assert.strictEqual(feed.status, 200);
- assert.match(feed.text, /You approved/, 'the person sees what they approved');
+ await check('the decision is on the person\'s approvals page too (the Console is folded into the chat)', async () => {
+ const rows = await approvals.list(t.ctx.s, owner);
+ const approved = rows.find((r) => r.state === 'approved');
+ assert.ok(approved, 'an approved request is kept for the person to read');
+ const card = await t.get(`/me/approvals/${approved.id}`, { as: ada });
+ assert.strictEqual(card.status, 200);
+ assert.match(card.text, /You approved/, 'the person sees what they approved');
+ const inbox = await t.get('/me/approvals', { as: ada });
+ assert.strictEqual(inbox.status, 200);
+ assert.match(inbox.text, /Approved/);
});
});
} finally {
} finally {
await t.close();
await t.close();
@@ -15,31 +15,29 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -15,31 +15,29 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const kim = t.network.addUser('kim');
const kim = t.network.addUser('kim');
const bo = t.network.addUser('bo');
const bo = t.network.addUser('bo');
try {
try {
- await check('the chip says Auto by default, and its menu offers OpenVibe\'s and each provider\'s own key', async () => {
+ await check('the model control is in Settings now, not a chip under the composer', async () => {
const page = await t.get('/chat', { as: kim });
const page = await t.get('/chat', { as: kim });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
- assert.match(page.text, /<details class="cc-model" data-cc-model>/);
- assert.match(page.text, /<span data-ccm-label>Auto<\/span>/);
- assert.match(page.text, /data-ccm-choice="auto" aria-pressed="true"/);
+ assert.ok(!/data-cc-model/.test(page.text), 'no model chip on the chat');
+ const settings = await t.get('/chat/settings', { as: kim });
+ assert.match(settings.text, /href="\/chat\/model"/, 'Settings links the model form');
+ const model = await t.get('/chat/model', { as: kim });
+ assert.match(model.text, /<input type="radio" name="choice" value="auto" checked>/);
['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'].forEach((p) => {
['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'].forEach((p) => {
- assert.ok(page.text.includes(`data-ccm-choice="own:${p}" aria-pressed="false"`), p);
- assert.ok(page.text.includes(`https://ai.openvibe.services/you?allow=actor&provider=${p}`), `${p}: set up at OpenVibe.AI`);
+ assert.ok(model.text.includes(`value="own:${p}"`), p);
+ assert.ok(model.text.includes(`https://ai.openvibe.services/you?allow=actor&provider=${p}`), `${p}: set up at OpenVibe.AI`);
});
});
- assert.ok(page.text.includes('href="/chat/model"'), 'the plain form, for no script');
- // Their own computer (L55 C3): its own group, set up and allowed at OpenVibe.AI.
- assert.ok(page.text.includes('<p class="ccm-head">Your computer</p>'));
- assert.ok(page.text.includes('data-ccm-choice="own:link" aria-pressed="false"'));
- assert.ok(page.text.includes('https://ai.openvibe.services/you?allow=actor&provider=link'));
+ assert.ok(model.text.includes('value="own:link"'), 'their own computer');
+ assert.ok(model.text.includes('https://ai.openvibe.services/you?allow=actor&provider=link'));
});
});
- await check('a tap saves through the API from the page; the chip says the person\'s own key after', async () => {
+ await check('the API saves the choice; the model form shows it, and another person keeps Auto', async () => {
const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });
const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });
assert.strictEqual(put.status, 200, put.text);
assert.strictEqual(put.status, 200, put.text);
assert.strictEqual(put.json().provider, 'openai');
assert.strictEqual(put.json().provider, 'openai');
- const page = await t.get('/chat', { as: kim });
- assert.match(page.text, /<span data-ccm-label>Your OpenAI key<\/span>/);
- assert.match(page.text, /data-ccm-choice="own:openai" aria-pressed="true"/);
- assert.match((await t.get('/chat', { as: bo })).text, /<span data-ccm-label>Auto<\/span>/, 'another person\'s choice is theirs');
+ const page = await t.get('/chat/model', { as: kim });
+ assert.match(page.text, /<input type="radio" name="choice" value="own:openai" checked>/);
+ assert.match((await t.get('/chat/model', { as: bo })).text, /<input type="radio" name="choice" value="auto" checked>/, 'another person\'s choice is theirs');
assert.strictEqual((await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:nope' }, headers: SAME })).status, 422);
assert.strictEqual((await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:nope' }, headers: SAME })).status, 422);
});
});
@@ -62,15 +60,14 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -62,15 +60,14 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const pcPage = await t.get('/chat/model?saved=1', { as: kim });
const pcPage = await t.get('/chat/model?saved=1', { as: kim });
assert.match(pcPage.text, /Saved\. Your next task runs on Your computer\./);
assert.match(pcPage.text, /Saved\. Your next task runs on Your computer\./);
assert.match(pcPage.text, /<input type="radio" name="choice" value="own:link" checked>/);
assert.match(pcPage.text, /<input type="radio" name="choice" value="own:link" checked>/);
- assert.match((await t.get('/chat', { as: kim })).text, /<span data-ccm-label>Your computer<\/span>/);
+ assert.match((await t.get('/chat/model?saved=1', { as: kim })).text, /<input type="radio" name="choice" value="own:link" checked>/);
});
});
- await check('the script: the menu asks which keys are ready, saves in one request, closes on Escape', async () => {
+ await check('the chat no longer mounts a model control; the model page keeps the API', async () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
- assert.match(js, /fetch\('\/api\/v1\/me\/model', \{ credentials: 'same-origin'/);
- assert.match(js, /fetch\('\/api\/v1\/me\/model', \{ method: 'PUT', credentials: 'same-origin'/);
- assert.match(js, /e\.key === 'Escape' && box\.open/);
- assert.match(js, /allow it at OpenVibe\.AI/, 'chosen but not allowed: it says what is left to do');
+ assert.ok(!/data-cc-model|modelChip/.test(js), 'no model chip in the chat script');
+ assert.match(js, /data-cc-composer/, 'the chat script mounts composer v2');
+ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).status, 200, 'the model API is still there');
});
});
} finally {
} finally {
await done(t);
await done(t);
@@ -49,22 +49,26 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -49,22 +49,26 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.match(r.headers.get('location'), /^\/auth\/login\?next=%2Fchat/);
assert.match(r.headers.get('location'), /^\/auth\/login\?next=%2Fchat/);
});
});
- await check('a new chat: one question, eight starters from what people use agents for, the composer, an empty list', async () => {
+ await check('a new chat: one question, four compact suggestion chips, the composer v2 host, an empty list', async () => {
const r = await t.get('/chat', { as: kim });
const r = await t.get('/chat', { as: kim });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);
assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);
- // The new runs' share default is a form on the page, never its markup printed as text (11-08, the owner's screenshot).
- assert.match(r.text, /<form class="ct-share-defaults" method="post" action="\/chat\/defaults">/);
- assert.ok(!/<form class="ct-share-defaults/.test(r.text), 'the share-defaults form is not escaped into text');
- assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 8, 'the starters fill the kit\'s composer');
- ['Find a flight', 'Is this true?', 'Best deal', 'Watch for it', 'Plan a trip', 'Make a list'].forEach((k) => assert.ok(r.text.includes(`<b>${k}</b>`), k));
+ // The run defaults moved to Settings (one-agent system, stage 1): nothing of them under the composer.
+ assert.ok(!/ct-share-defaults/.test(r.text), 'the run settings are not on the chat');
+ assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 4, 'four suggestion chips, not the card grid');
+ assert.strictEqual((r.text.match(/class="ct-suggest-chip"/g) || []).length, 4, 'one line each');
+ ['Find a flight', 'Is this true?', 'Best deal', 'Watch for it'].forEach((k) => assert.ok(r.text.includes(`<span>${k}</span>`), k));
+ assert.ok(!/class="ct-starters"/.test(r.text), 'not the wall of starter cards');
assert.ok(!/Who handles the mail/.test(r.text), 'not the old DNS example (the owner: "people aren\'t gonna use it for that")');
assert.ok(!/Who handles the mail/.test(r.text), 'not the old DNS example (the owner: "people aren\'t gonna use it for that")');
- assert.match(r.text, /<form class="ovt-composer" method="post" action="\/chat" data-ovt-composer>/);
+ assert.match(r.text, /<div class="cc-composer" id="cc-composer" data-cc-composer data-action="\/chat"/);
+ assert.match(r.text, /<form class="ct-composer-fallback" method="post" action="\/chat">/, 'a browser without script still sends');
+ assert.match(r.text, /<script src="\/shared\/ov-composer\.js(\?v=[0-9a-f]+)?" defer><\/script>/, 'composer v2 (4.215.0) is loaded');
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/thread\.css(\?v=[0-9a-f]+)?">/, 'the network\'s conversation UI (L55 C4)');
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/thread\.css(\?v=[0-9a-f]+)?">/, 'the network\'s conversation UI (L55 C4)');
assert.match(r.text, /<script src="\/shared\/ov-thread\.js(\?v=[0-9a-f]+)?" defer><\/script>/);
assert.match(r.text, /<script src="\/shared\/ov-thread\.js(\?v=[0-9a-f]+)?" defer><\/script>/);
- assert.match(r.text, /\/shared\/ov-upload\.css/);
- assert.match(r.text, /\/shared\/ov-upload\.js/);
- assert.match(r.text, /data-cc-upload data-accept=/);
+ assert.ok(!/data-cc-upload data-accept=/.test(r.text), 'the dashed "Attach files" box is gone (the composer attaches)');
+ assert.ok(!/data-cc-model/.test(r.text) && !/data-cc-mode/.test(r.text), 'no separate model or mode control');
+ assert.match(r.text, /<p class="cc-allowance">Actor checks each answer with a second model\./, 'one quiet allowance line under the composer');
+ assert.match(r.text, /href="\/chat\/settings" title="Settings"/, 'Settings is in the chat head');
assert.match(r.text, /Your chats show here\./);
assert.match(r.text, /Your chats show here\./);
assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');
assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');
assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');
assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');
@@ -86,7 +90,8 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -86,7 +90,8 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.match(page.text, /<div class="ovt-meta"><span>Balanced/);
assert.match(page.text, /<div class="ovt-meta"><span>Balanced/);
assert.match(page.text, new RegExp(`<input type="hidden" name="parent" value="${first}">`), 'the composer replies to the last turn');
assert.match(page.text, new RegExp(`<input type="hidden" name="parent" value="${first}">`), 'the composer replies to the last turn');
assert.match(page.text, new RegExp(`<a class="ovk-sidebar-item is-here" href="/chat/${first}" aria-current="page"`), 'the chat is in the list (the network\'s sidebar), marked as the one open');
assert.match(page.text, new RegExp(`<a class="ovk-sidebar-item is-here" href="/chat/${first}" aria-current="page"`), 'the chat is in the list (the network\'s sidebar), marked as the one open');
- assert.match(page.text, /<nav class="ovk-sidebar-nav"[^>]*>[\s\S]*Console[\s\S]*Your agents[\s\S]*Memory/, 'Actor\'s places as rows, as Codes\' are');
+ assert.match(page.text, /<nav class="ovk-sidebar-nav"[^>]*>[\s\S]*Settings[\s\S]*Your agents[\s\S]*Memory/, 'Actor\'s places as rows, as Codes\' are; the Console is gone');
+ assert.ok(!/href="\/console"/.test(page.text), 'no Console row or link');
});
});
await check('a reply continues the chat: both turns, in order, one chat in the list, with the turn before in mind', async () => {
await check('a reply continues the chat: both turns, in order, one chat in the list, with the turn before in mind', async () => {
@@ -248,9 +253,12 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -248,9 +253,12 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
const page = await t.get(`/chat/${last}`, { as: kim });
const page = await t.get(`/chat/${last}`, { as: kim });
await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);
await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);
- assert.match(page.text, new RegExp(`<form class="ovt-composer is-busy" method="post" action="/chat/${last}/steer" data-ovt-composer>`));
+ assert.match(page.text, new RegExp(`<div class="cc-composer" id="cc-composer" data-cc-composer data-action="/chat/${last}/steer"`), 'the mounted composer steers the open turn');
+ assert.match(page.text, new RegExp(`data-working="${last}"`), 'the composer knows which turn is open');
assert.match(page.text, /placeholder="Tell Actor something while it works"/);
assert.match(page.text, /placeholder="Tell Actor something while it works"/);
- assert.match(page.text, new RegExp(`<button type="submit" class="ovt-stop" formaction="/tasks/${last}/cancel" formnovalidate data-ovt-stop>`));
+ assert.match(page.text, new RegExp(`<article class="ovt-card ct-run" data-ct-run="${last}">`), 'the running task is a card in the conversation');
+ assert.match(page.text, /data-ct-run-state>Working…</);
+ assert.match(page.text, new RegExp(`<form class="ct-run-stop" method="post" action="/chat/${last}/cancel"><button class="ovt-card-btn is-quiet" type="submit">Stop</button></form>`), 'Stop is on the card');
assert.match(page.text, /<figure class="ct-screen" data-screen hidden><\/figure>/, 'no picture yet: the screen is there, hidden, for the script to fill');
assert.match(page.text, /<figure class="ct-screen" data-screen hidden><\/figure>/, 'no picture yet: the screen is there, hidden, for the script to fill');
});
});
@@ -1,163 +1,66 @@
@@ -1,163 +1,66 @@
'use strict';
'use strict';
/**
/**
- * The Console (server/http/console-pages.js, plan T17 layer 3): signed out it explains itself; signed in it shows the
- * person's running and recent tasks, what waits for them (their approvals, and approvals waiting on their own
- * computers), today's spend, their agents, their computers and robots read from OpenVibe.Bot as them, and links to the
- * services that own the rest. A slow or failing Bot never breaks the page. Only the person's own rows appear.
+ * The Console folded into the chat (one-agent system, stage 1: the owner, 11-12, "having this thing on the console
+ * doesn't make any sense … you should just chat with it"). /console answers 301 to /chat, carrying the Frame's "Ask
+ * Actor" prefill (?ask= and ?from=) into the composer; its one non-conversation page, /me/spend, stays. The APIs and
+ * pages the console linked to (approvals, your agents, computers and robots) are kept.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const store = require('../server/tasks/store');
const store = require('../server/tasks/store');
-const personal = require('../server/agents/personal');
-const approvals = require('../server/agents/approvals');
(async () => {
(async () => {
- const asked = [];
- let botMode = 'ok';
- const bot = {
- computers: {
- enabled: true,
- mine: async (subject) => {
- asked.push(['computers', subject]);
- if (botMode === 'fail') return { error: 'Bot answered 503 for your computers' };
- return { computers: [{ id: 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'desk-pc', online: true, level: 'ask', can: ['terminal', 'files', 'screen'], folders: ['/home/ada'], waiting_for_approval: 2 },
- { id: 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZA', name: 'old-laptop', online: false, level: 'observe', can: ['files', 'screen'], folders: [] }] };
- },
- },
- robots: {
- enabled: true,
- mine: async (subject, { signal }) => {
- asked.push(['robots', subject]);
- if (botMode === 'fail') return await new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted'))));
- return [{ id: 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9', name: 'Desk rover', agent_control: true, online: true, estop: { latched: false }, battery: 0.8, panel: 'https://openvibe.bot/panel/rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9' }];
- },
- },
- };
- const t = await boot({ bot });
+ const t = await boot();
try {
try {
const ada = t.network.addUser('ada');
const ada = t.network.addUser('ada');
const bo = t.network.addUser('bo');
const bo = t.network.addUser('bo');
const owner = `user:${ada.subject}`;
const owner = `user:${ada.subject}`;
- const task = async (who, text, state) => {
- const id = t.ctx.s.newId('tsk');
- await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: t.ctx.s.iso() });
- if (state !== 'queued') await store.updateTask(t.ctx.s, id, { state, ...(state === 'succeeded' ? { cost_usd: 0.0012, finished_at: t.ctx.s.iso() } : {}) });
- return id;
- };
- await check('signed out, the console says what it is and how to open it', async () => {
- const r = await t.get('/console');
- assert.strictEqual(r.status, 200);
- assert.match(r.text, /Sign in to open your console/);
- // What each card will hold, instead of empty space under the button; no numbers or rows of anyone's.
- for (const h of ['Now', 'Waiting for you', 'Today', 'Your agents', 'Your computers', 'Your robots']) assert.ok(r.text.includes(`>${h}</h2>`), h);
- assert.match(r.text, /class="cn-grid cn-preview" aria-label="What the console shows"/);
- assert.ok(!/cn-count|cn-meter|cn-tasks/.test(r.text), 'no counts, meters or task rows signed out');
- assert.ok(!asked.length, 'nothing asked of Bot for nobody');
- });
-
- await check('signed in: running and recent tasks, what waits, today, agents, computers, robots and the rest', async () => {
- const running = await task(owner, 'Check the certificate of example.com', 'running');
- await task(owner, 'Summarise the Node.js release notes', 'succeeded');
- await task(`user:${bo.subject}`, 'Bo\'s private task', 'running');
- await store.addSpend(t.ctx.s, owner, 0.0012);
- await approvals.request(t.ctx.s, { owner, kind: 'codes.task', summary: 'Fix the failing build in ada/site', payload: { task: 'fix it' } });
- await personal.create(t.ctx.s, t.config, owner, { name: 'Morning briefing', instructions: 'Summarise what shipped.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'daily', schedule_at: '08:00', status: 'active' });
- const r = await t.get('/console', { as: ada });
- assert.strictEqual(r.status, 200);
- const h = r.text;
- assert.match(h, /<h1>Console<\/h1>/);
- assert.match(h, /<form class="cn-command" method="post" action="\/tasks"/);
- assert.ok(h.includes(`href="/tasks/${running}"`) && h.includes('Check the certificate of example.com'));
- assert.match(h, /data-live="10"/, 'refreshes while something runs');
- assert.match(h, /Summarise the Node\.js release notes/);
- assert.ok(!h.includes('Bo's private task') && !h.includes("Bo's private task"), 'only the person\'s own tasks');
- assert.match(h, /Fix the failing build in ada\/site/);
- assert.match(h, /2 on your computers/);
- assert.match(h, /cn-count">3</, 'one approval here and two on the machine');
- assert.match(h, /Morning briefing/);
- assert.match(h, /desk-pc[\s\S]*Ask first · terminal, files, screen/);
- assert.match(h, /href="https:\/\/[^"]+\/machines\/dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9\/screen">Watch the screen</, 'an online computer with a screen links its live screen on Bot');
- assert.ok(!h.includes('dev_01M4W5JBFDG2TJ8C4FRDMCWBZA/screen'), 'never for one that is offline');
- assert.match(h, /Desk rover[\s\S]*battery 80%/);
- assert.match(h, /openvibe\.services\/projects/);
- assert.match(h, /Last 7 days: <b>\$0\.0012<\/b>, 2 tasks/, 'the week: what the two tasks spent today');
- assert.strictEqual((h.match(/class="cn-bar"/g) || []).length, 7);
- assert.deepStrictEqual(asked.filter((a) => a[1] !== ada.subject), [], 'Bot is asked as the person only');
- });
-
- await check('a failing or slow Bot leaves the rest of the page working', async () => {
- botMode = 'fail';
- const started = Date.now();
- const r = await t.get('/console', { as: ada });
- assert.strictEqual(r.status, 200);
- assert.match(r.text, /Bot answered 503 for your computers/);
- assert.match(r.text, /OpenVibe\.Bot did not answer in time/);
- assert.match(r.text, /Morning briefing/);
- assert.ok(Date.now() - started < 8000, 'bounded by the Bot timeout');
- botMode = 'ok';
+ await check('/console answers 301 to the chat, signed in or not, and keeps no page of its own', async () => {
+ const out = await t.get('/console');
+ assert.strictEqual(out.status, 301, 'a permanent redirect');
+ assert.strictEqual(out.headers.get('location'), '/chat');
+ assert.ok(!/<h1>Console<\/h1>/.test(out.text), 'no console markup');
+ const mine = await t.get('/console', { as: ada });
+ assert.strictEqual(mine.status, 301);
+ assert.strictEqual(mine.headers.get('location'), '/chat');
});
});
- await check('Lately: finished tasks with the agent that ran them, approvals asked and answered, newest first', async () => {
- const dee = t.network.addUser('dee');
- const who = `user:${dee.subject}`;
- const agent = await personal.create(t.ctx.s, t.config, who, { name: 'Price watcher', instructions: 'Check prices.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 1, schedule: 'off', status: 'active' });
- const at = (min) => new Date(Date.now() - min * 60_000).toISOString();
- const ins = async (text, state, finished, via = null) => {
- const id = t.ctx.s.newId('tsk');
- await store.insertTask(t.ctx.s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: at(finished + 1) });
- await store.updateTask(t.ctx.s, id, { state, finished_at: at(finished), cost_usd: 0.003, ...(via ? { via } : {}) });
- };
- await ins('Old check', 'failed', 30);
- await ins('Compare the three laptop prices', 'succeeded', 5, `agent:${agent.row.id}`);
- const asked = await approvals.request(t.ctx.s, { owner: who, kind: 'codes.task', summary: 'Open a pull request on dee/site', payload: { task: 'pr' } });
- await t.ctx.s.db.query("UPDATE actor_approvals SET state = 'declined', decided_at = $2, created_at = $3 WHERE id = $1", [asked.id || asked.approval.id, at(1), at(10)]);
- const r = await t.get('/console', { as: dee });
- const h = r.text;
- assert.match(h, /<h2 id="cn-lately">Lately<\/h2>/);
- const order = ['You declined: Open a pull request on dee/site', 'Done: Compare the three laptop prices', 'Asked for your approval: Open a pull request', 'Didn't finish: Old check'].map((x) => h.indexOf(x));
- assert.ok(order.every((i) => i > 0), JSON.stringify(order));
- assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'newest first');
- assert.match(h, /Price watcher · \$0\.0030/, 'the agent that ran it, and what it cost');
- assert.ok(!h.includes('Check the certificate of example.com'), 'only this person\'s');
+ await check('?ask= and ?from= go to the chat composer, escaped, and nothing runs; another site\'s address is dropped', async () => {
+ const before = (await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n;
+ const q = `?ask=${encodeURIComponent('Summarise <this> & explain')}&from=${encodeURIComponent('https://openvibe.wiki/s/openvibe/actor')}`;
+ const r = await t.get(`/console${q}`, { as: ada });
+ assert.strictEqual(r.status, 301);
+ assert.strictEqual(r.headers.get('location'), `/chat?ask=${encodeURIComponent('Summarise <this> & explain\n\n(On this page: https://openvibe.wiki/s/openvibe/actor)')}`);
+ const chat = await t.get(r.headers.get('location'), { as: ada });
+ assert.strictEqual(chat.status, 200);
+ assert.ok(chat.text.includes('>Summarise <this> & explain\n\n(On this page: https://openvibe.wiki/s/openvibe/actor)</textarea>'), 'escaped, in the fallback box and data-text');
+ const foreign = await t.get(`/console?ask=hi&from=${encodeURIComponent('https://evil.example/x')}`);
+ assert.strictEqual(foreign.headers.get('location'), '/chat?ask=hi', 'another site\'s address is not carried');
+ assert.strictEqual((await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n, before, 'nothing ran');
});
});
- await check('/me/spend: the last 30 days as bars and a table, for the signed-in person only', async () => {
+ await check('/me/spend: the last 30 days as bars and a table, signed in only; it is linked from Settings', async () => {
+ await store.addSpend(t.ctx.s, owner, 0.0012, { tasks: 2 });
const r = await t.get('/me/spend', { as: ada });
const r = await t.get('/me/spend', { as: ada });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /<h1>Spend<\/h1>/);
assert.match(r.text, /<h1>Spend<\/h1>/);
assert.match(r.text, /Last 30 days: <b>\$0\.0012<\/b>, 2 tasks/);
assert.match(r.text, /Last 30 days: <b>\$0\.0012<\/b>, 2 tasks/);
assert.strictEqual((r.text.match(/class="cn-bar"/g) || []).length, 30);
assert.strictEqual((r.text.match(/class="cn-bar"/g) || []).length, 30);
assert.strictEqual((await t.get('/me/spend')).status, 302, 'signed out: sign in first');
assert.strictEqual((await t.get('/me/spend')).status, 302, 'signed out: sign in first');
- assert.match((await t.get('/console', { as: ada })).text, /href="\/me\/spend"/, 'the console links it');
+ assert.match((await t.get('/chat/settings', { as: ada })).text, /href="\/me\/spend"/, 'Settings links it');
});
});
- await check('?ask= and ?from= fill the command box (the Frame\'s "Ask Actor"); nothing runs; other sites\' addresses are dropped', async () => {
- const before = (await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n;
- const q = `?ask=${encodeURIComponent('Summarise <this> & explain')}&from=${encodeURIComponent('https://openvibe.wiki/s/openvibe/actor')}`;
- const r = await t.get(`/console${q}`, { as: ada });
- assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('autofocus placeholder=') || r.text.includes(' autofocus '), 'the box takes the focus');
- assert.ok(r.text.includes('>Summarise <this> & explain\n\n(On this page: https://openvibe.wiki/s/openvibe/actor)</textarea>'), 'escaped, with the page it came from');
- const foreign = await t.get(`/console?ask=hi&from=${encodeURIComponent('https://evil.example/x')}`, { as: ada });
- assert.ok(foreign.text.includes('>hi</textarea>'), 'another site\'s address is not carried');
- assert.ok((await t.get('/console', { as: ada })).text.includes('"></textarea>'), 'without ?ask= the box is empty');
- assert.strictEqual((await t.ctx.s.db.maybe('SELECT count(*)::int AS n FROM tasks')).n, before, 'nothing ran');
- const out = await t.get(`/console?ask=${encodeURIComponent('hello there')}`);
- assert.ok(out.text.includes(`/auth/login?next=${encodeURIComponent('/console?ask=hello%20there')}`), 'signed out: sign-in comes back to the filled box');
- });
-
- await check('nothing running: no refresh; nothing waiting says so', async () => {
- const r = await t.get('/console', { as: bo });
- assert.strictEqual(r.status, 200);
- assert.match(r.text, /data-live="10"/, 'Bo has a running task');
- const cy = t.network.addUser('cy');
- const quiet = await t.get('/console', { as: cy });
- assert.doesNotMatch(quiet.text, /data-live=/);
- assert.match(quiet.text, /Nothing is running/);
- assert.match(quiet.text, /No agent yet/);
- assert.match(quiet.text, /class="ovk-empty"/, 'the empty console lists draw the kit empty state');
- assert.match(quiet.text, /class="ovk-btn[^"]*" href="#cn-task"/, 'the empty state points at the task box');
+ await check('the pages and modules the console used are still here (approvals, agents, computers, robots)', async () => {
+ assert.strictEqual(typeof require('../server/http/console-pages').createConsolePages, 'function');
+ assert.strictEqual(typeof require('../server/http/memory-pages').createMemoryPages, 'function');
+ for (const mod of ['../server/agents/computers', '../server/agents/robots', '../server/agents/approvals', '../server/agents/personal']) {
+ assert.ok(Object.keys(require(mod)).length, `${mod} still exports its API`);
+ }
+ assert.strictEqual((await t.get('/me/approvals', { as: ada })).status, 200);
+ assert.strictEqual((await t.get('/me/agents', { as: ada })).status, 200);
+ assert.match((await t.get('/memory', { as: ada })).text, /href="\/chat\/settings"/, 'the old Console link is Settings');
+ assert.ok(!(await t.get('/chat', { as: bo })).text.includes('ada'), 'the Now strip is the person\'s own');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
@@ -38,11 +38,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -38,11 +38,10 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
await store.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: who, task: 'open', mode: 'balanced', budget_task: 0.02, budget_day: 0.05, created_at: t.ctx.s.iso() });
await store.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: who, task: 'open', mode: 'balanced', budget_task: 0.02, budget_day: 0.05, created_at: t.ctx.s.iso() });
today = await store.spendToday(t.ctx.s, who);
today = await store.spendToday(t.ctx.s, who);
assert.strictEqual(today.reserved, 0.02);
assert.strictEqual(today.reserved, 0.02);
- // The console is where a person starts a run: $0.05 - $0.01 spent - $0.02 held = $0.02 left.
- assert.strictEqual(typeof require('../server/http/console-pages').createConsolePages, 'function');
- const consolePage = await t.get('/console', { as: u });
- assert.strictEqual(consolePage.status, 200);
- assert.match(consolePage.text, /\$0\.02 of the \$0\.05 free allowance left today \(\$0\.02 held by runs in progress\)/);
+ // The chat composer is where a person starts a run: $0.05 - $0.01 spent - $0.02 held = $0.02 left.
+ const chatPage = await t.get('/chat', { as: u });
+ assert.strictEqual(chatPage.status, 200);
+ assert.match(chatPage.text, /\$0\.02 of the \$0\.05 free allowance left today \(\$0\.02 held by runs in progress\)/);
await t.ctx.s.db.query("UPDATE tasks SET state = 'cancelled' WHERE requester = $1", [who]);
await t.ctx.s.db.query("UPDATE tasks SET state = 'cancelled' WHERE requester = $1", [who]);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// Screenshots for one-agent system, stage 1 (not run by test/run.js: only *.test.js are): the empty chat, a chat with a
+// running task, and Settings, at 390 and 1280, with the overflow and 44 px checks printed for the report.
+// Usage: node test/one-page-shots.js <outdir>
+const fs = require('fs');
+const path = require('path');
+const h = require('openvibe-shared/browser-harness');
+const { boot } = require('./helpers/boot');
+const store = require('../server/tasks/store');
+
+(async () => {
+ const out = process.argv[2] || path.join(__dirname, '..', '..', 'research-2026-11-12', 'actor-shots');
+ fs.mkdirSync(out, { recursive: true });
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const nora = t.network.addUser('nora');
+ const s = t.ctx.s;
+ const runId = s.newId('tsk');
+ await store.insertTask(s, { id: runId, requester: `user:${kim.subject}`, task: 'Check the certificate of example.com and tell me what expires first', mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: s.iso() });
+ await store.updateTask(s, runId, { state: 'running' });
+ await store.appendEvent(s, runId, { kind: 'output', step: 'plan', text: 'Step 1 of 3 · Read the certificate', at: s.iso() });
+ await store.appendEvent(s, runId, { kind: 'output', step: 'tool_call', tool: 'read_page', input: 'https://example.com', at: s.iso() });
+ await store.appendEvent(s, runId, { kind: 'output', step: 'tool_call', tool: 'web_search', input: 'example.com certificate expiry', at: s.iso() });
+ await store.appendEvent(s, runId, { kind: 'output', step: 'text', text: 'The page is read; checking the certificate dates now.', at: s.iso() });
+
+ const browser = await h.launch({});
+ const pages = [['empty', '/chat', nora], ['running', `/chat/${runId}`, kim], ['settings', '/chat/settings', nora]];
+ try {
+ for (const [name, url, user] of pages) {
+ for (const width of [390, 1280]) {
+ const page = await h.openPage(browser, await browser.newContext(), { width, height: 1500 });
+ await page.send('Network.setCookie', { name: 'actor_at', value: t.signIn(user).replace(/^actor_at=/, ''), url: t.base, path: '/' });
+ await page.goto(t.base + url, { maxMs: 10000 });
+ await new Promise((r) => setTimeout(r, 1400));
+ const shot = path.join(out, `${name}-${width}.png`);
+ const png = await page.send('Page.captureScreenshot', { format: 'png' });
+ fs.writeFileSync(shot, Buffer.from(png.data, 'base64'));
+ const read = await page.evaluate(`(() => {
+ const seen = (e) => e.closest('.cc-composer, .ct-composer-wrap, .ct-now, .ct-run, .ct-suggest, .ct-settings');
+ const small = [...document.querySelectorAll('button, a, input, select, textarea')].filter((e) => {
+ const r = e.getBoundingClientRect(), st = getComputedStyle(e);
+ return seen(e) && st.display !== 'none' && st.visibility !== 'hidden' && r.width > 0 && r.height > 0 && !e.hidden && e.type !== 'hidden' && (r.height < 44 || r.width < 44);
+ }).slice(0, 8).map((e) => { const r = e.getBoundingClientRect(); return e.tagName.toLowerCase() + '.' + String(e.className || '').split(' ')[0] + ' ' + Math.round(r.width) + 'x' + Math.round(r.height); });
+ const wide = [...document.querySelectorAll('main *')].filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && (r.right > innerWidth + 1 || r.left < -1); })
+ .slice(0, 6).map((e) => e.tagName.toLowerCase() + '.' + String(e.className || '').split(' ')[0]);
+ return { sw: document.documentElement.scrollWidth, iw: innerWidth, wide, small };
+ })()`);
+ process.stdout.write(`${shot} scrollWidth=${read.sw}/${read.iw}${read.wide.length ? ` WIDE ${read.wide.join(', ')}` : ''}${read.small.length ? ` SMALL ${read.small.join(', ')}` : ''}\n`);
+ await page.close();
+ }
+ }
+ } finally {
+ await browser.close();
+ await t.close();
+ }
+ process.exit(0);
+})().catch((e) => { process.stderr.write(String(e && e.stack)); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * One agent system, stage 1: Actor's chat is the one place you use Actor.
+ * - the empty chat shows four compact suggestion chips, not a wall of cards; a chat with messages shows none;
+ * - the run settings ("New runs: Only me … Apply to all my existing runs") are in Settings, not under the composer;
+ * - /console is 301 to /chat, and the phone tab bar is Home, Chat, Agents;
+ * - a running task is a card in the conversation (title, state, the last steps, Stop) that updates in place, and the
+ * "Now" strip at the top of the chat has one line per running task, Stop on each, hidden when nothing runs.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const store = require('../server/tasks/store');
+
+(async () => {
+ const t = await boot();
+ const s = t.ctx.s;
+ const SAME = { 'sec-fetch-site': 'same-origin' };
+ try {
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${kim.subject}`;
+ const task = async (text, state = 'running', who = owner) => {
+ const id = s.newId('tsk');
+ await store.insertTask(s, { id, requester: who, task: text, mode: 'balanced', budget_task: 0.05, budget_day: 0.5, created_at: s.iso() });
+ if (state !== 'queued') await store.updateTask(s, id, { state });
+ return id;
+ };
+ const event = (body) => store.appendEvent(s, running, { kind: 'output', at: s.iso(), ...body });
+ const running = await task('Check the certificate of example.com');
+
+ await check('an empty chat: four compact suggestion chips, one line each, and no run-settings form', async () => {
+ const r = await t.get('/chat', { as: kim });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual((r.text.match(/class="ct-suggest-chip"/g) || []).length, 4);
+ assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 4, 'one starter per chip, no more');
+ assert.ok(!/class="ct-starters"/.test(r.text), 'the card grid is gone');
+ assert.ok(!/ct-share-defaults/.test(r.text), 'the run settings are off the chat');
+ assert.match(r.text, /data-cc-composer/, 'composer v2 host');
+ });
+
+ await check('a chat with messages shows no suggestion chips', async () => {
+ const r = await t.get(`/chat/${running}`, { as: kim });
+ assert.strictEqual(r.status, 200);
+ assert.ok(!/data-ovt-starter=/.test(r.text), 'no suggestions once there is a conversation');
+ });
+
+ await check('the run settings live in Settings, with the same route and behaviour', async () => {
+ const page = await t.get('/chat/settings', { as: kim });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<h1>Settings<\/h1>/);
+ assert.match(page.text, /<form class="ct-share-defaults" method="post" action="\/chat\/defaults">/);
+ assert.match(page.text, /name="from" value="settings"/);
+ assert.match(page.text, /Apply to all my existing runs/);
+ assert.match(page.text, /href="\/chat\/model"/, 'the model settings are one link away');
+ const saved = await t.get('/chat/defaults', { as: kim, form: { audience: 'private', from: 'settings' }, headers: SAME });
+ assert.strictEqual(saved.status, 303);
+ assert.strictEqual(saved.headers.get('location'), '/chat/settings?saved=1');
+ assert.match((await t.get('/chat/settings?saved=1', { as: kim })).text, /Saved\. Your next run starts as Only me/);
+ assert.strictEqual((await t.get('/chat/settings')).status, 303, 'signed out: sign in first');
+ });
+
+ await check('a running task is a card in the conversation: title, state, last steps, Stop', async () => {
+ await event({ step: 'tool_call', tool: 'web_search', input: 'example.com certificate' });
+ await event({ step: 'text', text: 'Reading the certificate page' });
+ const page = await t.get(`/chat/${running}`, { as: kim });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, new RegExp(`<article class="ovt-card ct-run" data-ct-run="${running}">`));
+ assert.match(page.text, /Check the certificate of example\.com/);
+ assert.match(page.text, /data-ct-run-state>Working…</);
+ assert.match(page.text, /data-ovt-live>Reading the certificate page</, 'the last step, not a whole panel');
+ assert.match(page.text, /data-ovt-work>/, 'the card keeps the steps for the live refresh');
+ assert.match(page.text, new RegExp(`<form class="ct-run-stop" method="post" action="/chat/${running}/cancel">`));
+ });
+
+ await check('the card updates in place: the fragment has the new step, then the finished turn has no card', async () => {
+ await event({ step: 'tool_call', tool: 'read_page', input: 'https://example.com' });
+ const open = await t.get(`/chat/turn/${running}`, { as: kim });
+ assert.strictEqual(open.status, 200);
+ assert.match(open.text, /Read a page/, 'the newest step is in the refresh');
+ await store.updateTask(s, running, { state: 'verifying' });
+ const checking = await t.get(`/chat/turn/${running}`, { as: kim });
+ assert.match(checking.text, /data-ct-run-state>Checking the answer…</);
+ await store.updateTask(s, running, { state: 'succeeded', finished_at: s.iso(), result: { answer: 'It is valid.' } });
+ const finished = await t.get(`/chat/turn/${running}`, { as: kim });
+ assert.ok(!/data-ct-run=/.test(finished.text), 'a finished turn is the ordinary answer');
+ assert.match(finished.text, /It is valid\./);
+ });
+
+ await check('the Now strip: one line per running task with Stop, hidden when nothing runs', async () => {
+ const live = await task('Read the news for me');
+ const queued = await task('Summarise the release notes', 'queued');
+ const page = await t.get('/chat', { as: kim });
+ assert.strictEqual((page.text.match(/class="ct-now-item"/g) || []).length, 1, 'only what is running now');
+ const strip = page.text.slice(page.text.indexOf('<section class="ct-now"'), page.text.indexOf('</section>', page.text.indexOf('<section class="ct-now"')));
+ assert.match(strip, /Read the news for me/);
+ assert.ok(!/Summarise the release notes/.test(strip), 'a queued task is not running yet');
+ assert.match(page.text, /class="ct-now-stop" method="post" action="\/chat\/tsk_[0-9A-Z]{26}\/cancel"/);
+ const now = await t.get('/chat/now', { as: kim });
+ assert.strictEqual(now.status, 200);
+ assert.match(now.text, /Read the news for me/);
+ const empty = await t.get('/chat', { as: bo });
+ assert.match(empty.text, /<section class="ct-now" data-ct-now hidden><\/section>/, 'nothing runs: no strip');
+ assert.strictEqual((await t.get('/chat/now')).status, 401);
+ await store.updateTask(s, live, { state: 'cancelled' });
+ await store.updateTask(s, queued, { state: 'cancelled' });
+ });
+
+ await check('Stop on the card (and on the strip) cancels the run and comes back to the chat', async () => {
+ const stop = await task('Watch this page');
+ const before = t.ctx.engine.cancel;
+ const cancelled = [];
+ t.ctx.engine.cancel = async (id) => { cancelled.push(id); await store.updateTask(s, id, { state: 'cancelled' }); };
+ try {
+ const card = await t.get(`/chat/${stop}/cancel`, { as: kim, form: { do: 'stop' }, headers: SAME });
+ assert.strictEqual(card.status, 303);
+ assert.strictEqual(card.headers.get('location'), `/chat/${stop}`);
+ assert.deepStrictEqual(cancelled, [stop], 'the run was stopped');
+ assert.strictEqual((await store.getTask(s, stop)).state, 'cancelled');
+ const theirs = await t.get(`/chat/${stop}/cancel`, { as: bo, form: { do: 'stop' }, headers: SAME });
+ assert.deepStrictEqual([theirs.status, theirs.headers.get('location')], [303, '/chat'], 'another person: nothing');
+ assert.strictEqual((await t.get(`/chat/${stop}/cancel`, { as: kim, form: { do: 'stop' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
+ } finally { t.ctx.engine.cancel = before; }
+ });
+
+ await check('/console is 301 to the chat, and the phone tab bar is Home, Chat, Agents', async () => {
+ const out = await t.get('/console', { as: kim });
+ assert.strictEqual(out.status, 301);
+ assert.strictEqual(out.headers.get('location'), '/chat');
+ const page = await t.get('/chat', { as: kim });
+ const bar = /"appBar":\[(.*?)\]/.exec(page.text);
+ assert.ok(bar, 'the app bar is in the page config');
+ assert.deepStrictEqual([...bar[1].matchAll(/"label":"([^"]+)"/g)].map((m) => m[1]), ['Home', 'Chat', 'Agents']);
+ assert.ok(!/"label":"Console"/.test(page.text), 'no Console anywhere in the navigation');
+ });
+
+ await check('phone first: the controls the change adds are at least 44 px and the layout can shrink', async () => {
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
+ assert.match(css, /@media \(max-width: 640px\)[\s\S]*\.cc-composer \.ovc-v2 \.ovt-btn, \.cc-composer \.ovc-v2 \.ovt-send \{ width: 44px; height: 44px; min-width: 44px; min-height: 44px; \}/);
+ assert.match(css, /\.ct-now-stop button \{[^}]*min-height: 44px/);
+ assert.match(css, /\.ct-now-task \{[^}]*min-height: 44px/);
+ assert.match(css, /\.ct-run-stop \.ovt-card-btn \{[^}]*min-height: 44px/);
+ assert.match(css, /\.ct-suggest-chip \{[^}]*min-height: 44px/);
+ assert.match(css, /\.ct-now-item \{[^}]*minmax\(0,/, 'every track can shrink: nothing is wider than the phone');
+ const page = await t.get('/chat', { as: kim });
+ assert.match(page.text, /<meta name="viewport" content="width=device-width, initial-scale=1">/);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -38,22 +38,22 @@ const SECRET = 's'.repeat(43);
@@ -38,22 +38,22 @@ const SECRET = 's'.repeat(43);
const t = await boot({ env: { ...base, ACTOR_RUNTIME_URL: runtimeUrl, ACTOR_RUNTIME_MODEL: 'local-model', ACTOR_DEEPSEEK_API_KEY: null, ACTOR_OPENAI_API_KEY: null, ACTOR_LOCAL_LLM_URL: null, ACTOR_LOCAL_LLM_MODEL: null, ACTOR_MODEL_FAST: null, ACTOR_MODEL_SMART: null, ACTOR_MODEL_BEST: null, ACTOR_MODEL_CHECK: null, ACTOR_MODEL_VISION: null } });
const t = await boot({ env: { ...base, ACTOR_RUNTIME_URL: runtimeUrl, ACTOR_RUNTIME_MODEL: 'local-model', ACTOR_DEEPSEEK_API_KEY: null, ACTOR_OPENAI_API_KEY: null, ACTOR_LOCAL_LLM_URL: null, ACTOR_LOCAL_LLM_MODEL: null, ACTOR_MODEL_FAST: null, ACTOR_MODEL_SMART: null, ACTOR_MODEL_BEST: null, ACTOR_MODEL_CHECK: null, ACTOR_MODEL_VISION: null } });
await check('the owner signs in with the password; a wrong one is refused', async () => {
await check('the owner signs in with the password; a wrong one is refused', async () => {
- const form = await t.get('/auth/login?next=/console');
+ const form = await t.get('/auth/login?next=/chat');
assert.strictEqual(form.status, 200);
assert.strictEqual(form.status, 200);
assert.match(form.text, /Sign in to your Actor/);
assert.match(form.text, /Sign in to your Actor/);
- assert.match(form.text, /name="next" value="\/console"/);
- const wrong = await t.get('/auth/login', { form: { password: 'nope', next: '/console' } });
+ assert.match(form.text, /name="next" value="\/chat"/);
+ const wrong = await t.get('/auth/login', { form: { password: 'nope', next: '/chat' } });
assert.strictEqual(wrong.status, 401);
assert.strictEqual(wrong.status, 401);
assert.ok(!wrong.headers.get('set-cookie'));
assert.ok(!wrong.headers.get('set-cookie'));
- const ok = await t.get('/auth/login', { form: { password: PASSWORD, next: '/console' } });
- assert.deepStrictEqual([ok.status, ok.headers.get('location')], [303, '/console']);
+ const ok = await t.get('/auth/login', { form: { password: PASSWORD, next: '/chat' } });
+ assert.deepStrictEqual([ok.status, ok.headers.get('location')], [303, '/chat']);
const cookie = ok.headers.get('set-cookie').split(';')[0];
const cookie = ok.headers.get('set-cookie').split(';')[0];
assert.match(cookie, /^actor_local=\d{13}\.[A-Za-z0-9_-]{43}$/);
assert.match(cookie, /^actor_local=\d{13}\.[A-Za-z0-9_-]{43}$/);
const me = await t.get('/auth/me', { cookie });
const me = await t.get('/auth/me', { cookie });
assert.deepStrictEqual(me.json().user, { username: 'kai', display_name: 'kai', subject_id: SUBJECT });
assert.deepStrictEqual(me.json().user, { username: 'kai', display_name: 'kai', subject_id: SUBJECT });
const forged = await t.get('/auth/me', { cookie: `actor_local=9999999999999.${'A'.repeat(43)}` });
const forged = await t.get('/auth/me', { cookie: `actor_local=9999999999999.${'A'.repeat(43)}` });
assert.strictEqual(forged.json().user, null, 'a cookie not signed with the session secret is nobody');
assert.strictEqual(forged.json().user, null, 'a cookie not signed with the session secret is nobody');
- assert.strictEqual((await t.get('/console', { cookie })).status, 200);
+ assert.strictEqual((await t.get('/chat', { cookie })).status, 200);
});
});
await check('the API takes the owner\'s token only: never a Network token, never a wrong one', async () => {
await check('the API takes the owner\'s token only: never a Network token, never a wrong one', async () => {
@@ -101,8 +101,9 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -101,8 +101,9 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const next = await say('And the second cheapest?');
const next = await say('And the second cheapest?');
const nextId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(next.headers.get('location'))[1];
const nextId = /^\/chat\/(tsk_[0-9A-Z]{26})/.exec(next.headers.get('location'))[1];
assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });
assert.deepStrictEqual(await audienceRow(nextId), { audience: 'public' });
- const start = await t.get('/chat', { as: kim });
- assert.ok(start.text.includes('New runs'), 'the default is on the product page');
+ const settings = await t.get('/chat/settings', { as: kim });
+ assert.ok(settings.text.includes('New runs'), 'the default is in Settings now');
+ assert.ok(!(await t.get('/chat', { as: kim })).text.includes('New runs'), 'not under the composer');
});
});
} finally {
} finally {
await t.close();
await t.close();