AI-markdown/laravel-app/resources/views/index.blade.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 &nbsp;|&nbsp;
<b>Docling</b>: transcript markdown &nbsp;|&nbsp;
<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 }}">&nbsp;</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"> 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 }}">&nbsp;</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 }}">&nbsp;</span>{{ $label }}
</div>
<div class="history-{{ $k }}">
<div class="text-center text-muted py-3 small">Chưa 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