Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ from fastapi import BackgroundTasks, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from crawler_platform.app.config.loader import load_project_config
|
from crawler_platform.app.config.loader import ProjectConfig, SourceConfig, load_project_config
|
||||||
from crawler_platform.app.core.crawler.discovery import discover_links
|
from crawler_platform.app.core.crawler.discovery import discover_links
|
||||||
from crawler_platform.app.core.crawler.fetchers import RobotsPolicy, make_fetcher
|
from crawler_platform.app.core.crawler.fetchers import RobotsPolicy, make_fetcher
|
||||||
from crawler_platform.app.core.crawler.pipeline import CrawlPipeline
|
from crawler_platform.app.core.crawler.pipeline import CrawlPipeline
|
||||||
@@ -17,7 +17,7 @@ from crawler_platform.app.core.database.repository import KnowledgeRepository
|
|||||||
from crawler_platform.app.core.database.session import session_scope
|
from crawler_platform.app.core.database.session import session_scope
|
||||||
from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models
|
from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models
|
||||||
from crawler_platform.app.core.extractor.factory import extractor_for_domain
|
from crawler_platform.app.core.extractor.factory import extractor_for_domain
|
||||||
from crawler_platform.app.core.ontology.definitions import ontology_for_domain
|
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, ontology_for_domain
|
||||||
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
|
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
|
||||||
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
|
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
|
||||||
from crawler_platform.app.core.ontology.registry import OntologyRegistry
|
from crawler_platform.app.core.ontology.registry import OntologyRegistry
|
||||||
@@ -65,6 +65,43 @@ class CreateProjectRequest(BaseModel):
|
|||||||
config_path: str
|
config_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class InlineSourceConfig(BaseModel):
|
||||||
|
name: str
|
||||||
|
type: str = "unknown"
|
||||||
|
trust_level: float = 0.5
|
||||||
|
base_url: str | None = None
|
||||||
|
allowed_paths: list[str] = Field(default_factory=list)
|
||||||
|
parser: str = "generic"
|
||||||
|
fetcher: str = "requests"
|
||||||
|
rate_limit_per_minute: int = 30
|
||||||
|
respect_robots_txt: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProjectInlineRequest(BaseModel):
|
||||||
|
"""Create a project from inline JSON config (no filesystem dependency)."""
|
||||||
|
|
||||||
|
project_name: str
|
||||||
|
domain: str
|
||||||
|
target_entities: list[str] = Field(default_factory=list)
|
||||||
|
fields: list[str] = Field(default_factory=list)
|
||||||
|
sources: list[InlineSourceConfig] = Field(default_factory=list)
|
||||||
|
ontology: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
recommendation: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
update_policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_project_config(self) -> ProjectConfig:
|
||||||
|
return ProjectConfig(
|
||||||
|
project_name=self.project_name,
|
||||||
|
domain=self.domain,
|
||||||
|
target_entities=list(self.target_entities),
|
||||||
|
fields=list(self.fields),
|
||||||
|
sources=[SourceConfig(**s.model_dump()) for s in self.sources],
|
||||||
|
ontology=dict(self.ontology),
|
||||||
|
recommendation=dict(self.recommendation),
|
||||||
|
update_policy=dict(self.update_policy),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResetProjectRequest(BaseModel):
|
class ResetProjectRequest(BaseModel):
|
||||||
config_path: str
|
config_path: str
|
||||||
project_name: str | None = None
|
project_name: str | None = None
|
||||||
@@ -298,6 +335,26 @@ def register_routes(app, database_url: str) -> None:
|
|||||||
project = KnowledgeRepository(session).upsert_project(config)
|
project = KnowledgeRepository(session).upsert_project(config)
|
||||||
return {"id": project.id, "name": project.name, "domain": project.domain}
|
return {"id": project.id, "name": project.name, "domain": project.domain}
|
||||||
|
|
||||||
|
@app.post("/projects/inline")
|
||||||
|
def create_project_inline(request: CreateProjectInlineRequest):
|
||||||
|
config = request.to_project_config()
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
project = KnowledgeRepository(session).upsert_project(config)
|
||||||
|
return {"id": project.id, "name": project.name, "domain": project.domain}
|
||||||
|
|
||||||
|
@app.get("/domains")
|
||||||
|
def list_domains():
|
||||||
|
"""Available pre-defined ontology domains for project creation."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"domain": ont.domain,
|
||||||
|
"entity_types": list(ont.entity_types),
|
||||||
|
"predicates": list(ont.predicates),
|
||||||
|
"attribute_count": len(ont.attributes),
|
||||||
|
}
|
||||||
|
for ont in DOMAIN_ONTOLOGIES.values()
|
||||||
|
]
|
||||||
|
|
||||||
@app.post("/projects/reset")
|
@app.post("/projects/reset")
|
||||||
def reset_project(request: ResetProjectRequest):
|
def reset_project(request: ResetProjectRequest):
|
||||||
config = load_project_config(request.config_path)
|
config = load_project_config(request.config_path)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"onboard": "Upload Ontology",
|
"onboard": "New Project",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"crawl": "Crawl",
|
"crawl": "Crawl",
|
||||||
"review": "Review",
|
"review": "Review",
|
||||||
@@ -23,6 +23,18 @@
|
|||||||
"hint": "Create your first project to start building an ontology"
|
"hint": "Create your first project to start building an ontology"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Create New Project",
|
||||||
|
"formTitle": "Choose Ontology Domain",
|
||||||
|
"formDesc": "Pick the domain of ontology you want to build and give your project a name.",
|
||||||
|
"projectName": "Project Name",
|
||||||
|
"projectNameHint": "Letters, digits, _ and - only (2~64 chars)",
|
||||||
|
"domain": "Domain",
|
||||||
|
"domainSummary": "{{entities}} entity types · {{predicates}} predicates",
|
||||||
|
"submit": "Create Project",
|
||||||
|
"created": "Project created: {{name}}",
|
||||||
|
"createFailed": "Create failed: {{msg}}"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "대시보드",
|
"dashboard": "대시보드",
|
||||||
"onboard": "온톨로지 업로드",
|
"onboard": "프로젝트 생성",
|
||||||
"sources": "참고 소스",
|
"sources": "참고 소스",
|
||||||
"crawl": "크롤 진행",
|
"crawl": "크롤 진행",
|
||||||
"review": "결과 검토",
|
"review": "결과 검토",
|
||||||
@@ -23,6 +23,18 @@
|
|||||||
"hint": "첫 프로젝트를 만들어 온톨로지 구축을 시작하세요"
|
"hint": "첫 프로젝트를 만들어 온톨로지 구축을 시작하세요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "새 프로젝트 만들기",
|
||||||
|
"formTitle": "온톨로지 도메인 선택",
|
||||||
|
"formDesc": "어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||||
|
"projectName": "프로젝트 이름",
|
||||||
|
"projectNameHint": "영문, 숫자, _ , - 만 사용 (2~64자)",
|
||||||
|
"domain": "도메인",
|
||||||
|
"domainSummary": "엔티티 {{entities}}종 · 관계 {{predicates}}개",
|
||||||
|
"submit": "프로젝트 만들기",
|
||||||
|
"created": "프로젝트가 생성되었습니다: {{name}}",
|
||||||
|
"createFailed": "생성 실패: {{msg}}"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"retry": "다시 시도",
|
"retry": "다시 시도",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type = "text", ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Label = React.forwardRef<
|
||||||
|
HTMLLabelElement,
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = "Label";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
@@ -5,4 +5,13 @@ export const queryKeys = {
|
|||||||
detail: (name: string) =>
|
detail: (name: string) =>
|
||||||
[...queryKeys.projects.all, "detail", name] as const,
|
[...queryKeys.projects.all, "detail", name] as const,
|
||||||
},
|
},
|
||||||
|
domains: {
|
||||||
|
all: ["domains"] as const,
|
||||||
|
list: () => [...queryKeys.domains.all, "list"] as const,
|
||||||
|
},
|
||||||
|
ontology: {
|
||||||
|
all: ["ontology"] as const,
|
||||||
|
byDomain: (domain: string) =>
|
||||||
|
[...queryKeys.ontology.all, "byDomain", domain] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
21
crawler_platform/app/web/frontend/src/hooks/useDomains.ts
Normal file
21
crawler_platform/app/web/frontend/src/hooks/useDomains.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { domainsApi, DomainSummary } from "@/lib/api/domains";
|
||||||
|
import { ontologyApi, OntologyDetail } from "@/lib/api/ontology";
|
||||||
|
import { queryKeys } from "./queryKeys";
|
||||||
|
|
||||||
|
export function useDomains() {
|
||||||
|
return useQuery<DomainSummary[]>({
|
||||||
|
queryKey: queryKeys.domains.list(),
|
||||||
|
queryFn: () => domainsApi.list(),
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOntology(domain: string | undefined) {
|
||||||
|
return useQuery<OntologyDetail>({
|
||||||
|
queryKey: queryKeys.ontology.byDomain(domain ?? ""),
|
||||||
|
queryFn: () => ontologyApi.get(domain!),
|
||||||
|
enabled: Boolean(domain),
|
||||||
|
staleTime: 1000 * 60 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
CreateProjectInlineRequest,
|
||||||
CreateProjectRequest,
|
CreateProjectRequest,
|
||||||
projectsApi,
|
projectsApi,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
@@ -31,3 +32,14 @@ export function useCreateProject() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateProjectInline() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: CreateProjectInlineRequest) =>
|
||||||
|
projectsApi.createInline(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
17
crawler_platform/app/web/frontend/src/lib/api/domains.ts
Normal file
17
crawler_platform/app/web/frontend/src/lib/api/domains.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export const domainSummarySchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
entity_types: z.array(z.string()),
|
||||||
|
predicates: z.array(z.string()),
|
||||||
|
attribute_count: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const domainListSchema = z.array(domainSummarySchema);
|
||||||
|
|
||||||
|
export type DomainSummary = z.infer<typeof domainSummarySchema>;
|
||||||
|
|
||||||
|
export const domainsApi = {
|
||||||
|
list: () => apiClient.get("/domains", domainListSchema),
|
||||||
|
};
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { apiClient, ApiError } from "./client";
|
export { apiClient, ApiError } from "./client";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
|
export * from "./domains";
|
||||||
|
export * from "./ontology";
|
||||||
|
|||||||
20
crawler_platform/app/web/frontend/src/lib/api/ontology.ts
Normal file
20
crawler_platform/app/web/frontend/src/lib/api/ontology.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export const ontologyDetailSchema = z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
entity_types: z.array(z.string()),
|
||||||
|
predicates: z.array(z.string()),
|
||||||
|
attributes: z.array(z.string()),
|
||||||
|
aliases: z.record(z.string(), z.string()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OntologyDetail = z.infer<typeof ontologyDetailSchema>;
|
||||||
|
|
||||||
|
export const ontologyApi = {
|
||||||
|
get: (domain: string) =>
|
||||||
|
apiClient.get(
|
||||||
|
`/ontology/${encodeURIComponent(domain)}`,
|
||||||
|
ontologyDetailSchema,
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -42,6 +42,29 @@ export interface CreateProjectRequest {
|
|||||||
config_path: string;
|
config_path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InlineSource {
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
trust_level?: number;
|
||||||
|
base_url?: string | null;
|
||||||
|
allowed_paths?: string[];
|
||||||
|
parser?: string;
|
||||||
|
fetcher?: string;
|
||||||
|
rate_limit_per_minute?: number;
|
||||||
|
respect_robots_txt?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectInlineRequest {
|
||||||
|
project_name: string;
|
||||||
|
domain: string;
|
||||||
|
target_entities?: string[];
|
||||||
|
fields?: string[];
|
||||||
|
sources?: InlineSource[];
|
||||||
|
ontology?: Record<string, unknown>;
|
||||||
|
recommendation?: Record<string, unknown>;
|
||||||
|
update_policy?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export const projectsApi = {
|
export const projectsApi = {
|
||||||
list: () => apiClient.get("/projects", projectListSchema),
|
list: () => apiClient.get("/projects", projectListSchema),
|
||||||
detail: (projectName: string) =>
|
detail: (projectName: string) =>
|
||||||
@@ -51,4 +74,6 @@ export const projectsApi = {
|
|||||||
),
|
),
|
||||||
create: (body: CreateProjectRequest) =>
|
create: (body: CreateProjectRequest) =>
|
||||||
apiClient.post("/projects", createProjectResponseSchema, body),
|
apiClient.post("/projects", createProjectResponseSchema, body),
|
||||||
|
createInline: (body: CreateProjectInlineRequest) =>
|
||||||
|
apiClient.post("/projects/inline", createProjectResponseSchema, body),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,41 +1,237 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useDomains } from "@/hooks/useDomains";
|
||||||
|
import { useCreateProjectInline } from "@/hooks/useProjects";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const projectNameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
project_name: z
|
||||||
|
.string()
|
||||||
|
.min(2, "최소 2자 이상")
|
||||||
|
.max(64, "최대 64자")
|
||||||
|
.regex(projectNameRegex, "영문/숫자/_/- 만 허용"),
|
||||||
|
domain: z.string().min(1, "도메인을 선택하세요"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
data: domains,
|
||||||
|
isLoading: domainsLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useDomains();
|
||||||
|
const createProject = useCreateProjectInline();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { project_name: "", domain: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedDomain = watch("domain");
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
const selected = domains?.find((d) => d.domain === values.domain);
|
||||||
|
try {
|
||||||
|
const created = await createProject.mutateAsync({
|
||||||
|
project_name: values.project_name,
|
||||||
|
domain: values.domain,
|
||||||
|
target_entities: selected?.entity_types ?? [],
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
t("onboarding.created", "프로젝트가 생성되었습니다: {{name}}", {
|
||||||
|
name: created.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
navigate(`/sources/${created.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
t("onboarding.createFailed", "생성 실패: {{msg}}", {
|
||||||
|
msg: (e as Error).message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
|
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||||
<div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl w-full">
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
<Button
|
||||||
{t("Upload Ontology")}
|
variant="ghost"
|
||||||
</h1>
|
size="icon"
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{t("Step 1 of 4: Upload your domain ontology (YAML, JSON, or OWL)")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="border-2 border-dashed border-blue-300 rounded-lg p-8 text-center bg-blue-50 mb-6">
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{t("Drag and drop your ontology file here, or click to browse")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
|
aria-label={t("common.back", "이전")}
|
||||||
>
|
>
|
||||||
{t("Cancel")}
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{t("onboarding.title", "새 프로젝트 만들기")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{t("onboarding.formTitle", "온톨로지 도메인 선택")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"onboarding.formDesc",
|
||||||
|
"어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="project_name">
|
||||||
|
{t("onboarding.projectName", "프로젝트 이름")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="project_name"
|
||||||
|
placeholder="my_perfume_project"
|
||||||
|
aria-invalid={Boolean(errors.project_name)}
|
||||||
|
{...register("project_name")}
|
||||||
|
/>
|
||||||
|
{errors.project_name && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.project_name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"onboarding.projectNameHint",
|
||||||
|
"영문, 숫자, _ , - 만 사용 (2~64자)",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("onboarding.domain", "도메인")}</Label>
|
||||||
|
{isError && (
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{(error as Error).message}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
{t("common.retry", "다시 시도")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{domainsLoading && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{domains && (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{domains.map((d) => {
|
||||||
|
const active = selectedDomain === d.domain;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/sources/demo-project")}
|
key={d.domain}
|
||||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setValue("domain", d.domain, {
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
active && "border-primary bg-accent/60",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t("Next")}
|
<div className="mb-1 font-semibold capitalize">
|
||||||
|
{d.domain}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("onboarding.domainSummary", {
|
||||||
|
entities: d.entity_types.length,
|
||||||
|
predicates: d.predicates.length,
|
||||||
|
defaultValue:
|
||||||
|
"엔티티 {{entities}}종 · 관계 {{predicates}}개",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
|
||||||
|
{d.entity_types.slice(0, 5).join(", ")}
|
||||||
|
{d.entity_types.length > 5 && " …"}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.domain && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{errors.domain.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
disabled={isSubmitting || createProject.isPending}
|
||||||
|
>
|
||||||
|
{t("common.cancel", "취소")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || createProject.isPending}
|
||||||
|
>
|
||||||
|
{createProject.isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("onboarding.submit", "프로젝트 만들기")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const PROXY_PREFIXES = [
|
|||||||
"/projects",
|
"/projects",
|
||||||
"/ontology",
|
"/ontology",
|
||||||
"/ontologies",
|
"/ontologies",
|
||||||
|
"/domains",
|
||||||
"/extractors",
|
"/extractors",
|
||||||
"/crawl",
|
"/crawl",
|
||||||
"/crawl-site",
|
"/crawl-site",
|
||||||
|
|||||||
Reference in New Issue
Block a user