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 @@
-