diff --git a/web-ui/claude-app.html b/web-ui/claude-app.html
index af97a69..0348d90 100644
--- a/web-ui/claude-app.html
+++ b/web-ui/claude-app.html
@@ -116,6 +116,7 @@
@@ -130,7 +131,12 @@
-
+
+
+
+
+
+
diff --git a/web-ui/claude-app.js b/web-ui/claude-app.js
index 7ac3d73..36ea9ab 100644
--- a/web-ui/claude-app.js
+++ b/web-ui/claude-app.js
@@ -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 = '
';
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');
diff --git a/web-ui/code.html b/web-ui/code.html
index 03bc254..ff6b716 100644
--- a/web-ui/code.html
+++ b/web-ui/code.html
@@ -532,7 +532,7 @@
-
+
diff --git a/web-ui/code.js b/web-ui/code.js
index 07e68bf..b724470 100644
--- a/web-ui/code.js
+++ b/web-ui/code.js
@@ -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);
});
});