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
/**
* 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 ) => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' }[ 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>` ;
2026-10-29 01:11:32 +00:00
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 ) } ` : '' }${ c . cli ? `<br> ${ esc ( c . cli . tool || 'claude' ) }${ c . cli . version ? ' ' + esc ( c . cli . version ) : '' }${ c . cli . bin ? `<br><span class="mono"> ${ esc ( c . cli . bin ) } </span>` : ' · 자동 탐색' }${ c . cli . loggedIn === false ? ' · <span class="err">로그인 필요</span>' : c . cli . loggedIn === true ? ' · 로그인됨' : '' } ` : '' } </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>` ;
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
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>` ;
}
2026-10-29 01:11:32 +00:00
/** [0.3.1] 서버에서 찾은 CLI 표(/ops/api/ai/cli). 로그인 안 됨은 안내 문구와 함께, 연결 추가 단추는 양식을 미리 채운다. */
export function clisHtml ( data ) {
const clis = ( data && data . clis ) || []; const searched = ( data && data . searched ) || [];
if ( ! clis . length ) return `<div class="aw-state">서버에서 CLI 를 찾지 못했습니다 — 탐색 위치: ${ esc ( searched . slice ( 0 , 6 ). join ( ', ' )) }${ searched . length > 6 ? ' …' : '' } . 서버(HAL 실행 계정)에 Claude Code 또는 Codex CLI 를 설치하거나 연결 설정에 절대 경로를 적으세요.</div>` + `<div class="actions"><button class="btn small" data-act="ai-cli-refresh">다시 탐색</button></div>` ;
const row = ( c ) => `<tr><td data-l="CLI"><b> ${ esc ( c . label ) } </b><br><span class="muted small mono"> ${ esc ( c . bin ) }${ c . realpath && c . realpath !== c . bin ? ` → ${ esc ( c . realpath ) } ` : '' } </span></td><td data-l="버전"> ${ c . version ? esc ( c . version ) : `<span class="err"> ${ esc ( c . versionError || '확인 실패' ) } </span>` } </td><td data-l="로그인"> ${ c . loggedIn === true ? '<span class="badge ok">로그인됨</span>' + ( c . loginMethod ? ` <span class="muted small"> ${ esc ( c . loginMethod ) } </span>` : '' ) : c . loggedIn === false ? `<span class="badge warn">로그인 필요</span><br><span class="muted small"> ${ esc ( c . loginHint || '' ) } </span>` : '<span class="badge neutral">확인 안 됨</span>' } </td><td data-l="기본 모델"><code> ${ esc ( c . defaultModel || '' ) } </code> ${ c . models && c . models . length ? `<br><span class="muted small">사용 가능 ${ c . models . length } 개</span>` : '' } </td><td data-l=""><button class="btn small primary" data-act="ai-cli-use" data-tool=" ${ esc ( c . tool ) } " data-model=" ${ esc ( c . defaultModel || '' ) } " data-label=" ${ esc ( c . label ) } ">이 CLI 로 연결 추가</button></td></tr>` ;
return `<table class="r"><thead><tr><th>CLI</th><th>버전</th><th>로그인</th><th>기본 모델</th><th></th></tr></thead><tbody> ${ clis . map ( row ). join ( '' ) } </tbody></table><div class="actions"><button class="btn small" data-act="ai-cli-refresh">다시 탐색</button></div>` ;
}