v4.0.0: 라이브 서비스 /srv/homeclaw로 이전 + Android 실기기 USB 연동 + 관리자 IP 차단 뷰어
- homeclaw 라이브 서비스를 /home/kim/homeclaw에서 /srv/homeclaw(480G 전용 파티션)로 컷오버, 하드코딩된 절대경로 전부 치환 (config, mcp-servers, routes, scripts, web-ui) - Android 에뮬레이터 컨테이너 리소스를 32스레드 Xeon 기준으로 상향(3g/3cpu → 6g/10cpu) - 코드 에디터에 USB 실기기 패널 추가: 기기 감지, APK 설치, Logcat, scrcpy 화면 미러링 (companion.py에 독립 프로세스로 scrcpy 스폰하는 로직 포함) - 관리자 컨텍스트 뷰어에 차단된 IP 목록 탭 추가 - ollama primary 모델을 gemma4:31b-cloud로, 음성 TTS를 edge_tts로 전환 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+34
-1
@@ -2576,7 +2576,7 @@ function setSettingsTab(tab) {
|
||||
if (t === 'users') loadUsersTab();
|
||||
if (t === 'voice') loadVoiceSettings();
|
||||
if (t === 'session') loadSessionSettings();
|
||||
if (t === 'context-viewer') loadContextViewerTab();
|
||||
if (t === 'context-viewer') { loadContextViewerTab(); loadBannedIpsTab(); }
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
@@ -3488,6 +3488,39 @@ async function loadContextViewerTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBannedIpsTab() {
|
||||
const listEl = document.getElementById('cv-banned-ips-list');
|
||||
const statEl = document.getElementById('cv-banned-ips-stat');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">로딩 중...</div>';
|
||||
if (statEl) statEl.textContent = '';
|
||||
|
||||
try {
|
||||
const data = await api('/api/admin/banned-ips');
|
||||
if (!data || !data.banned) {
|
||||
listEl.innerHTML = '<div style="font-size:12px;color:var(--err)">권한이 없거나 데이터를 불러올 수 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
const banned = data.banned;
|
||||
if (statEl) statEl.textContent = `현재 차단 ${banned.length}건 · 누적 밴 ${data.totalBans.toLocaleString()}건`;
|
||||
if (banned.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:var(--muted);font-size:13px;text-align:center;padding:20px">현재 차단된 IP가 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = banned.map(b => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:8px;padding:6px 10px;background:var(--panel)">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--err)"></span>
|
||||
<span style="font-family:'IBM Plex Mono',monospace;font-size:12px">${escHtml(b.ip)}</span>
|
||||
<span style="font-size:10px;color:var(--muted);border:1px solid var(--line);border-radius:5px;padding:1px 5px">${escHtml(b.jail)}</span>
|
||||
</div>
|
||||
<span style="font-size:11px;color:var(--muted)">${escHtml(b.bannedAt)}</span>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div style="font-size:12px;color:var(--err)">오류: ${e.message || '알 수 없는 오류'}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function cvDeleteSession(username, sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sessions/${encodeURIComponent(username)}/${encodeURIComponent(sessionId)}`, {
|
||||
|
||||
@@ -333,6 +333,9 @@
|
||||
<button id="code-upload-btn" class="csb-term-btn" onclick="csbUploadToBoard()" title="보드에 업로드 (USB 연결 필요)" style="background:var(--brand);color:#fff;border-color:var(--brand)">⚡ 업로드</button>
|
||||
<button id="code-android-btn" class="csb-term-btn" onclick="openAndroidEmulator()" title="Android 에뮬레이터 열기">🤖 Android</button>
|
||||
</div>
|
||||
<div class="csb-term-row">
|
||||
<button id="code-usb-android-btn" class="csb-term-btn" onclick="toggleAndroidUsbPanel()" title="USB로 연결된 실제 Android 기기 (기기 감지 / APK 설치 / Logcat / 화면 미러링)">📱 실기기(USB)</button>
|
||||
</div>
|
||||
<!-- Claude App (로컬 LLM 코딩 에이전트) -->
|
||||
<div class="csb-term-row">
|
||||
<button id="code-claude-btn" class="csb-term-btn" onclick="toggleClaudeAppPanel()" title="Claude App(로컬 LLM 코딩 에이전트) 패널 토글">🤖 클로드 앱</button>
|
||||
@@ -635,6 +638,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 📱 USB 실기기 panel -->
|
||||
<div id="android-usb-panel" style="display:none;flex-direction:column;flex-shrink:0;height:340px;min-height:120px;border-top:2px solid #1e5a8a;background:#0e1a24;position:relative">
|
||||
<div id="android-usb-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
|
||||
<div style="display:flex;align-items:center;height:32px;background:#122636;border-bottom:1px solid #1e5a8a;flex-shrink:0;padding:0 8px;gap:6px;overflow-x:auto">
|
||||
<span id="android-usb-status" style="padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;background:#1a3448;color:#5aa8e8;flex-shrink:0;white-space:nowrap">대기 중</span>
|
||||
<select id="android-usb-device-sel" style="display:none;background:#1a3448;border:1px solid #2a5a8a;color:#7ac0f0;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace;flex-shrink:0;max-width:180px"></select>
|
||||
<input type="text" id="android-usb-pkg-filter" placeholder="패키지 필터 (선택, 예: com.example.app)"
|
||||
style="flex:1;min-width:100px;background:#1a3448;border:1px solid #2a5a8a;color:#bcdcf8;border-radius:4px;padding:3px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace;outline:none">
|
||||
<button onclick="androidUsbRefreshDevices()" title="새로고침" style="background:#1a3448;border:1px solid #2a5a8a;color:#5aa8e8;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0">🔄</button>
|
||||
<button id="android-usb-install-btn" onclick="androidUsbInstallApk()" disabled style="background:#1a3448;border:1px solid #2a5a8a;color:#5aa8e8;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0;white-space:nowrap">📦 APK 설치</button>
|
||||
<button id="android-usb-logcat-btn" onclick="androidUsbOpenLogcat()" disabled style="background:#1a3448;border:1px solid #2a5a8a;color:#5aa8e8;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0;white-space:nowrap">📜 Logcat</button>
|
||||
<button id="android-usb-mirror-btn" onclick="androidUsbStartMirror()" disabled style="background:color-mix(in srgb,#5aa8e8 20%,#122636);border:1px solid #5aa8e8;color:#5aa8e8;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-weight:700;flex-shrink:0;white-space:nowrap">🪞 화면 미러링</button>
|
||||
<button onclick="toggleAndroidUsbPanel()" style="background:none;border:none;color:#3d6f9f;cursor:pointer;font-size:16px;padding:0 6px;line-height:1;flex-shrink:0">×</button>
|
||||
</div>
|
||||
<div id="android-usb-hint" style="display:none;padding:8px 14px;font-size:12px;color:#7ac0f0;background:#0f2233;border-bottom:1px solid #1e5a8a;line-height:1.6"></div>
|
||||
<div id="android-usb-log" style="flex:1;overflow-y:auto;padding:10px 14px;font-size:13px;line-height:1.8;white-space:pre-wrap;word-break:break-all;font-family:'IBM Plex Mono',monospace;color:#a8ccec"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+204
-3
@@ -642,7 +642,192 @@ function updateCompanionStatus(connected) {
|
||||
if (lsBtn) lsBtn.style.color = connected ? '#22c55e' : '#6b7280';
|
||||
}
|
||||
|
||||
// reqId로 상관관계를 맺는 companion.py 요청/응답 헬퍼 (claude-app.js의 동일 함수 포팅)
|
||||
function companionRequest(type, payload, responseType, timeoutMs = 15000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!companionWs || companionWs.readyState !== WebSocket.OPEN) { reject(new Error('companion.py가 연결되지 않았습니다')); return; }
|
||||
const reqId = type + '_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
|
||||
const timeout = setTimeout(() => { companionWs.removeEventListener('message', handler); reject(new Error('시간 초과')); }, timeoutMs);
|
||||
const handler = (evt) => {
|
||||
let d; try { d = JSON.parse(evt.data); } catch { return; }
|
||||
if (d.reqId === reqId && d.type === responseType) {
|
||||
clearTimeout(timeout);
|
||||
companionWs.removeEventListener('message', handler);
|
||||
resolve(d);
|
||||
}
|
||||
};
|
||||
companionWs.addEventListener('message', handler);
|
||||
companionWs.send(JSON.stringify({ type, reqId, ...payload }));
|
||||
});
|
||||
}
|
||||
|
||||
// ── 📱 USB 실기기 (companion.py를 통한 로컬 PC의 adb/scrcpy) ──────────────────
|
||||
let _androidUsbOpen = false;
|
||||
let _androidUsbDevices = []; // [{serial, state, model}]
|
||||
|
||||
function _androidUsbLog(text) {
|
||||
const log = document.getElementById('android-usb-log');
|
||||
if (!log) return;
|
||||
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 30;
|
||||
log.textContent += (log.textContent ? '\n' : '') + text;
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function _androidUsbSetStatus(text, colorBg, colorFg) {
|
||||
const el = document.getElementById('android-usb-status');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
if (colorBg) el.style.background = colorBg;
|
||||
if (colorFg) el.style.color = colorFg;
|
||||
}
|
||||
|
||||
function _androidUsbSetHint(text) {
|
||||
const el = document.getElementById('android-usb-hint');
|
||||
if (!el) return;
|
||||
if (!text) { el.style.display = 'none'; el.textContent = ''; return; }
|
||||
el.style.display = '';
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function _androidUsbSetButtonsEnabled(enabled) {
|
||||
['android-usb-install-btn', 'android-usb-logcat-btn', 'android-usb-mirror-btn'].forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function _androidUsbSelectedSerial() {
|
||||
const sel = document.getElementById('android-usb-device-sel');
|
||||
return sel && sel.value ? sel.value : (_androidUsbDevices[0]?.serial || null);
|
||||
}
|
||||
|
||||
// companion.py가 이번 기능을 지원하지 않는 구버전일 때(browse_file/scrcpy_start 타임아웃)
|
||||
// exec 계열의 일반 타임아웃과 구분되는 안내 문구를 보여준다.
|
||||
function _androidUsbOutdatedCompanionMessage() {
|
||||
return 'companion.py가 이 기능을 지원하지 않는 것 같습니다. 최신 버전으로 다시 다운로드해주세요 '
|
||||
+ '(⬇ 다운로드 버튼, 또는 자동시작 사용자는 install_autostart.py 재실행).';
|
||||
}
|
||||
|
||||
function toggleAndroidUsbPanel() {
|
||||
const panel = document.getElementById('android-usb-panel');
|
||||
const btn = document.getElementById('code-usb-android-btn');
|
||||
if (!panel) return;
|
||||
_androidUsbOpen = !_androidUsbOpen;
|
||||
panel.style.display = _androidUsbOpen ? 'flex' : 'none';
|
||||
if (btn) btn.classList.toggle('active', _androidUsbOpen);
|
||||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||||
if (_androidUsbOpen) androidUsbRefreshDevices();
|
||||
}
|
||||
|
||||
async function androidUsbRefreshDevices() {
|
||||
if (!companionConnected) {
|
||||
_androidUsbSetStatus('companion 미연결', '#3a2a1a', '#e8a05a');
|
||||
_androidUsbSetHint('companion.py가 실행 중이 아닙니다. 상단의 🐍 표시를 클릭해서 연결하세요.');
|
||||
_androidUsbSetButtonsEnabled(false);
|
||||
return;
|
||||
}
|
||||
_androidUsbSetStatus('확인 중...', '#1a3448', '#5aa8e8');
|
||||
try {
|
||||
const res = await companionRequest('exec', { command: 'adb devices -l' }, 'exec_result', 31000);
|
||||
const out = res.output || '';
|
||||
_androidUsbLog('$ adb devices -l\n' + out);
|
||||
if (res.error) {
|
||||
if (/not recognized|command not found|no such file/i.test(out)) {
|
||||
_androidUsbSetStatus('adb 없음', '#3a1a1a', '#e85a5a');
|
||||
_androidUsbSetHint('adb를 찾을 수 없습니다. Android SDK Platform-Tools를 설치하고 PATH에 추가해주세요.');
|
||||
} else {
|
||||
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
|
||||
_androidUsbSetHint(out);
|
||||
}
|
||||
_androidUsbDevices = [];
|
||||
_androidUsbSetButtonsEnabled(false);
|
||||
return;
|
||||
}
|
||||
const lines = out.split('\n').map(l => l.trim());
|
||||
const startIdx = lines.findIndex(l => l.startsWith('List of devices attached'));
|
||||
const deviceLines = (startIdx >= 0 ? lines.slice(startIdx + 1) : lines).filter(l => l);
|
||||
_androidUsbDevices = deviceLines.map(l => {
|
||||
const parts = l.split(/\s+/);
|
||||
const serial = parts[0];
|
||||
const state = parts[1] || 'unknown';
|
||||
const modelTok = parts.find(p => p.startsWith('model:'));
|
||||
const model = modelTok ? modelTok.slice(6) : '';
|
||||
return { serial, state, model };
|
||||
}).filter(d => d.serial);
|
||||
|
||||
const sel = document.getElementById('android-usb-device-sel');
|
||||
if (sel) {
|
||||
sel.innerHTML = _androidUsbDevices.map(d => `<option value="${d.serial}">${d.model || d.serial}${d.state !== 'device' ? ' (' + d.state + ')' : ''}</option>`).join('');
|
||||
sel.style.display = _androidUsbDevices.length > 1 ? '' : 'none';
|
||||
}
|
||||
|
||||
if (_androidUsbDevices.length === 0) {
|
||||
_androidUsbSetStatus('기기 없음', '#1a3448', '#5aa8e8');
|
||||
_androidUsbSetHint('USB로 연결하고 휴대폰의 설정 > 개발자 옵션에서 \'USB 디버깅\'을 켜주세요.');
|
||||
_androidUsbSetButtonsEnabled(false);
|
||||
} else if (_androidUsbDevices.every(d => d.state !== 'device')) {
|
||||
_androidUsbSetStatus('⚠️ 인증 대기', '#3a2a1a', '#e8a05a');
|
||||
_androidUsbSetHint('휴대폰 화면에 뜬 \'USB 디버깅을 허용하시겠습니까?\' 팝업에서 허용을 눌러주세요.');
|
||||
_androidUsbSetButtonsEnabled(false);
|
||||
} else {
|
||||
_androidUsbSetStatus(`${_androidUsbDevices.length}대 연결됨`, '#1a3a2a', '#4ade80');
|
||||
_androidUsbSetHint('');
|
||||
_androidUsbSetButtonsEnabled(true);
|
||||
}
|
||||
} catch (e) {
|
||||
_androidUsbSetStatus('오류', '#3a1a1a', '#e85a5a');
|
||||
_androidUsbSetHint(e.message === '시간 초과' ? '시간 초과 (adb devices가 응답하지 않습니다)' : e.message);
|
||||
_androidUsbSetButtonsEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function androidUsbInstallApk() {
|
||||
const serial = _androidUsbSelectedSerial();
|
||||
try {
|
||||
const picked = await companionRequest('browse_file', {
|
||||
title: '설치할 APK 선택',
|
||||
filetypes: [['APK 파일', '*.apk'], ['모든 파일', '*.*']],
|
||||
}, 'browse_file_result', 120000);
|
||||
if (picked.error) { _androidUsbLog('⚠️ ' + picked.error); return; }
|
||||
if (!picked.path) { _androidUsbLog('(설치 취소됨)'); return; }
|
||||
const cmd = `adb ${serial ? '-s ' + serial + ' ' : ''}install -r "${picked.path}"`;
|
||||
_androidUsbLog('$ ' + cmd);
|
||||
const res = await companionRequest('exec', { command: cmd }, 'exec_result', 31000);
|
||||
_androidUsbLog(res.output || '');
|
||||
if (/INSTALL_FAILED_/i.test(res.output || '')) _androidUsbLog('⚠️ 설치 실패 — 서명/버전 불일치일 수 있습니다.');
|
||||
} catch (e) {
|
||||
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function androidUsbOpenLogcat() {
|
||||
if (!companionConnected) { _androidUsbLog('⚠️ companion.py 미연결'); return; }
|
||||
const serial = _androidUsbSelectedSerial();
|
||||
const pkg = (document.getElementById('android-usb-pkg-filter')?.value || '').trim();
|
||||
const id = addLocalShell('📱 Logcat');
|
||||
const sFlag = serial ? `-s ${serial} ` : '';
|
||||
let cmd = `adb ${sFlag}logcat -c && adb ${sFlag}logcat -v time`;
|
||||
if (pkg) cmd += companionPlatform === 'windows' ? ` | findstr /I "${pkg}"` : ` | grep -i "${pkg}"`;
|
||||
setTimeout(() => {
|
||||
if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: cmd + '\r' }));
|
||||
}, 1300);
|
||||
}
|
||||
|
||||
async function androidUsbStartMirror() {
|
||||
const serial = _androidUsbSelectedSerial();
|
||||
_androidUsbLog('🪞 화면 미러링 시작 중...');
|
||||
try {
|
||||
const res = await companionRequest('scrcpy_start', { serial }, 'scrcpy_result', 8000);
|
||||
if (res.ok) _androidUsbLog('🪞 scrcpy 실행됨 (기기 화면 창이 곧 나타납니다)');
|
||||
else _androidUsbLog('⚠️ ' + res.error);
|
||||
} catch (e) {
|
||||
_androidUsbLog('⚠️ ' + (e.message === '시간 초과' ? _androidUsbOutdatedCompanionMessage() : e.message));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompanionMessage(msg) {
|
||||
// 순수 요청/응답 타입(companionRequest로 처리됨) — 터미널 패널을 강제로 열지 않는다
|
||||
if (msg.type === 'browse_file_result' || msg.type === 'scrcpy_result') return;
|
||||
// shell_* messages → route by msg.shellId; run messages → active code terminal
|
||||
const isShellMsg = msg.type === 'shell_output' || msg.type === 'shell_ready' || msg.type === 'shell_exit';
|
||||
const targetId = isShellMsg && msg.shellId ? msg.shellId : activeCodeTermShellId;
|
||||
@@ -1209,7 +1394,7 @@ function renderCodeTermTabs() {
|
||||
const bar = document.getElementById('code-term-tabs'); if (!bar) return;
|
||||
bar.innerHTML = codeTermShells.map(s => {
|
||||
const a = s.id === activeCodeTermShellId;
|
||||
const label = s.local ? '🖥️ 로컬' : 'bash';
|
||||
const label = s.local ? (s.name || '🖥️ 로컬') : 'bash';
|
||||
return `<div style="display:flex;align-items:center;gap:5px;padding:0 10px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:12px;color:${a?'#c8e6c0':'#3d6b4f'};background:${a?'#0c0c0c':'transparent'};border-top:${a?'2px solid #22c55e':'2px solid transparent'};white-space:nowrap;user-select:none" onclick="switchCodeTermShell('${s.id}')">
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style="opacity:.7"><path d="M14 3v10H2V3h12zM1 2v12h14V2H1zm5 9l3-2.5L6 6v5z"/></svg>
|
||||
${label}${codeTermShells.length>1?` <span onclick="closeCodeTermShell('${s.id}',event)" style="color:#3d6b4f;margin-left:3px">×</span>`:''}
|
||||
@@ -1217,13 +1402,13 @@ function renderCodeTermTabs() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addLocalShell() {
|
||||
function addLocalShell(label) {
|
||||
if (!companionConnected) {
|
||||
alert('companion.py가 실행되지 않았습니다.\n\n터미널에서: python companion.py');
|
||||
return;
|
||||
}
|
||||
const id = 'cdt_' + Math.random().toString(36).slice(2, 8);
|
||||
codeTermShells.push({ id, name: '로컬', local: true, ptyReady: false, term: null, fitAddon: null });
|
||||
codeTermShells.push({ id, name: label || '🖥️ 로컬', local: true, ptyReady: false, term: null, fitAddon: null });
|
||||
localShellIds.add(id);
|
||||
if (!codeTermOpen) toggleCodeTerminal();
|
||||
switchCodeTermShell(id);
|
||||
@@ -1237,6 +1422,7 @@ function addLocalShell() {
|
||||
setTimeout(() => { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); }, 800);
|
||||
}
|
||||
}, 300);
|
||||
return id;
|
||||
}
|
||||
function addCodeTermShell() {
|
||||
const id = 'cdt_' + Math.random().toString(36).slice(2,8);
|
||||
@@ -6600,6 +6786,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// USB 실기기 panel resize drag
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const drag = document.getElementById('android-usb-drag');
|
||||
const panel = document.getElementById('android-usb-panel');
|
||||
if (!drag || !panel) return;
|
||||
let startY=0, startH=0;
|
||||
drag.addEventListener('mousedown', e => {
|
||||
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
|
||||
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(80,Math.min(window.innerHeight*.6,startH+dy))+'px'; };
|
||||
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
|
||||
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(90,168,232,.4)');
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
});
|
||||
|
||||
// Here we run them once the DOM is ready since this page IS the code view.
|
||||
(function bootCodePage() {
|
||||
async function boot() {
|
||||
|
||||
@@ -393,6 +393,54 @@ def _pick_folder():
|
||||
return path or None
|
||||
|
||||
|
||||
def _pick_file(title: str, filetypes):
|
||||
"""네이티브 OS 파일 선택 창을 열고 절대경로를 반환한다 (동기, executor에서 실행)."""
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes('-topmost', True)
|
||||
try:
|
||||
path = filedialog.askopenfilename(
|
||||
title=title or '파일 선택',
|
||||
filetypes=filetypes or [('모든 파일', '*.*')],
|
||||
)
|
||||
finally:
|
||||
root.destroy()
|
||||
return path or None
|
||||
|
||||
|
||||
def _spawn_scrcpy(serial):
|
||||
"""scrcpy를 companion.py와 완전히 독립된(disconnect/재시작에도 안 죽는) 프로세스로 띄운다.
|
||||
|
||||
exec/shell 세션의 모든 자식 프로세스는 WS 연결 종료 시 handle()의
|
||||
finally 블록에서 kill되므로, scrcpy처럼 브라우저 탭과 무관하게 계속
|
||||
떠 있어야 하는 네이티브 GUI 창은 shells/runner에 등록하지 않고
|
||||
OS 세션에서 완전히 분리해서 스폰해야 한다.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_refresh_env()
|
||||
scrcpy_path = shutil.which('scrcpy')
|
||||
if not scrcpy_path:
|
||||
return {'ok': False, 'error': 'scrcpy를 찾을 수 없습니다. https://github.com/Genymobile/scrcpy 에서 설치 후 PATH에 추가해주세요.'}
|
||||
|
||||
cmd = [scrcpy_path] + (['-s', serial] if serial else [])
|
||||
log_path = tempfile.mktemp(suffix='.scrcpy.log')
|
||||
try:
|
||||
log_file = open(log_path, 'w', encoding='utf-8', errors='replace')
|
||||
popen_kwargs = dict(stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
|
||||
if IS_WINDOWS:
|
||||
popen_kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs['start_new_session'] = True
|
||||
proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': f'scrcpy 실행 실패: {e}'}
|
||||
return {'ok': True, 'pid': proc.pid, '_proc': proc, '_log_path': log_path}
|
||||
|
||||
|
||||
def _safe_join(root_path: str, rel_path: str) -> str:
|
||||
root_abs = os.path.abspath(root_path)
|
||||
full = os.path.abspath(os.path.join(root_abs, rel_path))
|
||||
@@ -579,6 +627,42 @@ async def handle(ws):
|
||||
'error': f'폴더 선택 창을 열 수 없습니다: {e}',
|
||||
}))
|
||||
|
||||
elif t == 'browse_file':
|
||||
req_id = msg.get('reqId')
|
||||
title = msg.get('title') or '파일 선택'
|
||||
filetypes = msg.get('filetypes')
|
||||
filetypes = [tuple(ft) for ft in filetypes] if filetypes else None
|
||||
try:
|
||||
path = await asyncio.get_event_loop().run_in_executor(None, _pick_file, title, filetypes)
|
||||
await ws.send(json.dumps({'type': 'browse_file_result', 'reqId': req_id, 'path': path}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({
|
||||
'type': 'browse_file_result', 'reqId': req_id, 'path': None,
|
||||
'error': f'파일 선택 창을 열 수 없습니다: {e}',
|
||||
}))
|
||||
|
||||
elif t == 'scrcpy_start':
|
||||
req_id = msg.get('reqId')
|
||||
serial = msg.get('serial')
|
||||
try:
|
||||
result = await asyncio.get_event_loop().run_in_executor(None, _spawn_scrcpy, serial)
|
||||
if result.get('ok'):
|
||||
proc = result.pop('_proc')
|
||||
log_path = result.pop('_log_path')
|
||||
await asyncio.sleep(1.2)
|
||||
if proc.poll() is not None:
|
||||
# 스폰 직후 바로 종료됨 — 로그 tail을 에러로 반환
|
||||
tail = ''
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
tail = f.read()[-2000:]
|
||||
except Exception:
|
||||
pass
|
||||
result = {'ok': False, 'error': f'scrcpy가 바로 종료되었습니다: {tail or "(로그 없음)"}'}
|
||||
await ws.send(json.dumps({'type': 'scrcpy_result', 'reqId': req_id, **result}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({'type': 'scrcpy_result', 'reqId': req_id, 'ok': False, 'error': str(e)}))
|
||||
|
||||
elif t == 'fs_list':
|
||||
req_id = msg.get('reqId')
|
||||
root_path = msg.get('cwd') or ''
|
||||
|
||||
@@ -1543,7 +1543,7 @@ async function sendChat() {
|
||||
|
||||
appendChatMsg(msg, 'user');
|
||||
|
||||
const CONTEXT = `당신은 치과 사전 이미지 현황 조회 도우미입니다. DB 경로: /home/kim/homeclaw/.smallclaw/databases/dental_dict.db. 이미지 디렉토리: dental_images/. 스크립트: workflow_images.py, download_images.py, manage.py. 질문에 답변하고 현황을 분석해 주세요. 단, 스크립트 실행이나 API 호출은 절대 하지 마세요 — 실행은 사용자가 직접 버튼으로 합니다. 한국어로 답변하세요.`;
|
||||
const CONTEXT = `당신은 치과 사전 이미지 현황 조회 도우미입니다. DB 경로: /srv/homeclaw/.smallclaw/databases/dental_dict.db. 이미지 디렉토리: dental_images/. 스크립트: workflow_images.py, download_images.py, manage.py. 질문에 답변하고 현황을 분석해 주세요. 단, 스크립트 실행이나 API 호출은 절대 하지 마세요 — 실행은 사용자가 직접 버튼으로 합니다. 한국어로 답변하세요.`;
|
||||
|
||||
const history = [];
|
||||
const msgs = document.querySelectorAll('#chat-messages .chat-msg');
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
.btn { padding: 10px 16px; font-size: 12px; }
|
||||
.btn-sm { padding: 6px 10px; font-size: 11px; border-radius: 10px; }
|
||||
#mic-btn.recording { background: #ef4444 !important; animation: mic-pulse 1s ease-in-out infinite; }
|
||||
#voice-call-btn.recording { background: #16a34a !important; animation: mic-pulse 1s ease-in-out infinite; }
|
||||
@keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
|
||||
.tts-btn { background:none;border:none;cursor:pointer;font-size:13px;padding:1px 4px;border-radius:4px;opacity:0.45;vertical-align:middle;line-height:1; }
|
||||
.tts-btn:hover,.tts-btn.playing{opacity:1;background:var(--panel-2);}
|
||||
|
||||
Reference in New Issue
Block a user