2026-10-29 01:11:32 +00:00
/* hal/ui/public/ai.js — 운영자 콘솔 AI 채팅룸 패널·AI 연동 화면 브라우저 스크립트(0.3.0, 개발 지시 v0.3.0 · 0.3.1 서버 설치 CLI 자동 탐색 카드).
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건 통과
2026-10-28 17:41:50 +00:00
* - 패널(#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 ); }
2026-10-29 01:11:32 +00:00
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>` ; } }
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건 통과
2026-10-28 17:41:50 +00:00
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 = '' ;
2026-10-29 01:11:32 +00:00
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' ;
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건 통과
2026-10-28 17:41:50 +00:00
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' ); }
2026-10-29 01:11:32 +00:00
if ( kind === 'login' ) body . cli = { tool : fd . get ( 'cliTool' ) || 'claude' , bin : fd . get ( 'cliBin' ) || undefined };
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건 통과
2026-10-28 17:41:50 +00:00
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 ),
2026-10-29 01:11:32 +00:00
'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 (); },
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건 통과
2026-10-28 17:41:50 +00:00
'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 ();
2026-10-29 01:11:32 +00:00
loadConns (). catch (( e ) => { $ ( '#ai-conns' ). innerHTML = `<div class="aw-state failed"> ${ A . esc ( e . message ) } </div>` ; }); loadClis (); loadTools ();
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건 통과
2026-10-28 17:41:50 +00:00
const q = new URLSearchParams ( location . search ); if ( q . get ( 'oauth' ) === 'ok' ) toast ( 'OAuth 로그인이 완료되어 토큰을 Vault 에 저장했습니다 — "확인"으로 모델 목록을 받아 두세요' );
}