AI-markdown/frontend/email-convert.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">&nbsp;</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">&nbsp;</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>