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
+22 -4
View File
@@ -7,6 +7,7 @@
* - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는
* 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음).
* - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css).
* - [0.3.0] 본문 오른쪽에 AI 채팅룸 패널(#ai-panel, hal/ui/public/ai.js)이 붙는다 — 통합 탭 고정 + 연결별 탭. 좁은 화면에서는 본문 아래로 내려간다(ops.css). 메뉴 'AI 연동'(/ops/ai)에서 연결을 관리한다.
* - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs).
*/
import { esc } from '../lib/http.mjs';
@@ -33,7 +34,7 @@ export const MENU = [
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['account', '/ops/account', '운영자 계정'],
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
];
export function registerUi(app, d) {
@@ -51,11 +52,12 @@ export function registerUi(app, d) {
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
<div class="shell"><aside class="side" id="side"><nav aria-label="운영자 메뉴">${MENU.map(([k, href, label]) => `<a class="nav${nav === k ? ' on' : ''}" href="${href}">${esc(label)}</a>`).join('')}</nav></aside>
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div></main></div>
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div></main>
<aside id="ai-panel" class="ai-panel" aria-label="AI 채팅룸" data-operator="${esc(ctx.operator.login)}"><div class="ai-resize" title="너비 조절"></div><div class="ai-head"><div class="ai-tabs" role="tablist" id="ai-tabs"><span class="muted small">AI 채팅룸 불러오는 중</span></div></div><div class="ai-status" id="ai-status"></div><div class="ai-log" id="ai-log" aria-live="polite"></div><form class="ai-compose" id="ai-compose"><textarea id="ai-input" rows="2" placeholder="지시를 입력하세요. 통합 방에서는 @이름 으로 특정 모델만 부를 수 있습니다" aria-label="AI 지시"></textarea><div class="ai-compose-row"><label class="small"><input type="checkbox" id="ai-ctx" checked> 현재 화면 문맥 포함</label><button class="btn small" type="button" data-act="ai-reset" title="이 방의 기록을 비웁니다">새 대화</button><button class="btn primary small" type="submit">보내기</button></div></form></aside></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
<script type="module" src="/ops/ui/ops.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
<script type="module" src="/ops/ui/ops.js"></script><script type="module" src="/ops/ui/ai.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
}
// ---------------- 로그인·최초 설정
app.get('/ops/login', (ctx) => ctx.operator ? ctx.redirect('/ops/') : ctx.html(loginPage(ctx, d)));
@@ -90,11 +92,27 @@ export function registerUi(app, d) {
app.get('/ops/audit', (ctx) => shell(ctx, { title: '감사 기록', nav: 'audit', page: 'audit', body: `<form class="row" method="get" action="/ops/audit"><input name="action" placeholder="행동(예: ops., release., emergency.)" value="${esc(ctx.query.action || '')}"><input name="subjectId" placeholder="대상 id" value="${esc(ctx.query.subjectId || '')}"><input type="date" name="since" value="${esc(ctx.query.since || '')}"><button class="btn">조회</button></form>` + R.auditTable(listAudit(d.store, { action: ctx.query.action || undefined, subjectId: ctx.query.subjectId || undefined, since: ctx.query.since || undefined, limit: 300 })) }));
// ---------------- 플랫폼 복구(§33·§34)
app.get('/ops/recovery', (ctx) => shell(ctx, { title: '플랫폼 복구', nav: 'recovery', page: 'recovery', body: `<div class="tabs"><a href="#emergency" class="on" data-tab="emergency">긴급 복구(§33)</a><a href="#dr" data-tab="dr">재해복구(§34)</a></div><section id="emergency">${R.emergencyView({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS })}</section><section id="dr" hidden>${R.drView({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50'), hal: core.selfHealth(d) })}</section>` }));
// ---------------- AI 연동(0.3.0, 개발 지시 v0.3.0): 모델 연결 관리 화면. 표·동작은 ai.js 가 /ops/api/ai/* 로 채운다. 비밀값은 서버로만 가고 화면에는 힌트만 돌아온다.
app.get('/ops/ai', (ctx) => shell(ctx, { title: 'AI 연동', nav: 'ai', page: 'ai', body: aiPage(d) }));
// ---------------- 운영자 계정(§25·§3.3)
app.get('/ops/account', (ctx) => shell(ctx, { title: '운영자 계정', nav: 'account', page: 'account', body: R.accountsView({ me: ctx.operator, recentlyAuthed: session.recentlyAuthed(ctx.session), via: ctx.opsVia, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })), grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 100'), accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }) }));
// [ops UI 추가] 서버 화면용 읽기 전용: 릴리스 변경 이력(VERSION_HISTORY.md 절)
app.get('/ops/api/changelog', () => ({ ok: true, changelog: changelog() }));
}
/** AI 연동 화면 본문(0.3.0). 연결 표(#ai-conns)는 ai.js 가 채우고, 연결 추가 양식은 세 가지 방식(API 키·OAuth·로그인) 중 하나를 고른다. 비밀값 입력란은 autocomplete=off, 저장 뒤 다시 보이지 않는다. */
function aiPage(d) {
return `<p class="muted">AI 모델을 HAL 에 연결합니다. 연결마다 이름·역할·특성(고유 정보)을 두며, 오른쪽 AI 채팅룸의 개별 탭이 됩니다(탭 이름 기본값 = 이름, 탭을 두 번 눌러 바꿀 수 있음). 통합 방은 항상 첫 탭입니다. 비밀값(API 키·OAuth 토큰)은 HAL Vault 에만 저장되고 다시 표시하지 않습니다 — 키 등록·교체·삭제는 최근 10분 재인증이 필요합니다.</p>
<div id="ai-conns">${R.statePanel('loading', '연결 목록을 불러오는 중')}</div>
<h2>연결 추가</h2><form id="ai-conn-form" class="card" autocomplete="off"><input type="hidden" name="id">
<div class="row"><label>이름<input name="name" required maxlength="60" placeholder="예: 네오"></label><label>공급자<select name="provider"><option value="anthropic">Anthropic(Claude)</option><option value="openai">OpenAI 호환</option><option value="local-cli">로컬 CLI 로그인 세션(Claude Code)</option></select></label><label>인증 방식<select name="authKind"><option value="api_key">API 키</option><option value="oauth">OAuth</option><option value="login">로그인(로컬 CLI 세션)</option></select></label></div>
<div class="row"><label>기본 모델<input name="model" placeholder="예: claude-opus-5-5 / gpt-5" maxlength="120"></label><label>API 기본 주소(선택, OpenAI 호환 공급자)<input name="baseUrl" placeholder="https://…/v1"></label></div>
<fieldset data-auth="api_key"><legend>API 키</legend><label>API 키(저장 뒤 다시 표시하지 않음)<input name="apiKey" type="password" autocomplete="new-password"></label></fieldset>
<fieldset data-auth="oauth" hidden><legend>OAuth(authorization code + PKCE)</legend><div class="row"><label>authorize URL<input name="oauthAuthorizeUrl" placeholder="https://…/authorize"></label><label>token URL<input name="oauthTokenUrl" placeholder="https://…/oauth/token"></label></div><div class="row"><label>client id<input name="oauthClientId"></label><label>scope(공백 구분)<input name="oauthScopes"></label><label>client secret(선택, 저장 뒤 미표시)<input name="oauthClientSecret" type="password" autocomplete="new-password"></label></div><p class="muted small">저장 뒤 표의 "OAuth 로그인"을 누르면 공급자 로그인 화면으로 이동하고, 돌아오면 토큰이 Vault 에 저장됩니다. 콜백 주소: <code>${esc(d.config.publicUrl)}/ops/ai/oauth/callback</code></p></fieldset>
<fieldset data-auth="login" hidden><legend>로그인(로컬 CLI 세션)</legend><label>CLI 실행 파일 절대 경로(비우면 PATH 의 claude)<input name="cliBin" placeholder="/usr/local/bin/claude"></label><p class="muted small">HAL 서버에 설치되어 이미 로그인된 Claude Code 세션을 그대로 씁니다(HAL 은 세션 파일·자격을 읽지 않음). 이 방식은 대화·분석 전용이며 HAL 도구 호출은 지원하지 않습니다.</p></fieldset>
<fieldset><legend>고유 정보</legend><div class="row"><label>역할·업무<input name="role" maxlength="2000" placeholder="예: 릴리스 검토·배포 감시"></label><label>특성<input name="traits" maxlength="2000" placeholder="예: 꼼꼼함, 근거 우선"></label></div><label>추가 지시<textarea name="instructions" rows="2" maxlength="2000" placeholder="이 모델에게만 주는 지시"></textarea></label></fieldset>
<div class="actions"><button class="btn primary" type="submit" id="ai-conn-submit">연결 추가</button><button class="btn" type="button" data-act="ai-conn-cancel" hidden>수정 취소</button></div><p class="err" id="ai-conn-msg" role="alert"></p></form>
<h2>AI 가 쓰는 HAL 도구</h2><p class="muted small">읽기 도구는 즉시 실행됩니다. 쓰기·고위험 도구는 채팅룸에 "제안"으로 멈추고 운영자가 승인(재인증, 고위험은 3단계 확인)해야 실행됩니다. 임의 셸 명령 도구는 없습니다. MCP 클라이언트용: <code>node hal/index.mjs mcp</code>(읽기 전용).</p><div id="ai-tools">${R.statePanel('loading', '도구 목록을 불러오는 중')}</div>`;
}
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
function loginPage(ctx, d, { setup = false } = {}) {
const needSetup = !core.ensureOpsAccountExists(d); const via = ctx.opsVia === 'grant' ? '기기 귀속 접근권' : '허용 네트워크';