feat: claude-app 로컬 LLM 코딩 에이전트 추가 + 코드 에디터 임베드
- Ollama/LM Studio 기반 터미널형 코딩 에이전트(claude-app.html/js): 테마 11종, Plan/Confirm/Auto/Yolo 실행 모드, companion.py 연동 - companion.py에 browse_folder/fs_list/fs_read/fs_write 추가 — 네이티브 폴더 선택이 곧 Bash 작업 경로가 되도록 통합해 Write/Bash가 서로 다른 폴더를 보던 문제 해결 - code.html에 클로드 앱 하단 패널 추가, 우측 AI 채팅 패널은 그대로 유지되도록 그리드 레이아웃 조정, 패널 높이를 전체 높이의 50~70%로 제한 - iframe 관련 버그 수정: src="" 판정 오류로 패널이 로드되지 않던 문제, 드래그 리사이즈 중 마우스가 iframe 위를 지나면 릴리즈가 안 되던 문제 (미리보기/에뮬레이터 패널도 동일 수정) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ pygame 등 GUI 라이브러리도 정상 동작합니다.
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -364,6 +365,45 @@ class Runner:
|
||||
pass
|
||||
|
||||
|
||||
# ── 파일 작업(Read/Write/Edit/Glob/Grep)용 헬퍼 ──────────────────────────
|
||||
# claude-app의 Bash 경로와 동일한 root를 사용해 파일 작업을 처리한다.
|
||||
# 이렇게 하면 Bash cwd와 Read/Write 대상 폴더가 항상 같은 곳을 가리킨다.
|
||||
FS_IGNORE_DIRS = {'node_modules', '.git', 'dist', 'build', '__pycache__', 'venv', '.venv', '.next', 'target', '.cache', 'out'}
|
||||
|
||||
|
||||
def _pick_folder():
|
||||
"""네이티브 OS 폴더 선택 창을 열고 절대경로를 반환한다 (동기, executor에서 실행)."""
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.attributes('-topmost', True)
|
||||
try:
|
||||
path = filedialog.askdirectory(title='claude-app 프로젝트 폴더 선택')
|
||||
finally:
|
||||
root.destroy()
|
||||
return path or None
|
||||
|
||||
|
||||
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))
|
||||
if full != root_abs and not full.startswith(root_abs + os.sep):
|
||||
raise ValueError('경로가 작업 폴더를 벗어났습니다')
|
||||
return full
|
||||
|
||||
|
||||
def _fs_walk(root_path: str):
|
||||
root_abs = os.path.abspath(root_path)
|
||||
results = []
|
||||
for dirpath, dirnames, filenames in os.walk(root_abs):
|
||||
dirnames[:] = [d for d in dirnames if d not in FS_IGNORE_DIRS and not d.startswith('.')]
|
||||
for fn in filenames:
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root_abs).replace(os.sep, '/')
|
||||
results.append(rel)
|
||||
return results
|
||||
|
||||
|
||||
async def handle(ws):
|
||||
shells = {} # shell_id -> ShellSession
|
||||
runner = Runner()
|
||||
@@ -477,6 +517,95 @@ async def handle(ws):
|
||||
print('[강제 종료]')
|
||||
await ws.send(json.dumps({'type': 'killed'}))
|
||||
|
||||
elif t == 'exec':
|
||||
# 단발성 명령 실행 (에디터의 Bash 툴 / claude-app 에이전트 루프용).
|
||||
# 인터랙티브 셸(ShellSession)과 무관하게 reqId로 결과를 상관시킨다.
|
||||
req_id = msg.get('reqId')
|
||||
command = msg.get('command', '')
|
||||
cwd = msg.get('cwd') or None
|
||||
# Windows(cmd.exe)는 시스템 로컬 인코딩(CP949 등)을 사용하므로
|
||||
# UTF-8 디코딩 실패 시 시스템 기본 인코딩으로 폴백한다.
|
||||
_refresh_env()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=cwd if cwd and os.path.isdir(cwd) else None,
|
||||
env={**os.environ},
|
||||
)
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
# Try UTF-8 first; on Windows fall back to system locale encoding
|
||||
try:
|
||||
text = out.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
text = out.decode(locale.getpreferredencoding(False), errors='replace')
|
||||
if len(text) > 200_000:
|
||||
text = text[:200_000] + '\n…(출력 잘림)'
|
||||
await ws.send(json.dumps({
|
||||
'type': 'exec_result', 'reqId': req_id,
|
||||
'output': text, 'error': proc.returncode != 0,
|
||||
}))
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
await ws.send(json.dumps({
|
||||
'type': 'exec_result', 'reqId': req_id,
|
||||
'output': '오류: 명령 실행 시간 초과 (30s)', 'error': True,
|
||||
}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({
|
||||
'type': 'exec_result', 'reqId': req_id,
|
||||
'output': f'오류: {e}', 'error': True,
|
||||
}))
|
||||
|
||||
elif t == 'browse_folder':
|
||||
req_id = msg.get('reqId')
|
||||
try:
|
||||
path = await asyncio.get_event_loop().run_in_executor(None, _pick_folder)
|
||||
await ws.send(json.dumps({'type': 'browse_result', 'reqId': req_id, 'path': path}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({
|
||||
'type': 'browse_result', 'reqId': req_id, 'path': None,
|
||||
'error': f'폴더 선택 창을 열 수 없습니다: {e}',
|
||||
}))
|
||||
|
||||
elif t == 'fs_list':
|
||||
req_id = msg.get('reqId')
|
||||
root_path = msg.get('cwd') or ''
|
||||
try:
|
||||
paths = _fs_walk(root_path) if root_path and os.path.isdir(root_path) else []
|
||||
await ws.send(json.dumps({'type': 'fs_list_result', 'reqId': req_id, 'paths': paths}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({'type': 'fs_list_result', 'reqId': req_id, 'paths': [], 'error': str(e)}))
|
||||
|
||||
elif t == 'fs_read':
|
||||
req_id = msg.get('reqId')
|
||||
root_path = msg.get('cwd') or ''
|
||||
rel = msg.get('path') or ''
|
||||
try:
|
||||
full = _safe_join(root_path, rel)
|
||||
with open(full, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
await ws.send(json.dumps({'type': 'fs_read_result', 'reqId': req_id, 'content': content}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({'type': 'fs_read_result', 'reqId': req_id, 'content': None, 'error': str(e)}))
|
||||
|
||||
elif t == 'fs_write':
|
||||
req_id = msg.get('reqId')
|
||||
root_path = msg.get('cwd') or ''
|
||||
rel = msg.get('path') or ''
|
||||
content = msg.get('content', '')
|
||||
try:
|
||||
full = _safe_join(root_path, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
await ws.send(json.dumps({'type': 'fs_write_result', 'reqId': req_id, 'ok': True}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({'type': 'fs_write_result', 'reqId': req_id, 'ok': False, 'error': str(e)}))
|
||||
|
||||
elif t == 'ping':
|
||||
await ws.send(json.dumps({'type': 'pong'}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user