-
-
+
+
+
diff --git a/web-ui/code.js b/web-ui/code.js
index aa419e6..3147ecc 100644
--- a/web-ui/code.js
+++ b/web-ui/code.js
@@ -80,12 +80,26 @@ function _histForAPI(hist, maxMsgs = 20, maxBytes = 150 * 1024) {
let codeAiAbort = null;
let _codeAiAutoAccept = false; // when true: skip diff review, write files directly
let _autoAcceptTurn = false; // true during an auto-accept execute phase → triggers completion message
+let _prevTurnDoneTools = []; // legacy: kept for session-clear compatibility
+let _readFileCache = {}; // { bareFilename: fullContent } — persists across turns so model remembers Read files
+let _historySummary = ''; // compressed summary of old turns injected into system prompt
+let _karpathyMode = localStorage.getItem('codeKarpathyMode') === '1'; // Andrej Karpathy coding guidelines
let codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
let activeProjectName = localStorage.getItem('code_active_project') || null;
let codeAiModel = localStorage.getItem('codeAiModel') ?? 'glm-5.1:cloud';
let codeAiTech = localStorage.getItem('codeAiTech') ?? 'auto'; // 'auto' | 'html' | 'python' | 'node'
let codeDiffState = {}; // { fileId: { originalText, modifiedText, hunks, decorationIds, viewZoneIds, resolved, rejected, onReject } }
+// Claude Code-style tool definitions sent directly to Ollama by the client loop
+const CLIENT_TOOL_DEFS = [
+ { type: 'function', function: { name: 'Read', description: '파일 내용을 줄번호와 함께 읽습니다. offset/limit으로 부분 읽기 가능. 컨텍스트에 이미 있는 파일에는 사용하지 마세요.', parameters: { type: 'object', properties: { file: { type: 'string', description: '파일 경로' }, offset: { type: 'number', description: '시작 줄 번호 (1-based, 선택)' }, limit: { type: 'number', description: '읽을 줄 수 (선택)' } }, required: ['file'] } } },
+ { type: 'function', function: { name: 'Write', description: '새 파일을 생성하거나 전체 내용을 교체합니다. 기존 파일 수정에는 Edit을 사용하세요.', parameters: { type: 'object', properties: { file: { type: 'string' }, content: { type: 'string', description: '파일 전체 내용' } }, required: ['file', 'content'] } } },
+ { type: 'function', function: { name: 'Edit', description: '파일 내 정확한 문자열을 교체합니다. old_string은 파일에 정확히 존재해야 합니다 (공백, 들여쓰기, 줄바꿈 포함).', parameters: { type: 'object', properties: { file: { type: 'string' }, old_string: { type: 'string', description: '교체할 기존 텍스트 (정확히 일치해야 함)' }, new_string: { type: 'string', description: '새 텍스트' } }, required: ['file', 'old_string', 'new_string'] } } },
+ { type: 'function', function: { name: 'Glob', description: '파일 패턴으로 파일 목록을 검색합니다. 예: "**/*.py", "src/*.ts"', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '글로브 패턴' } }, required: ['pattern'] } } },
+ { type: 'function', function: { name: 'Grep', description: '파일에서 텍스트를 검색합니다. 결과는 "파일:줄번호: 내용" 형식입니다.', parameters: { type: 'object', properties: { pattern: { type: 'string', description: '검색 패턴 (정규식 가능)' }, path: { type: 'string', description: '검색 범위 파일/디렉토리 (선택, 기본: 전체 프로젝트)' }, case_insensitive: { type: 'boolean', description: '대소문자 무시 (기본: false)' } }, required: ['pattern'] } } },
+ { type: 'function', function: { name: 'Bash', description: '쉘 명령을 실행합니다. companion.py 연결 시에만 작동합니다.', parameters: { type: 'object', properties: { command: { type: 'string', description: '실행할 명령어' } }, required: ['command'] } } },
+];
+
const CODE_EXTS = new Set(['js','ts','jsx','tsx','py','html','htm','css','scss','java','c','cpp','cs','go','rs','php','rb','swift','kt','vue','svelte','json','yaml','yml','sh','sql']);
const isCodeFile = name => CODE_EXTS.has((name.split('.').pop() || '').toLowerCase());
@@ -1905,22 +1919,30 @@ async function _codeAiSendImpl() {
if (!content) continue;
addFile(rel, content);
}
- // 2. Files on disk not yet opened as tabs
- if (localDirHandle && activeProjectName && otherFilesCtx.length < CTX_TOTAL_BYTES) {
+ // 1.5. Files Read by model in prior turns — inject full content (cross-turn context memory)
+ for (const [cachedName, cachedContent] of Object.entries(_readFileCache)) {
+ const currentBare = currentFileName ? _clientBareFileName(currentFileName) : '';
+ if (cachedName === currentBare) continue;
+ if (seen.has(cachedName) || seen.has(activeProjectName + '/' + cachedName)) continue;
+ seen.add(cachedName);
+ addFile(cachedName, cachedContent);
+ }
+ // 2. Files on disk not yet opened as tabs — manifest only (Claude Code 방식: 목록만 주고 필요 시 coder_read_file로 요청)
+ if (localDirHandle) {
try {
const allFiles = await localListFiles(localDirHandle, '');
- const projFiles = allFiles.filter(f => {
- if (!f.startsWith(activeProjectName + '/')) return false;
- const bare = f.slice(activeProjectName.length + 1);
- // seen에는 베어 이름만 있으므로 양쪽 모두 체크해 중복 포함 방지
- return !seen.has(f) && !seen.has(bare) && f !== currentFileName && isCodeFile(f) && !SKIP_NAMES.test(f.split('/').pop() || '');
- });
- for (const fpath of projFiles) {
- if (otherFilesCtx.length >= CTX_TOTAL_BYTES) break;
- const rel = fpath.replace(activeProjectName + '/', '');
- const content = await localReadFile(fpath);
- if (content) addFile(rel, content);
- }
+ const projFiles = activeProjectName
+ ? allFiles.filter(f => f.startsWith(activeProjectName + '/'))
+ : allFiles;
+ const manifest = projFiles
+ .filter(f => {
+ const bare = activeProjectName ? f.slice(activeProjectName.length + 1) : f;
+ return !seen.has(f) && !seen.has(bare) && f !== currentFileName
+ && isCodeFile(f) && !SKIP_NAMES.test(f.split('/').pop() || '');
+ })
+ .map(f => activeProjectName ? f.slice(activeProjectName.length + 1) : f);
+ if (manifest.length)
+ otherFilesCtx += `[로컬 파일 (Read 툴로 요청 가능): ${manifest.join(', ')}]\n\n`;
} catch {}
}
}
@@ -1993,9 +2015,7 @@ async function _codeAiSendImpl() {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
- const historyForAPI = _histForAPI(codeAiHistory);
- // ── 단일 실행 (도구 포함 직접 호출) ─────────────────────────────────────
_codeAiAutoAccept = true;
_autoAcceptTurn = true;
const thinkEl = codeAiAppend('thinking', '생각 중');
@@ -2009,7 +2029,7 @@ async function _codeAiSendImpl() {
}, 1000);
_showLogBubble();
- // Full-file context (model needs exact SEARCH text for patches)
+ // Full-file context (Edit tool needs exact text to match)
let execContextMsg = contextMsg;
if (currentFile && monacoEditor && localDirHandle) {
const fullContent = monacoEditor.getValue();
@@ -2027,460 +2047,62 @@ async function _codeAiSendImpl() {
try {
if (codeAiAbort) codeAiAbort.abort();
codeAiAbort = new AbortController();
- const res = await fetch('/api/chat', {
- method: 'POST',
- headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
- signal: codeAiAbort.signal,
- body: JSON.stringify({ message: execContextMsg, history: historyForAPI, useTools: true, sessionId: codeAiSessionId, codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
- });
- if (res.status === 401) { setAuthToken(''); _codeAiShowRelogin(); return; }
- if (!res.ok) throw new Error('HTTP ' + res.status);
- const reader = res.body.getReader();
- const dec = new TextDecoder();
- let buf = '', reply = '', _preambleBuf = '', _firstToolSeen = false;
- let liveTextEl = null, segReply = '';
- thinkEl.dataset.status = '분석 중';
- thinkEl.textContent = `분석 중 ${_execSecs}s`;
- thinkEl.className = 'code-ai-msg thinking';
+
+ // Build messages for client-owned loop (Claude Code style)
const _statusMove = (text) => { thinkEl.dataset.status = text; thinkEl.textContent = `${text} ${_execSecs}s`; thinkEl.style.display = ''; const msgs = document.getElementById('code-ai-msgs'); if (msgs) msgs.appendChild(thinkEl); codeAiScrollBottom(); };
- // Live streaming state (mirrored into the editor via codeLiveStreamUpdate)
- // Pin the diff target to the file that was active when the user hit send —
- // that's the file we attached as "[현재 파일: …]" context, so any returned
- // diff/edit must land there rather than spawning a new tab.
- const liveState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
- const _localFailedFiles = new Set(); // track files where local apply failed this turn
+ const sysPrompt = _buildCodeSystemPrompt();
+ let messages = [
+ { role: 'system', content: sysPrompt },
+ ..._histForAPI(codeAiHistory),
+ { role: 'user', content: execContextMsg },
+ ];
+ let finalReply = '', _firstToolSeen = false;
+ let liveTextEl = null, segReply = '';
+ _statusMove('분석 중');
+ // Client-owned conversation loop (Claude Code style: Read/Write/Edit/Glob/Grep/Bash)
while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += dec.decode(value, { stream: true });
- const lines = buf.split('\n');
- buf = lines.pop();
- for (const line of lines) {
- if (!line.startsWith('data:')) continue;
- const data = line.slice(5).trim();
- if (data === '[DONE]') continue;
- try {
- const ev = JSON.parse(data);
- let token = null;
- if (ev.type === 'usage') { _codeAiOnUsage(ev); continue; }
- if (ev.type === 'token' && (ev.content || ev.text)) token = ev.content || ev.text;
- else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
- else if (ev.type === 'final' && (ev.text || ev.content)) { if (!reply) reply = ev.text || ev.content; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
- else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
- // Handle tool events for live editor update + action cards
- else if (ev.type === 'tool_call') {
- _firstToolSeen = true;
- // Discard any pre-tool preamble text (planning monologue before first tool call)
- if (liveTextEl) {
- if (!segReply.trim()) liveTextEl.remove();
- else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
- liveTextEl = null;
- }
- segReply = '';
- const toolName = ev.action || ev.name || '';
- const toolArgs = ev.args || {};
- const _fname = toolArgs.filename || toolArgs.name || toolArgs.path || '';
- const _short = _fname ? _fname.split('/').pop() : '';
- const _tIcon = _TOOL_ICONS[toolName] || '🔧';
- _statusMove(`${_tIcon} ${toolName}${_short ? ': ' + _short : ''} ...`);
- _showLogBubble();
- liveState.toolCallCount = (liveState.toolCallCount || 0) + 1;
- _codeAiToolAction(toolName, toolArgs, null, null);
- // ── Client-side file intercept: apply tool changes to local folder ──
- // The server returns synthetic success for local sessions, so we surface
- // any client-side apply failure here — otherwise the model (and user)
- // would think the edit landed when nothing actually changed.
- _codeAiApplyToolLocally(toolName, toolArgs).then(r => {
- if (r && r.ok === false) {
- _codeAiToolAction(toolName, null, `⚠️ 로컬 적용 실패: ${r.reason}`, true);
- const failedFname = toolArgs.filename || toolArgs.name || '';
- if (failedFname) _localFailedFiles.add(_clientBareFileName(failedFname.replace(/^code\//, '')));
- }
- });
- }
- else if (ev.type === 'tool_result') {
- const toolName = ev.action || '';
- const toolResult = ev.result || '';
- const toolError = ev.error || false;
- _codeAiToolAction(toolName, null, toolResult, toolError);
- _statusMove('💭 다음 단계 분석 중...');
- // Refresh file list after successful file-modifying tools.
- // Use setTimeout so the async localWriteFile completes before we scan.
- if (!toolError && ['coder_write_file', 'coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_patch_file', 'coder_move_file', 'coder_delete_file'].includes(toolName)) {
- setTimeout(() => codeSbRefresh(), 200);
- }
- }
- if (token) {
- reply += token;
- if (_firstToolSeen) {
- // Post-tool text: show as chat bubble (summary / follow-up)
- segReply += token;
- if (!liveTextEl) {
- liveTextEl = codeAiAppend('assistant', '');
- liveTextEl.classList.add('streaming');
- _statusMove('💬 응답 작성 중...');
- }
- liveTextEl.textContent = segReply; codeAiScrollBottom();
- try { codeLiveStreamUpdate(segReply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); liveTextEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); }
- }
- // Pre-tool preamble: silently accumulate in reply (for history) but don't show as bubble
- }
- } catch(e) { console.error('[codeAiStream] parse error:', e.message || e, 'data:', data?.slice(0, 80)); }
+ const { reply, toolCalls, usage } = await _codeAiOneRound(messages, (tok) => {
+ segReply += tok;
+ if (!liveTextEl) { liveTextEl = codeAiAppend('assistant', ''); liveTextEl.classList.add('streaming'); }
+ liveTextEl.textContent = segReply; codeAiScrollBottom();
+ });
+ finalReply = reply || segReply;
+ if (usage) _codeAiOnUsage(usage);
+ if (!toolCalls.length) break;
+
+ // Finalize pre-tool text bubble
+ if (liveTextEl) {
+ if (!segReply.trim()) liveTextEl.remove();
+ else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
+ liveTextEl = null; segReply = '';
}
- }
- liveState.inBlock = false;
- codeMonacoThrottleFlush();
+ _firstToolSeen = true;
- // If no tool calls occurred, the buffered preamble IS the reply (pure text response)
- if (!_firstToolSeen && _preambleBuf) { reply = _preambleBuf; thinkEl.textContent = reply; codeAiScrollBottom(); }
-
- // Save streaming state before resetting
- const streamFileId = liveState.fileId;
- const streamOriginalContent = liveState.originalContent;
- const streamIsDiff = liveState.isDiff;
- const streamDiffAccumulated = liveState.diffAccumulated;
- const streamModifiedText = liveState.streamingModifiedText || null;
- liveState.fileId = null;
- liveState.isDiff = false;
- liveState.diffAccumulated = null;
- liveState.streamingModifiedText = null;
-
- // Clear streaming diff decorations
- if (streamFileId) {
- const file = codeFiles.find(f => f.id === streamFileId);
- if (file?._streamingDecos) {
- try { file.model.deltaDecorations(file._streamingDecos, []); } catch {}
- file._streamingDecos = null;
+ // Execute each tool client-side
+ const ollamaToolCalls = [], toolResultContents = [];
+ for (const tc of toolCalls) {
+ const { id, name, args } = tc;
+ const shortFname = (args.file || args.filename || args.pattern || args.command || '').split('/').pop() || '';
+ _statusMove(`${_TOOL_ICONS[name] || '🔧'} ${name}${shortFname ? ': ' + shortFname : ''} ...`);
+ _showLogBubble();
+ _codeAiToolAction(name, args, null, null);
+ const { result, error } = await _executeToolLocal(name, args);
+ _codeAiToolAction(name, null, result, error);
+ if (!error && ['Write', 'Edit'].includes(name)) setTimeout(() => codeSbRefresh(), 200);
+ ollamaToolCalls.push({ id: id || `call_${name}_${Date.now()}`, function: { name, arguments: args } });
+ toolResultContents.push(result);
}
- }
- if (_streamingDiffTimer) { clearTimeout(_streamingDiffTimer); _streamingDiffTimer = null; }
+ _statusMove('💭 다음 단계 분석 중...');
- // After streaming completes: apply diff or show inline diff (Code mode only)
- // First check if this is a multi-file response (model split into CSS/JS/HTML etc.)
- const explicitBlocks = _extractExplicitFileBlocks(reply);
- const hasExplicitBlocks = explicitBlocks.length > 0;
-
- if (hasExplicitBlocks) {
- // Multi-file or mixed diff+new-file response: restore the streamed tab
- // first, then process each block individually.
- if (streamFileId) {
- const streamedFile = codeFiles.find(f => f.id === streamFileId);
- if (streamedFile && streamOriginalContent != null && streamOriginalContent.trim()) {
- if (streamedFile.model) streamedFile.model.setValue(streamOriginalContent);
- else streamedFile.content = streamOriginalContent;
- }
- }
- _fileApprovalQueue = [];
- // Separate diff blocks (modify existing files) from new-file blocks
- const diffBlocks = explicitBlocks.filter(b => b.isDiff);
- const newFileBlocks = explicitBlocks.filter(b => !b.isDiff);
-
- // Process diff blocks: apply each diff to the corresponding file
- for (const { fname, code } of diffBlocks) {
- const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
- // Clean trailing fence
- const cleanDiff = code.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
- // Find the target file — try exact match, then project-prefixed, then open tabs
- let targetFile = codeFiles.find(f => f.name === fullName || f.name === fname);
- if (!targetFile) targetFile = codeFiles.find(f => f.name.endsWith('/' + fname));
- if (!targetFile && localDirHandle) {
- // Try loading from disk
- const diskContent = await localReadFile(fullName) ?? await localReadFile(fname);
- if (diskContent !== null) {
- codeNewFile(fullName, diskContent);
- targetFile = codeFiles.find(f => f.name === fullName || f.name === fname);
- }
- }
- if (targetFile) {
- const oldContent = targetFile.model ? targetFile.model.getValue() : targetFile.content;
- const newContent = applyUnifiedDiff(oldContent, cleanDiff);
- if (oldContent !== newContent) {
- if (targetFile.model) targetFile.model.setValue(oldContent);
- else targetFile.content = oldContent;
- showCodeDiff(targetFile.id, oldContent, newContent);
- }
- }
- // If target file not found, the diff block is silently skipped.
- // The user can see the diff in the chat text and apply manually.
- }
-
- // Process new-file blocks: create tabs or update existing files
- for (const { fname, code } of newFileBlocks) {
- const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
- const existing = codeFiles.find(f => f.name === fullName || f.name === fname);
- if (existing) {
- const oldContent = existing.model ? existing.model.getValue() : existing.content;
- if (existing.model) existing.model.setValue(code);
- else existing.content = code;
- if (oldContent.trim() && oldContent !== code) {
- if (existing.model) existing.model.setValue(oldContent);
- showCodeDiff(existing.id, oldContent, code);
- } else {
- _enqueueFileApproval(existing.id, existing.name);
- }
- } else {
- const id = 'cf_' + Math.random().toString(36).slice(2, 8);
- const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fullName)) : null;
- codeFiles.push({ id, name: fullName, content: code, language: codeDetectLang(fullName), model });
- _enqueueFileApproval(id, fullName);
- }
- }
-
- renderCodeFileTabs();
- if (_fileApprovalQueue.length) _showNextApproval();
- else if (explicitBlocks.length) {
- const firstBlock = explicitBlocks[0];
- const firstFname = (activeProjectName && !firstBlock.fname.includes('/')) ? `${activeProjectName}/${firstBlock.fname}` : firstBlock.fname;
- switchCodeFile(codeFiles.find(f => f.name === firstFname || f.name === firstBlock.fname)?.id || activeCodeFileId);
- }
- } else if (streamFileId) {
- let file = codeFiles.find(f => f.id === streamFileId);
- let resolvedOriginal = streamOriginalContent;
-
- // Re-resolve diff target: streaming may have landed on the active tab even though
- // the diff names a different file. Extract the real filename and switch to it.
- if (file && streamIsDiff && streamDiffAccumulated) {
- const diffFname = codeDeriveName(streamDiffAccumulated, 'diff')
- || streamDiffAccumulated.match(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+?)(?:\t|$)/m)?.[1]?.trim();
- if (diffFname && !file.name.endsWith(diffFname) && file.name !== diffFname) {
- // Active tab doesn't match diff target — find the right file
- const rightFile = codeFiles.find(f => f.name === diffFname || f.name.endsWith('/' + diffFname));
- if (rightFile) {
- // Restore wrongly-targeted tab and switch to correct file
- if (file.model && streamOriginalContent != null) file.model.setValue(streamOriginalContent);
- resolvedOriginal = rightFile.model ? rightFile.model.getValue() : rightFile.content;
- file = rightFile;
- } else if (localDirHandle) {
- // File not open — load from disk, open as new tab
- const fullFname = (activeProjectName && !diffFname.includes('/'))
- ? `${activeProjectName}/${diffFname}` : diffFname;
- const diskContent = await localReadFile(fullFname) ?? await localReadFile(diffFname);
- if (diskContent !== null) {
- if (file.model && streamOriginalContent != null) file.model.setValue(streamOriginalContent);
- codeNewFile(fullFname, diskContent);
- const loaded = codeFiles.find(f => f.name === fullFname || f.name === diffFname);
- if (loaded) { resolvedOriginal = diskContent; file = loaded; }
- }
- }
- }
- }
-
- if (file) {
- if (streamIsDiff && streamDiffAccumulated && resolvedOriginal != null) {
- // Diff block: apply the unified diff to the original content.
- // Strip trailing closing fence that leaks in when the model emits ``` with no newline at end.
- const cleanDiff = streamDiffAccumulated.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
- const newContent = applyUnifiedDiff(resolvedOriginal, cleanDiff);
- if (resolvedOriginal !== newContent) {
- if (file.model) file.model.setValue(resolvedOriginal);
- else file.content = resolvedOriginal;
- showCodeDiff(file.id, resolvedOriginal, newContent);
- }
- } else if (resolvedOriginal != null && resolvedOriginal.trim()) {
- // Full code block: compare original vs streamed modified text
- const newContent = streamModifiedText || (file.model ? file.model.getValue() : file.content);
- if (resolvedOriginal !== newContent) {
- if (file.model) file.model.setValue(resolvedOriginal);
- else file.content = resolvedOriginal;
- showCodeDiff(file.id, resolvedOriginal, newContent);
- }
- } else {
- // New file created by AI — show approval bar
- _showNewFileApproval(file.id, file.name);
- }
- }
+ // Append tool round to messages (Ollama native format)
+ messages.push({ role: 'assistant', content: finalReply || '', tool_calls: ollamaToolCalls });
+ for (const r of toolResultContents) messages.push({ role: 'tool', content: r });
}
- // Auto-retry: if in code mode, had a file context, but the response contains
- // no diff/code blocks (analysis-only), automatically re-prompt for actual code.
- const hasCodeBlock = /```[\w-]*\n[\s\S]*?\n```/.test(reply || '');
- const hasDiffBlock = /```diff\n[\s\S]*?\n```/.test(reply || '');
- const toolsWereUsed = (liveState.toolCallCount || 0) > 0;
- const looksLikeAnalysis = /^(Diagnosis|Plan|Analysis|진단|분석|해결 방법|수정 사항|변경 사항|Steps|Step\s+\d|Incomplete |Fix |The (?:file|code|error|issue))/im.test(reply || '');
- // Skip retry for conversational/question messages — only retry when user clearly wants code changes
- const isQuestion = /[??]$/.test(message.trim()) ||
- /^(어떻게|어떤|뭐가|뭔|왜|언제|어디|누가|무엇|알려|설명|뭐야|뭐지|어디서|어떤게|차이|차이가|뭘|안녕|하이|hi\b|hello\b|hey\b)/i.test(message.trim()) ||
- /^(what|how|why|when|where|who|which|can you explain|tell me|is there|are there|does|do |did )/i.test(message.trim());
- if (!hasCodeBlock && !hasDiffBlock && !toolsWereUsed && looksLikeAnalysis && !isQuestion && codeAiHistory.length > 0) {
- const retryMsg = '위 분석만 하지 말고 coder_patch_file 또는 coder_write_file 도구를 사용해서 실제로 파일을 수정해줘.';
- codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
- codeAiHistory.push({ role: 'user', content: retryMsg });
- thinkEl.classList.add('streaming');
- thinkEl.textContent = '🔄 분석만 감지 — 도구로 파일 수정 재시도 중...';
- const retryAbort = new AbortController();
- try {
- const retryHistory = _histForAPI(codeAiHistory);
- const retryRes = await fetch('/api/chat', {
- method: 'POST',
- headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
- signal: retryAbort.signal,
- body: JSON.stringify({ message: retryMsg, history: retryHistory, useTools: true, sessionId: codeAiSessionId + '_retry', codeAiTech, codeAiLocal: !!localDirHandle }),
- });
- if (retryRes.ok) {
- const retryReader = retryRes.body.getReader();
- const retryDec = new TextDecoder();
- let retryBuf = '', retryReply = '';
- const retryState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
- while (true) {
- const { done: rDone, value: rVal } = await retryReader.read();
- if (rDone) break;
- retryBuf += retryDec.decode(rVal, { stream: true });
- const rLines = retryBuf.split('\n');
- retryBuf = rLines.pop();
- for (const rl of rLines) {
- if (!rl.startsWith('data:')) continue;
- const rd = rl.slice(5).trim();
- if (rd === '[DONE]') continue;
- try {
- const rev = JSON.parse(rd);
- let rToken = null;
- if (rev.type === 'token' && (rev.content || rev.text)) rToken = rev.content || rev.text;
- else if (rev.type === 'text_delta' && rev.delta) rToken = rev.delta;
- else if (rev.type === 'final' && (rev.text || rev.content)) { if (!retryReply) retryReply = rev.text || rev.content; continue; }
- else if (rev.type === 'done' && rev.reply) { if (!retryReply) retryReply = rev.reply; continue; }
- if (rToken) { retryReply += rToken; thinkEl.textContent = retryReply; codeAiScrollBottom(); try { codeLiveStreamUpdate(retryReply, retryState); } catch(e) { console.error('[codeLiveStream] retry error:', e); } }
- } catch(e) {}
- }
- }
- retryState.inBlock = false;
- codeMonacoThrottleFlush();
- // Clear streaming decorations for retry
- if (retryState.fileId) {
- const rf = codeFiles.find(f => f.id === retryState.fileId);
- if (rf?._streamingDecos) { try { rf.model.deltaDecorations(rf._streamingDecos, []); } catch {} rf._streamingDecos = null; }
- }
- if (_streamingDiffTimer) { clearTimeout(_streamingDiffTimer); _streamingDiffTimer = null; }
- // Retry result always wins — prevents original analysis from being re-rendered on top
- if (retryReply) {
- reply = retryReply;
- // Apply retry result (same logic as main flow)
- const retryBlocks = _extractExplicitFileBlocks(retryReply);
- const retryIsMulti = retryBlocks.length > 1;
- if (retryIsMulti) {
- // Restore original if streaming modified it
- const rStreamFile = codeFiles.find(f => f.id === retryState.fileId);
- if (rStreamFile && retryState.originalContent != null && retryState.originalContent.trim()) {
- if (rStreamFile.model) rStreamFile.model.setValue(retryState.originalContent);
- else rStreamFile.content = retryState.originalContent;
- }
- _fileApprovalQueue = [];
- for (const { fname, code, lang } of retryBlocks) {
- const fullName = (activeProjectName && !fname.includes('/')) ? `${activeProjectName}/${fname}` : fname;
- const existing = codeFiles.find(f => f.name === fullName || f.name === fname);
- if (existing) {
- const oldC = existing.model ? existing.model.getValue() : existing.content;
- if (existing.model) existing.model.setValue(code);
- else existing.content = code;
- if (oldC.trim() && oldC !== code) {
- if (existing.model) existing.model.setValue(oldC);
- showCodeDiff(existing.id, oldC, code);
- } else { _enqueueFileApproval(existing.id, existing.name); }
- } else {
- const id = 'cf_' + Math.random().toString(36).slice(2, 8);
- const model = monacoReady ? monaco.editor.createModel(code, codeDetectLang(fullName)) : null;
- codeFiles.push({ id, name: fullName, content: code, language: codeDetectLang(fullName), model });
- _enqueueFileApproval(id, fullName);
- }
- }
- renderCodeFileTabs();
- if (_fileApprovalQueue.length) _showNextApproval();
- } else if (retryState.isDiff && retryState.diffAccumulated) {
- const rFile = codeFiles.find(f => f.id === retryState.fileId);
- if (rFile) {
- const cleanD = retryState.diffAccumulated.replace(/\n`{3}[\w-]*[ \t]*$/, '\n');
- const newC = applyUnifiedDiff(retryState.originalContent, cleanD);
- if (retryState.originalContent !== newC) {
- if (rFile.model) rFile.model.setValue(retryState.originalContent);
- else rFile.content = retryState.originalContent;
- showCodeDiff(rFile.id, retryState.originalContent, newC);
- }
- }
- }
- }
- }
- } catch(retryErr) {
- if (retryErr.name === 'AbortError') {
- // User sent a new message during retry — that's fine, just log
- console.log('[codeAiRetry] aborted — user sent new message');
- } else {
- console.error('[codeAiRetry] failed:', retryErr);
- }
- }
- // Remove the injected retry turn (assistant analysis + user retry prompt);
- // the final `reply` is pushed once at the end. Mirrors the SEARCH-fix path.
- if (codeAiHistory[codeAiHistory.length - 1]?.role === 'user') codeAiHistory.pop();
- if (codeAiHistory[codeAiHistory.length - 1]?.role === 'assistant') codeAiHistory.pop();
- }
-
- // If any coder_patch_file failed locally (SEARCH mismatch), send the actual file
- // content back so the model can retry with correct SEARCH blocks.
- if (_localFailedFiles.size > 0) {
- // Wait a tick so all .then() handlers above have fired
- await new Promise(r => setTimeout(r, 50));
- const failedParts = [];
- for (const fname of _localFailedFiles) {
- const actual = await _codeAiReadLocalFile(fname);
- if (actual != null) {
- failedParts.push(`**${fname}** 현재 내용:\n\`\`\`\n${actual}\n\`\`\``);
- }
- }
- if (failedParts.length) {
- const fixMsg = `coder_patch_file SEARCH 블록이 실제 파일 내용과 일치하지 않았습니다. 아래 현재 내용을 기준으로 다시 수정해줘:\n\n${failedParts.join('\n\n')}`;
- codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
- codeAiHistory.push({ role: 'user', content: fixMsg });
- thinkEl.classList.add('streaming');
- thinkEl.textContent = '🔄 SEARCH 불일치 — 실제 파일로 재시도 중...';
- try {
- const fixRes = await fetch('/api/chat', {
- method: 'POST',
- headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }),
- signal: codeAiAbort?.signal,
- body: JSON.stringify({ message: fixMsg, history: _histForAPI(codeAiHistory), useTools: true, sessionId: codeAiSessionId + '_fix', codeAiTech, codeAiLocal: !!localDirHandle, ...(codeAiModel ? { model: codeAiModel } : {}) }),
- });
- if (fixRes.ok) {
- const fixReader = fixRes.body.getReader();
- const fixDec = new TextDecoder();
- let fixBuf = '', fixReply = '';
- const fixState = { inBlock: false, lang: '', fileId: null, originalContent: null, isDiff: false, diffAccumulated: null, streamingModifiedText: null, targetFileId: activeCodeFileId };
- while (true) {
- const { done, value } = await fixReader.read();
- if (done) break;
- fixBuf += fixDec.decode(value, { stream: true });
- const fixLines = fixBuf.split('\n'); fixBuf = fixLines.pop();
- for (const ln of fixLines) {
- if (!ln.startsWith('data:')) continue;
- const d = ln.slice(5).trim();
- if (d === '[DONE]') continue;
- try {
- const ev = JSON.parse(d);
- let tok = null;
- if (ev.type === 'token' || ev.type === 'text_delta') tok = ev.content || ev.text || ev.delta || null;
- else if (ev.type === 'final' || ev.type === 'done') { if (!fixReply) fixReply = ev.text || ev.reply || ev.content || ''; }
- else if (ev.type === 'tool_call') {
- _codeAiToolAction(ev.action || ev.name || '', ev.args || {}, null, null);
- _codeAiApplyToolLocally(ev.action || ev.name || '', ev.args || {}).then(r2 => {
- if (r2 && r2.ok === false) _codeAiToolAction(ev.action || ev.name || '', null, `⚠️ 로컬 적용 실패: ${r2.reason}`, true);
- });
- } else if (ev.type === 'tool_result') { _codeAiToolAction(ev.action || '', null, ev.result || '', ev.error || false); }
- if (tok) { fixReply += tok; thinkEl.textContent = fixReply; codeAiScrollBottom(); try { codeLiveStreamUpdate(fixReply, fixState); } catch {} }
- } catch {}
- }
- }
- codeMonacoThrottleFlush();
- reply = fixReply || reply;
- thinkEl.textContent = reply;
- }
- } catch(fixErr) {
- if (fixErr.name !== 'AbortError') console.error('[codeAiFix] failed:', fixErr);
- }
- // Remove the injected retry turn from history (will be replaced below)
- if (codeAiHistory[codeAiHistory.length - 1]?.role === 'user') codeAiHistory.pop();
- if (codeAiHistory[codeAiHistory.length - 1]?.role === 'assistant') codeAiHistory.pop();
- }
- }
-
- codeAiHistory.push({ role: 'assistant', content: _histEntry(reply) || '(응답 없음)' });
+ // Finalize history and UI
+ codeAiHistory.push({ role: 'assistant', content: _histEntry(finalReply) || '(응답 없음)' });
+ _compressHistory(); // fire-and-forget: runs in background, saves after completion
_autoSaveChatSession();
if (activeProjectName) saveProjectSession(activeProjectName);
try { updateSessionPanel(); } catch (_) {}
@@ -2495,8 +2117,8 @@ async function _codeAiSendImpl() {
} else {
_hideLogBubble();
thinkEl.className = 'code-ai-msg assistant';
- if (!reply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
- else { codeAiRenderMarkdown(thinkEl, reply); thinkEl.style.display = ''; }
+ if (!finalReply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
+ else { codeAiRenderMarkdown(thinkEl, finalReply); thinkEl.style.display = ''; }
}
} catch(e) {
_hideLogBubble();
@@ -2507,10 +2129,9 @@ async function _codeAiSendImpl() {
? '요청이 취소되었습니다. 다시 시도해주세요.'
: (e.message || e);
thinkEl.textContent = '오류: ' + errMsg;
- // On error no diff will ever be queued, so clear the auto-accept flag —
- // otherwise it leaks into the next turn and fires a stale "✅ 완료".
_autoAcceptTurn = false;
} finally {
+ clearInterval(_execTimer);
_codeAiAutoAccept = false;
}
}
@@ -2553,18 +2174,12 @@ function _hideLogBubble() {
// ── Tool action cards for Cline-style visibility ──────────────────────────
const _TOOL_ICONS = {
- coder_read_file: '📖', coder_list_files: '📂', search_files: '🔍',
- coder_patch_file: '✏️', coder_write_file: '📄', coder_overwrite_lines: '✏️',
- coder_insert_lines: '➕', coder_delete_lines: '🗑️', coder_str_replace: '🔄',
- coder_delete_file: '🗑️', coder_move_file: '📦', shell: '▶️', python_eval: '🐍', web_search: '🌐',
+ Read: '📖', Write: '📄', Edit: '✏️', Glob: '🔍', Grep: '🔎', Bash: '▶️',
};
const _TOOL_COLORS = {
- coder_read_file: '#3b82f6', coder_list_files: '#3b82f6', search_files: '#3b82f6',
- coder_patch_file: '#22c55e', coder_write_file: '#22c55e', coder_overwrite_lines: '#22c55e',
- coder_insert_lines: '#22c55e', coder_delete_lines: '#f87171', coder_str_replace: '#22c55e',
- coder_delete_file: '#f87171', coder_move_file: '#f59e0b', shell: '#f59e0b', python_eval: '#f59e0b', web_search: '#8b5cf6',
+ Read: '#3b82f6', Write: '#22c55e', Edit: '#f59e0b', Glob: '#3b82f6', Grep: '#8b5cf6', Bash: '#f59e0b',
};
-const _FILE_TOOLS = new Set(['coder_read_file', 'coder_write_file', 'coder_patch_file', 'coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_delete_file', 'coder_move_file']);
+const _FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Glob', 'Grep']);
function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
const msgs = document.getElementById('code-ai-messages');
@@ -2577,12 +2192,19 @@ function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
const card = document.createElement('div');
card.className = 'code-ai-tool-card';
card.dataset.tool = toolName;
- const fname = toolArgs.filename || toolArgs.name || '';
+ const _looksLikeFilename = (s) => s && typeof s === 'string' && s.length < 200 && !s.includes('\n') && !s.includes('\t');
+ const fname = (_looksLikeFilename(toolArgs.file) ? toolArgs.file : _looksLikeFilename(toolArgs.filename) ? toolArgs.filename : toolArgs.name || '');
const shortName = fname.replace(/^code\//, '').split('/').pop() || '';
const isFile = _FILE_TOOLS.has(toolName);
- const detail = toolName === 'coder_move_file'
- ? escHtml(`${toolArgs.source || ''} → ${toolArgs.destination || ''}`)
- : isFile ? escHtml(shortName || fname) : (toolArgs.command || toolArgs.query || toolArgs.code || '').slice(0, 60);
+ let detail;
+ if (toolName === 'Write') {
+ const lines = toolArgs.content ? toolArgs.content.split('\n').length : 0;
+ detail = escHtml((shortName || fname) + (lines ? ` (${lines}줄)` : ''));
+ } else if (isFile) {
+ detail = escHtml(shortName || toolArgs.pattern || fname);
+ } else {
+ detail = escHtml((toolArgs.command || toolArgs.query || toolArgs.code || '').slice(0, 60));
+ }
card.innerHTML = `
${icon} ${escHtml(toolName)} ${detail}`;
msgs.appendChild(card);
codeAiScrollBottom();
@@ -2593,8 +2215,7 @@ function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
if (card && !card.dataset.done) {
card.dataset.done = '1';
const resultIcon = toolError ? '❌' : '✅';
- const noDetail = !toolError && (toolName === 'coder_move_file' || toolName === 'coder_delete_file');
- const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : noDetail ? '' : escHtml((toolResult || '').slice(0, 80));
+ const resultText = toolError ? escHtml((toolResult || 'Error').slice(0, 120)) : escHtml((toolResult || '').slice(0, 80));
const resultEl = document.createElement('span');
resultEl.className = 'code-ai-tool-result';
resultEl.innerHTML = resultText ? `${resultIcon} ${resultText}` : resultIcon;
@@ -2605,134 +2226,261 @@ function _codeAiToolAction(toolName, toolArgs, toolResult, toolError) {
}
}
-// ── Client-side file intercept: apply tool changes to local folder ──────────
-// When the AI uses file-modifying tools, we also apply them locally so
-// the user's local folder stays in sync with the editor.
-// Applies a model tool_call to the local folder / editor. The server short-circuits
-// file tools for local sessions (synthetic success), so the CLIENT is the source of
-// truth — meaning a silent failure here would let the model believe an edit landed
-// when nothing changed. Returns { ok, reason } so the caller can warn the user.
-async function _codeAiApplyToolLocally(toolName, args) {
- if (!args) return { ok: true };
- // Normalise to bare filename: strip code/ prefix, then strip client project prefix.
- // Model always sends bare names; _clientProjectPath resolves to the disk path.
- let rawFname = (args.filename || args.name || '').replace(/^code\//, '');
- // Auto-inject filename from active tab when model omits it (happens ~40% of the time)
- if (!rawFname) {
- const activeFile = codeFiles.find(f => f.id === activeCodeFileId);
- if (activeFile) rawFname = _clientBareFileName(activeFile.name);
- }
- const fname = _clientBareFileName(rawFname);
- const diskPath = _clientProjectPath(fname);
- const _FILE_TOOLS = new Set(['coder_write_file', 'coder_patch_file', 'coder_overwrite_lines', 'coder_insert_lines', 'coder_delete_lines', 'coder_str_replace', 'coder_delete_file', 'coder_move_file']);
- if (!_FILE_TOOLS.has(toolName)) return { ok: true };
- if (!fname && toolName !== 'coder_move_file') return { ok: false, reason: '파일명이 없습니다' };
+// ── Client-owned tool execution (Read/Write/Edit/Glob/Grep/Bash) ──────────────
+function _globMatch(pattern, filepath) {
+ // Convert glob pattern to regex: ** → any path, * → non-slash chars, ? → one non-slash
+ const re = pattern
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
+ .replace(/\*\*/g, '__DOUBLESTAR__')
+ .replace(/\*/g, '[^/]*')
+ .replace(/\?/g, '[^/]')
+ .replace(/__DOUBLESTAR__/g, '.*');
+ return new RegExp('^' + re + '$').test(filepath);
+}
+
+async function _executeToolLocal(name, args) {
try {
- switch (toolName) {
- case 'coder_write_file': {
- const content = args.content || '';
- if (localDirHandle) await localWriteFile(diskPath, content);
- codeEditorWriteFile(fname, content);
- return { ok: true };
+ switch (name) {
+ case 'Read': {
+ const fname = args.file || '';
+ if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
+ const raw = await localReadFile(_clientProjectPath(_clientBareFileName(fname)))
+ ?? await localReadFile(_clientBareFileName(fname));
+ if (raw === null) {
+ const tab = codeFiles.find(f => f.name === fname || f.name.endsWith('/' + fname));
+ if (tab) {
+ const tc = tab.model ? tab.model.getValue() : tab.content;
+ _readFileCache[_clientBareFileName(fname)] = tc;
+ const lines = tc.split('\n');
+ const offset = args.offset ? Math.max(0, (args.offset|0) - 1) : 0;
+ const limit = args.limit ? (args.limit|0) : lines.length;
+ return { result: lines.slice(offset, offset + limit).map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false };
+ }
+ return { result: `오류: ${fname} 파일을 찾을 수 없습니다`, error: true };
+ }
+ _readFileCache[_clientBareFileName(fname)] = raw;
+ const lines = raw.split('\n');
+ const offset = args.offset ? Math.max(0, (args.offset|0) - 1) : 0;
+ const limit = args.limit ? (args.limit|0) : lines.length;
+ const sliced = lines.slice(offset, offset + limit);
+ return { result: sliced.map((l, i) => `${offset + i + 1}: ${l}`).join('\n'), error: false };
}
- case 'coder_patch_file': {
- // Read current content from editor or client-local disk
- const existing = await _codeAiReadLocalFile(fname);
- if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
- const newContent = _applySearchReplace(existing, args.edits || '');
- if (newContent === null) return { ok: false, reason: `${fname}: SEARCH 블록이 파일 내용과 일치하지 않습니다` };
+ case 'Write': {
+ const fname = args.file || '';
+ const newContent = args.content ?? '';
+ if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
+ const bare = _clientBareFileName(fname);
+ const diskPath = _clientProjectPath(bare);
if (localDirHandle) await localWriteFile(diskPath, newContent);
- codeEditorWriteFile(fname, newContent);
- return { ok: true };
+ codeEditorWriteFile(bare, newContent);
+ _readFileCache[bare] = newContent;
+ return { result: `${bare} 저장 완료 (${newContent.split('\n').length}줄)`, error: false };
}
- case 'coder_overwrite_lines': {
- const startLine = parseInt(args.start_line) || 1;
- const endLine = parseInt(args.end_line) || startLine;
- const newContent = args.new_content || '';
- const existing = await _codeAiReadLocalFile(fname);
- if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
- const lines = existing.split('\n');
- const before = lines.slice(0, startLine - 1).join('\n');
- const after = lines.slice(endLine).join('\n');
- const result = endLine < lines.length ? before + '\n' + newContent + '\n' + after : before + '\n' + newContent;
- if (localDirHandle) await localWriteFile(diskPath, result);
- codeEditorWriteFile(fname, result);
- return { ok: true };
- }
- case 'coder_insert_lines': {
- const afterLine = parseInt(args.after_line) || 0;
- const content = args.content || '';
- const existing = await _codeAiReadLocalFile(fname);
- if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
- const lines = existing.split('\n');
- const insertIdx = afterLine >= 0 ? afterLine : 0;
- lines.splice(insertIdx, 0, ...content.split('\n'));
- const result = lines.join('\n');
- if (localDirHandle) await localWriteFile(diskPath, result);
- codeEditorWriteFile(fname, result);
- return { ok: true };
- }
- case 'coder_delete_lines': {
- const startLine = parseInt(args.start_line) || 1;
- const endLine = parseInt(args.end_line) || startLine;
- const existing = await _codeAiReadLocalFile(fname);
- if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
- const lines = existing.split('\n');
- lines.splice(startLine - 1, endLine - startLine + 1);
- const result = lines.join('\n');
- if (localDirHandle) await localWriteFile(diskPath, result);
- codeEditorWriteFile(fname, result);
- return { ok: true };
- }
- case 'coder_str_replace': {
- const find = args.find || '';
- const replace = args.replace || '';
- const existing = await _codeAiReadLocalFile(fname);
- if (existing === null) return { ok: false, reason: `${fname}을(를) 로컬에서 찾을 수 없습니다` };
- if (!existing.includes(find)) return { ok: false, reason: `${fname}: "${find.slice(0, 30)}…" 텍스트를 찾을 수 없습니다` };
- const result = existing.replace(find, replace);
- if (localDirHandle) await localWriteFile(diskPath, result);
- codeEditorWriteFile(fname, result);
- return { ok: true };
- }
- case 'coder_delete_file': {
- if (localDirHandle) await localDeleteFile(diskPath);
- const fileId = codeFiles.find(f => f.name === fname)?.id;
- if (fileId) { codeFiles.splice(codeFiles.findIndex(f => f.id === fileId), 1); renderCodeFileTabs(); }
- return { ok: true };
- }
- case 'coder_move_file': {
- const srcRaw = _clientBareFileName((args.source || args.from || '').replace(/^code\//, ''));
- const dstRaw = _clientBareFileName((args.destination || args.to || '').replace(/^code\//, ''));
- const srcDisk = _clientProjectPath(srcRaw);
- const dstDisk = _clientProjectPath(dstRaw);
- if (!srcRaw || !dstRaw) return { ok: false, reason: 'source/destination가 없습니다' };
- const content = await _codeAiReadLocalFile(srcRaw);
- if (content === null) return { ok: false, reason: `${srcRaw}을(를) 찾을 수 없습니다 (탭에 파일이 열려있어야 합니다)` };
- if (localDirHandle) {
- const writeOk = await localWriteFile(dstDisk, content);
- if (!writeOk) return { ok: false, reason: `${dstRaw} 쓰기 실패 (폴더 권한 확인)` };
+ case 'Edit': {
+ const fname = args.file || '';
+ const oldStr = args.old_string ?? '';
+ const newStr = args.new_string ?? '';
+ if (!fname) return { result: '오류: file 인수가 필요합니다', error: true };
+ const bare = _clientBareFileName(fname);
+ const existing = await _codeAiReadLocalFile(bare);
+ if (existing === null) return { result: `오류: ${bare}을(를) 찾을 수 없습니다`, error: true };
+ if (!existing.includes(oldStr)) {
+ const preview = oldStr.slice(0, 60).replace(/\n/g, '↵');
+ return { result: `오류: old_string을 파일에서 찾을 수 없습니다\n검색: "${preview}…"`, error: true };
}
- codeEditorWriteFile(dstRaw, content);
- if (localDirHandle) {
- const delOk = await localDeleteFile(srcDisk);
- if (!delOk) console.warn('[coder_move_file] 소스 파일 삭제 실패:', srcDisk);
- }
- const srcId = codeFiles.find(f => f.name === srcRaw)?.id;
- if (srcId) { codeFiles.splice(codeFiles.findIndex(f => f.id === srcId), 1); renderCodeFileTabs(); }
- // Refresh sidebar after disk I/O is complete
- codeSbRefresh();
- return { ok: true };
+ const updated = existing.replace(oldStr, newStr);
+ const diskPath = _clientProjectPath(bare);
+ if (localDirHandle) await localWriteFile(diskPath, updated);
+ codeEditorWriteFile(bare, updated);
+ _readFileCache[bare] = updated;
+ return { result: `${bare} 수정 완료`, error: false };
}
+ case 'Glob': {
+ const pattern = args.pattern || '**/*';
+ if (!localDirHandle) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
+ const allFiles = await localListFiles(localDirHandle, '');
+ const matches = allFiles.filter(f => _globMatch(pattern, f));
+ return { result: matches.length ? matches.join('\n') : '(매칭 파일 없음)', error: false };
+ }
+ case 'Grep': {
+ const pattern = args.pattern || '';
+ const searchPath = args.path || null;
+ const ci = !!args.case_insensitive;
+ if (!localDirHandle && !searchPath) return { result: '(로컬 폴더가 연결되지 않았습니다)', error: false };
+ let files;
+ if (searchPath) {
+ files = [_clientBareFileName(searchPath)];
+ } else {
+ files = await localListFiles(localDirHandle, '');
+ const CODE_EXTS_RE = /\.(js|ts|jsx|tsx|py|java|c|cpp|h|cs|go|rs|php|rb|swift|kt|html|css|json|yaml|yml|xml|md|sh|sql)$/i;
+ files = files.filter(f => CODE_EXTS_RE.test(f));
+ }
+ let regex;
+ try { regex = new RegExp(pattern, ci ? 'gi' : 'g'); } catch { regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'gi' : 'g'); }
+ const results = [];
+ for (const f of files) {
+ if (results.length >= 200) break;
+ const fc = await localReadFile(_clientProjectPath(f)) ?? await localReadFile(f);
+ if (!fc) continue;
+ fc.split('\n').forEach((line, idx) => {
+ if (regex.test(line)) results.push(`${f}:${idx + 1}: ${line.trim()}`);
+ regex.lastIndex = 0;
+ });
+ }
+ return { result: results.slice(0, 200).join('\n') || '(결과 없음)', error: false };
+ }
+ case 'Bash': {
+ const cmd = args.command || '';
+ if (!cmd) return { result: '오류: command 인수가 필요합니다', error: true };
+ if (!companionWs || companionWs.readyState !== WebSocket.OPEN) {
+ return { result: '⚠️ companion.py가 연결되지 않았습니다. Bash 명령을 실행하려면 companion.py를 먼저 실행하세요.', error: true };
+ }
+ return await new Promise((resolve) => {
+ const reqId = 'bash_' + Date.now();
+ const timeout = setTimeout(() => resolve({ result: '오류: 명령 실행 시간 초과 (30s)', error: true }), 30000);
+ const handler = (evt) => {
+ try {
+ const d = JSON.parse(evt.data);
+ if (d.reqId === reqId) {
+ clearTimeout(timeout);
+ companionWs.removeEventListener('message', handler);
+ resolve({ result: d.output ?? d.result ?? '', error: !!d.error });
+ }
+ } catch {}
+ };
+ companionWs.addEventListener('message', handler);
+ companionWs.send(JSON.stringify({ type: 'exec', command: cmd, reqId }));
+ });
+ }
+ default:
+ return { result: `알 수 없는 툴: ${name}`, error: true };
}
- return { ok: true };
} catch (e) {
- console.warn('[codeAiApplyToolLocally] error:', e);
- return { ok: false, reason: (e && e.message) || String(e) };
+ return { result: `오류: ${e?.message || e}`, error: true };
}
}
+async function _compressHistory() {
+ const COMPRESS_AT = 24;
+ const KEEP_RECENT = 8;
+ if (codeAiHistory.length <= COMPRESS_AT) return;
+
+ const compressEntries = codeAiHistory.slice(0, codeAiHistory.length - KEEP_RECENT);
+ const keepEntries = codeAiHistory.slice(codeAiHistory.length - KEEP_RECENT);
+
+ const prevSummary = _historySummary ? `[이전 요약]\n${_historySummary}\n\n` : '';
+ const histText = compressEntries.map(m => {
+ const role = m.role === 'user' ? '사용자' : 'AI';
+ return `${role}: ${(m.content || '').slice(0, 600)}`;
+ }).join('\n\n');
+
+ const summaryMessages = [
+ { role: 'system', content: '당신은 코딩 세션 요약 전문가입니다.' },
+ { role: 'user', content: `${prevSummary}다음 코딩 대화를 요약하세요. 어떤 파일을 다뤘는지, 어떤 수정을 했는지, 해결된 문제와 현재 상태를 5~8문장으로 간결하게:\n\n${histText}` },
+ ];
+
+ try {
+ const resp = await fetch('/api/code/round', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ messages: summaryMessages, tools: [], sessionId: codeAiSessionId }),
+ });
+ if (!resp.ok) return;
+ const reader = resp.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '', summary = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += dec.decode(value, { stream: true });
+ const lines = buf.split('\n'); buf = lines.pop() ?? '';
+ for (const line of lines) {
+ if (!line.startsWith('data:')) continue;
+ try {
+ const ev = JSON.parse(line.slice(5).trim());
+ if (ev.type === 'done') summary = ev.reply || summary;
+ else if (ev.type === 'token') summary += ev.text;
+ } catch {}
+ }
+ }
+ if (!summary.trim()) return;
+ _historySummary = summary.trim();
+ codeAiHistory = keepEntries;
+ try { _autoSaveChatSession(); if (activeProjectName) saveProjectSession(activeProjectName); } catch {}
+ } catch {}
+}
+
+async function _codeAiOneRound(messages, onToken) {
+ const resp = await fetch('/api/code/round', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ signal: codeAiAbort?.signal,
+ body: JSON.stringify({
+ messages,
+ tools: CLIENT_TOOL_DEFS,
+ sessionId: codeAiSessionId,
+ ...(codeAiModel ? { model: codeAiModel } : {}),
+ }),
+ });
+ if (!resp.ok) throw new Error(`/api/code/round ${resp.status}: ${await resp.text()}`);
+ const reader = resp.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '', reply = '', usage = null, toolCalls = [];
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buf += dec.decode(value, { stream: true });
+ const lines = buf.split('\n');
+ buf = lines.pop() ?? '';
+ for (const line of lines) {
+ if (!line.startsWith('data:')) continue;
+ const raw = line.slice(5).trim();
+ if (raw === '[DONE]') continue;
+ try {
+ const ev = JSON.parse(raw);
+ if (ev.type === 'token' && ev.text) { onToken(ev.text); reply += ev.text; }
+ else if (ev.type === 'usage') { usage = ev; }
+ else if (ev.type === 'done') {
+ reply = ev.reply || reply;
+ toolCalls = ev.toolCalls || [];
+ usage = ev.usage || usage;
+ } else if (ev.type === 'error') { throw new Error(ev.message || 'Server error'); }
+ } catch (parseErr) {
+ if (parseErr.message?.includes('Server error') || parseErr.message?.includes('/api/code/round')) throw parseErr;
+ }
+ }
+ }
+ return { reply, toolCalls, usage };
+}
+
+function _buildCodeSystemPrompt() {
+ const localNote = localDirHandle ? `로컬 폴더 "${localDirHandle.name}"이 연결되어 있습니다.` : '로컬 폴더가 연결되지 않았습니다.';
+ const summaryBlock = _historySummary ? `\n[이전 대화 요약]\n${_historySummary}\n` : '';
+ const karpathyBlock = _karpathyMode ? `
+## Karpathy 코딩 가이드라인 (필수)
+1. Think Before Coding — 가정을 명시하고, 모호하면 질문하세요. 여러 해석 가능 시 제시 후 선택하세요.
+2. Simplicity First — 최소한의 코드로 문제를 해결하세요. 요청받지 않은 기능, 단일 사용 추상화, 불필요한 에러 핸들링을 넣지 마세요.
+3. Surgical Changes — 사용자 요청과 직접 관련 없는 코드는 건드리지 마세요. 기존 스타일을 그대로 따르세요. 내 변경으로 인한 고아 코드만 제거하세요.
+4. Goal-Driven Execution — 작업을 검증 가능한 목표로 변환하세요. "버그 수정" → "재현 테스트 작성 → 통과".
+` : '';
+ return `당신은 전문 코드 어시스턴트입니다. ${localNote}${summaryBlock}${karpathyBlock}
+
+## 툴 사용 지침
+- 파일을 수정하기 전에 반드시 Read로 현재 내용을 확인하세요.
+- **Edit**: 1~2곳의 정밀 교체에만 사용. old_string은 파일 내용과 정확히 일치해야 합니다 (공백·들여쓰기 포함).
+- **Write**: 수정이 3곳 이상이거나 전체 리팩토링·일괄 변경 시 사용. Read로 읽은 내용을 수정한 뒤 Write로 교체하세요. Edit 반복보다 훨씬 빠릅니다.
+- Glob으로 파일 목록 검색, Grep으로 코드 내용 검색이 가능합니다.
+- Bash는 companion.py 연결 시 터미널 명령을 실행합니다.
+
+## 응답 원칙
+- 행동 중심으로 응답하세요: 설명만 하지 말고 실제로 파일을 수정하세요.
+- 변경 사항은 간결하게 설명하고, 코드 블록으로 전체 내용을 반복하지 마세요.
+- 여러 파일을 수정해야 한다면 순서대로 각각 처리하세요.`;
+}
+
+
+
// Read a file from editor tabs (preferred) or local folder
async function _codeAiReadLocalFile(fname) {
// Normalise: strip code/ prefix, then strip project prefix → bare name
@@ -3879,7 +3627,7 @@ async function deleteActiveProject() {
renderCodeFileTabs();
activeProjectName = null;
localStorage.setItem('code_active_project', '');
- codeAiHistory = [];
+ codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
_codeAiLastUsage = null;
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
updateLocalModeUI();
@@ -3898,11 +3646,12 @@ function switchProject(name) {
const savedId = localStorage.getItem('code_proj_session_' + activeProjectName);
const savedHist = localStorage.getItem('code_proj_history_' + activeProjectName);
codeAiSessionId = savedId || ('code_ai_' + Math.random().toString(36).slice(2, 10));
- try { codeAiHistory = savedHist ? JSON.parse(savedHist) : []; } catch { codeAiHistory = []; }
+ try { codeAiHistory = savedHist ? JSON.parse(savedHist) : []; } catch { codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = ''; }
+ _readFileCache = {}; _historySummary = '';
saveProjectSession(activeProjectName);
} else {
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
- codeAiHistory = [];
+ codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
}
updateLocalModeUI();
updateSessionPanel();
@@ -3968,7 +3717,7 @@ function _autoSaveChatSession() {
function _backToSessionList() {
_autoSaveChatSession();
- codeAiHistory = [];
+ codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
_codeAiLastUsage = null;
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
@@ -4040,7 +3789,7 @@ function _deleteSession(id, event) {
const sess = list.find(s => s.id === id);
// If deleting current session, clear it
if (sess && sess.sessionId === codeAiSessionId) {
- codeAiHistory = [];
+ codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
@@ -4074,7 +3823,7 @@ function _deleteAllSessions(event) {
_saveSessions([]);
// Also clear project-specific session storage
Object.keys(localStorage).filter(k => k.startsWith('code_proj_session_') || k.startsWith('code_proj_history_')).forEach(k => localStorage.removeItem(k));
- codeAiHistory = [];
+ codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
codeAiSessionId = 'code_ai_' + Math.random().toString(36).slice(2, 10);
if (activeProjectName) saveProjectSession(activeProjectName);
const msgs = document.getElementById('code-ai-messages');
@@ -4091,7 +3840,7 @@ async function codeAiClear() {
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ sessionId: codeAiSessionId }),
}).catch(() => {});
- codeAiHistory = [];
+ codeAiHistory = []; _prevTurnDoneTools = []; _readFileCache = {}; _historySummary = '';
// In client-local mode: ask for a project name and create the folder
if (localDirHandle) {
@@ -4573,6 +4322,13 @@ function csbFontWeight(val) {
if (monacoEditor) monacoEditor.updateOptions({ fontWeight: val });
}
+function csbKarpathy(checked) {
+ _karpathyMode = !!checked;
+ localStorage.setItem('codeKarpathyMode', _karpathyMode ? '1' : '0');
+ const btn = document.getElementById('code-opt-karpathy');
+ if (btn) btn.classList.toggle('active', _karpathyMode);
+}
+
let _hideBlankLines = false;
function csbHideBlankLines(checked) {
_hideBlankLines = !!checked;
@@ -4821,8 +4577,11 @@ function csbRestoreSettings() {
const fwEl = document.getElementById('csb-fontweight'); if (fwEl) fwEl.value = fw;
_hideBlankLines = hb === '1';
_blockFlipFlop = bff === '1';
+ const kp = localStorage.getItem('codeKarpathyMode');
+ _karpathyMode = kp === '1';
const hbBtn = document.getElementById('code-opt-hideblank'); if (hbBtn) hbBtn.classList.toggle('active', _hideBlankLines);
const bffBtn = document.getElementById('code-opt-blockff'); if (bffBtn) bffBtn.classList.toggle('active', _blockFlipFlop);
+ const kpBtn = document.getElementById('code-opt-karpathy'); if (kpBtn) kpBtn.classList.toggle('active', _karpathyMode);
if (monacoEditor) monacoEditor.updateOptions({
fontSize: fs ? parseInt(fs) : 14,
wordWrap: ww === '0' ? 'off' : 'on',
@@ -4958,10 +4717,9 @@ function setupCodeEditorDrop() {
function setupCodeVResize() {
const handle = document.getElementById('code-vresize');
+ const codeView = document.getElementById('code-view');
const aiPanel = document.getElementById('code-ai-panel');
- if (!handle || !aiPanel) return;
- const modelCard = document.getElementById('code-ai-model-card');
- const syncModelCard = w => { if (modelCard) modelCard.style.width = w + 'px'; };
+ if (!handle || !aiPanel || !codeView) return;
let startX = 0, startW = 0;
handle.addEventListener('mousedown', function(e) {
startX = e.clientX; startW = aiPanel.offsetWidth;
@@ -4972,8 +4730,7 @@ function setupCodeVResize() {
function onVMove(e) {
const dx = startX - e.clientX;
const newW = Math.max(220, Math.min(window.innerWidth * 0.6, startW + dx));
- aiPanel.style.width = newW + 'px';
- syncModelCard(newW);
+ codeView.style.setProperty('--ai-panel-w', newW + 'px');
if (monacoEditor) monacoEditor.layout();
}
function onVUp() {
@@ -4997,7 +4754,6 @@ function setupCodeVResize() {
try { await restoreLocalDirHandle(); } catch (e) {}
try { updateSessionPanel(); } catch (e) {}
try { _fetchModelCtxMax(codeAiModel); } catch (e) {}
- try { const p = document.getElementById('code-ai-panel'), c = document.getElementById('code-ai-model-card'); if (p && c) c.style.width = p.offsetWidth + 'px'; } catch (e) {}
try { codeSbRefresh(); } catch (e) {}
try { companionConnect(); } catch (e) {}
// 히스토리 없으면 세션 목록 표시
diff --git a/web-ui/styles.css b/web-ui/styles.css
index ae25754..879520c 100644
--- a/web-ui/styles.css
+++ b/web-ui/styles.css
@@ -2358,12 +2358,18 @@
/* ── Code Editor View ────────────────────────────────────────── */
#code-view {
display: none;
- flex-direction: column;
height: 100%;
background: var(--bg);
overflow: hidden;
}
-#code-view.active { display: flex; }
+#code-view.active {
+ display: grid;
+ grid-template-columns: 1fr 5px var(--ai-panel-w, 360px);
+ grid-template-rows: 1fr auto;
+ grid-template-areas:
+ "editor vresize ai"
+ "term term term";
+}
.code-tabs-bar {
display: flex;
align-items: flex-start;
@@ -2417,22 +2423,33 @@
.code-tool-btn.btn-save { background:#0d1f2e; border-color:#1a4a6b; color:#60a5fa; }
.code-tool-btn.primary { background:#166534; border-color:#22c55e; color:#fff; }
.code-tool-btn.primary.busy { background:#7f1d1d; border-color:#ef4444; }
-.code-body {
- display: flex; flex: 1; min-height: 0; overflow: hidden;
+.code-editor-col {
+ grid-area: editor;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
}
#code-editor-container {
flex: 1; min-width: 0; position: relative; background: #1e1e1e;
}
.code-vresize {
+ grid-area: vresize;
width: 5px; cursor: col-resize; background: #333; flex-shrink: 0;
transition: background .15s;
}
.code-vresize:hover { background: #3b78ff; }
#code-ai-panel {
- width: 360px; min-width: 220px; max-width: 60%;
+ grid-area: ai;
+ min-width: 220px;
display: flex; flex-direction: column;
background: #080f0c; border-left: 1px solid #1a3a28;
- flex-shrink: 0;
+ overflow: hidden;
+}
+.code-bottom-panels {
+ grid-area: term;
+ display: flex;
+ flex-direction: column;
}
.code-ai-header {
display: flex; align-items: center; justify-content: space-between;
@@ -2673,6 +2690,9 @@
cursor: pointer;
padding-right: 20px;
color-scheme: dark;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.csb-model-select-wrap select option {
background: #1a2a1a;