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>` : '' } ` ;
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
const c = room . connection || {}; return ` ${ statusBadge ( c . status ) } <span class="muted small"> ${ esc ( c . name || '' ) } · ${ esc ( c . provider || '' ) } </span> ${ modelSelectHtml ({ id : c . id , model : c . model , models : c . models } )} ${ c . status === 'reauth_required' ? ` <button class="btn small" data-act="ai-fix-login" data-id=" ${ esc ( c . id ) } ">로그인·재인증</button>` : '' }${ pending ? ` <span class="badge running">응답 중</span>` : '' } ` ;
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 연동 화면). 비밀값은 힌트만 온다. */
export function connectionsTable ( conns ) {
if ( ! conns . length ) return `<div class="aw-state">연결된 AI 모델이 없습니다 — 아래 "연결 추가"로 API 키·OAuth·로그인(로컬 CLI) 방식 중 하나로 연결하세요</div>` ;
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +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="모델"> ${ c . models ? . length ? modelSelectHtml ( c ) : `<code> ${ esc ( c . model || '' ) } </code><br><span class="muted small">확인 전</span>` }${ c . tools ? `<br><span class="muted small">도구 ${ esc ( c . tools . preset ) } ( ${ c . tools . enabled . length } ) ${ c . toolsVia === 'mcp' ? ' · MCP' : '' } </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>` ;
}
HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
2026-10-29 01:43:02 +00:00
/** [0.3.2] 사전 준비 안내(공급자·인증 방식별). provider 는 /ops/api/ai/connections 의 providers 항목. */
export function guideHtml ( provider , authKind , callbackUrl ) {
if ( ! provider ) return '<b>사전 준비</b> — 공급자를 고르세요.' ; const g = provider . guide || {}; const lines = [];
if ( authKind === 'api_key' ) { lines . push (...( g . steps || [])); if ( g . keyUrl ) lines . push ( `키 발급: <a href=" ${ esc ( g . keyUrl ) } " target="_blank" rel="noopener"> ${ esc ( g . keyUrl ) } </a>` ); lines . push ( '키는 저장 뒤 Vault 에만 있고 다시 표시하지 않습니다. 저장하면 바로 "확인"(모델 목록)을 진행합니다.' ); }
else if ( authKind === 'oauth' ) { if ( g . oauth ) lines . push ( esc ( g . oauth )); if ( provider . oauthPreset && provider . oauthPreset . mode === 'openrouter_pkce' ) lines . push ( '저장하면 OpenRouter 로그인 창이 자동으로 열립니다(팝업 허용). 승인하면 키가 자동 발급되어 Vault 에 저장됩니다.' ); else lines . push ( `HAL 콜백 주소(공급자의 리디렉션 URI 에 등록): <code> ${ esc ( callbackUrl || '' ) } </code>` , '저장하면 공급자 로그인 창이 자동으로 열립니다(팝업이 막히면 같은 창에서 이동).' ); }
else { lines . push (...( g . steps || [])); lines . push ( '저장하면 서버에서 CLI 를 탐색해 로그인 상태를 확인하고, 로그인이 안 되어 있으면 텍스트 셸(login)을 자동으로 엽니다.' ); }
return `<b>사전 준비 — ${ esc ( provider . label ) } · ${ esc ({ api_key : 'API 키' , oauth : 'OAuth' , login : '로그인(CLI)' } [authKind] || authKind)}</b><ol class="small"> ${ lines . map (( l ) => `<li> ${ /< a |< code > /.test(l) ? l : esc(l)}</li>`).join('')}</ol>`;
}
/** [0.3.2] 텍스트 셸 화면: 출력은 ANSI 제어 문자를 걷어 내고 이스케이프한다. */
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[()][A-Z0-9]|\r/g;
export function shellHtml(session, output) {
const st = session.status === 'running' ? '<span class="badge running">실행 중</span>' : session.status === 'exited' ? ` < span class = "badge ${session.exitCode === 0 ? 'ok' : 'failed'}" > 종료 ( $ { session . exitCode } )</span>` : `<span class="badge warn"> ${ esc ( session . status ) } </span>` ;
return `<div class="ai-shell-head"><b> ${ esc ( session . tool ) } ${ esc ( session . command ) } </b> ${ st } <span class="muted small"> ${ esc ( session . bin || '' ) }${ session . pty === false ? ' · PTY 없음' : '' } </span></div><pre class="ai-shell-out" id="ai-shell-out"> ${ esc ( String ( output || '' ). replace ( ANSI_RE , '' )) } </pre>` ;
}
/** [0.3.2] 도구 정책 선택 목록(위험 등급별 체크박스). tools: /ops/api/ai/tools 의 tools, enabled: 현재 켜진 이름 */
export function toolPolicyHtml ( tools , enabled ) {
const on = new Set ( enabled || []); const groups = [[ 'read' , '읽기(즉시 실행)' ], [ 'write' , '쓰기(승인·재인증)' ], [ 'high' , '고위험(승인·재인증·3단계 확인)' ]];
return groups . map (([ risk , label ]) => { const list = tools . filter (( t ) => t . risk === risk ); return `<div class="ai-tool-group"><b> ${ esc ( label ) } </b> <button type="button" class="btn small" data-act="ai-tools-group" data-risk=" ${ risk } " data-on="1">모두 켜기</button> <button type="button" class="btn small" data-act="ai-tools-group" data-risk=" ${ risk } " data-on="0">모두 끄기</button><div class="ai-tool-list"> ${ list . map (( t ) => `<label class="small" title=" ${ esc ( t . description ) } "><input type="checkbox" name="toolsEnabled" value=" ${ esc ( t . name ) } " data-risk=" ${ risk } " ${ on . has ( t . name ) ? ' checked' : '' } > ${ esc ( t . name ) } </label>` ). join ( '' ) } </div></div>` ; }). join ( '' );
}
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
/** [0.3.9] 모델 선택(확인된 목록에서). 목록이 없으면 "확인" 안내. */
export function modelSelectHtml ( c ) {
const models = ( c && c . models ) || []; if ( ! models . length ) return `<span class="muted small">모델 목록 확인 전 — "확인"을 누르면 고를 수 있습니다</span>` ;
const cur = c . model || '' ; const opts = [... new Set ([ cur , ... models ]. filter ( Boolean ))];
return `<select class="ai-model-pick" data-act="ai-model-pick" data-id=" ${ esc ( c . id ) } " aria-label="모델 선택"> ${ opts . map (( m ) => `<option value=" ${ esc ( m ) } " ${ m === cur ? ' selected' : '' } > ${ esc ( m ) }${ ! models . includes ( m ) ? ' (목록 밖)' : '' } </option>` ). join ( '' ) } </select>` ;
}
/** [0.3.9] 텍스트 셸 세션 목록(살아 있는 것·끝난 것 구분, 열기·종료) */
export function shellSessionsHtml ( sessions ) {
if ( ! sessions || ! sessions . length ) return '<span class="muted small">열린 텍스트 셸이 없습니다.</span>' ;
return `<table class="r"><thead><tr><th>세션</th><th>상태</th><th>시작</th><th></th></tr></thead><tbody> ${ sessions . map (( s ) => `<tr><td data-l="세션"><b> ${ esc ( s . tool ) } ${ esc ( s . command ) } </b> <span class="muted small mono"> ${ esc ( s . id ) } </span></td><td data-l="상태"> ${ s . status === 'running' ? '<span class="badge running">실행 중</span>' : `<span class="badge ${ s . exitCode === 0 ? 'ok' : 'neutral' } "> ${ esc ( s . status ) }${ s . exitCode != null ? `( ${ s . exitCode } )` : '' } </span>` } </td><td data-l="시작"><span class="muted small"> ${ esc ( fmtTime ( s . startedAt )) } </span></td><td data-l=""><div class="actions"> ${ s . status === 'running' ? `<button class="btn small" data-act="ai-shell-attach" data-id=" ${ esc ( s . id ) } " data-tool=" ${ esc ( s . tool ) } " data-command=" ${ esc ( s . command ) } ">열기</button><button class="btn small danger" data-act="ai-shell-kill-id" data-id=" ${ esc ( s . id ) } ">종료</button>` : `<button class="btn small" data-act="ai-shell-attach" data-id=" ${ esc ( s . id ) } " data-tool=" ${ esc ( s . tool ) } " data-command=" ${ esc ( s . command ) } ">출력 보기</button>` } </div></td></tr>` ). join ( '' ) } </tbody></table>` ;
}
/** [0.3.9] /help 명령 목록 */
export function commandsHelpHtml ( commands ) { return `<table class="r"><thead><tr><th>명령</th><th>설명</th></tr></thead><tbody> ${ ( commands || []). map (( c ) => `<tr><td data-l="명령"><code> ${ esc ( c . usage ) } </code></td><td data-l="설명"> ${ esc ( c . description ) }${ c . kind === 'local' ? ' <span class="muted small">(HAL 즉시)</span>' : '' } </td></tr>` ). join ( '' ) } </tbody></table>` ; }
2026-10-29 02:49:46 +00:00
/** [0.3.10] 셸 출력에서 감지된 주소 목록(복사·열기). */
export function shellUrlsHtml ( urls ) {
if ( ! urls || ! urls . length ) return '<span class="muted small">감지된 주소 없음 — 셸에 https:// 주소가 나오면 여기에 복사·열기 단추가 생깁니다.</span>' ;
return `<ul class="ai-urls"> ${ urls . map (( u ) => `<li><code class="ai-url"> ${ esc ( u ) } </code> <button type="button" class="btn small primary" data-act="ai-shell-copy-url" data-url=" ${ esc ( u ) } ">복사</button> <a class="btn small" href=" ${ esc ( u ) } " target="_blank" rel="noopener noreferrer">열기</a></li>` ). join ( '' ) } </ul>` ;
}
/** [0.3.10] 텍스트 셸 모달 본문(순수). 단말 영역·키 단추·주소 목록·복사·텍스트 보기·줄 입력. */
export function shellModalHtml ( session , guide = []) {
const keys = [[ 'Enter' , '\r' ], [ 'Tab' , '\t' ], [ 'Esc' , '\u001b' ], [ '↑' , '\u001b[A' ], [ '↓' , '\u001b[B' ], [ '←' , '\u001b[D' ], [ '→' , '\u001b[C' ], [ 'y' , 'y' ], [ 'n' , 'n' ], [ 'Ctrl+C' , '\u0003' ], [ 'Ctrl+D' , '\u0004' ]];
return `<h2>텍스트 셸 — ${ esc ( session . tool ) } ${ esc ( session . command ) } </h2> ${ guide && guide . length ? `<div class="alert info small"><b>안내</b><ol> ${ guide . map (( g ) => `<li> ${ esc ( g ) } </li>` ). join ( '' ) } </ol></div>` : '' } <div id="ai-shell-view"> ${ shellHtml ( session , '' ) } </div>
<div class="ai-term-wrap"><pre class="ai-term" id="ai-term" tabindex="0" title="클릭한 뒤 키를 누르면 그대로 전달됩니다. 글을 드래그로 선택하면 Ctrl+C 가 복사가 됩니다(Ctrl+Shift+C 는 항상 복사)"></pre></div>
<div class="actions ai-keys"> ${ keys . map (([ l , b ]) => `<button type="button" class="btn small" data-key=" ${ esc ( b ) } "> ${ esc ( l ) } </button>` ). join ( '' ) } </div>
<div class="ai-shell-tools"><b class="small">감지된 주소</b> <span class="muted small">(줄 끝에서 끊긴 긴 주소도 이어 붙여 보입니다)</span><div id="ai-shell-urls"> ${ shellUrlsHtml ([]) } </div>
<div class="actions"><button type="button" class="btn small" data-act="ai-shell-copy-all">전체 출력 복사</button><button type="button" class="btn small" data-act="ai-shell-copy-sel">선택 복사</button><button type="button" class="btn small" data-act="ai-shell-text">텍스트로 보기/닫기</button><span class="muted small">단말에서 드래그 선택 뒤 Ctrl+Shift+C 또는 "선택 복사". 셸에 Ctrl+C 를 보내려면 선택을 풀고 누르거나 키 단추를 쓰세요.</span></div>
<textarea id="ai-shell-textview" class="ai-shell-textview" rows="10" readonly hidden aria-label="셸 출력 텍스트(선택·복사용)"></textarea></div>
<form id="ai-shell-form" class="row" autocomplete="off"><input id="ai-shell-in" placeholder="줄 입력: 글을 쓰고 Enter(코드 붙여넣기·한글 입력용). 저장되지 않습니다" autocomplete="off" style="max-width:none;flex:1"><button class="btn primary small" type="submit">보내기</button><button class="btn small danger" type="button" data-act="ai-shell-kill">세션 종료</button><button class="btn small" type="button" data-act="ai-shell-close">닫기(종료)</button></form>` ;
}