v2.9.0 — Settings overhaul, Mistral parallel tool fix, Anthropic provider

- Fix settings save bug: primaryModel captured before _modelSettingsLoaded guard → wiped model roles when models tab not opened
- Fix server: filter empty-string roles before saving to prevent role wipe
- Add Voice and Session settings tabs to UI
- Add OpenWeather API key field to Credentials tab
- Add GET/POST endpoints: /api/settings/voice, /api/settings/session, /api/settings/openweather
- Fix Mistral parallel tool calls: model embeds tool calls as text instead of tool_calls array; parseEmbeddedToolCalls() detects and converts
- Add Anthropic provider adapter (anthropic-adapter.ts)
- Weather tool vault key resolution fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-23 18:52:40 +09:00
co-authored by Claude Sonnet 4.6
parent eaf6678a21
commit 8aee152f2f
12 changed files with 1226 additions and 157 deletions
+585 -71
View File
@@ -2893,12 +2893,7 @@
<div class="code-vresize" id="code-vresize"></div>
<div id="code-ai-panel">
<div class="code-ai-header">
<span>💻 Coder AI</span>
<div style="display:flex;align-items:center;gap:6px">
<select id="code-ai-model-select" onchange="codeAiModelChanged()"
style="font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--line);border-radius:4px;padding:2px 4px;max-width:160px;cursor:pointer"></select>
<button onclick="codeAiClear()" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:11px">지우기</button>
</div>
<span id="code-ai-title">💻 Coder AI</span>
</div>
<div id="code-ai-messages">
<div class="code-ai-msg thinking">코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.</div>
@@ -3327,6 +3322,8 @@
<button id="settings-tab-agents" class="sidebar-action" onclick="setSettingsTab('agents')">에이전트</button>
<button id="settings-tab-channels" class="sidebar-action" onclick="setSettingsTab('channels')">채널</button>
<button id="settings-tab-ppt" class="sidebar-action" onclick="setSettingsTab('ppt')">프레젠테이션</button>
<button id="settings-tab-voice" class="sidebar-action" onclick="setSettingsTab('voice')">음성</button>
<button id="settings-tab-session" class="sidebar-action" onclick="setSettingsTab('session')">세션</button>
<button id="settings-tab-integrations" class="sidebar-action" onclick="setSettingsTab('integrations')">연동</button>
<button id="settings-tab-context-viewer" class="sidebar-action" onclick="setSettingsTab('context-viewer')">컨텍스트 뷰어</button>
</div>
@@ -3416,12 +3413,20 @@
style="width:100%;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;margin-bottom:10px;box-sizing:border-box" />
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:5px">Brave API 키</label>
<div style="display:flex;gap:6px">
<div style="display:flex;gap:6px;margin-bottom:10px">
<input id="cred-brave-key" type="password" placeholder="BSA…" autocomplete="new-password"
style="flex:1;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:var(--font-mono, monospace)" />
<button onclick="toggleCredVis('cred-brave-key',this)" title="Show/hide"
style="padding:0 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);font-size:13px;cursor:pointer;flex-shrink:0">👁</button>
</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:5px">OpenWeather API 키 <span style="font-weight:400">(날씨 도구)</span></label>
<div style="display:flex;gap:6px">
<input id="cred-ow-key" type="password" placeholder="…" autocomplete="new-password"
style="flex:1;border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;font-family:var(--font-mono, monospace)" />
<button onclick="toggleCredVis('cred-ow-key',this)" title="Show/hide"
style="padding:0 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel-2);font-size:13px;cursor:pointer;flex-shrink:0">👁</button>
</div>
</div>
<!-- Right column: status + info -->
@@ -3662,6 +3667,17 @@
보조 AI 어드바이저가 기본 모델이 막히거나 계획이 필요할 때 도와줍니다.
설정 후 <strong>멀티 에이전트 오케스트레이터</strong> 스킬을 활성화하세요.
</div>
<!-- Preset buttons -->
<div style="margin-bottom:12px">
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;font-weight:600">프리셋</div>
<div style="display:flex;gap:6px">
<button onclick="applyOrchPreset('conservative')" id="orch-preset-conservative" style="flex:1;padding:7px 4px;border-radius:8px;border:1px solid var(--line);background:#fff;font-size:12px;font-weight:600;color:#5a6e8a;cursor:pointer" onmouseover="this.style.borderColor='#5a6e8a'" onmouseout="this.style.borderColor=orchActivePreset==='conservative'?'#0d4faf':'var(--line)'">보수적</button>
<button onclick="applyOrchPreset('balanced')" id="orch-preset-balanced" style="flex:1;padding:7px 4px;border-radius:8px;border:1px solid var(--line);background:#fff;font-size:12px;font-weight:600;color:#5a6e8a;cursor:pointer" onmouseover="this.style.borderColor='#5a6e8a'" onmouseout="this.style.borderColor=orchActivePreset==='balanced'?'#0d4faf':'var(--line)'">균형</button>
<button onclick="applyOrchPreset('aggressive')" id="orch-preset-aggressive" style="flex:1;padding:7px 4px;border-radius:8px;border:1px solid var(--line);background:#fff;font-size:12px;font-weight:600;color:#5a6e8a;cursor:pointer" onmouseover="this.style.borderColor='#5a6e8a'" onmouseout="this.style.borderColor=orchActivePreset==='aggressive'?'#0d4faf':'var(--line)'">공격적</button>
</div>
<div id="orch-preset-desc" style="font-size:11px;color:var(--muted);margin-top:5px;min-height:16px"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">보조 제공자</div>
@@ -3681,6 +3697,14 @@
</select>
</div>
</div>
<!-- Advanced toggle -->
<div style="margin-bottom:8px">
<button onclick="toggleOrchAdvanced()" id="orch-advanced-toggle" style="display:flex;align-items:center;gap:5px;background:none;border:none;padding:0;font-size:11px;color:var(--muted);cursor:pointer;font-weight:600">
<span id="orch-advanced-arrow" style="font-size:10px;transition:transform .2s">▶</span> 고급 설정
</button>
</div>
<div id="orch-advanced-panel" style="display:none">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">실패 임계값</div>
@@ -3855,6 +3879,7 @@
</div>
</div>
<div id="orch-eligibility-msg" style="font-size:11px;color:var(--muted);margin-bottom:8px;padding:8px 10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;display:none"></div>
</div><!-- /orch-advanced-panel -->
<button onclick="saveOrchestrationConfig()" class="btn" style="width:100%;margin-bottom:8px">오케스트레이션 설정 저장</button>
</div>
@@ -4128,6 +4153,63 @@
</div>
</div>
<!-- ── Voice Settings ────────────────────────────────────────── -->
<div id="settings-panel-voice" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<!-- STT -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div class="right-section-title" style="margin-bottom:10px">음성 인식 (STT)</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">제공자</label>
<select id="voice-stt-provider" class="settings-input" style="margin-bottom:10px;font-size:13px">
<option value="whisper_cpp">Whisper.cpp (로컬)</option>
<option value="openai">OpenAI Whisper (API)</option>
</select>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">모델 파일</label>
<input id="voice-stt-model" type="text" class="settings-input" placeholder="ggml-base.bin" style="margin-bottom:10px;font-size:12px" />
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">언어</label>
<input id="voice-stt-language" type="text" class="settings-input" placeholder="ko" style="font-size:12px" />
</div>
<!-- TTS -->
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div class="right-section-title" style="margin-bottom:10px">음성 합성 (TTS)</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">제공자</label>
<select id="voice-tts-provider" class="settings-input" style="margin-bottom:10px;font-size:13px">
<option value="piper">Piper (로컬)</option>
<option value="edge_tts">Edge TTS (Microsoft)</option>
<option value="openai">OpenAI TTS (API)</option>
</select>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">모델 / 목소리</label>
<input id="voice-tts-model" type="text" class="settings-input" placeholder="ko_KR-kss-medium" style="font-size:12px" />
</div>
</div>
<div style="margin-top:14px;display:flex;justify-content:flex-end">
<button class="btn btn-sm" onclick="saveVoiceSettings()">음성 설정 저장</button>
</div>
</div>
<!-- ── Session Settings ──────────────────────────────────────── -->
<div id="settings-panel-session" style="display:none;padding:14px 16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div class="right-section-title" style="margin-bottom:10px">메시지 관리</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">최대 메시지 수</label>
<input id="session-max-messages" type="number" class="settings-input" min="20" max="500" step="10" style="margin-bottom:10px;font-size:12px" />
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">히스토리 턴 수 <span style="font-weight:400">(모델에 전달)</span></label>
<input id="session-history-turns" type="number" class="settings-input" min="2" max="40" step="1" style="font-size:12px" />
</div>
<div style="border:1px solid var(--line);border-radius:12px;padding:14px">
<div class="right-section-title" style="margin-bottom:10px">컴팩션 임계값</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">컨텍스트 압축 시작 비율 <span style="font-weight:400">(0.0 – 1.0)</span></label>
<input id="session-compaction" type="number" class="settings-input" min="0.3" max="0.95" step="0.05" style="margin-bottom:10px;font-size:12px" />
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px">메모리 플러시 임계값 <span style="font-weight:400">(0.0 – 1.0)</span></label>
<input id="session-memory-flush" type="number" class="settings-input" min="0.3" max="0.99" step="0.05" style="font-size:12px" />
</div>
</div>
<div style="margin-top:14px;display:flex;justify-content:flex-end">
<button class="btn btn-sm" onclick="saveSessionSettings()">세션 설정 저장</button>
</div>
</div>
<div id="settings-panel-integrations" style="display:none;padding:14px 16px">
<!-- Sub-tab row -->
<div style="display:flex;gap:8px;margin-bottom:14px">
@@ -4454,6 +4536,8 @@ let chatSessions = [];
let activeChatSessionId = null;
let sessionsEditMode = false;
let isThinking = false;
let thinkingStartedAt = 0;
let thinkingTicker = null;
let streamingSessionId = null; // which session owns the active stream
let processLogEntries = [];
let currentTurnStartIndex = -1;
@@ -4462,6 +4546,8 @@ let lastTurnKind = '-';
let settingsTab = 'system';
let liveTaskSteps = [];
let quickSearchRigor = 'verified';
let _searchSettingsLoaded = false; // true after openSettings() successfully loads search config
let _modelSettingsLoaded = false; // true after loadModelSettings() successfully populates model/provider DOM
let quickThinkingEffort = localStorage.getItem('cherryclaw_quick_thinking_effort') || 'standard';
let queuedPrompts = [];
const MAX_QUEUED_PROMPTS = 8;
@@ -5233,10 +5319,11 @@ function renderChatMessages() {
<div class="msg-role">CherryClaw${useAgentMode ? ' (Agent)' : ''}</div>
${currentPreflightStatus ? `<div class="msg-content" style="margin-bottom:6px;color:#26487e">${escHtml(currentPreflightStatus)}</div>` : ''}
${progressHtml}
<div class="thinking">
<div class="thinking" style="align-items:center">
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<div class="thinking-dot"></div>
<span id="thinking-elapsed" style="margin-left:8px;font-size:11px;color:var(--muted)"></span>
</div>
<div style="margin-top:8px">
<button class="skill-install-btn" style="font-size:10px;padding:3px 9px" onclick="toggleCurrentProcess()">Process</button>
@@ -5540,11 +5627,18 @@ async function sendChat(queuedMessage = null) {
input.style.height = 'auto';
}
isThinking = true;
thinkingStartedAt = Date.now();
if (thinkingTicker) clearInterval(thinkingTicker);
thinkingTicker = setInterval(() => {
const el = document.getElementById('thinking-elapsed');
if (el) el.textContent = `${Math.round((Date.now() - thinkingStartedAt) / 1000)}s 경과`;
}, 1000);
streamingSessionId = activeChatSessionId; // lock bubble to this session
setButtonState(true);
currentPreflightStatus = '';
currentProgressLines = [];
liveTaskSteps = [];
v2LiveFileId = null; v2LiveInBlock = false;
lastHeartbeat = {
state: 'running',
level: '',
@@ -5653,6 +5747,17 @@ async function sendChat(queuedMessage = null) {
}
break;
case 'token':
case 'text_delta': {
const token = event.text || event.content || event.delta || '';
if (token) {
partialContent += token;
// Stream code into editor in real-time
v2LiveCodeUpdate(partialContent);
}
break;
}
case 'info':
if (event.message) addProcessEntry('info', String(event.message));
break;
@@ -5692,6 +5797,25 @@ async function sendChat(queuedMessage = null) {
const args = (event.args && typeof event.args === 'object') ? event.args : null;
const argsPreview = args ? JSON.stringify(args).slice(0, 260) : '';
const syntheticTag = event.synthetic ? ' [synthetic]' : '';
// Capture the code file the agent is about to write so we can auto-open it on success.
if (args) {
let cf = null;
const isCodeExt = (f) => /^code\//.test(f) || /\.(?:py|js|ts|jsx|tsx|html|css|json|yaml|yml|sh|c|cpp|h|java|rb|go|rs|sql|md|txt|toml|ini|cfg|vue|svelte)$/i.test(f);
if (action === 'create_file' && (args.filename || args.path)) {
cf = String(args.filename || args.path);
// Mirror the model's code into the editor instantly, straight from the
// tool_call args — no disk round-trip needed. Switch to the code view so
// Monaco initializes in a visible container and the file opens in a tab.
if (isCodeExt(cf) && typeof args.content === 'string') {
if (currentMode !== 'code') setMode('code');
codeEditorWriteFile(cf.replace(/^code\//, ''), args.content);
}
} else if ((action === 'shell' || action === 'run_command') && args.command) {
const m = String(args.command).match(/(?:>>?|create_file\(\s*['"])\s*['"]?(\S+\.(?:py|js|ts|jsx|tsx|html|css|json|yaml|yml|sh|c|cpp|h|java|rb|go|rs|sql|md|txt|toml|ini|cfg|vue|svelte)|(?:code\/\S+?))(?:\s|['">]|$)/i);
if (m) cf = m[1] || m[2];
}
if (cf && isCodeExt(cf)) window._pendingCodeFile = cf;
}
sawToolActivityThisTurn = true;
if (action) pushProgressLine(`${stepNum ? `Step ${stepNum}: ` : ''}Running ${action}...`);
upsertLiveStep(action || 'tool', stepNum || (liveTaskSteps.length + 1), 'in_progress', 'running');
@@ -5719,6 +5843,19 @@ async function sendChat(queuedMessage = null) {
`${stepNum ? `Step ${stepNum}: ` : ''}${action || 'tool'}${syntheticTag} => ${text || '(no output)'}`,
);
loadAgentSessionContext();
// Auto-open file in code editor when agent writes a code file
// (via create_file OR shell/run_command redirect captured at tool_call).
if (ok && (action === 'create_file' || action === 'shell' || action === 'run_command')) {
const fnMatch = text.match(/(code\/\S+?)(?:\s+created|["'\s]|$)/) || text.match(/(\S+\.(?:py|js|ts|jsx|tsx|html|css|json|yaml|yml|sh|c|cpp|h|java|rb|go|rs|sql|md|txt|toml|ini|cfg|vue|svelte))(?:\s|["']|$)/i);
const captured = window._pendingCodeFile;
const full = (fnMatch && fnMatch[1]) || captured;
const isCodeExtCheck = full && (/^code\//.test(full) || /\.(?:py|js|ts|jsx|tsx|html|css|json|yaml|yml|sh|c|cpp|h|java|rb|go|rs|sql|md|txt|toml|ini|cfg|vue|svelte)$/i.test(full));
if (full && isCodeExtCheck) {
const fname = full.replace(/^code\//, '');
window._pendingCodeFile = null;
setTimeout(() => { csbOpenFile(fname); codeSbRefresh(); }, 400);
}
}
break;
}
@@ -6008,6 +6145,19 @@ async function sendChat(queuedMessage = null) {
}
persistActiveChat();
// Auto-open code blocks from final reply in the editor
if (finalReply && typeof codeAiRenderMarkdown === 'function') {
const codeBlockRe = /```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```/g;
let cbm;
while ((cbm = codeBlockRe.exec(finalReply)) !== null) {
const lang = cbm[1] || 'text';
const code = cbm[2];
if (codeIsOpenable(code, lang)) {
codeAiOpenInEditor(code, lang);
}
}
}
} catch (err) {
const turnEntries = currentTurnStartIndex >= 0 ? processLogEntries.slice(currentTurnStartIndex) : [];
if (err.name === 'AbortError') {
@@ -6025,6 +6175,7 @@ async function sendChat(queuedMessage = null) {
}
isThinking = false;
if (thinkingTicker) { clearInterval(thinkingTicker); thinkingTicker = null; }
streamingSessionId = null; // release session lock
currentAbortController = null;
setButtonState(false);
@@ -6599,7 +6750,7 @@ function setQuickThinkingEffort(level) {
function setSettingsTab(tab) {
settingsTab = tab;
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt', 'context-viewer'];
const tabs = ['system', 'search', 'credentials', 'policy', 'security', 'users', 'models', 'agents', 'channels', 'integrations', 'ppt', 'voice', 'session', 'context-viewer'];
tabs.forEach(t => {
const btn = document.getElementById(`settings-tab-${t}`);
const panel = document.getElementById(`settings-panel-${t}`);
@@ -6618,6 +6769,8 @@ function setSettingsTab(tab) {
if (t === 'credentials') loadCredentialsTab();
if (t === 'ppt') loadPptTab();
if (t === 'users') loadUsersTab();
if (t === 'voice') loadVoiceSettings();
if (t === 'session') loadSessionSettings();
if (t === 'context-viewer') loadContextViewerTab();
} else {
panel.style.display = 'none';
@@ -6650,6 +6803,17 @@ async function loadCredFields() {
} catch(e) {
console.warn('loadCredFields:', e);
}
// OpenWeather key is stored separately
try {
const ow = await api('/api/settings/openweather');
const owEl = document.getElementById('cred-ow-key');
if (owEl) {
owEl.value = ow.api_key || '';
owEl.placeholder = ow.api_key ? '•••••••• (key stored — enter new value to replace)' : '';
}
} catch(e) {
console.warn('loadCredFields openweather:', e);
}
}
async function loadCredVaultStatus() {
@@ -6671,6 +6835,7 @@ async function loadCredVaultStatus() {
'hooks.token': 'Webhook Token',
'channels.telegram.botToken': 'Telegram Token',
'channels.discord.botToken': 'Discord Token',
'openweather.api_key': 'OpenWeather API Key',
}[k] || k;
return `<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px">
<span style="color:var(--ok);font-size:13px">✓</span>
@@ -6760,7 +6925,8 @@ async function loadModelSettings() {
if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; }
// Set primary model from saved config before refreshing the list
const savedPrimaryModel = pc[prov]?.model || '';
// Fallback to data.models_primary (models.primary) when provider's model field is empty
const savedPrimaryModel = pc[prov]?.model || data?.models_primary || '';
if (savedPrimaryModel) {
const sel = document.getElementById('settings-primary-model');
if (sel) sel.value = savedPrimaryModel;
@@ -6776,13 +6942,14 @@ async function loadModelSettings() {
if (prov === 'openai') await refreshOpenAIModels(true);
// Auto-load model list for list-capable providers (preserves saved selection)
if (['ollama', 'lm_studio', 'llama_cpp'].includes(prov)) await refreshProviderModels();
if (['ollama', 'lm_studio', 'llama_cpp'].includes(prov)) await refreshProviderModels(savedPrimaryModel);
_modelSettingsLoaded = true;
} catch (e) {
console.warn('loadModelSettings error:', e);
}
}
async function refreshProviderModels() {
async function refreshProviderModels(forcedValue) {
const provider = document.getElementById('settings-llm-provider')?.value || 'ollama';
const msg = document.getElementById('provider-status-msg');
if (msg) msg.textContent = '모델 로딩 중…';
@@ -6796,7 +6963,8 @@ async function refreshProviderModels() {
const sel = document.getElementById(selId);
if (!sel) return;
if (i === 0) {
const prev = String(sel.value || '').trim();
// forcedValue: saved model passed from loadModelSettings (DOM value not yet set when options are empty)
const prev = forcedValue || String(sel.value || '').trim();
sel.innerHTML = models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (models.length) sel.value = (prev && models.includes(prev)) ? prev : models[0];
}
@@ -7168,6 +7336,7 @@ async function openSettings(tab) {
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
toggleOllamaModelRow(s.preferred_provider);
toggleSearxngUrlRow(s.preferred_provider);
_searchSettingsLoaded = true;
// Keys are loaded via the Credentials tab — not here
} catch {}
try {
@@ -7526,6 +7695,8 @@ async function savePptPrefs() {
function closeSettings() {
document.getElementById('settings-modal').style.display = 'none';
_searchSettingsLoaded = false;
_modelSettingsLoaded = false;
}
function ensureAgentMdEditor() {
@@ -8205,6 +8376,20 @@ async function saveSettings() {
const workspace_path = document.getElementById('settings-workspace-path').value.trim();
const allowed_paths = document.getElementById('settings-allowed-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
const blocked_paths = document.getElementById('settings-blocked-paths').value.split('\n').map(s => s.trim()).filter(Boolean);
// If search settings were never loaded from the server (API failed or race with async load),
// fetch current values now so we don't overwrite searxng_url / preferred_provider with HTML defaults.
if (!_searchSettingsLoaded) {
try {
const s = await api('/api/settings/search');
document.getElementById('settings-provider').value = s.preferred_provider || '';
document.getElementById('settings-search-rigor').value = s.search_rigor || 'verified';
if (document.getElementById('settings-search-searxng-url'))
document.getElementById('settings-search-searxng-url').value = s.searxng_url || '';
if (document.getElementById('settings-search-ollama-model'))
document.getElementById('settings-search-ollama-model').value = s.ollama_model || '';
_searchSettingsLoaded = true;
} catch {}
}
const payload = {
preferred_provider: document.getElementById('settings-provider').value,
search_rigor: document.getElementById('settings-search-rigor').value,
@@ -8222,11 +8407,33 @@ async function saveSettings() {
natural_language_tool_router: document.getElementById('settings-nl-tool-router').checked,
retrieval_mode: document.getElementById('settings-retrieval-mode').value,
};
// If model/provider settings were never loaded (models tab not opened), fetch and populate DOM now
// so buildProviderPayload() doesn't send HTML defaults (provider='ollama', empty model, etc.)
if (!_modelSettingsLoaded) {
try {
const data = await api('/api/settings/provider');
const llm = data?.llm || {};
const prov = llm.provider || '';
const pc = llm.providers || {};
const v = (id, val) => { const el = document.getElementById(id); if (el && val) el.value = val; };
if (prov) { const el = document.getElementById('settings-llm-provider'); if (el) el.value = prov; }
v('settings-ollama-endpoint', pc.ollama?.endpoint);
v('settings-llamacpp-endpoint', pc.llama_cpp?.endpoint);
v('settings-llamacpp-model', pc.llama_cpp?.model);
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
v('settings-lmstudio-model', pc.lm_studio?.model);
const savedPrimary = pc[prov]?.model || data?.models_primary || '';
if (savedPrimary) { const el = document.getElementById('settings-primary-model'); if (el) el.value = savedPrimary; }
_modelSettingsLoaded = true;
} catch {}
}
// Capture primaryModel AFTER _modelSettingsLoaded guard so the DOM value is correct
// even when the models tab was never opened in this settings session.
const primaryModel = (document.getElementById('settings-primary-model') || {}).value || '';
const modelPayload = {
ollama_endpoint: (document.getElementById('settings-ollama-endpoint') || {}).value || 'http://localhost:11434',
primary_model: primaryModel,
role_models: {
primary: primaryModel,
roles: {
manager: primaryModel,
executor: primaryModel,
verifier: primaryModel,
@@ -8244,6 +8451,10 @@ async function saveSettings() {
await api('/api/settings/agent', { method: 'POST', body: JSON.stringify(policyPayload) });
await api('/api/settings/model', { method: 'POST', body: JSON.stringify(modelPayload) });
await api('/api/settings/provider', { method: 'POST', body: JSON.stringify({ llm: providerPayload }) });
const owKey = document.getElementById('cred-ow-key')?.value?.trim();
if (owKey && owKey !== '••••••••') {
await api('/api/settings/openweather', { method: 'POST', body: JSON.stringify({ api_key: owKey }) });
}
await loadSearchSettingsSummary();
quickSearchRigor = payload.search_rigor || quickSearchRigor;
updateQuickModeUI();
@@ -8702,6 +8913,177 @@ async function updateOrchSecondaryModels(preferredModel = '') {
modelSelect.value = models.includes(selected) ? selected : models[0];
}
let orchActivePreset = '';
const ORCH_PRESETS = {
conservative: {
desc: '오케스트레이터가 최소한으로 개입 — 안정적이지만 느리게 반응',
fail_threshold: 3, stagnation: 5, loop_detect: 'true',
risk_files: 10, risk_ops: 400, no_progress_sec: 150,
preflight: 'off', allow_secondary_chat: 'false',
cooldown: 5, max_assists_turn: 2, max_assists_session: 10,
browser_calls_turn: 3, browser_max_items: 40, browser_forced_retries: 0,
preempt_enabled: 'false', preempt_threshold: 90, preempt_max_turn: 1, preempt_max_session: 1,
fileops_enabled: 'true', fileops_verify_create: 'false',
fileops_create_lines: 150, fileops_create_chars: 7000,
fileops_edit_lines: 25, fileops_edit_chars: 2000, fileops_edit_files: 2,
fileops_verify_lines: 50, fileops_verify_chars: 2500, fileops_watchdog: 5, fileops_checkpointing: 'true',
},
balanced: {
desc: '기본 권장값 — 대부분의 상황에 적합',
fail_threshold: 2, stagnation: 3, loop_detect: 'true',
risk_files: 6, risk_ops: 220, no_progress_sec: 90,
preflight: 'complex_only', allow_secondary_chat: 'false',
cooldown: 3, max_assists_turn: 3, max_assists_session: 18,
browser_calls_turn: 5, browser_max_items: 80, browser_forced_retries: 0,
preempt_enabled: 'false', preempt_threshold: 45, preempt_max_turn: 1, preempt_max_session: 3,
fileops_enabled: 'true', fileops_verify_create: 'true',
fileops_create_lines: 80, fileops_create_chars: 3500,
fileops_edit_lines: 12, fileops_edit_chars: 800, fileops_edit_files: 1,
fileops_verify_lines: 25, fileops_verify_chars: 1200, fileops_watchdog: 3, fileops_checkpointing: 'true',
},
aggressive: {
desc: '자주 적극적으로 개입 — 복잡한 멀티스텝 작업에 적합',
fail_threshold: 1, stagnation: 2, loop_detect: 'true',
risk_files: 3, risk_ops: 100, no_progress_sec: 45,
preflight: 'always', allow_secondary_chat: 'false',
cooldown: 2, max_assists_turn: 5, max_assists_session: 30,
browser_calls_turn: 8, browser_max_items: 120, browser_forced_retries: 2,
preempt_enabled: 'true', preempt_threshold: 30, preempt_max_turn: 2, preempt_max_session: 6,
fileops_enabled: 'true', fileops_verify_create: 'true',
fileops_create_lines: 40, fileops_create_chars: 1500,
fileops_edit_lines: 6, fileops_edit_chars: 400, fileops_edit_files: 1,
fileops_verify_lines: 15, fileops_verify_chars: 600, fileops_watchdog: 2, fileops_checkpointing: 'true',
},
};
function applyOrchPreset(name) {
const p = ORCH_PRESETS[name];
if (!p) return;
orchActivePreset = name;
const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
set('orch-fail-threshold', p.fail_threshold);
set('orch-stagnation', p.stagnation);
set('orch-loop-detect', p.loop_detect);
set('orch-risk-files', p.risk_files);
set('orch-risk-ops', p.risk_ops);
set('orch-no-progress-sec', p.no_progress_sec);
set('orch-preflight-mode', p.preflight);
set('orch-allow-secondary-chat', p.allow_secondary_chat);
set('orch-cooldown-rounds', p.cooldown);
set('orch-max-assists-turn', p.max_assists_turn);
set('orch-max-assists-session', p.max_assists_session);
set('orch-browser-calls-turn', p.browser_calls_turn);
set('orch-browser-max-items', p.browser_max_items);
set('orch-browser-forced-retries', p.browser_forced_retries);
set('orch-preempt-enabled', p.preempt_enabled);
set('orch-preempt-threshold', p.preempt_threshold);
set('orch-preempt-max-turn', p.preempt_max_turn);
set('orch-preempt-max-session', p.preempt_max_session);
set('orch-fileops-enabled', p.fileops_enabled);
set('orch-fileops-verify-create', p.fileops_verify_create);
set('orch-fileops-create-lines', p.fileops_create_lines);
set('orch-fileops-create-chars', p.fileops_create_chars);
set('orch-fileops-edit-lines', p.fileops_edit_lines);
set('orch-fileops-edit-chars', p.fileops_edit_chars);
set('orch-fileops-edit-files', p.fileops_edit_files);
set('orch-fileops-verify-lines', p.fileops_verify_lines);
set('orch-fileops-verify-chars', p.fileops_verify_chars);
set('orch-fileops-watchdog-cycles', p.fileops_watchdog);
set('orch-fileops-checkpointing', p.fileops_checkpointing);
// highlight active button
['conservative','balanced','aggressive'].forEach(n => {
const btn = document.getElementById('orch-preset-' + n);
if (!btn) return;
btn.style.background = n === name ? '#eaf2ff' : '#fff';
btn.style.borderColor = n === name ? '#0d4faf' : 'var(--line)';
btn.style.color = n === name ? '#0d4faf' : '#5a6e8a';
});
const desc = document.getElementById('orch-preset-desc');
if (desc) desc.textContent = p.desc;
}
function detectOrchPreset(cfg) {
const t = cfg.triggers || {};
const l = cfg.limits || {};
if (t.consecutive_failures <= 1 && t.stagnation_rounds <= 2) return 'aggressive';
if (t.consecutive_failures >= 3 && t.stagnation_rounds >= 5) return 'conservative';
return 'balanced';
}
function toggleOrchAdvanced() {
const panel = document.getElementById('orch-advanced-panel');
const arrow = document.getElementById('orch-advanced-arrow');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
if (arrow) arrow.style.transform = open ? '' : 'rotate(90deg)';
}
// ── Voice Settings ───────────────────────────────────────────────────────────
async function loadVoiceSettings() {
try {
const cfg = await api('/api/settings/voice');
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = val; };
v('voice-stt-provider', cfg.stt?.provider);
v('voice-stt-model', cfg.stt?.model);
v('voice-stt-language', cfg.stt?.language);
v('voice-tts-provider', cfg.tts?.provider);
v('voice-tts-model', cfg.tts?.model);
} catch (e) { console.warn('loadVoiceSettings:', e); }
}
async function saveVoiceSettings() {
const body = {
stt: {
provider: document.getElementById('voice-stt-provider')?.value,
model: document.getElementById('voice-stt-model')?.value?.trim(),
language: document.getElementById('voice-stt-language')?.value?.trim(),
},
tts: {
provider: document.getElementById('voice-tts-provider')?.value,
model: document.getElementById('voice-tts-model')?.value?.trim(),
},
};
try {
await api('/api/settings/voice', { method: 'POST', body: JSON.stringify(body) });
addProcessEntry('final', '음성 설정이 저장되었습니다.');
} catch (err) {
addProcessEntry('error', '음성 설정 저장 실패: ' + err.message);
}
}
// ── Session Settings ─────────────────────────────────────────────────────────
async function loadSessionSettings() {
try {
const cfg = await api('/api/settings/session');
const v = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined) el.value = String(val); };
v('session-max-messages', cfg.maxMessages);
v('session-history-turns', cfg.historyTurns);
v('session-compaction', cfg.compactionThreshold);
v('session-memory-flush', cfg.memoryFlushThreshold);
} catch (e) { console.warn('loadSessionSettings:', e); }
}
async function saveSessionSettings() {
const body = {
maxMessages: Number(document.getElementById('session-max-messages')?.value || 120),
historyTurns: Number(document.getElementById('session-history-turns')?.value || 8),
compactionThreshold: Number(document.getElementById('session-compaction')?.value || 0.7),
memoryFlushThreshold: Number(document.getElementById('session-memory-flush')?.value || 0.75),
};
try {
await api('/api/settings/session', { method: 'POST', body: JSON.stringify(body) });
addProcessEntry('final', '세션 설정이 저장되었습니다.');
} catch (err) {
addProcessEntry('error', '세션 설정 저장 실패: ' + err.message);
}
}
async function loadOrchestrationConfig() {
try {
const cfg = await api('/api/orchestration/config');
@@ -8767,6 +9149,8 @@ async function loadOrchestrationConfig() {
if (fileOpsWatchdogCyclesEl) fileOpsWatchdogCyclesEl.value = String(cfg.file_ops?.watchdog_no_progress_cycles ?? 3);
if (fileOpsCheckpointingEl) fileOpsCheckpointingEl.value = String(cfg.file_ops?.checkpointing_enabled !== false);
syncOrchPreemptControls();
// Auto-detect and highlight matching preset
applyOrchPreset(detectOrchPreset(cfg));
// Check eligibility
const elig = await api('/api/orchestration/eligible');
const msg = document.getElementById('orch-eligibility-msg');
@@ -12513,46 +12897,16 @@ function codeDetectLang(name) {
return CODE_LANG_MAP[ext] || 'plaintext';
}
async function loadCodeAiModels() {
const sel = document.getElementById('code-ai-model-select');
if (!sel || sel.dataset.loaded) return;
try {
const [settingRes, ollamaRes] = await Promise.all([
fetch('/api/settings/model', { headers: authHeaders() }),
fetch('/api/ollama/models', { headers: authHeaders() }),
]);
const setting = settingRes.ok ? await settingRes.json() : {};
const ollamaData = ollamaRes.ok ? await ollamaRes.json() : {};
const primaryModel = setting.primary || '';
const ollamaModels = (ollamaData.models || []).map(m => m.name);
// Build option list: default (기본 모델) + each ollama model
sel.innerHTML = '';
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = `기본 (${primaryModel || '설정 모델'})`;
sel.appendChild(defaultOpt);
const allModels = [...ollamaModels];
// Ensure current default model appears in list even if not in Ollama
if (codeAiModel && !allModels.includes(codeAiModel)) allModels.unshift(codeAiModel);
for (const m of allModels) {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
if (m === codeAiModel) opt.selected = true;
sel.appendChild(opt);
}
if (!codeAiModel) sel.value = '';
sel.dataset.loaded = '1';
} catch {}
}
function codeAiModelChanged() {
const sel = document.getElementById('code-ai-model-select');
codeAiModel = sel ? sel.value : '';
localStorage.setItem('codeAiModel', codeAiModel);
function updateCodeAiLabel() {
const label = codeAiModel || 'AI';
const title = document.getElementById('code-ai-title');
if (title) title.textContent = '💻 Coder-' + label;
const input = document.getElementById('code-ai-input');
if (input) input.placeholder = 'Coder-' + label + '에게 질문...';
}
function initCodeView() {
loadCodeAiModels();
updateCodeAiLabel();
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
if (typeof require === 'undefined') return;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
@@ -12579,6 +12933,7 @@ function initCodeView() {
else renderCodeFileTabs();
setupCodeVResize();
csbRestoreSettings();
setupCodeEditorDrop();
});
}
@@ -12906,6 +13261,43 @@ function codeSendSelectionToAI() {
codeAiSend();
}
// ─── V2 Chat Live Code Streaming ────────────────────────────────────────────────
// Global state for streaming code into the editor during the main v2 chat.
let v2LiveFileId = null, v2LiveLang = '', v2LiveInBlock = false;
function v2LiveCodeUpdate(fullReply) {
if (typeof codeIsOpenable !== 'function') return;
const fenceRe = /```([\w\-]*)\s*\n/g;
let lastOpen = null, m;
while ((m = fenceRe.exec(fullReply)) !== null) {
if (lastOpen === null) {
lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length };
} else {
lastOpen = null;
}
}
if (!lastOpen) { v2LiveInBlock = false; return; }
const code = fullReply.slice(lastOpen.start);
const lang = lastOpen.lang;
if (!v2LiveInBlock) {
if (!codeIsOpenable(code, lang)) return;
v2LiveInBlock = true;
v2LiveLang = lang;
if (monacoReady && monacoEditor) {
const fname = codeDeriveName(code, lang);
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
v2LiveFileId = id;
switchCodeFile(id);
}
} else if (v2LiveFileId) {
const file = codeFiles.find(f => f.id === v2LiveFileId);
if (file?.model) {
file.model.setValue(code);
}
}
}
async function codeAiSend() {
const input = document.getElementById('code-ai-input');
const message = input.value.trim();
@@ -12933,6 +13325,48 @@ async function codeAiSend() {
let buf = '', reply = '';
thinkEl.textContent = '';
thinkEl.className = 'code-ai-msg assistant';
// Live streaming state
let liveFileId = null, liveLang = '', liveCodeBuf = '', liveInBlock = false;
function codeAiLiveUpdate(fullReply) {
// Find the last unclosed code block
const fenceRe = /```([\w\-]*)\s*\n/g;
let lastOpen = null, lastClose = -1, m;
let pos = 0;
while ((m = fenceRe.exec(fullReply)) !== null) {
if (lastOpen === null) {
lastOpen = { lang: m[1] || 'text', start: m.index + m[0].length };
} else {
lastClose = m.index;
lastOpen = null;
}
}
if (!lastOpen) { liveInBlock = false; return; }
// We're inside an open code block
const code = fullReply.slice(lastOpen.start);
const lang = lastOpen.lang;
if (!liveInBlock) {
// Wait until the block looks like a real file (not a short run-command snippet)
if (!codeIsOpenable(code, lang)) return;
liveInBlock = true;
liveLang = lang;
if (monacoReady && monacoEditor) {
const fname = codeDeriveName(code, lang);
const id = 'cf_' + Math.random().toString(36).slice(2, 8);
const model = monaco.editor.createModel(code, codeDetectLang(fname));
codeFiles.push({ id, name: fname, content: code, language: codeDetectLang(fname), model });
liveFileId = id;
switchCodeFile(id);
}
} else if (liveFileId) {
const file = codeFiles.find(f => f.id === liveFileId);
if (file?.model) {
file.model.setValue(code);
liveCodeBuf = code;
}
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
@@ -12945,16 +13379,19 @@ async function codeAiSend() {
if (data === '[DONE]') continue;
try {
const ev = JSON.parse(data);
if (ev.type === 'token' && (ev.content || ev.text)) { reply += (ev.content || ev.text); thinkEl.textContent = reply; codeAiScrollBottom(); }
else if (ev.type === 'final' && (ev.text || ev.content)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); }
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); }
else if (ev.type === 'text_delta' && ev.delta) { reply += ev.delta; thinkEl.textContent = reply; codeAiScrollBottom(); }
let token = null;
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)) { reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
if (token) { reply += token; thinkEl.textContent = reply; codeAiScrollBottom(); codeAiLiveUpdate(reply); }
} catch {}
}
}
liveInBlock = false; liveFileId = null;
codeAiHistory.push({ role: 'assistant', content: reply || '(응답 없음)' });
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
else if (/```[\w]*\n/.test(reply)) codeAiRenderMarkdown(thinkEl, reply);
else codeAiRenderMarkdown(thinkEl, reply);
} catch(e) {
thinkEl.className = 'code-ai-msg thinking';
thinkEl.textContent = '오류: ' + (e.message || e);
@@ -12977,11 +13414,11 @@ function codeAiAppend(role, text) {
// Render AI message with markdown code blocks parsed out
function codeAiRenderMarkdown(el, text) {
el.innerHTML = '';
const parts = text.split(/(```[\w]*\n[\s\S]*?```)/g);
const parts = text.split(/(```[\w\-]*[ \t]*\r?\n[\s\S]*?```)/g);
let codeCount = 0;
const codeBlocks = [];
for (const part of parts) {
const m = part.match(/^```([\w]*)\n([\s\S]*?)```$/);
const m = part.match(/^```([\w\-]*)[ \t]*\r?\n([\s\S]*?)```\s*$/);
if (m) {
const lang = m[1] || 'text';
const code = m[2];
@@ -13011,8 +13448,8 @@ function codeAiRenderMarkdown(el, text) {
el.appendChild(span);
}
}
// Auto-open if exactly one code block
if (codeCount === 1) codeAiOpenInEditor(codeBlocks[0].code, codeBlocks[0].lang);
// Auto-open all code blocks in editor
if (codeBlocks.length) codeBlocks.forEach(b => codeAiOpenInEditor(b.code, b.lang));
}
const CODE_EXT_MAP = {
@@ -13043,11 +13480,69 @@ const CODE_EXT_MAP = {
markdown:'md', md:'md', tex:'tex', latex:'tex',
};
// Decide whether a markdown code block is a real file worth opening in the editor.
// Filters out shell run-instructions (e.g. ```python code/tetris.py```) and tiny snippets.
function codeIsOpenable(code, lang) {
const trimmed = (code || '').trim();
if (!trimmed) return false;
const lines = trimmed.split('\n').filter(l => l.trim());
const l = (lang || '').toLowerCase();
const isShellLang = ['', 'text', 'console', 'sh', 'bash', 'shell', 'zsh', 'powershell', 'ps1'].includes(l);
const cmdRe = /^\s*[$#>]?\s*(python3?|pip3?|npm|npx|node|yarn|pnpm|deno|bun|cd|ls|dir|cat|echo|curl|wget|git|chmod|mkdir|rm|cp|mv|source|sudo|export|set|run|\.\/)\b/i;
// Single line: skip shell langs and short command-looking lines
if (lines.length <= 1) {
if (isShellLang) return false;
if (cmdRe.test(trimmed)) return false;
if (trimmed.length < 40) return false;
}
// Short shell block where every line is a command → run instructions, not a file
if (isShellLang && lines.length <= 3 && lines.every(ln => cmdRe.test(ln))) return false;
return true;
}
// Derive a filename from an embedded `# filename:` hint, else a random one.
function codeDeriveName(code, lang) {
const m = (code || '').match(/(?:#|\/\/|--|\/\*|<!--)\s*(?:filename|file)\s*[:=]\s*(\S+)/i)
|| (code || '').match(/^(?:#|\/\/)\s*(\w+\.\w+)/m);
if (m) return m[1].replace(/[*\/>]+$/, '');
const ext = CODE_EXT_MAP[(lang || '').toLowerCase()] || ((lang && lang.length <= 6) ? lang : 'txt');
return `ai_${Math.random().toString(36).slice(2,7)}.${ext}`;
}
// Write an exact (filename, content) pair into the Monaco editor immediately,
// creating or updating the tab. Used to mirror the model's create_file in real time.
function codeEditorWriteFile(fname, content) {
if (!monacoReady || !monacoEditor) {
initCodeView();
setTimeout(() => codeEditorWriteFile(fname, content), 800);
return;
}
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
if (existing.model) existing.model.setValue(content);
else existing.content = content;
switchCodeFile(existing.id);
} else {
codeNewFile(fname, content);
}
}
function codeAiOpenInEditor(code, lang) {
const ext = CODE_EXT_MAP[lang] || (lang && lang.length <= 5 ? lang : 'txt');
const fnMatch = code.match(/(?:#|\/\/|--)\s*(?:filename|file):\s*(\S+)/i);
const fname = fnMatch ? fnMatch[1] : `ai_${Math.random().toString(36).slice(2,7)}.${ext}`;
codeNewFile(fname, code);
if (!codeIsOpenable(code, lang)) return;
if (!monacoReady || !monacoEditor) {
initCodeView();
setTimeout(() => codeAiOpenInEditor(code, lang), 800);
return;
}
const fname = codeDeriveName(code, lang);
const existing = codeFiles.find(f => f.name === fname);
if (existing) {
if (existing.model) existing.model.setValue(code);
else existing.content = code;
switchCodeFile(existing.id);
} else {
codeNewFile(fname, code);
}
}
function codeAiScrollBottom() {
@@ -13150,9 +13645,7 @@ async function csbLoadModels() {
sel.appendChild(opt);
}
if (!codeAiModel) sel.value = '';
// sync with AI panel selector
const panelSel = document.getElementById('code-ai-model-select');
if (panelSel) { panelSel.innerHTML = sel.innerHTML; panelSel.value = sel.value; panelSel.dataset.loaded = '1'; }
updateCodeAiLabel();
} catch {}
}
@@ -13160,9 +13653,7 @@ function csbModelChanged() {
const sel = document.getElementById('csb-model-select');
codeAiModel = sel ? sel.value : '';
localStorage.setItem('codeAiModel', codeAiModel);
// sync panel selector
const panelSel = document.getElementById('code-ai-model-select');
if (panelSel) panelSel.value = codeAiModel;
updateCodeAiLabel();
}
function csbFontSize(val) {
@@ -13306,6 +13797,29 @@ document.addEventListener('click', e => {
});
// ── Vertical resize handle between editor and AI panel ────────────────────
function setupCodeEditorDrop() {
const container = document.getElementById('code-editor-container');
if (!container) return;
container.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.style.outline = '2px solid var(--brand)'; });
container.addEventListener('dragleave', () => { container.style.outline = ''; });
container.addEventListener('drop', e => {
e.preventDefault();
container.style.outline = '';
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
files.forEach(file => {
const reader = new FileReader();
reader.onload = ev => {
const content = ev.target.result;
const existing = codeFiles.find(f => f.name === file.name);
if (existing) { existing.model.setValue(content); switchCodeFile(existing.id); }
else codeNewFile(file.name, content);
};
reader.readAsText(file);
});
});
}
function setupCodeVResize() {
const handle = document.getElementById('code-vresize');
const aiPanel = document.getElementById('code-ai-panel');