diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index fa5c9fd..637c74a 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -42,25 +42,11 @@ public interface DocumentRepository extends JpaRepository { // currentVersion은 LAZY라, OSIV가 꺼진 경로(/mcp)에서 findById만 쓰면 // 트랜잭션 종료 후 지연 로딩 시 LazyInitializationException이 난다. JOIN FETCH로 즉시 로딩한다. + // 상세 조회는 이 쿼리 뒤의 읽기 전용 Transaction 안에서 owner와 fileObject를 개별 로딩해 + // 배포 데이터의 선택적 연관관계까지 한 번에 묶는 다중 JOIN FETCH를 피한다. @Query("SELECT d FROM Document d LEFT JOIN FETCH d.currentVersion WHERE d.id = :documentId") Optional findByIdWithCurrentVersion(@Param("documentId") Long documentId); - /** - * 문서 상세·본문·원본 파일 조회에 필요한 소유자, 현재 버전과 파일 정보를 한 번에 조회한다. - * - * @param documentId 조회할 문서 식별자 - * @return 상세 조회에 필요한 연관관계가 초기화된 문서 - */ - @Query(""" - SELECT d - FROM Document d - JOIN FETCH d.owner - LEFT JOIN FETCH d.currentVersion currentVersion - LEFT JOIN FETCH currentVersion.fileObject - WHERE d.id = :documentId - """) - Optional findByIdWithDetail(@Param("documentId") Long documentId); - @Query(""" SELECT d.id AS documentId, d.status AS documentStatus, diff --git a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java index bc0f290..06e1528 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryService.java @@ -198,7 +198,8 @@ private Document getReadableDocument(Long userId, Long documentId) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); } - Document document = documentRepository.findByIdWithDetail(documentId) + // 현재 버전만 JOIN FETCH하고 소유자·파일은 이 읽기 전용 Transaction 안에서 필요할 때 로딩한다. + Document document = documentRepository.findByIdWithCurrentVersion(documentId) .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND)); if (document.getStatus() == DocumentStatus.DELETED) { throw new DocGridException(ErrorCode.DOCUMENT_NOT_FOUND); diff --git a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java index b364118..7567cf0 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/rag/service/RagFacade.java @@ -27,7 +27,7 @@ * 2. PromptBuilder로 프롬프트 조립 * 3. OllamaClient 호출 * 4. rag_responses 저장 (성공/실패) - * 5. 성공 시 response_citations 저장 + * 5. 성공 시 response_citations 저장, 실패 시 검색 후보와 안내 답변 반환 * * *

SearchFacade와 별도 트랜잭션으로 분리되어 있다(SearchController가 순차 호출) — 검색 DB 작업과 @@ -39,6 +39,9 @@ @Slf4j public class RagFacade { + private static final String LLM_FALLBACK_ANSWER = + "관련 문서는 찾았지만 AI 답변 생성이 지연되고 있습니다. 아래 검색 결과와 근거 문서를 확인해 주세요."; + private final PromptBuilder promptBuilder; private final OllamaClient ollamaClient; private final RagResponseCommandService ragResponseCommandService; @@ -59,15 +62,20 @@ public RagAnswer generate( // 검색 후보가 있으면 프롬프트 조립 후 LLM 호출 String prompt = promptBuilder.build(queryText, candidates); + OllamaGenerateResult result; try { - OllamaGenerateResult result = ollamaClient.generate(prompt); - RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); - responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); - log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); - return RagAnswer.of(result.answerText(), candidates); + result = ollamaClient.generate(prompt); } catch (DocGridException e) { ragResponseCommandService.createFailed(queryRef, prompt, e.getMessage()); - throw e; + // LLM 장애가 권한 검증을 통과한 벡터 검색 결과까지 숨기지 않도록 저하 응답으로 마무리한다. + log.warn("[RAG] fallback queryId={} errorCode={}", queryId, e.getErrorCode().getCode()); + return RagAnswer.of(LLM_FALLBACK_ANSWER, candidates); } + + // LLM 이후의 영속화 실패는 검색 저하 응답으로 숨기지 않고 Transaction 오류로 전달한다. + RagResponse ragResponse = ragResponseCommandService.createSuccess(queryRef, prompt, result); + responseCitationCommandService.saveAll(ragResponse, candidates, searchResults); + log.info("[RAG] done queryId={} responseId={} latencyMs={}", queryId, ragResponse.getId(), result.latencyMs()); + return RagAnswer.of(result.answerText(), candidates); } } diff --git a/backend/src/main/java/com/opensource/docgrid/global/config/OllamaServerConfig.java b/backend/src/main/java/com/opensource/docgrid/global/config/OllamaServerConfig.java index 924c2b5..43b7aa3 100644 --- a/backend/src/main/java/com/opensource/docgrid/global/config/OllamaServerConfig.java +++ b/backend/src/main/java/com/opensource/docgrid/global/config/OllamaServerConfig.java @@ -9,20 +9,37 @@ import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; +/** + * Ollama HTTP 연결과 추론 응답 제한 시간을 실행 환경별로 구성한다. + * + *

RAG 도메인은 Timeout 이후의 검색 결과 Fallback을 책임지고, 이 설정은 Sites 요청 제한보다 + * 먼저 호출을 종료할 수 있는 Transport 경계만 책임진다.

+ */ @Configuration public class OllamaServerConfig { @Value("${ollama.server.base-url}") private String baseUrl; + @Value("${ollama.server.connect-timeout:5s}") + private Duration connectTimeout; + + @Value("${ollama.server.read-timeout:20s}") + private Duration readTimeout; + + /** + * 실행 환경별 제한 시간을 적용한 Ollama 전용 Client를 만든다. + */ @Bean("ollamaRestClient") public RestClient ollamaRestClient() { + // 1. 연결 실패와 느린 추론을 서로 다른 제한 시간으로 중단한다. HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) + .connectTimeout(connectTimeout) .build(); JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); - requestFactory.setReadTimeout(Duration.ofSeconds(30)); + requestFactory.setReadTimeout(readTimeout); + // 2. RAG Client가 동일한 Transport 정책을 사용하도록 이름이 지정된 Client를 제공한다. return RestClient.builder() .baseUrl(baseUrl) .requestFactory(requestFactory) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e23517e..d43155f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -93,4 +93,7 @@ embedding: ollama: server: base-url: ${OLLAMA_SERVER_URL:http://localhost:11434} + # Sites Worker의 30초 요청 제한보다 먼저 종료해 검색 결과 Fallback을 반환한다. + connect-timeout: ${OLLAMA_SERVER_CONNECT_TIMEOUT:5s} + read-timeout: ${OLLAMA_SERVER_READ_TIMEOUT:20s} model: ${OLLAMA_MODEL:qwen2.5:3b} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentRepositoryFetchTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentRepositoryFetchTest.java index b8906f6..754e0a1 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentRepositoryFetchTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/repository/DocumentRepositoryFetchTest.java @@ -38,7 +38,7 @@ class DocumentRepositoryFetchTest { @Autowired private EntityManager entityManager; @Test - @DisplayName("정상 케이스: currentVersion이 JOIN FETCH로 즉시 로딩되어 영속성 컨텍스트 초기화 후에도 지연 로딩 없이 접근 가능하다") + @DisplayName("정상 케이스: 현재 버전을 즉시 로딩하고 선택적 파일이 없어도 상세 연관관계에 접근할 수 있다") void findByIdWithCurrentVersion_eagerlyFetchesCurrentVersion() { // Given User owner = saveOwner(); @@ -55,6 +55,8 @@ void findByIdWithCurrentVersion_eagerlyFetchesCurrentVersion() { // Then assertThat(Hibernate.isInitialized(found.getCurrentVersion())).isTrue(); assertThat(found.getCurrentVersion().getVersionNo()).isEqualTo(1); + assertThat(found.getCurrentVersion().getFileObject()).isNull(); + assertThat(found.getOwner().getName()).isEqualTo("JOIN FETCH 테스트 사용자"); } @Test diff --git a/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java index deb28cf..c49f2f2 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/document/service/query/DocumentQueryServiceTest.java @@ -92,7 +92,7 @@ void getDocumentDetail_throws_whenReadPermissionIsDenied() { assertThatThrownBy(() -> service.getDocumentDetail(USER_ID, DOCUMENT_ID)) .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); - then(documentRepository).should(never()).findByIdWithDetail(DOCUMENT_ID); + then(documentRepository).should(never()).findByIdWithCurrentVersion(DOCUMENT_ID); } @Test @@ -378,7 +378,7 @@ private DocumentSummaryResponse summaryResponse() { private void givenReadableDocument(Document document) { given(permissionQueryService.canReadDocument(USER_ID, DOCUMENT_ID)).willReturn(true); - given(documentRepository.findByIdWithDetail(DOCUMENT_ID)).willReturn(Optional.of(document)); + given(documentRepository.findByIdWithCurrentVersion(DOCUMENT_ID)).willReturn(Optional.of(document)); given(document.getStatus()).willReturn(DocumentStatus.INDEXED); } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java index f9155e5..b3158f7 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/rag/service/RagFacadeTest.java @@ -1,7 +1,6 @@ package com.opensource.docgrid.domain.rag.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -115,8 +114,8 @@ void generate_success_savesResponseAndCitations() { } @Test - @DisplayName("Ollama 호출 실패: FAILED로 기록하고 예외를 다시 던진다") - void generate_ollamaFails_savesFailedAndRethrows() { + @DisplayName("Ollama 호출 실패: FAILED로 기록하고 검색 후보가 포함된 저하 응답을 반환한다") + void generate_ollamaFails_savesFailedAndReturnsFallback() { SearchQuery queryRef = mock(SearchQuery.class); given(entityManager.getReference(SearchQuery.class, QUERY_ID)).willReturn(queryRef); @@ -129,9 +128,11 @@ void generate_ollamaFails_savesFailedAndRethrows() { given(ollamaClient.generate("조립된 프롬프트")) .willThrow(new DocGridException(ErrorCode.RAG_SERVICE_UNAVAILABLE)); - assertThatThrownBy(() -> ragFacade.generate(QUERY_ID, "질문", candidates, List.of())) - .isInstanceOf(DocGridException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.RAG_SERVICE_UNAVAILABLE); + RagAnswer answer = ragFacade.generate(QUERY_ID, "질문", candidates, List.of()); + + assertThat(answer.answerText()).contains("AI 답변 생성이 지연"); + assertThat(answer.citations()).hasSize(1); + assertThat(answer.citations().get(0).documentId()).isEqualTo(100L); then(ragResponseCommandService).should(times(1)) .createFailed(eq(queryRef), eq("조립된 프롬프트"), anyString()); diff --git a/frontend/.env.example b/frontend/.env.example index f96ed38..5f4f0c6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1 @@ -NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 +BACKEND_API_URL=http://localhost:8080 diff --git a/frontend/.gitignore b/frontend/.gitignore index daf75ac..4e67f75 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -21,6 +21,7 @@ # misc .DS_Store *.pem +*.tsbuildinfo # debug npm-debug.log* diff --git a/frontend/README.md b/frontend/README.md index bd9b9e0..a379081 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,100 +1,40 @@ -# vinext-starter +# DocGrid Frontend -A clean full-stack starter running on -[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and -Drizzle support. +DocGrid 백엔드 API를 사용하는 실제 웹 클라이언트입니다. 기존 화면 프로토타입의 보라색 워크스페이스 디자인을 유지하면서 검색, 문서, 컬렉션, 권한, MCP 토큰, 계정, RAGOps 관리 화면을 API에 연결합니다. -## Prerequisites +## 실행 -- Node.js `>=22.13.0` - -## Quick Start +Node.js 22.13 이상이 필요합니다. ```bash +cp .env.example .env.local npm install npm run dev -npm run build ``` -This starter does not use `wrangler.jsonc`. - -## Included Shape - -- edit site code under `app/` -- `.openai/hosting.json` declares optional Sites D1 and R2 bindings -- `vite.config.ts` simulates declared bindings for local development -- `db/schema.ts` starts intentionally empty -- `examples/d1/` contains an optional D1 example surface -- `drizzle.config.ts` supports local migration generation when needed - -## Workspace Auth Headers - -Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present. - -The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes. - -SIWC-authenticated workspace sites may also receive -`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty -`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by -`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. - -Treat the full name as optional and fall back to email when it is absent: +`.env.local`에서 백엔드 주소를 지정합니다. -```tsx -import { headers } from "next/headers"; - -export default async function Home() { - const requestHeaders = await headers(); - const userId = requestHeaders.get("oai-authenticated-user-id"); - const email = requestHeaders.get("oai-authenticated-user-email"); - const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); - const fullName = - encodedFullName && - requestHeaders.get("oai-authenticated-user-full-name-encoding") === - "percent-encoded-utf-8" - ? decodeURIComponent(encodedFullName) - : null; - - const displayName = fullName ?? email; - // ... -} +```dotenv +BACKEND_API_URL=http://localhost:8080 ``` -## Optional Dispatch-Owned ChatGPT Sign-In - -Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs -optional or required ChatGPT sign-in: - -- Use `getChatGPTUser()` for optional signed-in UI. -- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send - anonymous visitors through Sign in with ChatGPT. -- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for - browser links or actions. -- Pass a same-origin relative `returnTo` path for the destination after sign-in - or sign-out. The helper validates and safely encodes it. -- Mark protected pages with `export const dynamic = "force-dynamic"` because - they depend on per-request identity headers. +브라우저는 `/api/backend/*` 같은 출처 프록시만 호출합니다. 프록시가 `BACKEND_API_URL`로 요청을 전달하므로 별도 프론트엔드 CORS 허용 목록 없이 로컬·배포 환경을 동일하게 사용할 수 있습니다. -Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the -OAuth cookies, and identity header injection. Do not implement app routes for -those reserved paths. Routes that do not import and call the helper remain -anonymous-compatible. +## 인증 -SIWC establishes identity only; it does not prove workspace membership. Use the -Sites hosting platform's access policy controls for workspace-wide restrictions, -or enforce explicit server-side membership or allowlist checks. +- ChatGPT 로그인은 사용하지 않습니다. +- `/auth/login`, `/auth/signup`, `/auth/me`를 사용합니다. +- JWT는 탭이 닫히면 사라지는 `sessionStorage`에만 보관합니다. +- 백엔드가 `401`을 반환하면 세션을 지우고 로그인 화면으로 이동합니다. -Use SIWC for account pages, user-specific dashboards, saved records, and write -actions tied to the current ChatGPT user. Leave public content anonymous. +## 검증 -## Useful Commands - -- `npm run dev`: start local development -- `npm run build`: verify the vinext build output -- `npm test`: build the starter and verify its rendered loading skeleton -- `npm run db:generate`: generate Drizzle migrations after schema changes +```bash +npm run lint +npm run build +npm test +``` -## Learn More +## API 제공 범위 -- [vinext Documentation](https://github.com/cloudflare/vinext) -- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) +문서 상세·추출 본문·원본 미리보기·다운로드, 컬렉션 소속 문서, 직접 권한 목록, 관리자 사용자 목록은 현재 모노레포의 백엔드 계약에 연결되어 있습니다. 배포된 백엔드가 해당 조회 API보다 오래된 버전이면 화면에서 버전 불일치를 안내합니다. diff --git a/frontend/app/[...slug]/page.tsx b/frontend/app/[...slug]/page.tsx index 475a697..72125f3 100644 --- a/frontend/app/[...slug]/page.tsx +++ b/frontend/app/[...slug]/page.tsx @@ -1,10 +1,10 @@ -import PrototypeApp from "../components/PrototypeApp"; +import DocGridApp from "../components/DocGridApp"; -export default async function RoutedPrototype({ +export default async function RoutedDocGrid({ params, }: { params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - return ; + return ; } diff --git a/frontend/app/api/backend/[...path]/route.ts b/frontend/app/api/backend/[...path]/route.ts new file mode 100644 index 0000000..159fff6 --- /dev/null +++ b/frontend/app/api/backend/[...path]/route.ts @@ -0,0 +1,63 @@ +import type { NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_BACKEND_URL = "http://localhost:8080"; + +async function proxy(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params; + if (!path.length || path.some((segment) => segment === "." || segment === "..")) { + return Response.json({ success: false, message: "잘못된 API 경로입니다." }, { status: 400 }); + } + + const baseUrl = process.env.BACKEND_API_URL ?? DEFAULT_BACKEND_URL; + const target = new URL(path.map(encodeURIComponent).join("/"), `${baseUrl.replace(/\/$/, "")}/`); + target.search = new URL(request.url).search; + + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("origin"); + headers.delete("referer"); + headers.delete("content-length"); + + // 1. Buffer the request once so JSON and multipart bodies are forwarded unchanged. + const body = request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.arrayBuffer(); + + // 2. Keep browser traffic same-origin while the Worker performs the backend HTTP request. + let upstream: Response; + try { + upstream = await fetch(target, { + method: request.method, + headers, + body, + redirect: "manual", + }); + } catch { + return Response.json( + { success: false, status: 502, code: "BACKEND_UNAVAILABLE", message: "DocGrid 백엔드에 연결할 수 없습니다." }, + { status: 502 }, + ); + } + + // 3. Disable caching because every protected response is user-specific. + const responseHeaders = new Headers(upstream.headers); + responseHeaders.set("Cache-Control", "no-store"); + responseHeaders.delete("content-length"); + responseHeaders.delete("content-encoding"); + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); +} + +type ProxyContext = { params: Promise<{ path: string[] }> }; + +export const GET = (request: NextRequest, context: ProxyContext) => proxy(request, context); +export const POST = (request: NextRequest, context: ProxyContext) => proxy(request, context); +export const PUT = (request: NextRequest, context: ProxyContext) => proxy(request, context); +export const PATCH = (request: NextRequest, context: ProxyContext) => proxy(request, context); +export const DELETE = (request: NextRequest, context: ProxyContext) => proxy(request, context); +export const OPTIONS = (request: NextRequest, context: ProxyContext) => proxy(request, context); diff --git a/frontend/app/chatgpt-auth.ts b/frontend/app/chatgpt-auth.ts deleted file mode 100644 index a0ae2ed..0000000 --- a/frontend/app/chatgpt-auth.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -export type ChatGPTUser = { - userId: string; - displayName: string; - email: string; - fullName: string | null; -}; - -const USER_ID_HEADER = "oai-authenticated-user-id"; -const USER_EMAIL_HEADER = "oai-authenticated-user-email"; -const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; -const USER_FULL_NAME_ENCODING_HEADER = - "oai-authenticated-user-full-name-encoding"; -const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; -const SIGN_IN_PATH = "/signin-with-chatgpt"; -const SIGN_OUT_PATH = "/signout-with-chatgpt"; -const CALLBACK_PATH = "/callback"; - -export async function getChatGPTUser(): Promise { - const requestHeaders = await headers(); - const userId = requestHeaders.get(USER_ID_HEADER); - const email = requestHeaders.get(USER_EMAIL_HEADER); - if (!userId || !email) return null; - - const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); - const fullName = - encodedFullName && - requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 - ? safeDecodeURIComponent(encodedFullName) - : null; - - return { - userId, - displayName: fullName ?? email, - email, - fullName, - }; -} - -export async function requireChatGPTUser( - returnTo: string, -): Promise { - const user = await getChatGPTUser(); - if (user) return user; - - redirect(chatGPTSignInPath(returnTo)); -} - -export function chatGPTSignInPath(returnTo: string): string { - const safeReturnTo = safeRelativeReturnPath(returnTo); - return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; -} - -export function chatGPTSignOutPath(returnTo = "/"): string { - const safeReturnTo = safeRelativeReturnPath(returnTo); - return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; -} - -function safeRelativeReturnPath(value: string): string { - if (!value.startsWith("/") || value.startsWith("//")) return "/"; - - let url: URL; - try { - url = new URL(value, "https://app.local"); - } catch { - return "/"; - } - if (url.origin !== "https://app.local") return "/"; - if (isReservedAuthPath(url.pathname)) return "/"; - - return `${url.pathname}${url.search}${url.hash}`; -} - -function isReservedAuthPath(pathname: string): boolean { - return ( - pathname === SIGN_IN_PATH || - pathname === SIGN_OUT_PATH || - pathname === CALLBACK_PATH - ); -} - -function safeDecodeURIComponent(value: string): string | null { - try { - return decodeURIComponent(value); - } catch { - return null; - } -} diff --git a/frontend/app/components/AppShell.tsx b/frontend/app/components/AppShell.tsx new file mode 100644 index 0000000..2b2f53f --- /dev/null +++ b/frontend/app/components/AppShell.tsx @@ -0,0 +1,90 @@ +"use client"; + +// vinext production navigation uses full requests because its client router does not complete catch-all route transitions. +/* eslint-disable @next/next/no-html-link-for-pages */ + +import { useMemo, useState } from "react"; +import { useAuth } from "./AuthProvider"; +import { BrandMark, initials } from "./ui"; + +const navSections = [ + { + label: "WORKSPACE", + items: [ + ["/search", "⌕", "AI 검색"], + ["/documents", "▤", "문서"], + ["/collections", "▱", "컬렉션"], + ["/permissions", "⌘", "권한 관리"], + ], + }, + { label: "INTEGRATION", items: [["/mcp-tokens", "⌁", "MCP 토큰"]] }, + { + label: "ADMIN", + items: [ + ["/admin/dashboard", "◫", "RAGOps"], + ["/admin/indexing-jobs", "⚙", "인덱싱 Job"], + ["/admin/workers", "▰", "Worker"], + ["/admin/users", "♙", "사용자·역할"], + ], + }, +] as const; + +const titles: Record = { + "/search": "AI 검색", + "/documents": "문서", + "/collections": "컬렉션", + "/permissions": "권한 관리", + "/mcp-tokens": "MCP 토큰", + "/account": "내 계정", + "/admin/dashboard": "RAGOps", + "/admin/indexing-jobs": "인덱싱 Job", + "/admin/workers": "Worker", + "/admin/users": "사용자·역할", +}; + +export function AppShell({ route, onUpload, children }: { + route: string; + onUpload: () => void; + children: React.ReactNode; +}) { + const { user } = useAuth(); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const admin = user?.roles.includes("ADMIN") ?? false; + const routeTitle = useMemo(() => { + if (route.startsWith("/documents/")) return "문서 상세"; + if (route.startsWith("/collections/")) return "컬렉션 상세"; + if (route.startsWith("/admin/indexing-jobs/")) return "Job 상세"; + return titles[route] ?? "DocGrid"; + }, [route]); + + function isActive(path: string) { + if (path === "/documents") return route.startsWith("/documents"); + if (path === "/collections") return route.startsWith("/collections"); + if (path === "/admin/indexing-jobs") return route.startsWith("/admin/indexing-jobs"); + return route === path; + } + + return ( +
+ + {mobileNavOpen ? DocGrid/{routeTitle}
+ + + {children} + + + ); +} diff --git a/frontend/app/components/AuthPage.tsx b/frontend/app/components/AuthPage.tsx new file mode 100644 index 0000000..8f271d1 --- /dev/null +++ b/frontend/app/components/AuthPage.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { apiRequest, errorMessage } from "../lib/api"; +import type { Department } from "../lib/api-types"; +import { useAuth } from "./AuthProvider"; +import { BrandMark } from "./ui"; + +export function AuthPage({ mode }: { mode: "login" | "signup" }) { + const signup = mode === "signup"; + const { login, user } = useAuth(); + const [departments, setDepartments] = useState([]); + const [departmentId, setDepartmentId] = useState(""); + const [departmentsLoading, setDepartmentsLoading] = useState(signup); + const [departmentError, setDepartmentError] = useState(""); + const [departmentRequest, setDepartmentRequest] = useState(0); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (user) window.location.replace(returnPath()); + }, [user]); + + useEffect(() => { + if (!signup) return; + let active = true; + + // 1. Initial state and retry events keep signup unavailable until the required choices are ready. + apiRequest("/departments", { auth: false }) + .then((result) => { + if (!active) return; + setDepartments(result); + if (!result.length) setDepartmentError("현재 가입 가능한 부서가 없습니다."); + }) + .catch((reason) => { + if (active) setDepartmentError(errorMessage(reason)); + }) + .finally(() => { + if (active) setDepartmentsLoading(false); + }); + + return () => { + active = false; + }; + }, [departmentRequest, signup]); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const email = String(form.get("email") ?? ""); + const password = String(form.get("password") ?? ""); + if (signup && !departmentId) { + setError("부서를 선택해 주세요."); + return; + } + setSubmitting(true); + setError(""); + + try { + // 1. Signup creates the DocGrid account but does not issue an access token. + if (signup) { + await apiRequest("/auth/signup", { + method: "POST", + auth: false, + body: { + email, + password, + name: String(form.get("name") ?? ""), + departmentId: Number(departmentId), + }, + }); + } + // 2. Login immediately establishes the JWT session for both flows. + await login(email, password); + window.location.replace(returnPath()); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setSubmitting(false); + } + } + + const signupUnavailable = signup && (departmentsLoading || Boolean(departmentError) || !departmentId); + + return ( +
+
+
DocGrid
+
+ KNOWLEDGE, CONNECTED +

{signup ? <>팀의 지식을
한곳에서 시작하세요. : <>흩어진 문서에서
정확한 답을 찾으세요.}

+

{signup ? "가입 후 소속 부서와 공유된 문서를 바로 탐색할 수 있습니다." : "권한이 허용된 사내 문서를 검색하고 근거가 포함된 AI 답변을 확인하세요."}

+
+
  • 문서 업로드부터 임베딩·색인까지 자동화
  • 권한 사전 필터와 실시간 접근 검증
  • MCP 토큰으로 AI 클라이언트 연동
+
+
+
+
DocGrid
+ {signup ? "CREATE ACCOUNT" : "WELCOME BACK"} +

{signup ? "회원가입" : "로그인"}

+

{signup ? "DocGrid 계정을 만들고 워크스페이스에 참여하세요." : "DocGrid 이메일과 비밀번호를 입력하세요."}

+ {error ?
{error}
: null} + {departmentError ?
{departmentError}
: null} + + + {signup ? <> + + + {departmentsLoading ? "가입 가능한 부서 목록을 불러오는 중입니다." : departmentError ? "목록을 다시 불러온 후 가입할 수 있습니다." : departmentId ? "선택한 부서로 계정이 생성됩니다." : "부서를 선택하면 가입 버튼이 활성화됩니다."} + : null} + + {signup ? "이미 계정이 있나요?" : "계정이 없으신가요?"} {signup ? "로그인" : "회원가입"} +
+
+
+ ); +} + +function returnPath() { + const requested = new URLSearchParams(window.location.search).get("returnTo"); + return requested?.startsWith("/") && !requested.startsWith("//") ? requested : "/search"; +} diff --git a/frontend/app/components/AuthProvider.tsx b/frontend/app/components/AuthProvider.tsx new file mode 100644 index 0000000..ea207ea --- /dev/null +++ b/frontend/app/components/AuthProvider.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { ACCESS_TOKEN_KEY, AUTH_EXPIRED_EVENT, apiRequest } from "../lib/api"; +import type { LoginResponse, MeResponse } from "../lib/api-types"; + +type AuthContextValue = { + user: MeResponse | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + refresh: () => Promise; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const logout = useCallback(() => { + window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); + setUser(null); + }, []); + + const refresh = useCallback(async () => { + const token = window.sessionStorage.getItem(ACCESS_TOKEN_KEY); + if (!token) { + setUser(null); + setLoading(false); + return null; + } + + try { + const me = await apiRequest("/auth/me"); + setUser(me); + return me; + } catch { + logout(); + return null; + } finally { + setLoading(false); + } + }, [logout]); + + useEffect(() => { + // 1. Rehydrate only the short-lived in-tab token when the application mounts. + const timer = window.setTimeout(() => void refresh(), 0); + // 2. Centralize expiry handling for any API call that returns 401. + window.addEventListener(AUTH_EXPIRED_EVENT, logout); + return () => { + window.clearTimeout(timer); + window.removeEventListener(AUTH_EXPIRED_EVENT, logout); + }; + }, [logout, refresh]); + + const login = useCallback(async (email: string, password: string) => { + const response = await apiRequest("/auth/login", { + method: "POST", + auth: false, + body: { email, password }, + }); + window.sessionStorage.setItem(ACCESS_TOKEN_KEY, response.accessToken); + const me = await apiRequest("/auth/me"); + setUser(me); + return me; + }, []); + + const value = useMemo(() => ({ user, loading, login, logout, refresh }), [user, loading, login, logout, refresh]); + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) throw new Error("AuthProvider 안에서 useAuth를 사용해야 합니다."); + return context; +} diff --git a/frontend/app/components/DocGridApp.tsx b/frontend/app/components/DocGridApp.tsx new file mode 100644 index 0000000..bb0f02d --- /dev/null +++ b/frontend/app/components/DocGridApp.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AccountPage } from "../features/AccountPage"; +import { AdminUsersPage, DashboardPage, IndexingJobDetailPage, IndexingJobsPage, WorkersPage } from "../features/AdminPages"; +import { CollectionDetailPage, CollectionsPage } from "../features/CollectionsPage"; +import { DocumentDetailPage, DocumentsPage } from "../features/DocumentsPage"; +import { McpTokensPage } from "../features/McpTokensPage"; +import { PermissionsPage } from "../features/PermissionsPage"; +import { SearchPage } from "../features/SearchPage"; +import { AppShell } from "./AppShell"; +import { AuthPage } from "./AuthPage"; +import { AuthProvider, useAuth } from "./AuthProvider"; +import { BrandMark, EmptyState, Toast } from "./ui"; +import { UploadModal } from "./UploadModal"; + +type UploadState = { open: boolean; documentId?: number }; + +export default function DocGridApp({ initialRoute }: { initialRoute: string }) { + return ; +} + +function DocGridRouter({ initialRoute }: { initialRoute: string }) { + const route = initialRoute.split("?")[0]; + const { user, loading } = useAuth(); + const [upload, setUpload] = useState({ open: false }); + const [toast, setToast] = useState(""); + const publicRoute = route === "/login" || route === "/signup"; + + useEffect(() => { + if (!loading && !user && !publicRoute) { + window.location.replace(`/login?returnTo=${encodeURIComponent(route)}`); + } + }, [loading, publicRoute, route, user]); + + const notify = useCallback((message: string) => setToast(message), []); + const page = useMemo(() => { + if (!user) return null; + if (route === "/search") return ; + if (route === "/documents") return setUpload({ open: true })} />; + if (route.startsWith("/documents/")) { + const id = numericTail(route); + return id ? setUpload({ open: true, documentId: id })} /> : ; + } + if (route === "/collections") return ; + if (route.startsWith("/collections/")) { + const id = numericTail(route); + return id ? : ; + } + if (route === "/permissions") return ; + if (route === "/mcp-tokens") return ; + if (route === "/account") return ; + if (route.startsWith("/admin/") && !user.roles.includes("ADMIN")) return ; + if (route === "/admin/dashboard") return ; + if (route === "/admin/indexing-jobs") return ; + if (route.startsWith("/admin/indexing-jobs/")) { + const id = numericTail(route); + return id ? : ; + } + if (route === "/admin/workers") return ; + if (route === "/admin/users") return ; + return ; + }, [notify, route, user]); + + if (publicRoute) return ; + if (loading || !user) return ; + + return <> + setUpload({ open: true })}>{page} + {upload.open ? setUpload({ open: false })} onSuccess={notify} /> : null} + {toast ? setToast("")} /> : null} + ; +} + +function numericTail(route: string) { + const value = Number(route.split("/").at(-1)); + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function AppLoading() { + return
DocGrid

세션을 확인하는 중입니다.

; +} + +function Forbidden() { + return
; +} + +function NotFound() { + return
; +} diff --git a/frontend/app/components/PrototypeApp.tsx b/frontend/app/components/PrototypeApp.tsx deleted file mode 100644 index 67706b7..0000000 --- a/frontend/app/components/PrototypeApp.tsx +++ /dev/null @@ -1,659 +0,0 @@ -"use client"; - -import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; - -type ModalName = "upload" | "version" | "collection" | "add-document" | "permission" | "token" | "role" | null; - -type RagOpsSummary = { - documents: { total: number; searchable: number; pendingIndex: number }; - jobs: { pending: number; processing: number; failed: number; avgProcessMs: number | null }; - workers: { activeCount: number; totalCount: number }; - search: { recent24hCount: number }; -}; - -type SyncSummary = { - capturedAt: string; - events: { - pendingCount: number; - processingCount: number; - failedCount: number; - oldestPendingAgeSeconds: number | null; - processedLast24hCount: number; - failedLast24hCount: number; - retriedLast24hCount: number; - successRateLast24h: number; - lastProcessedEventId: string | null; - lastProcessedAt: string | null; - }; - issues: { - openCount: number; - repairingCount: number; - autoResolvedLast24hCount: number; - failedRepairCount: number; - }; - reconciliation: { - runId: string; - mode: string; - status: string; - scannedCount: number; - detectedCount: number; - repairRequestedCount: number; - startedAt: string; - completedAt: string | null; - } | null; -}; - -type SyncEvent = { - eventId: string; - eventType: string; - aggregateType: string; - aggregateId: number; - status: string; - retryCount: number; - maxRetryCount: number; - occurredAt: string; - lastErrorCode: string | null; -}; - -type SyncIssue = { - issueId: number; - issueType: string; - severity: string; - status: string; - documentId: number | null; - documentVersionId: number | null; - actualJson: string | null; - repairable: boolean; - lastDetectedAt: string; - repairAttemptCount: number; -}; - -type PageData = { content: T[] }; -type ApiEnvelope = { success: boolean; data: T }; - -const API_BASE_URL = (process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080").replace(/\/$/, ""); - -function accessToken() { - if (typeof window === "undefined") return ""; - return window.sessionStorage.getItem("accessToken") - ?? window.localStorage.getItem("accessToken") - ?? window.localStorage.getItem("docgridAccessToken") - ?? ""; -} - -async function adminRequest(path: string, init?: RequestInit): Promise { - const token = accessToken(); - const response = await fetch(`${API_BASE_URL}${path}`, { - ...init, - headers: { - ...(init?.body ? { "Content-Type": "application/json" } : {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...init?.headers, - }, - }); - const payload = await response.json().catch(() => null) as ApiEnvelope | { message?: string } | null; - if (!response.ok || !payload || !("data" in payload)) { - const message = payload && "message" in payload ? payload.message : null; - throw new Error(message || `운영 API 요청에 실패했습니다. (${response.status})`); - } - return payload.data; -} - -function formatMetric(value: number | null | undefined) { - return value == null ? "—" : value.toLocaleString("ko-KR"); -} - -function formatAge(seconds: number | null) { - if (seconds == null) return "대기 없음"; - if (seconds < 60) return `${seconds}초`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}분`; - return `${Math.floor(seconds / 3600)}시간 ${Math.floor((seconds % 3600) / 60)}분`; -} - -function formatTimestamp(value: string | null | undefined) { - if (!value) return "—"; - return value.replace("T", " ").slice(5, 16); -} - -const navSections = [ - { - label: "WORKSPACE", - items: [ - ["/search", "⌕", "AI 검색"], - ["/documents", "▤", "문서"], - ["/collections", "▱", "컬렉션"], - ["/permissions", "⌘", "권한 관리"], - ], - }, - { - label: "INTEGRATION", - items: [["/mcp-tokens", "⌁", "MCP 토큰"]], - }, - { - label: "ADMIN", - items: [ - ["/admin/dashboard", "◫", "RAGOps"], - ["/admin/indexing-jobs", "⚙", "인덱싱 Job"], - ["/admin/workers", "▰", "Worker"], - ["/admin/users", "♙", "사용자·역할"], - ], - }, -] as const; - -const documents = [ - { id: 1024, title: "배포 운영 가이드", type: "PDF", owner: "플랫폼개발팀", version: "v3", status: "EMBEDDING", visibility: "DEPARTMENT", updated: "12분 전", tone: "violet" }, - { id: 1018, title: "신규 입사자 온보딩 가이드", type: "DOCX", owner: "People 팀", version: "v2", status: "INDEXED", visibility: "PUBLIC", updated: "어제", tone: "green" }, - { id: 996, title: "보안 사고 대응 매뉴얼", type: "PDF", owner: "보안팀", version: "v3", status: "INDEXED", visibility: "DEPARTMENT", updated: "8월 8일", tone: "orange" }, - { id: 971, title: "고객 인터뷰 아카이브", type: "MD", owner: "UX 리서치", version: "v1", status: "PARSING", visibility: "COLLECTION", updated: "8월 5일", tone: "blue" }, - { id: 954, title: "법인카드 사용 정책", type: "PDF", owner: "재무팀", version: "v4", status: "FAILED", visibility: "PUBLIC", updated: "8월 1일", tone: "red" }, -]; - -const collections = [ - { id: 12, name: "운영 문서", description: "배포·장애 대응에 필요한 팀 운영 가이드", docs: 8, visibility: "DEPARTMENT", status: "ACTIVE", color: "#6558e8" }, - { id: 18, name: "신규 입사자 허브", description: "온보딩, 복지, 보안 교육에 필요한 문서", docs: 12, visibility: "PUBLIC", status: "ACTIVE", color: "#1b9d82" }, - { id: 21, name: "제품 전략", description: "로드맵과 고객 리서치, 제품 의사결정 기록", docs: 6, visibility: "PRIVATE", status: "ACTIVE", color: "#e88a35" }, - { id: 7, name: "2025 아카이브", description: "지난 연도의 정책과 종료된 프로젝트 자료", docs: 24, visibility: "PRIVATE", status: "ARCHIVED", color: "#667085" }, -]; - -const sources = [ - { label: "[1]", title: "배포 운영 가이드", page: "12페이지", score: "91.3%", quote: "헬스체크가 2회 연속 실패하면 배포 파이프라인은 직전 안정 릴리스 태그로 자동 롤백을 트리거합니다." }, - { label: "[2]", title: "DB 마이그레이션 정책", page: "4페이지", score: "87.4%", quote: "Flyway 마이그레이션은 되돌릴 수 없으므로 롤백 시 역방향 스크립트를 수동으로 준비합니다." }, - { label: "[3]", title: "인시던트 대응 SOP", page: "2페이지", score: "80.2%", quote: "장애 등급 산정 후 24시간 이내 보고서를 공유하고 후속 액션을 담당자에게 배정합니다." }, -]; - -const initialJobs = [ - { id: 4417, status: "PROCESSING", document: "배포 운영 가이드", version: "v3 · EMBEDDING", model: "BAAI/bge-m3", worker: "indexing-worker-01", retry: "0 / 3", error: "—", created: "08-12 09:41" }, - { id: 4402, status: "FAILED", document: "DB 마이그레이션 정책", version: "v1 · FAILED", model: "BAAI/bge-m3", worker: "indexing-worker-03", retry: "3 / 3", error: "EMBEDDING_PROVIDER_UNAVAILABLE", created: "08-12 08:12" }, - { id: 4396, status: "PENDING", document: "보안 점검 체크리스트", version: "v1 · UPLOADED", model: "BAAI/bge-m3", worker: "—", retry: "1 / 3", error: "—", created: "08-12 07:58" }, - { id: 4381, status: "INDEXED", document: "인시던트 대응 SOP", version: "v2 · INDEXED", model: "BAAI/bge-m3", worker: "indexing-worker-02", retry: "0 / 3", error: "—", created: "08-11 22:04" }, - { id: 4377, status: "FAILED", document: "사내 규정 2026", version: "v1 · FAILED", model: "BAAI/bge-m3", worker: "indexing-worker-03", retry: "3 / 3", error: "DOCUMENT_CONTENT_INVALID", created: "08-11 19:30" }, - { id: 4351, status: "FAILED", document: "보안 점검 체크리스트", version: "v1 · FAILED", model: "BAAI/bge-m3", worker: "indexing-worker-01", retry: "3 / 3", error: "STORAGE_UNAVAILABLE", created: "08-11 13:07" }, -]; - -const initialTokens = [ - { id: 41, created: "2026-08-12 10:02", lastUsed: "—", status: "사용 가능" }, - { id: 33, created: "2026-07-02 09:15", lastUsed: "2026-08-11 18:44", status: "사용 가능" }, - { id: 28, created: "2026-05-20 13:31", lastUsed: "2026-06-30 11:07", status: "폐기됨" }, -]; - -const workers = [ - { name: "indexing-worker-01", id: "#1", status: "ACTIVE", host: "docgrid-api-01", ip: "10.0.0.12", instance: "2f3a2d8c...9abc", heartbeat: "3초 전", started: "08-12 07:30" }, - { name: "indexing-worker-02", id: "#2", status: "ACTIVE", host: "docgrid-api-02", ip: "10.0.0.13", instance: "7b11c4de...10f2", heartbeat: "2초 전", started: "08-12 07:30" }, - { name: "indexing-worker-03", id: "#3", status: "DEAD", host: "docgrid-api-03", ip: "10.0.0.14", instance: "c93aa07f...4d81", heartbeat: "6분 전", started: "08-12 07:30" }, - { name: "indexing-worker-04", id: "#4", status: "ACTIVE", host: "docgrid-api-04", ip: "10.0.0.15", instance: "18ee9b23...77a0", heartbeat: "1초 전", started: "08-12 07:31" }, - { name: "indexing-worker-05", id: "#5", status: "IDLE", host: "docgrid-api-05", ip: "10.0.0.16", instance: "a02f7cc1...be44", heartbeat: "4초 전", started: "08-12 07:31" }, - { name: "indexing-worker-06", id: "#6", status: "IDLE", host: "docgrid-api-06", ip: "10.0.0.17", instance: "d5c8100b...31ac", heartbeat: "5초 전", started: "08-12 07:31" }, -]; - -const initialUsers = [ - { id: 21, name: "이수진", email: "sujin@docgrid.io", department: "플랫폼개발팀", roles: ["USER"], status: "ACTIVE" }, - { id: 18, name: "박준호", email: "junho@docgrid.io", department: "인프라팀", roles: ["USER", "DOCUMENT_MANAGER"], status: "ACTIVE" }, - { id: 7, name: "김기민", email: "gimin@docgrid.io", department: "플랫폼개발팀", roles: ["USER", "ADMIN"], status: "ACTIVE" }, - { id: 29, name: "최다은", email: "daeun@docgrid.io", department: "경영지원팀", roles: ["USER"], status: "LOCKED" }, -]; - -function BrandMark() { - return ; -} - -function StatusPill({ value }: { value: string }) { - const normalized = value.toLowerCase().replaceAll(" ", "-"); - return {value}; -} - -function ApiFlag({ children }: { children: React.ReactNode }) { - return {children}; -} - -function PageHeading({ - kicker, - title, - description, - actions, -}: { - kicker: string; - title: string; - description: string; - actions?: React.ReactNode; -}) { - return ( -
-
{kicker}

{title}

{description}

- {actions &&
{actions}
} -
- ); -} - -function EmptyState({ symbol, title, description }: { symbol: string; title: string; description: string }) { - return
{symbol}{title}

{description}

; -} - -function AuthPage({ mode }: { mode: "login" | "signup" }) { - const signup = mode === "signup"; - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [authError, setAuthError] = useState(""); - const [submitting, setSubmitting] = useState(false); - - async function submit(event: FormEvent) { - event.preventDefault(); - if (signup) { - window.location.href = "/search"; - return; - } - setSubmitting(true); - setAuthError(""); - try { - const response = await fetch(`${API_BASE_URL}/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - const payload = await response.json() as ApiEnvelope<{ accessToken: string; roles: string[] }> & { message?: string }; - if (!response.ok || !payload.data?.accessToken) { - throw new Error(payload.message || "이메일 또는 비밀번호를 확인하세요."); - } - // 관리자 API와 STOMP CONNECT가 같은 JWT를 재사용하도록 브라우저 Session에 저장한다. - window.sessionStorage.setItem("accessToken", payload.data.accessToken); - window.location.href = payload.data.roles.includes("ADMIN") ? "/admin/dashboard" : "/search"; - } catch (error) { - setAuthError(error instanceof Error ? error.message : "로그인에 실패했습니다."); - } finally { - setSubmitting(false); - } - } - - return ( -
- 로그인 없이 둘러보기 → -
-
DocGrid
-
- KNOWLEDGE, CONNECTED -

{signup ? <>팀의 지식을
한곳에서 시작하세요. : <>흩어진 문서에서
정확한 답을 찾으세요.}

-

{signup ? "가입과 동시에 USER 권한이 부여되며 소속 부서의 문서를 탐색할 수 있습니다." : "권한이 허용된 사내 문서를 검색하고 근거가 포함된 AI 답변을 확인하세요."}

-
-
  • 문서 업로드부터 임베딩·색인까지 자동화
  • 권한 사전 필터와 실시간 접근 검증
  • MCP 토큰으로 AI 클라이언트 연동
-
-
-
-
DocGrid
- {signup ? "CREATE ACCOUNT" : "WELCOME BACK"} -

{signup ? "회원가입" : "로그인"}

-

{signup ? "모든 항목은 필수입니다." : "이메일과 비밀번호를 입력하세요."}

- - - {signup && <>} - {authError &&
{authError}
} - - {signup ? "이미 계정이 있나요?" : "계정이 없으신가요?"} {signup ? "로그인" : "회원가입"} -
-
-
- ); -} - -export default function PrototypeApp({ initialRoute }: { initialRoute: string }) { - const route = initialRoute === "/" ? "/search" : initialRoute; - const [mobileNavOpen, setMobileNavOpen] = useState(false); - const [modal, setModal] = useState(null); - const [toast, setToast] = useState(""); - const [query, setQuery] = useState("배포 실패 시 롤백 절차가 어떻게 되나요?"); - const [submittedQuery, setSubmittedQuery] = useState(""); - const [searching, setSearching] = useState(false); - const [selectedFile, setSelectedFile] = useState(""); - const [uploading, setUploading] = useState(false); - const [jobs, setJobs] = useState(initialJobs); - const [tokens, setTokens] = useState(initialTokens); - const [issuedToken, setIssuedToken] = useState(""); - const [permissions, setPermissions] = useState([ - { id: 81, target: "이수진", type: "USER", permission: "WRITE", expires: "2026-12-31" }, - { id: 67, target: "플랫폼개발팀", type: "DEPARTMENT", permission: "READ", expires: "없음" }, - { id: 49, target: "DOCUMENT_MANAGER", type: "ROLE", permission: "ADMIN", expires: "없음" }, - ]); - const [users, setUsers] = useState(initialUsers); - const [ragOpsSummary, setRagOpsSummary] = useState(null); - const [syncSummary, setSyncSummary] = useState(null); - const [syncEvents, setSyncEvents] = useState([]); - const [syncIssues, setSyncIssues] = useState([]); - const [syncLoading, setSyncLoading] = useState(false); - const [syncError, setSyncError] = useState(""); - const [syncConnection, setSyncConnection] = useState<"CONNECTING" | "LIVE" | "POLLING">("CONNECTING"); - const [syncBusyKey, setSyncBusyKey] = useState(""); - - const loadSyncDashboard = useCallback(async (showLoading = false) => { - if (showLoading) setSyncLoading(true); - try { - const [dashboard, summary, eventsPage, issuesPage] = await Promise.all([ - adminRequest("/admin/dashboard/summary"), - adminRequest("/admin/sync/summary"), - adminRequest>("/admin/sync/events?size=8"), - adminRequest>("/admin/sync/issues?size=8"), - ]); - setRagOpsSummary(dashboard); - setSyncSummary(summary); - setSyncEvents(eventsPage.content); - setSyncIssues(issuesPage.content); - setSyncError(""); - } catch (error) { - setSyncError(error instanceof Error ? error.message : "운영 데이터를 불러오지 못했습니다."); - } finally { - if (showLoading) setSyncLoading(false); - } - }, []); - - useEffect(() => { - if (!toast) return; - const timer = window.setTimeout(() => setToast(""), 2500); - return () => window.clearTimeout(timer); - }, [toast]); - - useEffect(() => { - if (route !== "/admin/dashboard") return; - const initialLoad = window.setTimeout(() => void loadSyncDashboard(true), 0); - const timer = window.setInterval(() => void loadSyncDashboard(), 10_000); - return () => { - window.clearTimeout(initialLoad); - window.clearInterval(timer); - }; - }, [loadSyncDashboard, route]); - - useEffect(() => { - if (route !== "/admin/dashboard") return; - const token = accessToken(); - if (!token) { - const fallback = window.setTimeout(() => setSyncConnection("POLLING"), 0); - return () => window.clearTimeout(fallback); - } - - // 1. 기존 관리자 Dashboard STOMP Topic에 직접 연결해 상태 전이 push를 수신한다. - const socketUrl = `${API_BASE_URL.replace(/^http/, "ws")}/ws/websocket`; - const socket = new WebSocket(socketUrl); - socket.onopen = () => socket.send( - `CONNECT\naccept-version:1.2\nAuthorization:Bearer ${token}\nheart-beat:10000,10000\n\n\0`, - ); - socket.onmessage = (event) => { - const frame = String(event.data); - if (frame.startsWith("CONNECTED")) { - socket.send("SUBSCRIBE\nid:ragops-sync\ndestination:/topic/dashboard\nack:auto\n\n\0"); - setSyncConnection("LIVE"); - return; - } - // 2. 기존 RAGOps push를 신호로 사용하고 민감한 Frame 본문 대신 최신 관리자 API를 다시 읽는다. - if (frame.startsWith("MESSAGE")) void loadSyncDashboard(); - }; - socket.onerror = () => setSyncConnection("POLLING"); - socket.onclose = () => setSyncConnection("POLLING"); - return () => socket.close(); - }, [loadSyncDashboard, route]); - - const routeTitle = useMemo(() => { - if (route.startsWith("/documents/")) return "문서 상세"; - if (route.startsWith("/collections/")) return "컬렉션 상세"; - if (route.startsWith("/admin/indexing-jobs/")) return "Job 상세"; - const titles: Record = { - "/search": "AI 검색", "/documents": "문서", "/collections": "컬렉션", "/permissions": "권한 관리", - "/mcp-tokens": "MCP 토큰", "/account": "내 계정", "/admin/dashboard": "RAGOps", - "/admin/indexing-jobs": "인덱싱 Job", "/admin/workers": "Worker", "/admin/users": "사용자·역할", - }; - return titles[route] ?? "AI 검색"; - }, [route]); - - if (route === "/login" || route === "/signup") return ; - - function isActive(path: string) { - if (path === "/documents") return route.startsWith("/documents"); - if (path === "/collections") return route.startsWith("/collections"); - if (path === "/admin/indexing-jobs") return route.startsWith("/admin/indexing-jobs"); - return route === path; - } - - function submitSearch(event?: FormEvent) { - event?.preventDefault(); - if (!query.trim()) return; - // 1. Show the retrieval state before revealing the answer. - setSearching(true); - setSubmittedQuery(""); - // 2. Resolve to a deterministic demo response for prototype review. - window.setTimeout(() => { setSubmittedQuery(query.trim()); setSearching(false); }, 700); - } - - function finishUpload() { - if (!selectedFile) return; - // 1. Simulate upload and asynchronous indexing job creation. - setUploading(true); - // 2. Close with a status-oriented confirmation rather than claiming indexing completion. - window.setTimeout(() => { setUploading(false); setModal(null); setSelectedFile(""); setToast("업로드가 접수되어 인덱싱을 시작했습니다."); }, 900); - } - - function retryJob(id: number) { - setJobs((current) => current.map((job) => job.id === id ? { ...job, status: "PENDING", error: "—", retry: "0 / 3" } : job)); - setToast(`#${id} 작업을 대기열에 다시 추가했습니다.`); - } - - function issueToken() { - const rawToken = "dgk_9f2c1a7e4b8d3056a1c9e7f2b4d80a63"; - setIssuedToken(rawToken); - setTokens((current) => [{ id: 52, created: "방금", lastUsed: "—", status: "사용 가능" }, ...current]); - setModal(null); - setToast("새 MCP 토큰을 발급했습니다. 원문은 한 번만 표시됩니다."); - } - - async function runSyncCommand( - busyKey: string, - path: string, - successMessage: string, - body?: unknown, - ) { - setSyncBusyKey(busyKey); - try { - await adminRequest(path, { - method: "POST", - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - setToast(successMessage); - await loadSyncDashboard(); - } catch (error) { - setToast(error instanceof Error ? error.message : "운영 명령 실행에 실패했습니다."); - } finally { - setSyncBusyKey(""); - } - } - - function ignoreSyncIssue(issue: SyncIssue) { - const reason = window.prompt("이 Issue를 자동 조치하지 않는 이유를 입력하세요."); - if (!reason?.trim()) return; - void runSyncCommand( - `ignore-${issue.issueId}`, - `/admin/sync/issues/${issue.issueId}/ignore`, - `Issue #${issue.issueId}를 감사 사유와 함께 무시했습니다.`, - { reason: reason.trim() }, - ); - } - - function renderPage() { - if (route === "/search") return ( -
- {!submittedQuery && !searching ? ( -
-
AI KNOWLEDGE SEARCH
-

팀의 지식에서
정확한 답을 찾으세요.

-

벡터 검색과 권한 검증을 거쳐, 출처가 명확한 답변을 제공합니다.

-
setQuery(event.target.value)} aria-label="문서 검색" />
-
-
추천 질문{["배포 실패 시 롤백 절차", "법인카드 사용 기준", "보안 사고 보고 순서"].map((item) => )}
- -
- ) : ( -
-
setQuery(event.target.value)} />
- {searching ?
권한이 있는 문서에서 답을 찾고 있어요

벡터 유사도와 live check를 적용 중입니다.

: <> -
AI 답변3개의 문서를 참고했어요 · queryId 4821
-

{submittedQuery}

배포 실패가 감지되면 먼저 헬스체크 실패 원인과 직전 안정 릴리스 태그를 확인합니다. 롤백 후에는 DB 마이그레이션의 적용 여부를 점검해야 하며, 장애 보고서는 24시간 이내 공유합니다.

답변이 도움이 되었나요?
-

검색 결과와 근거 문서

유사도 높은 순
- } -
- )} -
- ); - - if (route === "/documents") return ( -
- 데모 데이터 · 목록 API 준비 중} /> -
총 24개 문서
-
문서소유 팀버전상태공개 범위최근 수정
{documents.map((document) =>
{document.title}{document.type} · documentId {document.id}
{document.owner}{document.version}{document.updated}
)}
-
- ); - - if (route.startsWith("/documents/")) return ( -
-
← 문서 목록documentId 1024
- setModal("version")}>+ 새 버전 올리기} /> -

인덱싱 진행 상태

3초 간격 폴링 · 현재 EMBEDDING 단계

{[["✓","UPLOADED","done"],["✓","PARSING","done"],["✓","CHUNKED","done"],["3","EMBEDDING","current"],["4","INDEXED",""]].map(([number,label,state]) =>
{number}{label}
)}
-

현재 검색 가능한 버전

currentVersion
versionNo
v2
status
공개 범위
DEPARTMENT

검색 결과에는 이 버전의 청크만 노출됩니다.

처리 중인 버전

processingVersion
versionNo
v3
status
jobStatus

완료되면 currentVersion이 v3으로 교체됩니다.

버전 히스토리

3개
v3오늘 09:41
v207-30 14:02
v107-28 11:20
- -
- ); - - if (route === "/collections") return ( -
setModal("collection")}>+ 새 컬렉션} />
- ); - - if (route.startsWith("/collections/")) return ( -
← 컬렉션 목록collectionId 12
} />
운영 문서ownerUserId 7 · 2026-05-02 생성
8개 문서

