feat(eid): 이드 채팅 표면 — /api/eid/chat SSE 스트리밍 + /chat 페이지 (P1)

- compose: eid_chat surface 등록(persona+rules, 자유-prose) + rules_present() 라이브 판정(D-6 fail-closed)
- EidAIClient.call_stream: 닫힌 mode 매핑(daily→mac-mini-default/deep→qwen-macbook), router 경유,
  MLX gate(FOREGROUND)+wall-clock 300s deadline, SSE 라인 relay(model→mode 치환·usage 제거),
  router 400 fail-loud, error_reason allowlist sanitize
- POST /api/eid/chat: JWT, role=system 422 거부, 8000자/40턴/총량 32000 cap,
  503 error_reason(ask 컨벤션), 본문 무로깅
- frontend /chat: 이드 표면 문법(일상/심층, 모델·머신명 비노출), SSE 파서(경계 buf·flush·[DONE]),
  error_reason UX, 8000자 선차단+422 오염 차단, localStorage 이력(logout 시 제거), nav 등록
- Caddyfile: encode 명시 match로 text/event-stream gzip 버퍼링 제외
- tests: 신규 32+ (fixture: router 경유 26B/27B SSE 박제), tests/eid 61 + ask 회귀 9 = 70 passed
- 적대 리뷰 3렌즈 18 finding 반영 13/13. 배포는 D26 게이트(fix/hwp 머지+Soft Lock) 대기

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-11 10:51:39 +09:00
parent d3aa640f65
commit cd06ef0403
16 changed files with 1641 additions and 3 deletions
+55
View File
@@ -172,6 +172,61 @@ export async function api<T = unknown>(
return res.json();
}
/**
* Raw fetch 헬퍼 — SSE/스트리밍 등 JSON 일괄 파싱이 부적합한 endpoint 전용.
*
* api<T>() 와 동일한 정책을 공유한다:
* - access token 자동 첨부
* - 401 → refresh 1회 재시도 (실패 시 handleTokenRefresh 가 강제 logout)
* - JSON body 면 Content-Type 자동 설정
*
* 차이: Response 를 그대로 반환한다 (status 판단 / body 소비는 호출자 책임).
* PR-Eid-Chat: `/api/eid/chat` SSE 스트림이 첫 소비자. additive export only —
* 기존 api()/uploadFile() 동작은 변경하지 않는다.
*/
export async function apiFetchRaw(
path: string,
options: RequestInit = {},
): Promise<Response> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
// 401 → refresh 1회 시도 (api() 와 같은 정책, auth endpoint 제외)
const isAuthEndpoint = path.startsWith('/auth/login') || path.startsWith('/auth/refresh');
if (res.status === 401 && accessToken && !isAuthEndpoint) {
try {
await handleTokenRefresh();
} catch {
// refresh 실패 — handleTokenRefresh 가 강제 logout(리다이렉트) 처리.
// api() 와 일관되게 원본 401 Response 를 그대로 반환해 호출자가
// 네트워크 에러로 오인하지 않게 한다 (body 미소비 상태라 재사용 가능).
return res;
}
headers['Authorization'] = `Bearer ${accessToken}`;
return fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
}
return res;
}
/**
* 업로드 전용 헬퍼 — XMLHttpRequest 기반.
*
+11 -1
View File
@@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck } from 'lucide-svelte';
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck, MessageCircle } from 'lucide-svelte';
let tree = $state([]);
let loading = $state(true);
@@ -229,6 +229,16 @@
공부
</span>
</a>
<a
href="/chat"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
{$page.url.pathname.startsWith('/chat') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
>
<span class="flex items-center gap-2">
<MessageCircle size={16} />
이드
</span>
</a>
<a
href="/inbox"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm text-text hover:bg-surface transition-colors"
+8
View File
@@ -0,0 +1,8 @@
/**
* (/chat) (PR-Eid-Chat).
*
* localStorage (routes/chat/+page.svelte) /
* logout(stores/auth.ts)
* ( posture 정합: 로그아웃 ).
*/
export const EID_CHAT_STORAGE_KEY = 'eid_chat:v1';
+9
View File
@@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import { api, setAccessToken } from '$lib/api';
import { EID_CHAT_STORAGE_KEY } from '$lib/eidChat';
interface User {
id: number;
@@ -39,6 +40,14 @@ export async function logout() {
setAccessToken(null);
user.set(null);
isAuthenticated.set(false);
// 본문 무로깅 posture 정합 — 로그아웃 시 이드 대화 이력도 브라우저에서 제거
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(EID_CHAT_STORAGE_KEY);
} catch {
// 이력 제거 실패가 logout 자체를 막지는 않음
}
}
}
export async function tryRefresh() {
+3 -1
View File
@@ -3,7 +3,7 @@
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox, PanelLeft } from 'lucide-svelte';
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox, PanelLeft, MessageCircle } from 'lucide-svelte';
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
import { toasts, removeToast } from '$lib/stores/toast';
import { refresh as refreshPublicConfig } from '$lib/stores/config';
@@ -140,6 +140,7 @@
</div>
<a href="/ask" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/ask') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">질문</a>
<a href="/chat" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/chat') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">이드</a>
<SystemStatusDot />
</div>
@@ -178,6 +179,7 @@
<a href="/documents" aria-current={docsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {docsActive ? 'text-accent' : 'text-dim'}"><FileText size={18} strokeWidth={1.9} /> 문서</a>
<a href="/news" aria-current={newsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {newsActive ? 'text-accent' : 'text-dim'}"><Newspaper size={18} strokeWidth={1.9} /> 뉴스</a>
<a href="/ask" aria-current={isActive('/ask') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/ask') ? 'text-accent' : 'text-dim'}"><HelpCircle size={18} strokeWidth={1.9} /> 질문</a>
<a href="/chat" aria-current={isActive('/chat') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/chat') ? 'text-accent' : 'text-dim'}"><MessageCircle size={18} strokeWidth={1.9} /> 이드</a>
<a href="/memos" aria-current={isActive('/memos') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/memos') ? 'text-accent' : 'text-dim'}"><StickyNote size={18} strokeWidth={1.9} /> 메모</a>
<button onclick={() => ui.openDrawer('sidebar')} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold text-dim"><Menu size={18} strokeWidth={1.9} /> 더보기</button>
</nav>
+567
View File
@@ -0,0 +1,567 @@
<!--
/chat — 이드 채팅 표면 (PR-Eid-Chat).
표면 문법: 페이지 정체성 = "이드". 모델명·머신명·alias 비노출
(persona model-agnostic 원칙 — 프로토콜 레이어도 동일: SSE payload 의
model 필드는 서버에서 mode 값으로 치환되고 usage 는 제거됨).
클라이언트는 mode('daily'|'deep') 만 보내고 alias 매핑은 서버(/api/eid/chat) 책임.
- 모드: 일상(daily) / 심층(deep) segmented 토글. 심층 = 장문·무거운 질문,
잠들어 있으면 자동 기동(처음 최대 ~1분) — 기계중립 표현만 사용.
- 스트리밍: POST /api/eid/chat → SSE. api<T>() 는 JSON 전용이라 raw fetch
(apiFetchRaw, 토큰 첨부 + 401 refresh 1회 공유) 사용. 라인 버퍼로 청크
경계 분리, "data:" 라인만, [DONE] 종료, choices[0].delta.content 누적
(fixture 2종 — 26B tool_calls 배열 / 27B reasoning·logprobs null — 모두
content 만 읽으면 동일 처리).
- 에러: error_reason 매핑 (warming / editor_busy / upstream_cold /
macbook_unavailable / substrate_degraded / 기타 detail). 자동 fallback
금지 — 다른 모드로 자동 전환하지 않는다. 스트림 도중 중단 = 받은 부분
유지 + 표시.
- 이력: localStorage `eid_chat:v1` (키 상수는 $lib/eidChat — logout 시 제거와 공유).
전송 payload 는 마지막 20턴(40 messages) cap.
- 입력 한도: 메시지당 8,000자 클라 선차단(서버 422 검증과 동일 한도).
422 수신 시 detail 을 한 줄로 정규화 + 방금 push 한 user 턴 pop 으로
payload 오염 고리 차단.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { 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 { MessageCircle, SendHorizontal, RotateCcw, AlertCircle } from 'lucide-svelte';
type ChatMode = 'daily' | 'deep';
type ChatMessage = { role: 'user' | 'assistant'; content: string };
type Notice = { kind: 'warn' | 'error'; message: string; retryable: boolean };
// 이력 키 — logout(stores/auth.ts) 의 이력 제거와 단일 상수 공유
const STORAGE_KEY = EID_CHAT_STORAGE_KEY;
// 전송 payload cap: 마지막 20턴(40 messages)
const MAX_PAYLOAD_MESSAGES = 40;
// localStorage 보존 cap (payload cap 과 별개 — 화면 표시용 이력)
const MAX_STORED_MESSAGES = 200;
// 메시지당 입력 한도 — 서버(eid_chat.py) 422 검증과 동일 한도, 클라에서 선차단
const MAX_MESSAGE_CHARS = 8000;
// 한도 근접 카운터 노출 시작점
const COUNTER_THRESHOLD = 7500;
const DEEP_CAPTION =
'장문·무거운 질문에 적합 — 잠들어 있으면 자동 기동 (처음 응답까지 최대 ~1분)';
// 프리셋 칩: 입력창 prefix 채움
const PRESETS: Array<{ label: string; prefix: string }> = [
{ label: '번역 한→영', prefix: '다음을 영어로 번역해줘.\n\n' },
{ label: '번역 영→한', prefix: '다음을 한국어로 번역해줘.\n\n' },
{ label: '요약', prefix: '다음 내용을 핵심만 간결히 요약해줘.\n\n' },
{ label: '글 다듬기', prefix: '다음 글을 뜻은 유지하면서 자연스럽게 다듬어줘.\n\n' },
];
// ── state ───────────────────────────────────────────
let mode = $state<ChatMode>('daily');
let messages = $state<ChatMessage[]>([]);
let input = $state('');
let streaming = $state(false);
let streamingText = $state('');
let notice = $state<Notice | null>(null);
let scrollEl: HTMLDivElement | undefined = $state();
let textareaEl: HTMLTextAreaElement | undefined = $state();
let abortCtrl: AbortController | null = null;
// ── localStorage 이력 ───────────────────────────────
function persist() {
if (typeof window === 'undefined') return;
try {
const trimmed = messages.slice(-MAX_STORED_MESSAGES);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode, messages: trimmed }));
} catch {
// quota 초과 등 — 이력 저장 실패는 치명적이지 않음
}
}
function restore() {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as { mode?: unknown; messages?: unknown };
if (parsed.mode === 'daily' || parsed.mode === 'deep') mode = parsed.mode;
if (Array.isArray(parsed.messages)) {
messages = parsed.messages
.filter(
(m): m is ChatMessage =>
!!m &&
typeof m === 'object' &&
((m as ChatMessage).role === 'user' || (m as ChatMessage).role === 'assistant') &&
typeof (m as ChatMessage).content === 'string'
)
// 배열 크기 가드 + content 8,000자 clamp — 외부에서 손상/비대해진
// 이력이 전송 payload 를 오염시키지 않도록 복원 시점에 정규화
.slice(-MAX_STORED_MESSAGES)
.map((m) => ({ role: m.role, content: m.content.slice(0, MAX_MESSAGE_CHARS) }));
}
} catch {
// 손상된 이력은 무시 (새 대화로 시작)
}
}
onMount(() => restore());
onDestroy(() => abortCtrl?.abort());
// ── 자동 스크롤 (새 메시지 / 스트림 청크마다 하단 고정) ──
$effect(() => {
void messages.length;
void streamingText;
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
});
// ── 입력 textarea auto-grow ─────────────────────────
$effect(() => {
void input;
if (!textareaEl) return;
textareaEl.style.height = 'auto';
textareaEl.style.height = Math.min(textareaEl.scrollHeight, 160) + 'px';
});
function applyPreset(prefix: string) {
if (!input.startsWith(prefix)) input = prefix + input;
textareaEl?.focus();
}
function newConversation() {
abortCtrl?.abort();
messages = [];
notice = null;
streamingText = '';
streaming = false;
persist();
textareaEl?.focus();
}
// ── error_reason → 안내 메시지 매핑 ──────────────────
// 자동 fallback 금지 ([[feedback_no_silent_fallback_explicit_opt_in]]):
// 어떤 사유든 다른 모드로 자동 전환하지 않고 명시 표시만 한다.
function mapErrorReason(reason: string | undefined, detail: string): Notice {
switch (reason) {
case 'warming':
return {
kind: 'warn',
message: '심층 엔진 기동 중입니다 — 잠시 후 다시 시도하세요.',
retryable: true,
};
case 'editor_busy':
return {
kind: 'warn',
message: '편집 작업 보호로 잠시 사용할 수 없습니다.',
retryable: false,
};
case 'upstream_cold':
case 'macbook_unavailable':
return {
kind: 'warn',
message: '심층 엔진이 잠들어 있습니다 — 다시 시도하면 기동을 시작합니다.',
retryable: true,
};
case 'substrate_degraded':
return {
kind: 'error',
message: '운영 규칙이 적재되지 않았습니다 — 관리자 확인이 필요합니다.',
retryable: false,
};
default:
return { kind: 'error', message: detail || '응답 생성에 실패했습니다.', retryable: true };
}
}
// 비-200 응답 body 파싱: {detail, error_reason} — detail 은 string 또는
// {message} 객체 가능 (api.ts parseDetail 과 같은 정규화 규칙의 축소판).
async function parseErrorBody(res: Response): Promise<Notice> {
const body = (await res.json().catch(() => null)) as
| { detail?: unknown; error_reason?: unknown }
| null;
const reason = typeof body?.error_reason === 'string' ? body.error_reason : undefined;
let detail = '';
if (typeof body?.detail === 'string') detail = body.detail;
else if (body?.detail && typeof body.detail === 'object') {
const obj = body.detail as { message?: string; error_reason?: string };
detail = obj.message || '';
// error_reason 이 detail 객체 안에 중첩된 경우도 수용
return mapErrorReason(reason ?? obj.error_reason, detail || res.statusText);
}
return mapErrorReason(reason, detail || res.statusText);
}
// 422: FastAPI validation detail(배열 shape — [{loc, msg, type}, ...]) 을
// 사람이 읽을 한 줄로 정규화. 길이 한도 위반(메시지당 8,000자 / 총량 cap)
// 은 친화 메시지로 치환. pydantic v2 의 "Value error, " prefix 는 제거.
function normalizeValidationDetail(detail: unknown): string {
const first = (Array.isArray(detail) ? detail[0] : undefined) as
| { msg?: unknown }
| undefined;
const msg =
typeof first?.msg === 'string' ? first.msg.replace(/^Value error,\s*/i, '') : '';
if (/at most|too.?long|초과|깁니다/i.test(msg)) {
return '입력이 너무 깁니다 — 메시지는 8,000자 이내로 줄이거나, 대화가 길면 새 대화로 시작하세요.';
}
if (msg) return `요청 형식 오류: ${msg}`;
return '요청 형식이 올바르지 않습니다 — 입력을 줄이거나 새 대화로 시작하세요.';
}
// ── 전송 / 재시도 ───────────────────────────────────
function sendMessage() {
const text = input.trim();
if (!text || streaming) return;
// 메시지당 8,000자 클라 선차단 — 한도 초과 payload 를 422 전에 막는다
// (입력바 하단 카운터가 같은 안내를 인라인으로 상시 표시)
if (text.length > MAX_MESSAGE_CHARS) {
notice = {
kind: 'error',
message: '입력이 너무 깁니다 — 8,000자 이내로 줄여주세요.',
retryable: false,
};
return;
}
messages.push({ role: 'user', content: text });
input = '';
persist();
void runStream();
}
// 재시도: 이력 끝의 user 메시지를 그대로 재전송 (user 턴 중복 추가 X)
function retry() {
if (streaming) return;
if (messages.length === 0 || messages[messages.length - 1].role !== 'user') return;
void runStream();
}
async function runStream() {
notice = null;
streaming = true;
streamingText = '';
const ctrl = new AbortController();
abortCtrl = ctrl;
const payload = {
mode,
messages: messages
.slice(-MAX_PAYLOAD_MESSAGES)
.map((m) => ({ role: m.role, content: m.content })),
};
let acc = '';
let sawDone = false;
try {
const res = await apiFetchRaw('/eid/chat', {
method: 'POST',
body: JSON.stringify(payload),
signal: ctrl.signal,
});
if (!res.ok) {
if (res.status === 422) {
// validation 거부 — detail 정규화 + 방금 push 한 user 턴 pop.
// 한도 초과 턴이 이력에 남으면 이후 모든 전송 payload 가 계속
// 422 를 맞는 오염 고리가 되므로 여기서 끊는다 (localStorage 재저장).
const body = (await res.json().catch(() => null)) as { detail?: unknown } | null;
notice = {
kind: 'error',
message: normalizeValidationDetail(body?.detail),
retryable: false,
};
if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
const popped = messages.pop();
// 입력창이 비어 있으면 본문을 돌려놓아 줄여서 재전송할 수 있게 한다
if (popped && !input) input = popped.content;
persist();
}
return;
}
notice = await parseErrorBody(res);
return;
}
if (!res.body) {
notice = { kind: 'error', message: '스트림을 열 수 없습니다.', retryable: true };
return;
}
// SSE 라인 버퍼 파싱 — 청크 경계에서 라인이 잘릴 수 있으므로
// 마지막 불완전 라인은 buf 에 남겨 다음 청크와 이어붙인다.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
// data: 라인 1개 처리 — [DONE] 이면 true (acc/streamingText 누적은 closure)
const processLine = (rawLine: string): boolean => {
const line = rawLine.trim();
if (!line.startsWith('data:')) return false;
const data = line.slice(5).trim();
if (data === '[DONE]') return true;
try {
const obj = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: unknown } }>;
};
const piece = obj?.choices?.[0]?.delta?.content;
if (typeof piece === 'string' && piece) {
acc += piece;
streamingText = acc;
}
} catch {
// 불완전/비 JSON data 라인 무시
}
return false;
};
while (true) {
const { value, done } = await reader.read();
if (done) {
// 종단 flush — decoder 내부 잔여 바이트 + 개행 없이 끝난 마지막
// 라인을 1회 처리. 마지막 data:/[DONE] 라인이 \n 없이 끝나면 buf 에
// 남아 '응답이 중단되었습니다' 오경보가 나던 경로의 해소 지점.
buf += decoder.decode();
for (const rawLine of buf.split('\n')) {
if (processLine(rawLine)) {
sawDone = true;
break;
}
}
break;
}
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const rawLine of lines) {
if (processLine(rawLine)) {
sawDone = true;
break;
}
}
if (sawDone) {
// [DONE] 수신 — 잔여 스트림 lock 해제 (실패해도 종료에 영향 없음)
void reader.cancel().catch(() => {});
break;
}
}
// [DONE] 없이 연결이 끊긴 경우 — 받은 부분 유지 + 표시
if (!sawDone) {
notice = acc
? {
kind: 'warn',
message: '응답이 중단되었습니다 — 받은 부분까지 표시합니다.',
retryable: false,
}
: { kind: 'error', message: '응답을 받지 못했습니다 — 다시 시도하세요.', retryable: true };
}
} catch (err) {
if ((err as Error)?.name === 'AbortError') {
// 새 대화 등 사용자 의도 중단 — 안내 불필요
return;
}
// 스트림 도중 네트워크 에러 — 받은 부분 유지 + 표시
notice = acc
? {
kind: 'warn',
message: '연결이 끊겼습니다 — 받은 부분까지 표시합니다.',
retryable: false,
}
: { kind: 'error', message: '요청에 실패했습니다 — 네트워크를 확인하세요.', retryable: true };
} finally {
// abort(새 대화/페이지 이탈) 시에는 push 하지 않음 — 새 대화로 비운
// messages 에 이전 스트림 잔여분이 흘러들어가는 race 방지.
if (acc && !ctrl.signal.aborted) {
messages.push({ role: 'assistant', content: acc });
}
if (abortCtrl === ctrl) {
streaming = false;
streamingText = '';
abortCtrl = null;
}
persist();
}
}
function handleKeydown(e: KeyboardEvent) {
// Enter 전송 / Shift+Enter 줄바꿈 (한글 조합 중 전송 방지)
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
sendMessage();
}
}
// 마지막 메시지가 user 턴이고 스트리밍 중이 아니면 재시도 가능 상태
let canRetry = $derived(
!streaming && messages.length > 0 && messages[messages.length - 1].role === 'user'
);
// 입력 길이(전송 기준 = trim 후) — 7,500자부터 카운터 노출, 8,000자 초과 차단
let inputLength = $derived(input.trim().length);
let overLimit = $derived(inputLength > MAX_MESSAGE_CHARS);
</script>
<svelte:head>
<title>이드 - PKM</title>
</svelte:head>
<div class="h-full flex flex-col">
<!-- 헤더: 정체성 + 모드 토글 + 새 대화 -->
<div class="shrink-0 border-b border-default bg-sidebar px-4 py-2.5">
<div class="max-w-3xl mx-auto flex items-center gap-2 flex-wrap">
<h1 class="flex items-center gap-2 text-sm font-extrabold tracking-tight shrink-0">
<MessageCircle size={16} class="text-accent" />
이드
</h1>
<!-- 모드 segmented 토글: 일상 / 심층 -->
<div class="flex rounded-md border border-default overflow-hidden" role="group" aria-label="응답 모드">
<button
type="button"
aria-pressed={mode === 'daily'}
onclick={() => (mode = 'daily')}
disabled={streaming}
title="짧은 질문·일상 대화에 적합"
class="px-3 py-1.5 text-xs font-semibold transition-colors disabled:opacity-50
{mode === 'daily' ? 'bg-accent text-white' : 'bg-surface text-dim hover:text-text hover:bg-surface-hover'}"
>
일상
</button>
<button
type="button"
aria-pressed={mode === 'deep'}
onclick={() => (mode = 'deep')}
disabled={streaming}
title={DEEP_CAPTION}
class="px-3 py-1.5 text-xs font-semibold border-l border-default transition-colors disabled:opacity-50
{mode === 'deep' ? 'bg-accent text-white' : 'bg-surface text-dim hover:text-text hover:bg-surface-hover'}"
>
심층
</button>
</div>
<div class="flex-1"></div>
<Button variant="ghost" size="sm" icon={RotateCcw} onclick={newConversation}>
새 대화
</Button>
</div>
{#if mode === 'deep'}
<div class="max-w-3xl mx-auto mt-1.5">
<p class="text-[11px] text-dim">{DEEP_CAPTION}</p>
</div>
{/if}
</div>
<!-- 메시지 리스트 -->
<div bind:this={scrollEl} class="flex-1 min-h-0 overflow-y-auto px-4 py-4">
<div class="max-w-3xl mx-auto flex flex-col gap-3" role="log" aria-live="polite">
{#if messages.length === 0 && !streaming}
<div class="py-10">
<EmptyState
icon={MessageCircle}
title="이드와 대화를 시작하세요"
description="일상 질문은 바로, 장문·무거운 질문은 심층 모드로 물어보세요. 아래 프리셋 칩으로 번역·요약·글 다듬기를 빠르게 시작할 수 있습니다."
/>
</div>
{/if}
{#each messages as msg, i (i)}
{#if msg.role === 'user'}
<div class="flex justify-end">
<div class="max-w-[85%] sm:max-w-[75%] px-3.5 py-2.5 rounded-lg rounded-br-sm bg-accent text-white text-sm whitespace-pre-wrap break-words">
{msg.content}
</div>
</div>
{:else}
<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">
{msg.content}
</div>
</div>
{/if}
{/each}
<!-- 스트리밍 중 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>
{/if}
</div>
</div>
{/if}
<!-- 에러/안내 카드: 자동 fallback 없이 명시 표시만 -->
{#if notice}
<div
class="flex items-start gap-2 px-3.5 py-3 rounded-lg border text-sm
{notice.kind === 'warn'
? 'border-warning/30 bg-warning/10 text-warning'
: 'border-error/30 bg-error/10 text-error'}"
>
<AlertCircle size={15} class="mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<p>{notice.message}</p>
{#if notice.retryable && canRetry}
<Button variant="secondary" size="sm" class="mt-2" onclick={retry}>
다시 시도
</Button>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- 입력 바 (하단 고정 — 모바일에서도 flex 컬럼 하단에 붙음) -->
<div class="shrink-0 border-t border-default bg-sidebar px-4 pt-2 pb-3">
<div class="max-w-3xl mx-auto">
<!-- 프리셋 칩 -->
<div class="flex gap-1.5 overflow-x-auto pb-2">
{#each PRESETS as preset (preset.label)}
<button
type="button"
onclick={() => applyPreset(preset.prefix)}
class="shrink-0 px-2.5 py-1 rounded-full border border-default bg-surface text-xs text-dim hover:text-text hover:border-accent transition-colors"
>
{preset.label}
</button>
{/each}
</div>
<div class="flex items-end gap-2">
<textarea
bind:this={textareaEl}
bind:value={input}
onkeydown={handleKeydown}
rows="1"
placeholder="이드에게 메시지 보내기 (Enter 전송, Shift+Enter 줄바꿈)"
class="flex-1 min-w-0 px-3 py-2 rounded-lg text-sm bg-bg text-text placeholder:text-faint border border-default focus:border-accent focus:ring-2 focus:ring-accent-ring outline-none resize-none overflow-y-auto transition-colors"
></textarea>
<Button
variant="primary"
size="md"
icon={SendHorizontal}
loading={streaming}
disabled={!input.trim() || overLimit}
onclick={sendMessage}
aria-label="전송"
>
<span class="hidden sm:inline">전송</span>
</Button>
</div>
<!-- 글자수 카운터: 한도(8,000자) 근접 시에만 노출, 초과 시 인라인 안내 -->
{#if inputLength >= COUNTER_THRESHOLD}
<p class="mt-1 text-right text-[11px] {overLimit ? 'text-error' : 'text-dim'}" aria-live="polite">
{inputLength.toLocaleString()} / {MAX_MESSAGE_CHARS.toLocaleString()}{overLimit
? ' — 입력이 너무 깁니다 (8,000자 이내)'
: ''}
</p>
{/if}
</div>
</div>
</div>