Merge pull request #126 from scub-france/fix/batch-progress-and-version
fix: batch progress, segmented UI, version source, about section
This commit is contained in:
commit
93f37282a0
13 changed files with 443 additions and 29 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -4,6 +4,21 @@ All notable changes to Docling Studio will be documented in this file.
|
|||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.3.1] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- Batch conversion progress: segmented progress bar with ring indicator and per-batch visual feedback
|
||||
- Inline mini progress bar in the top banner during analysis
|
||||
- Informational notice in Prepare mode when chunking is unavailable (batch mode)
|
||||
- `BATCH_PAGE_SIZE` environment variable forwarded in Docker Compose
|
||||
|
||||
### Fixed
|
||||
|
||||
- Batch progress reset to null on completion (progress_current/progress_total overwritten by stale in-memory job object)
|
||||
- Regression test for batch progress preservation in `_run_analysis_inner` flow
|
||||
- E2E assertion on final progress values in batch-progress feature
|
||||
|
||||
## [0.3.0] - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ services:
|
|||
DOCLING_SERVE_API_KEY: ${DOCLING_SERVE_API_KEY:-}
|
||||
RATE_LIMIT_RPM: ${RATE_LIMIT_RPM:-100}
|
||||
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-50}
|
||||
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-0}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
|
|
|||
|
|
@ -327,6 +327,9 @@ class AnalysisService:
|
|||
chunks_json = json.dumps([_chunk_to_dict(c) for c in chunks])
|
||||
logger.info("Chunking produced %d chunks for job %s", len(chunks), job_id)
|
||||
|
||||
# Re-read the job so we don't lose progress_current/progress_total
|
||||
# written to the DB during batched conversion.
|
||||
job = await analysis_repo.find_by_id(job_id) or job
|
||||
job.mark_completed(
|
||||
markdown=result.content_markdown,
|
||||
html=result.content_html,
|
||||
|
|
|
|||
|
|
@ -385,6 +385,90 @@ class TestBatchedConversion:
|
|||
batch_size=5,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_preserved_through_full_analysis_flow(self):
|
||||
"""Progress written during batches must survive the final update_status.
|
||||
|
||||
Regression: _run_analysis_inner used to re-read the job from DB at the
|
||||
start, then call update_status(job) at the end — overwriting
|
||||
progress_current/progress_total with None because the in-memory object
|
||||
was stale. The fix re-reads the job before mark_completed.
|
||||
"""
|
||||
from domain.models import AnalysisJob, AnalysisStatus
|
||||
|
||||
converter = AsyncMock()
|
||||
converter.convert.side_effect = [
|
||||
ConversionResult(
|
||||
page_count=5,
|
||||
content_markdown="# B1",
|
||||
content_html="<html><body><p>B1</p></body></html>",
|
||||
pages=[PageDetail(page_number=i, width=612, height=792) for i in range(1, 6)],
|
||||
),
|
||||
ConversionResult(
|
||||
page_count=3,
|
||||
content_markdown="# B2",
|
||||
content_html="<html><body><p>B2</p></body></html>",
|
||||
pages=[PageDetail(page_number=i, width=612, height=792) for i in range(6, 9)],
|
||||
),
|
||||
]
|
||||
|
||||
service = AnalysisService(converter=converter, conversion_timeout=60)
|
||||
|
||||
# Simulated DB state: find_by_id is called 4 times:
|
||||
# 1) start of _run_analysis_inner (initial load)
|
||||
# 2) batch 1 mid-flight deletion check
|
||||
# 3) batch 2 mid-flight deletion check
|
||||
# 4) re-read before mark_completed (must carry progress)
|
||||
initial_job = AnalysisJob(
|
||||
id="job-progress",
|
||||
document_id="doc-1",
|
||||
status=AnalysisStatus.PENDING,
|
||||
)
|
||||
batch_check_job = AnalysisJob(
|
||||
id="job-progress",
|
||||
document_id="doc-1",
|
||||
status=AnalysisStatus.RUNNING,
|
||||
)
|
||||
refreshed_job = AnalysisJob(
|
||||
id="job-progress",
|
||||
document_id="doc-1",
|
||||
status=AnalysisStatus.RUNNING,
|
||||
progress_current=8,
|
||||
progress_total=8,
|
||||
)
|
||||
|
||||
saved_jobs: list[AnalysisJob] = []
|
||||
|
||||
async def capture_update_status(job):
|
||||
saved_jobs.append(job)
|
||||
|
||||
with (
|
||||
patch("services.analysis_service.analysis_repo") as mock_repo,
|
||||
patch("services.analysis_service.document_repo") as mock_doc_repo,
|
||||
patch("services.analysis_service._count_pdf_pages", return_value=8),
|
||||
patch("services.analysis_service.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.batch_page_size = 5
|
||||
mock_settings.default_table_mode = "accurate"
|
||||
mock_repo.find_by_id = AsyncMock(
|
||||
side_effect=[initial_job, batch_check_job, batch_check_job, refreshed_job]
|
||||
)
|
||||
mock_repo.update_status = AsyncMock(side_effect=capture_update_status)
|
||||
mock_repo.update_progress = AsyncMock()
|
||||
mock_doc_repo.update_page_count = AsyncMock()
|
||||
|
||||
await service._run_analysis_inner("job-progress", "/fake.pdf", "fake.pdf")
|
||||
|
||||
# The last update_status call is the COMPLETED one
|
||||
completed_job = saved_jobs[-1]
|
||||
assert completed_job.status == AnalysisStatus.COMPLETED
|
||||
assert completed_job.progress_current == 8, (
|
||||
"progress_current must be preserved (was the bug: got None)"
|
||||
)
|
||||
assert completed_job.progress_total == 8, (
|
||||
"progress_total must be preserved (was the bug: got None)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_deleted_mid_batch_returns_none(self):
|
||||
"""If the job is deleted between batches, conversion aborts with None."""
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ Feature: Batched conversion with progress reporting
|
|||
And match response.contentMarkdown == '#string'
|
||||
And match response.pagesJson == '#string'
|
||||
|
||||
# When batched, progress must be preserved at completion (not reset to null)
|
||||
# progressTotal > 0 proves batching was active
|
||||
# progressCurrent == progressTotal proves the final update_status didn't erase them
|
||||
* if (response.progressTotal > 0) karate.match('response.progressCurrent', response.progressTotal)
|
||||
|
||||
# Cleanup
|
||||
* call read('classpath:common/helpers/cleanup.feature') { docId: '#(uploaded.docId)' }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "docling-studio",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -109,20 +109,54 @@
|
|||
</div>
|
||||
</div>
|
||||
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
|
||||
<div class="spinner-large" />
|
||||
<span>{{ t('studio.analysisRunning') }}</span>
|
||||
<!-- Batch progress: segmented bar -->
|
||||
<div
|
||||
v-if="store.currentAnalysis.progressTotal && store.currentAnalysis.progressTotal > 0"
|
||||
class="batch-progress"
|
||||
>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
|
||||
<div class="batch-progress-ring">
|
||||
<svg viewBox="0 0 48 48" class="progress-ring-svg">
|
||||
<circle cx="24" cy="24" r="20" fill="none" stroke="var(--border)" stroke-width="3" />
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="20"
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="125.66"
|
||||
:stroke-dashoffset="125.66 - (125.66 * progressPercent) / 100"
|
||||
class="progress-ring-fill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="progress-ring-label">{{ progressPercent }}%</span>
|
||||
</div>
|
||||
<div class="batch-progress-detail">
|
||||
<span class="batch-progress-title">{{ t('studio.analysisRunning') }}</span>
|
||||
<div class="batch-segments">
|
||||
<div
|
||||
v-for="i in batchSegments"
|
||||
:key="i"
|
||||
class="batch-segment"
|
||||
:class="{
|
||||
filled: i <= filledSegments,
|
||||
active: i === filledSegments + 1,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="batch-progress-sub">
|
||||
<span class="batch-progress-pages">{{ store.currentAnalysis.progressCurrent ?? 0 }}</span>
|
||||
<span class="batch-progress-sep">/</span>
|
||||
<span>{{ store.currentAnalysis.progressTotal }} pages</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="progress-text">
|
||||
Pages {{ store.currentAnalysis.progressCurrent ?? 0 }} /
|
||||
{{ store.currentAnalysis.progressTotal }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Fallback: no batch info -->
|
||||
<template v-else>
|
||||
<div class="spinner-large" />
|
||||
<span>{{ t('studio.analysisRunning') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="store.currentAnalysis?.status === 'FAILED'"
|
||||
|
|
@ -199,6 +233,31 @@ const progressPercent = computed(() => {
|
|||
return Math.min(100, Math.round(((a.progressCurrent ?? 0) / a.progressTotal) * 100))
|
||||
})
|
||||
|
||||
/** Number of batch segments to render in the segmented progress bar. */
|
||||
const batchSegments = computed(() => {
|
||||
const a = store.currentAnalysis
|
||||
if (!a?.progressTotal || a.progressTotal <= 0) return 0
|
||||
// Each segment = batch_page_size pages. We infer count from total & current.
|
||||
// Backend updates progressCurrent in increments, so we derive segment count.
|
||||
const current = a.progressCurrent ?? 0
|
||||
if (current <= 0) return 0
|
||||
// Guess batch size from the first non-zero progressCurrent value.
|
||||
// Fallback: assume ~5 segments for a clean visual.
|
||||
const total = a.progressTotal
|
||||
// We can't know batch_page_size on frontend, so compute a clean segment count
|
||||
// by aiming for 3-8 segments (visually optimal).
|
||||
let count = Math.round(total / Math.max(1, current || total / 5))
|
||||
if (count < 2) count = Math.ceil(total / Math.ceil(total / 5))
|
||||
return Math.max(2, Math.min(8, count))
|
||||
})
|
||||
|
||||
/** How many segments are "filled" (completed batches). */
|
||||
const filledSegments = computed(() => {
|
||||
const total = batchSegments.value
|
||||
if (total <= 0) return 0
|
||||
return Math.round((progressPercent.value / 100) * total)
|
||||
})
|
||||
|
||||
const currentPageData = computed(() => {
|
||||
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
|
||||
})
|
||||
|
|
@ -534,29 +593,98 @@ async function copyElement(idx: number, content: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/* ── Batch progress: ring + segmented bar ── */
|
||||
.batch-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
width: 200px;
|
||||
gap: 20px;
|
||||
}
|
||||
.progress-bar {
|
||||
|
||||
/* Circular ring */
|
||||
.batch-progress-ring {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.progress-ring-svg {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border-light);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
.progress-ring-fill {
|
||||
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
|
||||
}
|
||||
.progress-ring-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Right side: text + segments */
|
||||
.batch-progress-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.batch-progress-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Segmented bar */
|
||||
.batch-segments {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.batch-segment {
|
||||
width: 28px;
|
||||
height: 5px;
|
||||
border-radius: 2.5px;
|
||||
background: var(--border);
|
||||
transition:
|
||||
background 0.4s ease,
|
||||
box-shadow 0.4s ease;
|
||||
}
|
||||
.batch-segment.filled {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
.batch-segment.active {
|
||||
background: var(--accent-muted);
|
||||
animation: segment-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes segment-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
50% {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
/* Page counter */
|
||||
.batch-progress-sub {
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.batch-progress-pages {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.batch-progress-sep {
|
||||
margin: 0 2px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -66,6 +66,18 @@
|
|||
<div v-if="analysisStore.rechunking" class="spinner-sm" />
|
||||
{{ analysisStore.rechunking ? t('chunking.chunking') : t('chunking.run') }}
|
||||
</button>
|
||||
|
||||
<!-- Batch mode notice -->
|
||||
<div v-if="isBatchedAnalysis" class="batch-notice" data-e2e="batch-notice">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="batch-notice-icon">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('chunking.batchNotice') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -151,6 +163,12 @@ const canRechunk = computed(() => {
|
|||
return analysis?.status === 'COMPLETED' && analysis.hasDocumentJson
|
||||
})
|
||||
|
||||
/** True when the analysis was batched (document_json unavailable). */
|
||||
const isBatchedAnalysis = computed(() => {
|
||||
const analysis = analysisStore.currentAnalysis
|
||||
return analysis?.status === 'COMPLETED' && !analysis.hasDocumentJson
|
||||
})
|
||||
|
||||
const pageChunks = computed(() =>
|
||||
analysisStore.currentChunks.filter((c) => c.sourcePage === props.currentPage),
|
||||
)
|
||||
|
|
@ -426,6 +444,28 @@ async function doRechunk() {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Batch mode info notice */
|
||||
.batch-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.batch-notice-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--info);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type DeploymentMode = 'self-hosted' | 'huggingface'
|
|||
|
||||
interface HealthResponse {
|
||||
status: string
|
||||
version?: string
|
||||
engine: ConversionEngine
|
||||
deploymentMode?: DeploymentMode
|
||||
maxPageCount?: number
|
||||
|
|
@ -41,6 +42,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
|||
const deploymentMode = ref<DeploymentMode | null>(null)
|
||||
const maxPageCount = ref<number>(0)
|
||||
const maxFileSizeMb = ref<number>(0)
|
||||
const appVersion = ref<string>(__APP_VERSION__)
|
||||
const loaded = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
|
|
@ -62,6 +64,7 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
|||
deploymentMode.value = data.deploymentMode ?? 'self-hosted'
|
||||
maxPageCount.value = data.maxPageCount ?? 0
|
||||
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
|
||||
if (data.version) appVersion.value = data.version
|
||||
loaded.value = true
|
||||
error.value = null
|
||||
} catch (e) {
|
||||
|
|
@ -70,5 +73,15 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
|||
}
|
||||
}
|
||||
|
||||
return { engine, deploymentMode, maxPageCount, maxFileSizeMb, loaded, error, isEnabled, load }
|
||||
return {
|
||||
engine,
|
||||
deploymentMode,
|
||||
maxPageCount,
|
||||
maxFileSizeMb,
|
||||
appVersion,
|
||||
loaded,
|
||||
error,
|
||||
isEnabled,
|
||||
load,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,15 +36,43 @@
|
|||
<label class="setting-label">{{ t('settings.version') }}</label>
|
||||
<span class="setting-value">{{ version }}</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">{{ t('settings.about') }}</label>
|
||||
<a
|
||||
href="https://dzone.com/articles/designing-docling-studio"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="about-link"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="about-link-icon">
|
||||
<path
|
||||
d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('settings.designArticle') }}
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="about-link-external">
|
||||
<path
|
||||
d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"
|
||||
/>
|
||||
<path
|
||||
d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useSettingsStore } from '../store'
|
||||
import { useFeatureFlagStore } from '../../feature-flags/store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const version = __APP_VERSION__
|
||||
const store = useSettingsStore()
|
||||
const featureStore = useFeatureFlagStore()
|
||||
const version = computed(() => featureStore.appVersion)
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
|
|
@ -118,4 +146,35 @@ const { t } = useI18n()
|
|||
color: var(--text);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.about-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
transition: all var(--transition);
|
||||
width: fit-content;
|
||||
}
|
||||
.about-link:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
.about-link-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.about-link-external {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -118,6 +118,35 @@
|
|||
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
|
||||
<div class="spinner-xs" />
|
||||
{{ t('studio.analysisRunning') }}
|
||||
<span
|
||||
v-if="
|
||||
analysisStore.currentAnalysis.progressTotal &&
|
||||
analysisStore.currentAnalysis.progressTotal > 0
|
||||
"
|
||||
class="info-badge-progress"
|
||||
>
|
||||
<span class="info-badge-bar">
|
||||
<span
|
||||
class="info-badge-fill"
|
||||
:style="{
|
||||
width:
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((analysisStore.currentAnalysis.progressCurrent ?? 0) /
|
||||
analysisStore.currentAnalysis.progressTotal) *
|
||||
100,
|
||||
),
|
||||
) + '%',
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="info-badge-count"
|
||||
>{{ analysisStore.currentAnalysis.progressCurrent ?? 0 }}/{{
|
||||
analysisStore.currentAnalysis.progressTotal
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
|
||||
<span class="info-dot error" />
|
||||
|
|
@ -867,6 +896,32 @@ onBeforeUnmount(() => {
|
|||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* Inline mini progress in top bar */
|
||||
.info-badge-progress {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.info-badge-bar {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 1.5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.info-badge-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 1.5px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.info-badge-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.studio-main {
|
||||
flex: 1;
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ const messages: Messages = {
|
|||
'chunking.chunks': 'chunks',
|
||||
'chunking.noChunks': 'Lancez le chunking pour préparer les segments.',
|
||||
'chunking.noChunksOnPage': 'Aucun chunk sur cette page.',
|
||||
'chunking.batchNotice':
|
||||
'Le chunking n\u2019est pas disponible pour cette analyse. Les documents volumineux trait\u00e9s par batch ne g\u00e9n\u00e8rent pas la structure interne n\u00e9cessaire au d\u00e9coupage.',
|
||||
|
||||
// Pagination
|
||||
'pagination.pageOf': 'Page {current} sur {total}',
|
||||
|
|
@ -132,6 +134,8 @@ const messages: Messages = {
|
|||
'settings.themeDark': 'Sombre',
|
||||
'settings.themeLight': 'Clair',
|
||||
'settings.language': 'Langue',
|
||||
'settings.about': '\u00C0 propos',
|
||||
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
|
|
@ -241,6 +245,8 @@ const messages: Messages = {
|
|||
'chunking.chunks': 'chunks',
|
||||
'chunking.noChunks': 'Run chunking to prepare segments.',
|
||||
'chunking.noChunksOnPage': 'No chunks on this page.',
|
||||
'chunking.batchNotice':
|
||||
'Chunking is not available for this analysis. Large documents processed in batch mode do not generate the internal structure required for chunking.',
|
||||
|
||||
'pagination.pageOf': 'Page {current} of {total}',
|
||||
'pagination.perPage': '/ page',
|
||||
|
|
@ -252,6 +258,8 @@ const messages: Messages = {
|
|||
'settings.themeDark': 'Dark',
|
||||
'settings.themeLight': 'Light',
|
||||
'settings.language': 'Language',
|
||||
'settings.about': 'About',
|
||||
'settings.designArticle': 'How Docling Studio was designed',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
|
|
|
|||
|
|
@ -97,10 +97,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from '../i18n'
|
||||
import { useFeatureFlagStore } from '../../features/feature-flags/store'
|
||||
|
||||
const version = __APP_VERSION__
|
||||
const featureStore = useFeatureFlagStore()
|
||||
const version = computed(() => featureStore.appVersion)
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue