mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[eric] privacy: domain icons render locally (simple-icons + monogram), Google favicon beacon deleted
This commit is contained in:
Generated
+20
@@ -42,6 +42,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.23.0",
|
||||
"simple-icons": "^16.28.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -12843,6 +12844,25 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-icons": {
|
||||
"version": "16.28.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.28.0.tgz",
|
||||
"integrity": "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/simple-icons"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/simple-icons"
|
||||
}
|
||||
],
|
||||
"license": "CC0-1.0",
|
||||
"engines": {
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs": {
|
||||
"version": "0.3.24",
|
||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.23.0",
|
||||
"simple-icons": "^16.28.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -19,7 +19,8 @@ import { ParsedResult } from '../parsing/toolResultParsing';
|
||||
import { isSettingsWriteTool, settingsWriteSummary } from '../parsing/settingsToolMeta';
|
||||
import { McpToolInfo, getMcpShortAction, getMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
|
||||
import { McpResultCard } from '../mcp-cards/McpResultCard';
|
||||
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
|
||||
import { domainFromUrl } from './SourceFavicons';
|
||||
import { DomainIcon } from './DomainIcon';
|
||||
|
||||
interface CompactMcpBubbleProps {
|
||||
call: AgentMessage;
|
||||
@@ -106,16 +107,7 @@ export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
|
||||
)}
|
||||
{visibleSummary && !isError && !stackBelow && (
|
||||
<>
|
||||
{webDomain && (
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconUrlForDomain(webDomain)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
|
||||
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{webDomain && <DomainIcon domain={webDomain} size={13} />}
|
||||
<Typography
|
||||
sx={{
|
||||
color: hideVerbLabel ? c.text.primary : c.text.secondary,
|
||||
|
||||
@@ -20,7 +20,8 @@ import { useTermColors, colorizeInput, colorizeOutput } from '../parsing/toolCol
|
||||
import { ParsedResult } from '../parsing/toolResultParsing';
|
||||
import { McpToolInfo } from '@/shared/mcpToolMeta';
|
||||
import { McpResultCard } from '../mcp-cards/McpResultCard';
|
||||
import { domainFromUrl, faviconUrlForDomain } from './SourceFavicons';
|
||||
import { domainFromUrl } from './SourceFavicons';
|
||||
import { DomainIcon } from './DomainIcon';
|
||||
|
||||
interface DefaultToolBubbleProps {
|
||||
call: AgentMessage;
|
||||
@@ -143,16 +144,7 @@ export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
|
||||
)}
|
||||
{inputSummary && !isStreaming && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flex: 1, minWidth: 0 }}>
|
||||
{webDomain && (
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconUrlForDomain(webDomain)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.style.display = 'none'; }}
|
||||
sx={{ width: 13, height: 13, borderRadius: '3px', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{webDomain && <DomainIcon domain={webDomain} size={13} />}
|
||||
<Typography
|
||||
noWrap
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { brandIconForDomain, monogramHue } from './domainBrand';
|
||||
|
||||
interface DomainIconProps {
|
||||
domain: string;
|
||||
size: number;
|
||||
shape?: 'circle' | 'rounded';
|
||||
}
|
||||
|
||||
// Brand glyphs render in the neutral text tone: several brand hexes (GitHub's near-black) vanish on the dark theme.
|
||||
export const DomainIcon: React.FC<DomainIconProps> = ({ domain, size, shape = 'rounded' }) => {
|
||||
const c = useClaudeTokens();
|
||||
const icon = brandIconForDomain(domain);
|
||||
if (icon) {
|
||||
return (
|
||||
<Box component="span" sx={{ width: size, height: size, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" focusable="false">
|
||||
<path d={icon.path} fill={c.text.secondary} />
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: shape === 'circle' ? '50%' : `${Math.max(2, Math.round(size * 0.2))}px`,
|
||||
bgcolor: `hsl(${monogramHue(domain)} 42% 46%)`,
|
||||
color: '#fff',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.62,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{(domain.replace(/^www\./, '')[0] ?? '?').toUpperCase()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
// Same favicon service LibreChat ships with; the browser cards already load arbitrary sites, so
|
||||
// fetching site icons adds no new exposure class.
|
||||
export function faviconUrlForDomain(domain: string): string {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
|
||||
}
|
||||
import { DomainIcon } from './DomainIcon';
|
||||
|
||||
export function domainFromUrl(url: string): string {
|
||||
try {
|
||||
@@ -20,35 +15,26 @@ const MAX_STACK = 3;
|
||||
|
||||
const FaviconDot: React.FC<{ domain: string; size: number; overlap: boolean; z: number }> = ({ domain, size, overlap, z }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ring = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: `1.5px solid ${c.bg.elevated}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
ml: overlap ? '-6px' : 0,
|
||||
zIndex: z,
|
||||
position: 'relative' as const,
|
||||
flexShrink: 0,
|
||||
};
|
||||
if (failed) {
|
||||
// assistant-ui's fallback: the domain's first letter beats a hole in the stack.
|
||||
return (
|
||||
<Box sx={{ ...ring, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', color: c.text.tertiary, fontSize: size * 0.55, fontWeight: 700 }}>
|
||||
{(domain[0] || '?').toUpperCase()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconUrlForDomain(domain)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
sx={ring}
|
||||
/>
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
border: `1.5px solid ${c.bg.elevated}`,
|
||||
bgcolor: c.bg.secondary,
|
||||
ml: overlap ? '-6px' : 0,
|
||||
zIndex: z,
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<DomainIcon domain={domain} size={size - 4} shape="circle" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Run: node --test frontend/src/app/pages/AgentChat/tool-bubbles/domainBrand.test.ts
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { brandIconForDomain, monogramHue } from './domainBrand.ts';
|
||||
|
||||
test('a known brand domain resolves to a bundled icon', () => {
|
||||
assert.ok(brandIconForDomain('github.com'));
|
||||
});
|
||||
|
||||
test('a subdomain walks up to its registered brand', () => {
|
||||
assert.equal(brandIconForDomain('docs.github.com'), brandIconForDomain('github.com'));
|
||||
assert.equal(brandIconForDomain('en.wikipedia.org'), brandIconForDomain('wikipedia.org'));
|
||||
});
|
||||
|
||||
test('www and case are ignored', () => {
|
||||
assert.equal(brandIconForDomain('www.GitHub.com'), brandIconForDomain('github.com'));
|
||||
});
|
||||
|
||||
test('an unknown domain has no brand icon', () => {
|
||||
assert.equal(brandIconForDomain('example.org'), null);
|
||||
});
|
||||
|
||||
test('lookalike hosts never match across a label boundary', () => {
|
||||
assert.equal(brandIconForDomain('evilgithub.com'), null);
|
||||
});
|
||||
|
||||
test('monogram hue is deterministic and a valid hue', () => {
|
||||
assert.equal(monogramHue('example.org'), monogramHue('example.org'));
|
||||
for (const d of ['a', 'example.org', 'sub.long-domain-name.co.uk']) {
|
||||
const h = monogramHue(d);
|
||||
assert.ok(h >= 0 && h < 360, `${d} hue out of range: ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no favicon beacon: nothing in tool-bubbles builds a remote favicon URL', () => {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const sources = readdirSync(dir).filter((n) => (n.endsWith('.ts') || n.endsWith('.tsx')) && !n.includes('.test.'));
|
||||
for (const f of sources) {
|
||||
const src = readFileSync(join(dir, f), 'utf8');
|
||||
assert.ok(!src.includes('s2/favicons'), `${f} references a favicon service`);
|
||||
assert.ok(!src.includes('favicons?domain='), `${f} builds a favicon beacon URL`);
|
||||
assert.ok(!src.includes('faviconUrlForDomain'), `${f} still references the deleted beacon helper`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SimpleIcon } from 'simple-icons';
|
||||
import {
|
||||
siAnthropic, siArxiv, siBitbucket, siCloudflare, siDiscord, siDuckduckgo, siFacebook, siFigma,
|
||||
siGithub, siGitlab, siGoogle, siHuggingface, siInstagram, siKaggle, siMedium, siMozilla,
|
||||
siNetflix, siNotion, siNpm, siPinterest, siQuora, siReddit, siSpotify, siStackoverflow,
|
||||
siSubstack, siTiktok, siTwitch, siVercel, siWikipedia, siX, siYcombinator, siYoutube,
|
||||
} from 'simple-icons';
|
||||
|
||||
// Bundled CC0 marks instead of a favicon service: fetching icons remotely reports the user's reading list to a third party (ENG-130).
|
||||
const BRAND_BY_DOMAIN: Record<string, SimpleIcon> = {
|
||||
'anthropic.com': siAnthropic,
|
||||
'arxiv.org': siArxiv,
|
||||
'bitbucket.org': siBitbucket,
|
||||
'cloudflare.com': siCloudflare,
|
||||
'discord.com': siDiscord,
|
||||
'duckduckgo.com': siDuckduckgo,
|
||||
'facebook.com': siFacebook,
|
||||
'figma.com': siFigma,
|
||||
'github.com': siGithub,
|
||||
'gitlab.com': siGitlab,
|
||||
'google.com': siGoogle,
|
||||
'huggingface.co': siHuggingface,
|
||||
'instagram.com': siInstagram,
|
||||
'kaggle.com': siKaggle,
|
||||
'medium.com': siMedium,
|
||||
'mozilla.org': siMozilla,
|
||||
'netflix.com': siNetflix,
|
||||
'notion.so': siNotion,
|
||||
'npmjs.com': siNpm,
|
||||
'pinterest.com': siPinterest,
|
||||
'quora.com': siQuora,
|
||||
'reddit.com': siReddit,
|
||||
'spotify.com': siSpotify,
|
||||
'stackoverflow.com': siStackoverflow,
|
||||
'substack.com': siSubstack,
|
||||
'tiktok.com': siTiktok,
|
||||
'twitch.tv': siTwitch,
|
||||
'twitter.com': siX,
|
||||
'vercel.com': siVercel,
|
||||
'wikipedia.org': siWikipedia,
|
||||
'x.com': siX,
|
||||
'ycombinator.com': siYcombinator,
|
||||
'youtube.com': siYoutube,
|
||||
};
|
||||
|
||||
export function brandIconForDomain(domain: string): SimpleIcon | null {
|
||||
let host = domain.toLowerCase().replace(/^www\./, '');
|
||||
while (host.length > 0) {
|
||||
const hit = BRAND_BY_DOMAIN[host];
|
||||
if (hit !== undefined) return hit;
|
||||
const dot = host.indexOf('.');
|
||||
if (dot === -1) return null;
|
||||
host = host.slice(dot + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deterministic hue, so a domain's monogram tile never changes color between renders or sessions.
|
||||
export function monogramHue(domain: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < domain.length; i += 1) h = (h * 31 + domain.charCodeAt(i)) | 0;
|
||||
return Math.abs(h) % 360;
|
||||
}
|
||||
Reference in New Issue
Block a user