Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,17 +21,17 @@
|
|||||||
| Phase 0.5 | API 클라이언트 + TanStack Query + shadcn UI + AppShell + Dashboard 연결 | `37cad40` |
|
| Phase 0.5 | API 클라이언트 + TanStack Query + shadcn UI + AppShell + Dashboard 연결 | `37cad40` |
|
||||||
| Phase 1.1 | 프로젝트 생성 (OnboardingPage 폼 + 백엔드 `POST /projects/inline`, `GET /domains`) | `8681ac8` |
|
| Phase 1.1 | 프로젝트 생성 (OnboardingPage 폼 + 백엔드 `POST /projects/inline`, `GET /domains`) | `8681ac8` |
|
||||||
| Phase 1.2 | 참고 소스 관리 (ConfigureSourcesPage CRUD + 백엔드 `POST/DELETE /projects/{name}/sources`) | `461ebc0` |
|
| Phase 1.2 | 참고 소스 관리 (ConfigureSourcesPage CRUD + 백엔드 `POST/DELETE /projects/{name}/sources`) | `461ebc0` |
|
||||||
| Phase 1.3 | 시드 크롤 (CrawlPage + 폴링 + 취소, 백엔드 `POST /crawl-site/by-project`) | (이번 커밋) |
|
| Phase 1.3 | 시드 크롤 (CrawlPage + 폴링 + 취소, 백엔드 `POST /crawl-site/by-project`) | `aaaaa05` |
|
||||||
|
| Phase 1.4 | 자율 연구 (ResearchPage + 세션 이력, 백엔드 `POST /research/run/by-project`) | (이번 커밋) |
|
||||||
|
|
||||||
### 🚧 진행 중
|
### 🚧 진행 중
|
||||||
|
|
||||||
(없음 — Phase 1.4 시작 전)
|
(없음 — Phase 1.5 시작 전)
|
||||||
|
|
||||||
### ⏳ 대기
|
### ⏳ 대기
|
||||||
|
|
||||||
| Phase | 내용 | 다음 액션 |
|
| Phase | 내용 | 다음 액션 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Phase 1.4** | 자율 연구 (research/run 장시간 자율 온톨로지 확장) | `POST /research/run`의 `config_path` 의존을 by-project로 마이그레이션 (Phase 1.3과 동일 패턴) |
|
|
||||||
| Phase 1.5 | 엔티티/클레임 직접 입력 (`POST /projects/{n}/entities`, `/claims` 백엔드 추가 필요) | 독립 가능 |
|
| Phase 1.5 | 엔티티/클레임 직접 입력 (`POST /projects/{n}/entities`, `/claims` 백엔드 추가 필요) | 독립 가능 |
|
||||||
| Phase 2 | 그래프 시각화/편집 (Cytoscape 또는 react-flow 래퍼) | 데이터 일부 있어야 의미있음 |
|
| Phase 2 | 그래프 시각화/편집 (Cytoscape 또는 react-flow 래퍼) | 데이터 일부 있어야 의미있음 |
|
||||||
| Phase 3 | JSON Import/Export (대량 데이터 직접 입력) | 독립 가능 |
|
| Phase 3 | JSON Import/Export (대량 데이터 직접 입력) | 독립 가능 |
|
||||||
|
|||||||
@@ -148,6 +148,29 @@ class ResetProjectRequest(BaseModel):
|
|||||||
project_name: str | None = None
|
project_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchRunByProjectRequest(BaseModel):
|
||||||
|
"""Run autonomous research against an existing DB project (no config_path)."""
|
||||||
|
|
||||||
|
project_name: str
|
||||||
|
source_name: str
|
||||||
|
url: str | None = None
|
||||||
|
seed_entity_id: int | None = None
|
||||||
|
goal: str = "Semantic ontology exploration"
|
||||||
|
extractor_provider: str = "lm_studio"
|
||||||
|
extractor_model: str | None = None
|
||||||
|
extractor_base_url: str | None = "http://localhost:1234/v1"
|
||||||
|
check_robots_txt: bool = False
|
||||||
|
respect_robots_txt: bool | None = None
|
||||||
|
max_depth: int = 2
|
||||||
|
max_steps: int = 12
|
||||||
|
max_branch: int = 8
|
||||||
|
min_relevance: float = 0.35
|
||||||
|
same_domain_only: bool = True
|
||||||
|
analyze_page_types: list[str] = Field(
|
||||||
|
default_factory=lambda: ["ProductPage", "BrandStoryPage", "ReviewPage"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UpdateClaimConfidenceRequest(BaseModel):
|
class UpdateClaimConfidenceRequest(BaseModel):
|
||||||
confidence: float
|
confidence: float
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
@@ -796,6 +819,56 @@ def register_routes(app, database_url: str) -> None:
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
return asdict(result)
|
return asdict(result)
|
||||||
|
|
||||||
|
@app.post("/research/run/by-project")
|
||||||
|
def run_research_by_project(request: ResearchRunByProjectRequest):
|
||||||
|
"""Run research against an existing DB project (no config_path)."""
|
||||||
|
with session_scope(database_url) as session:
|
||||||
|
repo = KnowledgeRepository(session)
|
||||||
|
project = repo.get_project(request.project_name)
|
||||||
|
config_dict = project.config or {}
|
||||||
|
if not config_dict:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Project '{request.project_name}' has no stored config",
|
||||||
|
)
|
||||||
|
config = project_config_from_dict(config_dict)
|
||||||
|
try:
|
||||||
|
source_config = config.source_by_name(request.source_name)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
check_robots = request.respect_robots_txt
|
||||||
|
if check_robots is None:
|
||||||
|
check_robots = request.check_robots_txt
|
||||||
|
source_config.respect_robots_txt = check_robots
|
||||||
|
|
||||||
|
loop = GraphResearchLoop(
|
||||||
|
repo,
|
||||||
|
extractor_for_domain(
|
||||||
|
config.domain,
|
||||||
|
provider=request.extractor_provider,
|
||||||
|
model=request.extractor_model,
|
||||||
|
base_url=request.extractor_base_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = loop.run(
|
||||||
|
project_config=config,
|
||||||
|
source_name=request.source_name,
|
||||||
|
seed_url=request.url or None,
|
||||||
|
seed_entity_id=request.seed_entity_id,
|
||||||
|
goal=request.goal,
|
||||||
|
max_depth=max(request.max_depth, 0),
|
||||||
|
max_steps=max(min(request.max_steps, 50), 1),
|
||||||
|
max_branch=max(min(request.max_branch, 30), 1),
|
||||||
|
min_relevance=min(max(request.min_relevance, 0.0), 1.0),
|
||||||
|
same_domain_only=request.same_domain_only,
|
||||||
|
analyze_page_types=set(request.analyze_page_types),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return asdict(result)
|
||||||
|
|
||||||
@app.get("/projects/{project_name}/research/sessions")
|
@app.get("/projects/{project_name}/research/sessions")
|
||||||
def research_sessions(project_name: str, limit: int = 25):
|
def research_sessions(project_name: str, limit: int = 25):
|
||||||
with session_scope(database_url) as session:
|
with session_scope(database_url) as session:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"onboard": "New Project",
|
"onboard": "New Project",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"crawl": "Crawl",
|
"crawl": "Crawl",
|
||||||
|
"research": "Research",
|
||||||
"review": "Review",
|
"review": "Review",
|
||||||
"toggleSidebar": "Toggle sidebar"
|
"toggleSidebar": "Toggle sidebar"
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,40 @@
|
|||||||
"deleted": "Source deleted: {{name}}",
|
"deleted": "Source deleted: {{name}}",
|
||||||
"deleteFailed": "Delete failed: {{msg}}"
|
"deleteFailed": "Delete failed: {{msg}}"
|
||||||
},
|
},
|
||||||
|
"research": {
|
||||||
|
"title": "Autonomous Research",
|
||||||
|
"formTitle": "Research Settings",
|
||||||
|
"formDesc": "AI follows links from a seed to autonomously expand the ontology",
|
||||||
|
"source": "Source",
|
||||||
|
"pickSource": "Pick a source...",
|
||||||
|
"goal": "Goal",
|
||||||
|
"goalPlaceholder": "e.g. Collect note compositions and seasonal recommendations of popular perfume brands",
|
||||||
|
"seedUrl": "Seed URL",
|
||||||
|
"optional": "optional",
|
||||||
|
"maxSteps": "Max Steps",
|
||||||
|
"maxBranch": "Branch Width",
|
||||||
|
"maxDepth": "Max Depth",
|
||||||
|
"minRelevance": "Min Relevance",
|
||||||
|
"sameDomainOnly": "Same domain only",
|
||||||
|
"start": "Start Research",
|
||||||
|
"runningHint": "This may take a while. Don't close the page until it finishes.",
|
||||||
|
"runningTitle": "AI is researching...",
|
||||||
|
"completed": "Research completed",
|
||||||
|
"failed": "Failed: {{msg}}",
|
||||||
|
"doneHint": "Done",
|
||||||
|
"idleHint": "Start research on the left to see results here",
|
||||||
|
"resultTitle": "Latest Result",
|
||||||
|
"resultDesc": "Outcome of the research run in this session",
|
||||||
|
"stepsTaken": "Steps",
|
||||||
|
"pagesVisited": "Pages",
|
||||||
|
"entitiesFound": "Entities",
|
||||||
|
"claimsAdded": "Claims",
|
||||||
|
"rawResult": "Raw JSON",
|
||||||
|
"historyTitle": "Session History",
|
||||||
|
"historyDesc": "Past research sessions for this project",
|
||||||
|
"historyEmpty": "No sessions yet",
|
||||||
|
"pages": "pages"
|
||||||
|
},
|
||||||
"crawl": {
|
"crawl": {
|
||||||
"title": "Seed Crawl",
|
"title": "Seed Crawl",
|
||||||
"formTitle": "Crawl Settings",
|
"formTitle": "Crawl Settings",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"onboard": "프로젝트 생성",
|
"onboard": "프로젝트 생성",
|
||||||
"sources": "참고 소스",
|
"sources": "참고 소스",
|
||||||
"crawl": "크롤 진행",
|
"crawl": "크롤 진행",
|
||||||
|
"research": "자율 연구",
|
||||||
"review": "결과 검토",
|
"review": "결과 검토",
|
||||||
"toggleSidebar": "사이드바 토글"
|
"toggleSidebar": "사이드바 토글"
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,40 @@
|
|||||||
"deleted": "소스가 삭제되었습니다: {{name}}",
|
"deleted": "소스가 삭제되었습니다: {{name}}",
|
||||||
"deleteFailed": "삭제 실패: {{msg}}"
|
"deleteFailed": "삭제 실패: {{msg}}"
|
||||||
},
|
},
|
||||||
|
"research": {
|
||||||
|
"title": "자율 연구",
|
||||||
|
"formTitle": "자율 연구 설정",
|
||||||
|
"formDesc": "AI가 시드에서 시작해 스스로 링크를 따라가며 온톨로지를 확장합니다",
|
||||||
|
"source": "참고 소스",
|
||||||
|
"pickSource": "소스를 선택하세요...",
|
||||||
|
"goal": "목표",
|
||||||
|
"goalPlaceholder": "예: 인기 브랜드 향수의 노트 구성과 시즌 추천 정보 수집",
|
||||||
|
"seedUrl": "시드 URL",
|
||||||
|
"optional": "선택",
|
||||||
|
"maxSteps": "최대 단계",
|
||||||
|
"maxBranch": "분기 폭",
|
||||||
|
"maxDepth": "최대 깊이",
|
||||||
|
"minRelevance": "최소 관련도",
|
||||||
|
"sameDomainOnly": "동일 도메인만 탐색",
|
||||||
|
"start": "자율 연구 시작",
|
||||||
|
"runningHint": "장시간 걸릴 수 있습니다. 완료될 때까지 페이지를 닫지 마세요.",
|
||||||
|
"runningTitle": "AI가 연구 중입니다...",
|
||||||
|
"completed": "자율 연구가 완료되었습니다",
|
||||||
|
"failed": "실패: {{msg}}",
|
||||||
|
"doneHint": "완료",
|
||||||
|
"idleHint": "왼쪽에서 자율 연구를 시작하면 결과가 여기에 표시됩니다",
|
||||||
|
"resultTitle": "최근 결과",
|
||||||
|
"resultDesc": "이번 세션에서 실행된 연구의 결과",
|
||||||
|
"stepsTaken": "단계",
|
||||||
|
"pagesVisited": "페이지",
|
||||||
|
"entitiesFound": "엔티티",
|
||||||
|
"claimsAdded": "클레임",
|
||||||
|
"rawResult": "원시 응답 JSON",
|
||||||
|
"historyTitle": "세션 이력",
|
||||||
|
"historyDesc": "이 프로젝트의 자율 연구 세션 기록",
|
||||||
|
"historyEmpty": "아직 실행된 세션이 없습니다",
|
||||||
|
"pages": "페이지"
|
||||||
|
},
|
||||||
"crawl": {
|
"crawl": {
|
||||||
"title": "시드 크롤",
|
"title": "시드 크롤",
|
||||||
"formTitle": "크롤 설정",
|
"formTitle": "크롤 설정",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AppShell from "@/components/layout/AppShell";
|
|||||||
import OnboardingPage from "@/pages/OnboardingPage";
|
import OnboardingPage from "@/pages/OnboardingPage";
|
||||||
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
|
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
|
||||||
import CrawlPage from "@/pages/CrawlPage";
|
import CrawlPage from "@/pages/CrawlPage";
|
||||||
|
import ResearchPage from "@/pages/ResearchPage";
|
||||||
import ReviewPage from "@/pages/ReviewPage";
|
import ReviewPage from "@/pages/ReviewPage";
|
||||||
import DashboardPage from "@/pages/DashboardPage";
|
import DashboardPage from "@/pages/DashboardPage";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ function App() {
|
|||||||
<Route path="/onboard" element={<OnboardingPage />} />
|
<Route path="/onboard" element={<OnboardingPage />} />
|
||||||
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
|
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
|
||||||
<Route path="/crawl/:projectId" element={<CrawlPage />} />
|
<Route path="/crawl/:projectId" element={<CrawlPage />} />
|
||||||
|
<Route path="/research/:projectId" element={<ResearchPage />} />
|
||||||
<Route path="/review/:projectId" element={<ReviewPage />} />
|
<Route path="/review/:projectId" element={<ReviewPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { LayoutDashboard, UploadCloud, Settings2, Activity, ListChecks, Menu } from "lucide-react";
|
import { LayoutDashboard, UploadCloud, Settings2, Activity, Brain, ListChecks, Menu } from "lucide-react";
|
||||||
import { RootState } from "@/stores";
|
import { RootState } from "@/stores";
|
||||||
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -18,6 +18,7 @@ const navItems: NavItem[] = [
|
|||||||
{ to: "/onboard", labelKey: "nav.onboard", icon: UploadCloud },
|
{ to: "/onboard", labelKey: "nav.onboard", icon: UploadCloud },
|
||||||
{ to: "/sources/demo-project", labelKey: "nav.sources", icon: Settings2 },
|
{ to: "/sources/demo-project", labelKey: "nav.sources", icon: Settings2 },
|
||||||
{ to: "/crawl/demo-project", labelKey: "nav.crawl", icon: Activity },
|
{ to: "/crawl/demo-project", labelKey: "nav.crawl", icon: Activity },
|
||||||
|
{ to: "/research/demo-project", labelKey: "nav.research", icon: Brain },
|
||||||
{ to: "/review/demo-project", labelKey: "nav.review", icon: ListChecks },
|
{ to: "/review/demo-project", labelKey: "nav.review", icon: ListChecks },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,11 @@ export const queryKeys = {
|
|||||||
job: (jobId: string) =>
|
job: (jobId: string) =>
|
||||||
[...queryKeys.crawl.all, "job", jobId] as const,
|
[...queryKeys.crawl.all, "job", jobId] as const,
|
||||||
},
|
},
|
||||||
|
research: {
|
||||||
|
all: ["research"] as const,
|
||||||
|
sessions: (projectName: string) =>
|
||||||
|
[...queryKeys.research.all, "sessions", projectName] as const,
|
||||||
|
session: (jobId: string) =>
|
||||||
|
[...queryKeys.research.all, "session", jobId] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
37
crawler_platform/app/web/frontend/src/hooks/useResearch.ts
Normal file
37
crawler_platform/app/web/frontend/src/hooks/useResearch.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
researchApi,
|
||||||
|
ResearchRunResult,
|
||||||
|
ResearchSessionDetail,
|
||||||
|
ResearchSessionItem,
|
||||||
|
StartResearchRequest,
|
||||||
|
} from "@/lib/api/research";
|
||||||
|
import { queryKeys } from "./queryKeys";
|
||||||
|
|
||||||
|
export function useStartResearch(projectName: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<ResearchRunResult, Error, StartResearchRequest>({
|
||||||
|
mutationFn: (body) => researchApi.startByProject(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.research.sessions(projectName),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResearchSessions(projectName: string, limit = 25) {
|
||||||
|
return useQuery<ResearchSessionItem[]>({
|
||||||
|
queryKey: queryKeys.research.sessions(projectName),
|
||||||
|
queryFn: () => researchApi.listSessions(projectName, limit),
|
||||||
|
enabled: Boolean(projectName),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResearchSession(jobId: string | null | undefined) {
|
||||||
|
return useQuery<ResearchSessionDetail>({
|
||||||
|
queryKey: queryKeys.research.session(jobId ?? ""),
|
||||||
|
queryFn: () => researchApi.getSession(jobId!),
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export * from "./domains";
|
|||||||
export * from "./ontology";
|
export * from "./ontology";
|
||||||
export * from "./sources";
|
export * from "./sources";
|
||||||
export * from "./crawl";
|
export * from "./crawl";
|
||||||
|
export * from "./research";
|
||||||
|
|||||||
86
crawler_platform/app/web/frontend/src/lib/api/research.ts
Normal file
86
crawler_platform/app/web/frontend/src/lib/api/research.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
const idLike = z.union([z.string(), z.number()]).transform(String);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Research result and session payload are loose by design — the backend
|
||||||
|
* returns `asdict(result)` of a Python dataclass we don't want to mirror
|
||||||
|
* field-for-field. Keep core fields strict and let extras pass through.
|
||||||
|
*/
|
||||||
|
export const researchRunResultSchema = z
|
||||||
|
.object({
|
||||||
|
job_id: idLike.optional(),
|
||||||
|
project_id: idLike.optional(),
|
||||||
|
project_name: z.string().optional(),
|
||||||
|
goal: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
started_at: z.string().nullable().optional(),
|
||||||
|
finished_at: z.string().nullable().optional(),
|
||||||
|
steps_taken: z.number().optional(),
|
||||||
|
pages_visited: z.number().optional(),
|
||||||
|
entities_found: z.number().optional(),
|
||||||
|
claims_added: z.number().optional(),
|
||||||
|
error: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export type ResearchRunResult = z.infer<typeof researchRunResultSchema>;
|
||||||
|
|
||||||
|
export const researchSessionItemSchema = z
|
||||||
|
.object({
|
||||||
|
job_id: idLike,
|
||||||
|
status: z.string().optional(),
|
||||||
|
goal: z.string().nullable().optional(),
|
||||||
|
seed_url: z.string().nullable().optional(),
|
||||||
|
started_at: z.string().nullable().optional(),
|
||||||
|
finished_at: z.string().nullable().optional(),
|
||||||
|
steps_taken: z.number().nullable().optional(),
|
||||||
|
pages_visited: z.number().nullable().optional(),
|
||||||
|
entities_found: z.number().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
export const researchSessionListSchema = z.array(researchSessionItemSchema);
|
||||||
|
export type ResearchSessionItem = z.infer<typeof researchSessionItemSchema>;
|
||||||
|
|
||||||
|
export const researchSessionDetailSchema = researchSessionItemSchema.extend({
|
||||||
|
trace: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResearchSessionDetail = z.infer<typeof researchSessionDetailSchema>;
|
||||||
|
|
||||||
|
export interface StartResearchRequest {
|
||||||
|
project_name: string;
|
||||||
|
source_name: string;
|
||||||
|
url?: string;
|
||||||
|
seed_entity_id?: number | null;
|
||||||
|
goal?: string;
|
||||||
|
max_depth?: number;
|
||||||
|
max_steps?: number;
|
||||||
|
max_branch?: number;
|
||||||
|
min_relevance?: number;
|
||||||
|
same_domain_only?: boolean;
|
||||||
|
analyze_page_types?: string[];
|
||||||
|
extractor_provider?: string;
|
||||||
|
extractor_model?: string | null;
|
||||||
|
extractor_base_url?: string | null;
|
||||||
|
check_robots_txt?: boolean;
|
||||||
|
respect_robots_txt?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const researchApi = {
|
||||||
|
startByProject: (body: StartResearchRequest) =>
|
||||||
|
apiClient.post("/research/run/by-project", researchRunResultSchema, body),
|
||||||
|
listSessions: (projectName: string, limit = 25) =>
|
||||||
|
apiClient.get(
|
||||||
|
`/projects/${encodeURIComponent(projectName)}/research/sessions`,
|
||||||
|
researchSessionListSchema,
|
||||||
|
{ query: { limit } },
|
||||||
|
),
|
||||||
|
getSession: (jobId: string) =>
|
||||||
|
apiClient.get(
|
||||||
|
`/research/sessions/${encodeURIComponent(jobId)}`,
|
||||||
|
researchSessionDetailSchema,
|
||||||
|
),
|
||||||
|
};
|
||||||
526
crawler_platform/app/web/frontend/src/pages/ResearchPage.tsx
Normal file
526
crawler_platform/app/web/frontend/src/pages/ResearchPage.tsx
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
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,
|
||||||
|
Brain,
|
||||||
|
CheckCircle2,
|
||||||
|
History,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Target,
|
||||||
|
} 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 { Select } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useProject } from "@/hooks/useProjects";
|
||||||
|
import {
|
||||||
|
useResearchSessions,
|
||||||
|
useStartResearch,
|
||||||
|
} from "@/hooks/useResearch";
|
||||||
|
import { ResearchRunResult } from "@/lib/api/research";
|
||||||
|
|
||||||
|
const startResearchSchema = z.object({
|
||||||
|
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.url("올바른 URL 형식이 아닙니다")
|
||||||
|
.or(z.literal(""))
|
||||||
|
.optional(),
|
||||||
|
goal: z
|
||||||
|
.string()
|
||||||
|
.min(3, "최소 3자")
|
||||||
|
.max(500, "최대 500자"),
|
||||||
|
max_depth: z.number().int().min(0).max(10),
|
||||||
|
max_steps: z.number().int().min(1).max(50),
|
||||||
|
max_branch: z.number().int().min(1).max(30),
|
||||||
|
min_relevance: z.number().min(0).max(1),
|
||||||
|
same_domain_only: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type StartResearchFormValues = z.infer<typeof startResearchSchema>;
|
||||||
|
|
||||||
|
function sessionStatusVariant(status?: string | null): BadgeProps["variant"] {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "success";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
case "canceled":
|
||||||
|
return "secondary";
|
||||||
|
case "running":
|
||||||
|
case "pending":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResearchPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const projectName = projectId ?? "";
|
||||||
|
|
||||||
|
const { data: project, isLoading: projectLoading, isError, error, refetch } =
|
||||||
|
useProject(projectName);
|
||||||
|
const sessions = useResearchSessions(projectName);
|
||||||
|
const startResearch = useStartResearch(projectName);
|
||||||
|
|
||||||
|
const [lastResult, setLastResult] = useState<ResearchRunResult | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<StartResearchFormValues>({
|
||||||
|
resolver: zodResolver(startResearchSchema),
|
||||||
|
defaultValues: {
|
||||||
|
source_name: "",
|
||||||
|
url: "",
|
||||||
|
goal: "Semantic ontology exploration",
|
||||||
|
max_depth: 2,
|
||||||
|
max_steps: 12,
|
||||||
|
max_branch: 8,
|
||||||
|
min_relevance: 0.35,
|
||||||
|
same_domain_only: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: StartResearchFormValues) => {
|
||||||
|
try {
|
||||||
|
setLastResult(null);
|
||||||
|
const res = await startResearch.mutateAsync({
|
||||||
|
project_name: projectName,
|
||||||
|
source_name: values.source_name,
|
||||||
|
url: values.url || undefined,
|
||||||
|
goal: values.goal,
|
||||||
|
max_depth: values.max_depth,
|
||||||
|
max_steps: values.max_steps,
|
||||||
|
max_branch: values.max_branch,
|
||||||
|
min_relevance: values.min_relevance,
|
||||||
|
same_domain_only: values.same_domain_only,
|
||||||
|
});
|
||||||
|
setLastResult(res);
|
||||||
|
toast.success(t("research.completed", "자율 연구가 완료되었습니다"));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
t("research.failed", "실패: {{msg}}", {
|
||||||
|
msg: (e as Error).message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sources = project?.sources ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(`/sources/${projectName}`)}
|
||||||
|
aria-label={t("common.back", "이전")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Brain className="h-6 w-6 text-primary" />
|
||||||
|
{t("research.title", "자율 연구")}
|
||||||
|
</h1>
|
||||||
|
{project && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.name}
|
||||||
|
<span className="capitalize text-muted-foreground/70">
|
||||||
|
{" "}
|
||||||
|
· {project.domain}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Card className="mb-6 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[460px_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-5 w-5" />
|
||||||
|
{t("research.formTitle", "자율 연구 설정")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"research.formDesc",
|
||||||
|
"AI가 시드에서 시작해 스스로 링크를 따라가며 온톨로지를 확장합니다",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="source_name">
|
||||||
|
{t("research.source", "참고 소스")}
|
||||||
|
</Label>
|
||||||
|
{projectLoading ? (
|
||||||
|
<Skeleton className="h-10" />
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
id="source_name"
|
||||||
|
{...register("source_name")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValue("source_name", e.target.value, {
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t("research.pickSource", "소스를 선택하세요...")}
|
||||||
|
</option>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<option key={s.id} value={s.name}>
|
||||||
|
{s.name} ({s.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{errors.source_name && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.source_name.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="goal">{t("research.goal", "목표")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="goal"
|
||||||
|
rows={2}
|
||||||
|
placeholder={t(
|
||||||
|
"research.goalPlaceholder",
|
||||||
|
"예: 인기 브랜드 향수의 노트 구성과 시즌 추천 정보 수집",
|
||||||
|
)}
|
||||||
|
{...register("goal")}
|
||||||
|
/>
|
||||||
|
{errors.goal && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.goal.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="url">
|
||||||
|
{t("research.seedUrl", "시드 URL")}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({t("research.optional", "선택")})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="url"
|
||||||
|
placeholder="https://example.com/start"
|
||||||
|
{...register("url")}
|
||||||
|
/>
|
||||||
|
{errors.url && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.url.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="max_steps">
|
||||||
|
{t("research.maxSteps", "최대 단계")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max_steps"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
{...register("max_steps", { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
{errors.max_steps && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.max_steps.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="max_branch">
|
||||||
|
{t("research.maxBranch", "분기 폭")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max_branch"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
{...register("max_branch", { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
{errors.max_branch && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.max_branch.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="max_depth">
|
||||||
|
{t("research.maxDepth", "최대 깊이")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max_depth"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
{...register("max_depth", { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="min_relevance">
|
||||||
|
{t("research.minRelevance", "최소 관련도")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="min_relevance"
|
||||||
|
type="number"
|
||||||
|
step="0.05"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
{...register("min_relevance", { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input"
|
||||||
|
{...register("same_domain_only")}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t("research.sameDomainOnly", "동일 도메인만 탐색")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isSubmitting || startResearch.isPending}
|
||||||
|
>
|
||||||
|
{startResearch.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("research.start", "자율 연구 시작")}
|
||||||
|
</Button>
|
||||||
|
{startResearch.isPending && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"research.runningHint",
|
||||||
|
"장시간 걸릴 수 있습니다. 완료될 때까지 페이지를 닫지 마세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("research.resultTitle", "최근 결과")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"research.resultDesc",
|
||||||
|
"이번 세션에서 실행된 연구의 결과",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!lastResult && !startResearch.isPending && (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"research.idleHint",
|
||||||
|
"왼쪽에서 자율 연구를 시작하면 결과가 여기에 표시됩니다",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{startResearch.isPending && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p>{t("research.runningTitle", "AI가 연구 중입니다...")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lastResult && !startResearch.isPending && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
{t("research.doneHint", "완료")}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label={t("research.stepsTaken", "단계")}
|
||||||
|
value={lastResult.steps_taken}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t("research.pagesVisited", "페이지")}
|
||||||
|
value={lastResult.pages_visited}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t("research.entitiesFound", "엔티티")}
|
||||||
|
value={lastResult.entities_found}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={t("research.claimsAdded", "클레임")}
|
||||||
|
value={lastResult.claims_added}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{lastResult.error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{lastResult.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<details className="rounded-md border bg-background">
|
||||||
|
<summary className="cursor-pointer px-3 py-2 text-sm font-medium">
|
||||||
|
{t("research.rawResult", "원시 응답 JSON")}
|
||||||
|
</summary>
|
||||||
|
<pre className="max-h-64 overflow-auto px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
{JSON.stringify(lastResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
{t("research.historyTitle", "세션 이력")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"research.historyDesc",
|
||||||
|
"이 프로젝트의 자율 연구 세션 기록",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sessions.isLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sessions.data && sessions.data.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("research.historyEmpty", "아직 실행된 세션이 없습니다")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sessions.data && sessions.data.length > 0 && (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{sessions.data.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.job_id}
|
||||||
|
className="flex items-center justify-between gap-3 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">#{s.job_id}</span>
|
||||||
|
{s.status && (
|
||||||
|
<Badge variant={sessionStatusVariant(s.status)}>
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.goal && (
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
{s.goal}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-muted-foreground">
|
||||||
|
{typeof s.pages_visited === "number" && (
|
||||||
|
<div>
|
||||||
|
{s.pages_visited}{" "}
|
||||||
|
{t("research.pages", "페이지")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.started_at && (
|
||||||
|
<div>{new Date(s.started_at).toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | undefined | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-background px-3 py-2">
|
||||||
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">
|
||||||
|
{typeof value === "number" ? value : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user