Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ export function HeroChatReply({ content, onOpenWorkflowResource }: HeroChatReply
<>
{paragraph.slice(0, resourceIndex)}
<ResourceMention
icon={
<Workflow className='relative top-0.5 size-[12px] shrink-0 text-[var(--text-icon)]' />
}
icon={<Workflow className='size-[12px] shrink-0 text-[var(--text-icon)]' />}
title={WORKFLOW_TITLE}
onSelect={onOpenWorkflowResource}
/>
Expand Down
50 changes: 50 additions & 0 deletions apps/sim/app/api/link-preview/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** @vitest-environment node */
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
fetch: vi.fn(),
get: vi.fn(),
set: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({ getSession: async () => ({ user: { id: 'test-user' } }) }))
vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({
enforceUserRateLimit: async () => null,
}))
vi.mock('@/lib/api/server', () => ({
parseRequest: async () => ({
success: true,
data: { query: { url: 'https://example.com/guide' } },
}),
}))
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => mocks }))
vi.mock('@/lib/core/network/context.server', () => ({
runWithOutboundOrganization: (_organization: null, run: () => unknown) => run(),
}))
vi.mock('@/lib/core/utils/with-route-handler', () => ({
withRouteHandler: (handler: unknown) => handler,
}))
vi.mock('@/lib/link-preview/fetch-preview', () => ({ fetchLinkPreview: mocks.fetch }))

import { GET } from '@/app/api/link-preview/route'

const complete = { title: 'Guide', description: null, siteName: null }

describe('link preview cache lifetime', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.get.mockResolvedValue(null)
mocks.set.mockResolvedValue('OK')
})

it.each([
{ preview: { ...complete, imageRetryable: true }, ttl: 60 },
{ preview: complete, ttl: 24 * 60 * 60 },
{ preview: null, ttl: 60 * 60 },
])('caches $preview for $ttl seconds', async ({ preview, ttl }) => {
mocks.fetch.mockResolvedValue(preview)
const response = await GET(new NextRequest('https://example.com/api/link-preview'))
expect(await response.json()).toEqual({ preview })
expect(mocks.set).toHaveBeenCalledWith(expect.any(String), JSON.stringify(preview), 'EX', ttl)
})
})
68 changes: 10 additions & 58 deletions apps/sim/app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { createHash } from 'crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import * as cheerio from 'cheerio'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import type { LinkPreview } from '@/lib/api/contracts/link-preview'
Expand All @@ -12,66 +10,15 @@ import { getSession } from '@/lib/auth'
import { getRedisClient } from '@/lib/core/config/redis'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import { enforceUserRateLimit } from '@/lib/core/rate-limiter/route-helpers'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { fetchLinkPreview } from '@/lib/link-preview/fetch-preview'

const logger = createLogger('LinkPreviewAPI')

const FETCH_TIMEOUT_MS = 5000
const MAX_RESPONSE_BYTES = 256 * 1024
const MAX_REDIRECTS = 3
const TITLE_MAX_CHARS = 200
const DESCRIPTION_MAX_CHARS = 300
const CACHE_TTL_SECONDS = 24 * 60 * 60
const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60
const CACHE_KEY_PREFIX = 'link-preview:v1:'

/**
* Parses preview metadata from the fetched document (already capped at
* MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity
* decoding.
*/
function parsePreview(html: string): LinkPreview {
const $ = cheerio.load(html)

const meta = (key: string): string | null => {
const value = $(`meta[property="${key}"], meta[name="${key}"]`).first().attr('content')
return value?.trim() || null
}

const title =
meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
const siteName = meta('og:site_name')

if (!title && !description && !siteName) return null
return {
title: title ? truncate(title, TITLE_MAX_CHARS) : null,
description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null,
siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null,
}
}

async function fetchPreview(url: string): Promise<LinkPreview> {
const response = await secureFetchWithValidation(url, {
// The URL is harvested from a rendered link rather than authored as a
// destination, so it gets no reach into a private network.
profile: 'contentFetch',
timeout: FETCH_TIMEOUT_MS,
maxRedirects: MAX_REDIRECTS,
maxResponseBytes: MAX_RESPONSE_BYTES,
headers: {
'User-Agent': 'Simbot/1.0 (+https://sim.ai)',
Accept: 'text/html,application/xhtml+xml',
},
})
if (response.status < 200 || response.status >= 300) return null
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
return null
}
return parsePreview(await response.text())
}
const RETRYABLE_CACHE_TTL_SECONDS = 60
const CACHE_KEY_PREFIX = 'link-preview:v2:'

export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
Expand Down Expand Up @@ -106,16 +53,21 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
let preview: LinkPreview = null
try {
/** Link previews have no organization owner and use a shared URL cache. */
preview = await runWithOutboundOrganization(null, () => fetchPreview(url))
preview = await runWithOutboundOrganization(null, () => fetchLinkPreview(url, request.signal))
} catch (error) {
if (request.signal.aborted) return new NextResponse(null, { status: 499 })
logger.info('Link preview fetch failed; returning null preview', {
host: new URL(url).hostname,
error: getErrorMessage(error, 'unknown error').replaceAll(url, '[url]'),
})
}

