428 lines
20 KiB
PHP
428 lines
20 KiB
PHP
@extends('layouts.app')
|
|
@section('title', 'Upload File — AI Markdown Demo')
|
|
|
|
@section('content')
|
|
|
|
{{-- ── Upload card ──────────────────────────────────────────────── --}}
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-body">
|
|
<h6 class="card-title fw-semibold mb-3">Tải lên tài liệu để so sánh</h6>
|
|
|
|
{{-- Mode tabs --}}
|
|
<ul class="nav nav-tabs mb-3" id="modeTabs">
|
|
<li class="nav-item">
|
|
<a class="nav-link py-1 px-3 small active" href="#" data-mode="file">
|
|
<i class="bi bi-file-earmark-text me-1"></i>File
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link py-1 px-3 small" href="#" data-mode="youtube">
|
|
<i class="bi bi-youtube me-1 text-danger"></i>YouTube
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
|
|
{{-- File zone --}}
|
|
<div id="fileZone">
|
|
<div class="drop-zone mb-2" id="dropZone">
|
|
<input type="file" id="fileInput" style="display:none"
|
|
accept=".pdf,.docx,.xlsx,.pptx,.html,.htm,.csv,.txt,.jpg,.jpeg,.png,.tiff,.tif,.bmp,.md,.epub,.zip,.asciidoc,.adoc,.webp" />
|
|
<i class="bi bi-file-earmark-text fs-1 text-secondary"></i>
|
|
<p class="text-muted mt-2 mb-1">Kéo thả hoặc click để chọn file</p>
|
|
<div id="fileInfo" class="fw-semibold text-primary small"></div>
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-1">
|
|
@foreach(['PDF','DOCX','XLSX','PPTX','HTML','CSV','TXT','JPG/PNG','EPUB','TIFF','ASCIIDoc'] as $ext)
|
|
<span class="badge bg-secondary-subtle text-secondary">{{ $ext }}</span>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
{{-- YouTube zone --}}
|
|
<div id="youtubeZone" style="display:none">
|
|
<div class="input-group">
|
|
<span class="input-group-text bg-danger text-white"><i class="bi bi-youtube"></i></span>
|
|
<input type="url" class="form-control" id="ytUrl" placeholder="https://www.youtube.com/watch?v=..." />
|
|
<button class="btn btn-outline-secondary" id="ytClear"><i class="bi bi-x"></i></button>
|
|
</div>
|
|
<div class="form-text mt-1">
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
<b>MarkItDown</b>: yt-dlp |
|
|
<b>Docling</b>: transcript → markdown |
|
|
<b>Unlimited-OCR</b>: <span class="text-warning-emphasis">không hỗ trợ URL</span>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Controls --}}
|
|
<div class="d-flex flex-wrap align-items-center gap-3 mt-3">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<label class="form-label mb-0 small fw-medium">Docling format</label>
|
|
<select class="form-select form-select-sm" id="dlFormat" style="width:auto">
|
|
<option value="markdown">Markdown</option>
|
|
<option value="json">JSON</option>
|
|
<option value="html">HTML</option>
|
|
<option value="text">Plain Text</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="llmToggle" checked />
|
|
<label class="form-check-label small fw-medium" for="llmToggle">LLM</label>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" id="btnConvert" disabled>
|
|
<i class="bi bi-play-fill me-1"></i>Chuyển đổi & So sánh
|
|
</button>
|
|
<button class="btn btn-outline-secondary btn-sm" id="btnClear">
|
|
<i class="bi bi-x-circle me-1"></i>Xoá
|
|
</button>
|
|
</div>
|
|
|
|
{{-- Prompt panel --}}
|
|
<div id="promptPanel" class="mt-3">
|
|
<div class="p-3 rounded border bg-primary-subtle">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span class="small fw-semibold text-primary"><i class="bi bi-pencil-square me-1"></i>Custom LLM Prompt</span>
|
|
<button class="btn btn-link btn-sm p-0 text-primary" id="btnClearPrompt">Xoá</button>
|
|
</div>
|
|
<textarea class="form-control form-control-sm" id="llmPrompt" rows="3"
|
|
placeholder="Để trống = dùng default prompt..."></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Status row ───────────────────────────────────────────────── --}}
|
|
<div class="row g-3 mb-4" id="statusRow" style="display:none!important">
|
|
@foreach([['md','MarkItDown','bg-primary','text-primary'],['dl','Docling','bg-success','text-success'],['uo','Unlimited-OCR','bg-danger','text-danger']] as [$k,$label,$bg,$tc])
|
|
<div class="col-md-4">
|
|
<div class="card h-100">
|
|
<div class="card-body">
|
|
<h6 class="card-title d-flex align-items-center gap-2">
|
|
<span class="badge rounded-circle p-1 {{ $bg }}"> </span>{{ $label }}
|
|
</h6>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<div class="spinner-border spinner-border-sm {{ $tc }} status-spinner-{{ $k }}" style="display:none"></div>
|
|
<small class="text-muted status-text-{{ $k }}">Đang chờ...</small>
|
|
</div>
|
|
<div class="row g-2 mt-2 status-stats-{{ $k }}" style="display:none">
|
|
<div class="col-4"><div class="border rounded text-center py-2">
|
|
<div class="fw-bold stat-ms-{{ $k }}">—</div>
|
|
<div class="text-muted" style="font-size:.7rem">ms</div>
|
|
</div></div>
|
|
<div class="col-4"><div class="border rounded text-center py-2">
|
|
<div class="fw-bold stat-len-{{ $k }}">—</div>
|
|
<div class="text-muted" style="font-size:.7rem">ký tự</div>
|
|
</div></div>
|
|
<div class="col-4"><div class="border rounded text-center py-2">
|
|
<div class="fw-bold stat-lines-{{ $k }}">—</div>
|
|
<div class="text-muted" style="font-size:.7rem">dòng</div>
|
|
</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- ── Result cards ─────────────────────────────────────────────── --}}
|
|
<div class="row g-3 mb-4">
|
|
@foreach([['md','MarkItDown','bg-primary','text-primary'],['dl','Docling','bg-success','text-success'],['uo','Unlimited-OCR','bg-danger','text-danger']] as [$k,$label,$bg,$tc])
|
|
<div class="col-md-4">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
|
<span class="fw-semibold small d-flex align-items-center gap-2">
|
|
<span class="badge rounded-circle p-1 {{ $bg }}"> </span>
|
|
{{ $label }}
|
|
<span class="badge bg-primary-subtle text-primary llm-badge-{{ $k }}" style="display:none;font-size:.65rem">🤖 LLM</span>
|
|
</span>
|
|
<div class="d-flex align-items-center gap-1">
|
|
@if($k === 'md')
|
|
<button class="btn btn-outline-warning btn-sm py-0 px-2" id="btnCleanup" style="display:none">
|
|
✨ Làm đẹp
|
|
</button>
|
|
@endif
|
|
<button class="btn btn-outline-secondary btn-sm py-0 px-2 btn-dl-{{ $k }}" style="display:none">
|
|
<i class="bi bi-download"></i> .md
|
|
</button>
|
|
@include('partials.pane-tabs', ['k' => $k])
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
@include('partials.pane-body', ['k' => $k, 'tc' => $tc])
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- ── History ──────────────────────────────────────────────────── --}}
|
|
<h6 class="fw-semibold mb-3">Lịch sử chuyển đổi gần đây</h6>
|
|
<div class="row g-3">
|
|
@foreach([['md','MarkItDown','bg-primary'],['dl','Docling','bg-success'],['uo','Unlimited-OCR','bg-danger']] as [$k,$label,$bg])
|
|
<div class="col-md-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header py-2 small fw-semibold d-flex align-items-center gap-2">
|
|
<span class="badge rounded-circle p-1 {{ $bg }}"> </span>{{ $label }}
|
|
</div>
|
|
<div class="history-{{ $k }}">
|
|
<div class="text-center text-muted py-3 small">Chưa có lịch sử</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
<script>
|
|
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } })
|
|
|
|
$(function () {
|
|
|
|
// ── State ────────────────────────────────────────────────────
|
|
let mode = 'file'
|
|
let currentFile = null
|
|
const results = { md: '', dl: '', uo: '' }
|
|
const tabs = { md: 'raw', dl: 'raw', uo: 'raw' }
|
|
|
|
// ── Init ─────────────────────────────────────────────────────
|
|
loadHistory()
|
|
const savedPrompt = localStorage.getItem('llm_prompt')
|
|
if (savedPrompt) $('#llmPrompt').val(savedPrompt)
|
|
if (localStorage.getItem('llm_enabled') === '0') $('#llmToggle').prop('checked', false)
|
|
togglePromptPanel()
|
|
|
|
// ── Mode tabs ────────────────────────────────────────────────
|
|
$('#modeTabs a').on('click', function (e) {
|
|
e.preventDefault()
|
|
mode = $(this).data('mode')
|
|
$('#modeTabs a').removeClass('active')
|
|
$(this).addClass('active')
|
|
$('#fileZone').toggle(mode === 'file')
|
|
$('#youtubeZone').toggle(mode === 'youtube')
|
|
updateConvertBtn()
|
|
})
|
|
|
|
// ── File input ───────────────────────────────────────────────
|
|
$('#dropZone').on('click', () => $('#fileInput').click())
|
|
$('#fileInput').on('change', function () { setFile(this.files[0]) })
|
|
$('#dropZone').on('dragover', function (e) { e.preventDefault(); $(this).addClass('drag-over') })
|
|
$('#dropZone').on('dragleave', function () { $(this).removeClass('drag-over') })
|
|
$('#dropZone').on('drop', function (e) {
|
|
e.preventDefault(); $(this).removeClass('drag-over')
|
|
setFile(e.originalEvent.dataTransfer.files[0])
|
|
})
|
|
function setFile(f) {
|
|
if (!f) return
|
|
currentFile = f
|
|
const size = f.size < 1048576 ? (f.size/1024).toFixed(1)+' KB' : (f.size/1048576).toFixed(1)+' MB'
|
|
$('#fileInfo').text(f.name + ' (' + size + ')')
|
|
updateConvertBtn()
|
|
}
|
|
|
|
// ── YouTube ──────────────────────────────────────────────────
|
|
$('#ytUrl').on('input', updateConvertBtn)
|
|
$('#ytClear').on('click', function () { $('#ytUrl').val(''); updateConvertBtn() })
|
|
|
|
function updateConvertBtn() {
|
|
const ok = mode === 'file' ? !!currentFile : !!$('#ytUrl').val().trim()
|
|
$('#btnConvert').prop('disabled', !ok)
|
|
}
|
|
|
|
// ── LLM toggle ───────────────────────────────────────────────
|
|
$('#llmToggle').on('change', function () {
|
|
localStorage.setItem('llm_enabled', this.checked ? '1' : '0')
|
|
togglePromptPanel()
|
|
})
|
|
function togglePromptPanel() {
|
|
$('#promptPanel').toggle($('#llmToggle').is(':checked'))
|
|
}
|
|
$('#btnClearPrompt').on('click', function () { $('#llmPrompt').val('') })
|
|
|
|
// ── Clear ────────────────────────────────────────────────────
|
|
$('#btnClear').on('click', function () {
|
|
currentFile = null; $('#fileInfo').text(''); $('#ytUrl').val('')
|
|
updateConvertBtn()
|
|
['md','dl','uo'].forEach(k => resetPane(k))
|
|
$('#statusRow').hide()
|
|
})
|
|
|
|
// ── Convert ──────────────────────────────────────────────────
|
|
$('#btnConvert').on('click', function () {
|
|
const useLlm = $('#llmToggle').is(':checked')
|
|
const prompt = $('#llmPrompt').val().trim() || null
|
|
const dlFmt = $('#dlFormat').val()
|
|
|
|
$('#statusRow').show()
|
|
$('#btnConvert').prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Đang xử lý...')
|
|
|
|
const t0 = performance.now()
|
|
;['md','dl','uo'].forEach(k => startPane(k))
|
|
|
|
const mkUrl = (base, extra = {}) => {
|
|
const p = new URLSearchParams({ use_llm: useLlm, ...extra })
|
|
if (prompt) p.set('llm_prompt', prompt)
|
|
return base + '?' + p.toString()
|
|
}
|
|
|
|
const requests = {
|
|
md: mode === 'youtube'
|
|
? $.ajax({ url: '/api/markitdown/convert-url', method: 'POST', contentType: 'application/json',
|
|
data: JSON.stringify({ url: $('#ytUrl').val(), use_llm: useLlm, llm_prompt: prompt }) })
|
|
: (function () {
|
|
const fd = new FormData(); fd.append('file', currentFile)
|
|
return $.ajax({ url: mkUrl('/api/markitdown/convert'), method: 'POST', data: fd,
|
|
processData: false, contentType: false })
|
|
})(),
|
|
dl: mode === 'youtube'
|
|
? $.ajax({ url: '/api/docling/convert-url', method: 'POST', contentType: 'application/json',
|
|
data: JSON.stringify({ url: $('#ytUrl').val(), output_format: dlFmt, use_llm: useLlm, llm_prompt: prompt }) })
|
|
: (function () {
|
|
const fd = new FormData(); fd.append('file', currentFile)
|
|
return $.ajax({ url: mkUrl('/api/docling/convert', { output_format: dlFmt }), method: 'POST',
|
|
data: fd, processData: false, contentType: false })
|
|
})(),
|
|
uo: mode === 'youtube'
|
|
? Promise.reject({ responseJSON: { detail: 'Unlimited-OCR không hỗ trợ YouTube URL' } })
|
|
: (function () {
|
|
const fd = new FormData(); fd.append('file', currentFile)
|
|
return $.ajax({ url: mkUrl('/api/unlimited-ocr/convert'), method: 'POST', data: fd,
|
|
processData: false, contentType: false })
|
|
})(),
|
|
}
|
|
|
|
const settled = k => new Promise(resolve => {
|
|
const t1 = performance.now()
|
|
Promise.resolve(requests[k])
|
|
.then(d => resolve({ ok: true, data: d, ms: Math.round(performance.now() - t1) }))
|
|
.catch(e => resolve({ ok: false, err: e?.responseJSON?.detail || e?.statusText || 'Lỗi không xác định' }))
|
|
})
|
|
|
|
Promise.all(['md','dl','uo'].map(k => settled(k).then(r => ({ k, ...r })))).then(all => {
|
|
all.forEach(({ k, ok, data, ms, err }) => {
|
|
if (ok) finishPane(k, data, ms)
|
|
else errorPane(k, err)
|
|
})
|
|
$('#btnConvert').prop('disabled', false).html('<i class="bi bi-play-fill me-1"></i>Chuyển đổi & So sánh')
|
|
loadHistory()
|
|
localStorage.setItem('llm_prompt', $('#llmPrompt').val())
|
|
})
|
|
})
|
|
|
|
// ── Pane helpers ─────────────────────────────────────────────
|
|
function resetPane(k) {
|
|
results[k] = ''; tabs[k] = 'raw'
|
|
$(`.pane-idle-${k}`).show()
|
|
$(`.pane-loading-${k}, .pane-error-${k}, .pane-raw-${k}, .pane-preview-${k}`).hide()
|
|
$(`.status-spinner-${k}, .status-stats-${k}, .llm-badge-${k}, .btn-dl-${k}`).hide()
|
|
$(`.status-text-${k}`).text('Đang chờ...')
|
|
if (k === 'md') $('#btnCleanup').hide()
|
|
}
|
|
|
|
function startPane(k) {
|
|
$(`.pane-idle-${k}, .pane-error-${k}, .pane-raw-${k}, .pane-preview-${k}`).hide()
|
|
$(`.pane-loading-${k}`).show()
|
|
$(`.status-spinner-${k}`).show()
|
|
$(`.status-text-${k}`).text('Đang xử lý...')
|
|
}
|
|
|
|
function finishPane(k, data, ms) {
|
|
const content = data.markdown || data.content || ''
|
|
results[k] = content
|
|
$(`.pane-loading-${k}`).hide()
|
|
$(`.pane-raw-${k}`).text(content).show()
|
|
$(`.pane-preview-${k}`).html(mdRender(content)).hide()
|
|
tabs[k] = 'raw'
|
|
$(`.tab-raw-${k}`).addClass('active')
|
|
$(`.tab-preview-${k}`).removeClass('active')
|
|
$(`.status-spinner-${k}`).hide()
|
|
$(`.status-text-${k}`).text(`✅ ${ms} ms` + (data.llm_enabled ? ' 🤖' : ''))
|
|
$(`.status-stats-${k}`).show()
|
|
$(`.stat-ms-${k}`).text(ms.toLocaleString())
|
|
$(`.stat-len-${k}`).text(content.length.toLocaleString())
|
|
$(`.stat-lines-${k}`).text(content.split('\n').length)
|
|
if (data.llm_enabled) $(`.llm-badge-${k}`).show()
|
|
if (content) { $(`.btn-dl-${k}`).show() }
|
|
if (k === 'md' && content) $('#btnCleanup').show()
|
|
}
|
|
|
|
function errorPane(k, msg) {
|
|
$(`.pane-loading-${k}`).hide()
|
|
$(`.pane-error-${k}`).text(msg).show()
|
|
$(`.status-spinner-${k}`).hide()
|
|
$(`.status-text-${k}`).text('❌ ' + msg)
|
|
}
|
|
|
|
// ── Tab switching ─────────────────────────────────────────────
|
|
$(document).on('click', '[data-tab]', function (e) {
|
|
e.preventDefault()
|
|
const k = $(this).data('pane')
|
|
const t = $(this).data('tab')
|
|
tabs[k] = t
|
|
$(`.tab-raw-${k}, .tab-preview-${k}`).removeClass('active')
|
|
$(`.tab-${t}-${k}`).addClass('active')
|
|
$(`.pane-raw-${k}, .pane-preview-${k}`).hide()
|
|
$(`.pane-${t}-${k}`).show()
|
|
})
|
|
|
|
// ── Download ─────────────────────────────────────────────────
|
|
$(document).on('click', '[class*="btn-dl-"]', function () {
|
|
const k = $(this).attr('class').match(/btn-dl-(\w+)/)[1]
|
|
const name = (currentFile?.name || 'output').replace(/\.[^.]+$/, '')
|
|
const a = document.createElement('a')
|
|
a.href = URL.createObjectURL(new Blob([results[k]], { type: 'text/markdown' }))
|
|
a.download = `${name}_${k}.md`
|
|
a.click(); URL.revokeObjectURL(a.href)
|
|
})
|
|
|
|
// ── Cleanup ──────────────────────────────────────────────────
|
|
$('#btnCleanup').on('click', function () {
|
|
if (!results.md) return
|
|
$(this).prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>')
|
|
$.ajax({
|
|
url: '/api/markitdown/cleanup',
|
|
method: 'POST', contentType: 'application/json',
|
|
data: JSON.stringify({ text: results.md, prompt: $('#llmPrompt').val() || null }),
|
|
}).done(function (d) {
|
|
results.md = d.text
|
|
$(`.pane-raw-md`).text(d.text)
|
|
$(`.pane-preview-md`).html(mdRender(d.text))
|
|
}).fail(function (e) {
|
|
alert('Cleanup thất bại: ' + (e?.responseJSON?.detail || 'Lỗi'))
|
|
}).always(function () {
|
|
$('#btnCleanup').prop('disabled', false).text('✨ Làm đẹp')
|
|
})
|
|
})
|
|
|
|
// ── History ──────────────────────────────────────────────────
|
|
function loadHistory() {
|
|
const endpoints = {
|
|
md: '/api/markitdown/history?limit=8',
|
|
dl: '/api/docling/history?limit=8',
|
|
uo: '/api/unlimited-ocr/history?limit=8',
|
|
}
|
|
Object.entries(endpoints).forEach(([k, url]) => {
|
|
$.getJSON(url).done(function (items) {
|
|
const $el = $(`.history-${k}`)
|
|
if (!items.length) { $el.html('<div class="text-center text-muted py-3 small">Chưa có lịch sử</div>'); return }
|
|
const rows = items.map(item => {
|
|
const t = item.created_at ? new Date(item.created_at).toLocaleTimeString('vi-VN') : ''
|
|
const llm = item.llm_enabled ? '<span class="badge bg-primary-subtle text-primary">🤖 LLM</span>' : ''
|
|
const ft = item.file_type ? `<span class="badge bg-secondary-subtle text-secondary">${item.file_type}</span>` : ''
|
|
return `<li class="list-group-item d-flex justify-content-between align-items-center py-2 px-3">
|
|
<span class="small fw-medium text-truncate me-2" style="max-width:60%">${item.filename}</span>
|
|
<span class="d-flex gap-1 align-items-center flex-shrink-0">
|
|
${ft} ${llm}
|
|
<span class="text-muted" style="font-size:.7rem">${t}</span>
|
|
</span>
|
|
</li>`
|
|
}).join('')
|
|
$el.html(`<ul class="list-group list-group-flush">${rows}</ul>`)
|
|
})
|
|
})
|
|
}
|
|
|
|
})
|
|
</script>
|
|
@endsection
|