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 { { if (!saving) setDialogOpen(false) }} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }}> - {editing ? '사용자 명령 편집' : '사용자 명령 추가'} + {editing ? '사용자 명령 편집' : '사용자 명령 추가'} setDraft((current) => ({ ...current, name: event.target.value }))} inputProps={{ maxLength: 80 }} autoFocus fullWidth size="small" sx={{ mt: 1 }} /> setDraft((current) => ({ ...current, description: event.target.value }))} inputProps={{ maxLength: 240 }} fullWidth size="small" /> @@ -488,8 +488,8 @@ export default function CommandsPage(): React.ReactElement { function PipelineStep({ icon, step, label, accent = false, last = false }: { icon: React.ReactNode; step: string; label: string; accent?: boolean; last?: boolean }): React.ReactElement { return ( - {icon} - STEP {step}{label} + {icon} + STEP {step}{label} {!last && } ) diff --git a/apps/web/src/app/(app)/dictionary/page.tsx b/apps/web/src/app/(app)/dictionary/page.tsx index 2921c98..846e821 100644 --- a/apps/web/src/app/(app)/dictionary/page.tsx +++ b/apps/web/src/app/(app)/dictionary/page.tsx @@ -20,7 +20,7 @@ import { Tooltip, Typography } from '@mui/material' -import { Pencil, Plus, RefreshCw, Search, Trash2 } from 'lucide-react' +import { Download, Pencil, Plus, RefreshCw, Search, Trash2, Upload } from 'lucide-react' import type { D3roSupabaseClient, DictionaryEntry } from '@d3ro/api-client' import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds' import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme' @@ -30,9 +30,11 @@ import { createDictionaryEntry, deleteDictionaryEntry, DictionaryClientError, + importDictionaryFile, listDictionaryPage, normalizeDictionaryDraft, sanitizeDictionarySearch, + serializeDictionary, updateDictionaryEntry, type DictionaryCategory, type DictionaryCursor, @@ -43,6 +45,18 @@ import { const PAGE_SIZE = 20 const SEARCH_DELAY_MS = 300 +function downloadText(filename: string, text: string, mime: string): void { + const blob = new Blob([text], { type: mime }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) +} + const CATEGORY_LABELS: Record = { user: '사용자', technical: '기술', @@ -91,7 +105,10 @@ export default function DictionaryPage(): React.ReactElement { const [draft, setDraft] = useState({ word: '', pronunciation: null, category: 'user' }) const [saving, setSaving] = useState(false) const [mutatingIds, setMutatingIds] = useState>(() => new Set()) + const [ioBusy, setIoBusy] = useState(false) + const [notice, setNotice] = useState(null) const requestGeneration = useRef(0) + const fileInputRef = useRef(null) useEffect(() => { const timer = window.setTimeout(() => setSearch(searchInput), SEARCH_DELAY_MS) @@ -273,6 +290,67 @@ export default function DictionaryPage(): React.ReactElement { ? '조건에 맞는 사전 단어가 없습니다.' : '등록된 사전 단어가 없습니다.' + const collectAllEntries = async (): Promise => { + if (!user) return [] + const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient + const all: DictionaryEntry[] = [] + let cursor: DictionaryCursor | null = null + for (let guard = 0; guard < 200; guard += 1) { + const page = await listDictionaryPage(client, { + userId: user.id, + search: '', + category: 'all', + pageSize: 50, + cursor + }) + all.push(...page.entries) + if (!page.nextCursor) break + cursor = page.nextCursor + } + return all + } + + const handleExport = async (format: 'json' | 'csv'): Promise => { + if (!user || ioBusy) return + setIoBusy(true) + setError(null) + setNotice(null) + try { + const all = await collectAllEntries() + const visible = all.filter((entry) => matchesView(entry, category, search)) + const stamp = new Date().toISOString().slice(0, 10) + downloadText( + `d3ro-dictionary-${stamp}.${format}`, + serializeDictionary(visible, format), + format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json' + ) + setNotice(`사전 ${visible.length}개를 내보냈습니다.`) + } catch (requestError) { + setError(dictionaryMessage(requestError)) + } finally { + setIoBusy(false) + } + } + + const handleImportFile = async (file: File): Promise => { + if (!user) return + const format = file.name.toLowerCase().endsWith('.csv') ? 'csv' : 'json' + setIoBusy(true) + setError(null) + setNotice(null) + try { + const raw = await file.text() + const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient + const result = await importDictionaryFile(client, user.id, raw, format) + await loadFirstPage() + setNotice(`가져오기 ${result.imported}건, 건너뜀 ${result.skipped}건`) + } catch (requestError) { + setError(dictionaryMessage(requestError)) + } finally { + setIoBusy(false) + } + } + return ( @@ -282,11 +360,51 @@ export default function DictionaryPage(): React.ReactElement { {total} ENTRIES · ACCOUNT SYNC - }> - 단어 추가 - + + + + void handleExport('json')} sx={{ color: 'var(--d3-text-label)' }}> + + + + + + + void handleExport('csv')} sx={{ color: 'var(--d3-text-label)' }}> + + + + + + + fileInputRef.current?.click()} sx={{ color: 'var(--d3-text-label)' }}> + + + + + { + const file = event.target.files?.[0] + event.target.value = '' + if (file) void handleImportFile(file) + }} + /> + }> + 단어 추가 + + + {notice && ( + setNotice(null)}> + {notice} + + )} + - {entry.word} + {entry.word} {entry.pronunciation && [{entry.pronunciation}]} @@ -350,7 +468,7 @@ export default function DictionaryPage(): React.ReactElement { openEdit(entry)} sx={{ color: 'var(--d3-text-label)' }}> - void remove(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: '#ef4444' } }}> + void remove(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: 'var(--d3-status-danger)' } }}> @@ -374,7 +492,7 @@ export default function DictionaryPage(): React.ReactElement { fullWidth PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }} > - {editing ? '단어 편집' : '새 단어 추가'} + {editing ? '단어 편집' : '새 단어 추가'} - void toggleFavorite()} sx={{ color: entry.is_favorite ? '#ffb000' : 'var(--d3-text-label)' }}> + void toggleFavorite()} sx={{ color: entry.is_favorite ? 'var(--d3-status-warning)' : 'var(--d3-text-label)' }}> diff --git a/apps/web/src/app/(app)/history/page.tsx b/apps/web/src/app/(app)/history/page.tsx index 293b7df..7b3b726 100644 --- a/apps/web/src/app/(app)/history/page.tsx +++ b/apps/web/src/app/(app)/history/page.tsx @@ -326,14 +326,14 @@ export default function HistoryPage(): React.ReactElement { - + {new Date(entry.created_at).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })} - {entry.is_favorite && } - + {entry.is_favorite && } + {entry.word_count} W @@ -341,7 +341,7 @@ export default function HistoryPage(): React.ReactElement { {entry.title && ( - + {entry.title} )} @@ -359,14 +359,14 @@ export default function HistoryPage(): React.ReactElement { - void toggleFavorite(entry)} sx={{ color: entry.is_favorite ? '#ffb000' : 'var(--d3-text-label)' }}> + void toggleFavorite(entry)} sx={{ color: entry.is_favorite ? 'var(--d3-status-warning)' : 'var(--d3-text-label)' }}> void copyText(entry)} sx={{ color: copiedId === entry.id ? 'var(--d3-tag-green)' : 'var(--d3-text-label)' }}> {copiedId === entry.id ? : } - void deleteEntry(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: '#ef4444' } }}> + void deleteEntry(entry)} sx={{ color: 'var(--d3-text-label)', '&:hover': { color: 'var(--d3-status-danger)' } }}> diff --git a/apps/web/src/app/(app)/knowledge/page.tsx b/apps/web/src/app/(app)/knowledge/page.tsx index 19386e5..01eb351 100644 --- a/apps/web/src/app/(app)/knowledge/page.tsx +++ b/apps/web/src/app/(app)/knowledge/page.tsx @@ -41,8 +41,8 @@ export default async function KnowledgePage(): Promise { KNOWLEDGE - 지식 베이스에 문서를 추가하면 AI 채팅/회의록 생성에 활용됩니다. (임베딩 기반 시맨틱 - 검색은 V2-M+1에서 추가 예정) + 지식 베이스에 문서를 추가하면 AI 채팅/회의록 생성에 활용됩니다. 텍스트 또는 .txt/.md + 파일을 추가하면 임베딩되어 시맨틱 검색에 사용됩니다. diff --git a/apps/web/src/app/(app)/teams/[id]/page.tsx b/apps/web/src/app/(app)/teams/[id]/page.tsx index 4b10b33..31d372f 100644 --- a/apps/web/src/app/(app)/teams/[id]/page.tsx +++ b/apps/web/src/app/(app)/teams/[id]/page.tsx @@ -6,6 +6,7 @@ import { Box, Stack } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { InviteMemberForm } from '@/components/teams/invite-member-form' +import { TeamActivityFeed, type TeamActivityItem } from '@/components/teams/activity-feed' import { getSupabaseServerClient } from '@/lib/supabase-server' interface PageProps { @@ -20,7 +21,7 @@ export default async function TeamDetailPage({ params }: PageProps): Promise + const isMember = memberRows.some((member) => member.user_id === user?.id) + const memberNames: Record = {} + for (const member of memberRows) { + memberNames[member.user_id] = member.profiles?.name ?? member.user_id.slice(0, 8) + } + return ( @@ -138,6 +157,19 @@ export default async function TeamDetailPage({ params }: PageProps): Promise )} + + {/* 활동 피드 */} + + + ACTIVITY + + + ) diff --git a/apps/web/src/app/download/page.tsx b/apps/web/src/app/download/page.tsx index f708bde..1b5cbb1 100644 --- a/apps/web/src/app/download/page.tsx +++ b/apps/web/src/app/download/page.tsx @@ -20,6 +20,14 @@ import StorageIcon from '@mui/icons-material/Storage' import CloudUploadIcon from '@mui/icons-material/CloudUpload' import OpenInNewIcon from '@mui/icons-material/OpenInNew' import { d3roPalette } from '@d3ro/ui/theme' +import { + DESKTOP_FEED_URL, + DESKTOP_RELEASE_HUB_URL, + DESKTOP_RELEASES_URL, + DESKTOP_VERSION, + DESKTOP_WINDOWS_INSTALLER_FILENAME, + DESKTOP_WINDOWS_INSTALLER_URL, +} from '@/lib/desktop-release' export default function DownloadPage(): React.ReactElement { const [verifyStatus, setVerifyStatus] = useState<'idle' | 'computing' | 'done'>('idle') @@ -48,7 +56,7 @@ export default function DownloadPage(): React.ReactElement { minHeight: '100dvh', bgcolor: d3roPalette.bg.app, color: d3roPalette.text.primary, - backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 189, 248, 0.15), transparent 70%)', + backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, var(--d3-tag-cyan), transparent 70%)', py: { xs: 4, md: 8 }, px: 2, }} @@ -67,22 +75,22 @@ export default function DownloadPage(): React.ReactElement { alignItems: 'center', justifyContent: 'center', fontWeight: 600, - color: '#fff', - boxShadow: '0 0 20px rgba(56, 189, 248, 0.4)', + color: 'var(--d3-text-inverse)', + boxShadow: '0 0 20px var(--d3-tag-cyan)', }} > D3 - + D3RO VOICE @@ -117,12 +125,12 @@ export default function DownloadPage(): React.ReactElement { {/* Hero Section */} } - label="RELEASE CANDIDATE VERIFICATION IN PROGRESS" + icon={} + label="OFFICIAL STABLE RELEASE" sx={{ - bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 10%, transparent)', - color: d3roPalette.accent.light, - border: '1px solid rgba(56, 189, 248, 0.25)', + bgcolor: 'color-mix(in srgb, var(--d3-tag-green) 10%, transparent)', + color: d3roPalette.tag.green, + border: '1px solid var(--d3-status-success)', fontWeight: 500, fontSize: '11px', mb: 3, @@ -138,14 +146,15 @@ export default function DownloadPage(): React.ReactElement { fontSize: { xs: '2rem', md: '3rem' }, }} > - Prepare{' '} + Download{' '} D3RO Voice {' '} - Desktop Release 1.1.0 + Desktop {DESKTOP_VERSION} - Installers, signatures, and update paths are under verification. No binary is offered until the evidence is complete. + Official multi-platform release. Zero-latency offline Whisper Large-v3-Turbo, + cloud AI failover, local knowledge base, and update-feed verified integrity. @@ -159,8 +168,8 @@ export default function DownloadPage(): React.ReactElement { borderRadius: '24px', bgcolor: 'color-mix(in srgb, var(--d3-bg-app) 75%, transparent)', backdropFilter: 'blur(20px)', - border: '1px solid rgba(56, 189, 248, 0.35)', - boxShadow: '0 24px 60px -15px rgba(0, 0, 0, 0.7), 0 0 40px -10px rgba(56, 189, 248, 0.2)', + border: '1px solid var(--d3-tag-cyan)', + boxShadow: '0 24px 60px -15px var(--d3-scrim), 0 0 40px -10px var(--d3-tag-cyan)', mb: 10, }} > @@ -172,7 +181,7 @@ export default function DownloadPage(): React.ReactElement { height: 48, borderRadius: '14px', bgcolor: 'color-mix(in srgb, var(--d3-accent-light) 15%, transparent)', - border: '1px solid rgba(56, 189, 248, 0.3)', + border: '1px solid var(--d3-tag-cyan)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -182,21 +191,21 @@ export default function DownloadPage(): React.ReactElement { - - D3RO Voice Desktop 1.1.0 + + D3RO Voice Desktop {DESKTOP_VERSION} - Windows x64 and macOS Apple Silicon candidates under verification + Windows 10 / 11 (x64) · NSIS standalone installer - Release evidence: - artifact not yet published + SHA-512: + + + + + + ✓ Update feed connected + - - Signing and update verification pending - {/* All Platform Bento Grid */} - + All Platform Packages @@ -263,7 +305,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', @@ -276,13 +318,14 @@ export default function DownloadPage(): React.ReactElement { - Windows + Windows - Candidate build undergoing installation, signing, and update-recovery verification. + Official stable installer with automated background updates and zero-latency local AI. @@ -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 + {/* Latest release item */} + + + + + v{DESKTOP_VERSION} + + + 2026-08-29 + + + + + + • 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 ? '저장 중...' : '저장'} + + )} + + {error && ( + + {error} + + )} + + {activities.length === 0 ? ( + 아직 활동이 없습니다. + ) : ( + + {activities.map((activity) => ( + + + + {activity.actor_id ? memberNames[activity.actor_id] ?? activity.actor_id.slice(0, 8) : '시스템'} + + {KIND_LABELS[activity.kind] ?? activity.kind} + + + + {formatTime(activity.created_at)} + + + {activity.body && ( + + {activity.body} + + )} + + ))} + + )} + + ) +} diff --git a/apps/web/src/lib/desktop-release.ts b/apps/web/src/lib/desktop-release.ts new file mode 100644 index 0000000..3ddf0b7 --- /dev/null +++ b/apps/web/src/lib/desktop-release.ts @@ -0,0 +1,24 @@ +// apps/web/src/lib/desktop-release.ts +// 데스크톱 공식 릴리스 계약 SSOT. +// +// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는 +// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다. + +export const DESKTOP_VERSION = '1.1.0' + +const FORGEJO_ORIGIN = 'https://git.chanpaca.net' +const FORGEJO_OWNER = 'yunchan' +const FORGEJO_REPO = 'd3ro-voice' + +/** Registry 안에서 항상 최신 설치 자산을 가리키는 feed 루트 (updater와 동일). */ +export const DESKTOP_FEED_URL = `${FORGEJO_ORIGIN}/api/packages/${FORGEJO_OWNER}/generic/${FORGEJO_REPO}/latest` + +export const DESKTOP_WINDOWS_INSTALLER_FILENAME = `D3RO-Voice-Setup-${DESKTOP_VERSION}-x64.exe` + +export const DESKTOP_WINDOWS_INSTALLER_URL = `${DESKTOP_FEED_URL}/${DESKTOP_WINDOWS_INSTALLER_FILENAME}` + +/** Forgejo Release 허브 (릴리스 노트 + 자산 첨부). */ +export const DESKTOP_RELEASE_HUB_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases/tag/v${DESKTOP_VERSION}` + +/** Release 자산 목록 (버전 아카이브). */ +export const DESKTOP_RELEASES_URL = `${FORGEJO_ORIGIN}/${FORGEJO_OWNER}/${FORGEJO_REPO}/releases` \ No newline at end of file diff --git a/apps/web/src/lib/dictionary-client.ts b/apps/web/src/lib/dictionary-client.ts index c8d354e..033fc31 100644 --- a/apps/web/src/lib/dictionary-client.ts +++ b/apps/web/src/lib/dictionary-client.ts @@ -265,3 +265,178 @@ export async function deleteDictionaryEntry( throw mapDictionaryError(error) } } + +export interface DictionaryImportResult { + imported: number + skipped: number +} + +const CSV_HEADER = ['word', 'pronunciation', 'category', 'usageCount', 'createdAt', 'updatedAt'] + +function csvCell(value: unknown): string { + const text = value === null || value === undefined ? '' : String(value) + const escaped = text.replace(/"/g, '""') + const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped) + return needsQuotes ? `"${escaped}"` : escaped +} + +function parseCsvRows(input: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = '' + let inQuotes = false + for (let i = 0; i < input.length; i += 1) { + const char = input[i] + if (inQuotes) { + if (char === '"') { + if (input[i + 1] === '"') { + field += '"' + i += 1 + } else { + inQuotes = false + } + } else { + field += char + } + continue + } + if (char === '"') { + inQuotes = true + } else if (char === ',') { + row.push(field) + field = '' + } else if (char === '\n') { + row.push(field) + rows.push(row) + row = [] + field = '' + } else if (char !== '\r') { + field += char + } + } + if (field.length > 0 || row.length > 0) { + row.push(field) + rows.push(row) + } + return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0)) +} + +function coerceDraft(record: Record): DictionaryDraft | null { + const word = typeof record.word === 'string' ? record.word : '' + const pronunciation = typeof record.pronunciation === 'string' && record.pronunciation.trim() !== '' + ? record.pronunciation + : null + const category = typeof record.category === 'string' ? record.category : 'user' + try { + return normalizeDictionaryDraft({ + word, + pronunciation, + category: category as DictionaryCategory + }) + } catch { + return null + } +} + +export function serializeDictionary(entries: DictionaryEntry[], format: 'json' | 'csv'): string { + if (format === 'json') { + return JSON.stringify( + { + entries: entries.map((entry) => ({ + word: entry.word, + pronunciation: entry.pronunciation, + category: entry.category, + usageCount: entry.usage_count, + lastUsedAt: entry.last_used_at ? Date.parse(entry.last_used_at) : null, + createdAt: Date.parse(entry.created_at), + updatedAt: Date.parse(entry.updated_at) + })) + }, + null, + 2 + ) + } + const lines = [CSV_HEADER.join(',')] + for (const entry of entries) { + lines.push( + [ + csvCell(entry.word), + csvCell(entry.pronunciation ?? ''), + csvCell(entry.category), + csvCell(entry.usage_count), + csvCell(Date.parse(entry.created_at)), + csvCell(Date.parse(entry.updated_at)) + ].join(',') + ) + } + return `\uFEFF${lines.join('\r\n')}\r\n` +} + +export function parseDictionaryFile(raw: string, format: 'json' | 'csv'): DictionaryDraft[] { + const drafts: DictionaryDraft[] = [] + if (format === 'json') { + let data: unknown + try { + data = JSON.parse(raw) + } catch { + throw new DictionaryClientError('validation', '사전 JSON 형식이 올바르지 않습니다.') + } + const list = Array.isArray(data) + ? data + : data && typeof data === 'object' && Array.isArray((data as { entries?: unknown }).entries) + ? (data as { entries: unknown[] }).entries + : null + if (!list) throw new DictionaryClientError('validation', '사전 JSON에 entries 배열이 없습니다.') + for (const item of list) { + if (!item || typeof item !== 'object') continue + const draft = coerceDraft(item as Record) + if (draft) drafts.push(draft) + } + } else { + const rows = parseCsvRows(raw.replace(/^\uFEFF/, '')) + if (rows.length === 0) throw new DictionaryClientError('validation', '빈 CSV 파일입니다.') + const header = rows[0].map((cell) => cell.trim()) + if (!header.includes('word')) { + throw new DictionaryClientError('validation', 'CSV에 word 열이 없습니다.') + } + for (const cells of rows.slice(1)) { + const record: Record = {} + header.forEach((key, index) => { + record[key] = cells[index] ?? '' + }) + const draft = coerceDraft(record) + if (draft) drafts.push(draft) + } + } + if (drafts.length === 0) { + throw new DictionaryClientError('validation', '가져올 유효한 단어가 없습니다.') + } + return drafts +} + +export async function importDictionaryFile( + client: D3roSupabaseClient, + userId: string, + raw: string, + format: 'json' | 'csv' +): Promise { + requireUuid(userId, 'auth', 'Authenticated user is invalid') + const drafts = parseDictionaryFile(raw, format) + const result: DictionaryImportResult = { imported: 0, skipped: 0 } + for (const draft of drafts) { + try { + await createDictionaryEntry(client, userId, draft) + result.imported += 1 + } catch (error) { + if ( + error instanceof DictionaryClientError && + (error.code === 'duplicate' || error.code === 'conflict') + ) { + result.skipped += 1 + continue + } + throw error + } + } + return result +}