feat: 채팅·작업실 라이브 카메라 — 메시지마다 현재 화면 자동 첨부
- camera.js: getUserMedia 미리보기 패널, 전송 시 프레임(768px JPEG)을 /api/upload/image로 올려 기존 이미지 첨부 경로( → resolveImageContent)로 모델에 전달. 음성 통화 STT도 같은 전송 경로라 그대로 동작 - 메인 채팅: 📷 버튼+패널, sendChat에서 프레임 자동 첨부 - 작업실: 🎥 버튼+패널, 전송 훅·썸네일 표시·팝업에서 카메라 화면을 프로젝트 사진으로 저장 - handle-chat: 과거 턴의 카메라 프레임은 텍스트로 접어 비전 입력이 턴마다 누적되지 않게 함 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) });
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// ── 채팅 라이브 카메라 ───────────────────────────────────────────────────
|
||||||
|
// 📷 버튼으로 카메라 패널(미리보기)을 켜 두면, 메시지를 보낼 때마다(타이핑·음성 통화 STT 공통)
|
||||||
|
// 현재 프레임을 찍어 이미지로 올리고 메시지에 자동 첨부한다. 서버는 기존 이미지 첨부와 같은
|
||||||
|
// 경로(/api/upload/image →  → 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 '';
|
||||||
|
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);
|
||||||
|
})();
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 — 카메라 프레임 마크다운()은 썸네일로, 나머지는 이스케이프 텍스트로.
|
||||||
|
// 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='';
|
||||||
|
|||||||
Reference in New Issue
Block a user