// assistant.jsx — 전역 AI 어시스턴트 패널(모든 화면 우측 상주) // 3탭: 대화 / 요청(RFI) / 가이드 // 백엔드가 아직 없거나 404여도 크래시하지 않고 "아직 없음"을 정직하게 표시한다. const { Icon, Pill, Button, Progress } = window.RAlly; // ── 안전 fetch: 실패·404·비JSON이면 null 반환(패널 크래시 금지) ── const jget = (url) => fetch(url) .then((r) => (r.ok ? r.json() : null)) .then((d) => (d && d.ok !== false ? d : (d && d.ok === false ? null : d))) .catch(() => null); const jpost = (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }) .then((r) => (r.ok ? r.json() : null)) .catch(() => null); const PANEL_KEY = 'RALLY_AI_PANEL'; // '1' 열림 / '0' 접힘 window.RAlly.isPanelOpen = () => { try { return localStorage.getItem(PANEL_KEY) !== '0'; } catch (e) { return true; } }; window.RAlly.setPanelOpen = (b) => { try { localStorage.setItem(PANEL_KEY, b ? '1' : '0'); } catch (e) {} }; // 스피너 키프레임(전역 CSS를 건드리지 않고 주입) (() => { if (document.getElementById('ai-spin-kf')) return; const st = document.createElement('style'); st.id = 'ai-spin-kf'; st.textContent = '@keyframes ai-spin{to{transform:rotate(360deg)}}'; document.head.appendChild(st); })(); const ROLE_LABEL = { dev: '개발', ra: 'RA', qa_manager: '품질책임자' }; const STATUS_LABEL = { open: '열림', answered: '답변됨', na: '해당없음' }; // ── LLM 백엔드 상태 배지 ── // 정상 답변에는 아무것도 띄우지 않는다(노이즈 금지). 폴백(degraded)일 때만, // 백엔드가 정말 없는 경우와 이번 호출이 실패한 경우를 구분해 작게 표시한다. const BackendBadge = ({ backend, degraded }) => { if (!degraded) return null; const none = backend === 'none'; return (
{none ? 'AI 백엔드 미설정' : '근거만 표시 — 요약 생성 실패'}
); }; // ── API 키 안내(백엔드가 cli뿐일 때 한 번만) ── const APIKEY_HINT_KEY = 'RALLY_AI_APIKEY_HINT_DISMISSED'; const hintDismissed = () => { try { return localStorage.getItem(APIKEY_HINT_KEY) === '1'; } catch (e) { return true; } }; const dismissHint = () => { try { localStorage.setItem(APIKEY_HINT_KEY, '1'); } catch (e) {} }; const ApiKeyHint = ({ onClose }) => (
지금은 claude CLI 백엔드로 답하고 있어 응답에 20~40초가 걸립니다. ANTHROPIC_API_KEY를 서버 환경변수로 설정하면 응답이 수 초대로 빨라집니다.
); // ── 빈 상태 ── const Empty = ({ title, note }) => (
{title}
{note &&
{note}
}
); // ───────────────────────────────────────────────────────── // 답변 폼 — input_type별 (text / table / file / choice) + 해당없음 // ───────────────────────────────────────────────────────── const AnswerForm = ({ card, seed, onDone, onCancel }) => { const spec = card.table_spec || null; const headers = (spec && spec.headers) || []; const initRows = () => { if (seed && seed.rows && seed.rows.length) return seed.rows.map((r) => r.slice()); const n = Math.max(1, (spec && spec.rows) || 3); return Array.from({ length: n }, () => headers.map(() => '')); }; const [text, setText] = React.useState((seed && seed.value) || ''); const [rows, setRows] = React.useState(initRows); const [choice, setChoice] = React.useState(''); const [file, setFile] = React.useState(null); // {name, b64} const [naMode, setNaMode] = React.useState(false); const [naReason, setNaReason] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); // seed(가이드 템플릿 주입)가 바뀌면 폼 갱신 React.useEffect(() => { if (!seed) return; if (seed.value != null) setText(seed.value); if (seed.rows && seed.rows.length) setRows(seed.rows.map((r) => r.slice())); }, [seed]); const setCell = (i, j, v) => setRows((rs) => rs.map((r, ri) => (ri === i ? r.map((c, ci) => (ci === j ? v : c)) : r))); // 엑셀 붙여넣기(TSV) — 클릭한 셀을 시작점으로 채움 const onPaste = (i, j) => (e) => { const txt = (e.clipboardData || window.clipboardData).getData('text'); if (!txt || txt.indexOf('\t') < 0 && txt.indexOf('\n') < 0) return; // 단일 셀은 기본 동작 e.preventDefault(); const grid = txt.replace(/\r/g, '').replace(/\n+$/, '').split('\n').map((l) => l.split('\t')); setRows((rs) => { const w = headers.length || (grid[0] ? grid[0].length : 1); const next = rs.map((r) => r.slice()); grid.forEach((line, gi) => { const ri = i + gi; while (next.length <= ri) next.push(Array.from({ length: w }, () => '')); line.forEach((cell, gj) => { const ci = j + gj; if (ci < w) next[ri][ci] = cell; }); }); return next; }); }; const pickFile = (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { const s = String(rd.result || ''); setFile({ name: f.name, b64: s.slice(s.indexOf(',') + 1) }); }; rd.readAsDataURL(f); }; const submit = () => { setErr(''); setBusy(true); let body = {}; if (naMode) { if (!naReason.trim()) { setErr('해당 없음 사유를 적어주세요. (규제 문서에서 빈칸과 해당없음은 다릅니다)'); setBusy(false); return; } body = { na_reason: naReason.trim() }; } else if (card.input_type === 'table') { const clean = rows.filter((r) => r.some((c) => String(c).trim())); if (!clean.length) { setErr('표에 최소 1행을 입력하세요.'); setBusy(false); return; } body = { rows: clean }; } else if (card.input_type === 'file') { if (!file) { setErr('파일을 선택하세요.'); setBusy(false); return; } body = { file_b64: file.b64, filename: file.name }; } else if (card.input_type === 'choice') { if (!choice) { setErr('항목을 선택하세요.'); setBusy(false); return; } body = { value: choice }; } else { if (!text.trim()) { setErr('내용을 입력하세요.'); setBusy(false); return; } body = { value: text.trim() }; } jpost('/api/rfi/' + encodeURIComponent(card.id) + '/answer', body).then((d) => { setBusy(false); if (!d || d.ok === false) { setErr('제출에 실패했습니다. 서버 응답을 받지 못했습니다.'); return; } onDone(d); }); }; const opts = (card.options || (card.table_spec && card.table_spec.options) || []); return (
{ROLE_LABEL[card.role] || card.role}
{card.title}
{card.doc_code ? card.doc_code + ' · ' : ''}{card.doc_name || ''}{card.section ? ' · ' + card.section : ''}
{card.why &&
왜 필요한가
{card.why}
} {card.regulation && (
{card.regulation.source}{card.regulation.clause ? ' ' + card.regulation.clause : ''}
)} {!naMode && (
{card.input_type === 'table' && (
엑셀에서 표를 복사해 셀에 붙여넣으면(Ctrl+V) 자동으로 채워집니다.
{headers.map((h, j) => )} {rows.map((r, i) => ( {headers.map((h, j) => ( ))} ))}
{h}
setCell(i, j, e.target.value)} onPaste={onPaste(i, j)} />
)} {card.input_type === 'file' && (
{file &&
선택됨: {file.name}
}
)} {card.input_type === 'choice' && (
{opts.length === 0 &&
선택지가 제공되지 않았습니다. 직접 입력하세요.
} {opts.length === 0 ? (