72 lines
14 KiB
JavaScript
72 lines
14 KiB
JavaScript
/**
|
||
* 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>`;
|
||
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>'}${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>`;
|
||
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>`;
|
||
}
|
||
|
||
/** [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>`;
|
||
}
|
||
|
||
/** [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('');
|
||
}
|