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