/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드). * - 패널(#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 = ``; 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(`

${A.esc(title)}

${note ? `

${note}

` : ''}
${fields.map((f) => f.type === 'textarea' ? `` : f.type === 'checkbox' ? `` : ``).join('')}${fields.some((f) => f.hint) ? `

${fields.filter((f) => f.hint).map((f) => A.esc(f.hint)).join('
')}

` : ''}
`); $('#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 = ``; $('#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('') : `
${room() && room().kind === 'group' ? '통합 방입니다. 연결된 모든 활성 모델이 함께 듣습니다.' : '아직 대화가 없습니다.'}
`; $('#ai-status').innerHTML = A.statusLine(room(), r.pending); 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 = []; const f = () => $('#ai-conn-form'); function syncAuthFields() { const form = f(); if (!form) return; const prov = form.provider.value; const kinds = { anthropic: ['api_key', 'oauth'], openai: ['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; } async function loadConns() { const r = await api('GET', '/ops/api/ai/connections'); conns = r.connections; $('#ai-conns').innerHTML = A.connectionsTable(conns); } async function loadClis() { try { const r = await api('GET', '/ops/api/ai/cli'); $('#ai-clis').innerHTML = A.clisHtml(r); } catch (e) { $('#ai-clis').innerHTML = `
${A.esc(e.message)}
`; } } async function loadTools() { try { const r = await api('GET', '/ops/api/ai/tools'); $('#ai-tools').innerHTML = `${r.tools.map((t) => ``).join('')}
도구위험 등급설명
${A.esc(t.name)}${A.esc(t.riskLabel)}${A.esc(t.description)}
`; } catch (e) { $('#ai-tools').innerHTML = `
${A.esc(e.message)}
`; } } 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.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') || '' } }; 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('수정했습니다'); } else { await call('POST', '/ops/api/ai/connections', body); toast('연결을 추가했습니다 — "확인"으로 모델 목록을 받아 두세요'); } fillForm(null); form.reset(); syncAuthFields(); await loadConns(); form.apiKey.value = ''; form.oauthClientSecret.value = ''; } 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}`); } await loadConns(); }, 'ai-conn-oauth': async (b) => { const r = await api('POST', `/ops/api/ai/connections/${b.id}/oauth/start`); location.href = r.authorizeUrl; }, '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 = `AI 채팅룸을 불러오지 못했습니다: ${A.esc(e.message)}`; }); } if (PAGE_AI) { const form = f(); form.provider.addEventListener('change', () => { syncAuthFields(); }); form.authKind.addEventListener('change', syncAuthFields); form.addEventListener('submit', submitConn); syncAuthFields(); loadConns().catch((e) => { $('#ai-conns').innerHTML = `
${A.esc(e.message)}
`; }); loadClis(); loadTools(); const q = new URLSearchParams(location.search); if (q.get('oauth') === 'ok') toast('OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요'); }