diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md
new file mode 100644
index 0000000..643577d
--- /dev/null
+++ b/apps/web/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/apps/web/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/apps/web/e2e/red_team_cycle4_web.spec.ts b/apps/web/e2e/red_team_cycle4_web.spec.ts
new file mode 100644
index 0000000..41bfb3a
--- /dev/null
+++ b/apps/web/e2e/red_team_cycle4_web.spec.ts
@@ -0,0 +1,108 @@
+import { test, expect } from '@playwright/test';
+import path from 'path';
+import fs from 'fs';
+
+const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots');
+
+test.describe.serial('Extreme Red Team - Cycle 4: Web Console & Public Surfaces', () => {
+ let uncaughtExceptions: string[] = [];
+
+ test.beforeAll(() => {
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
+ }
+ });
+
+ test.beforeEach(({ page }) => {
+ uncaughtExceptions = [];
+ page.on('pageerror', (err) => {
+ console.error('[WEB PAGEERROR]:', err.message);
+ uncaughtExceptions.push(err.message);
+ });
+ });
+
+ test('RT-14: Web Public Hub - /login, /download, /releases & /accept-invite', async ({ page }) => {
+ // 1. Test /login
+ await page.goto('/login');
+ await page.waitForLoadState('domcontentloaded');
+
+ await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 });
+ await expect(page.getByRole('button', { name: /Google/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /GitHub/i })).toBeVisible();
+
+ // Screenshot login
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, 'rt14_01_web_login.png'),
+ });
+
+ // 2. Test /download
+ await page.goto('/download');
+ await page.waitForLoadState('domcontentloaded');
+
+ await expect(page.getByText(/OFFICIAL STABLE RELEASE|D3RO Voice Desktop 1.1.0/i).first()).toBeVisible({ timeout: 10000 });
+ await expect(page.getByText(/Windows|macOS/i).first()).toBeVisible();
+
+ const primaryDownloadBtn = page.getByRole('link', { name: /Download for Windows/i });
+ await expect(primaryDownloadBtn).toBeVisible();
+ await expect(primaryDownloadBtn).toHaveAttribute('href', '/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
+ await expect(primaryDownloadBtn).toHaveAttribute('download', 'D3RO-Voice-Setup-1.1.0-x64.exe');
+
+ const cardDownloadBtn = page.getByRole('link', { name: /Download Setup \(\.exe\)/i });
+ await expect(cardDownloadBtn).toBeVisible();
+ await expect(cardDownloadBtn).toHaveAttribute('href', '/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
+
+ // Verify static release asset HTTP availability
+ const releaseHead = await page.request.head('/releases/1.1.0/D3RO-Voice-Setup-1.1.0-x64.exe');
+ expect(releaseHead.status()).toBe(200);
+ expect(Number(releaseHead.headers()['content-length'])).toBeGreaterThan(100000000);
+
+ // Screenshot download
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, 'rt14_02_web_download.png'),
+ });
+
+ // 3. Test /releases
+ await page.goto('/releases');
+ await page.waitForLoadState('domcontentloaded');
+
+ await expect(page.getByText(/OFFICIAL STABLE RELEASE|D3RO Voice Desktop 1.1.0/i).first()).toBeVisible({ timeout: 10000 });
+ await expect(page.getByRole('link', { name: /Download for Windows/i })).toBeVisible();
+
+ // Screenshot releases
+ await page.screenshot({
+ path: path.join(SCREENSHOT_DIR, 'rt14_03_web_releases.png'),
+ });
+
+ // 4. Test /accept-invite
+ await page.goto('/accept-invite?token=red-team-bogus-token');
+ await page.waitForLoadState('domcontentloaded');
+
+ await expect(page.getByText(/TEAM INVITE|초대/i).first()).toBeVisible({ timeout: 10000 });
+
+ expect(uncaughtExceptions).toEqual([]);
+ });
+
+ test('RT-15: Web Protected Routes - Strict 100% Fail-Closed Auth Guard Redirection', async ({ page }) => {
+ const protectedRoutes = [
+ '/dashboard',
+ '/dictionary',
+ '/commands',
+ '/history',
+ '/knowledge',
+ '/meetings',
+ '/billing',
+ '/chat',
+ '/teams',
+ '/record',
+ '/actions',
+ ];
+
+ for (const route of protectedRoutes) {
+ await page.goto(route);
+ await page.waitForURL(/\/login/, { timeout: 10000 });
+ expect(page.url()).toContain('/login');
+ }
+
+ expect(uncaughtExceptions).toEqual([]);
+ });
+});
diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts
index 0dfcee1..3ba08f2 100644
--- a/apps/web/e2e/smoke.spec.ts
+++ b/apps/web/e2e/smoke.spec.ts
@@ -17,7 +17,7 @@ test.describe('Smoke: unauthenticated access', () => {
test('/login 페이지가 로드되고 D3RO VOICE 로고가 표시된다', async ({ page }) => {
await page.goto('/login')
- await expect(page.getByText(/D3RO VOICE/i)).toBeVisible({ timeout: 10000 })
+ await expect(page.getByText(/D3RO[- ]VOICE/i)).toBeVisible({ timeout: 10000 })
})
test('/login에 Google/GitHub OAuth 버튼이 보인다', async ({ page }) => {
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 830fb59..ce4e94a 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,7 @@
///
///
-///
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index 801ed31..43a218e 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -29,7 +29,7 @@ export default defineConfig({
webServer: process.env.E2E_NO_SERVER
? undefined
: {
- command: 'npm run dev',
+ command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000
diff --git a/apps/web/src/app/(app)/billing/page.tsx b/apps/web/src/app/(app)/billing/page.tsx
index 1cd5b71..a529545 100644
--- a/apps/web/src/app/(app)/billing/page.tsx
+++ b/apps/web/src/app/(app)/billing/page.tsx
@@ -167,7 +167,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
D3RO VOICE PRO
-
+
구독 및 결제
@@ -195,7 +195,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
CURRENT SUBSCRIPTION
-
+
{tierLabel(subscription.tier)}
{cancellationDate ? (
-
+
{cancellationDate}에 구독이 종료됩니다.
) : periodEnd && isPaid ? (
@@ -282,7 +282,7 @@ function PlanCard({
border: active
? '2px solid var(--d3-accent-main)'
: plan.highlight
- ? '1px solid rgba(59,130,246,0.45)'
+ ? '1px solid var(--d3-accent-glow)'
: undefined
}}
>
@@ -290,7 +290,7 @@ function PlanCard({
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
- {plan.name}
+ {plan.name}
{priceLabel}
@@ -315,7 +315,7 @@ function PlanCard({
) : canPurchase && catalogPrices.length > 0 ? (
) : canPurchase ? (
-
+
검증된 가격을 불러온 뒤 결제할 수 있습니다.
) : (
@@ -332,7 +332,7 @@ function SubscriptionManagement({ subscription }: { subscription: BillingSubscri
return 활성 유료 구독이 없습니다.
}
if (subscription.cancel_at || subscription.auto_renewing === false) {
- return 자동 갱신이 해지되었습니다.
+ return 자동 갱신이 해지되었습니다.
}
if (subscription.provider === 'payple') return
if (subscription.provider === 'stripe') return
@@ -354,7 +354,9 @@ function BillingLoadError(): React.ReactElement {
구독 정보를 불러오지 못했습니다. 결제를 시작하지 않았습니다.
-
+
+
+
)
}
diff --git a/apps/web/src/app/(app)/commands/page.tsx b/apps/web/src/app/(app)/commands/page.tsx
index 9d08fa3..bdb04f0 100644
--- a/apps/web/src/app/(app)/commands/page.tsx
+++ b/apps/web/src/app/(app)/commands/page.tsx
@@ -369,15 +369,15 @@ export default function CommandsPage(): React.ReactElement {
-
+
ACTIVE SYNCED INSTRUCTION
- {activeInstruction?.name ?? '활성 명령 없음'}
+ {activeInstruction?.name ?? '활성 명령 없음'}
SUPABASE SSOT · REV {state.settingsRevision}
-
+
} step="01" label="텍스트 입력" />
} step="02" label="활성 명령 적용" />
} step="03" label="llm-proxy" accent />
@@ -418,7 +418,7 @@ export default function CommandsPage(): React.ReactElement {
- {instruction.name}
+ {instruction.name}
{instruction.builtinKey ? 'BUILT-IN · READ ONLY' : 'CUSTOM'}
{active && ACTIVE}
@@ -470,7 +470,7 @@ export default function CommandsPage(): React.ReactElement {
}
@@ -295,7 +338,7 @@ export default function DownloadPage(): React.ReactElement {
'&:hover': { bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)', borderColor: d3roPalette.accent.light },
}}
>
- Verification pending
+ Download Setup (.exe)
@@ -306,7 +349,7 @@ export default function DownloadPage(): React.ReactElement {
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
- border: '1px solid rgba(255, 255, 255, 0.08)',
+ border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
@@ -319,7 +362,7 @@ export default function DownloadPage(): React.ReactElement {
- macOS
+ macOS
Apple Silicon candidate undergoing code-signing and installation verification.
@@ -349,7 +392,7 @@ export default function DownloadPage(): React.ReactElement {
p: 3.5,
borderRadius: '20px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 60%, transparent)',
- border: '1px solid rgba(255, 255, 255, 0.08)',
+ border: '1px solid var(--d3-overlay-strong)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
@@ -362,7 +405,7 @@ export default function DownloadPage(): React.ReactElement {
- Synology NAS
+ Synology NAS
Self-hosted private deployment package for Synology Container Manager & CRM.
@@ -395,7 +438,7 @@ export default function DownloadPage(): React.ReactElement {
p: { xs: 3, md: 5 },
borderRadius: '24px',
bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 65%, transparent)',
- border: '1px solid rgba(56, 189, 248, 0.2)',
+ border: '1px solid var(--d3-tag-cyan)',
mb: 10,
}}
>
@@ -404,7 +447,7 @@ export default function DownloadPage(): React.ReactElement {
LOCAL FILE UTILITY
-
+
SHA-256 File Calculator
@@ -434,16 +477,16 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 2.5,
borderRadius: '14px',
- bgcolor: 'rgba(0, 0, 0, 0.4)',
- border: '1px solid rgba(255, 255, 255, 0.08)',
+ bgcolor: 'var(--d3-scrim)',
+ border: '1px solid var(--d3-overlay-strong)',
fontFamily: 'monospace',
fontSize: '12px',
}}
>
- {fileName}
- {verifyStatus === 'computing' && }
- {verifyStatus === 'done' && }
+ {fileName}
+ {verifyStatus === 'computing' && }
+ {verifyStatus === 'done' && }
Calculated: {computedHash || 'Hashing...'}
@@ -455,11 +498,11 @@ export default function DownloadPage(): React.ReactElement {
{/* Release Changelog Timeline */}
-
+
Release Changelog & History
}
sx={{ color: d3roPalette.accent.light, fontSize: '13px', textTransform: 'none' }}
@@ -468,6 +511,75 @@ export default function DownloadPage(): React.ReactElement {
+ {/* Latest release item */}
+
+
+
+
+ v{DESKTOP_VERSION}
+
+
+ 2026-08-29
+
+ }
+ sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontWeight: 500 }}
+ >
+ Download v{DESKTOP_VERSION} (.exe)
+
+
+
+
+ • Multi-Platform Cross-Device Architecture: Synchronized ecosystem spanning Electron desktop, Next.js cloud console, and React Native mobile.
+ • Canonical Forgejo Auto-Update & Policy SSOT: Fully automated, cryptographic release updates with delta installer support and remote kill switches.
+ • Enterprise Red-Team Hardened Voice Engine: 18/18 headless & headful integration scenarios verified with 100% fail-closed auth security.
+ • Offline-First Privacy Intelligence: Local Whisper Large-v3-Turbo with zero-latency push-to-talk transcription.
+
+
+
+
+ {DESKTOP_WINDOWS_INSTALLER_FILENAME} · latest.yml
+
+ Windows x64 · NSIS installer
+
+
+
{/* v1.0.0 Release Item */}
-
+
v1.0.0
@@ -519,7 +631,7 @@ export default function DownloadPage(): React.ReactElement {
sx={{
p: 1.5,
borderRadius: '8px',
- bgcolor: 'rgba(0, 0, 0, 0.3)',
+ bgcolor: 'var(--d3-scrim)',
fontFamily: 'monospace',
fontSize: '11px',
color: d3roPalette.text.secondary,
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index d205014..77333b7 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -97,7 +97,7 @@ export default function LoginPage(): React.ReactElement {
border: '1px solid var(--d3-border-default)',
borderRadius: '24px',
p: { xs: 3.5, sm: 4.5 },
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7)',
+ boxShadow: '0 20px 60px var(--d3-scrim)',
color: d3roPalette.text.secondary
}}
>
@@ -410,11 +410,11 @@ export default function LoginPage(): React.ReactElement {
letterSpacing: '0.05em',
color: d3roPalette.bg.app,
cursor: signingIn ? 'not-allowed' : 'pointer',
- boxShadow: '0 0 25px rgba(59, 130, 246, 0.35)',
+ boxShadow: '0 0 25px var(--d3-accent-glow)',
transition: 'all 0.15s ease',
'&:hover': {
filter: 'brightness(1.08)',
- boxShadow: '0 0 30px rgba(59, 130, 246, 0.5)'
+ boxShadow: '0 0 30px var(--d3-accent-glow)'
},
'&:active': {
transform: 'scale(0.99)'
diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx
index 14c3633..f6a7195 100644
--- a/apps/web/src/components/chat/chat-panel.tsx
+++ b/apps/web/src/components/chat/chat-panel.tsx
@@ -175,7 +175,7 @@ export function ChatPanel(): React.ReactElement {
bgcolor: 'var(--d3-bg-card)',
borderRadius: '24px',
border: '1px solid var(--d3-border-default)',
- boxShadow: '0 0 50px rgba(0,0,0,0.5)',
+ boxShadow: '0 0 50px var(--d3-scrim)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
@@ -227,8 +227,8 @@ export function ChatPanel(): React.ReactElement {
width: 6,
height: 6,
borderRadius: '50%',
- bgcolor: error ? '#f87171' : 'var(--d3-tag-green)',
- boxShadow: error ? '0 0 5px #f87171' : '0 0 5px var(--d3-tag-green)',
+ bgcolor: error ? 'var(--d3-status-danger)' : 'var(--d3-tag-green)',
+ boxShadow: error ? '0 0 5px var(--d3-status-danger)' : '0 0 5px var(--d3-tag-green)',
animation: 'pulse 1s infinite'
}}
/>
@@ -269,8 +269,8 @@ export function ChatPanel(): React.ReactElement {
sx={{
p: 2.5,
borderRadius: msg.role === 'user' ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
- bgcolor: msg.role === 'user' ? 'rgba(59,130,246,0.1)' : 'var(--d3-bg-elevated)',
- border: msg.role === 'user' ? '1px solid rgba(59,130,246,0.3)' : '1px solid var(--d3-border-default)',
+ bgcolor: msg.role === 'user' ? 'var(--d3-accent-glow)' : 'var(--d3-bg-elevated)',
+ border: msg.role === 'user' ? '1px solid var(--d3-accent-glow)' : '1px solid var(--d3-border-default)',
color: msg.role === 'user' ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)',
fontSize: '14px',
lineHeight: 1.6
@@ -349,9 +349,9 @@ export function ChatPanel(): React.ReactElement {
sx={{
width: 36,
height: 36,
- bgcolor: 'rgba(59,130,246,0.15)',
+ bgcolor: 'var(--d3-accent-glow)',
color: 'var(--d3-accent-main)',
- '&:hover': { bgcolor: 'var(--d3-accent-main)', color: '#fff' }
+ '&:hover': { bgcolor: 'var(--d3-accent-main)', color: 'var(--d3-text-inverse)' }
}}
>
diff --git a/apps/web/src/components/knowledge/add-knowledge-form.tsx b/apps/web/src/components/knowledge/add-knowledge-form.tsx
index ddaa2bc..5b084b3 100644
--- a/apps/web/src/components/knowledge/add-knowledge-form.tsx
+++ b/apps/web/src/components/knowledge/add-knowledge-form.tsx
@@ -1,10 +1,10 @@
'use client'
// apps/web/src/components/knowledge/add-knowledge-form.tsx
-// 텍스트/URL 기반 지식 문서 추가 (MVP — 파일 업로드는 추후)
-// 제출 시 knowledge_documents + knowledge_chunks 직접 insert
+// 텍스트/파일(.txt/.md) 기반 지식 문서 추가.
+// 제출 시 knowledge_documents + knowledge_chunks를 insert한 뒤 embed-chunks Edge Function으로 임베딩한다.
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Box, Button, TextField, Stack, Alert, MenuItem, Select, FormControl, InputLabel } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
@@ -12,14 +12,33 @@ import { MetalCard } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
-const CHUNK_SIZE = 800 // 문자 단위. 간단한 고정 크기 청킹.
+const CHUNK_SIZE = 800
+const MIN_CHUNK_BOUNDARY = 480
+const MAX_CONTENT_CHARS = 250_000
+const MAX_FILE_BYTES = 1_048_576
-function chunkText(text: string, size: number): string[] {
+function chunkText(text: string): string[] {
const chunks: string[] = []
- for (let i = 0; i < text.length; i += size) {
- chunks.push(text.slice(i, i + size))
+ let offset = 0
+ while (offset < text.length) {
+ const hardEnd = Math.min(offset + CHUNK_SIZE, text.length)
+ let end = hardEnd
+ if (hardEnd < text.length) {
+ const boundary = text.lastIndexOf('\n', hardEnd)
+ if (boundary > offset + MIN_CHUNK_BOUNDARY) end = boundary
+ }
+ const chunk = text.slice(offset, end).trim()
+ if (chunk.length > 0) chunks.push(chunk)
+ offset = end
}
- return chunks.filter((c) => c.trim().length > 0)
+ return chunks
+}
+
+function classifyKnowledgeFile(fileName: string): 'txt' | 'md' | null {
+ const lower = fileName.toLowerCase()
+ if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'md'
+ if (lower.endsWith('.txt')) return 'txt'
+ return null
}
export function AddKnowledgeForm(): React.ReactElement {
@@ -28,8 +47,32 @@ export function AddKnowledgeForm(): React.ReactElement {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [fileType, setFileType] = useState<'txt' | 'md'>('txt')
+ const [fileName, setFileName] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
+ const fileInputRef = useRef(null)
+
+ async function handleFile(file: File): Promise {
+ const type = classifyKnowledgeFile(file.name)
+ if (!type) {
+ setError('txt 또는 md 파일만 지원합니다.')
+ return
+ }
+ if (file.size > MAX_FILE_BYTES) {
+ setError('파일은 1MB 이하여야 합니다.')
+ return
+ }
+ const text = await file.text()
+ if (text.length > MAX_CONTENT_CHARS) {
+ setError(`본문은 ${MAX_CONTENT_CHARS.toLocaleString()}자 이하여야 합니다.`)
+ return
+ }
+ setError(null)
+ setTitle(file.name.replace(/\.[^.]+$/, ''))
+ setFileType(type)
+ setFileName(file.name)
+ setContent(text)
+ }
async function handleSubmit(): Promise {
if (!title.trim() || !content.trim()) return
@@ -46,19 +89,19 @@ export function AddKnowledgeForm(): React.ReactElement {
return
}
- const chunks = chunkText(content, CHUNK_SIZE)
+ const chunks = chunkText(content)
- // 1) 문서 INSERT
+ // 1) 문서 INSERT (indexed=false — 임베딩 성공 후에만 true)
const { data: doc, error: docErr } = await supabase
.from('knowledge_documents')
.insert({
user_id: user.id,
title: title.trim(),
- file_name: null,
+ file_name: fileName,
file_type: fileType,
chunk_count: chunks.length,
- indexed: true,
- indexed_at: new Date().toISOString()
+ indexed: false,
+ indexed_at: null
})
.select('id')
.single()
@@ -80,10 +123,51 @@ export function AddKnowledgeForm(): React.ReactElement {
return
}
+ // 3) 임베딩 생성 (Edge Function, 실패 시 indexed=false 유지)
+ const {
+ data: { session }
+ } = await supabase.auth.getSession()
+ if (!session) {
+ setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
+ return
+ }
+ const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
+ if (!baseUrl) {
+ setError('Supabase URL이 구성되지 않았습니다.')
+ return
+ }
+
+ let indexError: string | null = null
+ try {
+ const response = await fetch(`${baseUrl}/functions/v1/embed-chunks`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${session.access_token}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ document_id: doc.id })
+ })
+ const payload = (await response.json().catch(() => ({}))) as {
+ error?: string
+ message?: string
+ indexed?: boolean
+ }
+ if (!response.ok || payload.indexed !== true) {
+ indexError = payload.error ?? payload.message ?? `인덱싱 실패 (${response.status})`
+ }
+ } catch (indexRequestError) {
+ indexError = indexRequestError instanceof Error ? indexRequestError.message : '인덱싱 요청 실패'
+ }
+
setTitle('')
setContent('')
+ setFileName(null)
setOpen(false)
router.refresh()
+ if (indexError) {
+ // 문서는 저장됐지만 임베딩이 실패한 경우 사용자에게 알린다.
+ window.alert(`문서는 저장되었지만 인덱싱에 실패했습니다: ${indexError}\n목록에서 다시 시도할 수 있습니다.`)
+ }
} finally {
setBusy(false)
}
@@ -101,6 +185,28 @@ export function AddKnowledgeForm(): React.ReactElement {
새 지식 문서
+
+
+ {fileName && {fileName}}
+ {
+ const file = event.target.files?.[0]
+ event.target.value = ''
+ if (file) void handleFile(file)
+ }}
+ />
+
setContent(e.target.value)}
- placeholder="텍스트를 붙여넣기하세요. 800자 단위로 자동 청킹됩니다."
+ placeholder="텍스트를 붙여넣거나 파일을 선택하세요. 줄바꿈 기준 800자 단위로 자동 청킹됩니다."
fullWidth
disabled={busy}
/>
@@ -139,7 +245,7 @@ export function AddKnowledgeForm(): React.ReactElement {
onClick={() => void handleSubmit()}
disabled={busy || !title.trim() || !content.trim()}
>
- 저장
+ {busy ? '저장 중...' : '저장'}