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:
+92
-6
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user