179 lines
28 KiB
JavaScript
179 lines
28 KiB
JavaScript
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드 · 0.3.2 사전 안내·OAuth 자동 진행·텍스트 셸·도구 정책·MCP 설정).
|
|
* - 패널(#ai-panel): 탭(통합 고정 + 연결별), 대화 기록, 지시 입력, 제안 승인·거부. 열림/너비/선택 탭은 운영자별 localStorage 에 두고 새로 고침 뒤 복원한다.
|
|
* - 데이터는 /ops/api/ai/* 만 쓴다(CSRF 자동). 응답 대기 중에는 1초, 아니면 4초 간격으로 다시 읽는다(탭이 숨겨지면 멈춤). 화면을 떠나도 턴은 HAL 이 계속한다.
|
|
* - 제안 승인: 쓰기 → 재인증 창(서버가 reauth_required 로 거부한 그 때만, reauth.mjs 계약), 고위험 → 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행) 뒤 승인 API.
|
|
* 승인 판단·실행은 전부 서버가 한다(여기는 묻고 보내는 것뿐). 비밀값은 입력란에서 서버로만 가고 화면에 다시 그리지 않는다.
|
|
* - 렌더링은 ai-render.mjs(순수)가 맡아 Node 테스트가 같은 HTML 계약을 검사한다.
|
|
*/
|
|
import * as A from './ai-render.mjs';
|
|
import { callWithReauth, reauthPrompt } from './reauth.mjs';
|
|
const $ = (s, el = document) => el.querySelector(s);
|
|
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
|
|
async function api(method, path, body) {
|
|
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' });
|
|
let j = null; try { j = await r.json(); } catch (_) {}
|
|
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
|
|
return j;
|
|
}
|
|
let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); }
|
|
function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; }
|
|
/** 입력 창(ops.js 의 ask 와 같은 계약의 축소판). fields: [{name,label,type,required,value,hint}] → 값 객체 또는 null */
|
|
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
|
|
return new Promise((resolve) => {
|
|
const m = modal(`<h2>${A.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="aiaskf">${fields.map((f) => f.type === 'textarea' ? `<label>${A.esc(f.label)}<textarea name="${f.name}" rows="3">${A.esc(f.value || '')}</textarea></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"> ${A.esc(f.label)}</label>` : `<label>${A.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${A.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="${f.type === 'password' ? 'new-password' : 'off'}"></label>`).join('')}${fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => A.esc(f.hint)).join('<br>')}</p>` : ''}<div class="actions"><button type="button" class="btn" id="aiaskc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${A.esc(okLabel)}</button></div></form>`);
|
|
$('#aiaskc', m.el).onclick = () => { m.close(); resolve(null); };
|
|
$('#aiaskf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = fd.get(f.name); m.close(); resolve(out); };
|
|
});
|
|
}
|
|
/** 재인증 창 — 서버가 reauth_required 로 거부한 그 때만 고위험 안내가 붙는다(reauth.mjs). */
|
|
async function reauth(opts = {}) { const p = reauthPrompt(opts); const v = await ask(p.title, [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: p.okLabel, note: p.note ? A.esc(p.note) : '' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
|
|
const call = (method, path, body) => callWithReauth(() => api(method, path, body), reauth);
|
|
/** 고위험 제안 승인용 3단계 확인(ops.js threeStep 과 같은 계약: confirmImpact → confirmIdentifiers → confirmExecute) */
|
|
function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌리기 가능 여부를 확인하세요' }) {
|
|
return new Promise((resolve) => {
|
|
let step = 1; const m = modal('');
|
|
const render = () => { m.el.innerHTML = `<div class="box" role="dialog" aria-modal="true"><h2>${A.esc(title)} — ${step}/3단계</h2>${step === 1 ? `<p><b>1단계 영향 범위 확인</b></p><ul>${impacts.map((i) => `<li>${A.esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="c1"> 위 영향 범위를 확인했습니다</label>` : step === 2 ? `<p><b>2단계 대상 식별정보 재확인</b> — 아래 식별정보를 정확히 입력하세요: <code>${identifiers.map(A.esc).join(', ')}</code></p><input id="c2" placeholder="식별정보(쉼표로 구분)" autocomplete="off">` : `<p><b>3단계 최종 실행</b></p><p>${A.esc(revertible)}</p><label><input type="checkbox" id="c3"> 최종 실행을 확인합니다</label>`}<div class="actions"><button class="btn" id="cancel">취소</button><button class="btn ${step === 3 ? 'danger' : 'primary'}" id="next">${step === 3 ? '실행' : '다음'}</button></div></div>`;
|
|
$('#cancel', m.el).onclick = () => { m.close(); resolve(null); };
|
|
$('#next', m.el).onclick = () => { if (step === 1 && !$('#c1', m.el).checked) return toast('영향 범위를 확인해야 합니다'); if (step === 2) { const got = $('#c2', m.el).value.split(',').map((s) => s.trim()).filter(Boolean).sort(); if (JSON.stringify(got) !== JSON.stringify(identifiers.slice().sort())) return toast('대상 식별정보가 다릅니다'); } if (step === 3 && !$('#c3', m.el).checked) return toast('최종 실행을 확인해야 합니다'); if (step < 3) { step++; render(); } else { m.close(); resolve({ confirmImpact: true, confirmIdentifiers: identifiers, confirmExecute: true }); } }; };
|
|
render();
|
|
});
|
|
}
|
|
|
|
// ---------------- 패널 상태(운영자별 localStorage)
|
|
const panel = $('#ai-panel'); const operator = panel ? panel.dataset.operator || '' : '';
|
|
const KEY = `aw_ai_panel:${operator}`;
|
|
const loadState = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch (_) { return {}; } };
|
|
const saveState = (p) => { try { localStorage.setItem(KEY, JSON.stringify({ ...loadState(), ...p })); } catch (_) {} };
|
|
let rooms = []; let current = null; let lastSeq = 0; let actions = {}; let pollT = null; let msgs = [];
|
|
function applyState() { const st = loadState(); if (!panel) return; panel.classList.toggle('closed', st.open === false); if (st.width) panel.style.setProperty('--ai-w', Math.max(280, Math.min(window.innerWidth * 0.6, st.width)) + 'px'); const b = $('[data-act=ai-toggle]'); if (b) b.setAttribute('aria-pressed', String(st.open !== false)); }
|
|
function pageContext() { const main = document.querySelector('[data-page]'); return main ? { page: main.dataset.page || '', id: main.dataset.id || '' } : null; }
|
|
|
|
async function loadRooms() {
|
|
const r = await api('GET', '/ops/api/ai/rooms'); rooms = r.rooms; const st = loadState();
|
|
if (!current || !rooms.some((x) => x.id === current)) current = rooms.some((x) => x.id === st.room) ? st.room : 'room_group';
|
|
$('#ai-tabs').innerHTML = A.tabsHtml(rooms, current);
|
|
}
|
|
function room() { return rooms.find((x) => x.id === current) || null; }
|
|
async function loadMessages({ full = false } = {}) {
|
|
if (!current) return; const r = await api('GET', `/ops/api/ai/rooms/${encodeURIComponent(current)}/messages${full ? '' : '?after=' + lastSeq}`);
|
|
actions = {}; for (const a of r.actions || []) actions[a.id] = a;
|
|
if (full) { msgs = r.messages; } else if (r.messages.length) { msgs = msgs.concat(r.messages); }
|
|
lastSeq = msgs.length ? msgs[msgs.length - 1].seq : 0;
|
|
const log = $('#ai-log'); const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
|
log.innerHTML = msgs.length ? msgs.map((m) => A.messageHtml(m, { actions })).join('') : `<div class="aw-state">${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}</div>`;
|
|
const proposed = (r.actions || []).filter((a) => a.status === 'proposed').length; $('#ai-status').innerHTML = A.statusLine(room(), r.pending) + (proposed ? ` <span class="badge warn">승인 대기 ${proposed}</span>` : '');
|
|
if (atBottom || full) log.scrollTop = log.scrollHeight;
|
|
schedule(r.pending ? 1000 : 4000);
|
|
}
|
|
function schedule(ms) { clearTimeout(pollT); pollT = setTimeout(() => { if (document.hidden || !panel || panel.classList.contains('closed')) return schedule(4000); loadMessages().catch(() => schedule(6000)); }, ms); }
|
|
async function switchRoom(id) { current = id; lastSeq = 0; msgs = []; saveState({ room: id }); $('#ai-tabs').innerHTML = A.tabsHtml(rooms, current); await loadMessages({ full: true }); }
|
|
|
|
async function send() {
|
|
const ta = $('#ai-input'); const text = ta.value.trim(); if (!text || !current) return;
|
|
try { ta.disabled = true; await call('POST', `/ops/api/ai/rooms/${encodeURIComponent(current)}/messages`, { text, context: $('#ai-ctx')?.checked ? pageContext() : null }); ta.value = ''; await loadMessages(); }
|
|
catch (e) { toast(e.code === 'unknown_addressee' ? `${e.message}${e.detail && e.detail.participants ? ' — 참여자: ' + e.detail.participants.join(', ') : ''}` : e.message); }
|
|
finally { ta.disabled = false; ta.focus(); }
|
|
}
|
|
async function approve(id, risk, ids, tool) {
|
|
const a = actions[id] || { args: {}, description: '' };
|
|
let body = {};
|
|
if (risk === 'high') { body = await threeStep({ title: `AI 제안 승인: ${tool}`, impacts: [a.description || tool, `인자: ${JSON.stringify(a.args || {})}`, '이 조치는 고위험으로 분류되어 재인증과 3단계 확인이 필요합니다'], identifiers: ids, revertible: '되돌리기 가능 여부는 도구 설명·HAL 작업 결과로 확인하세요' }); if (!body) return; }
|
|
else { const v = await ask(`AI 제안 승인: ${tool}`, [{ name: 'ok', label: `${a.description || tool} — 인자 ${JSON.stringify(a.args || {})}`, type: 'checkbox' }], { okLabel: '승인(재인증 필요)' }); if (!v || v.ok !== 'on') return toast('확인란을 체크해야 승인됩니다'); }
|
|
await call('POST', `/ops/api/ai/actions/${encodeURIComponent(id)}/approve`, body); toast('승인했습니다 — 실행 결과가 대화에 들어갑니다'); await loadMessages({ full: true });
|
|
}
|
|
async function reject(id) { const v = await ask('AI 제안 거부', [{ name: 'reason', label: '사유(선택)' }], { okLabel: '거부', danger: true }); if (!v) return; await api('POST', `/ops/api/ai/actions/${encodeURIComponent(id)}/reject`, { reason: v.reason || '' }); toast('거부했습니다 — 실행되지 않습니다'); await loadMessages({ full: true }); }
|
|
|
|
// ---------------- AI 연동 화면(/ops/ai)
|
|
const PAGE_AI = document.querySelector('[data-page="ai"]');
|
|
let conns = []; let providers = []; let callbackUrl = ''; let toolList = [];
|
|
const f = () => $('#ai-conn-form');
|
|
const providerOf = (id) => providers.find((p) => p.id === id) || null;
|
|
function renderGuide() { const form = f(); if (!form || !$('#ai-guide')) return; $('#ai-guide').innerHTML = A.guideHtml(providerOf(form.provider.value), form.authKind.value, callbackUrl); }
|
|
function renderToolPolicy() { const form = f(); if (!form) return; const box = $('#ai-tool-policy'); box.hidden = form.toolsPreset.value !== 'custom'; if (!box.hidden && !box.dataset.ready) { box.innerHTML = A.toolPolicyHtml(toolList, toolList.map((t) => t.name)); box.dataset.ready = '1'; } }
|
|
function toolsBody() { const form = f(); const preset = form.toolsPreset.value; if (preset !== 'custom') return { preset }; return { preset: 'custom', enabled: [...form.querySelectorAll('input[name=toolsEnabled]:checked')].map((x) => x.value) }; }
|
|
// ---------------- [0.3.2] OAuth 자동 진행: 저장 직후 로그인 창(팝업)을 열고, 완료 페이지가 postMessage 로 알리면 확인까지 잇는다
|
|
let oauthWin = null;
|
|
async function startOauth(id) { const r = await api('POST', `/ops/api/ai/connections/${id}/oauth/start`); toast('공급자 로그인 창을 엽니다 — 승인하면 자동으로 돌아옵니다'); oauthWin = window.open(r.authorizeUrl, 'hal-oauth', 'width=600,height=760,noopener=no'); if (!oauthWin) { toast('팝업이 막혀 같은 창에서 이동합니다'); location.href = r.authorizeUrl; } }
|
|
window.addEventListener('message', async (e) => { if (e.origin !== location.origin || !e.data || e.data.type !== 'hal-oauth') return; try { oauthWin && oauthWin.close(); } catch (_) {} if (e.data.ok) { toast('OAuth 로그인 완료 — 토큰을 Vault 에 저장했습니다. 모델 목록을 확인합니다'); if (e.data.connection) { try { await api('POST', `/ops/api/ai/connections/${e.data.connection}/test`); } catch (err) { toast(`확인 실패: ${err.message}`); } } } else toast(`OAuth 로그인 실패: ${e.data.reason || '거부됨'}`); await loadConns(); });
|
|
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 출력 0.5초 간격 갱신, 입력은 Enter 로 전송
|
|
let shell = null; let shellT = null; let shellM = null;
|
|
async function openShell(tool, command = 'login') {
|
|
const r = await call('POST', '/ops/api/ai/shell', { tool, command }); shell = { id: r.session.id, next: 0, out: '' };
|
|
shellM = modal(`<h2>텍스트 셸 — ${A.esc(tool)} ${A.esc(command)}</h2>${r.guide && r.guide.length ? `<div class="alert info small"><b>안내</b><ol>${r.guide.map((g) => `<li>${A.esc(g)}</li>`).join('')}</ol></div>` : ''}<div id="ai-shell-view">${A.shellHtml(r.session, '')}</div><form id="ai-shell-form" class="row" autocomplete="off"><input id="ai-shell-in" placeholder="입력 후 Enter(코드·답변). 입력 내용은 저장되지 않습니다" autocomplete="off" style="max-width:none;flex:1"><button class="btn primary small" type="submit">보내기</button><button class="btn small" type="button" data-act="ai-shell-ctrlc">Ctrl+C</button><button class="btn small danger" type="button" data-act="ai-shell-kill">종료</button><button class="btn small" type="button" data-act="ai-shell-close">닫기</button></form>`);
|
|
$('#ai-shell-form', shellM.el).onsubmit = async (e) => { e.preventDefault(); const inp = $('#ai-shell-in', shellM.el); const v = inp.value; inp.value = ''; try { await api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: v + '\r' }); } catch (err) { toast(err.message); } };
|
|
const closeShell = () => { clearTimeout(shellT); shellM.close(); shell = null; loadClis(); loadConns(); };
|
|
shellM.el.querySelector('[data-act=ai-shell-close]').onclick = closeShell; shellM.el.querySelector('[data-act=ai-shell-ctrlc]').onclick = () => api('POST', `/ops/api/ai/shell/${shell.id}/input`, { data: '\u0003' }).catch((err) => toast(err.message)); shellM.el.querySelector('[data-act=ai-shell-kill]').onclick = () => api('DELETE', `/ops/api/ai/shell/${shell.id}`).catch((err) => toast(err.message));
|
|
$('#ai-shell-in', shellM.el).focus(); pollShell();
|
|
}
|
|
async function pollShell() { if (!shell) return; try { const r = await api('GET', `/ops/api/ai/shell/${shell.id}?after=${shell.next}`); shell.out += r.output; shell.next = r.next; const view = $('#ai-shell-view'); if (view) { view.innerHTML = A.shellHtml(r.session, shell.out); const pre = $('#ai-shell-out'); if (pre) pre.scrollTop = pre.scrollHeight; } if (r.session.status === 'running') shellT = setTimeout(pollShell, 500); else { toast(`셸 종료(코드 ${r.session.exitCode}) — 로그인 상태를 다시 탐색합니다`); loadClis(); loadConns(); } } catch (e) { toast(e.message); } }
|
|
async function loadMcp() { try { const r = await api('GET', '/ops/api/ai/mcp-config'); $('#ai-mcp').innerHTML = `<p class="small">Claude Code: <code>${A.esc(r.claudeCommand)}</code></p><pre class="pre small">${A.esc(JSON.stringify(r.claude, null, 1))}</pre><p class="small">Codex(<code>~/.codex/config.toml</code>):</p><pre class="pre small">${A.esc(r.codexToml)}</pre><p class="muted small">${A.esc(r.note)}</p>`; } catch (e) { $('#ai-mcp').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
|
function syncAuthFields() { const form = f(); if (!form) return; const prov = form.provider.value; const kinds = (providerOf(prov) || {}).authKinds || ({ anthropic: ['api_key', 'oauth'], openai: ['api_key', 'oauth'], gemini: ['api_key', 'oauth'], openrouter: ['api_key', 'oauth'], other: ['api_key', 'oauth'], 'local-cli': ['login'] }[prov] || ['api_key']); for (const o of form.authKind.options) o.hidden = !kinds.includes(o.value); if (!kinds.includes(form.authKind.value)) form.authKind.value = kinds[0]; for (const fs of form.querySelectorAll('fieldset[data-auth]')) fs.hidden = fs.dataset.auth !== form.authKind.value; renderGuide(); }
|
|
async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; providers = r.providers || providers; callbackUrl = r.callbackUrl || callbackUrl; $('#ai-conns').innerHTML = A.connectionsTable(conns); renderGuide(); }
|
|
async function loadClis() { try { const r = await api('GET', '/ops/api/ai/cli'); $('#ai-clis').innerHTML = A.clisHtml(r); } catch (e) { $('#ai-clis').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
|
async function loadTools() { try { const r = await api('GET', '/ops/api/ai/tools'); toolList = r.tools; $('#ai-tools').innerHTML = `<table class="r"><thead><tr><th>도구</th><th>위험 등급</th><th>설명</th></tr></thead><tbody>${r.tools.map((t) => `<tr><td data-l="도구"><code>${A.esc(t.name)}</code></td><td data-l="위험 등급"><span class="badge ${t.risk === 'read' ? 'ok' : t.risk === 'write' ? 'warn' : 'failed'}">${A.esc(t.riskLabel)}</span></td><td data-l="설명">${A.esc(t.description)}</td></tr>`).join('')}</tbody></table>`; } catch (e) { $('#ai-tools').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; } }
|
|
function fillForm(c) { const form = f(); form.id.value = c ? c.id : ''; form.name.value = c ? c.name : ''; form.provider.value = c ? c.provider : 'anthropic'; form.authKind.value = c ? c.authKind : 'api_key'; form.model.value = c ? c.model || '' : ''; form.baseUrl.value = c ? c.baseUrl || '' : ''; form.apiKey.value = ''; form.oauthClientSecret.value = '';
|
|
form.oauthAuthorizeUrl.value = c && c.oauth ? c.oauth.authorizeUrl : ''; form.oauthTokenUrl.value = c && c.oauth ? c.oauth.tokenUrl : ''; form.oauthClientId.value = c && c.oauth ? c.oauth.clientId : ''; form.oauthScopes.value = c && c.oauth ? (c.oauth.scopes || []).join(' ') : ''; form.cliBin.value = c && c.cli ? c.cli.bin || '' : ''; form.cliTool.value = c && c.cli && c.cli.tool ? c.cli.tool : 'claude';
|
|
form.role.value = c ? c.identity?.role || '' : ''; form.traits.value = c ? c.identity?.traits || '' : ''; form.instructions.value = c ? c.identity?.instructions || '' : '';
|
|
form.toolsPreset.value = c && c.tools ? c.tools.preset : 'all'; const box = $('#ai-tool-policy'); box.dataset.ready = ''; if (form.toolsPreset.value === 'custom') { box.innerHTML = A.toolPolicyHtml(toolList, c.tools.enabled); box.dataset.ready = '1'; } renderToolPolicy();
|
|
form.provider.disabled = !!c; form.authKind.disabled = !!c; $('#ai-conn-submit').textContent = c ? '수정 저장' : '연결 추가'; $('[data-act=ai-conn-cancel]', form).hidden = !c; $('#ai-conn-msg').textContent = ''; syncAuthFields(); if (c) form.scrollIntoView({ behavior: 'smooth' }); }
|
|
async function submitConn(e) {
|
|
e.preventDefault(); const form = f(); const fd = new FormData(form); const id = fd.get('id'); const msg = $('#ai-conn-msg'); msg.textContent = '';
|
|
const body = { name: fd.get('name'), model: fd.get('model') || undefined, baseUrl: fd.get('baseUrl') || undefined, identity: { role: fd.get('role') || '', traits: fd.get('traits') || '', instructions: fd.get('instructions') || '' }, tools: toolsBody() };
|
|
if (!id) { body.provider = form.provider.value; body.authKind = form.authKind.value; }
|
|
const kind = form.authKind.value;
|
|
if (kind === 'api_key' && fd.get('apiKey')) body.apiKey = fd.get('apiKey');
|
|
if (kind === 'oauth') { body.oauth = { authorizeUrl: fd.get('oauthAuthorizeUrl'), tokenUrl: fd.get('oauthTokenUrl'), clientId: fd.get('oauthClientId'), scopes: String(fd.get('oauthScopes') || '').split(/\s+/).filter(Boolean) }; if (fd.get('oauthClientSecret')) body.oauth.clientSecret = fd.get('oauthClientSecret'); }
|
|
if (kind === 'login') body.cli = { tool: fd.get('cliTool') || 'claude', bin: fd.get('cliBin') || undefined };
|
|
try {
|
|
if (id) { delete body.apiKey; await call('PATCH', `/ops/api/ai/connections/${encodeURIComponent(id)}`, body); toast('수정했습니다'); fillForm(null); form.reset(); syncAuthFields(); await loadConns(); return; }
|
|
const r = await call('POST', '/ops/api/ai/connections', body); fillForm(null); form.reset(); syncAuthFields(); form.apiKey.value = ''; form.oauthClientSecret.value = ''; await loadConns();
|
|
// [0.3.2] 다음 단계를 자동으로 진행: OAuth → 로그인 창, CLI → 탐색·로그인 확인(안 되어 있으면 텍스트 셸), API 키 → 모델 확인
|
|
const next = r.next || {}; const cid = r.connection.id; if (next.note) toast(next.note);
|
|
if (next.action === 'oauth') await startOauth(cid);
|
|
else if (next.action === 'cli_check') { try { const t = await api('POST', `/ops/api/ai/connections/${cid}/test`); toast(`CLI 확인됨 — ${t.cliVersion || ''} · 로그인 ${t.loggedIn === false ? '필요' : '됨'}`); } catch (err) { toast(`확인: ${err.message}`); if (err.code === 'cli_not_logged_in') await openShell(r.connection.cli && r.connection.cli.tool ? r.connection.cli.tool : 'claude', 'login'); } await loadConns(); }
|
|
else if (next.action === 'test') { try { const t = await api('POST', `/ops/api/ai/connections/${cid}/test`); toast(`확인됨 — 사용 가능 모델 ${t.models.length}개`); } catch (err) { toast(`확인 실패: ${err.message}`); } await loadConns(); }
|
|
}
|
|
catch (err) { msg.textContent = err.message; }
|
|
}
|
|
const CONN_ACT = {
|
|
'ai-conn-test': async (b) => { try { const r = await api('POST', `/ops/api/ai/connections/${b.id}/test`); toast(`확인됨 — 사용 가능 모델 ${r.models.length}개${r.cliVersion ? ' · ' + r.cliVersion : ''}`); } catch (e) { toast(`확인 실패: ${e.message}`); if (e.code === 'cli_not_logged_in') { const c = conns.find((x) => x.id === b.id); await openShell(c && c.cli && c.cli.tool ? c.cli.tool : 'claude', 'login'); } } await loadConns(); },
|
|
'ai-conn-oauth': async (b) => { await startOauth(b.id); },
|
|
'ai-shell-open': async (b) => { await openShell(b.tool, b.command || 'login'); },
|
|
'ai-tools-group': (b) => { for (const x of f().querySelectorAll(`input[name=toolsEnabled][data-risk=${b.risk}]`)) x.checked = b.on === '1'; },
|
|
'ai-conn-secret': async (b) => { const v = await ask('API 키 교체', [{ name: 'apiKey', label: '새 API 키(저장 뒤 다시 표시하지 않음)', type: 'password', required: true }], { okLabel: '교체(재인증 필요)' }); if (!v) return; await call('POST', `/ops/api/ai/connections/${b.id}/secret`, { apiKey: v.apiKey }); toast('키를 교체했습니다'); await loadConns(); },
|
|
'ai-conn-edit': (b) => fillForm(conns.find((c) => c.id === b.id) || null),
|
|
'ai-cli-refresh': () => loadClis(),
|
|
'ai-cli-use': (b) => { fillForm(null); const form = f(); form.provider.value = 'local-cli'; syncAuthFields(); form.authKind.value = 'login'; syncAuthFields(); form.cliTool.value = b.tool; form.model.value = b.model || ''; form.name.value = form.name.value || (b.tool === 'codex' ? '코덱스' : '클로드'); form.scrollIntoView({ behavior: 'smooth' }); form.name.focus(); },
|
|
'ai-conn-cancel': () => { fillForm(null); f().reset(); syncAuthFields(); },
|
|
'ai-conn-toggle': async (b) => { await api('PATCH', `/ops/api/ai/connections/${b.id}`, { status: b.to }); await loadConns(); },
|
|
'ai-conn-delete': async (b) => { const v = await ask('연결 삭제', [{ name: 'name', label: `삭제하려면 연결 이름 "${b.name}" 을 입력하세요`, required: true }], { okLabel: '삭제(재인증 필요)', danger: true }); if (!v || v.name !== b.name) return toast('이름이 다릅니다 — 삭제하지 않았습니다'); await call('DELETE', `/ops/api/ai/connections/${b.id}`); toast('삭제했습니다(Vault 비밀도 제거)'); await loadConns(); await loadRooms(); },
|
|
};
|
|
|
|
// ---------------- 이벤트
|
|
document.addEventListener('click', async (e) => {
|
|
const tab = e.target.closest('.ai-tab[data-room]'); if (tab) { e.preventDefault(); return switchRoom(tab.dataset.room).catch((err) => toast(err.message)); }
|
|
const el = e.target.closest('[data-act]'); if (!el) return; const act = el.dataset.act;
|
|
if (act === 'ai-toggle') { e.preventDefault(); const open = panel.classList.contains('closed'); saveState({ open }); applyState(); if (open) loadMessages({ full: true }).catch(() => {}); return; }
|
|
if (act === 'ai-reset') { e.preventDefault(); if (!current) return; const v = await ask('새 대화', [{ name: 'ok', label: '이 방의 대화 기록을 비우고 승인 대기 제안을 취소합니다', type: 'checkbox' }], { okLabel: '비우기', danger: true }); if (!v || v.ok !== 'on') return; await api('POST', `/ops/api/ai/rooms/${encodeURIComponent(current)}/reset`).catch((err) => toast(err.message)); return loadMessages({ full: true }); }
|
|
if (act === 'ai-approve') { e.preventDefault(); return approve(el.dataset.id, el.dataset.risk, (el.dataset.ids || '').split(',').filter(Boolean), el.dataset.tool).catch((err) => toast(err.message)); }
|
|
if (act === 'ai-reject') { e.preventDefault(); return reject(el.dataset.id).catch((err) => toast(err.message)); }
|
|
if (CONN_ACT[act]) { e.preventDefault(); try { await CONN_ACT[act](el.dataset); } catch (err) { toast(err.message); } }
|
|
});
|
|
document.addEventListener('dblclick', async (e) => { const tab = e.target.closest('.ai-tab[data-room]:not(.fixed)'); if (!tab) return; const r = rooms.find((x) => x.id === tab.dataset.room); const v = await ask('탭 이름 바꾸기', [{ name: 'title', label: '탭 이름(모델 이름이 기본값)', value: r ? r.title : '', required: true }]); if (!v) return; try { await api('PATCH', `/ops/api/ai/rooms/${encodeURIComponent(tab.dataset.room)}`, { title: v.title }); await loadRooms(); } catch (err) { toast(err.message); } });
|
|
if (panel) {
|
|
$('#ai-compose').addEventListener('submit', (e) => { e.preventDefault(); send(); });
|
|
$('#ai-input').addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); send(); } });
|
|
const rs = $('.ai-resize', panel); let drag = null;
|
|
rs.addEventListener('pointerdown', (e) => { drag = { x: e.clientX, w: panel.getBoundingClientRect().width }; rs.setPointerCapture(e.pointerId); });
|
|
rs.addEventListener('pointermove', (e) => { if (!drag) return; const w = Math.max(280, Math.min(window.innerWidth * 0.6, drag.w + (drag.x - e.clientX))); panel.style.setProperty('--ai-w', w + 'px'); });
|
|
rs.addEventListener('pointerup', () => { if (drag) saveState({ width: panel.getBoundingClientRect().width }); drag = null; });
|
|
window.addEventListener('resize', applyState); document.addEventListener('visibilitychange', () => { if (!document.hidden) schedule(300); });
|
|
applyState();
|
|
loadRooms().then(() => loadMessages({ full: true })).catch((e) => { $('#ai-tabs').innerHTML = `<span class="err small">AI 채팅룸을 불러오지 못했습니다: ${A.esc(e.message)}</span>`; });
|
|
}
|
|
if (PAGE_AI) {
|
|
const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.toolsPreset.addEventListener('change', renderToolPolicy); form.addEventListener('submit', submitConn); syncAuthFields();
|
|
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadClis(); loadTools(); loadMcp();
|
|
const q = new URLSearchParams(location.search); if (q.get('oauth') === 'ok') toast('OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
|
|
}
|