mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
commit
ed962104c8
46 changed files with 2002 additions and 10 deletions
48
apps/desktop/electron/embed-referer.cjs
Normal file
48
apps/desktop/electron/embed-referer.cjs
Normal 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 }
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
125
apps/desktop/src/components/assistant-ui/embeds/alert.tsx
Normal file
125
apps/desktop/src/components/assistant-ui/embeds/alert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,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`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
5
apps/desktop/src/components/assistant-ui/embeds/index.ts
Normal file
5
apps/desktop/src/components/assistant-ui/embeds/index.ts
Normal 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'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
39
apps/desktop/src/components/assistant-ui/embeds/registry.tsx
Normal file
39
apps/desktop/src/components/assistant-ui/embeds/registry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
131
apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
Normal file
131
apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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,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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
97
apps/desktop/src/components/ui/use-zoom-pan.ts
Normal file
97
apps/desktop/src/components/ui/use-zoom-pan.ts
Normal 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
|
||||
}
|
||||
}
|
||||
172
apps/desktop/src/components/ui/zoomable.tsx
Normal file
172
apps/desktop/src/components/ui/zoomable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: 'テクニカル',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '技術',
|
||||
|
|
|
|||
|
|
@ -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: '技术',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
56
apps/desktop/src/lib/svg-image.ts
Normal file
56
apps/desktop/src/lib/svg-image.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
37
apps/desktop/src/store/embed-consent.ts
Normal file
37
apps/desktop/src/store/embed-consent.ts
Normal 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
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