Merge pull request #52935 from NousResearch/bb/desktop-inline-rendering

feat(desktop): inline rich embeds, diagrams & alerts in assistant markdown
This commit is contained in:
brooklyn! 2026-06-26 13:36:43 -05:00 committed by GitHub
commit ed962104c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2002 additions and 10 deletions

View file

@ -0,0 +1,48 @@
'use strict'
const { session } = require('electron')
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
const EMBED_REFERER = 'https://www.youtube.com/'
const YOUTUBE_REFERER_HOST_RE =
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
function installEmbedRefererForSession(embedSession) {
if (!embedSession) {
return
}
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
let host = ''
try {
host = new URL(details.url).hostname
} catch {
host = ''
}
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders })
return
}
const headers = { ...details.requestHeaders }
if (!headers.Referer && !headers.referer) {
headers.Referer = EMBED_REFERER
}
callback({ requestHeaders: headers })
})
}
/** Stamp Referer on YouTube requests in the embed webview partition only. */
function installEmbedReferer() {
try {
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
} catch {
// Non-fatal: embeds still render; YouTube may show referer errors.
}
}
module.exports = { installEmbedReferer }

View file

@ -24,6 +24,7 @@ const https = require('node:https')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { installEmbedReferer } = require('./embed-referer.cjs')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
@ -7447,6 +7448,7 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
installEmbedReferer()
registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()

View file

@ -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",

View file

@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { Button } from '@/components/ui/button'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
@ -10,6 +11,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
@ -215,6 +217,8 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
@ -266,6 +270,12 @@ export function AppearanceSettings() {
{ id: 'technical', label: a.technical }
] as const
const embedOptions = [
{ id: 'ask', label: a.embedsAsk },
{ id: 'always', label: a.embedsAlways },
{ id: 'off', label: a.embedsOff }
] as const satisfies readonly { id: EmbedMode; label: string }[]
return (
<SettingsContent>
<div>
@ -425,6 +435,35 @@ export function AppearanceSettings() {
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
<ListRow
action={
<div className="flex flex-col items-end gap-1.5">
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setEmbedMode(id)
}}
options={embedOptions}
value={embedMode}
/>
{embedAllowed.length > 0 && (
<Button
onClick={() => {
triggerHaptic('selection')
clearEmbedAllowed()
}}
size="inline"
variant="text"
>
{a.embedsReset(embedAllowed.length)}
</Button>
)}
</div>
}
description={a.embedsDesc}
title={a.embedsTitle}
/>
</div>
</div>

View file

@ -0,0 +1,39 @@
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { extractAlert } from './alert'
describe('extractAlert', () => {
it('detects each GFM alert kind from the leading marker', () => {
for (const [marker, type] of [
['[!NOTE]', 'note'],
['[!TIP]', 'tip'],
['[!IMPORTANT]', 'important'],
['[!WARNING]', 'warning'],
['[!CAUTION]', 'caution']
] as const) {
const node = createElement('p', null, `${marker}\nBody text`)
const result = extractAlert(node)
expect(result?.type).toBe(type)
}
})
it('is case-insensitive on the marker', () => {
expect(extractAlert(createElement('p', null, '[!note] hi'))?.type).toBe('note')
})
it('returns null for a plain blockquote', () => {
expect(extractAlert(createElement('p', null, 'just a quote'))).toBeNull()
expect(extractAlert('no marker here')).toBeNull()
})
it('strips the marker token from the body', () => {
const result = extractAlert(createElement('p', null, '[!WARNING]\nDanger ahead'))
expect(result).not.toBeNull()
// The marker must not survive into the rendered body.
expect(JSON.stringify(result?.body)).not.toContain('[!WARNING]')
expect(JSON.stringify(result?.body)).toContain('Danger ahead')
})
})

View file

