280 lines
13 KiB
HTML
280 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="vi">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Email Convert — MarkItDown vs Docling</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
|
|
<link href="/style.css" rel="stylesheet" />
|
|
<script>
|
|
function App() {
|
|
return {
|
|
basePath: '/workspace/emailfiles',
|
|
rootEntries: [],
|
|
expanded: {},
|
|
browserLoading: false,
|
|
selectedPath: null,
|
|
selectedName: '',
|
|
useLlm: true,
|
|
dlFormat: 'markdown',
|
|
converting: false,
|
|
md: pane('md', 'MarkItDown', 'bg-primary', 'text-primary'),
|
|
dl: pane('dl', 'Docling', 'bg-success', 'text-success'),
|
|
|
|
get visibleEntries() {
|
|
const out = []
|
|
const walk = (entries, depth) => {
|
|
for (const e of entries) {
|
|
out.push({ ...e, depth })
|
|
if (e.type === 'dir' && this.expanded[e.path])
|
|
walk(this.expanded[e.path], depth + 1)
|
|
}
|
|
}
|
|
walk(this.rootEntries, 0)
|
|
return out
|
|
},
|
|
|
|
async init() { await this.loadDir(this.basePath, true) },
|
|
|
|
async loadDir(path, isRoot = false) {
|
|
if (!isRoot && this.expanded[path]) {
|
|
const copy = { ...this.expanded }; delete copy[path]; this.expanded = copy; return
|
|
}
|
|
if (isRoot) this.browserLoading = true
|
|
try {
|
|
const d = await fetch(`/api/markitdown/browse?path=${encodeURIComponent(path)}`).then(r => r.json())
|
|
if (d.type === 'dir') {
|
|
if (isRoot) this.rootEntries = d.entries
|
|
else this.expanded = { ...this.expanded, [path]: d.entries }
|
|
}
|
|
} catch {}
|
|
this.browserLoading = false
|
|
},
|
|
|
|
async changeBase() { this.expanded = {}; this.rootEntries = []; await this.loadDir(this.basePath, true) },
|
|
|
|
onEntry(e) {
|
|
if (e.type === 'dir') this.loadDir(e.path)
|
|
else { this.selectedPath = e.path; this.selectedName = e.name }
|
|
},
|
|
|
|
fileIcon(e) {
|
|
if (e.type === 'dir') return this.expanded[e.path] ? 'bi-folder2-open text-warning' : 'bi-folder2 text-warning'
|
|
const m = { '.pdf':'bi-file-earmark-pdf text-danger','.docx':'bi-file-earmark-word text-primary',
|
|
'.xlsx':'bi-file-earmark-excel text-success','.html':'bi-file-earmark-code text-info',
|
|
'.htm':'bi-file-earmark-code text-info','.md':'bi-markdown text-secondary',
|
|
'.txt':'bi-file-earmark-text text-secondary','.png':'bi-file-earmark-image text-info',
|
|
'.jpg':'bi-file-earmark-image','.jpeg':'bi-file-earmark-image',
|
|
'.csv':'bi-file-earmark-spreadsheet text-success' }
|
|
return m[e.ext] || 'bi-file-earmark text-secondary'
|
|
},
|
|
|
|
async convert() {
|
|
if (!this.selectedPath || this.converting) return
|
|
this.converting = true
|
|
for (const p of [this.md, this.dl])
|
|
Object.assign(p, { loading: true, done: false, content: '', error: null, status: 'Đang xử lý...' })
|
|
|
|
const body = { path: this.selectedPath, use_llm: this.useLlm }
|
|
const [r1, r2] = await Promise.allSettled([
|
|
api('/api/markitdown/convert-path', body),
|
|
api('/api/docling/convert-path', { ...body, output_format: this.dlFormat }),
|
|
])
|
|
applyResult(this.md, r1); applyResult(this.dl, r2)
|
|
this.converting = false
|
|
},
|
|
|
|
download(key) {
|
|
const p = this[key], base = this.selectedName.replace(/\.[^.]+$/, '')
|
|
const a = Object.assign(document.createElement('a'), {
|
|
href: URL.createObjectURL(new Blob([p.content], { type: 'text/markdown' })),
|
|
download: `${base}_${key}.md`
|
|
})
|
|
a.click(); URL.revokeObjectURL(a.href)
|
|
},
|
|
}
|
|
}
|
|
|
|
function pane(id, label, badgeClass, spinnerClass) {
|
|
return { id, label, badgeClass, spinnerClass, tab: 'raw',
|
|
loading: false, done: false, error: null, content: '', preview: '',
|
|
llmEnabled: false, ms: 0, status: '' }
|
|
}
|
|
|
|
async function api(url, body) {
|
|
const t0 = performance.now()
|
|
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
|
const ms = Math.round(performance.now() - t0)
|
|
if (!res.ok) { const e = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(e.detail) }
|
|
return { data: await res.json(), ms }
|
|
}
|
|
|
|
function applyResult(pane, result) {
|
|
if (result.status === 'fulfilled') {
|
|
const { data, ms } = result.value
|
|
pane.content = data.markdown || data.content || ''
|
|
pane.preview = marked.parse(pane.content)
|
|
pane.llmEnabled = !!data.llm_enabled
|
|
pane.ms = ms; pane.status = `✅ ${ms} ms`; pane.error = null
|
|
} else {
|
|
pane.content = ''; pane.error = result.reason.message; pane.status = `❌ ${result.reason.message}`
|
|
}
|
|
pane.loading = false; pane.done = true
|
|
}
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="app-nav"></div>
|
|
<script src="/layout.js"></script>
|
|
|
|
<div x-data="App()" x-init="init()">
|
|
<div class="container-xl py-4">
|
|
<div class="row g-3">
|
|
|
|
<!-- ── File browser ──────────────────────────────────────── -->
|
|
<div class="col-lg-4">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header py-2 d-flex align-items-center gap-2">
|
|
<i class="bi bi-hdd-fill text-secondary"></i>
|
|
<span class="fw-semibold small flex-grow-1">Duyệt file trên server</span>
|
|
<button class="btn btn-sm btn-outline-secondary py-0 px-2" @click="changeBase()">
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
</button>
|
|
</div>
|
|
<div class="px-3 pt-2 pb-1 border-bottom">
|
|
<select class="form-select form-select-sm" x-model="basePath" @change="changeBase()">
|
|
<option value="/workspace/emailfiles">emailfiles/</option>
|
|
<option value="/workspace/emailfiles/output">emailfiles/output/</option>
|
|
<option value="/workspace">/ (root project)</option>
|
|
</select>
|
|
<div class="path-badge text-muted mt-1" x-text="basePath"></div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="FileBrowser px-1 py-1">
|
|
<div x-show="browserLoading" class="text-center text-muted py-4 small">
|
|
<div class="spinner-border spinner-border-sm mb-2"></div><br>Đang tải...
|
|
</div>
|
|
<template x-if="!browserLoading">
|
|
<div>
|
|
<template x-for="e in visibleEntries" :key="e.path">
|
|
<div class="FileEntry"
|
|
:class="[e.type, selectedPath === e.path ? 'selected' : '', 'indent-' + Math.min(e.depth, 4)]"
|
|
@click="onEntry(e)" :title="e.path">
|
|
<i class="bi flex-shrink-0" :class="fileIcon(e)"></i>
|
|
<span class="text-truncate" x-text="e.name"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Controls + Results ───────────────────────────────── -->
|
|
<div class="col-lg-8">
|
|
|
|
<!-- Controls bar -->
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
|
<i class="bi bi-file-earmark-text fs-5 text-secondary"></i>
|
|
<div class="flex-grow-1 overflow-hidden">
|
|
<div class="fw-semibold small text-truncate" :class="selectedPath ? '' : 'text-muted'"
|
|
x-text="selectedName || 'Chưa chọn file'"></div>
|
|
<div class="path-badge text-muted" x-text="selectedPath"></div>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2 ms-auto flex-shrink-0">
|
|
<label class="form-label mb-0 small fw-medium">Docling</label>
|
|
<select class="form-select form-select-sm" x-model="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 class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" x-model="useLlm" id="LlmToggle" />
|
|
<label class="form-check-label small fw-medium" for="LlmToggle">LLM</label>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm" :disabled="!selectedPath || converting" @click="convert()">
|
|
<span x-show="converting" class="spinner-border spinner-border-sm me-1"></span>
|
|
<i x-show="!converting" class="bi bi-play-fill me-1"></i>Chuyển đổi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="row g-2 mb-3" x-show="md.done || dl.done || converting">
|
|
<template x-for="key in ['md','dl']" :key="key">
|
|
<div class="col-6">
|
|
<div class="card" :class="'border-' + $data[key].spinnerClass.replace('text-','') + '-subtle'">
|
|
<div class="card-body py-2 px-3 d-flex align-items-center gap-2">
|
|
<span class="badge rounded-circle p-1" :class="$data[key].badgeClass"> </span>
|
|
<span class="small fw-semibold flex-grow-1" x-text="$data[key].label"></span>
|
|
<span x-show="$data[key].loading" class="spinner-border spinner-border-sm" :class="$data[key].spinnerClass"></span>
|
|
<small class="text-muted" x-text="$data[key].status"></small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Result cards -->
|
|
<div class="row g-3">
|
|
<template x-for="key in ['md','dl']" :key="key">
|
|
<div class="col-md-6">
|
|
<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" :class="$data[key].badgeClass"> </span>
|
|
<span x-text="$data[key].label"></span>
|
|
<span x-show="$data[key].llmEnabled" class="badge bg-primary-subtle text-primary" style="font-size:.65rem">🤖 LLM</span>
|
|
</span>
|
|
<div class="d-flex align-items-center gap-1">
|
|
<button x-show="$data[key].content" class="btn btn-outline-secondary btn-sm py-0 px-2"
|
|
@click="download(key)"><i class="bi bi-download"></i></button>
|
|
<ul class="nav nav-tabs card-header-tabs border-0">
|
|
<li class="nav-item">
|
|
<a class="nav-link py-1 px-2 small" :class="{ active: $data[key].tab==='raw' }"
|
|
href="#" @click.prevent="$data[key].tab='raw'">Raw</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link py-1 px-2 small" :class="{ active: $data[key].tab==='preview' }"
|
|
href="#" @click.prevent="$data[key].tab='preview'">Preview</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div x-show="$data[key].loading" class="text-center text-muted py-5 small">
|
|
<div class="spinner-border spinner-border-sm mb-2" :class="$data[key].spinnerClass"></div>
|
|
<br>Đang xử lý...
|
|
</div>
|
|
<div x-show="!$data[key].loading && $data[key].error"
|
|
class="alert alert-danger m-3 small" x-text="$data[key].error"></div>
|
|
<pre x-show="!$data[key].loading && !$data[key].error && $data[key].tab==='raw'"
|
|
class="ResultPre p-3 m-0" x-text="$data[key].content"></pre>
|
|
<div x-show="!$data[key].loading && !$data[key].error && $data[key].tab==='preview'"
|
|
class="PreviewPane" x-html="$data[key].preview"></div>
|
|
<div x-show="!$data[key].loading && !$data[key].error && !$data[key].content"
|
|
class="text-center text-muted py-5 small">
|
|
<i class="bi bi-hdd fs-3 d-block mb-2"></i>Chọn file bên trái
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/marked@13/marked.min.js"></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
|
|
</body>
|
|
</html>
|