if (redis) {
const ttl = preview ? CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS
const ttl = preview?.imageRetryable
? RETRYABLE_CACHE_TTL_SECONDS
: preview
? CACHE_TTL_SECONDS
: NEGATIVE_CACHE_TTL_SECONDS
try {
await redis.set(cacheKey, JSON.stringify(preview), 'EX', ttl)
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isAssistantImageType,
} from '@/lib/uploads/shared/assistant-images'
import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { SearchInputBar } from '@/app/o/[organizationId]/components/search-input-bar'
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
import { AttachedFilesList } from '@/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list'
Expand Down Expand Up @@ -268,6 +269,7 @@ export function Composer({
onDragOver={files.handleDragOver}
onDrop={files.handleDrop}
className={cn(
inter.className,
'relative z-10 mx-auto w-full max-w-chat',
!imagesOnly &&
'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vi.mock('next/navigation', () => ({
usePathname: () => '/workspace/workspace-1/files',
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/app/_styles/fonts/inter/inter', () => ({ inter: { variable: 'test-inter-variable' } }))
vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: null, isPending: false }) }))
vi.mock('@/hooks/queries/workspace-files', () => ({
useUploadWorkspaceFile: () => ({ mutateAsync: uploadFile }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '@/lib/mothership/chat/selection-context'
import type { FileDownloadSource } from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar/find-bar'
import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict'
import { PreviewLoadingFrame } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
Expand Down Expand Up @@ -118,8 +119,10 @@ function warnRichMarkdownPasteLimit(reason?: 'paste' | 'formatting') {
* {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder →
* live swap never reflows. Shared as one constant to keep them in lockstep.
*/
const EDITOR_SURFACE_CLASS =
'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white'
const EDITOR_SURFACE_CLASS = cn(
'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white',
inter.variable
)

/** ProseMirror block positions do not correspond to markdown source line numbers. */
function buildEditorSelectionContext(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { cn, Lightbox } from '@sim/emcn'
import { ChipTag, cn, Lightbox, OverflowText } from '@sim/emcn'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'

function FileAttachmentPill(props: { mediaType: string; filename: string }) {
const Icon = getDocumentIcon(props.mediaType, props.filename)
return (
<div className='flex max-w-[140px] items-center gap-[5px] rounded-lg bg-[var(--surface-5)] px-[6px] py-[3px]'>
<Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)] text-xs'>{props.filename}</span>
</div>
<ChipTag variant='mono' leftIcon={Icon} className='max-w-[140px]'>
<OverflowText label={props.filename} />
</ChipTag>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function ActivityDisclosure({
{collapsible && (
<Expandable expanded={expanded}>
<ExpandableContent id={contentId}>
<div className='pt-1.5'>
<div className='pt-2'>
<ActivityViewport isStreaming={isStreaming} unbounded={unbounded}>
{children}
</ActivityViewport>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ describe('flat expanded activity layout', () => {
expect(iconSlot(row).classList).toContain(ICON_SLOT)
}
const list = rows[0].closest('.flex-col')!
expect(list.classList).toContain('gap-1.5')
expect(list.classList).toContain('gap-2')
let node: Element | null = rows[0]
while (node && node !== container) {
expect(hasIndent(node), node.className).toBe(false)
Expand Down Expand Up @@ -134,16 +134,15 @@ describe('flat expanded activity layout', () => {
},
},
])
const blocks = container.querySelector('.flex-col.gap-3')!
const blocks = container.querySelector('.flex-col.gap-2')!
expect(blocks.contains(statuses()[0])).toBe(true)
expect(blocks.classList).toContain('gap-3')
expect(blocks.classList).not.toContain('gap-1.5')
expect(blocks.classList).toContain('gap-2')
expect(statuses()).toHaveLength(1)
expand()
const rows = statuses().slice(1)
expect(rows).toHaveLength(3)
for (const row of rows) {
expect(row.closest('.flex-col')!.classList).toContain('gap-1.5')
expect(row.closest('.flex-col')!.classList).toContain('gap-2')
expect(iconSlot(row).classList).toContain(ICON_SLOT)
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ export function ActivityViewport({
className={cn(
'pr-2',
!unbounded && 'scrollbar-hide max-h-[110px] overflow-y-auto',
scrollFadeClass,
(edges.top || edges.bottom) && 'py-1'
scrollFadeClass
)}
{...scrollFadeAttributes(edges)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export function AgentGroupView({
liveToolId={liveCall?.id}
/>
) : (
<div className='flex min-w-0 flex-col gap-1.5 py-0.5'>{items.map(renderItem)}</div>
<div className='flex min-w-0 flex-col gap-2'>{items.map(renderItem)}</div>
)
const headerText = error
? agentLabel
Expand Down Expand Up @@ -308,7 +308,7 @@ export function AgentGroupView({
)

return (
<div className='flex min-w-0 flex-col gap-1.5'>
<div className='flex min-w-0 flex-col gap-2'>
{isMainAgent ? (
activity
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@ export function MainAgentActivity({
)
})

return <div className='flex min-w-0 flex-col gap-3'>{activity}</div>
return <div className='flex min-w-0 flex-col gap-2'>{activity}</div>
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import {
scrollFadeClass,
useScrollEdges,
} from '@sim/emcn'
import {
externalLinkHostname,
handleExternalLinkClick,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/external-link'
import {
SourceIcon,
sourceLabel,
sourceSiteName,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip'
import {
externalLinkHostname,
handleExternalLinkClick,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-link'
import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'

interface SearchActivityResultsProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export function ToolActivityGroup({
isStreaming={working && autoScrollActivity}
unbounded={entries.some(({ sources }) => (sources?.length ?? 0) > 0)}
>
<div className='flex min-w-0 flex-col gap-1.5 py-0.5'>
<div className='flex min-w-0 flex-col gap-2'>
{entries.map(({ tool, sources }, index) => (
<Fragment key={tool.id}>
{tools.length === 1 ? null : tool.id === headerTool.id ? (
Expand Down
Loading
Loading