@ -0,0 +1,125 @@
import { cloneElement, isValidElement, type ReactNode } from 'react'
import { AlertCircle, AlertTriangle, type IconComponent, Info, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
export type AlertType = 'caution' | 'important' | 'note' | 'tip' | 'warning'
interface AlertStyle {
accent: string
icon: IconComponent
label: string
}
// GitHub's five alert kinds, mapped to our icon set + a tinted accent.
const ALERT_STYLES: Record<AlertType, AlertStyle> = {
caution: { accent: 'text-rose-600 dark:text-rose-400', icon: AlertTriangle, label: 'Caution' },
important: { accent: 'text-violet-600 dark:text-violet-400', icon: AlertCircle, label: 'Important' },
note: { accent: 'text-blue-600 dark:text-blue-400', icon: Info, label: 'Note' },
tip: { accent: 'text-emerald-600 dark:text-emerald-400', icon: Zap, label: 'Tip' },
warning: { accent: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle, label: 'Warning' }
}
const MARKER_RE = /^\s*\[!(note|tip|important|warning|caution)\]\s*\n?/i
function firstText(node: ReactNode): string {
if (typeof node === 'string') {
return node
}
if (typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
for (const child of node) {
const text = firstText(child)
if (text.trim()) {
return text
}
}
return ''
}
if (isValidElement(node)) {
return firstText((node.props as { children?: ReactNode }).children)
}
return ''
}
// Remove the leading `[!TYPE]` token from the first text node that carries it,
// leaving the rest of the blockquote body intact. One-shot via the `state` flag.
function stripMarker(node: ReactNode, state: { done: boolean }): ReactNode {
if (state.done) {
return node
}
if (typeof node === 'string') {
const replaced = node.replace(MARKER_RE, '')
if (replaced !== node) {
state.done = true
return replaced
}
return node
}
if (Array.isArray(node)) {
return node.map((child, index) => <Fragmentless key={index} node={stripMarker(child, state)} />)
}
if (isValidElement(node)) {
const children = (node.props as { children?: ReactNode }).children
if (children == null) {
return node
}
return cloneElement(node, undefined, stripMarker(children, state))
}
return node
}
// Tiny helper so the array branch can return keyed nodes without wrapping
// strings in extra elements (React renders the raw node).
function Fragmentless({ node }: { node: ReactNode }) {
return <>{node}</>
}
/**
* Detect a GitHub-style alert blockquote (`> [!NOTE]`). Returns the alert kind
* and the body with the marker stripped, or null for a plain blockquote.
*/
export function extractAlert(children: ReactNode): { body: ReactNode; type: AlertType } | null {
const match = firstText(children).match(MARKER_RE)
if (!match) {
return null
}
return { body: stripMarker(children, { done: false }), type: match[1].toLowerCase() as AlertType }
}
export function MarkdownAlert({ children, type }: { children: ReactNode; type: AlertType }) {
const style = ALERT_STYLES[type]
const Icon = style.icon
return (
<div
className="my-2 rounded-lg border border-border bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
data-slot="aui_markdown-alert"
>
<div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>
<Icon className="size-4 shrink-0" />
{style.label}
</div>
{children}
</div>
)
}

View file

@ -0,0 +1,55 @@
'use client'
import { type CSSProperties, useState } from 'react'
import { SplitButton } from '@/components/ui/split-button'
import { Play } from '@/lib/icons'
import { allowProvider } from '@/store/embed-consent'
import type { EmbedDescriptor } from './providers/types'
// Privacy placeholder shown before an embed reaches out to a third party. Sized
// to the embed's footprint (no layout shift). The split control mirrors the
// commit button: primary "Load" (this embed) with a caret for "Always allow
// <service>" (persisted). Global off lives in Appearance settings.
export function EmbedFacade({ descriptor, onLoad }: { descriptor: EmbedDescriptor; onLoad: () => void }) {
const [choice, setChoice] = useState('once')
const style: CSSProperties = descriptor.aspectRatio
? { aspectRatio: descriptor.aspectRatio }
: { height: descriptor.height ?? 320 }
const actions = [
{ id: 'once', label: `Load ${descriptor.label}` },
{ id: 'always', label: `Always allow ${descriptor.label}` }
]
return (
<span
className="flex size-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)/30"
style={style}
>
<SplitButton
actions={actions}
onTrigger={id => (id === 'always' ? allowProvider(descriptor.provider) : onLoad())}
onValueChange={setChoice}
primaryIcon={<Play className="size-3 translate-x-px fill-current" />}
value={choice}
/>
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{hostOf(descriptor)}</span>
</span>
)
}
function hostOf(descriptor: EmbedDescriptor): string {
// x.com posts often arrive as twitter.com links — show the current brand.
if (descriptor.provider === 'twitter') {
return 'x.com'
}
try {
return new URL(descriptor.sourceUrl).hostname.replace(/^www\./, '')
} catch {
return descriptor.label
}
}

View file

@ -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'

View file

@ -0,0 +1,3 @@
export function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

View 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>
)
}

View file

@ -0,0 +1,55 @@
'use client'
import { type CSSProperties } from 'react'
import type { FrameEmbed } from './providers/types'
import { ScrollGate } from './scroll-gate'
import { useIsDark } from './use-is-dark'
const ALLOW = 'autoplay; encrypted-media; picture-in-picture; clipboard-write; fullscreen'
// Plain iframes (not webviews): a non-scrollable cross-origin iframe lets the
// wheel chain to the transcript instead of capturing it. Maps are the one
// exception — they're interactive, so a ScrollGate blocks them until ⌘ is held.
export default function FrameEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const isMap = descriptor.provider === 'googlemaps' || descriptor.provider === 'openstreetmap'
// color-scheme makes the iframe's default (unpainted) backdrop follow the
// theme instead of flashing white at the corners / during load.
const colorScheme = isDark ? 'dark' : 'light'
const style: CSSProperties = descriptor.aspectRatio
? { aspectRatio: descriptor.aspectRatio, colorScheme }
: { colorScheme, height: descriptor.height }
if (isMap) {
return (
<div className="relative w-full overflow-hidden" style={style}>
<iframe
allow={ALLOW}
className="absolute inset-0 size-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
src={descriptor.embedUrl}
style={{ colorScheme }}
title={`${descriptor.label} embed`}
/>
<ScrollGate />
</div>
)
}
return (
<iframe
allow={ALLOW}
allowFullScreen
className="block w-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
scrolling="no"
src={descriptor.embedUrl}
style={style}
title={`${descriptor.label} embed`}
/>
)
}

