HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5.1
2026-10-28 17:41:50 +00:00
parent 6bfde82c5e
commit ba3c5cb2f8
23 changed files with 1494 additions and 11 deletions
+43
View File
@@ -0,0 +1,43 @@
/**
* hal/ui/public/ai-render.mjs — AI 채팅 패널의 순수 렌더러(0.3.0). DOM·fetch 없이 HTML 문자열만 만들어 Node 테스트와 브라우저(ai.js)가 같이 쓴다.
* 모든 값은 esc() 로 이스케이프한다(모델 응답·도구 결과·운영자 입력은 신뢰하지 않는 글이다).
*/
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const STATUS = { active: ['ok', '연결됨'], disabled: ['neutral', '비활성'], reauth_required: ['warn', '재인증 필요'], error: ['failed', '오류'] };
export const statusBadge = (st) => { const [cls, label] = STATUS[st] || ['neutral', st || '알 수 없음']; return `<span class="badge ${cls}">${esc(label)}</span>`; };
export const fmtTime = (iso) => { if (!iso) return ''; const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); };
/** 탭 줄: 통합 방이 항상 먼저(고정, 이름 변경 불가), 그다음 연결별 방. rooms 는 /ops/api/ai/rooms 응답 순서 그대로. */
export function tabsHtml(rooms, currentId) {
const sorted = [...rooms.filter((r) => r.kind === 'group'), ...rooms.filter((r) => r.kind !== 'group')];
return sorted.map((r) => { const on = r.id === currentId; const st = r.connection ? r.connection.status : 'active'; return `<button class="ai-tab${on ? ' on' : ''}${r.fixed ? ' fixed' : ''}${st !== 'active' ? ' off' : ''}" role="tab" aria-selected="${on}" data-room="${esc(r.id)}" title="${esc(r.fixed ? '통합 방(모든 모델)' : `${r.connection ? r.connection.name : ''} · ${r.connection ? r.connection.model || '' : ''} · ${(STATUS[st] || [0, st])[1]}`)}">${r.fixed ? '◎ ' : ''}${esc(r.title)}${st !== 'active' && !r.fixed ? ' <span class="ai-dot" aria-label="비활성">·</span>' : ''}</button>`; }).join('') + `<a class="ai-tab add" href="/ops/ai" title="AI 연동(연결 추가·설정)">+</a>`;
}
/** 블록 하나 → HTML */
export function blockHtml(b, { actions = {} } = {}) {
if (!b) return '';
if (b.type === 'text') return b.text ? `<div class="ai-text">${esc(b.text).replace(/\n/g, '<br>')}</div>` : '';
if (b.type === 'tool_use') return `<div class="ai-tool"><span class="badge info">도구</span> <code>${esc(b.name)}</code> <span class="muted small mono">${esc(JSON.stringify(b.input || {})).slice(0, 300)}</span></div>`;
if (b.type === 'tool_result') return `<details class="ai-result${b.ok ? '' : ' err'}"><summary><span class="badge ${b.ok ? 'ok' : 'failed'}">${b.ok ? '결과' : '실패'}</span> <code>${esc(b.name)}</code></summary><pre class="pre">${esc(String(b.output ?? '').slice(0, 6000))}</pre></details>`;
if (b.type === 'proposal') { const a = actions[b.actionId] || { status: 'proposed' }; const st = a.status; return `<div class="ai-proposal ${esc(st)}" data-action="${esc(b.actionId)}"><div><span class="badge ${b.risk === 'high' ? 'failed' : 'warn'}">${esc(b.riskLabel || b.risk)}</span> <b>${esc(b.tool)}</b> <span class="muted small">${esc(b.description || '')}</span></div><pre class="pre small">${esc(JSON.stringify(b.args || {}, null, 1)).slice(0, 2000)}</pre>${st === 'proposed' ? `<div class="actions"><button class="btn ${b.risk === 'high' ? 'danger' : 'primary'} small" data-act="ai-approve" data-id="${esc(b.actionId)}" data-risk="${esc(b.risk)}" data-ids="${esc((b.identifiers || []).join(','))}" data-tool="${esc(b.tool)}">승인${b.risk === 'high' ? '(재인증·3단계 확인)' : '(재인증)'}</button> <button class="btn small" data-act="ai-reject" data-id="${esc(b.actionId)}">거부</button></div>` : `<div class="muted small">${esc({ executed: '승인·실행됨', rejected: '거부됨(실행 안 함)', failed: '승인했지만 실행 실패', cancelled: '취소됨', executing: '실행 중' }[st] || st)}${a.decidedBy ? ` · ${esc(a.decidedBy)}` : ''}${a.error ? ` · <span class="err">${esc(a.error)}</span>` : ''}</div>`}</div>`; }
return '';
}
/** 메시지 하나 → HTML. role: user(운영자) · assistant(모델) · tool(도구 결과) · system(HAL 안내) */
export function messageHtml(m, { actions = {} } = {}) {
const who = m.role === 'user' ? (m.author || '운영자') : m.role === 'system' ? 'HAL' : (m.author || '모델');
const body = (m.content || []).map((b) => blockHtml(b, { actions })).join('');
const failed = m.status === 'failed' ? `<div class="err small">응답 실패 — ${esc(m.error || '알 수 없는 오류')}</div>` : '';
const usage = m.usage && (m.usage.inputTokens != null || m.usage.outputTokens != null) ? `<span class="muted small"> · 토큰 입력 ${m.usage.inputTokens ?? '제공되지 않음'} / 출력 ${m.usage.outputTokens ?? '제공되지 않음'}</span>` : '';
return `<article class="ai-msg ${esc(m.role)}${m.relayDepth ? ' relay' : ''}${m.status === 'failed' ? ' failed' : ''}" data-seq="${m.seq}"><header><b>${esc(who)}</b>${m.relayDepth ? ' <span class="badge neutral">전달 응답</span>' : ''}<span class="muted small"> ${esc(fmtTime(m.createdAt))}</span>${usage}</header>${body}${failed}</article>`;
}
/** 상태 줄: 방 종류·연결·모델·진행 중 */
export function statusLine(room, pending) {
if (!room) return '';
if (room.kind === 'group') return `<span class="muted small">통합 방 — 지시는 모든 활성 모델에게, <code>@이름</code> 으로 지정하면 그 모델만 답합니다. 모델→모델 전달(@이름)은 자동 1회만.</span>${pending ? ` <span class="badge running">응답 중 ${pending}</span>` : ''}`;
const c = room.connection || {}; return `${statusBadge(c.status)} <span class="muted small">${esc(c.name || '')} · ${esc(c.model || '')} · ${esc(c.provider || '')}</span>${pending ? ` <span class="badge running">응답 중</span>` : ''}`;
}
/** 연결 표(AI 연동 화면). 비밀값은 힌트만 온다. */
export function connectionsTable(conns) {
if (!conns.length) return `<div class="aw-state">연결된 AI 모델이 없습니다 — 아래 "연결 추가"로 API 키·OAuth·로그인(로컬 CLI) 방식 중 하나로 연결하세요</div>`;
const row = (c) => `<tr data-conn="${esc(c.id)}"><td data-l="이름"><b>${esc(c.name)}</b>${c.tabLabel ? `<br><span class="muted small">탭: ${esc(c.tabLabel)}</span>` : ''}<br><span class="muted small">${esc(c.identity?.role || '역할 없음')}${c.identity?.traits ? ' · ' + esc(c.identity.traits) : ''}</span></td><td data-l="공급자·인증">${esc(c.providerLabel)}<br><span class="muted small">${esc({ api_key: 'API 키', oauth: 'OAuth', login: '로그인(CLI)' }[c.authKind] || c.authKind)}${c.secret?.hint ? ` · ${esc(c.secret.hint)}` : ''}${c.secret?.expiresAt ? ` · 만료 ${esc(c.secret.expiresAt)}` : ''}${c.baseUrl ? `<br>${esc(c.baseUrl)}` : ''}</span></td><td data-l="모델"><code>${esc(c.model || '')}</code>${c.models?.length ? `<br><span class="muted small">사용 가능 ${c.models.length}개</span>` : '<br><span class="muted small">확인 전</span>'}</td><td data-l="상태">${statusBadge(c.status)}<br><span class="muted small">${c.lastOkAt ? '마지막 확인 ' + esc(c.lastOkAt) : '확인 전'}</span>${c.lastError ? `<br><span class="err small">${esc(c.lastError)}</span>` : ''}</td><td data-l="조치"><div class="actions"><button class="btn small" data-act="ai-conn-test" data-id="${esc(c.id)}">확인</button>${c.authKind === 'oauth' ? `<button class="btn small" data-act="ai-conn-oauth" data-id="${esc(c.id)}">${c.status === 'active' ? '다시 로그인' : 'OAuth 로그인'}</button>` : ''}${c.authKind === 'api_key' ? `<button class="btn small" data-act="ai-conn-secret" data-id="${esc(c.id)}">키 교체</button>` : ''}<button class="btn small" data-act="ai-conn-edit" data-id="${esc(c.id)}">수정</button><button class="btn small" data-act="ai-conn-toggle" data-id="${esc(c.id)}" data-to="${c.status === 'disabled' ? 'active' : 'disabled'}">${c.status === 'disabled' ? '활성화' : '비활성화'}</button><button class="btn small danger" data-act="ai-conn-delete" data-id="${esc(c.id)}" data-name="${esc(c.name)}">삭제</button></div></td></tr>`;
return `<table class="r"><thead><tr><th>이름·역할</th><th>공급자·인증</th><th>모델</th><th>상태</th><th></th></tr></thead><tbody>${conns.map(row).join('')}</tbody></table>`;
}
+144
View File
@@ -0,0 +1,144 @@
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0).
* - 패널(#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>`;
$('#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 loadTools() { try { const r = await api('GET', '/ops/api/ai/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.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 = { 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-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.addEventListener('submit', submitConn); syncAuthFields();
loadConns().catch((e) => { $('#ai-conns').innerHTML = `<div class="aw-state failed">${A.esc(e.message)}</div>`; }); loadTools();
const q = new URLSearchParams(location.search); if (q.get('oauth') === 'ok') toast('OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요');
}
+14
View File
@@ -25,3 +25,17 @@ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(-
body.login{display:grid;place-items:center;min-height:100vh}.login-box{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px;width:min(420px,94vw)}
@media (max-width:760px){.menu-btn{display:block}.side{position:fixed;left:-230px;top:48px;bottom:0;transition:left .2s;z-index:30;overflow:auto}.side.open{left:0}.main{padding:10px 12px}.top-right .muted{display:none}
table.r{display:block;border:0;background:none}table.r thead{display:none}table.r tr{display:block;background:#fff;border:1px solid var(--line);border-radius:var(--rad);margin-bottom:8px;padding:6px 10px}table.r td{display:flex;justify-content:space-between;gap:8px;border:0;padding:5px 0;flex-wrap:wrap}table.r td::before{content:attr(data-l);color:var(--mut);font-size:12px;min-width:70px}.kv{grid-template-columns:1fr}}
/* 0.3.0 AI 채팅룸 패널: 본문 오른쪽 고정, 너비 조절, 접기. 1100px 이하에서는 본문 아래 전체 너비. 상태는 글자+배지로 구분 */
.ai-panel{width:var(--ai-w,400px);min-width:280px;max-width:60vw;flex:0 0 auto;border-left:1px solid var(--line);background:#fff;display:flex;flex-direction:column;position:sticky;top:48px;height:calc(100vh - 48px);min-height:0}
.ai-panel.closed{display:none}.ai-resize{position:absolute;left:0;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2}.ai-panel{position:sticky}.ai-panel>.ai-resize{position:absolute}.ai-panel{isolation:isolate}
.ai-head{border-bottom:1px solid var(--line);padding:6px 6px 0 10px}.ai-tabs{display:flex;flex-wrap:wrap;gap:2px}
.ai-tab{border:1px solid transparent;border-bottom:0;background:none;padding:6px 10px;border-radius:8px 8px 0 0;cursor:pointer;font-size:13px;color:var(--fg);text-decoration:none}.ai-tab.on{background:#e6edfb;color:var(--pri);font-weight:600;border-color:var(--line)}.ai-tab.fixed{font-weight:600}.ai-tab.off{color:var(--mut)}.ai-tab.add{color:var(--mut)}.ai-dot{color:var(--warn)}
.ai-status{padding:4px 10px;border-bottom:1px solid var(--line);font-size:12px;min-height:22px}
.ai-log{flex:1;overflow:auto;padding:8px 10px;display:flex;flex-direction:column;gap:8px;min-height:0}
.ai-msg{border:1px solid var(--line);border-radius:10px;padding:8px 10px;background:#fff;font-size:13px}.ai-msg header{margin-bottom:4px}.ai-msg.user{background:#e8effc;border-color:#b9cdf2}.ai-msg.assistant{background:#fff}.ai-msg.tool{background:#f7f9fc}.ai-msg.system{background:#fff7e6;border-color:#f0c36d}.ai-msg.failed{border-color:var(--bad)}.ai-msg.relay{border-style:dashed}
.ai-text{white-space:pre-wrap;word-break:break-word}.ai-tool{margin:4px 0;font-size:12px}.ai-result summary{cursor:pointer;font-size:12px}.ai-result .pre{max-height:200px}
.ai-proposal{border:1px solid var(--warn);background:#fff7e6;border-radius:8px;padding:8px;margin-top:6px}.ai-proposal.executed{border-color:var(--ok);background:#eef8f1}.ai-proposal.rejected,.ai-proposal.cancelled{border-color:var(--line);background:#f7f9fc}.ai-proposal.failed{border-color:var(--bad);background:#fdecea}
.ai-compose{border-top:1px solid var(--line);padding:8px 10px;display:flex;flex-direction:column;gap:6px}.ai-compose textarea{max-width:none;resize:vertical}.ai-compose-row{display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap}.ai-compose-row label{margin-right:auto;font-weight:400}
.badge.neutral{border-color:var(--line);color:var(--mut)}
@media (max-width:1100px){.shell{flex-wrap:wrap}.ai-panel{position:static;width:100%!important;max-width:none;height:60vh;border-left:0;border-top:1px solid var(--line)}.ai-resize{display:none}}