feat: 채팅·작업실 라이브 카메라 — 메시지마다 현재 화면 자동 첨부

- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려
  기존 이미지 첨부 경로(![📷…](/api/files/…) → resolveImageContent)로 모델에 전달. 음성 통화 STT도
  같은 전송 경로라 그대로 동작
- 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부
- 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장
- handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-09-26 20:37:16 +09:00
co-authored by Claude Sonnet 5
parent cfcd2aa829
commit 57cf65fd7e
7 changed files with 201 additions and 8 deletions
+6 -1
View File
@@ -864,7 +864,12 @@ async function handleChat(
// Only resolve image markdown for user messages — assistant messages with // Only resolve image markdown for user messages — assistant messages with
// /api/files/ image references must stay as plain text so Ollama (which // /api/files/ image references must stay as plain text so Ollama (which
// rejects image_url content parts on assistant turns) doesn't error. // rejects image_url content parts on assistant turns) doesn't error.
const content = role === 'user' ? resolveImageContent(msg.content, workspacePath) : msg.content; // 라이브 카메라 프레임(alt가 "📷"로 시작)은 매 턴 붙어 오므로, 과거 턴의 것까지 전부 다시 모델에
// 실으면 세션이 길수록 비전 입력이 선형으로 쌓인다. 이전 프레임은 이미 답변에 반영됐으니 텍스트로 접는다.
const foldedContent = role === 'user' && typeof msg.content === 'string'
? msg.content.replace(/!\[📷[^\]]*\]\([^)]*\)/g, '[이전 카메라 프레임 생략]')
: msg.content;
const content = role === 'user' ? resolveImageContent(foldedContent, workspacePath) : msg.content;
messages.push({ role, content }); messages.push({ role, content });
} }
messages.push({ role: 'user', content: resolveImageContent(message, workspacePath) }); messages.push({ role: 'user', content: resolveImageContent(message, workspacePath) });
+8
View File
@@ -291,3 +291,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* 지연 로딩 모듈을 받는 동안 탭 패널을 흐리게 하고 클릭을 막는다 */ /* 지연 로딩 모듈을 받는 동안 탭 패널을 흐리게 하고 클릭을 막는다 */
.rb-pane.rb-loading{opacity:.45;pointer-events:none;transition:opacity .15s;} .rb-pane.rb-loading{opacity:.45;pointer-events:none;transition:opacity .15s;}
/* 라이브 카메라 패널 */
.rb-attach-btn.on{border-color:#ef4444;color:#ef4444;}
.rb-cam-panel{display:flex;align-items:center;gap:10px;padding:8px 10px;border-top:1px solid var(--line);flex-wrap:wrap;}
.rb-cam-panel video{height:96px;max-width:48%;border-radius:8px;background:#000;object-fit:cover;}
.rb-cam-ctl{display:flex;flex-direction:column;gap:6px;font-size:11px;color:var(--muted);}
.rb-cam-ctl label{display:flex;align-items:center;gap:6px;cursor:pointer;}
.rb-cam-btns{display:flex;gap:6px;flex-wrap:wrap;}
.rb-msg .rb-cam-shot{display:block;max-width:140px;border-radius:6px;margin-bottom:4px;}
+14
View File
@@ -424,6 +424,18 @@
<div class="queued-prompts-list" id="queued-prompts-list"></div> <div class="queued-prompts-list" id="queued-prompts-list"></div>
</div> </div>
<!-- 첨부 파일 카드 영역 (전송 전 표시) --> <!-- 첨부 파일 카드 영역 (전송 전 표시) -->
<!-- 라이브 카메라 패널: 켜 두면 메시지를 보낼 때마다 현재 프레임이 자동 첨부된다 (js/app/camera.js) -->
<div id="camera-panel" style="display:none;align-items:center;gap:10px;padding:8px 12px;border-top:1px solid var(--line);flex-wrap:wrap">
<video id="camera-video" autoplay muted playsinline style="height:110px;max-width:45vw;border-radius:8px;background:#000;object-fit:cover"></video>
<div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="camera-auto" checked /> 메시지마다 현재 화면 자동 첨부</label>
<div style="display:flex;gap:6px">
<button class="send-btn" onclick="cameraSnapAttach()" title="지금 화면을 입력창에 첨부" style="font-size:14px;width:auto;padding:0 10px;height:32px" aria-label="지금 촬영">📸 촬영</button>
<button class="send-btn" onclick="cameraFlip()" title="전면/후면 전환" style="font-size:14px;width:auto;padding:0 10px;height:32px" aria-label="카메라 전환">🔄 전환</button>
<button class="send-btn" onclick="cameraClose()" title="카메라 끄기" style="font-size:14px;width:32px;height:32px;background:#c0392b" aria-label="카메라 끄기">✕</button>
</div>
</div>
</div>
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div> <div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
<div id="voice-call-bar" style="display:none;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--line);font-size:13px;color:var(--text-dim,#888)"> <div id="voice-call-bar" style="display:none;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid var(--line);font-size:13px;color:var(--text-dim,#888)">
<span id="voice-call-status">통화 연결 중…</span> <span id="voice-call-status">통화 연결 중…</span>
@@ -437,6 +449,7 @@
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button> <button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
<button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button> <button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button>
<button class="send-btn" id="voice-call-btn" onclick="toggleVoiceCall()" title="실시간 음성 대화 (연속 대화 모드)" style="font-size:18px;width:44px;height:44px" aria-label="실시간 음성 대화">📞</button> <button class="send-btn" id="voice-call-btn" onclick="toggleVoiceCall()" title="실시간 음성 대화 (연속 대화 모드)" style="font-size:18px;width:44px;height:44px" aria-label="실시간 음성 대화">📞</button>
<button class="send-btn" id="camera-btn" onclick="toggleCamera()" title="카메라 켜기 (메시지마다 현재 화면 첨부)" style="font-size:18px;width:44px;height:44px" aria-label="카메라">📷</button>
<textarea <textarea
id="chat-input" id="chat-input"
placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)" placeholder="메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)"
@@ -2037,6 +2050,7 @@
<script src="../js/app/voice.js"></script> <script src="../js/app/voice.js"></script>
<script src="../js/app/media.js"></script> <script src="../js/app/media.js"></script>
<script src="../js/app/files.js"></script> <script src="../js/app/files.js"></script>
<script src="../js/app/camera.js"></script>
<script src="../js/app/chat-sse.js"></script> <script src="../js/app/chat-sse.js"></script>
<script src="../js/app/chat-sessions.js"></script> <script src="../js/app/chat-sessions.js"></script>
<script src="../js/app/chat-render.js"></script> <script src="../js/app/chat-render.js"></script>
+14
View File
@@ -157,7 +157,20 @@
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button> <button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
</div> </div>
<div class="rb-msgs" id="chat-msgs"></div> <div class="rb-msgs" id="chat-msgs"></div>
<!-- 라이브 카메라 패널: 켜 두면 메시지마다 현재 화면이 자동 첨부된다 (js/app/camera.js) -->
<div id="camera-panel" class="rb-cam-panel" style="display:none">
<video id="camera-video" autoplay muted playsinline></video>
<div class="rb-cam-ctl">
<label><input type="checkbox" id="camera-auto" checked> 메시지마다 현재 화면 자동 첨부</label>
<div class="rb-cam-btns">
<button class="rb-mini-btn" onclick="cameraSnapAttach()" title="지금 화면을 입력창에 첨부">📸 촬영</button>
<button class="rb-mini-btn" onclick="cameraFlip()" title="전면/후면 전환">🔄 전환</button>
<button class="rb-mini-btn" onclick="cameraClose()" title="카메라 끄기">✕</button>
</div>
</div>
</div>
<div class="rb-input-bar"> <div class="rb-input-bar">
<button class="rb-attach-btn" id="camera-btn" onclick="toggleCamera()" title="카메라 켜기 (메시지마다 현재 화면 첨부)">🎥</button>
<div class="rb-attach-wrap"> <div class="rb-attach-wrap">
<button class="rb-attach-btn" onclick="toggleAttachPop(event)" title="이 프로젝트의 사진을 채팅에 첨부(모델이 직접 봄)">📷</button> <button class="rb-attach-btn" onclick="toggleAttachPop(event)" title="이 프로젝트의 사진을 채팅에 첨부(모델이 직접 봄)">📷</button>
<div class="rb-attach-pop" id="attach-pop"></div> <div class="rb-attach-pop" id="attach-pop"></div>
@@ -180,6 +193,7 @@
<script src="../js/app/workshop-parts.js"></script> <script src="../js/app/workshop-parts.js"></script>
<script src="../js/app/workshop-tasks.js"></script> <script src="../js/app/workshop-tasks.js"></script>
<script src="../js/app/workshop-files.js"></script> <script src="../js/app/workshop-files.js"></script>
<script src="../js/app/camera.js"></script>
<script src="../js/app/workshop-chat.js"></script> <script src="../js/app/workshop-chat.js"></script>
<script src="../js/app/workshop-init.js"></script> <script src="../js/app/workshop-init.js"></script>
</body> </body>
+114
View File
@@ -0,0 +1,114 @@
// ── 채팅 라이브 카메라 ───────────────────────────────────────────────────
// 📷 버튼으로 카메라 패널(미리보기)을 켜 두면, 메시지를 보낼 때마다(타이핑·음성 통화 STT 공통)
// 현재 프레임을 찍어 이미지로 올리고 메시지에 자동 첨부한다. 서버는 기존 이미지 첨부와 같은
// 경로(/api/upload/image → ![alt](/api/files/..) → resolveImageContent)로 모델에 시각 입력을 넘긴다.
// 프레임 alt는 CAMERA_ALT("📷…")로 시작 — 서버가 이전 턴의 카메라 프레임을 텍스트로 접어 비용 누적을 막는다.
(function () {
var CAMERA_ALT = '📷 카메라';
var MAX_SIDE = 768; // 긴 변 기준 다운스케일 (모델 입력 비용·업로드 크기 절감)
var JPEG_QUALITY = 0.72;
var stream = null;
var facing = 'environment'; // 폰은 후면 우선, 후면이 없으면 브라우저가 알아서 대체
function $(id) { return document.getElementById(id); }
// 메인 채팅은 authHeaders(), 작업실 페이지는 authH() — 둘 다 지원
function authHdr() {
if (typeof window.authHeaders === 'function') return window.authHeaders();
if (typeof window.authH === 'function') return window.authH();
return {};
}
function insertText(md) {
if (typeof window.insertIntoChatInput === 'function') { window.insertIntoChatInput(md); return; }
var el = $('chat-input');
if (!el) return;
el.value = el.value ? el.value + '\n' + md : md;
el.dispatchEvent(new Event('input'));
el.focus();
}
function setOpenUI(open) {
var panel = $('camera-panel'), btn = $('camera-btn');
if (panel) panel.style.display = open ? 'flex' : 'none';
if (btn) { btn.classList.toggle('recording', open); btn.classList.toggle('on', open); btn.title = open ? '카메라 끄기' : '카메라 켜기 (메시지마다 현재 화면 첨부)'; }
}
async function openCamera() {
var video = $('camera-video');
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facing }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
});
} catch (e) {
alert('카메라 권한이 필요합니다: ' + e.message);
stream = null;
return;
}
video.srcObject = stream;
try { await video.play(); } catch (e) { /* autoplay 정책 — muted+playsinline이면 보통 통과 */ }
setOpenUI(true);
}
function closeCamera() {
if (stream) { try { stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {} }
stream = null;
var video = $('camera-video');
if (video) video.srcObject = null;
setOpenUI(false);
}
window.toggleCamera = function () { if (stream) closeCamera(); else openCamera(); };
window.cameraFlip = async function () {
facing = facing === 'environment' ? 'user' : 'environment';
if (stream) { closeCamera(); await openCamera(); }
};
window.cameraClose = closeCamera;
window.cameraIsOn = function () { return !!stream; };
function captureBlob() {
var video = $('camera-video');
if (!stream || !video || !video.videoWidth) return Promise.resolve(null);
var w = video.videoWidth, h = video.videoHeight;
var scale = Math.min(1, MAX_SIDE / Math.max(w, h));
var cw = Math.round(w * scale), ch = Math.round(h * scale);
var canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
canvas.getContext('2d').drawImage(video, 0, 0, cw, ch);
return new Promise(function (resolve) { canvas.toBlob(resolve, 'image/jpeg', JPEG_QUALITY); });
}
window.cameraCaptureBlob = function () { return captureBlob(); };
// 현재 프레임을 서버에 올리고 채팅용 마크다운을 돌려준다. 실패하면 '' (메시지는 그대로 전송된다).
async function uploadFrame() {
var blob = await captureBlob();
if (!blob) return '';
var form = new FormData();
form.append('image', blob, 'camera-' + Date.now() + '.jpg');
var res = await fetch('/api/upload/image', { method: 'POST', headers: authHdr(), body: form });
var data = await res.json();
if (data && data.success && data.path) return '![' + CAMERA_ALT + '](/api/files/' + data.path + ')';
return '';
}
// 📸 버튼: 지금 화면을 입력창에 이미지로 넣는다 (자동 첨부와 별개로 수동 첨부)
window.cameraSnapAttach = async function () {
try {
var md = await uploadFrame();
if (md) insertText(md);
else alert('카메라 화면을 아직 읽을 수 없습니다. 잠시 후 다시 시도하세요.');
} catch (e) { alert('촬영 오류: ' + e.message); }
};
// sendChat이 전송 직전에 호출 — 라이브 자동 첨부가 켜져 있으면 프레임 마크다운, 아니면 ''
window.cameraFrameForSend = async function () {
var chk = $('camera-auto');
if (!stream || (chk && !chk.checked)) return '';
try { return await uploadFrame(); } catch (e) { console.warn('[camera] frame upload failed:', e.message); return ''; }
};
// 탭을 떠나면 카메라 스트림 정리 (모바일에서 카메라 표시등이 계속 켜진 채 남는 것 방지)
window.addEventListener('pagehide', closeCamera);
})();
+10 -4
View File
@@ -25,16 +25,22 @@ async function sendChat(queuedMessage = null) {
return; return;
} }
// 라이브 카메라(camera.js): 켜져 있으면 현재 프레임을 찍어 이 메시지에 자동 첨부. 큐 재전송은 제외.
let cameraMd = '';
if (typeof queuedMessage !== 'string' && typeof window.cameraFrameForSend === 'function') {
try { cameraMd = await window.cameraFrameForSend(); } catch (e) { cameraMd = ''; }
}
// 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만 // 첨부파일: AI에겐 전체 내용, 채팅 버블엔 파일명만
let fullMessage = message; let fullMessage = cameraMd ? cameraMd + (message ? '\n\n' + message : '') : message;
let displayMessage = message; let displayMessage = fullMessage;
if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') { if (stagedAttachments.length > 0 && typeof queuedMessage !== 'string') {
const attachParts = stagedAttachments const attachParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함 .filter(function(a) { return a.type !== 'txt-auto'; }) // txt-auto는 PDF가 이미 내용 포함
.map(function(a) { .map(function(a) {
return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')'); return a.content || ('[첨부: ' + a.name + '](' + (a.url || '') + ')');
}); });
fullMessage = attachParts.join('\n\n') + (message ? '\n\n' + message : ''); fullMessage = attachParts.join('\n\n') + (fullMessage ? '\n\n' + fullMessage : '');
const compactParts = stagedAttachments const compactParts = stagedAttachments
.filter(function(a) { return a.type !== 'txt-auto'; }) .filter(function(a) { return a.type !== 'txt-auto'; })
.map(function(a) { .map(function(a) {
@@ -46,7 +52,7 @@ async function sendChat(queuedMessage = null) {
} }
return line; return line;
}); });
displayMessage = compactParts.join('\n') + (message ? '\n\n' + message : ''); displayMessage = compactParts.join('\n') + (displayMessage ? '\n\n' + displayMessage : '');
clearStagedAttachments(); clearStagedAttachments();
} }
chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage }); chatHistory.push({ role: 'user', content: fullMessage, display: displayMessage });
+35 -3
View File
@@ -23,12 +23,19 @@ async function loadHistory(){
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant'); const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
if(!msgs.length||seq!==_chatLoadSeq)return; if(!msgs.length||seq!==_chatLoadSeq)return;
for(const m of msgs.slice(-30)){ for(const m of msgs.slice(-30)){
if(m.role==='user')addMsg('user',escHtml(m.content||'')); if(m.role==='user')addMsg('user',userMsgHtml(m.content));
else addMsg('assistant',renderMd(m.content||'')); else addMsg('assistant',renderMd(m.content||''));
} }
}catch{} }catch{}
} }
// 사용자 메시지 HTML — 카메라 프레임 마크다운(![📷 …](/api/files/…))은 썸네일로, 나머지는 이스케이프 텍스트로.
// escHtml은 ! [ ] ( ) / 를 건드리지 않으므로 이스케이프한 뒤에 치환해도 안전하다.
function userMsgHtml(content){
return escHtml(content||'').replace(/!\[📷[^\]]*\]\((\/api\/files\/[^)]+)\)\s*/g,
(m,url)=>'<img class="rb-cam-shot" src="'+url+'" alt="카메라 화면">');
}
function addMsg(role,html){ function addMsg(role,html){
const msgs=document.getElementById('chat-msgs'); const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='rb-msg '+role; const div=document.createElement('div');div.className='rb-msg '+role;
@@ -133,8 +140,12 @@ async function sendMessage(){
const msg=input.value.trim(); const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return; if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input); input.value='';autoResize(input);
addMsg('user',escHtml(msg)); // 라이브 카메라(camera.js)가 켜져 있으면 지금 화면을 찍어 이 메시지에 붙인다 — 실패해도 메시지는 그대로 전송.
await callChat(msg); let cam='';
if(typeof window.cameraFrameForSend==='function'){try{cam=await window.cameraFrameForSend();}catch{cam='';}}
const full=cam?cam+'\n\n'+msg:msg;
addMsg('user',userMsgHtml(full));
await callChat(full);
} }
function setProjectControlsBusy(busy){ function setProjectControlsBusy(busy){
@@ -214,7 +225,11 @@ async function toggleAttachPop(ev){
const d=await r.json(); const d=await r.json();
photos=(d.files||[]).filter(f=>f.category==='photo').slice(-30).reverse(); photos=(d.files||[]).filter(f=>f.category==='photo').slice(-30).reverse();
}catch{} }catch{}
const camOn=typeof window.cameraIsOn==='function'&&window.cameraIsOn();
pop.innerHTML='<label class="rb-mini-btn" style="display:block;text-align:center;cursor:pointer">+ 새 사진 올려서 첨부<input type="file" accept="image/*" style="display:none" onchange="attachUpload(event)"></label>' pop.innerHTML='<label class="rb-mini-btn" style="display:block;text-align:center;cursor:pointer">+ 새 사진 올려서 첨부<input type="file" accept="image/*" style="display:none" onchange="attachUpload(event)"></label>'
+(camOn
?'<button class="rb-mini-btn" style="display:block;width:100%;margin-top:6px" onclick="saveCameraShot()">🎥 지금 카메라 화면을 사진으로 저장·첨부</button>'
:'<div class="rb-empty" style="margin-top:6px">🎥로 카메라를 켜면 여기서 바로 찍어 저장할 수 있습니다.</div>')
+(photos.length +(photos.length
?'<div class="rb-attach-grid">'+photos.map(f=>'<img src="'+escAttr(f.url)+'" title="'+escAttr(f.relPath)+'" loading="lazy" onclick="attachPhoto(\''+escJs(f.relPath)+'\')">').join('')+'</div>' ?'<div class="rb-attach-grid">'+photos.map(f=>'<img src="'+escAttr(f.url)+'" title="'+escAttr(f.relPath)+'" loading="lazy" onclick="attachPhoto(\''+escJs(f.relPath)+'\')">').join('')+'</div>'
:'<div class="rb-empty">이 프로젝트에 올린 사진이 없습니다.</div>'); :'<div class="rb-empty">이 프로젝트에 올린 사진이 없습니다.</div>');
@@ -225,6 +240,23 @@ function attachPhoto(relPath){
autoResize(inp);inp.focus(); autoResize(inp);inp.focus();
document.getElementById('attach-pop').classList.remove('open'); document.getElementById('attach-pop').classList.remove('open');
} }
// 카메라 현재 화면을 이 프로젝트의 사진 폴더에 저장하고 채팅에 첨부한다 (수동 촬영 = 기록용).
async function saveCameraShot(){
const proj=currentProject();
const pop=document.getElementById('attach-pop');
if(!proj)return;
const blob=await window.cameraCaptureBlob();
if(!blob){pop.innerHTML='<div class="rb-empty">카메라 화면을 아직 읽을 수 없습니다.</div>';return;}
pop.innerHTML='<div class="rb-empty">저장 중…</div>';
try{
const fd=new FormData();fd.append('file',blob,'camera-'+Date.now()+'.jpg');
const r=await fetch('/api/workshop/upload?projectId='+encodeURIComponent(proj.id)+'&folder='+encodeURIComponent('사진'),{method:'POST',headers:authH(),body:fd});
const d=await r.json().catch(()=>({}));
if(!r.ok||!d.relPath)throw new Error(d.error||'저장 실패');
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
attachPhoto(d.relPath);
}catch(e){pop.innerHTML='<div class="rb-empty">저장 실패: '+escHtml(e.message)+'</div>';}
}
async function attachUpload(ev){ async function attachUpload(ev){
const file=ev.target.files&&ev.target.files[0]; const file=ev.target.files&&ev.target.files[0];
ev.target.value=''; ev.target.value='';