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 @@ -31,6 +31,8 @@ describe('inline-file-preview-utils', () => {
).toBe('document')
expect(getInlineFilePreviewKind({ filename: 'data.xlsx' })).toBe('spreadsheet')
expect(getInlineFilePreviewKind({ filename: 'chart.png' })).toBe('image')
expect(getInlineFilePreviewKind({ filename: 'podcast.mp3' })).toBe('audio')
expect(getInlineFilePreviewKind({ mimeType: 'audio/mpeg' })).toBe('audio')
})

it('classifies .pptx (OOXML) as inline-previewable presentation', () => {
Expand Down Expand Up @@ -236,6 +238,7 @@ describe('inline-file-preview-utils', () => {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
expect(getInlineFilePreviewMimeType('image')).toBeUndefined()
expect(getInlineFilePreviewMimeType('audio')).toBeUndefined()
expect(getInlineFilePreviewMimeType('file')).toBeUndefined()
})
})
7 changes: 6 additions & 1 deletion frontend/src/components/file/inline-file-preview-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getFilePublicDownloadUrl, getFilePublicPreviewUrl } from '@/lib/utils'

export type InlineFilePreviewKind =
| 'image'
| 'audio'
| 'presentation'
| 'document'
| 'spreadsheet'
Expand All @@ -25,6 +26,7 @@ export type PreviewUrlTrust = {

const PREVIEWABLE_KINDS = new Set<PreviewableInlineFileKind>([
'image',
'audio',
'presentation',
'document',
'spreadsheet',
Expand Down Expand Up @@ -58,7 +60,7 @@ export const INLINE_FILE_PREVIEW_MIME_BY_KIND: Partial<
export const getInlineFilePreviewMimeType = (
kind: InlineFilePreviewKind
): string | undefined => {
if (kind === 'file' || kind === 'image') return undefined
if (kind === 'file' || kind === 'image' || kind === 'audio') return undefined
return INLINE_FILE_PREVIEW_MIME_BY_KIND[kind]
}

Expand All @@ -75,6 +77,7 @@ export const getInlineFilePreviewKind = (
const mimeType = source.mimeType?.toLowerCase() || ''

if (type === 'image') return 'image'
if (type === 'audio') return 'audio'
if (type === 'presentation') {
// An explicit ``type: 'presentation'`` artifact must still be
// cross-checked: pptxviewjs only supports OOXML .pptx, so a
Expand Down Expand Up @@ -104,6 +107,7 @@ export const getInlineFilePreviewKind = (
if (type === 'spreadsheet') return 'spreadsheet'

if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('audio/')) return 'audio'
if (PRESENTATION_MIME_TYPES.has(mimeType) || mimeType.includes('presentationml')) {
return 'presentation'
}
Expand All @@ -113,6 +117,7 @@ export const getInlineFilePreviewKind = (
}

if (/\.(jpg|jpeg|png|gif|webp|svg)$/.test(filename)) return 'image'
if (/\.(mp3|wav|ogg|opus|flac|m4a|aac)$/.test(filename)) return 'audio'
// Only OOXML .pptx is previewable inline — see PRESENTATION_MIME_TYPES
// comment. Legacy .ppt falls through to the generic 'file' kind.
if (filename.endsWith('.pptx')) return 'presentation'
Expand Down
79 changes: 79 additions & 0 deletions frontend/src/components/file/inline-file-preview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,85 @@ describe('InlineFilePreview', () => {
})
})

it('loads managed audio files through authenticated preview', async () => {
apiRequestMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(['audio-bytes'], { type: 'audio/mpeg' }),
})

render(
<InlineFilePreview
source={{ type: 'audio', fileId: 'audio-file-id', filename: 'podcast.mp3' }}
/>
)

await waitFor(() => {
expect(apiRequestMock).toHaveBeenCalledWith(
'http://api.local/api/files/preview/audio-file-id',
expect.objectContaining({ cache: 'no-cache' })
)
})

const audio = await screen.findByLabelText('podcast.mp3')
expect(audio.tagName.toLowerCase()).toBe('audio')
expect(audio.getAttribute('src')).toMatch(/^blob:/)
expect(screen.getByRole('link', { name: 'Open' }).getAttribute('href')).toMatch(
/^blob:/
)
})

it('falls back to the public audio preview when authenticated loading fails', async () => {
apiRequestMock.mockResolvedValue({ ok: false, status: 401 })

render(
<InlineFilePreview
source={{ type: 'audio', fileId: 'audio-file-id', filename: 'podcast.mp3' }}
/>
)

const audio = await screen.findByLabelText('podcast.mp3')
expect(audio).toHaveAttribute(
'src',
'http://api.local/api/files/public/preview/audio-file-id'
)
expect(screen.getByRole('link', { name: 'Open' })).toHaveAttribute(
'href',
'http://api.local/api/files/public/preview/audio-file-id'
)
})

it('loads legacy workspace audio paths through authenticated preview', async () => {
apiRequestMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(['audio-bytes'], { type: 'audio/mpeg' }),
})

render(
<InlineFilePreview
source={{
type: 'audio',
fileId: 'output/xagent_061_podcast.mp3',
filename: 'xagent_061_podcast.mp3',
}}
/>
)

await waitFor(() => {
expect(apiRequestMock).toHaveBeenCalledWith(
'http://api.local/api/files/preview/output%2Fxagent_061_podcast.mp3',
expect.objectContaining({ cache: 'no-cache' })
)
})

const audio = await screen.findByLabelText('xagent_061_podcast.mp3')
await waitFor(() => {
expect(audio.getAttribute('src')).toMatch(/^blob:/)
})
expect(screen.getByRole('link', { name: 'Open' }).getAttribute('href')).toMatch(
/^blob:/
)
})

