/**
* 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 `${esc(label)} `; };
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 `${r.fixed ? '◎ ' : ''}${esc(r.title)}${st !== 'active' && !r.fixed ? ' · ' : ''} `; }).join('') + `+ `;
}
/** 블록 하나 → HTML */
export function blockHtml(b, { actions = {} } = {}) {
if (!b) return '';
if (b.type === 'text') return b.text ? `
${esc(b.text).replace(/\n/g, ' ')}
` : '';
if (b.type === 'tool_use') return `도구 ${esc(b.name)} ${esc(JSON.stringify(b.input || {})).slice(0, 300)}
`;
if (b.type === 'tool_result') return `${b.ok ? '결과' : '실패'} ${esc(b.name)}${esc(String(b.output ?? '').slice(0, 6000))} `;
if (b.type === 'proposal') { const a = actions[b.actionId] || { status: 'proposed' }; const st = a.status; return `${esc(b.riskLabel || b.risk)} ${esc(b.tool)} ${esc(b.description || '')}
${esc(JSON.stringify(b.args || {}, null, 1)).slice(0, 2000)} ${st === 'proposed' ? `
승인${b.risk === 'high' ? '(재인증·3단계 확인)' : '(재인증)'} 거부
` : `
${esc({ executed: '승인·실행됨', rejected: '거부됨(실행 안 함)', failed: '승인했지만 실행 실패', cancelled: '취소됨', executing: '실행 중' }[st] || st)}${a.decidedBy ? ` · ${esc(a.decidedBy)}` : ''}${a.error ? ` · ${esc(a.error)} ` : ''}
`}
`; }
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' ? `응답 실패 — ${esc(m.error || '알 수 없는 오류')}
` : '';
const usage = m.usage && (m.usage.inputTokens != null || m.usage.outputTokens != null) ? ` · 토큰 입력 ${m.usage.inputTokens ?? '제공되지 않음'} / 출력 ${m.usage.outputTokens ?? '제공되지 않음'} ` : '';
return `${esc(who)} ${m.relayDepth ? ' 전달 응답 ' : ''} ${esc(fmtTime(m.createdAt))} ${usage} ${body}${failed} `;
}
/** 상태 줄: 방 종류·연결·모델·진행 중 */
export function statusLine(room, pending) {
if (!room) return '';
if (room.kind === 'group') return `통합 방 — 지시는 모든 활성 모델에게, @이름 으로 지정하면 그 모델만 답합니다. 모델→모델 전달(@이름)은 자동 1회만. ${pending ? ` 응답 중 ${pending} ` : ''}`;
const c = room.connection || {}; return `${statusBadge(c.status)} ${esc(c.name || '')} · ${esc(c.model || '')} · ${esc(c.provider || '')} ${pending ? ` 응답 중 ` : ''}`;
}
/** 연결 표(AI 연동 화면). 비밀값은 힌트만 온다. */
export function connectionsTable(conns) {
if (!conns.length) return `연결된 AI 모델이 없습니다 — 아래 "연결 추가"로 API 키·OAuth·로그인(로컬 CLI) 방식 중 하나로 연결하세요
`;
const row = (c) => `${esc(c.name)} ${c.tabLabel ? `탭: ${esc(c.tabLabel)} ` : ''}${esc(c.identity?.role || '역할 없음')}${c.identity?.traits ? ' · ' + esc(c.identity.traits) : ''} ${esc(c.providerLabel)}${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 ? ` ${esc(c.baseUrl)}` : ''}${c.cli ? ` ${esc(c.cli.tool || 'claude')}${c.cli.version ? ' ' + esc(c.cli.version) : ''}${c.cli.bin ? `${esc(c.cli.bin)} ` : ' · 자동 탐색'}${c.cli.loggedIn === false ? ' · 로그인 필요 ' : c.cli.loggedIn === true ? ' · 로그인됨' : ''}` : ''} ${esc(c.model || '')}${c.models?.length ? `사용 가능 ${c.models.length}개 ` : '확인 전 '}${statusBadge(c.status)}${c.lastOkAt ? '마지막 확인 ' + esc(c.lastOkAt) : '확인 전'} ${c.lastError ? `${esc(c.lastError)} ` : ''} 확인 ${c.authKind === 'oauth' ? `${c.status === 'active' ? '다시 로그인' : 'OAuth 로그인'} ` : ''}${c.authKind === 'api_key' ? `키 교체 ` : ''}수정 ${c.status === 'disabled' ? '활성화' : '비활성화'} 삭제
`;
return `이름·역할 공급자·인증 모델 상태 ${conns.map(row).join('')}
`;
}
/** [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 `서버에서 CLI 를 찾지 못했습니다 — 탐색 위치: ${esc(searched.slice(0, 6).join(', '))}${searched.length > 6 ? ' …' : ''}. 서버(HAL 실행 계정)에 Claude Code 또는 Codex CLI 를 설치하거나 연결 설정에 절대 경로를 적으세요.
` + `다시 탐색
`;
const row = (c) => `${esc(c.label)} ${esc(c.bin)}${c.realpath && c.realpath !== c.bin ? ` → ${esc(c.realpath)}` : ''} ${c.version ? esc(c.version) : `${esc(c.versionError || '확인 실패')} `} ${c.loggedIn === true ? '로그인됨 ' + (c.loginMethod ? ` ${esc(c.loginMethod)} ` : '') : c.loggedIn === false ? `로그인 필요 ${esc(c.loginHint || '')} ` : '확인 안 됨 '} ${esc(c.defaultModel || '')}${c.models && c.models.length ? `사용 가능 ${c.models.length}개 ` : ''}이 CLI 로 연결 추가 `;
return `CLI 버전 로그인 기본 모델 ${clis.map(row).join('')}
다시 탐색
`;
}