feat(#240,#241): DocInspectTab — Markdown/Elements/Images from Docling analysis

This commit is contained in:
Pier-Jean Malandrino 2026-04-30 10:57:46 +02:00
parent 63dfcaab67
commit 7dd76e39f8
5 changed files with 281 additions and 12 deletions

View file

@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api'
import {
createAnalysis,
fetchAnalyses,
fetchAnalysis,
deleteAnalysis,
fetchDocumentAnalyses,
} from './api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
@ -66,4 +72,14 @@ describe('analysis API', () => {
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/42', { method: 'DELETE' })
})
it('fetchDocumentAnalyses calls GET /api/analyses?documentId=:id', async () => {
const analyses = [{ id: '1', documentId: 'doc-42', status: 'COMPLETED' }]
apiFetch.mockResolvedValue(analyses)
const result = await fetchDocumentAnalyses('doc-42')
expect(apiFetch).toHaveBeenCalledWith('/api/analyses?documentId=doc-42')
expect(result).toEqual(analyses)
})
})

View file

@ -37,3 +37,7 @@ export function fetchAnalysis(id: string): Promise<Analysis> {
export function deleteAnalysis(id: string): Promise<unknown> {
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
}
export function fetchDocumentAnalyses(docId: string): Promise<Analysis[]> {
return apiFetch<Analysis[]>(`/api/analyses?documentId=${encodeURIComponent(docId)}`)
}

View file

@ -0,0 +1,131 @@
<template>
<div class="inspect-result-tabs">
<div class="irt-tab-strip">
<button
v-for="tab in TABS"
:key="tab.id"
class="irt-tab-btn"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
{{ t(tab.labelKey) }}
</button>
</div>
<div class="irt-content">
<!-- Markdown full document -->
<div v-if="activeTab === 'markdown'" class="irt-markdown">
<MarkdownViewer :content="analysis.contentMarkdown ?? undefined" />
</div>
<!-- Elements StructureViewer (page images + bbox overlay) -->
<div v-else-if="activeTab === 'elements'" class="irt-elements">
<div v-if="pages.length === 0" class="irt-empty">
{{ t('inspect.noElements') }}
</div>
<StructureViewer v-else :pages="pages" :document-id="docId" />
</div>
<!-- Images picture elements extracted from all pages -->
<div v-else-if="activeTab === 'images'" class="irt-images">
<ImageGallery :pages="pages" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Analysis, Page } from '../../../shared/types'
import MarkdownViewer from './MarkdownViewer.vue'
import StructureViewer from './StructureViewer.vue'
import ImageGallery from './ImageGallery.vue'
import { useI18n } from '../../../shared/i18n'
const props = defineProps<{
analysis: Analysis
docId: string
}>()
const { t } = useI18n()
const TABS = [
{ id: 'markdown', labelKey: 'inspect.tabMarkdown' },
{ id: 'elements', labelKey: 'inspect.tabElements' },
{ id: 'images', labelKey: 'inspect.tabImages' },
] as const
type TabId = (typeof TABS)[number]['id']
const activeTab = ref<TabId>('elements')
const pages = computed<Page[]>(() => {
if (!props.analysis.pagesJson) return []
try {
return JSON.parse(props.analysis.pagesJson) as Page[]
} catch {
return []
}
})
</script>
<style scoped>
.inspect-result-tabs {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.irt-tab-strip {
display: flex;
border-bottom: 1px solid var(--border);
padding: 0 16px;
flex-shrink: 0;
background: var(--bg-surface);
}
.irt-tab-btn {
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition);
margin-bottom: -1px;
}
.irt-tab-btn:hover {
color: var(--text-secondary);
}
.irt-tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.irt-content {
flex: 1;
overflow: hidden;
}
.irt-markdown,
.irt-elements,
.irt-images {
height: 100%;
overflow-y: auto;
padding: 16px 20px;
}
.irt-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
</style>

View file