it('mounts PptxPreviewRenderer immediately with fileId without eager byte fetch', () => {
// PDF-first path: when a managed fileId is available, InlineFilePreview
// skips the eager /api/files/public/preview bytes download and mounts
Expand Down
116 changes: 115 additions & 1 deletion frontend/src/components/file/inline-file-preview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { FileText, Loader2 } from 'lucide-react'
import { FileText, Loader2, Volume2 } from 'lucide-react'

import { DocxPreviewRenderer } from '@/components/file/docx-preview-renderer'
import { ExcelPreviewRenderer } from '@/components/file/excel-preview-renderer'
Expand Down Expand Up @@ -130,6 +130,108 @@ function InlineImagePreview({
)
}

function InlineAudioPreview({
source,
previewUrl,
filename,
openLabel,
className,
}: {
source: InlineFilePreviewSource
previewUrl: string
filename: string
openLabel: string
className?: string
}) {
const apiUrl = getApiUrl()
const shouldFallback = Boolean(source.fileId)
const [resolvedUrl, setResolvedUrl] = useState(shouldFallback ? '' : previewUrl)

useEffect(() => {
let objectUrl: string | null = null
let isCancelled = false

setResolvedUrl(shouldFallback ? '' : previewUrl)

const loadAuthenticatedAudio = async () => {
if (!shouldFallback || !source.fileId) return
try {
const response = await apiRequest(
`${apiUrl}/api/files/preview/${encodeURIComponent(source.fileId)}`,
{
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
}
)
if (isCancelled) return
if (!response.ok) {
setResolvedUrl(previewUrl)
return
}
const blob = await response.blob()
if (isCancelled) return
objectUrl = URL.createObjectURL(blob)
setResolvedUrl(objectUrl)
} catch {
if (!isCancelled) {
setResolvedUrl(previewUrl)
}
}
}

void loadAuthenticatedAudio()

return () => {
isCancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [apiUrl, previewUrl, shouldFallback, source.fileId])

return (
<div
className={cn(
'overflow-hidden rounded-md border border-border/50 bg-background',
className
)}
data-inline-file-preview-wrapper
>
<div className="flex items-center gap-2 border-b border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<Volume2 className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{filename}</span>
{resolvedUrl ? (
<a
href={resolvedUrl}
target="_blank"
rel="noreferrer"
className="shrink-0 text-foreground hover:underline"
>
{openLabel}
</a>
) : null}
</div>
<div className="p-3">
{resolvedUrl ? (
<audio
controls
preload="metadata"
src={resolvedUrl}
className="w-full"
aria-label={filename}
title={filename}
/>
) : (
<div className="flex h-14 items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
</div>
)
}

function InlineOfficeContent({
kind,
previewUrl,
Expand Down Expand Up @@ -303,6 +405,18 @@ export function InlineFilePreview({
)
}

if (kind === 'audio') {
return (
<InlineAudioPreview
source={resolvedSource}
previewUrl={previewUrl}
filename={filename}
openLabel={openLabel}
className={className}
/>
)
}

if (!isPreviewableInlineFileKind(kind)) {
return (
<a
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/components/pages/model-display-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest"

import { getProviderDisplayCapabilities } from "./model-display-capabilities"

describe("getProviderDisplayCapabilities", () => {
it("keeps sound effect and music distinct on the audio tab", () => {
expect(
getProviderDisplayCapabilities(
[
{ category: "speech", abilities: ["tts"] },
{ category: "speech", abilities: ["asr"] },
{ category: "sound_effect", abilities: ["generate"] },
{ category: "music", abilities: ["generate"] },
],
"audio",
),
).toEqual(["tts", "asr", "sound_effect", "music"])
})

it("keeps generic generate for non-audio provider cards", () => {
expect(
getProviderDisplayCapabilities(
[{ category: "image", abilities: ["generate"] }],
"image",
),
).toEqual(["generate"])
})

it("keeps non-generate abilities on sound effect and music models", () => {
expect(
getProviderDisplayCapabilities(
[
{ category: "sound_effect", abilities: ["generate", "edit"] },
{ category: "music", abilities: ["generate", "tts"] },
],
"audio",
),
).toEqual(["sound_effect", "edit", "music", "tts"])
})

it("deduplicates repeated provider capabilities", () => {
expect(
getProviderDisplayCapabilities(
[
{ category: "speech", abilities: ["tts"] },
{ category: "speech", abilities: ["tts"] },
],
"audio",
),
).toEqual(["tts"])
})
})
35 changes: 35 additions & 0 deletions frontend/src/components/pages/model-display-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface DisplayCapabilityModel {
category: string
abilities?: string[]
}

/**
* Build provider-card capability badges without collapsing distinct audio
* model categories that share the generic backend `generate` ability.
*/
export function getProviderDisplayCapabilities(
models: DisplayCapabilityModel[],
activeTab: string,
): string[] {
const capabilities = new Set<string>()

models.forEach((model) => {
const isSoundEffect =
activeTab === "audio" && model.category === "sound_effect"
const isMusic = activeTab === "audio" && model.category === "music"

if (isSoundEffect) {
capabilities.add("sound_effect")
} else if (isMusic) {
capabilities.add("music")
}

model.abilities?.forEach((ability) => {
if (ability !== "generate" || (!isSoundEffect && !isMusic)) {
capabilities.add(ability)
}
})
})
Comment thread
qinxuye marked this conversation as resolved.

return Array.from(capabilities)
}
Loading
Loading