Files
AriWeb-Suite/hal/ui/public/ai-render.mjs
T

44 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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)}` : ''}</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>`;
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>`;
}