From d7063b7868c9e5881b4522b6fba9ae578d742c6a Mon Sep 17 00:00:00 2001 From: GiminKim Date: Wed, 12 Aug 2026 19:05:24 +0900 Subject: [PATCH 1/4] feat: add DocGrid interactive prototype --- .gitignore | 40 + .openai/hosting.json | 5 + README.md | 100 + app/chatgpt-auth.ts | 90 + app/globals.css | 377 ++ app/layout.tsx | 34 + app/page.tsx | 498 ++ build/sites-vite-plugin.ts | 45 + db/index.ts | 13 + db/schema.ts | 4 + drizzle.config.ts | 7 + eslint.config.mjs | 41 + next-env.d.ts | 5 + next.config.ts | 7 + package-lock.json | 10371 +++++++++++++++++++++++++++++++++ package.json | 46 + postcss.config.mjs | 7 + public/favicon.svg | 6 + public/file.svg | 1 + public/globe.svg | 1 + public/og.png | Bin 0 -> 1547711 bytes public/window.svg | 1 + tests/rendered-html.test.mjs | 37 + tsconfig.json | 29 + vite.config.ts | 59 + worker/index.ts | 47 + 26 files changed, 11871 insertions(+) create mode 100644 .gitignore create mode 100644 .openai/hosting.json create mode 100644 README.md create mode 100644 app/chatgpt-auth.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 build/sites-vite-plugin.ts create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 drizzle.config.ts create mode 100644 eslint.config.mjs create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/favicon.svg create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/og.png create mode 100644 public/window.svg create mode 100644 tests/rendered-html.test.mjs create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 worker/index.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..967037f --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +/dist/ +/.wrangler/ +/outputs/ +/work/ diff --git a/.openai/hosting.json b/.openai/hosting.json new file mode 100644 index 0000000..8c533b3 --- /dev/null +++ b/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a7c45237a0c8191a30fd32a0e7b6b0c", + "d1": null, + "r2": null +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd9b9e0 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# vinext-starter + +A clean full-stack starter running on +[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and +Drizzle support. + +## Prerequisites + +- Node.js `>=22.13.0` + +## Quick Start + +```bash +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: + +```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; + // ... +} +``` + +## 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. + +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. + +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 + +## Learn More + +- [vinext Documentation](https://github.com/cloudflare/vinext) +- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts new file mode 100644 index 0000000..a0ae2ed --- /dev/null +++ b/app/chatgpt-auth.ts @@ -0,0 +1,90 @@ +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/app/globals.css b/app/globals.css new file mode 100644 index 0000000..3f6c2e0 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,377 @@ +@import "tailwindcss"; + +:root { + --ink: #17172b; + --muted: #717285; + --subtle: #9899aa; + --line: #e7e6ed; + --line-soft: #efedf3; + --surface: #ffffff; + --canvas: #f7f7fa; + --sidebar: #f1f0f5; + --violet: #6558e8; + --violet-dark: #5144d1; + --violet-soft: #eeecff; + --mint: #1b9d82; + --shadow: 0 18px 54px rgba(27, 24, 57, 0.11); +} + +* { box-sizing: border-box; } + +html { background: var(--canvas); } + +body { + margin: 0; + background: var(--canvas); + color: var(--ink); + font-family: Pretendard, "Apple SD Gothic Neo", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; +} + +button, input, select, textarea { font: inherit; } +button { color: inherit; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(101, 88, 232, .25); outline-offset: 2px; } + +.app-shell { min-height: 100vh; } + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 30; + width: 252px; + padding: 28px 18px 20px; + display: flex; + flex-direction: column; + background: var(--sidebar); + border-right: 1px solid #e1e0e8; +} + +.brand { display: flex; align-items: center; gap: 11px; padding: 0 10px; font-size: 19px; letter-spacing: -.4px; } +.brand-mark { width: 27px; height: 27px; display: grid; grid-template-columns: repeat(3, 1fr); align-items: end; gap: 3px; transform: rotate(-8deg); } +.brand-mark span { display: block; border-radius: 3px; background: var(--violet); } +.brand-mark span:nth-child(1) { height: 13px; } +.brand-mark span:nth-child(2) { height: 24px; } +.brand-mark span:nth-child(3) { height: 18px; } + +.primary-button, .secondary-button { + min-height: 42px; + border: 0; + border-radius: 10px; + padding: 0 17px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-weight: 700; + cursor: pointer; + transition: transform .15s ease, box-shadow .15s ease, background .15s ease; +} +.primary-button { color: white; background: var(--violet); box-shadow: 0 7px 16px rgba(84, 69, 213, .22); } +.primary-button:hover { background: var(--violet-dark); transform: translateY(-1px); } +.primary-button:disabled { opacity: .45; cursor: not-allowed; transform: none; } +.secondary-button { background: white; border: 1px solid var(--line); color: #4f5061; } +.secondary-button:hover { background: #faf9fd; } +.sidebar-upload { width: 100%; margin: 30px 0 29px; } +.sidebar-upload span { font-size: 20px; font-weight: 400; } + +.side-nav { display: flex; flex-direction: column; gap: 5px; } +.nav-label { margin: 0 11px 7px; color: #9b9bab; font-size: 10px; font-weight: 800; letter-spacing: 1.25px; } +.nav-label-admin { margin-top: 25px; } +.side-nav button { + height: 43px; + padding: 0 11px; + border: 0; + border-radius: 9px; + display: flex; + align-items: center; + gap: 12px; + color: #626373; + background: transparent; + font-size: 14px; + font-weight: 650; + text-align: left; + cursor: pointer; +} +.side-nav button:hover { background: rgba(255,255,255,.56); color: var(--ink); } +.side-nav button.active { color: var(--violet-dark); background: white; box-shadow: 0 2px 9px rgba(32, 29, 57, .06); } +.nav-symbol { width: 20px; text-align: center; font-size: 21px; line-height: 1; font-weight: 500; } +.side-nav small { margin-left: auto; padding: 2px 7px; color: #8a8b9b; background: #e6e5eb; border-radius: 20px; font-size: 10px; } +.alert-dot { width: 7px; height: 7px; margin-left: auto; background: #ef6b6b; border-radius: 50%; box-shadow: 0 0 0 3px #fde2e2; } + +.workspace-card { margin-top: auto; padding: 12px 10px; display: flex; align-items: center; gap: 9px; border-top: 1px solid #dedde5; } +.workspace-icon { flex: 0 0 auto; width: 32px; height: 32px; display: grid; place-items: center; color: white; background: #24233d; border-radius: 9px; font-weight: 800; } +.workspace-card div:nth-child(2) { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.workspace-card strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } +.workspace-card span { color: #9293a2; font-size: 9px; } +.workspace-card button { margin-left: auto; border: 0; background: transparent; color: #8c8d9d; cursor: pointer; } + +.main-area { min-height: 100vh; margin-left: 252px; } +.topbar { height: 76px; padding: 0 35px; display: flex; align-items: center; justify-content: space-between; background: rgba(255,255,255,.92); border-bottom: 1px solid var(--line); backdrop-filter: blur(16px); } +.topbar-left { display: flex; align-items: center; gap: 10px; font-size: 13px; } +.topbar-left strong { font-weight: 700; } +.breadcrumb { color: #a0a0ad; } +.breadcrumb-divider { color: #cac9d1; } +.topbar-actions { display: flex; align-items: center; gap: 20px; } +.icon-button { position: relative; width: 34px; height: 34px; border: 0; background: transparent; color: #636474; font-size: 22px; cursor: pointer; } +.notification-dot { position: absolute; top: 5px; right: 5px; width: 6px; height: 6px; background: #ef6464; border: 2px solid white; box-sizing: content-box; border-radius: 50%; } +.profile { display: flex; align-items: center; gap: 9px; } +.avatar { width: 35px; height: 35px; display: grid; place-items: center; color: white; background: linear-gradient(135deg, #5e50dc, #9187f1); border-radius: 11px; font-size: 12px; font-weight: 800; } +.profile > div:nth-child(2) { display: flex; flex-direction: column; gap: 2px; } +.profile strong { font-size: 11px; } +.profile span { color: #9b9baa; font-size: 9px; } +.profile .chevron { margin-left: 4px; font-size: 16px; } +.mobile-menu { display: none; border: 0; background: transparent; font-size: 20px; } + +.content { min-height: calc(100vh - 76px); } +.search-view { background: radial-gradient(circle at 50% 18%, rgba(237, 234, 255, .78), transparent 28%), #fff; } +.search-landing { max-width: 1080px; margin: 0 auto; padding: 74px 50px 56px; text-align: center; } +.eyebrow, .page-kicker { color: var(--violet); font-size: 10px; font-weight: 850; letter-spacing: 1.4px; } +.eyebrow span { display: inline-grid; width: 20px; height: 20px; margin-right: 3px; place-items: center; color: white; background: var(--violet); border-radius: 6px; font-size: 11px; } +.search-landing h1 { margin: 22px 0 15px; font-family: Georgia, "Times New Roman", serif; font-size: clamp(43px, 5vw, 62px); line-height: 1.12; letter-spacing: -2.7px; font-weight: 520; } +.search-landing h1 em { color: var(--violet); font-style: italic; } +.hero-copy { margin: 0 auto; color: #7a7b8d; font-size: 15px; line-height: 1.75; } + +.hero-search { max-width: 730px; height: 66px; margin: 37px auto 20px; padding: 0 10px 0 22px; display: flex; align-items: center; gap: 12px; background: white; border: 1px solid #dddae8; border-radius: 16px; box-shadow: 0 14px 45px rgba(42, 35, 82, .13); } +.search-icon { color: #747486; font-size: 27px; line-height: 1; transform: translateY(-2px); } +.hero-search input, .results-search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 15px; } +.hero-search input::placeholder { color: #aaa9b6; } +.shortcut { padding: 4px 8px; color: #9695a3; background: #f3f2f6; border: 1px solid #e7e5eb; border-radius: 6px; font-size: 10px; } +.hero-search button { width: 44px; height: 44px; border: 0; display: grid; place-items: center; color: white; background: var(--violet); border-radius: 11px; font-size: 22px; cursor: pointer; } +.suggestions { max-width: 730px; margin: 0 auto; display: flex; align-items: center; justify-content: center; gap: 7px; flex-wrap: wrap; } +.suggestions > span { color: #a2a1ad; font-size: 10px; } +.suggestions button { height: 29px; padding: 0 10px; border: 1px solid #e7e5ec; color: #747484; background: #fff; border-radius: 8px; font-size: 10px; cursor: pointer; } +.suggestions button:hover { border-color: #c8c4f5; color: var(--violet); } +.suggestions b { margin-left: 7px; font-weight: 500; } + +.recent-section { margin-top: 70px; text-align: left; } +.section-heading { margin-bottom: 17px; display: flex; align-items: end; justify-content: space-between; } +.section-heading h2 { margin: 0 0 5px; font-size: 16px; } +.section-heading p { margin: 0; color: #9696a5; font-size: 11px; } +.section-heading button, .panel-heading button { border: 0; color: #7d7e8d; background: transparent; font-size: 11px; font-weight: 650; cursor: pointer; } +.section-heading button span { margin-left: 5px; } +.document-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +.document-card { min-width: 0; padding: 20px; border: 1px solid #e9e7ee; border-radius: 14px; text-align: left; background: white; cursor: pointer; transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; } +.document-card:hover { transform: translateY(-3px); border-color: #dcd8f5; box-shadow: 0 13px 32px rgba(28, 25, 55, .08); } +.file-badge { width: fit-content; padding: 6px 8px; display: flex; align-items: center; gap: 5px; border-radius: 7px; font-size: 8px; font-weight: 850; letter-spacing: .3px; } +.file-badge span { font-size: 12px; } +.violet { color: #6758dc; background: #eeebff; } +.green { color: #15876e; background: #e1f6ef; } +.orange { color: #c26b27; background: #fff0df; } +.blue { color: #3974bf; background: #e8f2ff; } +.document-card h3 { margin: 18px 0 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.document-card p { margin: 0; color: #9292a0; font-size: 10px; } +.card-footer { margin-top: 20px; padding-top: 14px; display: flex; justify-content: space-between; color: #a4a4b0; border-top: 1px solid #efedf2; font-size: 9px; } +.card-arrow { font-size: 13px; } + +.has-results { background: #fafafd; } +.results-page { max-width: 960px; margin: 0 auto; padding: 45px 50px 80px; } +.results-search { height: 56px; padding: 0 8px 0 17px; display: flex; align-items: center; gap: 12px; background: white; border: 1px solid #dedce7; border-radius: 13px; box-shadow: 0 8px 25px rgba(29, 27, 57, .05); } +.results-search > span { font-size: 23px; color: #777888; } +.results-search button { height: 40px; padding: 0 20px; border: 0; color: white; background: var(--violet); border-radius: 9px; font-size: 12px; font-weight: 750; cursor: pointer; } +.results-meta { margin: 31px 2px 12px; display: flex; align-items: center; gap: 10px; } +.results-meta span { font-size: 16px; font-weight: 800; } +.results-meta small { color: #9999a6; font-size: 10px; } +.answer-card { padding: 28px 31px; display: flex; gap: 20px; background: linear-gradient(135deg, #fff, #fbfaff); border: 1px solid #dedaf6; border-radius: 17px; box-shadow: 0 10px 32px rgba(44, 35, 93, .06); } +.answer-icon { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; color: white; background: var(--violet); border-radius: 11px; box-shadow: 0 7px 14px rgba(101, 88, 232, .25); } +.answer-content h2 { margin: 2px 0 17px; font-size: 17px; } +.answer-content > p { margin: 0 0 11px; color: #4e4f62; font-size: 13px; line-height: 1.85; } +.answer-content p button { padding: 0; border: 0; color: var(--violet); background: transparent; font-weight: 800; cursor: pointer; } +.answer-actions { margin-top: 22px; padding-top: 17px; display: flex; align-items: center; gap: 6px; color: #9999a7; border-top: 1px solid #eceaf2; font-size: 9px; } +.answer-actions button { height: 27px; padding: 0 8px; border: 1px solid #e6e4eb; color: #7f7f8e; background: white; border-radius: 7px; font-size: 10px; cursor: pointer; } +.answer-actions button:nth-last-child(1) { margin-left: auto; } +.source-heading { margin: 28px 2px 12px; display: flex; align-items: center; justify-content: space-between; } +.source-heading h2 { margin: 0; font-size: 15px; } +.source-heading span { color: #999aa7; font-size: 10px; } +.source-list { display: grid; gap: 10px; } +.source-card { width: 100%; padding: 18px 20px; display: flex; gap: 14px; border: 1px solid #e6e4eb; border-radius: 13px; text-align: left; background: white; cursor: pointer; transition: border .15s ease, transform .15s ease; } +.source-card:hover { border-color: #c9c4f3; transform: translateX(2px); } +.source-number { flex: 0 0 auto; width: 29px; height: 29px; display: grid; place-items: center; color: var(--violet); background: var(--violet-soft); border-radius: 8px; font-size: 10px; font-weight: 800; } +.source-card > div { flex: 1; min-width: 0; } +.source-title { display: flex; align-items: center; gap: 9px; } +.source-title h3 { margin: 0; font-size: 12px; } +.source-card p { margin: 9px 0; overflow: hidden; color: #737484; font-size: 10px; line-height: 1.6; text-overflow: ellipsis; white-space: nowrap; } +.source-card > div > span { color: #9d9dab; font-size: 9px; } +.status-pill { width: fit-content; padding: 4px 8px; border-radius: 20px; color: #6f7080; background: #f0eff3; font-size: 9px; font-weight: 750; white-space: nowrap; } +.status-pill.match { color: #6558d7; background: #eeecff; } +.status-pill.success { color: #14816b; background: #e3f6ef; } +.status-pill.progress { color: #ae6a24; background: #fff0dc; } +.status-pill.danger { color: #c34f52; background: #ffe8e8; } +.thinking-card { margin-top: 30px; padding: 29px; display: flex; align-items: center; gap: 17px; background: white; border: 1px solid var(--line); border-radius: 16px; } +.thinking-orb { width: 42px; height: 42px; display: flex; align-items: center; justify-content: center; gap: 3px; background: var(--violet-soft); border-radius: 13px; } +.thinking-orb span { width: 5px; height: 5px; background: var(--violet); border-radius: 50%; animation: pulse 1s infinite alternate; } +.thinking-orb span:nth-child(2) { animation-delay: .2s; } +.thinking-orb span:nth-child(3) { animation-delay: .4s; } +.thinking-card strong { font-size: 13px; } +.thinking-card p { margin: 4px 0 0; color: #8a8b99; font-size: 10px; } +@keyframes pulse { to { transform: translateY(-5px); opacity: .4; } } + +.page-view { padding: 48px clamp(35px, 5vw, 72px) 80px; background: #fafafd; } +.page-header { max-width: 1180px; margin: 0 auto 31px; display: flex; align-items: end; justify-content: space-between; } +.page-header h1 { margin: 8px 0 5px; font-family: Georgia, "Times New Roman", serif; font-size: 34px; letter-spacing: -1.2px; font-weight: 600; } +.page-header p { margin: 0; color: #858696; font-size: 12px; } +.toolbar { max-width: 1180px; height: 55px; margin: 0 auto 14px; padding: 0 12px; display: flex; align-items: center; gap: 8px; background: white; border: 1px solid var(--line); border-radius: 12px; } +.toolbar-search { width: 290px; display: flex; align-items: center; gap: 7px; color: #8a8b98; } +.toolbar-search span { font-size: 21px; } +.toolbar-search input { width: 100%; border: 0; outline: 0; font-size: 11px; } +.toolbar > button { height: 32px; padding: 0 11px; border: 1px solid var(--line); color: #727381; background: white; border-radius: 8px; font-size: 9px; cursor: pointer; } +.toolbar > button span { margin-left: 10px; } +.toolbar-count { margin-left: auto; color: #a0a0ad; font-size: 9px; } +.table-card { max-width: 1180px; margin: 0 auto; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: 13px; } +.table-row { min-height: 68px; padding: 0 17px; display: grid; grid-template-columns: minmax(260px, 1.6fr) 1fr .7fr .75fr 32px; align-items: center; gap: 15px; color: #707180; border-top: 1px solid var(--line-soft); font-size: 10px; } +.table-row:first-child { border-top: 0; } +.table-head { min-height: 39px; color: #9696a4; background: #faf9fc; font-size: 9px; font-weight: 700; } +.doc-name { min-width: 0; display: flex; align-items: center; gap: 11px; } +.doc-name > div:last-child { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.doc-name strong { overflow: hidden; color: #363647; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; } +.doc-name small { color: #9b9ba8; font-size: 8px; } +.file-square { flex: 0 0 auto; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; font-size: 14px; } +.more-button { border: 0; color: #9a9aa7; background: transparent; cursor: pointer; } + +.collection-grid { max-width: 1180px; margin: 0 auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; } +.collection-card { padding: 23px; border: 1px solid var(--line); border-radius: 15px; text-align: left; background: white; cursor: pointer; transition: transform .18s ease, box-shadow .18s ease; } +.collection-card:hover { transform: translateY(-3px); box-shadow: 0 14px 35px rgba(34, 31, 65, .07); } +.collection-top { display: flex; align-items: center; justify-content: space-between; color: #a2a2ae; } +.folder-shape { width: 39px; height: 39px; display: grid; place-items: center; color: var(--folder-color); background: color-mix(in srgb, var(--folder-color) 12%, white); border-radius: 10px; font-size: 24px; } +.collection-card h2 { margin: 20px 0 7px; font-size: 15px; } +.collection-card > p { min-height: 33px; margin: 0; color: #888996; font-size: 10px; line-height: 1.6; } +.collection-footer { margin-top: 22px; padding-top: 15px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line-soft); } +.collection-footer strong { color: #8c8d9b; font-size: 9px; } +.member-stack { display: flex; } +.member-stack span { width: 24px; height: 24px; margin-left: -5px; display: grid; place-items: center; color: #5e5991; background: #e8e5ff; border: 2px solid white; border-radius: 50%; font-size: 8px; font-weight: 800; } +.member-stack span:first-child { margin-left: 0; } + +.operations-header { align-items: center; } +.live-indicator { padding: 8px 12px; display: flex; align-items: center; gap: 7px; color: #397466; background: #f1faf7; border: 1px solid #dcefe8; border-radius: 9px; font-size: 9px; font-weight: 700; } +.live-indicator > span { width: 7px; height: 7px; background: #32b38f; border-radius: 50%; box-shadow: 0 0 0 4px rgba(50, 179, 143, .12); } +.live-indicator small { margin-left: 6px; color: #9aaba6; font-size: 8px; font-weight: 500; } +.metric-grid { max-width: 1180px; margin: 0 auto 15px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.metric-card { min-height: 130px; padding: 19px; display: grid; grid-template-columns: 1fr auto; align-items: start; background: white; border: 1px solid var(--line); border-radius: 13px; } +.metric-card > div { display: flex; flex-direction: column; gap: 8px; } +.metric-card > div span { color: #8e8f9d; font-size: 9px; } +.metric-card > div b { font-family: Georgia, "Times New Roman", serif; font-size: 25px; } +.metric-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; font-size: 16px; } +.metric-card > small { grid-column: 1 / -1; align-self: end; color: #9a9aa7; font-size: 8px; } +.metric-card > small b { color: #45977f; } +.metric-icon.purple { color: #6658dc; background: #eeebff; } +.metric-icon.green { color: #19856f; background: #e1f5ee; } +.metric-icon.orange { color: #bd712b; background: #fff0df; } +.metric-icon.blue { color: #3975bd; background: #e8f2ff; } +.ops-grid { max-width: 1180px; margin: 0 auto 15px; display: grid; grid-template-columns: 1.3fr .7fr; gap: 15px; } +.system-card, .performance-card { padding: 20px; background: white; border: 1px solid var(--line); border-radius: 13px; } +.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; } +.panel-heading h2 { margin: 0 0 4px; font-size: 13px; } +.panel-heading p { margin: 0; color: #9899a6; font-size: 9px; } +.system-row { min-height: 47px; display: grid; grid-template-columns: 9px 1.2fr .9fr auto; align-items: center; gap: 9px; border-top: 1px solid var(--line-soft); font-size: 9px; } +.system-row:nth-child(2) { margin-top: 13px; } +.system-row strong { color: #515261; font-size: 10px; } +.system-row > span:nth-child(3) { color: #9696a4; } +.system-dot { width: 7px; height: 7px; background: #34b28e; border-radius: 50%; } +.bar-chart { height: 95px; margin: 18px 3px 5px; display: flex; align-items: end; gap: 9px; border-bottom: 1px solid var(--line); } +.bar-chart span { flex: 1; min-height: 14px; background: linear-gradient(#776be9, #d8d3ff); border-radius: 4px 4px 0 0; opacity: .9; } +.chart-days { display: flex; justify-content: space-around; color: #aaaab5; font-size: 7px; } +.jobs-card { padding: 0 19px; } +.jobs-heading { padding: 18px 0 14px; } +.jobs-heading .secondary-button { min-height: 33px; font-size: 9px; } +.job-row { min-height: 54px; display: grid; grid-template-columns: .55fr 1.6fr .7fr 1.1fr .65fr .75fr; align-items: center; gap: 11px; color: #757684; border-top: 1px solid var(--line-soft); font-size: 9px; } +.job-head { min-height: 34px; color: #9999a7; background: #faf9fc; font-size: 8px; } +.job-row strong { color: #545564; } +.job-row code { color: #8a8a98; font-size: 8px; } +.retry-button { width: fit-content; margin-left: auto; padding: 5px 8px; border: 1px solid #e0dcf6; color: var(--violet); background: #f7f5ff; border-radius: 7px; font-size: 8px; font-weight: 750; cursor: pointer; } + +.modal-layer { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 20px; background: rgba(27, 25, 47, .42); backdrop-filter: blur(5px); } +.modal { width: min(510px, 100%); max-height: calc(100vh - 40px); padding: 24px; overflow-y: auto; background: white; border-radius: 18px; box-shadow: var(--shadow); } +.compact-modal { width: min(470px, 100%); } +.modal-header { margin-bottom: 21px; display: flex; align-items: flex-start; justify-content: space-between; } +.modal-header > div { display: flex; align-items: center; gap: 12px; } +.modal-symbol { width: 37px; height: 37px; display: grid; place-items: center; color: var(--violet); background: var(--violet-soft); border-radius: 10px; font-size: 20px; font-weight: 700; } +.modal-symbol.mint { color: #178b74; background: #e3f6ef; } +.modal-header h2 { margin: 0 0 4px; font-size: 16px; } +.modal-header p { margin: 0; color: #8e8f9d; font-size: 10px; } +.modal-header > button { border: 0; color: #8c8d9a; background: transparent; font-size: 22px; cursor: pointer; } +.dropzone { height: 164px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; background: #faf9fd; border: 1.5px dashed #d7d3e5; border-radius: 13px; cursor: pointer; } +.dropzone.selected { background: #f7f6ff; border-color: #a9a1ee; } +.dropzone input { display: none; } +.dropzone strong { font-size: 11px; } +.dropzone small { color: #9a9aa7; font-size: 9px; } +.upload-illustration { width: 41px; height: 41px; display: grid; place-items: center; color: var(--violet); background: var(--violet-soft); border-radius: 11px; font-size: 19px; } +.form-field { margin-top: 16px; display: flex; flex-direction: column; gap: 7px; } +.form-field label { color: #555666; font-size: 10px; font-weight: 750; } +.form-field input, .form-field select, .form-field textarea { width: 100%; padding: 11px 12px; border: 1px solid #dfdde6; border-radius: 9px; outline: 0; color: #4f5060; background: white; font-size: 11px; resize: vertical; } +.modal-footer { margin-top: 23px; padding-top: 17px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line-soft); } + +.source-drawer { position: fixed; z-index: 65; top: 0; right: 0; bottom: 0; width: min(390px, 92vw); background: white; border-left: 1px solid var(--line); box-shadow: -16px 0 50px rgba(25, 23, 51, .12); animation: slide-in .2s ease-out; } +@keyframes slide-in { from { transform: translateX(100%); } } +.drawer-header { height: 67px; padding: 0 22px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); } +.drawer-header > span { width: 30px; height: 30px; display: grid; place-items: center; color: var(--violet); background: var(--violet-soft); border-radius: 8px; font-size: 10px; font-weight: 800; } +.drawer-header button { border: 0; color: #888997; background: transparent; font-size: 23px; cursor: pointer; } +.drawer-body { padding: 30px 24px; } +.drawer-body h2 { margin: 17px 0 6px; font-size: 18px; line-height: 1.4; } +.drawer-page { margin: 0; color: #9a9aa8; font-size: 10px; } +.quote-block { margin: 24px 0; padding: 18px; color: #595a6a; background: #f8f7fb; border-left: 3px solid var(--violet); border-radius: 0 10px 10px 0; font-size: 12px; line-height: 1.75; } +.drawer-body .primary-button { width: 100%; } +.toast { position: fixed; z-index: 100; left: 50%; bottom: 25px; padding: 12px 18px; display: flex; align-items: center; gap: 9px; transform: translateX(-50%); color: white; background: #29283c; border-radius: 10px; box-shadow: 0 12px 30px rgba(24, 22, 42, .25); font-size: 10px; font-weight: 650; animation: toast-up .2s ease-out; } +.toast span { width: 18px; height: 18px; display: grid; place-items: center; color: #1b8c73; background: #dcf7ed; border-radius: 50%; } +@keyframes toast-up { from { opacity: 0; transform: translate(-50%, 8px); } } +.nav-backdrop { display: none; } + +@media (max-width: 1000px) { + .metric-grid { grid-template-columns: repeat(2, 1fr); } + .ops-grid { grid-template-columns: 1fr; } + .table-row { grid-template-columns: minmax(230px, 1.5fr) .8fr .7fr .7fr 25px; } +} + +@media (max-width: 760px) { + .sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(24, 22, 44, .15); } + .sidebar.open { transform: translateX(0); } + .nav-backdrop { position: fixed; inset: 0; z-index: 25; display: block; border: 0; background: rgba(23, 21, 40, .35); } + .main-area { margin-left: 0; } + .topbar { height: 64px; padding: 0 18px; } + .mobile-menu { display: block; } + .breadcrumb, .breadcrumb-divider { display: none; } + .profile > div:nth-child(2), .profile .chevron, .icon-button { display: none; } + .content { min-height: calc(100vh - 64px); } + .search-landing { padding: 58px 20px 40px; } + .search-landing h1 { font-size: 43px; letter-spacing: -2px; } + .hero-search { height: 59px; margin-top: 29px; padding-left: 15px; } + .shortcut { display: none; } + .suggestions { justify-content: flex-start; } + .suggestions > span { width: 100%; text-align: left; } + .recent-section { margin-top: 50px; } + .document-grid { grid-template-columns: 1fr; } + .results-page { padding: 25px 17px 60px; } + .answer-card { padding: 20px; gap: 13px; } + .answer-icon { width: 32px; height: 32px; } + .answer-actions > span { display: none; } + .source-card p { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + .page-view { padding: 35px 17px 60px; } + .page-header { align-items: flex-start; gap: 18px; } + .page-header h1 { font-size: 30px; } + .page-header .primary-button { padding: 0 12px; font-size: 10px; white-space: nowrap; } + .toolbar { height: auto; padding: 10px; flex-wrap: wrap; } + .toolbar-search { width: 100%; } + .toolbar-count { margin-left: 0; } + .document-table { overflow-x: auto; } + .table-row { min-width: 730px; } + .collection-grid { grid-template-columns: 1fr; } + .operations-header { display: block; } + .live-indicator { width: fit-content; margin-top: 16px; } + .metric-grid { grid-template-columns: 1fr 1fr; } + .metric-card { min-height: 121px; padding: 15px; } + .jobs-card { overflow-x: auto; } + .jobs-heading { min-width: 760px; } + .job-row { min-width: 760px; } +} + +@media (max-width: 430px) { + .search-landing h1 { font-size: 36px; } + .hero-copy { font-size: 13px; } + .suggestions button { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .metric-grid { grid-template-columns: 1fr; } + .page-header { display: block; } + .page-header .primary-button { margin-top: 17px; } + .modal { padding: 20px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ea30b95 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import "./globals.css"; + +export async function generateMetadata(): Promise { + const requestHeaders = await headers(); + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:3000"; + const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https"); + const previewImage = `${protocol}://${host}/og.png`; + + return { + title: "DocGrid — 팀의 지식에서 정확한 답을", + description: "사내 문서를 검색하고 근거가 포함된 AI 답변을 확인하는 지식 검색 워크스페이스", + openGraph: { + title: "DocGrid — 팀의 지식에서 정확한 답을", + description: "흩어진 사내 문서에서 근거가 포함된 정확한 답을 찾으세요.", + images: [{ url: previewImage, width: 1792, height: 909, alt: "DocGrid 지식 검색" }], + }, + twitter: { + card: "summary_large_image", + title: "DocGrid — 팀의 지식에서 정확한 답을", + description: "흩어진 사내 문서에서 근거가 포함된 정확한 답을 찾으세요.", + images: [previewImage], + }, + }; +} + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..efda69b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,498 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; + +type View = "search" | "documents" | "collections" | "operations"; + +const navItems: { id: View; label: string; symbol: string }[] = [ + { id: "search", label: "AI 검색", symbol: "⌕" }, + { id: "documents", label: "문서", symbol: "▤" }, + { id: "collections", label: "컬렉션", symbol: "▱" }, + { id: "operations", label: "운영 현황", symbol: "⌁" }, +]; + +const suggestions = [ + "신규 입사자 온보딩 절차는?", + "법인카드 사용 기준 알려줘", + "프로덕션 장애 대응 순서", +]; + +const demoDocuments = [ + { + title: "2026 제품 로드맵", + type: "PDF", + owner: "제품전략팀", + updated: "12분 전", + status: "검색 가능", + tone: "violet", + }, + { + title: "신규 입사자 온보딩 가이드", + type: "DOCX", + owner: "People 팀", + updated: "어제", + status: "검색 가능", + tone: "green", + }, + { + title: "보안 사고 대응 매뉴얼 v3", + type: "PDF", + owner: "보안팀", + updated: "8월 8일", + status: "검색 가능", + tone: "orange", + }, + { + title: "고객 인터뷰 아카이브", + type: "MD", + owner: "UX 리서치", + updated: "8월 5일", + status: "인덱싱 중", + tone: "blue", + }, +]; + +const sources = [ + { + label: "[1]", + title: "신규 입사자 온보딩 가이드", + page: "4페이지", + score: "94%", + quote: + "입사 첫날에는 오전 10시까지 People 팀 오리엔테이션에 참석하고, 계정 및 장비 수령 여부를 확인합니다.", + }, + { + label: "[2]", + title: "IT 계정 및 장비 지급 정책", + page: "2페이지", + score: "89%", + quote: + "업무 시스템 계정은 입사일 기준으로 자동 발급되며, 추가 권한은 팀 리더 승인 후 부여됩니다.", + }, + { + label: "[3]", + title: "첫 주 체크리스트", + page: "1페이지", + score: "86%", + quote: + "첫 주 안에 버디 미팅, 팀별 업무 소개, 보안 교육을 완료하고 체크리스트를 제출합니다.", + }, +]; + +const initialJobs = [ + { + id: "#1842", + document: "2026 영업 플레이북.pdf", + status: "실패", + error: "EMBEDDING_TIMEOUT", + time: "3분 전", + }, + { + id: "#1839", + document: "정보보호 교육자료.docx", + status: "실패", + error: "PARSER_ERROR", + time: "18분 전", + }, + { + id: "#1834", + document: "시장 분석 보고서.pdf", + status: "처리 중", + error: "—", + time: "26분 전", + }, +]; + +function BrandMark() { + return ( + + ); +} + +function StatusPill({ children, kind = "neutral" }: { children: React.ReactNode; kind?: string }) { + return {children}; +} + +export default function Home() { + const [view, setView] = useState("search"); + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); + const [collectionOpen, setCollectionOpen] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(""); + const [uploading, setUploading] = useState(false); + const [toast, setToast] = useState(""); + const [selectedSource, setSelectedSource] = useState<(typeof sources)[number] | null>(null); + const [jobs, setJobs] = useState(initialJobs); + + const pageTitle = useMemo( + () => navItems.find((item) => item.id === view)?.label ?? "AI 검색", + [view], + ); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(""), 2600); + return () => window.clearTimeout(timer); + }, [toast]); + + function navigate(nextView: View) { + setView(nextView); + setMobileNavOpen(false); + } + + function submitSearch(event?: FormEvent) { + event?.preventDefault(); + if (!query.trim()) return; + + // The short delay keeps the prototype interaction close to an AI answer flow. + setSearching(true); + setSubmittedQuery(""); + window.setTimeout(() => { + setSubmittedQuery(query.trim()); + setSearching(false); + }, 720); + } + + function runSuggestion(suggestion: string) { + setQuery(suggestion); + setSearching(true); + window.setTimeout(() => { + setSubmittedQuery(suggestion); + setSearching(false); + }, 620); + } + + function finishUpload() { + if (!selectedFile) return; + setUploading(true); + window.setTimeout(() => { + setUploading(false); + setUploadOpen(false); + setSelectedFile(""); + setToast("문서가 등록되었습니다. 인덱싱을 시작합니다."); + }, 1000); + } + + function retryJob(id: string) { + setJobs((current) => + current.map((job) => + job.id === id ? { ...job, status: "대기 중", error: "—", time: "방금" } : job, + ), + ); + setToast(`${id} 작업을 다시 대기열에 추가했습니다.`); + } + + return ( +
+ + + {mobileNavOpen && + DocGrid + / + {pageTitle} +
+
+ +
+
+
+ 김민지 + Product Designer +
+ +
+
+ + + {view === "search" && ( +
+ {!submittedQuery && !searching ? ( +
+
AI KNOWLEDGE SEARCH
+

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

+

흩어진 사내 문서를 한곳에서 검색하고, 근거가 포함된 답변을 바로 확인하세요.

+ +
+ + setQuery(event.target.value)} + placeholder="무엇이든 물어보세요" + /> + ⌘ K + +
+ +
+ 추천 질문 + {suggestions.map((suggestion) => ( + + ))} +
+ +
+
+
+

최근 업데이트

+

새롭게 추가되거나 변경된 문서예요.

+
+ +
+
+ {demoDocuments.slice(0, 3).map((document) => ( + + ))} +
+
+
+ ) : ( +
+
+ + setQuery(event.target.value)} aria-label="검색어" /> + +
+ + {searching ? ( +
+
+
문서에서 답을 찾고 있어요

접근 가능한 문서의 의미를 분석하고 있습니다.

+
+ ) : ( + <> +
AI 답변3개의 문서를 참고했어요
+
+
+
+

{submittedQuery}

+

신규 입사자는 첫날 오전 10시까지 People 팀 오리엔테이션에 참석한 뒤 업무 계정과 장비를 수령합니다.

+

첫 주 안에는 버디 미팅, 팀별 업무 소개, 필수 보안 교육을 완료해야 하며, 추가 시스템 권한은 팀 리더 승인 후 부여됩니다.

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

근거 문서

유사도 높은 순
+
+ {sources.map((source) => ( + + ))} +
+ + )} +
+ )} +
+ )} + + {view === "documents" && ( +
+
+
KNOWLEDGE BASE

문서

검색에 활용되는 팀 문서를 관리하세요.

+ +
+
+
+ + + 총 24개 문서 +
+
+
문서소유 팀상태최근 수정
+ {demoDocuments.map((document) => ( +
+
{document.title}{document.type} · 2.4 MB
+ {document.owner} + {document.status} + {document.updated} + +
+ ))} +
+
+ )} + + {view === "collections" && ( +
+
+
CURATED KNOWLEDGE

컬렉션

주제와 팀별로 문서를 묶어 검색 범위를 관리하세요.

+ +
+
+ {[ + { name: "신규 입사자 허브", description: "온보딩, 복지, 보안 교육에 필요한 문서", docs: 8, color: "#6d5dfc", members: ["김", "이", "박"] }, + { name: "제품 전략", description: "로드맵과 고객 리서치, 제품 의사결정 기록", docs: 12, color: "#1ca887", members: ["최", "정"] }, + { name: "운영 매뉴얼", description: "서비스 운영과 장애 대응을 위한 가이드", docs: 6, color: "#e88a35", members: ["오", "한", "유"] }, + { name: "보안·컴플라이언스", description: "보안 정책과 필수 규정, 감사 자료", docs: 9, color: "#3977d6", members: ["문", "장"] }, + ].map((collection) => ( + + ))} +
+
+ )} + + {view === "operations" && ( +
+
+
RAGOPS

운영 현황

문서 인덱싱과 검색 시스템 상태를 실시간으로 확인하세요.

+
모든 시스템 정상 방금 업데이트
+
+
+
전체 문서24,892
↑ 8.4% 지난달 대비
+
검색 가능23,741
95.4% 인덱싱 완료
+
처리 대기132
8건 현재 처리 중
+
24시간 검색1,284
↑ 12.7% 어제 대비
+
+
+
+

시스템 상태

Worker 및 외부 서비스 연결

+ {[ + ["Indexing Worker", "5 / 6 활성", "정상"], + ["Embedding Server", "평균 228ms", "정상"], + ["Ollama RAG", "평균 1.8s", "정상"], + ].map(([name, detail, status]) =>
{name}{detail}{status}
)} +
+
+

평균 처리 시간

최근 7일 인덱싱 성능

3.2초
+
+ {[54, 68, 49, 78, 62, 88, 64].map((height, index) => )} +
+
+
+
+
+

최근 인덱싱 작업

실패한 작업은 원인을 확인하고 다시 처리할 수 있어요.

+
작업 ID문서상태오류 코드업데이트
+ {jobs.map((job) =>
{job.id}{job.document}{job.status}{job.error}{job.time}{job.status === "실패" ? : }
)} +
+
+ )} + + + {uploadOpen && ( +
setUploadOpen(false)}> +
event.stopPropagation()}> +

문서 업로드

검색할 수 있는 새 문서를 추가합니다.

+ +
+
+
+
+
+ )} + + {collectionOpen && ( +
setCollectionOpen(false)}> +
event.stopPropagation()}> +

새 컬렉션

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

+
+