@ -1,44 +1,141 @@
<template>
<div class="inspect-tab" data-e2e="inspect-tab">
<div class="coming-soon">
<p class="coming-soon-title">{{ t('workspace.inspectComingSoon') }}</p>
<p class="coming-soon-sub">{{ t('workspace.inspectComingSoonHint') }}</p>
<!-- Loading -->
<div v-if="loading" class="inspect-state">
<span class="spinner" />
</div>
<!-- Error -->
<div v-else-if="error" class="inspect-state inspect-state--error">
<p>{{ error }}</p>
<button class="retry-btn" @click="load">{{ t('inspect.retry') }}</button>
</div>
<!-- No analysis yet -->
<div v-else-if="!analysis" class="inspect-state">
<p class="inspect-empty-title">{{ t('inspect.noAnalysis') }}</p>
<p class="inspect-empty-sub">{{ t('inspect.noAnalysisSub') }}</p>
<RouterLink :to="{ name: ROUTES.STUDIO }" class="inspect-cta">
{{ t('inspect.goToStudio') }}
</RouterLink>
</div>
<!-- Result -->
<InspectResultTabs v-else :analysis="analysis" :doc-id="docId" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import type { Analysis } from '../shared/types'
import { fetchDocumentAnalyses } from '../features/analysis/api'
import InspectResultTabs from '../features/analysis/ui/InspectResultTabs.vue'
import { useI18n } from '../shared/i18n'
import { ROUTES } from '../shared/routing/names'
defineProps<{ docId: string }>()
const props = defineProps<{ docId: string }>()
const { t } = useI18n()
const loading = ref(false)
const error = ref<string | null>(null)
const analysis = ref<Analysis | null>(null)
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
const analyses = await fetchDocumentAnalyses(props.docId)
analysis.value = analyses.find((a) => a.status === 'COMPLETED') ?? null
} catch (e) {
error.value = (e as Error).message || 'Failed to load analysis'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.inspect-tab {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.inspect-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 13px;
}
.coming-soon {
text-align: center;
display: flex;
flex-direction: column;
gap: 8px;
.inspect-state--error {
color: var(--error);
}
.coming-soon-title {
.inspect-empty-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
}
.coming-soon-sub {
.inspect-empty-sub {
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.inspect-cta {
font-size: 13px;
color: var(--accent);
text-decoration: none;
border: 1px solid var(--accent);
padding: 6px 14px;
border-radius: var(--radius-sm);
transition: all var(--transition);
}
.inspect-cta:hover {
background: var(--accent-muted);
}
.retry-btn {
font-size: 13px;
color: var(--text-secondary);
background: none;
border: 1px solid var(--border);
padding: 6px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.retry-btn:hover {
border-color: var(--text-secondary);
color: var(--text);
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -361,6 +361,17 @@ const messages: Messages = {
'settings.about': '\u00C0 propos',
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
// Inspect tab (#240, #241)
'inspect.tabMarkdown': 'Markdown',
'inspect.tabElements': '\u00c9l\u00e9ments',
'inspect.tabImages': 'Images',
'inspect.noAnalysis': 'Aucune analyse disponible',
'inspect.noAnalysisSub': 'Analysez ce document dans le Studio pour voir sa structure.',
'inspect.goToStudio': 'Aller dans le Studio',
'inspect.retry': 'R\u00e9essayer',
'inspect.noElements':
'Aucun \u00e9l\u00e9ment \u2014 lancez une analyse pour g\u00e9n\u00e9rer la structure.',
// Doc workspace (#216, #218)
'workspace.tabs.ask': 'Ask',
'workspace.tabs.inspect': 'Inspect',
@ -758,6 +769,16 @@ const messages: Messages = {
'settings.about': 'About',
'settings.designArticle': 'How Docling Studio was designed',
// Inspect tab (#240, #241)
'inspect.tabMarkdown': 'Markdown',
'inspect.tabElements': 'Elements',
'inspect.tabImages': 'Images',
'inspect.noAnalysis': 'No analysis available',
'inspect.noAnalysisSub': 'Analyze this document in Studio to see its structure.',
'inspect.goToStudio': 'Go to Studio',
'inspect.retry': 'Retry',
'inspect.noElements': 'No elements — run an analysis to generate structure.',
// Doc workspace (#216, #218)
'workspace.tabs.ask': 'Ask',
'workspace.tabs.inspect': 'Inspect',