// 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 }) => (
);
// ─────────────────────────────────────────────────────────
// 답변 폼 — 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.regulation && (
{card.regulation.source}{card.regulation.clause ? ' ' + card.regulation.clause : ''}
)}
{!naMode && (
{card.input_type === 'table' && (
엑셀에서 표를 복사해 셀에 붙여넣으면(Ctrl+V) 자동으로 채워집니다.
)}
{card.input_type === 'file' && (
{file &&
선택됨: {file.name}
}
)}
{card.input_type === 'choice' && (
{opts.length === 0 &&
선택지가 제공되지 않았습니다. 직접 입력하세요.
}
{opts.length === 0 ? (
)}
{(!card.input_type || card.input_type === 'text') && (
)}
{naMode && (
이 항목이 우리 제품에 해당하지 않는 이유를 적어주세요. 심사에서는 빈칸과 ‘해당없음’이 다르게 평가됩니다.
)}
{err &&
{err}
}
{card.marker_count > 0 && 빈칸 {card.marker_count}개}
);
};
// ─────────────────────────────────────────────────────────
// 요청(RFI) 탭
// ─────────────────────────────────────────────────────────
const RfiTab = ({ cards, summary, loading, filters, setFilters, active, setActive, reload, seed, onOpenGuide }) => {
if (active) {
return (
setActive(null)}
onDone={(d) => {
setActive(null);
reload();
if (d && d.remaining != null) {
window.RAlly.__aiToast && window.RAlly.__aiToast('제출 완료 · 남은 빈칸 ' + d.remaining + '개');
}
}}
/>
);
}
return (
{filters.doc_id && (
setFilters({ ...filters, doc_id: '' })} style={{ cursor: 'pointer' }}>
현재 문서만 ✕
)}
{summary && (
열림 {summary.open != null ? summary.open : '–'} · 답변 {summary.answered != null ? summary.answered : '–'} · 해당없음 {summary.na != null ? summary.na : '–'}
{summary.by_priority && summary.by_priority['1'] != null && (
· 제출 차단(1순위) {summary.by_priority['1']}
)}
)}
{loading &&
}
{!loading && cards.length === 0 && (
)}
{cards.map((c) => (
setActive(c)}>
{ROLE_LABEL[c.role] || c.role}
{c.priority === 1 && 긴급}
{c.doc_code || ''}
{STATUS_LABEL[c.status] || c.status}
{c.title}
{c.why &&
{c.why}
}
{c.marker_count > 0 && 빈칸 {c.marker_count}}
{ e.stopPropagation(); setActive(c); onOpenGuide(c); }}>가이드 보기
))}
);
};
// ─────────────────────────────────────────────────────────
// 가이드 탭
// ─────────────────────────────────────────────────────────
const GuideTab = ({ guide, loading, card, onUseTemplate }) => {
const [openReg, setOpenReg] = React.useState(false);
if (loading) return ;
if (!card) return ;
if (!guide) return ;
const t = guide.template || {};
return (
{guide.title}
{/* ① 평문 설명 */}
{guide.plain && (
)}
{/* ② 빈 템플릿 */}
② 빈 템플릿
{t.type === 'table' && t.headers && (
{t.headers.map((h, i) => | {h} | )}
{Array.from({ length: Math.max(1, t.rows || 2) }).map((_, i) => (
{t.headers.map((h, j) => | | )}
))}
)}
{t.type === 'outline' && (
{(t.bullets || []).map((b, i) => - {b}
)}
)}
{t.type === 'text' &&
{t.skeleton || ''}}
{!t.type &&
템플릿이 제공되지 않았습니다.
}
{t.type && (
)}
{/* ③ 작성 예시 */}
{guide.examples && guide.examples.length > 0 && (
③ 작성 예시
{guide.examples.map((ex, i) => (
{ex.source === 'own' ? '우리 회사 이전 문서' : '업계 표준 문구'}
{ex.label && {ex.label}}
{ex.text}
))}
)}
{/* ④ 체크리스트 */}
{guide.checklist && guide.checklist.length > 0 && (
④ 체크리스트
{guide.checklist.map((c, i) => - {c}
)}
)}
{/* ⑤ 과거 보완 사례 */}
{guide.common_findings && guide.common_findings.length > 0 && (
⑤ 과거 보완 요구 사례
{guide.common_findings.map((c, i) => - {c}
)}
)}
{/* 규정 원문(접힘) */}
{guide.regulation && (
setOpenReg((v) => !v)}>
규정 원문 · {guide.regulation.source}{guide.regulation.clause ? ' ' + guide.regulation.clause : ''}
{openReg &&
{guide.regulation.text || '(원문 없음)'}}
)}
);
};
// ─────────────────────────────────────────────────────────
// 근거(cite) — 접이식
// ─────────────────────────────────────────────────────────
const SRC_LABEL = { regulation: '규정', guide: '가이드', product: '제품지식', catalog: '카탈로그', live: '내 데이터' };
const Cites = ({ cites }) => {
const [open, setOpen] = React.useState(false);
if (!cites || !cites.length) return null;
return (
setOpen((v) => !v)}>
근거 {cites.length}건
{open && (
{cites.map((c, i) => (
-
{SRC_LABEL[c.source] || c.source || '근거'}
{c.cite || ''}{c.title ? ' — ' + c.title : ''}
))}
)}
);
};
// ─────────────────────────────────────────────────────────
// 대화 탭 (+ 인터뷰 흐름)
// ─────────────────────────────────────────────────────────
const ChatTab = ({ ctx, screen, go, onAsk, trackId, docId }) => {
const [msgs, setMsgs] = React.useState([
{ role: 'bot', text: '무엇을 도와드릴까요? 현재 화면·트랙을 보고 근거를 갖고 답합니다.\n예: "우리 AI 제품은 어떤 별표가 적용돼?", "지금 우리 트랙 상태 어때?", "제품표준서 제조방법 어떻게 써?"' },
]);
const [input, setInput] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [sid, setSid] = React.useState(null); // 대화 세션(멀티턴)
const [iv, setIv] = React.useState(null); // 인터뷰 세션 {session_id, question, progress}
const [ivAns, setIvAns] = React.useState('');
const [backend, setBackend] = React.useState(null); // 'api' | 'cli' | 'none'
const [hintOff, setHintOff] = React.useState(hintDismissed);
const endRef = React.useRef(null);
React.useEffect(() => { if (endRef.current) endRef.current.scrollIntoView({ block: 'end' }); }, [msgs, busy, iv]);
const send = (t) => {
const q = (t != null ? t : input).trim();
if (!q || busy) return;
setMsgs((m) => m.concat([{ role: 'me', text: q }]));
setInput(''); setBusy(true);
jpost('/api/assistant/chat', {
message: q,
session_id: sid || undefined,
context: { track_id: trackId, doc_id: docId, page: screen },
}).then((d) => {
setBusy(false);
if (!d) {
setMsgs((m) => m.concat([{ role: 'bot', text: '어시스턴트 서버에 연결하지 못했습니다. (백엔드 /api/assistant/chat 준비 전이거나 응답 없음)' }]));
return;
}
if (d.session_id && d.session_id !== sid) setSid(d.session_id);
if (d.backend) setBackend(d.backend);
setMsgs((m) => m.concat([{
role: 'bot', text: d.reply || '(빈 응답)',
actions: d.actions || [], asks: d.asks || [], cites: d.cites || [],
backend: d.backend || null, degraded: !!d.degraded,
}]));
});
};
const doAction = (a) => {
const p = a.payload || {};
if (p.track_id) window.__RALLY_TRACK = p.track_id;
if (p.doc_id) window.__RALLY_DOC = p.doc_id;
if (a.kind === 'navigate' || a.kind === 'screen' || p.screen) { go(p.screen || a.kind); return; }
if (a.kind === 'interview') { startInterview(p.topic); return; }
if (a.kind === 'api' && p.url) {
setBusy(true);
jpost(p.url, p.body || {}).then((d) => {
setBusy(false);
setMsgs((m) => m.concat([{ role: 'bot', text: d && d.reply ? d.reply : (d ? '실행했습니다.' : '실행에 실패했습니다.') }]));
});
return;
}
// 알 수 없는 kind — 지어내지 말고 정직하게
setMsgs((m) => m.concat([{ role: 'bot', text: '이 동작(' + a.kind + ')은 아직 화면에서 지원하지 않습니다.' }]));
};
const startInterview = (topic) => {
setBusy(true);
jpost('/api/interview/start', { track_id: trackId, topic: topic }).then((d) => {
setBusy(false);
if (!d || !d.question) {
setMsgs((m) => m.concat([{ role: 'bot', text: '인터뷰를 시작하지 못했습니다. (백엔드 /api/interview/start 미준비)' }]));
return;
}
setIv({ session_id: d.session_id, question: d.question, progress: d.progress });
});
};
const answerInterview = () => {
if (!iv || !ivAns.trim()) return;
setBusy(true);
const a = ivAns.trim();
setMsgs((m) => m.concat([{ role: 'me', text: a }]));
setIvAns('');
jpost('/api/interview/answer', { session_id: iv.session_id, answer: a }).then((d) => {
setBusy(false);
if (!d) { setMsgs((m) => m.concat([{ role: 'bot', text: '답변 저장에 실패했습니다.' }])); setIv(null); return; }
if (d.done) {
setIv(null);
setMsgs((m) => m.concat([{ role: 'bot', text: '인터뷰가 끝났습니다.' + (d.applied ? ' 입력한 정보를 문서에 반영했습니다.' : '') }]));
return;
}
setIv({ session_id: iv.session_id, question: d.question, progress: d.progress });
});
};
return (
{msgs.map((m, i) => (
{m.text}
{m.role === 'bot' && m.degraded &&
}
{m.actions && m.actions.length > 0 && (
{m.actions.map((a, j) => (
))}
)}
{m.cites && m.cites.length > 0 &&
}
{m.asks && m.asks.length > 0 && (
{m.asks.map((c, j) => (
onAsk(c)}>
{ROLE_LABEL[c.role] || c.role}
{c.doc_code || ''}
{c.title}
답변하기 →
))}
)}
))}
{busy && (
생각 중… (규정·가이드 근거를 찾고 있습니다. 수 초 걸릴 수 있어요)
)}
{iv ? (
제품 인터뷰
{iv.progress && {iv.progress.step} / {iv.progress.total}}
{iv.progress && iv.progress.total ? (
) : null}
{iv.question.text}
{iv.question.help &&
{iv.question.help}
}
{iv.question.input_type === 'choice' && (iv.question.options || []).length > 0 ? (
{iv.question.options.map((o) => (
))}
) : (
) : (
{backend === 'cli' && !hintOff && (
{ dismissHint(); setHintOff(true); }} />
)}
)}
);
};
// ─────────────────────────────────────────────────────────
// 패널 본체
// ─────────────────────────────────────────────────────────
const AssistantPanel = ({ screen, go, open, onToggle }) => {
const [tab, setTab] = React.useState('chat');
const [ctx, setCtx] = React.useState(null);
const [ctxTried, setCtxTried] = React.useState(false);
const [trackId, setTrackId] = React.useState(window.__RALLY_TRACK || null);
const [docId, setDocId] = React.useState(window.__RALLY_DOC || null);
const [cards, setCards] = React.useState([]);
const [summary, setSummary] = React.useState(null);
const [loadingCards, setLoadingCards] = React.useState(false);
const [filters, setFilters] = React.useState({ role: '', status: 'open', doc_id: '', priority: '' });
const [active, setActive] = React.useState(null);
const [guide, setGuide] = React.useState(null);
const [loadingGuide, setLoadingGuide] = React.useState(false);
const [seed, setSeed] = React.useState(null);
const [toast, setToast] = React.useState('');
React.useEffect(() => {
window.RAlly.__aiToast = (t) => { setToast(t); setTimeout(() => setToast(''), 3200); };
}, []);
// 화면 전환 시 컨텍스트(트랙·문서) 재해석
React.useEffect(() => {
const t = window.__RALLY_TRACK || null;
const d = screen === 'editor' ? (window.__RALLY_DOC || null) : null;
setTrackId(t);
setDocId(d);
// 화면이 바뀌면 열어 둔 답변 폼은 닫는다(다른 문서의 폼이 남아 있지 않도록)
setActive(null);
setSeed(null);
// 에디터에서 문서를 열면 요청 탭을 해당 문서로 자동 필터
setFilters((f) => ({ ...f, doc_id: d || '' }));
}, [screen]);
// 컨텍스트 헤더
React.useEffect(() => {
if (!open) return;
const qs = [];
if (trackId) qs.push('track_id=' + encodeURIComponent(trackId));
if (docId) qs.push('doc_id=' + encodeURIComponent(docId));
qs.push('page=' + encodeURIComponent(screen));
jget('/api/assistant/context?' + qs.join('&')).then((d) => { setCtx(d); setCtxTried(true); });
}, [open, screen, trackId, docId]);
// 요청 목록
const loadCards = React.useCallback(() => {
if (!open || !trackId) { setCards([]); setSummary(null); return; }
setLoadingCards(true);
const qs = ['track_id=' + encodeURIComponent(trackId)];
if (filters.role) qs.push('role=' + filters.role);
if (filters.status) qs.push('status=' + filters.status);
if (filters.doc_id) qs.push('doc_id=' + encodeURIComponent(filters.doc_id));
if (filters.priority) qs.push('priority=' + filters.priority);
jget('/api/rfi/list?' + qs.join('&')).then((d) => {
setLoadingCards(false);
setCards((d && d.cards) || []);
setSummary((d && d.summary) || null);
});
}, [open, trackId, filters.role, filters.status, filters.doc_id, filters.priority]);
React.useEffect(() => { loadCards(); }, [loadCards]);
// 선택 카드의 가이드 — /api/rfi/{id}가 카드 컨텍스트를 반영한 가이드를 함께 준다
React.useEffect(() => {
if (!active) { setGuide(null); return; }
setLoadingGuide(true);
const q = active.track_id ? ('?track_id=' + encodeURIComponent(active.track_id)) : '';
jget('/api/rfi/' + encodeURIComponent(active.id) + q).then((d) => {
setLoadingGuide(false);
setGuide((d && d.guide) || null);
});
}, [active]);
const openCard = (c) => { setActive(c); setSeed(null); setTab('rfi'); };
const useTemplate = (g) => {
const t = (g && g.template) || {};
if (t.type === 'table') setSeed({ rows: Array.from({ length: Math.max(1, t.rows || 3) }, () => (t.headers || []).map(() => '')) });
else if (t.type === 'outline') setSeed({ value: (t.bullets || []).map((b) => '- ' + b + '\n ').join('\n') });
else setSeed({ value: t.skeleton || '' });
setTab('rfi');
};
const openCount = (summary && summary.open != null) ? summary.open
: (ctx && ctx.open_rfi != null ? ctx.open_rfi : cards.filter((c) => c.status === 'open').length);
if (!open) {
return (
);
}
return (
);
};
window.RAlly.AssistantPanel = AssistantPanel;