openvibers/openvibe.shared agent-sheet.js

agent-sheet.js

main 17.3 KB · 237 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — an agent's sheet (openvibe-shared 4.211.0; Opus, 2026-11-11)
3//
4// One window (ov-window.js: a panel beside what opened it on a computer, a sheet on a phone) that says everything about
5// one agent: who it is and its state, what it is doing now, what it asks you, the work it did, when it runs, what it
6// remembers and its computer. The data is the agent's sheet (Contracts actor.agent-sheet@1 / codes.agent-sheet@1),
7// read through Network's same-origin relay with the person's token:
8// OVAgentSheet.open({ kind: 'actor' | 'codes', id, name, token, frameBase, base, from, openHref }) → { close() }
9// The parts are tabs (a segmented row; arrow keys move between them); a part with nothing in it says so in its own
10// words (asks_empty) rather than disappearing. While it reads, the window shows the mark's wait; a failed read says so
11// with Retry. Everything an agent or a person wrote is set as text, never markup.
12// ═══════════════════════════════════════════════════════════════
13(function (root) {
14 'use strict';
15 if (typeof document === 'undefined' || root.OVAgentSheet) return;
16
17 const CSS = `.ovas { display: grid; gap: 12px; padding: 4px 2px 6px; }
18.ovas-who { display: flex; align-items: center; gap: 12px; min-width: 0; }
19.ovas-face { flex: none; width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; overflow: hidden; font-weight: 800; font-size: 17px;
20 color: #fff; background: linear-gradient(135deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #7c3aed)); box-shadow: 0 0 0 3px var(--ovas-ring, transparent); }
21.ovas-face img { width: 100%; height: 100%; object-fit: cover; }
22.ovas[data-state="working"] { --ovas-ring: color-mix(in srgb, #22c55e 55%, transparent); }
23.ovas[data-state="paused"] { --ovas-ring: color-mix(in srgb, #f59e0b 55%, transparent); }
24.ovas-name { display: grid; gap: 2px; min-width: 0; flex: 1; }
25.ovas-name b { font-size: 16px; overflow-wrap: anywhere; }
26.ovas-name small { color: var(--text-secondary, #a8b3c4); font-size: 12.5px; overflow-wrap: anywhere; }
27.ovas-state { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 650; width: fit-content;
28 background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
29.ovas-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ovas-dot, #94a3b8); }
30.ovas[data-state="working"] .ovas-state { --ovas-dot: #22c55e; }
31.ovas[data-state="paused"] .ovas-state { --ovas-dot: #f59e0b; }
32.ovas-open { flex: none; display: inline-flex; align-items: center; min-height: 36px; padding: 0 12px; border-radius: 10px; color: var(--accent-light, #60a5fa);
33 font-size: 13px; font-weight: 650; text-decoration: none; }
34.ovas-open:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
35.ovas-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 999px; overflow-x: auto; scrollbar-width: none; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
36.ovas-tabs::-webkit-scrollbar { display: none; }
37@media (max-width: 520px) { .ovas-tabs { -webkit-mask-image: linear-gradient(90deg, #000 85%, transparent); mask-image: linear-gradient(90deg, #000 85%, transparent); } }
38.ovas-tab { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 13px; border: 0; border-radius: 999px; background: none;
39 color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 13px; cursor: pointer; }
40.ovas-tab[aria-selected="true"] { background: var(--bg-primary, #0b1220); color: var(--text-primary, #e6edf7); font-weight: 650; box-shadow: 0 1px 3px rgba(0, 0, 0, .25); }
41.ovas-tab em { font-style: normal; font-size: 11px; font-weight: 700; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, #f59e0b 22%, transparent); color: #fbbf24; }
42.ovas-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
43.ovas-part { display: grid; align-content: start; gap: 8px; min-height: 90px; }
44.ovas-part[hidden] { display: none; }
45.ovas-empty { margin: 0; padding: 18px 8px; text-align: center; color: var(--text-secondary, #a8b3c4); font-size: 13.5px; }
46.ovas-card { display: grid; gap: 4px; padding: 11px 13px; border-radius: 14px; border: 1px solid var(--border, rgba(255, 255, 255, .1));
47 background: color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent); min-width: 0; }
48.ovas-card b { font-size: 14px; font-weight: 650; overflow-wrap: anywhere; }
49.ovas-card p { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 13px; overflow-wrap: anywhere; }
50.ovas-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; color: var(--text-muted, #7d8aa0); font-size: 12px; font-variant-numeric: tabular-nums; }
51.ovas-chip { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 650;
52 background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); color: var(--text-primary, #e6edf7); }
53.ovas-chip.is-ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
54.ovas-chip.is-bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #f87171; }
55.ovas-chip.is-run { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, #60a5fa); }
56.ovas-card.is-now { border-color: color-mix(in srgb, #22c55e 45%, transparent); }
57.ovas-card.is-ask { border-color: color-mix(in srgb, #f59e0b 50%, transparent); background: color-mix(in srgb, #f59e0b 7%, transparent); }
58.ovas-go { justify-self: start; display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 650; font-size: 14px;
59 color: #fff; background: var(--accent, #3b82f6); }
60.ovas-wait { display: grid; justify-items: center; gap: 10px; padding: 26px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13.5px; }
61.ovas-retry { min-height: 40px; padding: 0 16px; border-radius: 12px; border: 1px solid var(--border, rgba(255, 255, 255, .14)); background: none; color: inherit; font: inherit; cursor: pointer; }
62@media (pointer: coarse) { .ovas-tab { min-height: 44px; } .ovas-open { min-height: 44px; } }`;
63
64 function styles(d) {
65 if (d.getElementById('ovas-css')) return;
66 const s = d.createElement('style');
67 s.id = 'ovas-css';
68 s.textContent = CSS;
69 d.head.appendChild(s);
70 }
71 const money = (n) => (Number(n) > 0 ? `$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: Number(n) < 0.1 ? 4 : 2 })}` : '$0');
72 function when(iso) {
73 const t = Date.parse(iso || '');
74 if (!Number.isFinite(t)) return '';
75 const s = (t - Date.now()) / 1000;
76 const a = Math.abs(s);
77 const unit = a < 3600 ? [Math.max(1, Math.round(a / 60)), 'min'] : a < 86400 ? [Math.round(a / 3600), 'h'] : [Math.round(a / 86400), a < 172800 ? 'day' : 'days'];
78 if (a < 60) return s > 0 ? 'in a moment' : 'just now';
79 return s > 0 ? `in ${unit[0]} ${unit[1]}` : `${unit[0]} ${unit[1]} ago`;
80 }
81 const tone = (state) => (/^(done|succeeded|ok|passed|completed)$/i.test(state) ? 'is-ok' : /^(failed|error|refused|stopped)$/i.test(state) ? 'is-bad' : /^(running|working|started)$/i.test(state) ? 'is-run' : '');
82 const STATE_WORD = { working: 'Working', idle: 'Resting', paused: 'Paused', ready: 'Ready' };
83
84 function open(opts = {}) {
85 const W = root.OVWindow;
86 if (!W) throw new Error('ov-window.js is not loaded');
87 const d = document;
88 styles(d);
89 const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = String(text); return n; };
90 const kind = opts.kind === 'codes' ? 'codes' : 'actor';
91 const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
92 const aid = encodeURIComponent(String(opts.id || ''));
93 // Through Network's relay on a site; `base` reaches the service itself (its own site, or a test).
94 const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';
95 const url = direct ? (kind === 'codes' ? `${direct}/api/v1/me/agents/${aid}/sheet` : `${direct}/api/v1/me/agents/${aid}?view=sheet`)
96 : `${frameBase}/${kind === 'codes' ? 'codes-agents' : 'agents'}/${aid}/sheet`;
97 const body = h('div', 'ovas');
98 // The window's title names what it is; the agent's own name leads the sheet under it (said once).
99 const win = W.open({ id: `agent-sheet-${kind}`, title: kind === 'codes' ? 'Coding agent' : 'Your Actor', body, kind: 'panel', size: 'md', from: opts.from || null,
100 onClose: () => { gone = true; } });
101 let gone = false;
102
103 function wait() {
104 const box = h('div', 'ovas-wait');
105 const mark = h('span', 'ov-mark');
106 mark.setAttribute('data-state', 'busy'); mark.setAttribute('data-size', '34'); mark.setAttribute('aria-hidden', 'true');
107 box.append(mark, h('span', '', 'Reading your agent…'));
108 body.replaceChildren(box);
109 }
110 function failed(text) {
111 const box = h('div', 'ovas-wait');
112 const retry = h('button', 'ovas-retry', 'Retry'); retry.type = 'button';
113 retry.addEventListener('click', load);
114 box.append(h('span', '', text), retry);
115 body.replaceChildren(box);
116 }
117
118 function card(cls, title, line, more) {
119 const c = h('div', `ovas-card${cls ? ` ${cls}` : ''}`);
120 c.append(h('b', '', title));
121 if (more) c.append(h('p', '', more));
122 if (line && line.childNodes.length) c.append(line);
123 return c;
124 }
125 function workCard(w, isNow) {
126 const line = h('div', 'ovas-line');
127 if (w.state) line.append(h('span', `ovas-chip ${tone(w.state)}`, w.state));
128 if (Number.isFinite(Number(w.cost_usd))) line.append(h('span', '', money(w.cost_usd)));
129 if (w.updated_at || w.started_at || w.at) line.append(h('span', '', when(w.updated_at || w.started_at || w.at)));
130 const result = typeof w.result === 'string' ? w.result : w.result && (w.result.summary || w.result.text) ? (w.result.summary || w.result.text) : '';
131 return card(isNow ? 'is-now' : '', w.task || 'A task', line, result ? String(result).slice(0, 400) : '');
132 }
133
134 function render(sheet) {
135 const a = sheet.agent || {};
136 body.dataset.state = a.state || '';
137 const who = h('div', 'ovas-who');
138 const face = h('span', 'ovas-face');
139 if (a.face && /^https:\/\//.test(a.face)) { const img = h('img'); img.src = a.face; img.alt = ''; face.append(img); } else face.textContent = String(a.name || opts.name || 'A').trim().charAt(0).toUpperCase();
140 const name = h('span', 'ovas-name');
141 name.append(h('b', '', a.name || opts.name || 'Your agent'), h('span', 'ovas-state', STATE_WORD[a.state] || a.state || 'Ready'));
142 if (a.instructions) name.append(h('small', '', String(a.instructions).slice(0, 160)));
143 else if (a.kind) name.append(h('small', '', a.kind));
144 who.append(face, name);
145 if (opts.openHref) { const go = h('a', 'ovas-open', 'Open ↗'); go.href = opts.openHref; go.target = '_blank'; go.rel = 'noopener'; who.append(go); }
146
147 const asks = Array.isArray(sheet.asks) ? sheet.asks : [];
148 const work = Array.isArray(sheet.work) ? sheet.work : [];
149 const parts = [
150 ['now', 'Now', () => (sheet.now ? [workCard(sheet.now, true)] : [h('p', 'ovas-empty', a.state === 'paused' ? 'Paused. Nothing runs until you resume it.' : 'Nothing running right now.')])],
151 ['asks', 'Asks', () => (asks.length ? asks.map((x) => {
152 const line = h('div', 'ovas-line');
153 if (x.state) line.append(h('span', 'ovas-chip', x.state));
154 if (x.touch && typeof x.touch === 'string') line.append(h('span', '', x.touch));
155 return card('is-ask', x.what || 'A request', line, x.why || '');
156 }) : [h('p', 'ovas-empty', sheet.asks_empty || 'Nothing to approve.')]), asks.length],
157 ['work', 'Work', () => (work.length ? work.map((w) => workCard(w, false)) : [h('p', 'ovas-empty', 'No work yet.')])],
158 ['when', 'Schedule', () => {
159 const rows = (Array.isArray(sheet.schedules) ? sheet.schedules : []).filter((s) => s && s.schedule !== 'off');
160 if (!rows.length) return [h('p', 'ovas-empty', 'It runs when you ask, on no schedule.')];
161 return rows.map((s) => {
162 const line = h('div', 'ovas-line');
163 if (s.next_run_at) line.append(h('span', '', `Next ${when(s.next_run_at)}`));
164 if (s.last_run_at) line.append(h('span', '', `Last ${when(s.last_run_at)}`));
165 if (s.last_result) line.append(h('span', `ovas-chip ${tone(s.last_result)}`, s.last_result));
166 return card('', String(s.schedule || 'On a schedule'), line, '');
167 });
168 }],
169 ['memory', 'Memory', () => {
170 const rows = Array.isArray(sheet.memory) ? sheet.memory : [];
171 return rows.length ? rows.slice(0, 30).map((m) => { const line = h('div', 'ovas-line'); if (m.source) line.append(h('span', '', m.source)); return card('', String(m.text || '').slice(0, 300), line, ''); }) : [h('p', 'ovas-empty', 'Nothing remembered yet.')];
172 }],
173 ['computer', 'Computer', () => {
174 const c = sheet.computer;
175 if (!c) return [h('p', 'ovas-empty', 'No computer is attached to this agent.')];
176 const out = [card('', c.name || 'Its computer', null, c.state || c.status || '')];
177 const href = [c.url, c.open].find((u) => typeof u === 'string' && /^https:\/\//.test(u));
178 if (href) { const go = h('a', 'ovas-go', 'Watch its computer'); go.href = href; go.target = '_blank'; go.rel = 'noopener'; out.push(go); }
179 return out;
180 }],
181 ];
182 const tabs = h('div', 'ovas-tabs');
183 tabs.setAttribute('role', 'tablist');
184 tabs.setAttribute('aria-label', 'About this agent');
185 const panes = [];
186 const first = asks.length ? 'asks' : 'now';
187 parts.forEach(([id, label, build, count]) => {
188 const tab = h('button', 'ovas-tab', label);
189 tab.type = 'button';
190 tab.setAttribute('role', 'tab');
191 tab.id = `ovas-t-${id}`;
192 tab.setAttribute('aria-controls', `ovas-p-${id}`);
193 if (count) tab.append(h('em', '', count));
194 const pane = h('div', 'ovas-part');
195 pane.id = `ovas-p-${id}`;
196 pane.setAttribute('role', 'tabpanel');
197 pane.setAttribute('aria-labelledby', tab.id);
198 pane.append(...build());
199 const on = id === first;
200 tab.setAttribute('aria-selected', on ? 'true' : 'false');
201 tab.tabIndex = on ? 0 : -1;
202 pane.hidden = !on;
203 tab.addEventListener('click', () => select(id));
204 tabs.append(tab);
205 panes.push([id, tab, pane]);
206 });
207 function select(id) {
208 panes.forEach(([pid, tab, pane]) => { const on = pid === id; tab.setAttribute('aria-selected', on ? 'true' : 'false'); tab.tabIndex = on ? 0 : -1; pane.hidden = !on; });
209 }
210 tabs.addEventListener('keydown', (e) => {
211 if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
212 const i = panes.findIndex(([, tab]) => tab.getAttribute('aria-selected') === 'true');
213 const next = panes[(i + (e.key === 'ArrowRight' ? 1 : panes.length - 1)) % panes.length];
214 select(next[0]); next[1].focus(); e.preventDefault();
215 });
216 body.replaceChildren(who, tabs, ...panes.map(([, , pane]) => pane));
217 }
218
219 async function load() {
220 wait();
221 try {
222 const t = typeof opts.token === 'function' ? await opts.token() : opts.token;
223 if (!t) { failed('Sign in to see your agent.'); return; }
224 const r = await fetch(url, { headers: { accept: 'application/json', authorization: `Bearer ${t}` }, credentials: 'omit' });
225 const b = await r.json().catch(() => null);
226 if (gone) return;
227 if (!r.ok || !b) { failed(r.status === 404 ? 'This agent is not there any more.' : 'Its sheet could not be read just now.'); return; }
228 render(b.sheet || b);
229 } catch { if (!gone) failed('Its sheet could not be read just now.'); }
230 }
231 load();
232 return { close: () => win.close() };
233 }
234
235 root.OVAgentSheet = { open };
236})(typeof window !== 'undefined' ? window : globalThis);
237