openvibers/openvibe.codes Commits
Codes' turns in the network's conversation kit (#316)
Changes
@@@ -329,27 +329,125 @@return 'tool';};const attemptN = Number(live.dataset.attempt || 0);++ const MD = window.CodesMarkdown;++ // The live line says what the agent is doing, in words: "Editing server/app.js".++ const VERB = { read: 'Reading', find: 'Searching', edit: 'Editing', run: 'Running', web: 'Browsing', sub: 'Handing over', tool: 'Using' };++ // What a tool call was given: its one line, whatever shape the agent wrote it in.++ const whatOf = (b) => {++ if (b.what) return String(b.what);++ const i = b.input;++ if (!i) return '';++ if (typeof i === 'string') return i;++ return String(i.file_path || i.path || i.command || i.pattern || i.query || i.url || '');++ };const es = page.es = new EventSource(stream);-- let reloading = false;-- const reload = () => { if (reloading) return; reloading = true; es.close(); setTimeout(() => location.reload(), 500); };++ // The answer grows as the text arrives: it is drawn again at most every 120 ms, and the page follows only when++ // the reader was at the bottom (the kit's rule).++ let buffer = '', painted = 0, timer = 0, ended = false;++ const nearBottom = () => (window.innerHeight || 0) + (window.scrollY || 0) >= (document.documentElement.scrollHeight || 0) - 220;++ const answerOf = () => {++ let node = live.querySelector('[data-ovt-answer]');++ if (!node) {++ node = document.createElement('div');++ node.className = 'ovt-answer';++ node.setAttribute('data-ovt-answer', '');++ const reply = live.querySelector('.ovt-reply');++ if (reply) reply.appendChild(node);++ }++ return node;++ };++ const paint = () => {++ timer = 0;++ painted = Date.now();++ const node = answerOf();++ if (!node) return;++ const stay = nearBottom();++ if (MD) { node.innerHTML = MD.markdown(buffer); if (MD.enhance) MD.enhance(node); }++ else node.textContent = buffer;++ if (stay) window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'auto' });++ };++ const schedule = () => {++ if (timer) return;++ timer = setTimeout(paint, Math.max(0, 120 - (Date.now() - painted)));++ };++ // A tool's output folds under its row, and the fold says how much is in it.++ const foldOutput = (key, output) => {++ if (output == null) return;++ const rows = live.querySelectorAll('[data-ovt-work] > .ovt-w-tool');++ let row = null;++ for (const r of rows) if (String(r.getAttribute('data-key')) === String(key)) row = r;++ if (!row) return;++ const head = row.querySelector('.ovt-w-row');++ if (head && MD) {++ const mark = head.querySelector('.ovt-w-st');++ const label = document.createElement('span');++ label.className = 'ovt-w-in ovt-w-fold';++ label.textContent = MD.outputLabel(output);++ head.insertBefore(label, mark);++ }++ const det = document.createElement('details');++ const sum = document.createElement('summary');++ if (head) sum.appendChild(head);++ det.appendChild(sum);++ det.insertAdjacentHTML('beforeend', MD ? MD.outputBody(output) : '');++ row.appendChild(det);++ };++ // The finished turn comes from the server: its answer has its Copy buttons and its work its output folds.++ const finish = () => {++ if (ended) return;++ ended = true;++ es.close();++ const swap = (again) => {++ fetch(window.location.href, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })++ .then((r) => (r.ok ? r.text() : ''))++ .then((html) => {++ if (!html) { location.reload(); return; }++ const doc = new DOMParser().parseFromString(html, 'text/html');++ const fresh = doc.querySelector('[data-turn="' + id + '"]');++ if (!fresh) { location.reload(); return; }++ if (again && fresh.hasAttribute('data-open')) { setTimeout(() => swap(false), 900); return; }++ OV.replace(id, fresh.outerHTML);++ // The composer stops working with the turn.++ const form = $('[data-ovt-composer]', app);++ if (form) {++ form.classList.remove('is-busy');++ const stop = $('[data-ovt-stop]', form);++ if (stop) stop.hidden = true;++ }++ })++ .catch(() => location.reload());++ };++ setTimeout(swap, 400);++ };es.addEventListener('line', (ev) => {let d; try { d = JSON.parse(ev.data); } catch { return; }if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };if (b.t === 'tool') {-- OV.tool(id, { name: b.name || 'tool', input: b.what || '', icon: kindOf(b.name), key: b.id });-- OV.live(id, `${b.name || 'Working'} ${b.what || ''}`.trim());++ const what = whatOf(b);++ OV.tool(id, { name: b.name || 'tool', input: what, icon: kindOf(b.name), key: b.id });++ OV.live(id, `${VERB[kindOf(b.name)] || 'Using'} ${what}`.trim());} else if (b.t === 'tool_result') {OV.toolDone(id, b.ok !== false, b.id);++ foldOutput(b.id, b.output);} else if (b.t === 'text' && b.text) {-- OV.live(id, String(b.text).split('\n').find(Boolean) || 'Working');-- } else if (b.t === 'done' || b.t === 'end' || d.kind === 'end') reload();++ const t = String(b.text);++ // The words may arrive as a whole message or as a piece of one; either way the answer holds all of them.++ buffer = buffer && !t.startsWith(buffer) ? buffer + t : t;++ OV.live(id, t.split('\n').find(Boolean) || 'Working');++ schedule();++ } else if (b.t === 'done') {++ if (b.text) buffer = String(b.text);++ schedule();++ finish();++ } else if (b.t === 'end' || d.kind === 'end') finish();});es.addEventListener('attempt', (ev) => {let d; try { d = JSON.parse(ev.data); } catch { return; }// The stream replays every attempt on connect: only the live one ending, or a newer one starting, is news.-- if ((d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) || (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN)) reload();++ if (d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) finish();++ else if (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN) { es.close(); location.reload(); }});-- es.addEventListener('end', reload);++ es.addEventListener('end', finish);}})();nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * Codes' Markdown for the conversation kit (openvibe-shared/thread + ov-thread.js): one renderer, used by the server++ * to draw a turn and by the browser to draw the same turn again while the text streams in. Every character is escaped++ * first and the markup is added afterwards, so an agent's words can never become HTML.++ *++ * The markup is the kit's: an answer is .ovt-answer, a code block carries its language and a Copy button, a table++ * scrolls inside itself (thread.css) and a link is marked nofollow. Nothing here is styled: the kit draws it.++ *++ * const md = require('../../public/js/md.js'); // on the server++ * <script src="/js/md.js"></script> // in the browser: window.CodesMarkdown++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ if (root) root.CodesMarkdown = api;++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };++ // Only these may become a link; anything else stays words.++ const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;++++ /** The inline markup of one line: code, links, bold, italic. The text is already escaped. */++ function inline(text) {++ const codes = [];++ let out = text.replace(/`([^`\n]+)`/g, (m, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });++ out = out.replace(/\[([^\]\n]*)\]\(([^)\s]+)\)/g, (m, label, url) => (++ SAFE_URL.test(url) ? '<a href="' + url + '" rel="nofollow ugc noopener" target="_blank">' + label + '</a>' : m++ ));++ out = out.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');++ out = out.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');++ out = out.replace(/\u0000(\d+)\u0000/g, (m, i) => '<code>' + codes[Number(i)] + '</code>');++ return out;++ }++++ const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());++ const isDivider = (line) => /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line) && line.includes('-');++++ /** One Markdown answer, as the kit's answer markup. */++ function markdown(source) {++ const lines = String(source == null ? '' : source).replace(/\r\n?/g, '\n').split('\n');++ const out = [];++ let i = 0;++ while (i < lines.length) {++ const line = lines[i];++ if (!line.trim()) { i++; continue; }++ // A fenced code block: its language, its text, and the Copy button the kit draws on it.++ const fence = /^\s*```\s*([A-Za-z0-9+#._-]*)\s*$/.exec(line);++ if (fence) {++ const lang = fence[1] || '';++ const body = [];++ i++;++ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { body.push(lines[i]); i++; }++ i++;++ const label = lang ? '<p class="ovt-code-lang"><span class="ovt-w-in">' + esc(lang) + '</span></p>' : '';++ out.push(label + '<pre><code' + (lang ? ' data-lang="' + esc(lang) + '" class="language-' + esc(lang) + '"' : '') + '>' + esc(body.join('\n')) +++ '</code><button type="button" class="ovt-code-copy" data-ovt-code-copy hidden>Copy</button></pre>');++ continue;++ }++ const head = /^(#{1,6})\s+(.*)$/.exec(line);++ if (head) {++ const level = Math.min(6, head[1].length + 1);++ out.push('<h' + level + '>' + inline(esc(head[2])) + '</h' + level + '>');++ i++;++ continue;++ }++ if (/^\s*>\s?/.test(line)) {++ const body = [];++ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, '')); i++; }++ out.push('<blockquote><p>' + inline(esc(body.join(' '))) + '</p></blockquote>');++ continue;++ }++ const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);++ if (item) {++ const ordered = /\d/.test(item[2]);++ const items = [];++ while (i < lines.length) {++ const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);++ if (!m) break;++ items.push('<li>' + inline(esc(m[3])) + '</li>');++ i++;++ }++ const tag = ordered ? 'ol' : 'ul';++ out.push('<' + tag + '>' + items.join('') + '</' + tag + '>');++ continue;++ }++ if (line.includes('|') && i + 1 < lines.length && isDivider(lines[i + 1])) {++ const head2 = cells(line);++ i += 2;++ const body = [];++ while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { body.push(cells(lines[i])); i++; }++ out.push('<table><thead><tr>' + head2.map((h) => '<th>' + inline(esc(h)) + '</th>').join('') + '</tr></thead><tbody>' +++ body.map((r) => '<tr>' + r.map((c) => '<td>' + inline(esc(c)) + '</td>').join('') + '</tr>').join('') + '</tbody></table>');++ continue;++ }++ // A paragraph: until a blank line or the next kind of block.++ const para = [];++ while (i < lines.length && lines[i].trim() && !/^\s*```/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i])++ && !/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) && !/^\s*>\s?/.test(lines[i])++ && !(lines[i].includes('|') && i + 1 < lines.length && isDivider(lines[i + 1]))) {++ para.push(lines[i]);++ i++;++ }++ if (!para.length) { i++; continue; }++ out.push('<p>' + inline(esc(para.join('\n')).replace(/\n/g, '<br>')) + '</p>');++ }++ return out.join('');++ }++++ // A tool's output: long output folds, and the fold says how much is in it. Never hidden without a count.++ const OUT_LIMIT = 4000;++ function outputLabel(output) {++ const text = String(output == null ? '' : output);++ const cut = text.length > OUT_LIMIT;++ const n = text.slice(0, OUT_LIMIT).split('\n').length;++ return 'Output (' + n + ' line' + (n === 1 ? '' : 's') + (cut ? ', cut' : '') + ')';++ }++ function outputBody(output) {++ const text = String(output == null ? '' : output);++ return '<pre class="ovt-w-out">' + esc(text.slice(0, OUT_LIMIT)) + '</pre>';++ }++++ /**++ * The Copy button on a code block: the kit draws one on every answer's code, but it is drawn in the browser, so++ * the server's own button stays hidden until this shows it and gives it something to do.++ */++ function enhance(root) {++ if (!root || typeof document === 'undefined') return root;++ const list = root.querySelectorAll ? root.querySelectorAll('[data-ovt-code-copy]') : [];++ for (const b of list) {++ if (b.dataset.ovtReady) continue;++ b.dataset.ovtReady = '1';++ b.hidden = false;++ b.addEventListener('click', () => {++ const pre = b.closest('pre');++ const code = pre && pre.querySelector('code');++ const text = code ? code.textContent : '';++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).catch(() => { /* a refused clipboard changes nothing */ });++ });++ }++ return root;++ }++++ if (typeof document !== 'undefined') {++ const boot = () => enhance(document);++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });++ else boot();++ document.addEventListener('ov:boost:load', boot);++ }++++ return { markdown, escape: esc, clip, outputLabel, outputBody, enhance };++});@@@ -17,7 +17,7 @@const express = require('express');const { send } = require('../render/layout');const { raw, esc } = require('../render/html');--const { markdown } = require('../render/markdown');++const md = require('../../public/js/md.js');const { asyncRouter } = require('./router');const { sessionLogOf } = require('../domain/run-tasks');const models = require('../data/models');@@@ -228,14 +228,40 @@ function createChatRoutes(ctx) {// A conversation is a list of turns: what the person said, and the agent's reply under its face and name. A// hand-over to another agent, or a wait, is a line across the thread. How a task ended is a card in its last reply.const STEP_ICON = { read: 'read', find: 'find', edit: 'edit', run: 'run', web: 'web', sub: 'sub', tool: 'tool' };++ // The live line says what the agent is doing, in words: "Editing server/app.js".++ const STEP_VERB = { read: 'Reading', find: 'Searching', edit: 'Editing', run: 'Running', web: 'Browsing', sub: 'Handing over', tool: 'Using' };++ /** What a tool call was given: its one line, whatever shape the agent wrote it in. */++ function toolWhat(b) {++ if (b.what) return String(b.what);++ const i = b.input;++ if (!i) return '';++ if (typeof i === 'string') return i;++ return String(i.file_path || i.path || i.command || i.pattern || i.query || i.url || '');++ }const errorRow = (msg) => thread.turn({ avatar: false, reply: thread.card({ tone: 'bad', text: msg }), attrs: { role: 'alert' } });++ /** One tool as a row of the kit's work list: its name, the one line it was given, and how it ended. */++ function workRow(s) {++ const st = s.ok == null ? (s.running ? 'is-run' : 'is-done') : (s.ok ? 'is-ok' : 'is-bad');++ const mark = st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : st === 'is-done' ? '' : thread.icon(st === 'is-ok' ? 'ok' : 'bad');++ const what = s.what ? `<span class="ovt-w-in">${esc(md.clip(s.what, 90))}</span>` : '';++ const head = `${thread.icon(STEP_ICON[s.kind] || 'tool')}<b>${esc(s.name || 'Tool')}</b>${what}`;++ if (s.output == null && st !== 'is-run') return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row">${head}<span class="ovt-w-st">${mark}</span></div></li>`;++ // Long output folds, and the fold says how much is in it: never hidden without a count.++ const count = s.output != null ? `<span class="ovt-w-in ovt-w-fold">${esc(md.outputLabel(s.output))}</span>` : '';++ const body = s.output != null ? md.outputBody(s.output) : '<p class="ovt-w-out ovt-muted">Running…</p>';++ return `<li class="ovt-w-tool ${st}"><details><summary>${head}${count}<span class="ovt-w-st">${mark}</span></summary>${body}</details></li>`;++ }/** The steps an agent took, as the kit's work rows: what it said along the way is a note, each tool a row. */function workHtml(steps, ms, { live = false, liveText = '' } = {}) {if (!steps.length && !live) return '';const rows = steps.map((s, i) => (s.said-- ? { kind: 'note', text: s.what }-- : { kind: 'tool', name: s.name, input: s.what || '', icon: STEP_ICON[s.kind] || 'tool', ok: s.ok == null ? undefined : s.ok, running: live && s.ok == null && i === steps.length - 1 ? true : false }));-- return thread.work({ open: live, live: liveText || 'Starting', took: ms ? minutes(ms) : null, rows });++ ? `<li class="ovt-w-note">${thread.icon('note')}<p>${esc(s.what)}</p></li>`++ : workRow(s.ok == null && live && i === steps.length - 1 ? { ...s, running: true } : s))).join('');++ const tools = steps.filter((s) => !s.said).length;++ const line = live++ ? `<span class="ovt-w-live" data-ovt-live>${esc(liveText || 'Starting')}…</span>`++ : `<span>${ms ? `Worked for ${esc(minutes(ms))}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;++ return `<details class="ovt-work"${live ? ' open data-open' : ''}><summary>${line}${thread.icon('chev', 'ovt-chev')}</summary><ol class="ovt-w-list" data-ovt-work>${rows}</ol></details>`;}/** One agent's reply from its session lines: the steps it took, then what it said last (its answer). */function agentReply(a, lines, { live = false, attempt = null } = {}) {@@@ -245,8 +271,8 @@ function createChatRoutes(ctx) {for (const l of lines) {const b = asObject(l.body) || { t: 'text', text: String(l.body || '') };const t = b.t || l.kind;-- if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: b.what || '', kind: stepKind(b.name), ok: null });-- else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) s.ok = b.ok !== false; }++ if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: toolWhat(b), kind: stepKind(b.name), ok: null, output: null });++ else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) { s.ok = b.ok !== false; if (b.output != null) s.output = String(b.output); } }else if (t === 'text' && b.text) said.push(String(b.text));else if (t === 'done') done = b;else if (t === 'note' && b.text) said.push(`> ${String(b.text)}`);@@@ -259,8 +285,8 @@ function createChatRoutes(ctx) {const all = [...along.map((text) => ({ said: true, what: firstLine(text, 140) })), ...steps];const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';const lastStep = steps[steps.length - 1];-- const liveText = lastStep ? `${lastStep.name} ${lastStep.what || ''}`.trim() : '';-- const body = answer ? thread.answer({ html: markdown(answer) })++ const liveText = lastStep ? `${STEP_VERB[lastStep.kind] || 'Using'} ${lastStep.what || ''}`.trim() : '';++ const body = answer ? thread.answer({ html: md.markdown(answer) }): live ? '' : `<p class="ovt-muted">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;return { who: { ...a, sub: ran }, reply: workHtml(all, ms, { live, liveText }) + body };}@@@ -318,7 +344,7 @@ function createChatRoutes(ctx) {const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: m.model || row.model } });out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));} else if (m.result && m.result.summary) {-- out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));++ out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: md.markdown(m.result.summary) }) }));} else if (m.error) {out.push(thread.turn({ id, you: mine, who, reply: `<p class="ovt-muted">${esc(m.error)}</p>` }));} else if (m.state === 'queued') {@@@ -429,7 +455,7 @@ function createChatRoutes(ctx) {cls: page != null ? 'is-page' : '',});const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/chat.js'], body: raw(body) });++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });}/** The panel: who worked on it, and where the work is. */nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++// Codes' turns in the network's conversation kit (openvibe-shared/thread): a turn is what the person said and what++// the agent answered, the answer is Markdown (headings, lists, tables, code with its language and a Copy button,++// links marked nofollow), each tool call is a row with its name, its one line and how it ended, and a long output++// folds behind a fold that says how much is in it. Nothing an agent writes ever becomes HTML.++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const md = require('../public/js/md.js');++++(async () => {++ const t = await boot();++ const ada = t.network.addUser('kit-ada');++ const db = t.ctx.store.db;++ const now = Date.now();++ const P = 'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW';++ const MINE = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FD1';++ const ANSWER = [++ '# What changed',++ '',++ 'The rail scrolls sideways now. See [the notes](https://example.com/notes).',++ '',++ '- one line',++ '- another',++ '',++ '```js',++ 'const rail = document.querySelector(".ovt-rail");',++ '```',++ '',++ '| file | lines |',++ '| --- | --- |',++ '| public/css/chat.css | 12 |',++ '',++ 'And a script tag: <script>alert("no")</script>',++ ].join('\n');++ const OUTPUT = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join('\n');++ try {++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at)++ VALUES ($1, $2, $3, 'https://github.com/OpenVibers/OpenVibe.Live', 'main', 'Make the rail scroll sideways', 'claude-code', 'succeeded', '{}'::jsonb, $4, $4)`,++ [MINE, ada.subject, P, now - 60_000]);++ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, model, state, why, kind, created_at)++ VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FE1', $1, 1, 'claude-code', 'opus', 'done', 'The rail scrolls sideways', 'first', now() - interval '50 minutes')`, [MINE]);++ await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES++ ($1, 1, now() - interval '49 minutes', 'tool', $2::jsonb, 1),++ ($1, 2, now() - interval '48 minutes', 'tool_result', $3::jsonb, 1),++ ($1, 3, now() - interval '47 minutes', 'done', $4::jsonb, 1)`,++ [MINE,++ JSON.stringify({ t: 'tool', id: 'call-1', name: 'Edit', what: 'public/css/chat.css' }),++ JSON.stringify({ t: 'tool_result', id: 'call-1', ok: true, output: OUTPUT }),++ JSON.stringify({ t: 'done', ok: true, text: ANSWER })]);++++ const page = await t.get(`/tasks/${MINE}`, { as: ada });++ assert.strictEqual(page.status, 200);++++ await check('the answer is Markdown: headings, lists, a table, code with its language and Copy, safe links', async () => {++ const text = page.text;++ assert.ok(text.includes('<div class="ovt-answer" data-ovt-answer>'), 'the answer is the kit\'s own');++ assert.ok(text.includes('<h2>What changed</h2>'), 'a heading');++ assert.ok(text.includes('<ul><li>one line</li><li>another</li></ul>'), 'a list');++ assert.ok(text.includes('<table><thead><tr><th>file</th><th>lines</th></tr></thead>'), 'a table');++ assert.ok(text.includes('<code data-lang="js" class="language-js">'), 'the code block says its language');++ assert.ok(text.includes('data-ovt-code-copy'), 'the code block has a Copy button');++ assert.ok(text.includes('data-ovt-copy'), 'and the answer has one too');++ assert.ok(text.includes('<a href="https://example.com/notes" rel="nofollow ugc noopener" target="_blank">the notes</a>'), 'a link says what it is');++ });++++ await check('a tool call is a row: its name, the one line it was given, and how it ended', async () => {++ const text = page.text;++ assert.ok(text.includes('<li class="ovt-w-tool is-ok">'), 'the row is the kit\'s, and it worked');++ assert.ok(text.includes('<b>Edit</b>'), 'the name');++ assert.ok(text.includes('<span class="ovt-w-in">public/css/chat.css</span>'), 'the one line it was given');++ assert.ok(text.includes('<span class="ovt-w-st">'), 'and the mark that says how it ended');++ });++++ await check('a long output folds, and the fold says how much is in it', async () => {++ const text = page.text;++ assert.ok(text.includes('Output (12 lines)'), 'the count is on the fold');++ assert.ok(text.includes('<details>') && text.includes('<pre class="ovt-w-out">'), 'the output is folded away');++ });++++ await check('the renderer itself escapes first and marks up afterwards', async () => {++ const html = md.markdown('# Title\n\nA [link](https://example.com) and <script>x</script>');++ assert.ok(html.includes('<h2>Title</h2>'), 'a heading');++ assert.ok(html.includes('rel="nofollow ugc noopener"'), 'a link says what it is');++ assert.ok(html.includes('<script>x</script>'), 'and a script tag stays words');++ assert.ok(!html.includes('<script>'), 'never a script');++ });++++ await check('a script tag in the agent\'s words is words, not a script', async () => {++ const text = page.text;++ assert.ok(text.includes('<script>alert("no")</script>'), 'it is escaped');++ assert.ok(!text.includes('<script>alert'), 'and never a script');++ });++++ await check('the turn is drawn by the server: the page says it all without a script', async () => {++ const text = page.text;++ assert.ok(text.includes('<li class="ovt-turn"') && text.includes('class="ovt-bubble">Make the rail scroll sideways'), 'the turn and the brief are in the page');++ assert.ok(text.includes('<details class="ovt-work">') || text.includes('<details class="ovt-work"'), 'the work folds under the answer');++ assert.ok(text.includes('data-ovt-answer'), 'the answer is there to be read');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -329,27 +329,125 @@
@@ -329,27 +329,125 @@
return 'tool';
return 'tool';
};
};
const attemptN = Number(live.dataset.attempt || 0);
const attemptN = Number(live.dataset.attempt || 0);
+ const MD = window.CodesMarkdown;
+ // The live line says what the agent is doing, in words: "Editing server/app.js".
+ const VERB = { read: 'Reading', find: 'Searching', edit: 'Editing', run: 'Running', web: 'Browsing', sub: 'Handing over', tool: 'Using' };
+ // What a tool call was given: its one line, whatever shape the agent wrote it in.
+ const whatOf = (b) => {
+ if (b.what) return String(b.what);
+ const i = b.input;
+ if (!i) return '';
+ if (typeof i === 'string') return i;
+ return String(i.file_path || i.path || i.command || i.pattern || i.query || i.url || '');
+ };
const es = page.es = new EventSource(stream);
const es = page.es = new EventSource(stream);
- let reloading = false;
- const reload = () => { if (reloading) return; reloading = true; es.close(); setTimeout(() => location.reload(), 500); };
+ // The answer grows as the text arrives: it is drawn again at most every 120 ms, and the page follows only when
+ // the reader was at the bottom (the kit's rule).
+ let buffer = '', painted = 0, timer = 0, ended = false;
+ const nearBottom = () => (window.innerHeight || 0) + (window.scrollY || 0) >= (document.documentElement.scrollHeight || 0) - 220;
+ const answerOf = () => {
+ let node = live.querySelector('[data-ovt-answer]');
+ if (!node) {
+ node = document.createElement('div');
+ node.className = 'ovt-answer';
+ node.setAttribute('data-ovt-answer', '');
+ const reply = live.querySelector('.ovt-reply');
+ if (reply) reply.appendChild(node);
+ }
+ return node;
+ };
+ const paint = () => {
+ timer = 0;
+ painted = Date.now();
+ const node = answerOf();
+ if (!node) return;
+ const stay = nearBottom();
+ if (MD) { node.innerHTML = MD.markdown(buffer); if (MD.enhance) MD.enhance(node); }
+ else node.textContent = buffer;
+ if (stay) window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'auto' });
+ };
+ const schedule = () => {
+ if (timer) return;
+ timer = setTimeout(paint, Math.max(0, 120 - (Date.now() - painted)));
+ };
+ // A tool's output folds under its row, and the fold says how much is in it.
+ const foldOutput = (key, output) => {
+ if (output == null) return;
+ const rows = live.querySelectorAll('[data-ovt-work] > .ovt-w-tool');
+ let row = null;
+ for (const r of rows) if (String(r.getAttribute('data-key')) === String(key)) row = r;
+ if (!row) return;
+ const head = row.querySelector('.ovt-w-row');
+ if (head && MD) {
+ const mark = head.querySelector('.ovt-w-st');
+ const label = document.createElement('span');
+ label.className = 'ovt-w-in ovt-w-fold';
+ label.textContent = MD.outputLabel(output);
+ head.insertBefore(label, mark);
+ }
+ const det = document.createElement('details');
+ const sum = document.createElement('summary');
+ if (head) sum.appendChild(head);
+ det.appendChild(sum);
+ det.insertAdjacentHTML('beforeend', MD ? MD.outputBody(output) : '');
+ row.appendChild(det);
+ };
+ // The finished turn comes from the server: its answer has its Copy buttons and its work its output folds.
+ const finish = () => {
+ if (ended) return;
+ ended = true;
+ es.close();
+ const swap = (again) => {
+ fetch(window.location.href, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
+ .then((r) => (r.ok ? r.text() : ''))
+ .then((html) => {
+ if (!html) { location.reload(); return; }
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const fresh = doc.querySelector('[data-turn="' + id + '"]');
+ if (!fresh) { location.reload(); return; }
+ if (again && fresh.hasAttribute('data-open')) { setTimeout(() => swap(false), 900); return; }
+ OV.replace(id, fresh.outerHTML);
+ // The composer stops working with the turn.
+ const form = $('[data-ovt-composer]', app);
+ if (form) {
+ form.classList.remove('is-busy');
+ const stop = $('[data-ovt-stop]', form);
+ if (stop) stop.hidden = true;
+ }
+ })
+ .catch(() => location.reload());
+ };
+ setTimeout(swap, 400);
+ };
es.addEventListener('line', (ev) => {
es.addEventListener('line', (ev) => {
let d; try { d = JSON.parse(ev.data); } catch { return; }
let d; try { d = JSON.parse(ev.data); } catch { return; }
if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;
if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;
const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };
const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };
if (b.t === 'tool') {
if (b.t === 'tool') {
- OV.tool(id, { name: b.name || 'tool', input: b.what || '', icon: kindOf(b.name), key: b.id });
- OV.live(id, `${b.name || 'Working'} ${b.what || ''}`.trim());
+ const what = whatOf(b);
+ OV.tool(id, { name: b.name || 'tool', input: what, icon: kindOf(b.name), key: b.id });
+ OV.live(id, `${VERB[kindOf(b.name)] || 'Using'} ${what}`.trim());
} else if (b.t === 'tool_result') {
} else if (b.t === 'tool_result') {
OV.toolDone(id, b.ok !== false, b.id);
OV.toolDone(id, b.ok !== false, b.id);
+ foldOutput(b.id, b.output);
} else if (b.t === 'text' && b.text) {
} else if (b.t === 'text' && b.text) {
- OV.live(id, String(b.text).split('\n').find(Boolean) || 'Working');
- } else if (b.t === 'done' || b.t === 'end' || d.kind === 'end') reload();
+ const t = String(b.text);
+ // The words may arrive as a whole message or as a piece of one; either way the answer holds all of them.
+ buffer = buffer && !t.startsWith(buffer) ? buffer + t : t;
+ OV.live(id, t.split('\n').find(Boolean) || 'Working');
+ schedule();
+ } else if (b.t === 'done') {
+ if (b.text) buffer = String(b.text);
+ schedule();
+ finish();
+ } else if (b.t === 'end' || d.kind === 'end') finish();
});
});
es.addEventListener('attempt', (ev) => {
es.addEventListener('attempt', (ev) => {
let d; try { d = JSON.parse(ev.data); } catch { return; }
let d; try { d = JSON.parse(ev.data); } catch { return; }
// The stream replays every attempt on connect: only the live one ending, or a newer one starting, is news.
// The stream replays every attempt on connect: only the live one ending, or a newer one starting, is news.
- if ((d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) || (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN)) reload();
+ if (d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) finish();
+ else if (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN) { es.close(); location.reload(); }
});
});
- es.addEventListener('end', reload);
+ es.addEventListener('end', finish);
}
}
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+/**
+ * Codes' Markdown for the conversation kit (openvibe-shared/thread + ov-thread.js): one renderer, used by the server
+ * to draw a turn and by the browser to draw the same turn again while the text streams in. Every character is escaped
+ * first and the markup is added afterwards, so an agent's words can never become HTML.
+ *
+ * The markup is the kit's: an answer is .ovt-answer, a code block carries its language and a Copy button, a table
+ * scrolls inside itself (thread.css) and a link is marked nofollow. Nothing here is styled: the kit draws it.
+ *
+ * const md = require('../../public/js/md.js'); // on the server
+ * <script src="/js/md.js"></script> // in the browser: window.CodesMarkdown
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ if (root) root.CodesMarkdown = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
+ // Only these may become a link; anything else stays words.
+ const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;
+
+ /** The inline markup of one line: code, links, bold, italic. The text is already escaped. */
+ function inline(text) {
+ const codes = [];
+ let out = text.replace(/`([^`\n]+)`/g, (m, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
+ out = out.replace(/\[([^\]\n]*)\]\(([^)\s]+)\)/g, (m, label, url) => (
+ SAFE_URL.test(url) ? '<a href="' + url + '" rel="nofollow ugc noopener" target="_blank">' + label + '</a>' : m
+ ));
+ out = out.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
+ out = out.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
+ out = out.replace(/\u0000(\d+)\u0000/g, (m, i) => '<code>' + codes[Number(i)] + '</code>');
+ return out;
+ }
+
+ const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
+ const isDivider = (line) => /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line) && line.includes('-');
+
+ /** One Markdown answer, as the kit's answer markup. */
+ function markdown(source) {
+ const lines = String(source == null ? '' : source).replace(/\r\n?/g, '\n').split('\n');
+ const out = [];
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ if (!line.trim()) { i++; continue; }
+ // A fenced code block: its language, its text, and the Copy button the kit draws on it.
+ const fence = /^\s*```\s*([A-Za-z0-9+#._-]*)\s*$/.exec(line);
+ if (fence) {
+ const lang = fence[1] || '';
+ const body = [];
+ i++;
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { body.push(lines[i]); i++; }
+ i++;
+ const label = lang ? '<p class="ovt-code-lang"><span class="ovt-w-in">' + esc(lang) + '</span></p>' : '';
+ out.push(label + '<pre><code' + (lang ? ' data-lang="' + esc(lang) + '" class="language-' + esc(lang) + '"' : '') + '>' + esc(body.join('\n')) +
+ '</code><button type="button" class="ovt-code-copy" data-ovt-code-copy hidden>Copy</button></pre>');
+ continue;
+ }
+ const head = /^(#{1,6})\s+(.*)$/.exec(line);
+ if (head) {
+ const level = Math.min(6, head[1].length + 1);
+ out.push('<h' + level + '>' + inline(esc(head[2])) + '</h' + level + '>');
+ i++;
+ continue;
+ }
+ if (/^\s*>\s?/.test(line)) {
+ const body = [];
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
+ out.push('<blockquote><p>' + inline(esc(body.join(' '))) + '</p></blockquote>');
+ continue;
+ }
+ const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
+ if (item) {
+ const ordered = /\d/.test(item[2]);
+ const items = [];
+ while (i < lines.length) {
+ const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
+ if (!m) break;
+ items.push('<li>' + inline(esc(m[3])) + '</li>');
+ i++;
+ }
+ const tag = ordered ? 'ol' : 'ul';
+ out.push('<' + tag + '>' + items.join('') + '</' + tag + '>');
+ continue;
+ }
+ if (line.includes('|') && i + 1 < lines.length && isDivider(lines[i + 1])) {
+ const head2 = cells(line);
+ i += 2;
+ const body = [];
+ while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { body.push(cells(lines[i])); i++; }
+ out.push('<table><thead><tr>' + head2.map((h) => '<th>' + inline(esc(h)) + '</th>').join('') + '</tr></thead><tbody>' +
+ body.map((r) => '<tr>' + r.map((c) => '<td>' + inline(esc(c)) + '</td>').join('') + '</tr>').join('') + '</tbody></table>');
+ continue;
+ }
+ // A paragraph: until a blank line or the next kind of block.
+ const para = [];
+ while (i < lines.length && lines[i].trim() && !/^\s*```/.test(lines[i]) && !/^#{1,6}\s/.test(lines[i])
+ && !/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) && !/^\s*>\s?/.test(lines[i])
+ && !(lines[i].includes('|') && i + 1 < lines.length && isDivider(lines[i + 1]))) {
+ para.push(lines[i]);
+ i++;
+ }
+ if (!para.length) { i++; continue; }
+ out.push('<p>' + inline(esc(para.join('\n')).replace(/\n/g, '<br>')) + '</p>');
+ }
+ return out.join('');
+ }
+
+ // A tool's output: long output folds, and the fold says how much is in it. Never hidden without a count.
+ const OUT_LIMIT = 4000;
+ function outputLabel(output) {
+ const text = String(output == null ? '' : output);
+ const cut = text.length > OUT_LIMIT;
+ const n = text.slice(0, OUT_LIMIT).split('\n').length;
+ return 'Output (' + n + ' line' + (n === 1 ? '' : 's') + (cut ? ', cut' : '') + ')';
+ }
+ function outputBody(output) {
+ const text = String(output == null ? '' : output);
+ return '<pre class="ovt-w-out">' + esc(text.slice(0, OUT_LIMIT)) + '</pre>';
+ }
+
+ /**
+ * The Copy button on a code block: the kit draws one on every answer's code, but it is drawn in the browser, so
+ * the server's own button stays hidden until this shows it and gives it something to do.
+ */
+ function enhance(root) {
+ if (!root || typeof document === 'undefined') return root;
+ const list = root.querySelectorAll ? root.querySelectorAll('[data-ovt-code-copy]') : [];
+ for (const b of list) {
+ if (b.dataset.ovtReady) continue;
+ b.dataset.ovtReady = '1';
+ b.hidden = false;
+ b.addEventListener('click', () => {
+ const pre = b.closest('pre');
+ const code = pre && pre.querySelector('code');
+ const text = code ? code.textContent : '';
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).catch(() => { /* a refused clipboard changes nothing */ });
+ });
+ }
+ return root;
+ }
+
+ if (typeof document !== 'undefined') {
+ const boot = () => enhance(document);
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
+ else boot();
+ document.addEventListener('ov:boost:load', boot);
+ }
+
+ return { markdown, escape: esc, clip, outputLabel, outputBody, enhance };
+});
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
const express = require('express');
const express = require('express');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { raw, esc } = require('../render/html');
const { raw, esc } = require('../render/html');
-const { markdown } = require('../render/markdown');
+const md = require('../../public/js/md.js');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
const { sessionLogOf } = require('../domain/run-tasks');
const models = require('../data/models');
const models = require('../data/models');
@@ -228,14 +228,40 @@ function createChatRoutes(ctx) {
@@ -228,14 +228,40 @@ function createChatRoutes(ctx) {
// A conversation is a list of turns: what the person said, and the agent's reply under its face and name. A
// A conversation is a list of turns: what the person said, and the agent's reply under its face and name. A
// hand-over to another agent, or a wait, is a line across the thread. How a task ended is a card in its last reply.
// hand-over to another agent, or a wait, is a line across the thread. How a task ended is a card in its last reply.
const STEP_ICON = { read: 'read', find: 'find', edit: 'edit', run: 'run', web: 'web', sub: 'sub', tool: 'tool' };
const STEP_ICON = { read: 'read', find: 'find', edit: 'edit', run: 'run', web: 'web', sub: 'sub', tool: 'tool' };
+ // The live line says what the agent is doing, in words: "Editing server/app.js".
+ const STEP_VERB = { read: 'Reading', find: 'Searching', edit: 'Editing', run: 'Running', web: 'Browsing', sub: 'Handing over', tool: 'Using' };
+ /** What a tool call was given: its one line, whatever shape the agent wrote it in. */
+ function toolWhat(b) {
+ if (b.what) return String(b.what);
+ const i = b.input;
+ if (!i) return '';
+ if (typeof i === 'string') return i;
+ return String(i.file_path || i.path || i.command || i.pattern || i.query || i.url || '');
+ }
const errorRow = (msg) => thread.turn({ avatar: false, reply: thread.card({ tone: 'bad', text: msg }), attrs: { role: 'alert' } });
const errorRow = (msg) => thread.turn({ avatar: false, reply: thread.card({ tone: 'bad', text: msg }), attrs: { role: 'alert' } });
+ /** One tool as a row of the kit's work list: its name, the one line it was given, and how it ended. */
+ function workRow(s) {
+ const st = s.ok == null ? (s.running ? 'is-run' : 'is-done') : (s.ok ? 'is-ok' : 'is-bad');
+ const mark = st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : st === 'is-done' ? '' : thread.icon(st === 'is-ok' ? 'ok' : 'bad');
+ const what = s.what ? `<span class="ovt-w-in">${esc(md.clip(s.what, 90))}</span>` : '';
+ const head = `${thread.icon(STEP_ICON[s.kind] || 'tool')}<b>${esc(s.name || 'Tool')}</b>${what}`;
+ if (s.output == null && st !== 'is-run') return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row">${head}<span class="ovt-w-st">${mark}</span></div></li>`;
+ // Long output folds, and the fold says how much is in it: never hidden without a count.
+ const count = s.output != null ? `<span class="ovt-w-in ovt-w-fold">${esc(md.outputLabel(s.output))}</span>` : '';
+ const body = s.output != null ? md.outputBody(s.output) : '<p class="ovt-w-out ovt-muted">Running…</p>';
+ return `<li class="ovt-w-tool ${st}"><details><summary>${head}${count}<span class="ovt-w-st">${mark}</span></summary>${body}</details></li>`;
+ }
/** The steps an agent took, as the kit's work rows: what it said along the way is a note, each tool a row. */
/** The steps an agent took, as the kit's work rows: what it said along the way is a note, each tool a row. */
function workHtml(steps, ms, { live = false, liveText = '' } = {}) {
function workHtml(steps, ms, { live = false, liveText = '' } = {}) {
if (!steps.length && !live) return '';
if (!steps.length && !live) return '';
const rows = steps.map((s, i) => (s.said
const rows = steps.map((s, i) => (s.said
- ? { kind: 'note', text: s.what }
- : { kind: 'tool', name: s.name, input: s.what || '', icon: STEP_ICON[s.kind] || 'tool', ok: s.ok == null ? undefined : s.ok, running: live && s.ok == null && i === steps.length - 1 ? true : false }));
- return thread.work({ open: live, live: liveText || 'Starting', took: ms ? minutes(ms) : null, rows });
+ ? `<li class="ovt-w-note">${thread.icon('note')}<p>${esc(s.what)}</p></li>`
+ : workRow(s.ok == null && live && i === steps.length - 1 ? { ...s, running: true } : s))).join('');
+ const tools = steps.filter((s) => !s.said).length;
+ const line = live
+ ? `<span class="ovt-w-live" data-ovt-live>${esc(liveText || 'Starting')}…</span>`
+ : `<span>${ms ? `Worked for ${esc(minutes(ms))}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;
+ return `<details class="ovt-work"${live ? ' open data-open' : ''}><summary>${line}${thread.icon('chev', 'ovt-chev')}</summary><ol class="ovt-w-list" data-ovt-work>${rows}</ol></details>`;
}
}
/** One agent's reply from its session lines: the steps it took, then what it said last (its answer). */
/** One agent's reply from its session lines: the steps it took, then what it said last (its answer). */
function agentReply(a, lines, { live = false, attempt = null } = {}) {
function agentReply(a, lines, { live = false, attempt = null } = {}) {
@@ -245,8 +271,8 @@ function createChatRoutes(ctx) {
@@ -245,8 +271,8 @@ function createChatRoutes(ctx) {
for (const l of lines) {
for (const l of lines) {
const b = asObject(l.body) || { t: 'text', text: String(l.body || '') };
const b = asObject(l.body) || { t: 'text', text: String(l.body || '') };
const t = b.t || l.kind;
const t = b.t || l.kind;
- if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: b.what || '', kind: stepKind(b.name), ok: null });
- else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) s.ok = b.ok !== false; }
+ if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: toolWhat(b), kind: stepKind(b.name), ok: null, output: null });
+ else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) { s.ok = b.ok !== false; if (b.output != null) s.output = String(b.output); } }
else if (t === 'text' && b.text) said.push(String(b.text));
else if (t === 'text' && b.text) said.push(String(b.text));
else if (t === 'done') done = b;
else if (t === 'done') done = b;
else if (t === 'note' && b.text) said.push(`> ${String(b.text)}`);
else if (t === 'note' && b.text) said.push(`> ${String(b.text)}`);
@@ -259,8 +285,8 @@ function createChatRoutes(ctx) {
@@ -259,8 +285,8 @@ function createChatRoutes(ctx) {
const all = [...along.map((text) => ({ said: true, what: firstLine(text, 140) })), ...steps];
const all = [...along.map((text) => ({ said: true, what: firstLine(text, 140) })), ...steps];
const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';
const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';
const lastStep = steps[steps.length - 1];
const lastStep = steps[steps.length - 1];
- const liveText = lastStep ? `${lastStep.name} ${lastStep.what || ''}`.trim() : '';
- const body = answer ? thread.answer({ html: markdown(answer) })
+ const liveText = lastStep ? `${STEP_VERB[lastStep.kind] || 'Using'} ${lastStep.what || ''}`.trim() : '';
+ const body = answer ? thread.answer({ html: md.markdown(answer) })
: live ? '' : `<p class="ovt-muted">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;
: live ? '' : `<p class="ovt-muted">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;
return { who: { ...a, sub: ran }, reply: workHtml(all, ms, { live, liveText }) + body };
return { who: { ...a, sub: ran }, reply: workHtml(all, ms, { live, liveText }) + body };
}
}
@@ -318,7 +344,7 @@ function createChatRoutes(ctx) {
@@ -318,7 +344,7 @@ function createChatRoutes(ctx) {
const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: m.model || row.model } });
const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: m.model || row.model } });
out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));
out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));
} else if (m.result && m.result.summary) {
} else if (m.result && m.result.summary) {
- out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));
+ out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: md.markdown(m.result.summary) }) }));
} else if (m.error) {
} else if (m.error) {
out.push(thread.turn({ id, you: mine, who, reply: `<p class="ovt-muted">${esc(m.error)}</p>` }));
out.push(thread.turn({ id, you: mine, who, reply: `<p class="ovt-muted">${esc(m.error)}</p>` }));
} else if (m.state === 'queued') {
} else if (m.state === 'queued') {
@@ -429,7 +455,7 @@ function createChatRoutes(ctx) {
@@ -429,7 +455,7 @@ function createChatRoutes(ctx) {
cls: page != null ? 'is-page' : '',
cls: page != null ? 'is-page' : '',
});
});
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });
}
}
/** The panel: who worked on it, and where the work is. */
/** The panel: who worked on it, and where the work is. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+// Codes' turns in the network's conversation kit (openvibe-shared/thread): a turn is what the person said and what
+// the agent answered, the answer is Markdown (headings, lists, tables, code with its language and a Copy button,
+// links marked nofollow), each tool call is a row with its name, its one line and how it ended, and a long output
+// folds behind a fold that says how much is in it. Nothing an agent writes ever becomes HTML.
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const md = require('../public/js/md.js');
+
+(async () => {
+ const t = await boot();
+ const ada = t.network.addUser('kit-ada');
+ const db = t.ctx.store.db;
+ const now = Date.now();
+ const P = 'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW';
+ const MINE = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FD1';
+ const ANSWER = [
+ '# What changed',
+ '',
+ 'The rail scrolls sideways now. See [the notes](https://example.com/notes).',
+ '',
+ '- one line',
+ '- another',
+ '',
+ '```js',
+ 'const rail = document.querySelector(".ovt-rail");',
+ '```',
+ '',
+ '| file | lines |',
+ '| --- | --- |',
+ '| public/css/chat.css | 12 |',
+ '',
+ 'And a script tag: <script>alert("no")</script>',
+ ].join('\n');
+ const OUTPUT = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`).join('\n');
+ try {
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at)
+ VALUES ($1, $2, $3, 'https://github.com/OpenVibers/OpenVibe.Live', 'main', 'Make the rail scroll sideways', 'claude-code', 'succeeded', '{}'::jsonb, $4, $4)`,
+ [MINE, ada.subject, P, now - 60_000]);
+ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, model, state, why, kind, created_at)
+ VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FE1', $1, 1, 'claude-code', 'opus', 'done', 'The rail scrolls sideways', 'first', now() - interval '50 minutes')`, [MINE]);
+ await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES
+ ($1, 1, now() - interval '49 minutes', 'tool', $2::jsonb, 1),
+ ($1, 2, now() - interval '48 minutes', 'tool_result', $3::jsonb, 1),
+ ($1, 3, now() - interval '47 minutes', 'done', $4::jsonb, 1)`,
+ [MINE,
+ JSON.stringify({ t: 'tool', id: 'call-1', name: 'Edit', what: 'public/css/chat.css' }),
+ JSON.stringify({ t: 'tool_result', id: 'call-1', ok: true, output: OUTPUT }),
+ JSON.stringify({ t: 'done', ok: true, text: ANSWER })]);
+
+ const page = await t.get(`/tasks/${MINE}`, { as: ada });
+ assert.strictEqual(page.status, 200);
+
+ await check('the answer is Markdown: headings, lists, a table, code with its language and Copy, safe links', async () => {
+ const text = page.text;
+ assert.ok(text.includes('<div class="ovt-answer" data-ovt-answer>'), 'the answer is the kit\'s own');
+ assert.ok(text.includes('<h2>What changed</h2>'), 'a heading');
+ assert.ok(text.includes('<ul><li>one line</li><li>another</li></ul>'), 'a list');
+ assert.ok(text.includes('<table><thead><tr><th>file</th><th>lines</th></tr></thead>'), 'a table');
+ assert.ok(text.includes('<code data-lang="js" class="language-js">'), 'the code block says its language');
+ assert.ok(text.includes('data-ovt-code-copy'), 'the code block has a Copy button');
+ assert.ok(text.includes('data-ovt-copy'), 'and the answer has one too');
+ assert.ok(text.includes('<a href="https://example.com/notes" rel="nofollow ugc noopener" target="_blank">the notes</a>'), 'a link says what it is');
+ });
+
+ await check('a tool call is a row: its name, the one line it was given, and how it ended', async () => {
+ const text = page.text;
+ assert.ok(text.includes('<li class="ovt-w-tool is-ok">'), 'the row is the kit\'s, and it worked');
+ assert.ok(text.includes('<b>Edit</b>'), 'the name');
+ assert.ok(text.includes('<span class="ovt-w-in">public/css/chat.css</span>'), 'the one line it was given');
+ assert.ok(text.includes('<span class="ovt-w-st">'), 'and the mark that says how it ended');
+ });
+
+ await check('a long output folds, and the fold says how much is in it', async () => {
+ const text = page.text;
+ assert.ok(text.includes('Output (12 lines)'), 'the count is on the fold');
+ assert.ok(text.includes('<details>') && text.includes('<pre class="ovt-w-out">'), 'the output is folded away');
+ });
+
+ await check('the renderer itself escapes first and marks up afterwards', async () => {
+ const html = md.markdown('# Title\n\nA [link](https://example.com) and <script>x</script>');
+ assert.ok(html.includes('<h2>Title</h2>'), 'a heading');
+ assert.ok(html.includes('rel="nofollow ugc noopener"'), 'a link says what it is');
+ assert.ok(html.includes('<script>x</script>'), 'and a script tag stays words');
+ assert.ok(!html.includes('<script>'), 'never a script');
+ });
+
+ await check('a script tag in the agent\'s words is words, not a script', async () => {
+ const text = page.text;
+ assert.ok(text.includes('<script>alert("no")</script>'), 'it is escaped');
+ assert.ok(!text.includes('<script>alert'), 'and never a script');
+ });
+
+ await check('the turn is drawn by the server: the page says it all without a script', async () => {
+ const text = page.text;
+ assert.ok(text.includes('<li class="ovt-turn"') && text.includes('class="ovt-bubble">Make the rail scroll sideways'), 'the turn and the brief are in the page');
+ assert.ok(text.includes('<details class="ovt-work">') || text.includes('<details class="ovt-work"'), 'the work folds under the answer');
+ assert.ok(text.includes('data-ovt-answer'), 'the answer is there to be read');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });