diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.cjs new file mode 100644 index 00000000000..2825eda40e7 --- /dev/null +++ b/apps/desktop/electron/embed-referer.cjs @@ -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 } diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d7184bc9743..5d859bca649 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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() diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 56a7374eeef..6d0ca01f33f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 36fb9e91687..ffa41c5af4e 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -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 (
@@ -425,6 +435,35 @@ export function AppearanceSettings() { description={a.toolViewDesc} title={a.toolViewTitle} /> + + + { + triggerHaptic('selection') + setEmbedMode(id) + }} + options={embedOptions} + value={embedMode} + /> + {embedAllowed.length > 0 && ( + + )} +
+ } + description={a.embedsDesc} + title={a.embedsTitle} + /> diff --git a/apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx b/apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx new file mode 100644 index 00000000000..b003b914a89 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/alert.test.tsx @@ -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') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/embeds/alert.tsx b/apps/desktop/src/components/assistant-ui/embeds/alert.tsx new file mode 100644 index 00000000000..d2c55e43d71 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/alert.tsx @@ -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 = { + 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) => ) + } + + 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 ( +
+
+ + {style.label} +
+ {children} +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx b/apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx new file mode 100644 index 00000000000..c2fa1bff21a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/embed-consent.tsx @@ -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 +// " (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 ( + + (id === 'always' ? allowProvider(descriptor.provider) : onLoad())} + onValueChange={setChoice} + primaryIcon={} + value={choice} + /> + {hostOf(descriptor)} + + ) +} + +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 + } +} diff --git a/apps/desktop/src/components/assistant-ui/embeds/embed-size.ts b/apps/desktop/src/components/assistant-ui/embeds/embed-size.ts new file mode 100644 index 00000000000..3f3da9fb30f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/embed-size.ts @@ -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' diff --git a/apps/desktop/src/components/assistant-ui/embeds/escape-html.ts b/apps/desktop/src/components/assistant-ui/embeds/escape-html.ts new file mode 100644 index 00000000000..c8545bca52e --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/escape-html.ts @@ -0,0 +1,3 @@ +export function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} diff --git a/apps/desktop/src/components/assistant-ui/embeds/fail.tsx b/apps/desktop/src/components/assistant-ui/embeds/fail.tsx new file mode 100644 index 00000000000..833f67f8390 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/fail.tsx @@ -0,0 +1,7 @@ +export function EmbedFail({ label }: { label: string }) { + return ( + + Failed to load {label} embed + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx b/apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx new file mode 100644 index 00000000000..7d99c0dd7a2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/embeds/frame-embed.tsx @@ -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 ( +
+