포함 문서

이 컬렉션으로 검색할 수 있는 문서입니다.

목록 API 준비 중 · 데모 데이터
문서상태공개 범위추가 시각
{documents.slice(0,4).map((document) =>
{document.title}documentId {document.id}
{document.visibility}2026-08-12 09:41
)}
- ); - - if (route === "/permissions") return ( -
setModal("permission")}>+ 권한 부여} />
배포 운영 가이드documentId 1024

내 권한 요약

/permissions/documents/1024/me
권한이 부여된 경로
DEPARTMENTROLEOWNERPUBLIC

하나라도 허용이면 접근 가능하며 회수는 이 사용자에게 직접 부여된 권한만 적용됩니다.

부여된 권한

목록 API 준비 중
{permissions.map((permission) =>
{permission.target}{permission.type}{permission.expires}
)}
-
- ); - - if (route === "/mcp-tokens") return ( -
setModal("token")}>+ 토큰 발급} />{issuedToken &&
🔐 토큰 원문은 지금 한 번만 표시됩니다.{issuedToken}
}

발급된 토큰

revokedAt이 없으면 사용할 수 있습니다.

{tokens.map((token) =>
#{token.id}{token.created}{token.lastUsed}{token.status === "사용 가능" ? : }
)}

사용할 수 있는 도구

