openvibers/openvibe.mediahub Commits
Video and file pages: Ask your agent chip beside Share (#319)
Changes
@@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');const { TWIN_PATHS } = require('../drive/agent-pages');const { sameOrigin } = require('./principal');--const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');++const { html, raw, table, notice, time, badge, shareButton, askAgent } = require('../render/html');const { send } = require('../render/layout');const rules = require('../drive/rules');const ui = require('../drive/ui');@@@ -463,7 +463,7 @@ ${pathBar(path, row.name)}<div class="fv-side"><h1 class="fv-name">${row.name}</h1><p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>--<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}</div>++<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div><section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2><form class="fv-share" method="post" action="/files/${row.id}/share">@@@ -44,6 +44,12 @@ function shareButton({ url = '', title = '', text = '', image = '', timeFrom = nconst summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;}++++/**++ * "Ask your agent" (openvibe-shared 4.135.0, plan Z3): one chip beside Share. A tap opens the person's Vibe in the++ * Dock with this page's context waiting in its box, unsent. text is escaped by kit.askAgent.++ */++function askAgent({ text = '' }) { return kit.askAgent({ text }); }const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column@@@ -65,4 +71,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="sconst csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;--module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton };++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton, askAgent };@@@ -29,7 +29,7 @@ const seo = require('openvibe-shared/seo');const ovServe = require('openvibe-shared/serve');const { asyncRouter } = require('../http/router');const { sameOrigin } = require('../http/principal');--const { html, raw, notice } = require('../render/html');++const { html, raw, notice, askAgent } = require('../render/html');// A recording's or clip's AI overview comes from Live, which filters it; read through the same filter here too.const frame = require('openvibe-shared/frame');const { send, asset, assetVersion, SITE_NAME } = require('./layout');@@@ -1375,7 +1375,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div></div>--<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div></div>${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}</div>@@@ -115,6 +115,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');assert.ok(r.text.includes('<script>') || r.text.includes('<img src=x'), `${where} does not show it at all`);}++ // The file page's Ask your agent chip (openvibe-shared 4.135.0): the typed name is escaped, and no link is in it.++ const filePage = await t.get(`/files/${file.id}`, { as: kim });++ const asks = filePage.text.match(/<button[^>]*data-ov-ask[^>]*>/g) || [];++ assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));++ assert.ok(asks[0].includes('data-ov-ask="About my file “<script>alert("x")') && asks[0].includes('” (Document, 50 B): "'), asks[0]);++ assert.ok(!asks[0].includes('<script>'), 'the file name is escaped in the chip');++ assert.ok(!asks[0].includes('http') && !asks[0].includes('/files/'), 'no link to the file in the chip');++ assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');++// A report note is text somebody typed, and it is escaped where staff read it.const queue = await t.get('/staff', { as: t.network.addUser('mod', { role: 'global_mod' }) });assert.ok(queue.text.includes('<b>not bold</b>'), 'the note is escaped in the queue');@@@ -379,6 +379,11 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Discuss on OpenVibe.Community'));assert.ok(r.text.includes('data-ovv-chapter'));assert.ok(r.text.includes('data-ovv-seek'));++ // The Ask your agent chip (openvibe-shared 4.135.0): beside Share, the video's title and public address.++ const asks = r.text.match(/<button[^>]*data-ov-ask[^>]*>/g) || [];++ assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));++ assert.ok(asks[0].includes('data-ov-ask="About the video “A public recording” on OpenVibe.Video (https://openvibe.video/v/101): "'), asks[0]);++ assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');});await check('the clip watch page attributes its maker and links the full recording', async () => {
@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');
@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { TWIN_PATHS } = require('../drive/agent-pages');
const { TWIN_PATHS } = require('../drive/agent-pages');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
-const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');
+const { html, raw, table, notice, time, badge, shareButton, askAgent } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const rules = require('../drive/rules');
const rules = require('../drive/rules');
const ui = require('../drive/ui');
const ui = require('../drive/ui');
@@ -463,7 +463,7 @@ ${pathBar(path, row.name)}
@@ -463,7 +463,7 @@ ${pathBar(path, row.name)}
<div class="fv-side">
<div class="fv-side">
<h1 class="fv-name">${row.name}</h1>
<h1 class="fv-name">${row.name}</h1>
<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
<p class="fv-sub"><span class="fv-kind" data-kind="${kind}">${ui.icon(ui.KINDS[kind].icon)}${ui.kindWord(row)}</span><span>${fileSize(row)}</span><span>Added ${ui.when(row.created_at)}</span></p>
-<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}</div>
+<div class="fv-actions"><a class="dr-btn is-primary" href="/files/${row.id}/download">${ui.icon('download')}Download</a>${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: pic ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '', cls: 'dr-btn' }))}${raw(askAgent({ text: `About my file “${row.name}” (${ui.kindWord(row)}, ${fileSize(row)}): ` }))}</div>
<section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2>
<section class="fv-card" id="share" aria-labelledby="h-share"><h2 id="h-share">${ui.icon('link')}Share a link</h2>
<form class="fv-share" method="post" action="/files/${row.id}/share">
<form class="fv-share" method="post" action="/files/${row.id}/share">
@@ -44,6 +44,12 @@ function shareButton({ url = '', title = '', text = '', image = '', timeFrom = n
@@ -44,6 +44,12 @@ function shareButton({ url = '', title = '', text = '', image = '', timeFrom = n
const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}${kind && id ? html` data-kind="${kind}" data-id="${id}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
}
}
+
+/**
+ * "Ask your agent" (openvibe-shared 4.135.0, plan Z3): one chip beside Share. A tap opens the person's Vibe in the
+ * Dock with this page's context waiting in its box, unsent. text is escaped by kit.askAgent.
+ */
+function askAgent({ text = '' }) { return kit.askAgent({ text }); }
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column
/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column
@@ -65,4 +71,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="s
@@ -65,4 +71,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="s
const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
-module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton };
+module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton, askAgent };
@@ -29,7 +29,7 @@ const seo = require('openvibe-shared/seo');
@@ -29,7 +29,7 @@ const seo = require('openvibe-shared/seo');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
-const { html, raw, notice } = require('../render/html');
+const { html, raw, notice, askAgent } = require('../render/html');
// A recording's or clip's AI overview comes from Live, which filters it; read through the same filter here too.
// A recording's or clip's AI overview comes from Live, which filters it; read through the same filter here too.
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
@@ -1375,7 +1375,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
@@ -1375,7 +1375,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
</div>
-<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
</div>
@@ -115,6 +115,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -115,6 +115,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.ok(r.text.includes('<script>') || r.text.includes('<img src=x'), `${where} does not show it at all`);
assert.ok(r.text.includes('<script>') || r.text.includes('<img src=x'), `${where} does not show it at all`);
}
}
+ // The file page's Ask your agent chip (openvibe-shared 4.135.0): the typed name is escaped, and no link is in it.
+ const filePage = await t.get(`/files/${file.id}`, { as: kim });
+ const asks = filePage.text.match(/<button[^>]*data-ov-ask[^>]*>/g) || [];
+ assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));
+ assert.ok(asks[0].includes('data-ov-ask="About my file “<script>alert("x")') && asks[0].includes('” (Document, 50 B): "'), asks[0]);
+ assert.ok(!asks[0].includes('<script>'), 'the file name is escaped in the chip');
+ assert.ok(!asks[0].includes('http') && !asks[0].includes('/files/'), 'no link to the file in the chip');
+ assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');
+
// A report note is text somebody typed, and it is escaped where staff read it.
// A report note is text somebody typed, and it is escaped where staff read it.
const queue = await t.get('/staff', { as: t.network.addUser('mod', { role: 'global_mod' }) });
const queue = await t.get('/staff', { as: t.network.addUser('mod', { role: 'global_mod' }) });
assert.ok(queue.text.includes('<b>not bold</b>'), 'the note is escaped in the queue');
assert.ok(queue.text.includes('<b>not bold</b>'), 'the note is escaped in the queue');
@@ -379,6 +379,11 @@ function countOccurrences(haystack, needle) {
@@ -379,6 +379,11 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('data-ovv-chapter'));
assert.ok(r.text.includes('data-ovv-chapter'));
assert.ok(r.text.includes('data-ovv-seek'));
assert.ok(r.text.includes('data-ovv-seek'));
+ // The Ask your agent chip (openvibe-shared 4.135.0): beside Share, the video's title and public address.
+ const asks = r.text.match(/<button[^>]*data-ov-ask[^>]*>/g) || [];
+ assert.strictEqual(asks.length, 1, 'one Ask your agent chip: ' + asks.join(' | '));
+ assert.ok(asks[0].includes('data-ov-ask="About the video “A public recording” on OpenVibe.Video (https://openvibe.video/v/101): "'), asks[0]);
+ assert.ok(asks[0].includes('class="ovk-ask ovk-btn'), 'the shared kit chip');
});
});
await check('the clip watch page attributes its maker and links the full recording', async () => {
await check('the clip watch page attributes its maker and links the full recording', async () => {