feat: claude-app Anthropic 백엔드 추가 + 코드 에디터 하단 패널 리사이즈/테마 통일

- claude-app에 Anthropic 백엔드 추가 (Ollama/LM Studio에 이어 3번째). Messages
  API(system 필드, tool_use/tool_result 블록) 변환 로직 구현, 브라우저 직접
  호출(anthropic-dangerous-direct-browser-access), API 키는 브라우저 로컬
  저장만 하고 서버로는 전송하지 않음
- 코드 에디터 터미널 패널에도 클로드 앱과 동일한 하단 패널 높이 제한(전체
  높이의 1/2~7/10) 적용, 공용 헬퍼(_bottomPanelHeightBounds)로 정리
- 터미널 배경색을 클로드 앱 Ubuntu 테마 색(#4B1D45)으로 통일

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-04 00:18:10 +09:00
co-authored by Claude Sonnet 5
parent 504830693e
commit 830ae12bac
4 changed files with 111 additions and 15 deletions
+7 -1
View File
@@ -116,6 +116,7 @@
<select id="backend-select">
<option value="ollama">Ollama</option>
<option value="lm_studio">LM Studio</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
@@ -130,7 +131,12 @@
<input type="text" id="endpoint-input">
</div>
<div class="field" title="Ollama/LM Studio가 이 homeclaw 서버와 같은 컴퓨터에서 돌고 있을 때 사용. 브라우저에서 직접 접속하지 않고 서버를 통해 우회하므로 CORS 설정이 필요 없습니다.">
<div class="field" id="anthropic-key-field" title="Anthropic Console에서 발급한 API 키. 브라우저에 저장되며 api.anthropic.com에 직접 전송됩니다.">
<label>API 키</label>
<input type="password" id="anthropic-key-input" placeholder="sk-ant-...">
</div>
<div class="field" id="proxy-toggle-field" title="Ollama/LM Studio가 이 homeclaw 서버와 같은 컴퓨터에서 돌고 있을 때 사용. 브라우저에서 직접 접속하지 않고 서버를 통해 우회하므로 CORS 설정이 필요 없습니다.">
<input type="checkbox" id="proxy-toggle">
<label for="proxy-toggle">서버 프록시</label>
</div>
+92 -6
View File
@@ -1,9 +1,10 @@
// claude-app.js — local-model coding agent rendered as a real terminal.
// Backends: Ollama (/api/chat) and LM Studio (/v1/chat/completions, OpenAI-compatible).
// Backends: Ollama (/api/chat), LM Studio (/v1/chat/completions, OpenAI-compatible),
// and Anthropic (/v1/messages, called directly from the browser with a user-supplied API key).
// Tools (Read/Write/Edit/Glob/Grep) run against a folder picked via the File System
// Access API; Bash runs through the same companion.py bridge code.html uses.
const DEFAULT_ENDPOINTS = { ollama: 'http://localhost:11434', lm_studio: 'http://localhost:1234' };
const DEFAULT_ENDPOINTS = { ollama: 'http://localhost:11434', lm_studio: 'http://localhost:1234', anthropic: 'https://api.anthropic.com' };
const COMPANION_URL = 'ws://127.0.0.1:9000';
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '__pycache__', 'venv', '.venv', '.next', 'target', '.cache', 'out']);
const MAX_ROUNDS = 25;
@@ -234,10 +235,12 @@ let backend = localStorage.getItem('claude_app_backend') || 'ollama';
const endpoints = {
ollama: localStorage.getItem('claude_app_ep_ollama') || DEFAULT_ENDPOINTS.ollama,
lm_studio: localStorage.getItem('claude_app_ep_lmstudio') || DEFAULT_ENDPOINTS.lm_studio,
anthropic: localStorage.getItem('claude_app_ep_anthropic') || DEFAULT_ENDPOINTS.anthropic,
};
const models = {
ollama: localStorage.getItem('claude_app_model_ollama') || '',
lm_studio: localStorage.getItem('claude_app_model_lmstudio') || '',
anthropic: localStorage.getItem('claude_app_model_anthropic') || '',
};
let dirHandle = null;
let companionWs = null, companionConnected = false;
@@ -252,9 +255,14 @@ const PROXY_BASES = { ollama: '/api/localllm/ollama', lm_studio: '/api/localllm/
// When useServerProxy is on, requests go through this homeclaw server (same-origin, no CORS
// needed) which forwards to Ollama/LM Studio on the server's own machine. Off = the browser
// hits localhost directly, which only works when the local model runs on the browsing device.
const currentEndpoint = () => useServerProxy ? PROXY_BASES[backend] : endpoints[backend];
// Anthropic has no proxy target (it's called directly — see callModel) so this always falls
// back to endpoints.anthropic regardless of the toggle.
const currentEndpoint = () => (useServerProxy && PROXY_BASES[backend]) ? PROXY_BASES[backend] : endpoints[backend];
const currentModel = () => models[backend];
const modelStorageKey = () => 'claude_app_model_' + (backend === 'ollama' ? 'ollama' : 'lmstudio');
const backendStorageSuffix = () => backend === 'ollama' ? 'ollama' : backend === 'lm_studio' ? 'lmstudio' : 'anthropic';
const modelStorageKey = () => 'claude_app_model_' + backendStorageSuffix();
const endpointStorageKey = () => 'claude_app_ep_' + backendStorageSuffix();
const getAnthropicKey = () => (document.getElementById('anthropic-key-input')?.value || '').trim();
// ── Terminal ─────────────────────────────────────────────────────────────
function initTerm() {
@@ -703,6 +711,14 @@ async function fetchModels() {
const r = await fetch(currentEndpoint() + '/api/tags');
if (!r.ok) throw new Error('HTTP ' + r.status);
names = ((await r.json()).models || []).map(m => m.name);
} else if (backend === 'anthropic') {
const apiKey = getAnthropicKey();
if (!apiKey) throw new Error('API 키를 입력하세요');
const r = await fetch(currentEndpoint() + '/v1/models', {
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' },
});
if (!r.ok) throw new Error('HTTP ' + r.status);
names = ((await r.json()).data || []).map(m => m.id);
} else {
const r = await fetch(currentEndpoint() + '/v1/models');
if (!r.ok) throw new Error('HTTP ' + r.status);
@@ -715,13 +731,68 @@ async function fetchModels() {
sel.innerHTML = '<option value="">(연결 실패)</option>';
if (term) {
twriteln(`\r\n\x1b[31m⚠️ ${backend} 모델 목록을 가져오지 못했습니다: ${e.message}\x1b[0m`);
twriteln(`\x1b[90m CORS 문제일 수 있습니다 — ${backend === 'ollama' ? 'OLLAMA_ORIGINS=* ollama serve 로 재시작' : 'LM Studio 서버 설정에서 CORS 활성화'} 를 확인하세요.\x1b[0m`);
const hint = backend === 'ollama' ? 'CORS 문제일 수 있습니다 — OLLAMA_ORIGINS=* ollama serve 로 재시작을 확인하세요.'
: backend === 'lm_studio' ? 'CORS 문제일 수 있습니다 — LM Studio 서버 설정에서 CORS 활성화를 확인하세요.'
: '상단 "API 키"가 올바른지 확인하세요.';
twriteln(`\x1b[90m ${hint}\x1b[0m`);
}
}
}
function setModel(name) { models[backend] = name; localStorage.setItem(modelStorageKey(), name); }
// Converts the app's internal OpenAI/Ollama-style messages array into Anthropic's Messages API
// shape: system prompt becomes a top-level field, assistant tool_calls become tool_use content
// blocks, and consecutive 'tool' result messages (one per call in a round) merge into a single
// user turn with multiple tool_result blocks — Anthropic expects one user turn per round, not one
// message per tool result.
function toAnthropicMessages(messages) {
const out = [];
let system;
for (const m of messages) {
if (m.role === 'system') { system = system ? system + '\n\n' + (m.content || '') : (m.content || ''); continue; }
if (m.role === 'user') { out.push({ role: 'user', content: m.content || '' }); continue; }
if (m.role === 'assistant') {
const blocks = [];
if (m.content) blocks.push({ type: 'text', text: m.content });
for (const tc of (m.tool_calls || [])) {
blocks.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input: normalizeArgs(tc.function.arguments) });
}
out.push({ role: 'assistant', content: blocks.length ? blocks : (m.content || '(응답 없음)') });
continue;
}
if (m.role === 'tool') {
const block = { type: 'tool_result', tool_use_id: m.tool_call_id, content: String(m.content ?? '') };
const last = out[out.length - 1];
if (last && last.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result') last.content.push(block);
else out.push({ role: 'user', content: [block] });
continue;
}
}
return { system, messages: out };
}
async function callModel(messages, tools, signal) {
if (backend === 'anthropic') {
const apiKey = getAnthropicKey();
if (!apiKey) throw new Error('상단에 Anthropic API 키를 입력하세요');
const { system, messages: aMessages } = toAnthropicMessages(messages);
const anthropicTools = tools.map(t => ({ name: t.function.name, description: t.function.description, input_schema: t.function.parameters }));
const r = await fetch(currentEndpoint() + '/v1/messages', {
method: 'POST', signal,
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({ model: currentModel(), max_tokens: 8192, system, messages: aMessages, tools: anthropicTools }),
});
if (!r.ok) throw new Error(`Anthropic HTTP ${r.status}: ${await r.text()}`);
const blocks = (await r.json()).content || [];
const content = blocks.filter(b => b.type === 'text').map(b => b.text).join('');
const toolCalls = blocks.filter(b => b.type === 'tool_use').map(b => ({ id: b.id, name: b.name, args: b.input || {} }));
return { content, toolCalls };
}
if (backend === 'ollama') {
const r = await fetch(currentEndpoint() + '/api/chat', {
method: 'POST', signal, headers: { 'Content-Type': 'application/json' },
@@ -882,24 +953,39 @@ window.addEventListener('load', async () => {
const endpointInput = document.getElementById('endpoint-input');
const localPathInput = document.getElementById('local-path-input');
const proxyToggle = document.getElementById('proxy-toggle');
const anthropicKeyInput = document.getElementById('anthropic-key-input');
function updateBackendFieldsVisibility() {
const isAnthropic = backend === 'anthropic';
document.getElementById('anthropic-key-field').style.display = isAnthropic ? '' : 'none';
// 서버 프록시는 Ollama/LM Studio 전용 — Anthropic은 항상 직접 호출된다.
document.getElementById('proxy-toggle-field').style.display = isAnthropic ? 'none' : '';
}
backendSel.value = backend;
endpointInput.value = currentEndpoint();
localPathInput.value = localStorage.getItem('claude_app_local_path') || '';
anthropicKeyInput.value = localStorage.getItem('claude_app_anthropic_key') || '';
proxyToggle.checked = useServerProxy;
endpointInput.disabled = useServerProxy;
updateBackendFieldsVisibility();
backendSel.addEventListener('change', async (e) => {
backend = e.target.value;
localStorage.setItem('claude_app_backend', backend);
endpointInput.value = currentEndpoint();
updateBackendFieldsVisibility();
await fetchModels();
});
endpointInput.addEventListener('change', (e) => {
endpoints[backend] = e.target.value.trim() || DEFAULT_ENDPOINTS[backend];
localStorage.setItem('claude_app_ep_' + (backend === 'ollama' ? 'ollama' : 'lmstudio'), endpoints[backend]);
localStorage.setItem(endpointStorageKey(), endpoints[backend]);
fetchModels();
});
anthropicKeyInput.addEventListener('change', (e) => {
localStorage.setItem('claude_app_anthropic_key', e.target.value.trim());
if (backend === 'anthropic') fetchModels();
});
proxyToggle.addEventListener('change', async (e) => {
useServerProxy = e.target.checked;
localStorage.setItem('claude_app_use_proxy', useServerProxy ? '1' : '0');
+1 -1
View File
@@ -532,7 +532,7 @@
<!-- Bottom panels: terminal + preview (grid-area: term) -->
<div class="code-bottom-panels">
<!-- Code editor terminal panel -->
<div id="code-term-panel" style="display:none;flex-direction:column;flex-shrink:0;height:260px;min-height:80px;border-top:2px solid #1a4a35;background:#0c0c0c;position:relative">
<div id="code-term-panel" style="display:none;flex-direction:column;flex-shrink:0;height:260px;min-height:80px;border-top:2px solid #1a4a35;background:#4B1D45;position:relative">
<div id="code-term-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:28px;background:#0d1f1a;border-bottom:1px solid #1a4a35;flex-shrink:0;overflow-x:auto;padding-left:2px">
<div id="code-term-tabs" style="display:flex;align-items:stretch;height:100%;flex:1"></div>
+11 -7
View File
@@ -757,7 +757,7 @@ function createXtermShell(container, shellId) {
sub.style.cssText = 'position:absolute;inset:0';
container.appendChild(sub);
const term = new Terminal({
theme: { background:'#0C0C0C', foreground:'#CCCCCC', cursor:'#22c55e',
theme: { background:'#4B1D45', foreground:'#CCCCCC', cursor:'#22c55e',
selectionBackground:'rgba(34,197,94,0.3)', red:'#E74856', green:'#16C60C',
yellow:'#C19C00', blue:'#3B78FF', cyan:'#61D6D6', white:'#CCCCCC',
brightBlack:'#767676', brightGreen:'#69FF69', brightWhite:'#FFFFFF' },
@@ -882,6 +882,9 @@ function toggleCodeTerminal() {
if (codeTermOpen) {
panel.style.display = 'flex';
if (btn) btn.classList.add('active');
const { min, max } = _bottomPanelHeightBounds();
const cur = panel.offsetHeight;
if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px';
if (!codeTermShells.length) { setTimeout(() => { if (companionConnected) addLocalShell(); else addCodeTermShell(); }, 60); }
else { setTimeout(() => { const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit(); if (s?.term) { s.term.focus(); const inst = xtermInstances.get(s.id); if (inst?.ptyReady) { if (localShellIds.has(s.id)) { if (companionConnected && companionWs) companionWs.send(JSON.stringify({ type: 'input', shellId: s.id, data: companionPlatform === 'windows' ? 'cls\r' : 'clear\r' })); } else { termWsSend({ type: 'pty_input', shellId: s.id, data: 'clear\r' }); } } } }, 50); }
} else {
@@ -950,8 +953,8 @@ function emuPanelNewTab() {
if (_emuPanelUrl) window.open(_emuPanelUrl, '_blank');
}
// 클로드 앱 패널 높이는 항상 전체 높이의 1/2~7/10 사이로 제한한다.
function _claudePanelBounds() {
// 하단 패널(클로드 앱, 터미널 등) 높이는 항상 전체 높이의 1/2~7/10 사이로 제한한다.
function _bottomPanelHeightBounds() {
const total = document.getElementById('code-view')?.clientHeight || window.innerHeight;
return { min: total * 0.5, max: total * 0.7 };
}
@@ -967,7 +970,7 @@ function toggleClaudeAppPanel() {
if (_claudePanelOpen) {
const iframe = document.getElementById('code-claude-iframe');
if (iframe && !iframe.getAttribute('src')) iframe.src = '/claude-app.html';
const { min, max } = _claudePanelBounds();
const { min, max } = _bottomPanelHeightBounds();
const cur = panel.offsetHeight;
if (cur < min || cur > max) panel.style.height = Math.round((min + max) / 2) + 'px';
}
@@ -982,7 +985,7 @@ document.addEventListener('DOMContentLoaded', function() {
drag.addEventListener('mouseover', () => drag.style.background = 'rgba(189,147,249,.4)');
drag.addEventListener('mousedown', e => {
startY = e.clientY; startH = panel.offsetHeight; e.preventDefault();
const { min, max } = _claudePanelBounds();
const { min, max } = _bottomPanelHeightBounds();
// 드래그 중 마우스가 iframe 위로 지나가면 부모 document가 mousemove/mouseup을 못 받아
// 드래그가 안 풀리므로, 드래그하는 동안은 iframe이 마우스 이벤트를 가로채지 않게 한다.
const iframe = document.getElementById('code-claude-iframe');
@@ -1278,15 +1281,16 @@ document.addEventListener('DOMContentLoaded', function() {
const panel = document.getElementById('code-term-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const { min, max } = _bottomPanelHeightBounds();
const onMove = e2 => {
const dy = startY - e2.clientY;
panel.style.height = Math.max(80, Math.min(window.innerHeight*.6, startH+dy)) + 'px';
panel.style.height = Math.max(min, Math.min(max, startH+dy)) + 'px';
const s = codeTermShells.find(s => s.id === activeCodeTermShellId); if (s?.fitAddon) s.fitAddon.fit();
};
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(34,197,94,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});