접근 가능한 문서만 반환합니다.

search_documents질문과 관련된 문서 청크 벡터 검색
get_document_detail문서 메타데이터와 현재 버전 조회
get_indexing_status문서 인덱싱 진행 상태 조회
- ); - - if (route === "/account") return ( -
로그아웃} />
기민

김기민

gimin@docgrid.io
상태
부서
플랫폼개발팀

계정 정보

GET /auth/me
userId
7
nickname
기민
departmentId
3
createdAt
2026-03-04 09:12
lastLoginAt
2026-08-12 08:55
status
ACTIVE

역할에 따라 보이는 메뉴

검색 · 문서 · 컬렉션 · 권한 · MCP 토큰
문서 운영 담당 · 관리자 부여
RAGOps · 인덱싱 Job · Worker · 사용자·역할
- ); - - if (route === "/admin/dashboard") return ( -
- -
{syncConnection === "LIVE" ? "실시간 연결" : syncConnection === "CONNECTING" ? "연결 중" : "10초 자동 갱신"}
- - - } - /> - - {syncError &&
운영 API를 연결할 수 없습니다. {syncError} ADMIN JWT를 브라우저의 accessToken에 저장했는지 확인하세요.
} - -
-
전체 문서{formatMetric(ragOpsSummary?.documents.total)}soft-delete 제외
-
검색 가능{formatMetric(ragOpsSummary?.documents.searchable)}INDEXED
-
인덱싱 대기{formatMetric(ragOpsSummary?.documents.pendingIndex)}UPLOADED · INDEXING
-
최근 24시간 검색{formatMetric(ragOpsSummary?.search.recent24hCount)}실제 Query 집계
-
-
-
Job 대기{formatMetric(ragOpsSummary?.jobs.pending)}PENDING
-
Job 처리 중{formatMetric(ragOpsSummary?.jobs.processing)}PROCESSING
-
Job 실패{formatMetric(ragOpsSummary?.jobs.failed)}실패 Job 확인 →
-
평균 처리 시간{formatMetric(ragOpsSummary?.jobs.avgProcessMs)}msQueue 대기 제외
-
정상 Worker{formatMetric(ragOpsSummary?.workers.activeCount)}/ {formatMetric(ragOpsSummary?.workers.totalCount)}ACTIVE · IDLE
-
- -
-
TRANSACTIONAL OUTBOX

