mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
feat(desktop): inline embed detection + module primitives
Pure, synchronous URL→descriptor matchers for YouTube, Vimeo, Instagram, Pinterest, TikTok, X, Spotify, Google Maps and OpenStreetMap, plus the shared embed primitives (error boundary, fail card, escape-html, dark-mode hook, sizing token). Declares the mermaid + dompurify deps used by the fenced renderers.
This commit is contained in:
parent
7d568293f9
commit
81ac562bf0
19 changed files with 666 additions and 0 deletions
|
|
@ -81,11 +81,13 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"node-pty": "1.1.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
// Shared height cap for inline embeds. Ratio embeds cap their width off this in
|
||||
// UrlEmbed so height follows the aspect ratio; fenced renderers (mermaid, svg)
|
||||
// reuse it directly. Pure CSS — no measuring.
|
||||
export const EMBED_MAX_H = '33dvh'
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
7
apps/desktop/src/components/assistant-ui/embeds/fail.tsx
Normal file
7
apps/desktop/src/components/assistant-ui/embeds/fail.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function EmbedFail({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="grid min-h-32 w-full place-items-center p-4">
|
||||
<span className="text-xs font-medium text-(--ui-red)">Failed to load {label} embed</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { FrameEmbed, TweetEmbed } from './types'
|
||||
|
||||
import { detectEmbed, isEmbeddableUrl } from './index'
|
||||
|
||||
function frame(url: string): FrameEmbed {
|
||||
const descriptor = detectEmbed(url)
|
||||
|
||||
if (!descriptor || descriptor.renderer !== 'frame') {
|
||||
throw new Error(`expected a frame embed for ${url}`)
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
|
||||
describe('detectEmbed — YouTube', () => {
|
||||
it.each([
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://youtu.be/dQw4w9WgXcQ',
|
||||
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
|
||||
'https://m.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://www.youtube.com/embed/dQw4w9WgXcQ',
|
||||
'https://www.youtube.com/live/dQw4w9WgXcQ'
|
||||
])('resolves %s to the privacy-enhanced embed of the same id', url => {
|
||||
const embed = frame(url)
|
||||
|
||||
expect(embed.provider).toBe('youtube')
|
||||
expect(embed.id).toBe('youtube:dQw4w9WgXcQ')
|
||||
expect(embed.embedUrl).toContain('youtube-nocookie.com/embed/dQw4w9WgXcQ')
|
||||
})
|
||||
|
||||
it('carries a start time from t/start through to the embed', () => {
|
||||
expect(frame('https://youtu.be/dQw4w9WgXcQ?t=90').embedUrl).toContain('start=90')
|
||||
expect(frame('https://youtu.be/dQw4w9WgXcQ?t=1m30s').embedUrl).toContain('start=90')
|
||||
})
|
||||
|
||||
it('rejects ids that are not 11 chars', () => {
|
||||
expect(detectEmbed('https://www.youtube.com/watch?v=short')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectEmbed — other frame providers', () => {
|
||||
it('resolves Vimeo numeric ids across path shapes', () => {
|
||||
expect(frame('https://vimeo.com/76979871').embedUrl).toBe('https://player.vimeo.com/video/76979871')
|
||||
expect(frame('https://vimeo.com/channels/staffpicks/76979871').id).toBe('vimeo:76979871')
|
||||
})
|
||||
|
||||
it('resolves Instagram posts and reels', () => {
|
||||
expect(frame('https://www.instagram.com/p/CabcDEF123/').embedUrl).toBe(
|
||||
'https://www.instagram.com/p/CabcDEF123/embed'
|
||||
)
|
||||
expect(frame('https://www.instagram.com/reel/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
|
||||
expect(frame('https://www.instagram.com/reels/CabcDEF123/').embedUrl).toContain('/reel/CabcDEF123/embed')
|
||||
})
|
||||
|
||||
it('resolves Pinterest pins across locale hosts', () => {
|
||||
expect(frame('https://www.pinterest.com/pin/1234567890/').embedUrl).toBe(
|
||||
'https://assets.pinterest.com/ext/embed.html?id=1234567890'
|
||||
)
|
||||
expect(frame('https://fr.pinterest.com/pin/1234567890/').provider).toBe('pinterest')
|
||||
})
|
||||
|
||||
it('resolves TikTok videos to the official player', () => {
|
||||
expect(frame('https://www.tiktok.com/@user/video/7212345678901234567').embedUrl).toBe(
|
||||
'https://www.tiktok.com/player/v1/7212345678901234567'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves Spotify tracks, collections, and locale-prefixed urls', () => {
|
||||
expect(frame('https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT').embedUrl).toBe(
|
||||
'https://open.spotify.com/embed/track/4cOdK2wGLETKBW3PvgPWqT'
|
||||
)
|
||||
expect(frame('https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M').provider).toBe('spotify')
|
||||
expect(frame('https://open.spotify.com/intl-de/album/1DFixLWuPkv3KT3TnV35m3').id).toBe(
|
||||
'spotify:album:1DFixLWuPkv3KT3TnV35m3'
|
||||
)
|
||||
expect(detectEmbed('https://open.spotify.com/track/')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectEmbed — maps', () => {
|
||||
it('resolves Google Maps coordinates with zoom', () => {
|
||||
const embed = frame('https://www.google.com/maps/@40.7128,-74.0060,12z')
|
||||
|
||||
expect(embed.provider).toBe('googlemaps')
|
||||
expect(embed.embedUrl).toContain('output=embed')
|
||||
expect(embed.embedUrl).toContain('q=40.7128%2C-74.006')
|
||||
expect(embed.embedUrl).toContain('z=12')
|
||||
})
|
||||
|
||||
it('resolves a Google Maps place name', () => {
|
||||
expect(frame('https://www.google.com/maps/place/Eiffel+Tower/').embedUrl).toContain('q=Eiffel+Tower')
|
||||
})
|
||||
|
||||
it('resolves OpenStreetMap fragment state to a bbox embed', () => {
|
||||
const embed = frame('https://www.openstreetmap.org/#map=12/40.7128/-74.0060')
|
||||
|
||||
expect(embed.provider).toBe('openstreetmap')
|
||||
expect(embed.embedUrl).toContain('export/embed.html')
|
||||
expect(embed.embedUrl).toContain('marker=40.7128%2C-74.006')
|
||||
expect(embed.embedUrl).toContain('bbox=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectEmbed — Twitter/X', () => {
|
||||
it('resolves twitter.com and x.com status urls to a tweet descriptor', () => {
|
||||
for (const url of ['https://twitter.com/jack/status/20', 'https://x.com/jack/status/20']) {
|
||||
const descriptor = detectEmbed(url)
|
||||
|
||||
expect(descriptor?.renderer).toBe('tweet')
|
||||
expect((descriptor as TweetEmbed).tweetId).toBe('20')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectEmbed — non-matches', () => {
|
||||
it.each([
|
||||
'https://example.com/watch?v=dQw4w9WgXcQ',
|
||||
'https://github.com/NousResearch/hermes',
|
||||
'not-a-url',
|
||||
'ftp://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
'mailto:someone@youtube.com'
|
||||
])('returns null for %s', url => {
|
||||
expect(detectEmbed(url)).toBeNull()
|
||||
expect(isEmbeddableUrl(url)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles empty input without throwing', () => {
|
||||
expect(detectEmbed(undefined)).toBeNull()
|
||||
expect(detectEmbed('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { instagram } from './instagram'
|
||||
import { maps } from './maps'
|
||||
import { pinterest } from './pinterest'
|
||||
import { spotify } from './spotify'
|
||||
import { tiktok } from './tiktok'
|
||||
import { twitter } from './twitter'
|
||||
import type { EmbedDescriptor, EmbedMatcher } from './types'
|
||||
import { vimeo } from './vimeo'
|
||||
import { youtube } from './youtube'
|
||||
|
||||
export type { EmbedDescriptor, EmbedProvider, EmbedRenderer, FrameEmbed, TweetEmbed } from './types'
|
||||
|
||||
// All provider hosts are disjoint, so order is irrelevant — first match wins.
|
||||
const MATCHERS: EmbedMatcher[] = [youtube, vimeo, instagram, pinterest, tiktok, twitter, spotify, maps]
|
||||
|
||||
function parseUrl(raw: string): URL | null {
|
||||
try {
|
||||
const url = new URL(raw)
|
||||
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL to a rich-embed descriptor, or null when no provider matches.
|
||||
* Pure and synchronous — safe to call during render.
|
||||
*/
|
||||
export function detectEmbed(rawUrl: string | null | undefined): EmbedDescriptor | null {
|
||||
if (!rawUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = parseUrl(rawUrl)
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const match of MATCHERS) {
|
||||
const descriptor = match(url)
|
||||
|
||||
if (descriptor) {
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isEmbeddableUrl(rawUrl: string | null | undefined): boolean {
|
||||
return detectEmbed(rawUrl) !== null
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
export const instagram: EmbedMatcher = url => {
|
||||
if (bareHost(url.hostname) !== 'instagram.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
const [typeRaw, code] = url.pathname.split('/').filter(Boolean)
|
||||
const type = typeRaw === 'reels' ? 'reel' : typeRaw
|
||||
|
||||
if (!code || !['p', 'reel', 'tv'].includes(type || '') || !/^[A-Za-z0-9_-]+$/.test(code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
embedUrl: `https://www.instagram.com/${type}/${code}/embed`,
|
||||
// Placeholder height for content-visibility; embed.js self-sizes in-document.
|
||||
height: 450,
|
||||
id: `instagram:${code}`,
|
||||
label: 'Instagram',
|
||||
maxWidth: 400,
|
||||
provider: 'instagram',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { bareHost, type EmbedMatcher, type FrameEmbed } from './types'
|
||||
|
||||
// `@lat,lng` (optionally `,<zoom>z`) as it appears in Google Maps URLs.
|
||||
const LATLNG_RE = /@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,(\d+(?:\.\d+)?)z)?/
|
||||
|
||||
function googleMapsEmbed(url: URL): FrameEmbed | null {
|
||||
const host = bareHost(url.hostname)
|
||||
|
||||
if (host !== 'google.com' && host !== 'maps.google.com' && !host.startsWith('google.')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isMapsPath = host.startsWith('maps.') || url.pathname.startsWith('/maps')
|
||||
|
||||
if (!isMapsPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Prefer explicit coordinates; then a `q=` query; then a `/place/<name>`.
|
||||
const coords = url.pathname.match(LATLNG_RE)
|
||||
const placeName = url.pathname.match(/\/place\/([^/@]+)/)
|
||||
const query = url.searchParams.get('q') || url.searchParams.get('query')
|
||||
let q = ''
|
||||
let zoom = ''
|
||||
|
||||
if (coords) {
|
||||
q = `${coords[1]},${coords[2]}`
|
||||
zoom = coords[3] ? String(Math.round(Number(coords[3]))) : ''
|
||||
} else if (query) {
|
||||
q = query
|
||||
} else if (placeName) {
|
||||
q = decodeURIComponent(placeName[1].replace(/\+/g, ' '))
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return null
|
||||
}
|
||||
|
||||
// `output=embed` is the long-standing keyless Maps embed surface.
|
||||
const params = new URLSearchParams({ output: 'embed', q })
|
||||
|
||||
if (zoom) {
|
||||
params.set('z', zoom)
|
||||
}
|
||||
|
||||
return {
|
||||
aspectRatio: 16 / 10,
|
||||
embedUrl: `https://maps.google.com/maps?${params.toString()}`,
|
||||
id: `googlemaps:${q}${zoom ? `@${zoom}` : ''}`,
|
||||
label: 'Google Maps',
|
||||
maxWidth: 640,
|
||||
provider: 'googlemaps',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
function openStreetMapEmbed(url: URL): FrameEmbed | null {
|
||||
if (bareHost(url.hostname) !== 'openstreetmap.org') {
|
||||
return null
|
||||
}
|
||||
|
||||
// State lives in the fragment: `#map=<zoom>/<lat>/<lng>`.
|
||||
const match = url.hash.match(/map=(\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const zoom = Number(match[1])
|
||||
const lat = Number(match[2])
|
||||
const lng = Number(match[3])
|
||||
// Degrees spanned at this zoom; halved for the bbox half-extent.
|
||||
const lonDelta = 360 / 2 ** zoom
|
||||
const latDelta = lonDelta / 2
|
||||
|
||||
const bbox = [lng - lonDelta / 2, lat - latDelta / 2, lng + lonDelta / 2, lat + latDelta / 2]
|
||||
.map(value => value.toFixed(5))
|
||||
.join(',')
|
||||
|
||||
const params = new URLSearchParams({ bbox, layer: 'mapnik', marker: `${lat},${lng}` })
|
||||
|
||||
return {
|
||||
aspectRatio: 16 / 10,
|
||||
embedUrl: `https://www.openstreetmap.org/export/embed.html?${params.toString()}`,
|
||||
id: `openstreetmap:${lat},${lng}@${zoom}`,
|
||||
label: 'OpenStreetMap',
|
||||
maxWidth: 640,
|
||||
provider: 'openstreetmap',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export const maps: EmbedMatcher = url => googleMapsEmbed(url) || openStreetMapEmbed(url)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
export const pinterest: EmbedMatcher = url => {
|
||||
// Pinterest runs many locale TLDs (pinterest.co.uk, fr.pinterest.com, ...).
|
||||
if (!bareHost(url.hostname).includes('pinterest.')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (segments[0] !== 'pin' || !/^\d+$/.test(segments[1] || '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = segments[1]
|
||||
|
||||
return {
|
||||
embedUrl: `https://assets.pinterest.com/ext/embed.html?id=${id}`,
|
||||
// Pinterest's "small" pin size — the default card is too dominant inline.
|
||||
height: 380,
|
||||
id: `pinterest:${id}`,
|
||||
label: 'Pinterest',
|
||||
maxWidth: 236,
|
||||
provider: 'pinterest',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
// Spotify's embed has only two layouts: compact (≤152) and full (352). Any
|
||||
// in-between height renders the compact player and pads the rest with grey, so
|
||||
// we snap to the compact size for every type — tight, no dead space.
|
||||
const COMPACT_HEIGHT = 152
|
||||
const EMBED_TYPES = new Set(['album', 'artist', 'episode', 'playlist', 'show', 'track'])
|
||||
|
||||
export const spotify: EmbedMatcher = url => {
|
||||
if (bareHost(url.hostname) !== 'open.spotify.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Drop an optional locale prefix (`/intl-de/track/...`).
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
const start = segments[0]?.startsWith('intl-') ? 1 : 0
|
||||
const type = segments[start] || ''
|
||||
const id = segments[start + 1] || ''
|
||||
|
||||
if (!EMBED_TYPES.has(type) || !/^[A-Za-z0-9]+$/.test(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
embedUrl: `https://open.spotify.com/embed/${type}/${id}`,
|
||||
height: COMPACT_HEIGHT,
|
||||
id: `spotify:${type}:${id}`,
|
||||
label: 'Spotify',
|
||||
maxWidth: 480,
|
||||
provider: 'spotify',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
export const tiktok: EmbedMatcher = url => {
|
||||
if (bareHost(url.hostname) !== 'tiktok.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
const videoIndex = segments.indexOf('video')
|
||||
const id = videoIndex >= 0 ? segments[videoIndex + 1] : ''
|
||||
|
||||
if (!/^\d+$/.test(id || '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
// The official player is a clean dark video iframe (no white blockquote
|
||||
// chrome), so it goes through the plain-iframe frame path, sized 9:16.
|
||||
aspectRatio: 9 / 16,
|
||||
embedUrl: `https://www.tiktok.com/player/v1/${id}`,
|
||||
id: `tiktok:${id}`,
|
||||
label: 'TikTok',
|
||||
maxWidth: 365,
|
||||
provider: 'tiktok',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
export const twitter: EmbedMatcher = url => {
|
||||
const host = bareHost(url.hostname)
|
||||
|
||||
if (host !== 'twitter.com' && host !== 'x.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
const statusIndex = segments.indexOf('status')
|
||||
const id = statusIndex >= 0 ? segments[statusIndex + 1] : ''
|
||||
|
||||
if (!/^\d+$/.test(id || '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: `twitter:${id}`,
|
||||
label: 'X',
|
||||
maxWidth: 480,
|
||||
provider: 'twitter',
|
||||
renderer: 'tweet',
|
||||
sourceUrl: url.toString(),
|
||||
tweetId: id
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// Embed provider model. Detection is pure, synchronous, and dependency-free so
|
||||
// it is safe to run during render and trivial to unit-test. Rendering lives in
|
||||
// the lazy renderers (see ../registry.tsx) keyed off `renderer`.
|
||||
|
||||
export type EmbedProvider =
|
||||
| 'googlemaps'
|
||||
| 'instagram'
|
||||
| 'openstreetmap'
|
||||
| 'pinterest'
|
||||
| 'spotify'
|
||||
| 'tiktok'
|
||||
| 'twitter'
|
||||
| 'vimeo'
|
||||
| 'youtube'
|
||||
|
||||
/** Which lazy renderer materialises the descriptor. */
|
||||
export type EmbedRenderer = 'frame' | 'tweet'
|
||||
|
||||
interface EmbedLayout {
|
||||
/** Frame aspect ratio (width / height). For video/maps. */
|
||||
aspectRatio?: number
|
||||
/** Fixed pixel height for non-ratio embeds (Instagram, Pinterest, Spotify). */
|
||||
height?: number
|
||||
/** Max rendered width in px; falls back to the conversation column. */
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
interface BaseEmbed extends EmbedLayout {
|
||||
/** Stable id for React keys / dedupe. */
|
||||
id: string
|
||||
/** Human-facing provider name (e.g. "YouTube"). */
|
||||
label: string
|
||||
provider: EmbedProvider
|
||||
renderer: EmbedRenderer
|
||||
/** Canonical URL opened in the system browser from the card. */
|
||||
sourceUrl: string
|
||||
}
|
||||
|
||||
/** A provider whose embed is a single iframe URL (video, post, map, ...). */
|
||||
export interface FrameEmbed extends BaseEmbed {
|
||||
/** URL loaded inside the iframe. */
|
||||
embedUrl: string
|
||||
renderer: 'frame'
|
||||
}
|
||||
|
||||
/** Twitter/X ships no iframe URL — only a widget script (see social-embed.tsx). */
|
||||
export interface TweetEmbed extends BaseEmbed {
|
||||
renderer: 'tweet'
|
||||
tweetId: string
|
||||
}
|
||||
|
||||
export type EmbedDescriptor = FrameEmbed | TweetEmbed
|
||||
|
||||
/** A provider matcher. Receives a parsed http(s) URL; returns null if unmatched. */
|
||||
export type EmbedMatcher = (url: URL) => EmbedDescriptor | null
|
||||
|
||||
/** Strip a leading `www.`/`m.`/`mobile.` so host checks read cleanly. */
|
||||
export function bareHost(host: string): string {
|
||||
return host.replace(/^(?:www|m|mobile)\./i, '').toLowerCase()
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
export const vimeo: EmbedMatcher = url => {
|
||||
const host = bareHost(url.hostname)
|
||||
|
||||
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') {
|
||||
return null
|
||||
}
|
||||
|
||||
// The clip id is the last all-digits segment, covering vimeo.com/123,
|
||||
// /channels/x/123, /groups/x/videos/123, and player/video/123.
|
||||
const id = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
.find(segment => /^\d+$/.test(segment))
|
||||
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
aspectRatio: 16 / 9,
|
||||
embedUrl: `https://player.vimeo.com/video/${id}`,
|
||||
id: `vimeo:${id}`,
|
||||
label: 'Vimeo',
|
||||
maxWidth: 640,
|
||||
provider: 'vimeo',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { bareHost, type EmbedMatcher } from './types'
|
||||
|
||||
const YOUTUBE_ID_RE = /^[A-Za-z0-9_-]{11}$/
|
||||
|
||||
// `t`/`start` accept either raw seconds ("90") or the "1m30s" form.
|
||||
function startSeconds(value: string | null): number | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(value)) {
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/)
|
||||
|
||||
if (!match || !match[0]) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const seconds = Number(match[1] || 0) * 3600 + Number(match[2] || 0) * 60 + Number(match[3] || 0)
|
||||
|
||||
return seconds > 0 ? seconds : undefined
|
||||
}
|
||||
|
||||
export const youtube: EmbedMatcher = url => {
|
||||
const host = bareHost(url.hostname)
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
let id = ''
|
||||
|
||||
if (host === 'youtu.be') {
|
||||
id = segments[0] || ''
|
||||
} else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
|
||||
if (segments[0] === 'watch') {
|
||||
id = url.searchParams.get('v') || ''
|
||||
} else if (['embed', 'shorts', 'live', 'v'].includes(segments[0] || '')) {
|
||||
id = segments[1] || ''
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!YOUTUBE_ID_RE.test(id)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ modestbranding: '1', rel: '0' })
|
||||
|
||||
const start = startSeconds(url.searchParams.get('t') || url.searchParams.get('start'))
|
||||
|
||||
if (start) {
|
||||
params.set('start', String(start))
|
||||
}
|
||||
|
||||
return {
|
||||
aspectRatio: 16 / 9,
|
||||
embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`,
|
||||
id: `youtube:${id}`,
|
||||
label: 'YouTube',
|
||||
maxWidth: 640,
|
||||
provider: 'youtube',
|
||||
renderer: 'frame',
|
||||
sourceUrl: url.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
/** Rendered in place of the subtree when a render throws. */
|
||||
fallback: ReactNode
|
||||
/** Changing this clears a caught error (e.g. new source for a re-parse). */
|
||||
resetKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Local boundary for rich renderers (Mermaid parse throws, malformed SVG, a
|
||||
* provider widget blowing up). A failed embed must never blank the transcript —
|
||||
* we show the `fallback` (typically the raw source) and recover when `resetKey`
|
||||
* changes. Unlike MessageRenderBoundary this swallows ALL render errors, because
|
||||
* the blast radius is one self-contained block, not the message tree.
|
||||
*/
|
||||
export class RichBoundary extends Component<Props, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true }
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
|
||||
this.setState({ failed: false })
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
8
apps/desktop/src/components/assistant-ui/embeds/types.ts
Normal file
8
apps/desktop/src/components/assistant-ui/embeds/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Shared prop contract for fenced-block renderers (mermaid, svg). Kept in its
|
||||
// own module so renderers and the registry can both import it without a cycle.
|
||||
export interface RichFenceProps {
|
||||
code: string
|
||||
/** True while the surrounding message is still streaming. Renderers that can
|
||||
* throw on partial input (e.g. mermaid) defer until this is false. */
|
||||
streaming?: boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Tracks the app's dark/light mode off the `dark` class on <html> (set by
|
||||
// themes/context.tsx). Embeds that theme their own content (tweets) read this.
|
||||
export function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState(
|
||||
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
const observer = new MutationObserver(() => setDark(root.classList.contains('dark')))
|
||||
|
||||
observer.observe(root, { attributeFilter: ['class'], attributes: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return dark
|
||||
}
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -95,11 +95,13 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"node-pty": "1.1.0",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue