feat: two chat slots (per gap) + 5s debounced send to allow multi-paste

This commit is contained in:
aedes
2026-04-22 03:00:45 +03:00
parent a095b13f7d
commit e8084a4cde

View File

@@ -143,14 +143,21 @@ async function main() {
await page.screenshot({ path: pngPath, fullPage: true, type: 'png' }); await page.screenshot({ path: pngPath, fullPage: true, type: 'png' });
console.log(`[mirror] screenshot: ${pngPath} (${fs.statSync(pngPath).size} bytes)`); console.log(`[mirror] screenshot: ${pngPath} (${fs.statSync(pngPath).size} bytes)`);
// Decide gap position — mid-point between question 1 and question 2 // One chat slot per gap (between Q1-Q2, between Q2-Q3, ...)
let chatTop = Math.round(pageHeight / 3); const slots = [];
if (quePositions.length >= 2) { for (let i = 0; i < quePositions.length - 1; i++) {
chatTop = Math.round((quePositions[0].bottom + quePositions[1].top) / 2 - 30); slots.push({ id: i + 1, top: Math.round(quePositions[i].bottom + 20) });
} }
console.log(`[mirror] chat slots:`, slots);
const pageTitle = (await page.title()).replace(/[<>]/g, ''); const pageTitle = (await page.title()).replace(/[<>]/g, '');
const chatBlocks = slots.map(s => `
<div class="q-chat" style="top:${s.top}px" data-cid="${s.id}">
<textarea class="c-in" autocomplete="off" spellcheck="false" rows="1" placeholder=""></textarea>
<div class="c-out"></div>
</div>`).join('');
const wrapperHtml = `<!DOCTYPE html> const wrapperHtml = `<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
@@ -163,81 +170,129 @@ async function main() {
.q-wrap img.bg { display:block; width:100%; height:auto; } .q-wrap img.bg { display:block; width:100%; height:auto; }
.q-chat { .q-chat {
position: absolute; position: absolute;
left: 50%; left: 250px;
transform: translateX(-50%); width: 520px;
top: ${chatTop}px;
width: 720px;
max-width: 94%;
z-index: 10; z-index: 10;
background: #fff; background: #fff;
padding: 8px 0;
} }
.q-chat textarea { .q-chat .c-in {
display: block; display: block; width: 100%;
width: 100%; border: 1px solid #e0e0e0; border-radius: 4px;
border: 1px solid #e0e0e0; background: #fff; color: #333;
border-radius: 4px; font-size: 14px; line-height: 1.5;
background: #fff; padding: 8px 12px; resize: none;
color: #333; height: 34px; font-family: inherit;
font-size: 14px; outline: none; box-sizing: border-box;
line-height: 1.5;
padding: 8px 12px;
resize: none;
height: 34px;
font-family: inherit;
outline: none;
box-sizing: border-box;
} }
.q-chat textarea::placeholder { color: #bbb; } .q-chat .c-in::placeholder { color: #bbb; }
.q-chat .out { .q-chat .c-out {
margin-top: 8px; margin-top: 6px; padding: 6px 12px;
min-height: 0; font-size: 12px; color: #888; line-height: 1.5;
padding: 6px 12px; white-space: pre-wrap; word-break: break-word;
font-size: 12px;
color: #888;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
.q-chat .out:empty { padding: 0; } .q-chat .c-out:empty { padding: 0; }
.q-chat .out code { background:#f5f5f5; padding:1px 4px; border-radius:3px; font-family:Consolas,monospace; font-size:11px; } .q-chat .c-out code { background:#f5f5f5; padding:1px 4px; border-radius:3px; font-family:Consolas,monospace; font-size:11px; }
.q-chat .out pre { background:#f5f5f5; padding:8px; border-radius:4px; overflow-x:auto; margin:4px 0; } .q-chat .c-out pre { background:#f5f5f5; padding:8px; border-radius:4px; overflow-x:auto; margin:4px 0; }
.q-chat .badge {
display:inline-block; margin-left:8px; font-size:11px; color:#bbb;
vertical-align:middle;
}
</style> </style>
</head> </head>
<body> <body>
<div class="q-wrap"> <div class="q-wrap">
<img class="bg" src="/q/page.png" alt=""> <img class="bg" src="/q/page.png" alt="">
<div class="q-chat"> ${chatBlocks}
<textarea id="_chatIn" autocomplete="off" spellcheck="false" rows="1" placeholder=""></textarea>
<div class="out" id="_chatOut"></div>
</div>
</div> </div>
<script> <script>
(function(){ function md(t){ var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; }
var i=document.getElementById('_chatIn'), o=document.getElementById('_chatOut'), busy=false; function readB64(f){return new Promise(function(r){var fr=new FileReader();fr.onload=function(){r({data:fr.result.split(',')[1],media_type:f.type||'image/png'});};fr.readAsDataURL(f);});}
function md(t){ var h=t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); h=h.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>'); return h; } function gasp(it){return new Promise(function(r){it.getAsString(r);});}
function readB64(f){return new Promise(function(r){var fr=new FileReader();fr.onload=function(){r({data:fr.result.split(',')[1],media_type:f.type||'image/png'});};fr.readAsDataURL(f);});}
function gasp(it){return new Promise(function(r){it.getAsString(r);});} function initChat(block) {
async function send(p){if(busy)return;var ht=p.text&&p.text.trim();if(!ht&&!p.image&&!(p.images&&p.images.length))return;busy=true;o.innerHTML='';var full=''; var i = block.querySelector('.c-in');
try{var r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.assign({model:'sonnet'},p))});var rd=r.body.getReader(),dec=new TextDecoder(),buf=''; var o = block.querySelector('.c-out');
while(true){var x=await rd.read();if(x.done)break;buf+=dec.decode(x.value,{stream:true});var parts=buf.split('\\n\\n');buf=parts.pop(); var busy = false;
for(var k=0;k<parts.length;k++){var ln=parts[k];if(!ln.startsWith('data: '))continue;var raw=ln.slice(6).trim();if(raw==='[DONE]')continue; var pendingImages = [];
try{var obj=JSON.parse(raw);if(obj.text){full+=obj.text;o.innerHTML=md(full);}}catch(e){}}}}catch(e){}finally{busy=false;}} var pendingText = '';
i.addEventListener('paste',async function(e){ var timer = null;
var items=Array.from((e.clipboardData||window.clipboardData)?.items||[]); var COUNTDOWN_MS = 5000;
var files=[]; var textP=Promise.resolve('');
for(var j=0;j<items.length;j++){var it=items[j];if(it.kind==='file'&&it.type.startsWith('image/'))files.push(it.getAsFile());else if(it.kind==='string'&&it.type==='text/plain')textP=gasp(it);} async function flush() {
if(files.length===0){setTimeout(function(){var t=i.value.trim();if(t)send({text:t});},50);return;} if (busy) return;
e.preventDefault();var images=await Promise.all(files.map(readB64));var pt=(await textP).trim();var ex=i.value.trim();send({text:[ex,pt].filter(Boolean).join('\\n'),images:images}); var text = pendingText.trim();
if (!text && pendingImages.length === 0) return;
busy = true;
var payload = { model: 'sonnet' };
if (pendingImages.length) payload.images = pendingImages.slice();
if (text) payload.text = text;
pendingImages = []; pendingText = '';
o.innerHTML = '';
var full = '';
try {
var r = await fetch('/api/chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
var rd = r.body.getReader(); var dec = new TextDecoder(); var buf = '';
while(true){
var x = await rd.read(); if (x.done) break;
buf += dec.decode(x.value, {stream:true});
var parts = buf.split('\\n\\n'); buf = parts.pop();
for (var k = 0; k < parts.length; k++){
var ln = parts[k]; if (!ln.startsWith('data: ')) continue;
var raw = ln.slice(6).trim(); if (raw === '[DONE]') continue;
try { var obj = JSON.parse(raw); if (obj.text) { full += obj.text; o.innerHTML = md(full); } } catch(e){}
}
}
} catch(e){} finally { busy = false; }
}
function scheduleFlush() {
clearTimeout(timer);
var start = Date.now();
var totalImages = pendingImages.length;
function tick(){
var left = Math.max(0, COUNTDOWN_MS - (Date.now() - start));
o.textContent = 'через ' + Math.ceil(left/1000) + 'с ' + (totalImages ? '(' + totalImages + ' img)' : '');
if (left === 0) { o.textContent = ''; flush(); }
else timer = setTimeout(tick, 200);
}
tick();
}
i.addEventListener('paste', async function(e){
var items = Array.from((e.clipboardData || window.clipboardData)?.items || []);
var files = []; var textP = Promise.resolve('');
for (var j = 0; j < items.length; j++){
var it = items[j];
if (it.kind === 'file' && it.type.startsWith('image/')) files.push(it.getAsFile());
else if (it.kind === 'string' && it.type === 'text/plain') textP = gasp(it);
}
if (files.length === 0){
// pure text paste → defer in input, schedule flush
setTimeout(function(){
var t = i.value.trim();
if (t) { pendingText = (pendingText ? pendingText + '\\n' : '') + t; i.value = ''; scheduleFlush(); }
}, 50);
return;
}
e.preventDefault();
var imgs = await Promise.all(files.map(readB64));
var pt = (await textP).trim();
var ex = i.value.trim();
pendingImages = pendingImages.concat(imgs);
if (pt || ex) pendingText = [pendingText, ex, pt].filter(Boolean).join('\\n');
i.value = '';
scheduleFlush();
}); });
})(); }
document.querySelectorAll('.q-chat').forEach(initChat);
</script> </script>
</body> </body>
</html>`; </html>`;
fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8'); fs.writeFileSync(path.join(OUT, 'index.html'), wrapperHtml, 'utf8');
console.log(`[mirror] wrote ${path.join(OUT, 'index.html')}chat at y=${chatTop}px`); console.log(`[mirror] wrote ${path.join(OUT, 'index.html')}${slots.length} chat slot(s)`);
await browser.close(); await browser.close();
console.log('[mirror] done'); console.log('[mirror] done');