diff --git a/Dockerfile b/Dockerfile index da78883be..3686880b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,66 @@ -FROM python:3.11-slim +# Stage 1: Build Frontend +FROM node:22-slim AS frontend-builder +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci --fetch-timeout=600000 --fetch-retries=5 +COPY frontend ./ +# Pass dummy URL for build if needed +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build +# Stage 2: Final Image (Python + Node.js) +FROM python:3.11-slim WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app -# Install system dependencies if any are needed for pgvector/psycopg2 +# Install system dependencies & Node.js RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc libpq-dev \ + && apt-get install -y --no-install-recommends gcc libpq-dev curl \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* +# Install Backend dependencies COPY backend/requirements.txt /app/requirements.txt RUN PIP_ROOT_USER_ACTION=ignore PIP_DISABLE_PIP_VERSION_CHECK=1 \ pip install --no-cache-dir -r requirements.txt +# Copy Backend COPY backend /app/ -# Create a non-root user -RUN adduser --disabled-password --gecos "" appuser +# Copy Frontend runtime artifacts +COPY --from=frontend-builder /app/.next /app/frontend/.next +COPY --from=frontend-builder /app/public /app/frontend/public +COPY --from=frontend-builder /app/node_modules /app/frontend/node_modules +COPY --from=frontend-builder /app/package.json /app/frontend/package.json +COPY --from=frontend-builder /app/next.config.ts /app/frontend/next.config.ts + +# Create a startup script +RUN echo '#!/bin/bash\n\ +echo "Starting Naruon Backend and Frontend..."\n\ +python scripts/bootstrap_db.py\n\ +python scripts/start_backend.py --host 0.0.0.0 --port 8000 &\n\ +BACKEND_PID=$!\n\ +cd frontend && npm run start -- --hostname 0.0.0.0 --port 3000 &\n\ +FRONTEND_PID=$!\n\ +wait -n\n\ +exit $?\n\ +' > /app/start.sh && chmod +x /app/start.sh + +# Create non-root user +RUN useradd -m -s /bin/bash appuser && chown -R appuser:appuser /app USER appuser -EXPOSE 8000 +# Environment variables for Frontend +ENV NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV BACKEND_INTERNAL_URL=http://127.0.0.1:8000 +ENV ALLOW_DOCKER_BACKEND_INTERNAL_URL=1 + +EXPOSE 3000 8000 -CMD ["python", "scripts/start_backend.py", "--host", "0.0.0.0", "--port", "8000"] +CMD ["/app/start.sh"] diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index 698fbda4b..67b1f47ed 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -400,6 +400,14 @@ export function DataLayout() { style={{ width: `${embeddingStage?.progress_percent ?? 0}%` }} > +
+ + +
@@ -794,6 +802,12 @@ export function DataLayout() { style={{ width: `${stage.progress_percent}%` }} >
+
+ +
))} @@ -868,6 +882,12 @@ export function DataLayout() {
{formatCount(collection.vector_dimensions)}
+
+ +
))} @@ -887,6 +907,14 @@ export function DataLayout() { {getSurfaceStatusLabel(check.status_code)} +
+ + +
))} {dataSurfaceStatus === 'loading' && ( diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx index 1290f3d82..60c1eecf7 100644 --- a/frontend/src/components/EmailDetail.test.tsx +++ b/frontend/src/components/EmailDetail.test.tsx @@ -296,12 +296,12 @@ describe("EmailDetail", () => { const cards = Array.from(container.querySelectorAll('article[data-insight-card="true"]')); expect(cards.map((card) => card.getAttribute("aria-label"))).toEqual( - expect.arrayContaining(["맥락 종합", "실행 항목", "답장 실행"]), + expect.arrayContaining(["맥락 종합", "실행 항목", "답장 초안"]), ); - expect(cards.find((card) => card.getAttribute("aria-label") === "답장 실행")?.querySelector('[role="heading"][aria-level="3"]')?.textContent).toContain("답장 실행"); + expect(cards.find((card) => card.getAttribute("aria-label") === "답장 초안")?.querySelector('[role="heading"][aria-level="3"]')?.textContent).toContain("답장 초안"); expect(cards.find((card) => card.getAttribute("aria-label") === "맥락 종합")?.textContent).toContain("출시 메시지의 핵심 맥락입니다."); expect(cards.find((card) => card.getAttribute("aria-label") === "실행 항목")?.textContent).toContain("캘린더에 출시 리뷰 일정을 반영"); - expect(cards.find((card) => card.getAttribute("aria-label") === "답장 실행")?.querySelector('textarea[aria-label="답장 초안"]')).not.toBeNull(); + expect(cards.find((card) => card.getAttribute("aria-label") === "답장 초안")?.querySelector('textarea[aria-label="답장 초안"]')).not.toBeNull(); }); it("lets users create tasks from visible execution items in the email detail", async () => { diff --git a/frontend/src/components/EmailDetail.tsx b/frontend/src/components/EmailDetail.tsx index a61515433..5e12f26bb 100644 --- a/frontend/src/components/EmailDetail.tsx +++ b/frontend/src/components/EmailDetail.tsx @@ -356,8 +356,18 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number loading={!llmData && !llmError} error={llmError} provenance="AI 생성" + {/* TODO: API 연동 시 실제 llmData.confidence 값으로 대체 */} > - {llmData ?

{llmData.summary}

: null} + {llmData ? ( +
+

{llmData.summary}

+
+ + 근거 원본 보기 + +
+
+ ) : null} 0 || syncStatus || taskStatus) ? ( <> {llmData.todos.length > 0 && ( @@ -423,12 +434,19 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number
-
-

대화 흐름

- - - {conversationMessages.length}개 메시지 - +
+
+

+ 스레드 전체 +

+ + + {conversationMessages.length}개 메시지 + +
+

오래된 메시지부터 최신 메시지 순서로 보여줍니다. 답장은 선택된 메시지를 기준으로 작성됩니다.

{threadLoading &&

대화 흐름을 불러오는 중입니다...

} @@ -440,10 +458,17 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number )}
{conversationMessages.map((msg) => ( -
+
{toMailDisplayText(msg.sender, '보낸 사람')} - {formatEmailDate(msg.date)} +
+ {formatEmailDate(msg.date)} + {msg.id !== conversationMessages[0]?.id && ( + + )} +
{msg.id === email.id && 선택된 메시지}
{toMailBodyText(msg.body)}
@@ -454,7 +479,7 @@ export function EmailDetail({ emailId, actionCommand = null }: { emailId: number - +
diff --git a/frontend/src/components/InsightCard.tsx b/frontend/src/components/InsightCard.tsx index 42609e25c..cf1e7f955 100644 --- a/frontend/src/components/InsightCard.tsx +++ b/frontend/src/components/InsightCard.tsx @@ -13,6 +13,7 @@ export interface InsightCardProps { emptyMessage?: string; onRetry?: () => void; provenance?: string; + confidence?: number; children: ReactNode; footerActions?: ReactNode; } @@ -27,6 +28,7 @@ export function InsightCard({ emptyMessage = "데이터가 없습니다.", onRetry, provenance, + confidence, children, footerActions, }: InsightCardProps) { @@ -38,13 +40,28 @@ export function InsightCard({ {icon && {icon}} {title} - {provenance && ( -
- - {provenance} +
+ {confidence !== undefined && ( +
= 80 ? 'bg-emerald-50 text-emerald-700 border-emerald-200' : + confidence >= 50 ? 'bg-amber-50 text-amber-700 border-amber-200' : + 'bg-red-50 text-red-700 border-red-200' + }`} + title="AI 판단 확신도" + > + 신뢰도 + {confidence}% +
+ )} + {provenance && ( +
+ + {provenance} +
+ )}
- )} - + {loading ? ( diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx index 80c4b8ca6..c54f8b8e5 100644 --- a/frontend/src/components/TasksLayout.tsx +++ b/frontend/src/components/TasksLayout.tsx @@ -307,6 +307,7 @@ export function TasksLayout() { value={taskSearch} onChange={(event) => setTaskSearch(event.target.value)} placeholder="작업 검색..." + aria-label="작업 검색" className="h-9 w-full rounded-md border border-border bg-background pl-9 pr-4 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary sm:w-64" />
diff --git a/frontend/src/components/WorkspaceHome.dashboard.test.tsx b/frontend/src/components/WorkspaceHome.dashboard.test.tsx index 85e2130fe..07428616d 100644 --- a/frontend/src/components/WorkspaceHome.dashboard.test.tsx +++ b/frontend/src/components/WorkspaceHome.dashboard.test.tsx @@ -35,6 +35,7 @@ vi.mock("lucide-react", () => ({ Network: () => , Send: () => , Settings: () => , + Sparkles: () => , })); import { WorkspaceHome } from "./WorkspaceHome"; @@ -344,7 +345,7 @@ describe("WorkspaceHome Today dashboard", () => { expect(linkHrefByText("메일함 열기")).toBe("/mail"); expect(linkHrefByText("보낸 메일 답변 추적")).toBe("/mail?folder=sent"); expect(linkHrefByText("일정 후보 검토")).toBe("/calendar"); - expect(linkHrefByText("작업 보드")).toBe("/tasks"); + expect(linkHrefByText("실행 항목 보드")).toBe("/tasks"); expect(linkHrefByText("프로젝트 의사결정")).toBe("/projects"); expect(linkHrefByText("AI 허브")).toBe("/ai-hub"); expect(linkHrefByText("데이터 품질 점검")).toBe("/data"); diff --git a/frontend/src/components/WorkspaceHome.tsx b/frontend/src/components/WorkspaceHome.tsx index 29f51675d..de45841bc 100644 --- a/frontend/src/components/WorkspaceHome.tsx +++ b/frontend/src/components/WorkspaceHome.tsx @@ -7,7 +7,7 @@ import type { MailFolder } from '@/components/EmailList'; import { EmailDetail } from '@/components/EmailDetail'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import dynamic from 'next/dynamic'; -import { CalendarDays, CheckCircle2, Inbox, Network, Send, Settings } from 'lucide-react'; +import { CalendarDays, CheckCircle2, Inbox, Network, Send, Settings, Sparkles } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; import { setMobileWorkspaceView, useMobileWorkspaceView } from '@/lib/mobile-workspace'; import { toSafeReactText } from '@/lib/safe-text'; @@ -90,9 +90,9 @@ const dashboardQuickActions = [ { label: '메일함 열기', href: '/mail', icon: Inbox, color: 'text-blue-500' }, { label: '보낸 메일 답변 추적', href: '/mail?folder=sent', icon: Send, color: 'text-rose-500' }, { label: '일정 후보 검토', href: '/calendar', icon: CalendarDays, color: 'text-blue-500' }, - { label: '작업 보드', href: '/tasks', icon: CheckCircle2, color: 'text-green-500' }, + { label: '실행 항목 보드', href: '/tasks', icon: CheckCircle2, color: 'text-green-500' }, { label: '프로젝트 의사결정', href: '/projects', icon: Network, color: 'text-purple-500' }, - { label: 'AI 허브', href: '/ai-hub', icon: Network, color: 'text-purple-500' }, + { label: 'AI 허브', href: '/ai-hub', icon: Sparkles, color: 'text-purple-500' }, { label: '데이터 품질 점검', href: '/data', icon: Network, color: 'text-blue-500' }, { label: '보안 감사 로그', href: '/security', icon: CheckCircle2, color: 'text-emerald-500' }, ]; @@ -821,7 +821,7 @@ export function WorkspaceHome({