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:
kim
2026-07-10 13:31:28 +09:00
co-authored by Claude Sonnet 5
parent 34fac14b20
commit f014fec7e2
17 changed files with 380 additions and 265 deletions
+4 -1
View File
@@ -205,7 +205,10 @@
"WebFetch(domain:pcsp.nationalregister.org)",
"Bash(mkdir -p pptx)",
"Bash(mv sinus *)",
"Bash(~/.local/bin/arduino-cli core *)"
"Bash(~/.local/bin/arduino-cli core *)",
"Bash(nvidia-smi --query-gpu=memory.used,memory.total,memory.free --format=csv)",
"Bash(/home/kim/homeclaw/.smallclaw/voice-venv/bin/pip list *)",
"Bash(apt-cache policy *)"
]
},
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
+10 -10
View File
@@ -21,7 +21,7 @@
"providers": {
"ollama": {
"endpoint": "http://localhost:11434",
"model": "gemini-3-flash-preview:cloud"
"model": "gemma4:31b-cloud"
},
"lm_studio": {
"endpoint": "http://host.docker.internal:1234",
@@ -45,11 +45,11 @@
}
},
"models": {
"primary": "gemini-3-flash-preview:cloud",
"primary": "gemma4:31b-cloud",
"roles": {
"manager": "gemini-3-flash-preview:cloud",
"executor": "gemini-3-flash-preview:cloud",
"verifier": "gemini-3-flash-preview:cloud",
"manager": "gemma4:31b-cloud",
"executor": "gemma4:31b-cloud",
"verifier": "gemma4:31b-cloud",
"background_task": ""
}
},
@@ -88,7 +88,7 @@
}
},
"skills": {
"directory": "/home/kim/homeclaw/.smallclaw/skills",
"directory": "/srv/homeclaw/.smallclaw/skills",
"registries": [
"https://clawhub.ai"
],
@@ -96,7 +96,7 @@
},
"memory": {
"provider": "chromadb",
"path": "/home/kim/homeclaw/.smallclaw/memory",
"path": "/srv/homeclaw/.smallclaw/memory",
"embedding_model": "nomic-embed-text"
},
"memory_options": {
@@ -118,7 +118,7 @@
"workspace_file": "HEARTBEAT.md"
},
"workspace": {
"path": "/home/kim/homeclaw/.smallclaw/workspace",
"path": "/srv/homeclaw/.smallclaw/workspace",
"codeDir": "code"
},
"agents": [
@@ -239,14 +239,14 @@
"language": "ko"
},
"tts": {
"provider": "xtts_gpu",
"provider": "edge_tts",
"model": "ko_KR-kss-medium",
"voice": "ko-KR-SunHiNeural"
},
"engine": {
"port": 8765,
"sttModel": "medium",
"speakerWav": "/home/kim/homeclaw/.smallclaw/voice/speaker_ko.wav",
"speakerWav": "/srv/homeclaw/.smallclaw/voice/speaker_ko.wav",
"device": "cuda"
}
},
+3 -3
View File
@@ -7,7 +7,7 @@
"command": "node",
"args": [
"/home/kim/.local/lib/node_modules/mcp-sqlite/mcp-sqlite-server.js",
"/home/kim/homeclaw/.smallclaw/databases/dental_dict.db"
"/srv/homeclaw/.smallclaw/databases/dental_dict.db"
],
"description": "치의학 사전 SQLite DB. 테이블: terms(id,korean,english,latin,abbreviation,definition,category,synonyms,related_ids,pmids,icd_code,notes,image_url,source_url,created_at,updated_at), categories(id,name,name_en), references_tb(id,term_id,pubmed_id,title,authors,year). 한국어 검색은 korean 컬럼, 영어는 english, 라틴어는 latin 컬럼 사용.\n\n**카테고리 목록** (category 컬럼 값 — 반드시 정확히 일치):\n- 구강악안면영상의학 (방사선, X선, CT, 촬영법 등)\n- 치주 (치주염, 잇몸, 치조골 등)\n- 보존 (우식, 근관치료, 수복 등)\n- 보철 (틀니, 크라운, 브릿지 등)\n- 교정 (부정교합, 브라켓 등)\n- 구강병리 (낭종, 종양, 조직병리 등)\n- 구강외과 (발치, 임플란트 수술 등)\n- 구강내과 (구강점막질환, 통증 등)\n- 소아치과, 예방치학, 임플란트, 치과마취학, 치과생체재료학, 노인치의학, 심미치의학, 디지털치의학, 기초치의학\n\n**퀴즈/플래시카드 생성 규칙**: 주제별 퀴즈를 낼 때 반드시 해당 category로 WHERE 절 필터를 걸 것. 예) '영상의학 퀴즈' → WHERE category='구강악안면영상의학', '치주 퀴즈' → WHERE category='치주'. 카테고리를 섞어서 선택하지 말 것. 이미지 있는 용어만 뽑을 때는 AND image_url IS NOT NULL AND image_url != '' 조건 추가.\n\n**이미지 표시 규칙**: 용어 조회 시 image_url 컬럼 값을 반드시 포함할 것. image_url은 위키미디어 직접 링크(https://upload.wikimedia.org/...) 또는 PMC CDN 링크(https://cdn.ncbi.nlm.nih.gov/pmc/blobs/...) 또는 로컬 경로(/api/files/uploads/dental_images/remote/숫자.jpg 또는 .png) 형식. 이미지 표시 시 반드시 image_url 컬럼의 값을 그대로 사용할 것. 절대 직접 경로를 만들거나 임의의 URL을 생성하지 말 것. **절대 read_file이나 다른 툴로 이미지 파일 존재 여부를 검증하지 말 것 — image_url 값이 있으면 무조건 바로 마크다운으로 출력할 것.** source_url은 참고문헌 URL.\n\n**퀴즈/플래시카드 이미지 출력 형식**: 퀴즈나 플래시카드를 출력할 때 image_url이 있는 용어는 반드시 마크다운 이미지 형식으로 표시할 것. 형식: ![용어명](image_url). SELECT 쿼리에 반드시 image_url 컬럼을 포함하고, 결과의 image_url 값이 비어있지 않으면 퀴즈 항목 아래에 이미지를 삽입할 것. 이미지가 없는 용어는 이미지 없이 표시."
},
@@ -19,7 +19,7 @@
"command": "node",
"args": [
"/home/kim/.local/lib/node_modules/mcp-sqlite/mcp-sqlite-server.js",
"/home/kim/homeclaw/.smallclaw/databases/psychotherapy_cases.db"
"/srv/homeclaw/.smallclaw/databases/psychotherapy_cases.db"
],
"description": "PCSP(Pragmatic Case Studies in Psychotherapy) 저널 기반 심리치료 임상 사례 DB. 테이블: cases(id, article_id, issue_id, vol, num, year, title_en, title_ko, authors, article_type, category_en, category_ko, therapy_approach_en, therapy_approach_ko, keywords, abstract_en, abstract_ko, doi, url, source_url, created_at). 총 352개 기사(사례연구 161, 일반기사 166, 논평 23, 응답 2).\n\n**치료 접근법별 카테고리** (category_en, category_ko):\n- CBT (인지행동치료): 80건\n- Psychodynamic (정신역동치료): 37건\n- Exposure Therapy (노출치료): 28건\n- Integrative (통합치료): 13건\n- AEDP (가속적경험적역동치료): 13건\n- Humanistic (인간중심치료): 9건\n- ACT (수용전념치료): 7건\n- EFT (정서초점치료): 6건\n- DBT (변증법적행동치료): 5건\n- Schema Therapy (도식치료): 4건\n- Mindfulness (마음챙김치료): 4건\n- Hypnosis (최면치료): 4건\n- Narrative Therapy (내러티브치료): 3건\n- Other (기타): 139건\n\n**기사 유형** (article_type): case_study, article, commentary, response\n\n**쿼리 예시**:\n- CBT 사례연구: SELECT title_en, title_ko, authors, year FROM cases WHERE category_en='CBT' AND article_type='case_study'\n- 특정 치료법 사례: SELECT title_en, title_ko, abstract_en FROM cases WHERE category_en='DBT'\n- 연도별 검색: SELECT title_en, title_ko, year FROM cases WHERE year >= 2020\n- 키워드 검색: SELECT title_en, title_ko FROM cases WHERE keywords LIKE '%anxiety%'\n- 초상 포함 검색: SELECT title_en, title_ko, abstract_en FROM cases WHERE abstract_en != '' AND category_en='Exposure Therapy'\n\n**한국어 검색**: title_ko 컬럼 사용. 일부 기사는 [카테고리한국어] + 영어 원문 제목 형식.\n**출처**: Pragmatic Case Studies in Psychotherapy (PCSP) - https://pcsp.nationalregister.org/"
},
@@ -31,7 +31,7 @@
"command": "node",
"args": [
"/home/kim/.local/lib/node_modules/mcp-sqlite/mcp-sqlite-server.js",
"/home/kim/homeclaw/.smallclaw/databases/psychiatry_cases.db"
"/srv/homeclaw/.smallclaw/databases/psychiatry_cases.db"
],
"description": "DSM-5 기반 정신과 임상 증례 DB. 테이블: cases(id, section_num, case_title, diagnosis_en, diagnosis_ko, chapter_num, chapter_en, chapter_ko, content, source_url, created_at). 총 117개 증례, 19개 챕터(신경발달장애~성도착장애).\n\n**챕터 목록** (chapter_num, chapter_ko):\n1. 신경발달장애, 2. 조현병 스펙트럼, 3. 양극성 장애, 4. 우울장애, 5. 불안장애, 6. 강박 및 관련 장애, 7. 외상 및 스트레스 관련 장애, 8. 해리장애, 9. 신체증상 및 관련 장애, 10. 섭식장애, 11. 배설장애, 12. 수면-각성 장애, 13. 성기능 장애, 14. 성별 불쾌감, 15. 파괴적 충동조절 장애, 16. 물질관련 및 중독장애, 17. 신경인지장애, 18. 인격장애, 19. 성도착장애\n\n**쿼리 예시**:\n- 특정 장애 증례: SELECT case_title, diagnosis_en, content FROM cases WHERE diagnosis_en LIKE '%Schizophrenia%'\n- 챕터별 증례: SELECT case_title, diagnosis_en FROM cases WHERE chapter_num = 5\n- 한국어 챕터 검색: SELECT case_title, diagnosis_en FROM cases WHERE chapter_ko = '인격장애'\n- 증례 내용 검색: SELECT case_title, diagnosis_en FROM cases WHERE content LIKE '%bipolar%'\n\n**출력 규칙**: 증례를 설명할 때 case_title과 diagnosis_en을 함께 표시할 것. 위기 상황(자해·자살 언급)이 감지되면 즉시 자살예방상담전화 1393, 정신건강위기상담전화 1577-0199를 안내할 것."
}
@@ -20,4 +20,6 @@
- Local LLM Server: HP Z840, 2x RTX 5060 Ti, 64GB RAM, Crucial P310 1TB NVMe. [2026-07-03] [2026-07-03]
- Local LLM Server: HP Z840, 2x RTX 5060 Ti (16GB each, total 32GB VRAM), 64GB RAM, Crucial P310 1TB NVMe. [2026-07-03] [2026-07-03]
- Local LLM Server name: 지서버 (Z-Server). [2026-07-03] [2026-07-03]
- Web server name: 클로서버 (Claw-Server). [2026-07-03] [2026-07-03]
- Web server name: 클로서버 (Claw-Server). [2026-07-03] [2026-07-03]
- Web server (클로서버) 사양: HP ProDesk 600 G1 DM, 16GB RAM. [2026-07-06] [2026-07-06]
- Web server (클로서버) 사양 업그레이드: Dell T5810, Xeon E5-2683 v4, 40GB RAM, RTX 3060 12GB. [2026-07-09] [2026-07-09]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
"version": "3.5.0",
"version": "4.0.0",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
+2 -2
View File
@@ -17,7 +17,7 @@ import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
DB_PATH = '/home/kim/homeclaw/.smallclaw/databases/dental_dict.db'
DB_PATH = '/srv/homeclaw/.smallclaw/databases/dental_dict.db'
EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
# ── Known Latin translations for common dental/implant terms ──
@@ -323,7 +323,7 @@ def fetch_pubmed_abstract(pmid: str, api_key: str = None) -> str:
def get_api_key():
"""Read PubMed API key from config."""
try:
with open('/home/kim/homeclaw/.smallclaw/config.json') as f:
with open('/srv/homeclaw/.smallclaw/config.json') as f:
cfg = json.load(f)
return cfg.get('pubmed', {}).get('api_key') or cfg.get('search', {}).get('pubmed_api_key')
except:
+2 -2
View File
@@ -18,7 +18,7 @@ import urllib.request
import urllib.parse
import re
DB_PATH = '/home/kim/homeclaw/.smallclaw/databases/dental_dict.db'
DB_PATH = '/srv/homeclaw/.smallclaw/databases/dental_dict.db'
EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
# ── Comprehensive Latin dictionary ──
@@ -1010,7 +1010,7 @@ def search_mesh(term: str, api_key: str = None) -> str:
def get_api_key():
try:
with open('/home/kim/homeclaw/.smallclaw/config.json') as f:
with open('/srv/homeclaw/.smallclaw/config.json') as f:
cfg = json.load(f)
return cfg.get('pubmed', {}).get('api_key') or cfg.get('search', {}).get('pubmed_api_key')
except:
+6 -6
View File
@@ -7,7 +7,7 @@ import json, os, re, sys, time, subprocess
import urllib.request, urllib.error
# .env에서 API 키 로드
def _load_env(path="/home/kim/homeclaw/.env"):
def _load_env(path="/srv/homeclaw/.env"):
env = {}
try:
for line in open(path):
@@ -24,10 +24,10 @@ PEXELS_KEY = os.environ.get("PEXELS_API_KEY") or _env.get("PEXELS_API_KEY",
UNSPLASH_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") or _env.get("UNSPLASH_ACCESS_KEY", "")
OLLAMA_URL = "http://localhost:11434/api/chat"
WORKSPACE = "/home/kim/homeclaw/.smallclaw/users/papa/workspace"
WORKSPACE = "/srv/homeclaw/.smallclaw/users/papa/workspace"
REPORT_TITLE = "한반도_기후_변화_100년_보고서" # 모든 모델 출력 폴더 고정
PPTX_GEN = "/home/kim/homeclaw/scripts/pptx_gen.py"
PREVIEW = "/home/kim/homeclaw/scripts/pptx_preview.py"
PPTX_GEN = "/srv/homeclaw/scripts/pptx_gen.py"
PREVIEW = "/srv/homeclaw/scripts/pptx_preview.py"
MODELS = [
"mistral-large-3:675b-cloud",
@@ -249,7 +249,7 @@ def build_pptx(spec: dict, model_tag: str, username: str = "papa") -> str | None
print(f"[{model_tag}] PPTX 생성 중...", flush=True)
r = subprocess.run(
["python3", PPTX_GEN, spec_path, WORKSPACE],
capture_output=True, text=True, cwd="/home/kim/homeclaw"
capture_output=True, text=True, cwd="/srv/homeclaw"
)
try:
out = json.loads(r.stdout)
@@ -277,7 +277,7 @@ def make_preview(pptx_path: str) -> list[str]:
os.makedirs(prev_dir, exist_ok=True)
r = subprocess.run(
["python3", PREVIEW, pptx_path, prev_dir],
capture_output=True, text=True, cwd="/home/kim/homeclaw"
capture_output=True, text=True, cwd="/srv/homeclaw"
)
try:
# MuPDF error messages may appear before the JSON line
+4 -4
View File
@@ -132,11 +132,11 @@ export function registerAndroidRoutes(app: express.Application, getSessionUser:
'-p', `127.0.0.1:${vncPort}:6080`,
'-p', `127.0.0.1:${adbPort}:5555`,
'--shm-size=1g',
// Host has 4 cores total; leave 1 for the gateway/OS. Software
// Host is a 32-thread Xeon E5-2682 v4 with 38GB RAM. Software
// rendering (swiftshader) is multi-threaded, so more cores here
// measurably speeds up boot — at the cost of more contention if a
// second concurrent session is also running.
'--memory=3g', '--cpus=3',
// measurably speeds up boot. MAX_CONCURRENT sessions * 10 cores
// still leaves headroom for the gateway/OS.
'--memory=6g', '--cpus=10',
ANDROID_IMAGE,
], { timeout: 20000 }, async (err, stdout) => {
if (err) {
+1 -1
View File
@@ -1836,7 +1836,7 @@ function _stm32RenodeInfo(fqbn: string): STM32RenodeInfo {
function _buildCustomRepl(tmpDir: string, info: STM32RenodeInfo): string {
// stm32f103_fixed.repl: built-in + PWR Python mock (HAL polling 루프 픽스)
const fixedReplPath = '/home/kim/homeclaw/stm32f103_fixed.repl';
const fixedReplPath = '/srv/homeclaw/stm32f103_fixed.repl';
if (require('fs').existsSync(fixedReplPath)) {
const replPath = tmpDir + '/platform.repl';
require('fs').copyFileSync(fixedReplPath, replPath);
-229
View File
@@ -1,229 +0,0 @@
// STM32F103 fixed platform — PWR Python mock 추가로 HAL 폴링 루프 해결
// SVD 태그는 writes를 무시해서 HAL_PWR_EnableBkUpAccess()가 무한루프에 빠짐
// Python mock은 write를 저장하고 read에서 되돌려줘서 HAL이 정상 진행됨
fsmcBank1: Memory.MappedMemory @ sysbus 0x60000000
size: 0x10000000
sram: Memory.MappedMemory @ sysbus 0x20000000
size: 0x5000
flash: Memory.MappedMemory @ sysbus 0x00000000
size: 0x20000000
usart1: UART.STM32_UART @ sysbus <0x40013800, +0x100>
-> nvic@37
usart2: UART.STM32_UART @ sysbus <0x40004400, +0x100>
-> nvic@38
usart3: UART.STM32_UART @ sysbus <0x40004800, +0x100>
-> nvic@39
usart4: UART.STM32_UART @ sysbus <0x40004C00, +0x100>
-> nvic@52
usart5: UART.STM32_UART @ sysbus <0x40005000, +0x100>
-> nvic@53
nvic: IRQControllers.NVIC @ sysbus 0xE000E000
priorityMask: 0xF0
systickFrequency: 8000000
IRQ -> cpu@0
cpu: CPU.CortexM @ sysbus
cpuType: "cortex-m3"
nvic: nvic
i2c1: I2C.STM32F4_I2C @ sysbus 0x40005400
EventInterrupt -> nvic@31
ErrorInterrupt -> nvic@32
i2c2: I2C.STM32F4_I2C @ sysbus 0x40005800
EventInterrupt -> nvic@33
ErrorInterrupt -> nvic@34
exti: IRQControllers.STM32F4_EXTI @ sysbus 0x40010400
numberOfOutputLines: 19
[0-4] -> nvic@[6-10]
[5-9] -> nvicInput23@[0-4]
[10-15] -> nvicInput40@[0-5]
nvicInput23: Miscellaneous.CombinedInput @ none
numberOfInputs: 5
-> nvic@23
nvicInput40: Miscellaneous.CombinedInput @ none
numberOfInputs: 6
-> nvic@40
gpioPortA: GPIOPort.STM32F1GPIOPort @ sysbus <0x40010800, +0x400>
invertedAFPins: [[7, 1]]
[0-15] -> exti@[0-15]
gpioPortB: GPIOPort.STM32F1GPIOPort @ sysbus <0x40010C00, +0x400>
invertedAFPins: [[13, 1], [14, 1], [15, 1], [0, 1], [1, 1]]
[0-15] -> exti@[0-15]
gpioPortC: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011000, +0x400>
[0-15] -> exti@[0-15]
gpioPortD: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011400, +0x400>
[0-15] -> exti@[0-15]
gpioPortE: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011800, +0x400>
invertedAFPins: [[8, 1], [10, 1], [12, 1]]
[0-15] -> exti@[0-15]
gpioPortF: GPIOPort.STM32F1GPIOPort @ sysbus <0x40011C00, +0x400>
[0-15] -> exti@[0-15]
gpioPortG: GPIOPort.STM32F1GPIOPort @ sysbus <0x40012000, +0x400>
[0-15] -> exti@[0-15]
timer1: Timers.STM32_Timer @ sysbus 0x40012c00
BreakInterrupt -> nvic@24
UpdateInterrupt -> nvic@25
[TriggerInterrupt, CommutationInterrupt] -> nvic@[26, 26]
CaptureCompareInterrupt -> nvic@27
frequency: 10000000
initialLimit: 0xFFFF
timer2: Timers.STM32_Timer @ sysbus 0x40000000
-> nvic@28
frequency: 10000000
initialLimit: 0xFFFF
timer3: Timers.STM32_Timer @ sysbus 0x40000400
-> nvic@29
frequency: 10000000
initialLimit: 0xFFFF
timer4: Timers.STM32_Timer @ sysbus 0x40000800
-> nvic@30
frequency: 10000000
initialLimit: 0xFFFF
timer5: Timers.STM32_Timer @ sysbus 0x40000C00
-> nvic@50
frequency: 10000000
initialLimit: 0xFFFF
timer6: Timers.STM32_Timer @ sysbus 0x40001000
-> nvic@54
frequency: 10000000
initialLimit: 0xFFFF
timer7: Timers.STM32_Timer @ sysbus 0x40001400
-> nvic@55
frequency: 10000000
initialLimit: 0xFFFF
timer8: Timers.STM32_Timer @ sysbus 0x40013400
BreakInterrupt -> nvic@43
UpdateInterrupt -> nvic@44
[TriggerInterrupt, CommutationInterrupt] -> nvic@[45, 45]
CaptureCompareInterrupt -> nvic@46
frequency: 10000000
initialLimit: 0xFFFF
timer9: Timers.STM32_Timer @ sysbus 0x40014c00
-> nvic@24
frequency: 10000000
initialLimit: 0xFFFF
timer10: Timers.STM32_Timer @ sysbus 0x40015000
-> nvic@25
frequency: 10000000
initialLimit: 0xFFFF
timer11: Timers.STM32_Timer @ sysbus 0x40015400
-> nvic@26
frequency: 10000000
initialLimit: 0xFFFF
timer12: Timers.STM32_Timer @ sysbus 0x40001800
-> nvic@43
frequency: 10000000
initialLimit: 0xFFFF
timer13: Timers.STM32_Timer @ sysbus 0x40001C00
-> nvic@44
frequency: 10000000
initialLimit: 0xFFFF
timer14: Timers.STM32_Timer @ sysbus 0x40002000
-> nvic@45
frequency: 10000000
initialLimit: 0xFFFF
afio: GPIOPort.STM32F1AFIO @ sysbus 0x40010000
gpioPorts: [gpioPortA, gpioPortB, gpioPortC, gpioPortD, gpioPortE, gpioPortF, gpioPortG]
timer1:
0 -> gpioPortA#08@01 | gpioPortE#09@01 | gpioPortB#13@01 | gpioPortA#07@01 | gpioPortE#08@01
1 -> gpioPortA#09@01 | gpioPortE#11@01 | gpioPortB#14@01 | gpioPortB#00@01 | gpioPortE#10@01
2 -> gpioPortA#10@01 | gpioPortE#13@01 | gpioPortB#15@01 | gpioPortB#01@01 | gpioPortE#12@01
3 -> gpioPortA#11@01 | gpioPortE#14@01
timer2:
0 -> gpioPortA#00@02 | gpioPortA#15@02
1 -> gpioPortA#01@02 | gpioPortB#03@02
2 -> gpioPortA#02@02 | gpioPortB#10@02
3 -> gpioPortA#03@02 | gpioPortB#11@02
timer3:
0 -> gpioPortA#06@03 | gpioPortB#04@03 | gpioPortC#06@03
1 -> gpioPortA#07@03 | gpioPortB#05@03 | gpioPortC#07@03
2 -> gpioPortB#00@03 | gpioPortC#08@03
3 -> gpioPortB#01@03 | gpioPortC#09@03
timer4:
0 -> gpioPortB#06@04 | gpioPortD#12@04
1 -> gpioPortB#07@04 | gpioPortD#13@04
2 -> gpioPortB#08@04 | gpioPortD#14@04
3 -> gpioPortB#09@04 | gpioPortD#15@04
timer5:
3 -> gpioPortA#03@05
timer9:
0 -> gpioPortA#02@09 | gpioPortE#05@09
1 -> gpioPortA#03@09 | gpioPortE#06@09
timer10:
0 -> gpioPortB#08@10 | gpioPortF#06@10
timer11:
0 -> gpioPortB#09@11 | gpioPortF#07@11
timer13:
0 -> gpioPortA#06@13 | gpioPortF#08@13
timer14:
0 -> gpioPortA#07@14 | gpioPortF#09@14
// 비트밴드: Cortex-M3 bit-band 영역을 실제 주변장치 주소로 변환
bitbandPeripherals: Miscellaneous.BitBanding @ sysbus <0x42000000, +0x2000000>
peripheralBase: 0x40000000
// Python mocks: SVD 태그 대신 실제 write-echo 구현으로 HAL 폴링 정상화
rcc: Python.PythonPeripheral @ sysbus 0x40021000
size: 0x400
initable: true
filename: "/home/kim/.local/lib/renode/scripts/pydev/stm32f1_rcc.py"
flash_ctrl: Python.PythonPeripheral @ sysbus 0x40022000
size: 0x400
initable: true
filename: "/home/kim/.local/lib/renode/scripts/pydev/stm32f1_flash.py"
pwr: Python.PythonPeripheral @ sysbus 0x40007000
size: 0x400
initable: true
filename: "/home/kim/.local/lib/renode/scripts/pydev/stm32f1_pwr.py"
sysbus:
init:
ApplySVD @/home/kim/.local/lib/renode/svd/STM32F103.svd.gz
+34 -1
View File
@@ -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)}`, {
+20
View File
@@ -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">&times;</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
View File
@@ -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">&times;</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() {
+84
View File
@@ -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 ''
+1 -1
View File
@@ -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');
+1
View File
@@ -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);}