동기화 원장과 정합성

-

마지막 갱신 {formatTimestamp(syncSummary?.capturedAt)} · 마지막 처리 Event {syncSummary?.events.lastProcessedEventId?.slice(0, 8) ?? "—"}

-
-
-
Outbox 대기{formatMetric(syncSummary?.events.pendingCount)}최대 지연 {formatAge(syncSummary?.events.oldestPendingAgeSeconds ?? null)}
-
Dispatcher 처리 중{formatMetric(syncSummary?.events.processingCount)}Lease 소유 Event
-
0 ? "danger-metric" : "success-metric"}`}>Event 최종 실패{formatMetric(syncSummary?.events.failedCount)}최근 24시간 {formatMetric(syncSummary?.events.failedLast24hCount)}
-
24시간 처리 성공률{syncSummary ? `${syncSummary.events.successRateLast24h}%` : "—"}성공 {formatMetric(syncSummary?.events.processedLast24hCount)} · 재시도 {formatMetric(syncSummary?.events.retriedLast24hCount)}
-
0 ? "danger-metric" : "success-metric"}`}>미해결 정합성 Issue{formatMetric(syncSummary?.issues.openCount)}복구 중 {formatMetric(syncSummary?.issues.repairingCount)}
-
24시간 자동 복구{formatMetric(syncSummary?.issues.autoResolvedLast24hCount)}실패 {formatMetric(syncSummary?.issues.failedRepairCount)}
-
마지막 Reconciliation{syncSummary?.reconciliation?.status ?? "미실행"}{syncSummary?.reconciliation ? `${syncSummary.reconciliation.scannedCount}개 검사 · ${syncSummary.reconciliation.detectedCount}개 탐지` : "실행 이력 없음"}
-
복구 요청{formatMetric(syncSummary?.reconciliation?.repairRequestedCount)}{syncSummary?.reconciliation?.mode ?? "DRY_RUN / REPAIR"}
-
- - {((syncSummary?.events.failedCount ?? 0) > 0 || (syncSummary?.issues.failedRepairCount ?? 0) > 0) &&
- {(syncSummary?.events.failedCount ?? 0) > 0 &&
최종 실패 Sync Event가 {syncSummary?.events.failedCount}건 있습니다. 원인을 확인한 뒤 개별 재시도하세요.
} - {(syncSummary?.issues.failedRepairCount ?? 0) > 0 &&
자동 복구에 실패한 Issue가 {syncSummary?.issues.failedRepairCount}건 있습니다.
} -
} - -
-
-

최근 Sync Event

Event ID로 장애 전후 처리 지점을 추적합니다.

GET /admin/sync/events
-
- {syncEvents.length === 0 ? : syncEvents.map((event) =>
- {event.eventId.slice(0, 8)} - {event.eventType}{event.aggregateType} #{event.aggregateId} - - {event.retryCount} / {event.maxRetryCount} - {event.lastErrorCode ?? formatTimestamp(event.occurredAt)} - {event.status === "FAILED" ? : } -
)} -
-
-
-

정합성 Issue

위험한 변경은 보고만 하고 관리자 판단을 기다립니다.

GET /admin/sync/issues
-
- {syncIssues.length === 0 ? : syncIssues.map((issue) =>
- #{issue.issueId} · {issue.issueType}document {issue.documentId ?? "GLOBAL"} · {formatTimestamp(issue.lastDetectedAt)} - - - - {issue.status === "OPEN" && issue.repairable && } - {issue.status === "OPEN" && } - -
)} -
-
-
-
- ); - - if (route === "/admin/indexing-jobs") return ( -
setToast("실패한 작업을 모두 재처리 요청했습니다.")}>실패 전체 재처리} />
필터
JOB상태문서버전모델WORKER재시도오류 코드생성
{jobs.map((job) =>
#{job.id}{job.document}{job.version}{job.model}{job.worker}{job.retry}{job.error}{job.created}{job.status === "FAILED" ? : }
)}
총 214건 · 20건씩
- ); - - if (route.startsWith("/admin/indexing-jobs/")) return ( -
} />
문서
DB 마이그레이션 정책 #9
버전
v1 ·
모델
BAAI/bge-m3 · v1
priority
0
재시도
3 / 3 · 한도 도달
소유 Worker
indexing-worker-03
lockExpiresAt
2026-08-12 08:17
errorCode
EMBEDDING_PROVIDER_UNAVAILABLE
⚠ 수동 재처리 시 기존 retryCount는 보존됩니다.