View file

@ -0,0 +1,5 @@
export { extractAlert, MarkdownAlert } from './alert'
export type { EmbedDescriptor } from './providers'
export { detectEmbed, isEmbeddableUrl } from './providers'
export { RICH_FENCE_LANGUAGES, RichCodeBlock } from './registry'
export { UrlEmbed } from './url-embed'

View file

@ -0,0 +1,113 @@
'use client'
import mermaid from 'mermaid'
import { useEffect, useState } from 'react'
import { Zoomable } from '@/components/ui/zoomable'
import { copySvgAsPng } from '@/lib/svg-image'
import { cn } from '@/lib/utils'
import type { RichFenceProps } from './types'
import { useIsDark } from './use-is-dark'
let lastTheme: 'dark' | 'default' | null = null
// Re-initialise only on first use / theme flip. `securityLevel: 'strict'` makes
// mermaid sanitise label HTML and drop click handlers, so the rendered SVG is
// safe to inject.
function ensureInit(dark: boolean) {
const theme = dark ? 'dark' : 'default'
if (theme === lastTheme) {
return
}
mermaid.initialize({ fontFamily: 'inherit', securityLevel: 'strict', startOnLoad: false, theme })
lastTheme = theme
}
function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {
return (
<pre
className={cn(
'overflow-auto p-3 font-mono text-[0.7rem] leading-relaxed whitespace-pre-wrap wrap-anywhere',
muted ? 'text-muted-foreground/70' : 'text-foreground/90'
)}
>
{code}
</pre>
)
}
// Lazy chunk (pulls in mermaid). Renders ```mermaid fences as diagrams; shows
// the source while the message streams (partial syntax throws) and falls back
// to source on parse failure.
export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
const isDark = useIsDark()
const [svg, setSvg] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
if (streaming) {
return
}
let cancelled = false
setFailed(false)
void (async () => {
try {
ensureInit(isDark)
const id = `mmd-${Math.random().toString(36).slice(2)}`
const result = await mermaid.render(id, code)
if (!cancelled) {
setSvg(result.svg)
}
} catch {
if (!cancelled) {
setFailed(true)
setSvg('')
}
}
})()
return () => {
cancelled = true
}
}, [code, isDark, streaming])
if (streaming) {
return <SourcePreview code={code} muted />
}
if (failed) {
return <SourcePreview code={code} />
}
if (!svg) {
return <SourcePreview code={code} muted />
}
// Click to open the diagram full-screen with pan/zoom + copy-as-PNG. The
// overlay keeps the diagram's natural width (capped to the viewport) so it
// renders before any zoom; the inline version stays capped at 33dvh.
return (
<Zoomable
label="Open diagram"
onCopy={() => copySvgAsPng(svg)}
overlay={
<div
className="[&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[80vh] [&_svg]:max-w-[85vw]"
dangerouslySetInnerHTML={{ __html: svg }}
/>
}
>
<div
className="overflow-hidden p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</Zoomable>
)
}

View file

@ -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()
})
})

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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()
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,39 @@
'use client'
import { type ComponentType, lazy, type LazyExoticComponent, type ReactNode, Suspense } from 'react'
import { RichBoundary } from './rich-boundary'
import type { RichFenceProps } from './types'
// Root renderer for fenced code blocks: a language → lazy-renderer table. Each
// renderer is its own split chunk (mermaid pulls in the mermaid lib, svg pulls
// in DOMPurify), loaded only when a block of that language actually appears.
const LAZY_FENCE: Record<string, LazyExoticComponent<ComponentType<RichFenceProps>>> = {
mermaid: lazy(() => import('./mermaid-embed')),
svg: lazy(() => import('./svg-embed'))
}
export const RICH_FENCE_LANGUAGES: ReadonlySet<string> = new Set(Object.keys(LAZY_FENCE))
interface RichCodeBlockProps extends RichFenceProps {
/** Rendered for unhandled languages, while the chunk loads, and on failure
* (typically the normal syntax-highlighted code block). */
fallback: ReactNode
language?: string
}
export function RichCodeBlock({ code, fallback, language, streaming }: RichCodeBlockProps) {
const Renderer = language ? LAZY_FENCE[language.toLowerCase()] : undefined
if (!Renderer) {
return <>{fallback}</>
}
return (
<RichBoundary fallback={fallback} resetKey={code}>
<Suspense fallback={fallback}>
<Renderer code={code} streaming={streaming} />
</Suspense>
</RichBoundary>
)
}

View file

@ -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
}
}

View file

@ -0,0 +1,33 @@
'use client'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
/** Block wheel until ⌘/Ctrl so map embeds don't hijack transcript scroll. */
export function ScrollGate() {
const [active, setActive] = useState(false)
useEffect(() => {
const sync = (event: KeyboardEvent) => setActive(event.metaKey || event.ctrlKey)
const clear = () => setActive(false)
window.addEventListener('keydown', sync)
window.addEventListener('keyup', sync)
window.addEventListener('blur', clear)
return () => {
window.removeEventListener('keydown', sync)
window.removeEventListener('keyup', sync)
window.removeEventListener('blur', clear)
}
}, [])
return (
<div className={cn('group/gate absolute inset-0', active ? 'pointer-events-none' : 'pointer-events-auto')}>
<span className="pointer-events-none absolute bottom-2 left-2 rounded-md bg-black/55 px-1.5 py-0.5 text-[0.625rem] font-medium text-white opacity-0 transition-opacity group-hover/embed:opacity-100">
Hold to zoom
</span>
</div>
)
}

View file

@ -0,0 +1,131 @@
'use client'
import { useEffect, useRef } from 'react'
import { escapeHtml } from './escape-html'
import type { EmbedDescriptor } from './providers/types'
import { useIsDark } from './use-is-dark'
// The provider embed scripts need a REAL origin to run (they touch
// cookies/storage/postMessage), so — exactly like react-social-media-embed — we
// render the official blockquote in this document and let the script swap it for
// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and
// the scripts silently bail (white / 2px). The container is height:auto, so it
// grows to whatever the provider renders. No measuring, no forced height.
type EmbedWindow = Window &
typeof globalThis & {
instgrm?: { Embeds?: { process?: () => void } }
twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }
}
const SCRIPT: Record<string, { id: string; src: string }> = {
instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },
tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },
twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }
}
const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]
function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
const url = escapeHtml(descriptor.sourceUrl)
switch (descriptor.provider) {
case 'instagram':
return `<blockquote class="instagram-media" data-instgrm-permalink="${url}" data-instgrm-version="14" style="margin:0;width:100%;min-width:0;max-width:100%"></blockquote>`
case 'tiktok': {
const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
return `<blockquote class="tiktok-embed" cite="${url}" data-video-id="${id}" style="margin:0;max-width:100%"><section></section></blockquote>`
}
case 'twitter':
// data-chrome="transparent" drops the card background so the themed page
// shows through instead of a white box.
return `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}" data-chrome="transparent"><a href="${url}"></a></blockquote>`
default:
return ''
}
}
function loadScript(provider: string): Promise<void> {
const { id, src } = SCRIPT[provider]
// TikTok exposes no re-process API; its script rescans the document each time
// it runs, so we re-inject it. The others are loaded once and reused.
if (provider === 'tiktok') {
document.getElementById(id)?.remove()
} else if (document.getElementById(id)) {
return Promise.resolve()
}
return new Promise(resolve => {
const script = document.createElement('script')
script.async = true
script.id = id
script.onload = () => resolve()
script.onerror = () => resolve()
script.src = src
document.body.appendChild(script)
})
}
function processEmbed(provider: string, container: HTMLElement): void {
const win = window as EmbedWindow
if (provider === 'instagram') {
win.instgrm?.Embeds?.process?.()
} else if (provider === 'twitter') {
win.twttr?.widgets?.load?.(container)
}
// TikTok auto-scans on (re)injection — no manual process call.
}
export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
const isDark = useIsDark()
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const container = ref.current
if (!container) {
return
}
let cancelled = false
const timers: number[] = []
container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')
void loadScript(descriptor.provider).then(() => {
// The script renders asynchronously; nudge a few times so the embed
// settles whether the script was cached or freshly fetched.
for (const delay of PROCESS_DELAYS_MS) {
timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))
}
})
return () => {
cancelled = true
for (const timer of timers) {
clearTimeout(timer)
}
container.innerHTML = ''
}
}, [descriptor, isDark])
// The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's
// resolved scheme differs from ours, the browser paints an opaque (white)
// Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe
// to `light` to match — no mismatch, no Canvas — and data-chrome=transparent
// then lets the dark page show through. (Confirmed: mkdocs-material #6889.)
return (
<div
className="w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]"
ref={ref}
/>
)
}

View file

@ -0,0 +1,45 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import type { FrameEmbed } from './providers/types'
import { useIsDark } from './use-is-dark'
const ALLOW = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
// Spotify paints a white backdrop behind its card; theme=0 gives the dark
// player and the card wrapper's overflow-hidden clips the corners.
function spotifySrc(embedUrl: string, isDark: boolean): string {
const url = new URL(embedUrl)
url.searchParams.set('utm_source', 'generator')
if (isDark) {
url.searchParams.set('theme', '0')
}
return url.toString()
}
export default function SpotifyEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const src = useMemo(() => spotifySrc(descriptor.embedUrl, isDark), [descriptor.embedUrl, isDark])
// Match the iframe's own (light) scheme — a `dark` mismatch makes the browser
// paint an opaque white Canvas behind it. theme=0 still gives the dark player.
const style: CSSProperties = {
colorScheme: 'light',
height: descriptor.height
}
return (
<iframe
allow={ALLOW}
className="block w-full border-0 bg-transparent"
loading="lazy"
src={src}
style={style}
title="Spotify embed"
/>
)
}

View file

@ -0,0 +1,32 @@
'use client'
import DOMPurify from 'dompurify'
import { useMemo } from 'react'
import type { RichFenceProps } from './types'
// Lazy chunk (pulls in DOMPurify). Renders a ```svg fence as an image after
// hard-sanitising it: the svg profile strips scripts, event handlers, and
// foreignObject, so untrusted model output can't execute.
export default function SvgRenderer({ code }: RichFenceProps) {
const clean = useMemo(
() =>
DOMPurify.sanitize(code, {
USE_PROFILES: { svg: true, svgFilters: true }
}),
[code]
)
if (!clean.trim()) {
return null
}
// Left-aligned, capped on both axes so a large intrinsic SVG scales down
// (preserving ratio) instead of filling the column or centering.
return (
<div
className="my-2 [&_svg]:block [&_svg]:h-auto [&_svg]:w-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: clean }}
/>
)
}

View 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
}

View file

@ -0,0 +1,84 @@
'use client'
import { useStore } from '@nanostores/react'
import { type CSSProperties, lazy, Suspense, useState } from 'react'
import { PrettyLink } from '@/lib/external-link'
import { $embedAllowed, $embedMode } from '@/store/embed-consent'
import { EmbedFacade } from './embed-consent'
import { EMBED_MAX_H } from './embed-size'
import { EmbedFail } from './fail'
import type { EmbedDescriptor } from './providers/types'
import { RichBoundary } from './rich-boundary'
const FrameEmbedRenderer = lazy(() => import('./frame-embed'))
const SocialEmbedRenderer = lazy(() => import('./social-embed'))
const SpotifyEmbedRenderer = lazy(() => import('./spotify-embed'))
const YouTubeEmbedRenderer = lazy(() => import('./youtube-embed'))
function intrinsicHeight(descriptor: EmbedDescriptor): number {
if (descriptor.aspectRatio) {
return Math.round((descriptor.maxWidth ?? 640) / descriptor.aspectRatio)
}
return descriptor.height ?? 320
}
function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
// X and Instagram load their official blockquote script in-document. The tweet
// check also narrows the union to FrameEmbed for the iframe renderers below.
if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {
return <SocialEmbedRenderer descriptor={descriptor} />
}
if (descriptor.provider === 'youtube') {
return <YouTubeEmbedRenderer descriptor={descriptor} />
}
if (descriptor.provider === 'spotify') {
return <SpotifyEmbedRenderer descriptor={descriptor} />
}
return <FrameEmbedRenderer descriptor={descriptor} />
}
export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
const mode = useStore($embedMode)
const allowed = useStore($embedAllowed)
const [loaded, setLoaded] = useState(false)
// Privacy gate: don't reach out to the provider until consented. `off` keeps
// it a plain link; otherwise the placeholder shows until "Load" (this embed)
// or "Always allow" / global `always` permits the fetch.
if (mode === 'off') {
return <PrettyLink className="wrap-anywhere" href={descriptor.sourceUrl} />
}
const consented = mode === 'always' || loaded || allowed.includes(descriptor.provider)
const aspect = descriptor.aspectRatio
// Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
// scaling. Non-ratio embeds own their own height (measured / fixed).
const style: CSSProperties = {
containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
contentVisibility: 'auto',
...(aspect
? { width: `min(${descriptor.maxWidth ?? 640}px, 100%, calc(${EMBED_MAX_H} * ${aspect}))` }
: { width: descriptor.maxWidth ? `min(${descriptor.maxWidth}px, 100%)` : '100%' })
}
return (
<span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
<RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
{consented ? (
<Suspense fallback={null}>
<LazyRenderer descriptor={descriptor} />
</Suspense>
) : (
<EmbedFacade descriptor={descriptor} onLoad={() => setLoaded(true)} />
)}
</RichBoundary>
</span>
)
}

View file

@ -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
}

View file

@ -0,0 +1,47 @@
'use client'
import { useMemo } from 'react'
import type { FrameEmbed } from './providers/types'
import { useIsDark } from './use-is-dark'
const YOUTUBE_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'
function youtubeSrc(embedUrl: string): string {
const url = new URL(embedUrl)
// Only pass origin when it is an HTTP(S) origin; custom schemes (app://,
// file://) can make the player reject otherwise embeddable videos.
if (
typeof window !== 'undefined' &&
(window.location.protocol === 'http:' || window.location.protocol === 'https:') &&
window.location.origin &&
window.location.origin !== 'null'
) {
url.searchParams.set('origin', window.location.origin)
}
return url.toString()
}
// Keep this as a plain iframe and let YouTube render its native player/error UI.
export default function YouTubeEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
const isDark = useIsDark()
const src = useMemo(() => youtubeSrc(descriptor.embedUrl), [descriptor.embedUrl])
// Width is capped to the ratio by UrlEmbed, so aspect-video sizes height ≤ cap.
return (
<iframe
allow={YOUTUBE_ALLOW}
allowFullScreen
className="block aspect-video w-full border-0 bg-transparent"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
scrolling="no"
src={src}
style={{ colorScheme: isDark ? 'dark' : 'light' }}
title="YouTube embed"
/>
)
}

