Last active
April 27, 2025 21:50
-
-
Save celsowm/7b8874f739cbe125a7d8ce7469a66f37 to your computer and use it in GitHub Desktop.
LLM EDITOR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="pt-BR"> | |
<head> | |
<meta charset="utf-8"> | |
<title>DOCX Editor + LLM Chat</title> | |
<!-- FontAwesome --> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" /> | |
<!-- docx-preview deps --> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/docx-preview.min.js"></script> | |
<style> | |
:root { | |
--dz-h: 42px; | |
} | |
html, | |
body { | |
height: 100%; | |
margin: 0; | |
font-family: Arial, Helvetica, sans-serif | |
} | |
body { | |
padding-top: var(--dz-h); | |
} | |
#container { | |
display: flex; | |
height: 100% | |
} | |
/* ——— EDITOR ——— */ | |
#left { | |
flex: 1; | |
display: flex; | |
flex-direction: column; | |
border-right: 1px solid #ccc | |
} | |
#toolbar { | |
position: sticky; | |
top: 0; | |
z-index: 20; | |
display: flex; | |
flex-wrap: wrap; | |
gap: .4rem; | |
padding: 6px 8px; | |
border-bottom: 1px solid #ccc; | |
background: #f7f7f7 | |
} | |
#toolbar button { | |
background: none; | |
border: none; | |
cursor: pointer; | |
width: 32px; | |
height: 32px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
font-size: 1.1em | |
} | |
#toolbar button:hover { | |
background: #e6e6e6 | |
} | |
#toolbar button span { | |
font-size: .75em; | |
font-weight: 600 | |
} | |
#editor { | |
flex: 1; | |
overflow: auto; | |
padding: 12px | |
} | |
#editor:focus { | |
outline: none | |
} | |
/* ——— CHAT ——— */ | |
#right { | |
width: 38%; | |
display: flex; | |
flex-direction: column | |
} | |
#messages { | |
flex: 1; | |
overflow: auto; | |
padding: 12px; | |
background: #fafafa | |
} | |
.msg { | |
margin-bottom: 12px; | |
white-space: pre-wrap; | |
line-height: 1.35 | |
} | |
.user { | |
color: #0a0 | |
} | |
.assistant { | |
color: #0645ad | |
} | |
#promptForm { | |
display: flex; | |
border-top: 1px solid #ccc | |
} | |
#prompt { | |
flex: 1; | |
border: none; | |
padding: 10px; | |
resize: none; | |
font: inherit; | |
height: 74px | |
} | |
#prompt:focus { | |
outline: none | |
} | |
#promptSend { | |
width: 90px; | |
background: #007bff; | |
border: none; | |
color: #fff; | |
font-weight: 600; | |
cursor: pointer | |
} | |
/* ——— DROP ZONE ——— */ | |
#dropZone { | |
position: fixed; | |
top: 0; | |
left: 0; | |
right: 0; | |
height: var(--dz-h); | |
line-height: var(--dz-h); | |
text-align: center; | |
background: #eaeaea; | |
border-bottom: 1px dashed #999; | |
cursor: pointer; | |
z-index: 1000; | |
transition: background .2s | |
} | |
#dropZone.drag { | |
background: #dfefff | |
} | |
/* ——— EFEITO “PROCESSING” ——— */ | |
.processing { | |
position: relative; | |
} | |
.processing::after { | |
content: ''; | |
position: absolute; | |
inset: 0; | |
border-radius: 3px; | |
background: linear-gradient(120deg, | |
rgba(255, 255, 150, 0) 0%, | |
rgba(255, 255, 150, .9) 50%, | |
rgba(255, 255, 150, 0) 100%); | |
background-size: 200% 100%; | |
animation: shimmer 1.2s linear infinite; | |
pointer-events: none; | |
} | |
@keyframes shimmer { | |
from { | |
background-position: 200% 0 | |
} | |
to { | |
background-position: -200% 0 | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<input type="file" id="fileInput" accept=".docx" hidden> | |
<div id="dropZone">Clique ou arraste um arquivo .docx aqui</div> | |
<div id="container"> | |
<div id="left"> | |
<div id="toolbar"> | |
<button aria-label="Negrito" data-cmd="bold"><i class="fa-solid fa-bold"></i></button> | |
<button aria-label="Itálico" data-cmd="italic"><i class="fa-solid fa-italic"></i></button> | |
<button aria-label="Sublinhado" data-cmd="underline"><i class="fa-solid fa-underline"></i></button> | |
<button aria-label="Tachado" data-cmd="strikethrough"><i class="fa-solid fa-strikethrough"></i></button> | |
<button aria-label="Lista não-ordenada" data-cmd="insertUnorderedList"><i | |
class="fa-solid fa-list-ul"></i></button> | |
<button aria-label="Lista ordenada" data-cmd="insertOrderedList"><i class="fa-solid fa-list-ol"></i></button> | |
<button aria-label="Alinhar à esquerda" data-cmd="justifyLeft"><i class="fa-solid fa-align-left"></i></button> | |
<button aria-label="Centralizar" data-cmd="justifyCenter"><i class="fa-solid fa-align-center"></i></button> | |
<button aria-label="Alinhar à direita" data-cmd="justifyRight"><i class="fa-solid fa-align-right"></i></button> | |
<button aria-label="Título 1" data-cmd="formatBlock" data-value="H1"><span>H1</span></button> | |
<button aria-label="Título 2" data-cmd="formatBlock" data-value="H2"><span>H2</span></button> | |
<button aria-label="Parágrafo" data-cmd="formatBlock" data-value="P"><span>¶</span></button> | |
<button aria-label="Criar link" data-cmd="createLink"><i class="fa-solid fa-link"></i></button> | |
<button aria-label="Remover link" data-cmd="unlink"><i class="fa-solid fa-link-slash"></i></button> | |
<button aria-label="Desfazer" data-cmd="undo"><i class="fa-solid fa-rotate-left"></i></button> | |
<button aria-label="Refazer" data-cmd="redo"><i class="fa-solid fa-rotate-right"></i></button> | |
</div> | |
<div id="editor" contenteditable="true"></div> | |
</div> | |
<div id="right"> | |
<div id="messages"></div> | |
<form id="promptForm"> | |
<textarea id="prompt" placeholder="Digite seu prompt (Shift+Enter = nova linha)"></textarea> | |
<button id="promptSend" type="submit">Enviar</button> | |
</form> | |
</div> | |
</div> | |
<script type="module"> | |
/* ===== ELEMENTOS ===== */ | |
const fileInput = document.getElementById('fileInput'); | |
const dropZone = document.getElementById('dropZone'); | |
const editor = document.getElementById('editor'); | |
const messagesBox = document.getElementById('messages'); | |
const promptTxt = document.getElementById('prompt'); | |
const promptForm = document.getElementById('promptForm'); | |
/* seleção múltipla */ | |
let selectedSegIds = []; | |
/* ===== DROP-ZONE ===== */ | |
dropZone.addEventListener('click', () => fileInput.click()); | |
['dragenter', 'dragover'].forEach(ev => | |
dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.add('drag'); }) | |
); | |
['dragleave', 'drop'].forEach(ev => | |
dropZone.addEventListener(ev, e => { dropZone.classList.remove('drag'); }) | |
); | |
dropZone.addEventListener('drop', e => { | |
e.preventDefault(); | |
const file = [...e.dataTransfer.files].find(f => f.name.endsWith('.docx')); | |
if (file) loadDocx(file); | |
}); | |
fileInput.addEventListener('change', e => { | |
const file = e.target.files[0]; | |
if (file) loadDocx(file); | |
fileInput.value = ''; | |
}); | |
/* ===== CARREGAR DOCX ===== */ | |
async function loadDocx(file) { | |
editor.innerHTML = '<p style="color:#666">Carregando…</p>'; | |
const buffer = await file.arrayBuffer(); | |
await docx.renderAsync(buffer, editor, null, { className: 'docx' }); | |
indexSegments(); | |
dropZone.style.display = 'none'; | |
} | |
/* indexa blocos para patch */ | |
function indexSegments() { | |
[...editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li')] | |
.forEach((n, i) => n.dataset.seg = 's' + i); | |
} | |
/* ===== CONTROLA SELEÇÃO ===== */ | |
function refreshSelection() { | |
const sel = window.getSelection(); | |
selectedSegIds = []; | |
editor.querySelectorAll('[data-seg]').forEach(n => n.style.background = ''); | |
if (!sel || sel.isCollapsed || !sel.rangeCount) return; | |
const range = sel.getRangeAt(0); | |
[...editor.querySelectorAll('[data-seg]')].forEach(node => { | |
if (range.intersectsNode(node)) { | |
selectedSegIds.push(node.dataset.seg); | |
node.style.background = '#ffffcc'; | |
} | |
}); | |
} | |
editor.addEventListener('mouseup', refreshSelection); | |
editor.addEventListener('keyup', refreshSelection); // teclas de navegação | |
/* ===== TOOLBAR ===== */ | |
document.getElementById('toolbar').addEventListener('click', e => { | |
const btn = e.target.closest('button[data-cmd]'); if (!btn) return; | |
let cmd = btn.dataset.cmd, val = btn.dataset.value || null; | |
if (cmd === 'createLink') { val = prompt('Insira a URL do link:', 'https://'); if (!val) return; } | |
document.execCommand(cmd, false, val); | |
editor.focus(); | |
}); | |
/* --- EFEITO “PROCESSING” -------------------------------------------- */ | |
function toggleProcessing(on) { | |
selectedSegIds.forEach(id => { | |
const n = editor.querySelector(`[data-seg="${id}"]`); | |
if (n) n.classList.toggle('processing', on); | |
}); | |
} | |
/* ===== CHAT HELPERS ===== */ | |
function addMsg(txt, type) { | |
const div = document.createElement('div'); | |
div.className = `msg ${type}`; div.textContent = txt; | |
messagesBox.appendChild(div); | |
messagesBox.scrollTop = messagesBox.scrollHeight; | |
return div; | |
} | |
function updateMsg(txt, div, type) { | |
div.textContent = txt; div.className = `msg ${type}`; | |
messagesBox.scrollTop = messagesBox.scrollHeight; | |
} | |
/* ===== APLICA PATCH ===== */ | |
function handleAssistant(content, holder) { | |
try { | |
const data = (typeof content === 'string') ? JSON.parse(content) : content; | |
const patches = data.patches; | |
if (!Array.isArray(patches)) throw new Error('"patches" não é array'); | |
patches.forEach(p => { | |
const node = editor.querySelector(`[data-seg="${p.id}"]`); | |
if (node) node.outerHTML = p.html; | |
}); | |
indexSegments(); | |
refreshSelection(); | |
updateMsg('✅ trechos atualizados', holder, 'assistant'); | |
} catch (e) { | |
updateMsg('❌ Não foi possível aplicar os patches', holder, 'assistant'); | |
console.error('handleAssistant:', e); | |
} finally { | |
toggleProcessing(false); // sempre remove o shimmer | |
} | |
} | |
/* ===== ENVIO DE PROMPT ===== */ | |
promptForm.addEventListener('submit', async e => { | |
e.preventDefault(); | |
const userText = promptTxt.value.trim(); | |
if (!userText) return; | |
addMsg(userText, 'user'); | |
promptTxt.value = ''; | |
/* prepara HTML dos blocos selecionados */ | |
const selHTMLArr = selectedSegIds.map(id => { | |
const n = editor.querySelector(`[data-seg="${id}"]`); | |
return n ? n.outerHTML : ''; | |
}); | |
const joinedHTML = selHTMLArr.join('\n'); | |
toggleProcessing(true); // liga o shimmer | |
const thinking = addMsg('⌛ pensando…', 'assistant'); | |
const messages = [ | |
{ | |
role: 'system', | |
content: `Responda SOMENTE JSON no formato ` + | |
`[{"id":"sX","html":"<tag data-seg=\\"sX\\">…</tag>"}, …] ` + | |
`— mantenha o atributo data-seg original e em caso de remoções ou resumos trazer html vazio os que não fizerem parte da alteração` | |
}, | |
{ | |
role: 'user', | |
content: joinedHTML | |
? `${userText}:\n${joinedHTML}` | |
: userText | |
} | |
]; | |
console.log(messages); | |
try { | |
const rsp = await fetch('http://localhost:8081/v1/chat/completions', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
model: 'gpt-4o-mini', | |
temperature: 0.3, | |
response_format: { | |
type: 'json_object', | |
schema: { | |
type: 'object', | |
properties: { | |
patches: { | |
type: 'array', | |
items: { | |
type: 'object', | |
properties: { | |
id: { type: 'string' }, | |
html: { type: 'string' } | |
}, | |
required: ['id', 'html'], | |
additionalProperties: false | |
} | |
} | |
}, | |
required: ['patches'], | |
additionalProperties: false | |
} | |
}, | |
messages: messages | |
}) | |
}).then(r => r.json()); | |
const content = rsp.choices[0].message.content; | |
console.log("Resposta da LLM:"); | |
console.log(content); | |
handleAssistant(content, thinking); | |
} catch (err) { | |
updateMsg('Erro: ' + err, thinking, 'assistant'); | |
} finally { | |
toggleProcessing(false); // garante desligar | |
} | |
}); | |
/* Enter = enviar, Shift+Enter = nova linha */ | |
promptTxt.addEventListener('keydown', ev => { | |
if (ev.key === 'Enter' && !ev.shiftKey) { | |
ev.preventDefault(); | |
promptForm.requestSubmit(); | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment