Merge pull request 'Feat/eid chat' (#35) from feat/eid-chat into main
Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
EidEvidenceCard — 이드 채팅 deep(검색) 답변의 근거 카드 (ds-eid-ask-absorb P1).
|
||||
|
||||
ReactResult.sources = {id, doc_id, title, score} (citation 번호 n 없음 — /ask 의 Citation 과
|
||||
다름) → 순서 기반 번호([1],[2]...). 1단계 카드 = 제목·출처·점수 (스니펫은 react_loop
|
||||
_result_payload items_src 에 없음 — 2단계 후보). 접이식 <details> 로 채팅 흐름 보존.
|
||||
디자인 토큰만 (CLAUDE.md lint:tokens).
|
||||
-->
|
||||
<script lang="ts">
|
||||
type EidSource = { id?: number; doc_id?: number; title?: string; score?: number };
|
||||
let { sources, partial = false }: { sources: EidSource[]; partial?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
{#if sources.length}
|
||||
<details class="mt-2 rounded-lg border border-default bg-surface text-xs max-w-[85%] sm:max-w-[75%]">
|
||||
<summary class="cursor-pointer px-3 py-2 text-dim hover:text-text select-none font-semibold">
|
||||
근거 {sources.length}개{partial ? ' · 부분 답변 (확정 근거 부족)' : ''}
|
||||
</summary>
|
||||
<ul class="px-3 pb-2.5 flex flex-col gap-1.5">
|
||||
{#each sources as src, i (src.id ?? i)}
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-accent font-bold shrink-0">[{i + 1}]</span>
|
||||
<span class="flex-1 min-w-0 text-text break-words">{src.title || `문서 ${src.doc_id ?? '?'}`}</span>
|
||||
{#if typeof src.score === 'number'}
|
||||
<span class="text-faint shrink-0 tabular-nums">{src.score.toFixed(2)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
@@ -17,6 +17,12 @@
|
||||
macbook_unavailable / substrate_degraded / 기타 detail). 자동 fallback
|
||||
금지 — 다른 모드로 자동 전환하지 않는다. 스트림 도중 중단 = 받은 부분
|
||||
유지 + 표시.
|
||||
- 대기 표시(첫 바이트 전): 경과 타이머 1초 갱신 + 3초 후 GET /api/eid/status
|
||||
1회·이후 8초 간격 재조회(실패는 조용히 무시 — 기능 비차단)로 "대기"와
|
||||
"고장"을 정직하게 구분. daily.busy=true 면 줄 서는 중 안내. 15초 경과 +
|
||||
daily 모드면 [심층으로 전환]/[취소] 버튼 노출 — 전환은 명시 클릭만
|
||||
(자동 fallback 금지 정책 위반 아님). 첫 바이트 도착/스트림 종료 시
|
||||
타이머·폴링 즉시 정리.
|
||||
- 이력: localStorage `eid_chat:v1` (키 상수는 $lib/eidChat — logout 시 제거와 공유).
|
||||
전송 payload 는 마지막 20턴(40 messages) cap.
|
||||
- 입력 한도: 메시지당 8,000자 클라 선차단(서버 422 검증과 동일 한도).
|
||||
@@ -25,15 +31,25 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { apiFetchRaw } from '$lib/api';
|
||||
import { api, apiFetchRaw } from '$lib/api';
|
||||
import { EID_CHAT_STORAGE_KEY } from '$lib/eidChat';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import EidEvidenceCard from '$lib/components/eid/EidEvidenceCard.svelte';
|
||||
import { MessageCircle, SendHorizontal, RotateCcw, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
type ChatMode = 'daily' | 'deep';
|
||||
type ChatMessage = { role: 'user' | 'assistant'; content: string };
|
||||
// deep(검색) 답변은 sources(근거)·partial 동반. daily 답변은 없음.
|
||||
type EidSource = { id?: number; doc_id?: number; title?: string; score?: number };
|
||||
type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
sources?: EidSource[];
|
||||
partial?: boolean;
|
||||
};
|
||||
type Notice = { kind: 'warn' | 'error'; message: string; retryable: boolean };
|
||||
// GET /api/eid/status 응답 — 대기 중 바쁨 신호 조회에 필요한 필드만 좁게 정의
|
||||
type EidStatus = { daily?: { busy?: boolean } };
|
||||
|
||||
// 이력 키 — logout(stores/auth.ts) 의 이력 제거와 단일 상수 공유
|
||||
const STORAGE_KEY = EID_CHAT_STORAGE_KEY;
|
||||
@@ -45,6 +61,10 @@
|
||||
const MAX_MESSAGE_CHARS = 8000;
|
||||
// 한도 근접 카운터 노출 시작점
|
||||
const COUNTER_THRESHOLD = 7500;
|
||||
// 대기 표시(첫 바이트 전): 상태 폴링 시작 시점(초) / 재조회 간격(초) / 행동 버튼 노출 시점(초)
|
||||
const STATUS_POLL_START_SEC = 3;
|
||||
const STATUS_POLL_INTERVAL_SEC = 8;
|
||||
const WAIT_ACTIONS_SEC = 15;
|
||||
|
||||
const DEEP_CAPTION =
|
||||
'장문·무거운 질문에 적합 — 잠들어 있으면 자동 기동 (처음 응답까지 최대 ~1분)';
|
||||
@@ -64,11 +84,72 @@
|
||||
let streaming = $state(false);
|
||||
let streamingText = $state('');
|
||||
let notice = $state<Notice | null>(null);
|
||||
// deep(검색) 모드 첫 바이트 전 단계 — 'searching' 이면 대기 표시를 "근거 검색 중"으로
|
||||
let deepPhase = $state<'searching' | null>(null);
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined = $state();
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
let abortCtrl: AbortController | null = null;
|
||||
|
||||
// ── 대기 추적 (첫 바이트 전) ────────────────────────
|
||||
// 경과 초 + daily 엔진 바쁨 여부(null = 미확인). 토큰(세대 카운터)으로
|
||||
// 스트림별 소유를 구분 — abort 직후 즉시 재전송(심층 전환) 경로에서
|
||||
// 이전 스트림의 늦은 정리가 새 스트림의 타이머를 죽이지 않게 한다.
|
||||
let waitSeconds = $state(0);
|
||||
let dailyBusy = $state<boolean | null>(null);
|
||||
let waitIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
let waitTokenSeq = 0;
|
||||
let waitToken = 0; // 현재 활성 추적 토큰 (0 = 추적 없음)
|
||||
|
||||
function startWaitTracking(streamMode: ChatMode): number {
|
||||
// 이전 추적 잔여 정리 (전환 재전송처럼 stop 전에 start 가 오는 경로 방어)
|
||||
if (waitIntervalId !== null) {
|
||||
clearInterval(waitIntervalId);
|
||||
waitIntervalId = null;
|
||||
}
|
||||
const token = ++waitTokenSeq;
|
||||
waitToken = token;
|
||||
waitSeconds = 0;
|
||||
dailyBusy = null;
|
||||
waitIntervalId = setInterval(() => {
|
||||
if (waitToken !== token) return; // 정리 누락 방어 — 무해 no-op
|
||||
waitSeconds += 1;
|
||||
// 바쁨 신호 폴링: 3초 경과 시 1회 + 이후 8초 간격 (3, 11, 19, ...).
|
||||
// daily 모드 전용 — deep 대기는 기존 wake 안내 + 경과 타이머만.
|
||||
if (
|
||||
streamMode === 'daily' &&
|
||||
waitSeconds >= STATUS_POLL_START_SEC &&
|
||||
(waitSeconds - STATUS_POLL_START_SEC) % STATUS_POLL_INTERVAL_SEC === 0
|
||||
) {
|
||||
void pollEidStatus(token);
|
||||
}
|
||||
}, 1000);
|
||||
return token;
|
||||
}
|
||||
|
||||
// token 가드: 본인 소유 추적만 정리 — 다른 스트림이 이어받았으면 no-op
|
||||
function stopWaitTracking(token: number) {
|
||||
if (token !== waitToken) return;
|
||||
waitToken = 0;
|
||||
if (waitIntervalId !== null) {
|
||||
clearInterval(waitIntervalId);
|
||||
waitIntervalId = null;
|
||||
}
|
||||
waitSeconds = 0;
|
||||
dailyBusy = null;
|
||||
}
|
||||
|
||||
// 상태 조회 — 실패는 조용히 무시 (대기 표시는 타이머만으로 유지, 기능 비차단)
|
||||
async function pollEidStatus(token: number) {
|
||||
try {
|
||||
const status = await api<EidStatus>('/eid/status');
|
||||
if (token !== waitToken) return; // 스트림 종료/교체 후 도착한 늦은 응답 폐기
|
||||
dailyBusy = status?.daily?.busy === true;
|
||||
} catch {
|
||||
// 무시 — 바쁨 신호는 부가 정보일 뿐 채팅 기능을 차단하지 않는다
|
||||
}
|
||||
}
|
||||
|
||||
// ── localStorage 이력 ───────────────────────────────
|
||||
function persist() {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -97,9 +178,15 @@
|
||||
typeof (m as ChatMessage).content === 'string'
|
||||
)
|
||||
// 배열 크기 가드 + content 8,000자 clamp — 외부에서 손상/비대해진
|
||||
// 이력이 전송 payload 를 오염시키지 않도록 복원 시점에 정규화
|
||||
// 이력이 전송 payload 를 오염시키지 않도록 복원 시점에 정규화.
|
||||
// sources/partial(deep 답변 근거)은 보존 — 전송 payload 엔 안 실림(runStream map 이 role/content 만).
|
||||
.slice(-MAX_STORED_MESSAGES)
|
||||
.map((m) => ({ role: m.role, content: m.content.slice(0, MAX_MESSAGE_CHARS) }));
|
||||
.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content.slice(0, MAX_MESSAGE_CHARS),
|
||||
sources: Array.isArray((m as ChatMessage).sources) ? (m as ChatMessage).sources : undefined,
|
||||
partial: (m as ChatMessage).partial === true || undefined,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// 손상된 이력은 무시 (새 대화로 시작)
|
||||
@@ -107,7 +194,11 @@
|
||||
}
|
||||
|
||||
onMount(() => restore());
|
||||
onDestroy(() => abortCtrl?.abort());
|
||||
onDestroy(() => {
|
||||
abortCtrl?.abort();
|
||||
// 페이지 이탈 시 대기 타이머/폴링 정리 (abort 의 finally 와 이중이어도 무해)
|
||||
if (waitIntervalId !== null) clearInterval(waitIntervalId);
|
||||
});
|
||||
|
||||
// ── 자동 스크롤 (새 메시지 / 스트림 청크마다 하단 고정) ──
|
||||
$effect(() => {
|
||||
@@ -235,12 +326,39 @@
|
||||
void runStream();
|
||||
}
|
||||
|
||||
// ── 대기 중 행동 버튼 (daily + 15초 경과) ────────────
|
||||
// [심층으로 전환] — 명시 클릭에 의한 모드 전환 (자동 fallback 금지 정책
|
||||
// 위반 아님). 현재 fetch abort → 같은 user 턴을 mode=deep 으로 즉시 재전송.
|
||||
// abort 된 이전 스트림의 finally 는 abortCtrl 비교 + 대기 token 가드로
|
||||
// 새 스트림 상태를 건드리지 않는다 (새 대화 abort race 가드와 동일 구조).
|
||||
function switchToDeep() {
|
||||
if (!streaming || mode !== 'daily') return;
|
||||
mode = 'deep'; // 모드 토글 상태도 deep 으로 갱신
|
||||
abortCtrl?.abort();
|
||||
void runStream();
|
||||
}
|
||||
|
||||
// [취소] — abort 후 방금 push 한 user 턴 pop + 입력창 본문 복원
|
||||
// (422 처리와 동일 패턴: 이력 오염 차단 + localStorage 재저장).
|
||||
// placeholder 제거는 abort 된 스트림의 finally(streaming=false)가 처리.
|
||||
function cancelWait() {
|
||||
if (!streaming) return;
|
||||
abortCtrl?.abort();
|
||||
if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
|
||||
const popped = messages.pop();
|
||||
if (popped && !input) input = popped.content;
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream() {
|
||||
notice = null;
|
||||
streaming = true;
|
||||
streamingText = '';
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
// 첫 바이트 전 대기 추적 시작 — 본 스트림 소유 토큰으로 정리 시점 제어
|
||||
const waitTok = startWaitTracking(mode);
|
||||
|
||||
const payload = {
|
||||
mode,
|
||||
@@ -251,6 +369,9 @@
|
||||
|
||||
let acc = '';
|
||||
let sawDone = false;
|
||||
// deep(검색) 답변 동반 데이터 — daily 는 안 옴
|
||||
let accSources: EidSource[] = [];
|
||||
let accPartial = false;
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw('/eid/chat', {
|
||||
@@ -301,9 +422,35 @@
|
||||
try {
|
||||
const obj = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { content?: unknown } }>;
|
||||
phase?: string;
|
||||
error_reason?: string;
|
||||
eid_sources?: EidSource[];
|
||||
partial?: boolean;
|
||||
};
|
||||
// deep(검색) envelope 분기 — daily 응답엔 없음
|
||||
if (obj?.phase === 'ping') return false; // heartbeat — 무시
|
||||
if (obj?.phase === 'searching') {
|
||||
deepPhase = 'searching'; // 대기 표시를 "근거 검색 중"으로
|
||||
return false;
|
||||
}
|
||||
if (obj?.phase === 'error') {
|
||||
// in-stream 미가용/실패 — 받은 부분 유지 + 명시 표시 (자동 fallback 0).
|
||||
// 뒤따르는 [DONE] 이 sawDone 처리하므로 '중단' 오경보 없음.
|
||||
notice = mapErrorReason(obj.error_reason, '');
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(obj?.eid_sources)) {
|
||||
accSources = obj.eid_sources;
|
||||
accPartial = obj.partial === true;
|
||||
return false;
|
||||
}
|
||||
const piece = obj?.choices?.[0]?.delta?.content;
|
||||
if (typeof piece === 'string' && piece) {
|
||||
// 첫 바이트 도착 — 대기 타이머/폴링 제거, 기존 스트리밍 표시로 전환
|
||||
if (!acc) {
|
||||
stopWaitTracking(waitTok);
|
||||
deepPhase = null;
|
||||
}
|
||||
acc += piece;
|
||||
streamingText = acc;
|
||||
}
|
||||
@@ -356,7 +503,7 @@
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') {
|
||||
// 새 대화 등 사용자 의도 중단 — 안내 불필요
|
||||
// 새 대화 / 대기 취소 / 심층 전환 등 사용자 의도 중단 — 안내 불필요
|
||||
return;
|
||||
}
|
||||
// 스트림 도중 네트워크 에러 — 받은 부분 유지 + 표시
|
||||
@@ -368,14 +515,23 @@
|
||||
}
|
||||
: { kind: 'error', message: '요청에 실패했습니다 — 네트워크를 확인하세요.', retryable: true };
|
||||
} finally {
|
||||
// 스트림 종료 — 대기 타이머/폴링 정리. 첫 바이트에서 이미 정리됐거나
|
||||
// 전환 재전송으로 새 스트림이 추적을 이어받았으면 token 가드로 no-op.
|
||||
stopWaitTracking(waitTok);
|
||||
// abort(새 대화/페이지 이탈) 시에는 push 하지 않음 — 새 대화로 비운
|
||||
// messages 에 이전 스트림 잔여분이 흘러들어가는 race 방지.
|
||||
if (acc && !ctrl.signal.aborted) {
|
||||
messages.push({ role: 'assistant', content: acc });
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: acc,
|
||||
sources: accSources.length ? accSources : undefined,
|
||||
partial: accPartial || undefined,
|
||||
});
|
||||
}
|
||||
if (abortCtrl === ctrl) {
|
||||
streaming = false;
|
||||
streamingText = '';
|
||||
deepPhase = null;
|
||||
abortCtrl = null;
|
||||
}
|
||||
persist();
|
||||
@@ -398,6 +554,24 @@
|
||||
// 입력 길이(전송 기준 = trim 후) — 7,500자부터 카운터 노출, 8,000자 초과 차단
|
||||
let inputLength = $derived(input.trim().length);
|
||||
let overLimit = $derived(inputLength > MAX_MESSAGE_CHARS);
|
||||
|
||||
// 첫 바이트 전 placeholder 문구 — "대기"와 "고장"의 정직한 구분:
|
||||
// 바쁨 확인 = 줄 서는 중 / 비-바쁨 확인 = 생성 준비 중 / 미확인 = 응답 대기 중.
|
||||
// deep 모드는 폴링하지 않으므로 항상 미확인(타이머만) — wake 안내는 헤더 caption.
|
||||
let waitPlaceholder = $derived(
|
||||
deepPhase === 'searching'
|
||||
? `이드가 문서·뉴스에서 근거를 찾는 중 · ${waitSeconds}초`
|
||||
: dailyBusy === true
|
||||
? `엔진이 다른 작업을 처리하고 있어요 — 차례가 오면 바로 시작됩니다 (대기 ${waitSeconds}초)`
|
||||
: dailyBusy === false
|
||||
? `응답 생성 준비 중 · ${waitSeconds}초`
|
||||
: `응답 대기 중 · ${waitSeconds}초`
|
||||
);
|
||||
|
||||
// 행동 버튼 노출: daily 모드 + 첫 바이트 전 + 15초 경과
|
||||
let showWaitActions = $derived(
|
||||
streaming && !streamingText && mode === 'daily' && waitSeconds >= WAIT_ACTIONS_SEC
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -473,25 +647,35 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex justify-start">
|
||||
<div class="flex flex-col items-start">
|
||||
<div class="max-w-[85%] sm:max-w-[75%] px-3.5 py-2.5 rounded-lg rounded-bl-sm bg-surface border border-default text-text text-sm whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
</div>
|
||||
{#if msg.sources?.length}
|
||||
<EidEvidenceCard sources={msg.sources} partial={msg.partial ?? false} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- 스트리밍 중 assistant 부분 응답 -->
|
||||
<!-- 스트리밍 중 assistant 부분 응답 / 첫 바이트 전 대기 표시 -->
|
||||
{#if streaming}
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[85%] sm:max-w-[75%] px-3.5 py-2.5 rounded-lg rounded-bl-sm bg-surface border border-default text-text text-sm whitespace-pre-wrap break-words">
|
||||
{#if streamingText}
|
||||
{streamingText}<span class="inline-block w-1.5 h-3.5 ml-0.5 align-middle bg-accent animate-pulse rounded-sm"></span>
|
||||
{:else}
|
||||
<span class="text-dim animate-pulse">응답 준비 중...</span>
|
||||
<span class="text-dim animate-pulse">{waitPlaceholder}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 대기 행동 버튼: daily + 15초 경과 — 전환은 명시 클릭만 (자동 fallback 금지) -->
|
||||
{#if showWaitActions}
|
||||
<div class="flex justify-start gap-2">
|
||||
<Button variant="secondary" size="sm" onclick={switchToDeep}>심층으로 전환</Button>
|
||||
<Button variant="ghost" size="sm" onclick={cancelWait}>취소</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- 에러/안내 카드: 자동 fallback 없이 명시 표시만 -->
|
||||
|
||||
Reference in New Issue
Block a user