Skip to content

Instantly share code, notes, and snippets.

@celsowm
Last active April 27, 2025 21:50
Show Gist options
  • Save celsowm/7b8874f739cbe125a7d8ce7469a66f37 to your computer and use it in GitHub Desktop.
Save celsowm/7b8874f739cbe125a7d8ce7469a66f37 to your computer and use it in GitHub Desktop.
LLM EDITOR
<!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