View file

@ -40,6 +40,8 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { tailBoundedRemend } from '@/lib/remend-tail'
import { cn } from '@/lib/utils'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
// Math rendering plugin (KaTeX). Configured once at module scope — the
// plugin is stateless beyond its internal cache so re-creating per-render
// would needlessly thrash. We use a memoizing wrapper around rehype-katex
@ -270,6 +272,17 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
}
const text = childrenToText(children)
// Bare autolink → inline rich embed when a provider matches. Labeled links
// (`[watch](url)`) stay plain. Desktop only (webview / iframe renderers).
if (window.hermesDesktop && text && normalizeExternalUrl(text) === target) {
const embed = detectEmbed(target)
if (embed) {
return <UrlEmbed descriptor={embed} />
}
}
const fallbackLabel = text && normalizeExternalUrl(text) !== target ? text : undefined
return (
@ -535,13 +548,25 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props}
/>
),
// A `> [!NOTE]`/`[!WARNING]`/... blockquote renders as a GFM alert
// callout; everything else stays a plain quote.
blockquote: ({ children, className, ...props }: ComponentProps<'blockquote'>) => {
const alert = extractAlert(children)
if (alert) {
return <MarkdownAlert type={alert.type}>{alert.body}</MarkdownAlert>
}
return (
<blockquote
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props}
>
{children}
</blockquote>
)
},
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
@ -578,7 +603,16 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
SyntaxHighlighter: (props: SyntaxHighlighterProps) => <SyntaxHighlighter {...props} defer={isStreaming} />
// ```mermaid / ```svg fences route to their lazy renderers; every other
// language falls back to the Shiki-highlighted code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => (
<RichCodeBlock
code={props.code}
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
language={props.language}
streaming={isStreaming}
/>
)
}) as StreamdownTextComponents,
[isStreaming]
)

View file

@ -0,0 +1,97 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
useCallback,
useRef,
useState
} from 'react'
interface Transform {
scale: number
x: number
y: number
}
const MIN_SCALE = 0.25
const MAX_SCALE = 8
const WHEEL_STEP = 1.1
const BUTTON_STEP = 1.25
const clamp = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale))
/**
* Headless pan/zoom transform. Wheel zooms toward the cursor, drag pans, buttons
* zoom toward centre. Returns the transform style plus the surface handlers, so
* any content (SVG, image, canvas) can be made pan/zoomable.
*/
export function useZoomPan() {
const [transform, setTransform] = useState<Transform>({ scale: 1, x: 0, y: 0 })
const drag = useRef<{ x: number; y: number } | null>(null)
const [panning, setPanning] = useState(false)
// Zoom toward (cx, cy), measured from the surface centre, keeping that point fixed.
const zoomAt = useCallback((factor: number, cx = 0, cy = 0) => {
setTransform(prev => {
const scale = clamp(prev.scale * factor)
const k = scale / prev.scale
return { scale, x: cx - k * (cx - prev.x), y: cy - k * (cy - prev.y) }
})
}, [])
const onWheel = useCallback(
(event: ReactWheelEvent) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const cx = event.clientX - rect.left - rect.width / 2
const cy = event.clientY - rect.top - rect.height / 2
zoomAt(event.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP, cx, cy)
},
[zoomAt]
)
const onPointerDown = useCallback((event: ReactPointerEvent) => {
event.currentTarget.setPointerCapture(event.pointerId)
setTransform(prev => {
drag.current = { x: event.clientX - prev.x, y: event.clientY - prev.y }
return prev
})
setPanning(true)
}, [])
const onPointerMove = useCallback((event: ReactPointerEvent) => {
if (!drag.current) {
return
}
const start = drag.current
setTransform(prev => ({ ...prev, x: event.clientX - start.x, y: event.clientY - start.y }))
}, [])
const endPan = useCallback(() => {
drag.current = null
setPanning(false)
}, [])
const reset = useCallback(() => setTransform({ scale: 1, x: 0, y: 0 }), [])
const zoomIn = useCallback(() => zoomAt(BUTTON_STEP), [zoomAt])
const zoomOut = useCallback(() => zoomAt(1 / BUTTON_STEP), [zoomAt])
const style: CSSProperties = {
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`
}
return {
panning,
reset,
scale: transform.scale,
stageProps: { onPointerDown, onPointerLeave: endPan, onPointerMove, onPointerUp: endPan, onWheel },
style,
zoomIn,
zoomOut
}
}

View file

@ -0,0 +1,172 @@
'use client'
import { type ReactNode, useEffect, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Check, Copy, Maximize, RefreshCw, X, ZoomIn, ZoomOut } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useZoomPan } from './use-zoom-pan'
interface ZoomableProps {
/** Inline content; also the default full-view content. */
children: ReactNode
/** Full-view content, if it should differ from the inline version. */
overlay?: ReactNode
/** Copy/export action shown in the viewer toolbar. */
onCopy?: () => Promise<void> | void
/** Accessible label for the expand affordance. */
label?: string
className?: string
}
/**
* Generic click-to-expand viewer: renders inline content with a hover "expand"
* affordance, then opens a full overlay where the content can be panned/zoomed
* (see useZoomPan) and optionally copied. Content-agnostic wrap a diagram,
* image, or any node.
*/
export function Zoomable({ children, overlay, onCopy, label = 'Open full view', className }: ZoomableProps) {
const [open, setOpen] = useState(false)
return (
<>
<div className={cn('group/zoomable relative', className)}>
{/* The whole content is the trigger — click anywhere to open, like an image. */}
<button
className="block w-full cursor-zoom-in text-left"
onClick={() => setOpen(true)}
title={label}
type="button"
>
{children}
</button>
<span
aria-hidden
className="pointer-events-none absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity group-hover/zoomable:opacity-100"
>
<Maximize className="size-4" />
</span>
</div>
{open && (
<ZoomPanViewer onCopy={onCopy} onOpenChange={setOpen} open={open}>
{overlay ?? children}
</ZoomPanViewer>
)}
</>
)
}
function ZoomPanViewer({
children,
onCopy,
onOpenChange,
open
}: {
children: ReactNode
onCopy?: () => Promise<void> | void
onOpenChange: (open: boolean) => void
open: boolean
}) {
const { panning, reset, stageProps, style, zoomIn, zoomOut } = useZoomPan()
useEffect(() => {
if (open) {
reset()
}
}, [open, reset])
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex h-[85vh] w-[90vw] max-w-[90vw] flex-col gap-0 overflow-hidden p-0"
showCloseButton={false}
>
<div
className={cn(
'relative flex-1 touch-none select-none overflow-hidden',
panning ? 'cursor-grabbing' : 'cursor-grab'
)}
{...stageProps}
>
<div className="absolute inset-0 grid place-items-center">
<div className="origin-center" style={style}>
{children}
</div>
</div>
</div>
<Toolbar onClose={() => onOpenChange(false)} onCopy={onCopy} reset={reset} zoomIn={zoomIn} zoomOut={zoomOut} />
</DialogContent>
</Dialog>
)
}
function Toolbar({
onClose,
onCopy,
reset,
zoomIn,
zoomOut
}: {
onClose: () => void
onCopy?: () => Promise<void> | void
reset: () => void
zoomIn: () => void
zoomOut: () => void
}) {
const [copied, setCopied] = useState(false)
const copy = async () => {
if (!onCopy) {
return
}
await onCopy()
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
}
return (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border/70 bg-background/85 p-1 shadow-sm backdrop-blur">
<ToolbarButton label="Zoom out" onClick={zoomOut}>
<ZoomOut className="size-4" />
</ToolbarButton>
<ToolbarButton label="Reset" onClick={reset}>
<RefreshCw className="size-4" />
</ToolbarButton>
<ToolbarButton label="Zoom in" onClick={zoomIn}>
<ZoomIn className="size-4" />
</ToolbarButton>
{onCopy && (
<>
<Divider />
<ToolbarButton label={copied ? 'Copied' : 'Copy'} onClick={() => void copy()}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</ToolbarButton>
</>
)}
<Divider />
<ToolbarButton label="Close" onClick={onClose}>
<X className="size-4" />
</ToolbarButton>
</div>
)
}
function Divider() {
return <span className="mx-0.5 h-5 w-px bg-border" />
}
function ToolbarButton({ children, label, onClick }: { children: ReactNode; label: string; onClick: () => void }) {
return (
<button
aria-label={label}
className="grid size-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
)
}

View file

@ -376,6 +376,13 @@ export const en: Translations = {
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
translucencyTitle: 'Window Translucency',
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
embedsTitle: 'Inline Embeds',
embedsDesc:
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',
embedsAsk: 'Ask',
embedsAlways: 'Always',
embedsOff: 'Off',
embedsReset: (count: number) => `Reset ${count} allowed ${count === 1 ? 'service' : 'services'}`,
product: 'Product',
productDesc: 'Human-friendly tool activity with concise summaries.',
technical: 'Technical',

View file

@ -286,6 +286,13 @@ export const ja = defineLocale({
toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。',
translucencyTitle: 'ウィンドウの透過',
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
embedsTitle: 'インライン埋め込み',
embedsDesc:
'リッチプレビューは第三者サイトYouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
embedsAsk: '確認',
embedsAlways: '常に',
embedsOff: 'オフ',
embedsReset: (count: number) => `許可した${count}件のサービスをリセット`,
product: 'プロダクト',
productDesc: '読みやすいツール活動と簡潔な要約を表示します。',
technical: 'テクニカル',

View file

@ -301,6 +301,12 @@ export interface Translations {
toolViewDesc: string
translucencyTitle: string
translucencyDesc: string
embedsTitle: string
embedsDesc: string
embedsAsk: string
embedsAlways: string
embedsOff: string
embedsReset: (count: number) => string
product: string
productDesc: string
technical: string

View file

@ -278,6 +278,12 @@ export const zhHant = defineLocale({
toolViewDesc: '產品模式會隱藏原始工具 payload技術模式會顯示完整輸入/輸出。',
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
embedsTitle: '內嵌預覽',
embedsDesc: '豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
embedsAsk: '詢問',
embedsAlways: '一律',
embedsOff: '關閉',
embedsReset: (count: number) => `重設 ${count} 個已允許的服務`,
product: '產品',
productDesc: '易讀的工具活動與精簡摘要。',
technical: '技術',

View file

@ -369,6 +369,12 @@ export const zh: Translations = {
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
embedsTitle: '内嵌预览',
embedsDesc: '富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
embedsAsk: '询问',
embedsAlways: '总是',
embedsOff: '关闭',
embedsReset: (count: number) => `重置 ${count} 个已允许的服务`,
product: '产品',
productDesc: '易读的工具活动与简洁摘要。',
technical: '技术',

View file

@ -56,6 +56,7 @@ import {
IconLock as Lock,
IconLogin as LogIn,
IconMail as Mail,
IconMaximize as Maximize,
IconMessageCircle as MessageCircle,
IconMessageQuestion as MessageQuestion,
IconMessage2 as MessageSquareText,
@ -105,7 +106,9 @@ import {
IconX as X,
IconX as XIcon,
IconBolt as Zap,
IconBoltFilled as ZapFilled
IconBoltFilled as ZapFilled,
IconZoomIn as ZoomIn,
IconZoomOut as ZoomOut
} from '@tabler/icons-react'
export {
@ -166,6 +169,7 @@ export {
Lock,
LogIn,
Mail,
Maximize,
MessageCircle,
MessageQuestion,
MessageSquareText,
@ -215,7 +219,9 @@ export {
X,
XIcon,
Zap,
ZapFilled
ZapFilled,
ZoomIn,
ZoomOut
}
export type { Icon as IconComponent } from '@tabler/icons-react'

View file

@ -0,0 +1,56 @@
// Rasterise an SVG string to PNG and copy it to the clipboard. Self-contained
// SVGs only (inline styles) — mermaid output qualifies. Falls back to copying
// the SVG markup as text where image clipboard writes aren't permitted.
function svgSize(svg: string): { height: number; width: number } {
const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
const width = parseFloat(el.getAttribute('width') || '')
const height = parseFloat(el.getAttribute('height') || '')
if (width && height) {
return { height, width }
}
const [, , vbW, vbH] = (el.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number)
return vbW && vbH ? { height: vbH, width: vbW } : { height: 600, width: 800 }
}
export function svgToPngBlob(svg: string, scale = 2): Promise<Blob> {
const { height, width } = svgSize(svg)
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width * scale))
canvas.height = Math.max(1, Math.round(height * scale))
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('no 2d context'))
return
}
ctx.scale(scale, scale)
ctx.drawImage(image, 0, 0, width, height)
canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/png')
}
image.onerror = () => reject(new Error('svg load failed'))
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
})
}
export async function copySvgAsPng(svg: string): Promise<void> {
try {
const blob = await svgToPngBlob(svg)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
} catch {
await navigator.clipboard.writeText(svg)
}
}

View file

@ -0,0 +1,37 @@
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
// Privacy gate for inline embeds. Loading an embed reaches out to a third party
// (IP, referrer, cookies), so by default we render a placeholder until the user
// consents — per embed ("Load once") or per service ("Always allow YouTube").
// Mirrors the tool-approval model, but purely client-side (the renderer is what
// makes the request) so it never touches the gateway/config.yaml.
export type EmbedMode = 'always' | 'ask' | 'off'
const MODE_KEY = 'hermes.desktop.embed-mode'
const ALLOWED_KEY = 'hermes.desktop.embed-allowed'
const modeCodec: Codec<EmbedMode> = {
decode: raw => (raw === 'always' || raw === 'off' ? raw : 'ask'),
encode: value => value
}
/** Global default: ask (placeholder), always (auto-load), off (plain link). */
export const $embedMode = persistentAtom<EmbedMode>(MODE_KEY, 'ask', modeCodec)
/** Providers granted a standing "always allow" (e.g. `youtube`, `twitter`). */
export const $embedAllowed = persistentAtom<string[]>(ALLOWED_KEY, [], Codecs.stringArray)
export function allowProvider(provider: string) {
const current = $embedAllowed.get()
if (!current.includes(provider)) {
$embedAllowed.set([...current, provider])
}
}
export function setEmbedMode(mode: EmbedMode) {
$embedMode.set(mode)
}
export function clearEmbedAllowed() {
$embedAllowed.set([])
}

2
package-lock.json generated
View file

@ -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",