실행 시도

/attempts

#상태WORKER소요오류 코드
3worker-0342,031msEMBEDDING_PROVIDER_UNAVAILABLE
2worker-0138,110msEMBEDDING_PROVIDER_UNAVAILABLE
1worker-021,250msSTORAGE_UNAVAILABLE

실패 유형: STORAGE_UNAVAILABLE · DOCUMENT_CONTENT_INVALID · EMBEDDING_PROVIDER_UNAVAILABLE · WORKER_INTERNAL_ERROR

이벤트 타임라인

/events
{[["FAILED","08:12","PROCESSING → FAILED · 재시도 한도 도달","danger"],["EMBEDDING_FAILED","08:11","임베딩 서버 응답 없음","purple"],["RETRY","08:05","FAILED → PENDING · 3번째 자동 재시도","purple"],["EMBEDDING_STARTED","08:06","CHUNKED → EMBEDDING","purple"],["CHUNKED","07:59","청크 24개 생성","success"],["LOCKED","07:58","worker-03이 Job claim","purple"],["JOB_CREATED","07:55","업로드 Job 생성","purple"]].map(([name,time,detail,tone]) =>
{name} {time}

{detail}

)}
- ); - - if (route === "/admin/workers") return ( -
30초마다 자동 새로고침 · 방금} />
ACTIVE3Job 처리 중
IDLE2대기 중
DEAD1heartbeat 끊김
STOPPED0정상 종료
WORKER상태호스트IPINSTANCE ID마지막 HEARTBEAT시작
{workers.map((worker) =>
{worker.name} {worker.id}{worker.host}{worker.ip}{worker.instance}{worker.heartbeat}{worker.started}
)}
indexing-worker-03이 DEAD로 판정되었습니다. 이 Worker가 잡고 있던 Job은 lease 만료 후 다른 Worker가 다시 가져갑니다.
- ); - - if (route === "/admin/users") return ( -
사용자 목록 API 준비 중 · 데모 데이터} />
{users.map((user) =>
{user.name}{user.email}{user.department}{user.roles.map((role) => )}
)}

역할 부여

선택 사용자 · 이수진

userId 21
⚠ 이미 부여된 역할을 다시 부여하면 409를 반환합니다.

부여 결과 응답

userId
21
email
sujin@docgrid.io
roles
USER · DOCUMENT_MANAGER
- ); - - return
; - } - - return ( -
- - {mobileNavOpen && DocGrid/{routeTitle}
INTERACTIVE PROTOTYPE
김기민Platform · ADMIN
{renderPage()} - - {(modal === "upload" || modal === "version") &&
setModal(null)}>
event.stopPropagation()}>

{modal === "version" ? "새 버전 업로드" : "문서 업로드"}

{modal === "version" ? "현재 문서에 새 버전을 추가합니다." : "검색할 수 있는 새 문서를 추가합니다."}

{modal === "upload" && <>}
} - {modal === "collection" &&
setModal(null)}>
event.stopPropagation()}>

새 컬렉션

관련 문서를 하나의 검색 범위로 묶습니다.