Compare commits
1 Commits
feat/phase
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfdf33b442 |
144
frontend/src/lib/components/ask/AskAnswer.svelte
Normal file
144
frontend/src/lib/components/ask/AskAnswer.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
AskAnswer.svelte — /ask 페이지 상단 패널.
|
||||
|
||||
Answer 본문 + clickable [n] citations + 신뢰도/상태 Badge.
|
||||
status != completed 또는 refused=true → warning empty state +
|
||||
no_results_reason + "검색 결과 확인하기" 역링크.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { AlertTriangle, Sparkles } from 'lucide-svelte';
|
||||
import type { AskResponse, Confidence, SynthesisStatus } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
onCitationClick: (n: number) => void;
|
||||
}
|
||||
|
||||
let { data, loading, onCitationClick }: Props = $props();
|
||||
|
||||
type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'cite'; n: number; raw: string };
|
||||
|
||||
function splitAnswer(text: string): Token[] {
|
||||
return text
|
||||
.split(/(\[\d+\])/g)
|
||||
.filter(Boolean)
|
||||
.map((tok): Token => {
|
||||
const m = tok.match(/^\[(\d+)\]$/);
|
||||
return m
|
||||
? { type: 'cite', n: Number(m[1]), raw: tok }
|
||||
: { type: 'text', value: tok };
|
||||
});
|
||||
}
|
||||
|
||||
function confidenceTone(
|
||||
c: Confidence | null,
|
||||
): 'success' | 'warning' | 'error' | 'neutral' {
|
||||
if (c === 'high') return 'success';
|
||||
if (c === 'medium') return 'warning';
|
||||
if (c === 'low') return 'error';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function confidenceLabel(c: Confidence | null): string {
|
||||
if (c === 'high') return '높음';
|
||||
if (c === 'medium') return '중간';
|
||||
if (c === 'low') return '낮음';
|
||||
return '없음';
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<SynthesisStatus, string> = {
|
||||
completed: '답변 완료',
|
||||
timeout: '답변 지연',
|
||||
skipped: '답변 생략',
|
||||
no_evidence: '근거 없음',
|
||||
parse_failed: '형식 오류',
|
||||
llm_error: 'AI 오류',
|
||||
};
|
||||
|
||||
let tokens = $derived(data?.ai_answer ? splitAnswer(data.ai_answer) : []);
|
||||
let showAnswer = $derived(
|
||||
!!data && !!data.ai_answer && data.synthesis_status === 'completed' && !data.refused,
|
||||
);
|
||||
let showWarning = $derived(!!data && !showAnswer);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface border border-default rounded-card p-5">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
||||
<Sparkles size={12} /> AI Answer
|
||||
</p>
|
||||
<h2 class="mt-1 text-base font-semibold text-text">근거 기반 답변</h2>
|
||||
</div>
|
||||
|
||||
{#if data && !loading}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge tone={confidenceTone(data.confidence)} size="sm">
|
||||
신뢰도 {confidenceLabel(data.confidence)}
|
||||
</Badge>
|
||||
<Badge tone="neutral" size="sm">
|
||||
{STATUS_LABEL[data.synthesis_status]}
|
||||
</Badge>
|
||||
{#if data.synthesis_ms > 0}
|
||||
<Badge tone="neutral" size="sm">
|
||||
{Math.round(data.synthesis_ms)}ms
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
<Skeleton w="w-3/4" h="h-4" />
|
||||
<Skeleton w="w-full" h="h-4" />
|
||||
<Skeleton w="w-5/6" h="h-4" />
|
||||
<p class="mt-4 text-xs text-dim flex items-center gap-2">
|
||||
<span class="inline-block w-3 h-3 rounded-full border-2 border-dim border-t-accent animate-spin"></span>
|
||||
근거 기반 답변 생성 중… 약 15초 소요
|
||||
</p>
|
||||
</div>
|
||||
{:else if showAnswer && data}
|
||||
<div class="text-sm leading-7 text-text">
|
||||
{#each tokens as tok}
|
||||
{#if tok.type === 'cite'}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-block align-baseline text-accent font-semibold hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring rounded px-0.5"
|
||||
onclick={() => onCitationClick(tok.n)}
|
||||
aria-label={`인용 ${tok.n}번 보기`}
|
||||
>
|
||||
{tok.raw}
|
||||
</button>
|
||||
{:else}
|
||||
<span>{tok.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showWarning && data}
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title={data.refused && data.no_results_reason
|
||||
? data.no_results_reason
|
||||
: (data.no_results_reason ?? '관련 근거를 찾지 못했습니다.')}
|
||||
description="검색 결과를 직접 확인해 보세요."
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={`/documents?q=${encodeURIComponent(data.query)}`}
|
||||
>
|
||||
검색 결과 확인하기
|
||||
</Button>
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
91
frontend/src/lib/components/ask/AskEvidence.svelte
Normal file
91
frontend/src/lib/components/ask/AskEvidence.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<!--
|
||||
AskEvidence.svelte — /ask 페이지 우측 sticky 패널.
|
||||
|
||||
⚠ 영구 룰 (Phase 3.4 plan):
|
||||
`citation.full_snippet` 은 UI 에 직접 렌더 금지. debug 모드(`?debug=1`)
|
||||
에서 hover tooltip 으로만 조건부 노출 가능.
|
||||
|
||||
이 규칙이 깨지면 backend span-precision UX 가치가 사라진다. 코드 리뷰에서
|
||||
반드시 reject. span_text 만 본문으로 노출한다.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { BookOpen } from 'lucide-svelte';
|
||||
import type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
activeCitation: number | null;
|
||||
registerCitation: (n: number, node: HTMLElement) => { destroy: () => void };
|
||||
}
|
||||
|
||||
let { data, loading, activeCitation, registerCitation }: Props = $props();
|
||||
|
||||
let citations = $derived(data?.citations ?? []);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
||||
<BookOpen size={12} /> Evidence Highlights
|
||||
</p>
|
||||
<h3 class="mt-1 text-sm font-semibold text-text">인용 근거</h3>
|
||||
</div>
|
||||
{#if data && !loading}
|
||||
<Badge tone="neutral" size="sm">{citations.length}개</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(2) as _}
|
||||
<div class="border border-default rounded-card p-4 space-y-2">
|
||||
<Skeleton w="w-24" h="h-3" />
|
||||
<Skeleton w="w-full" h="h-3" />
|
||||
<Skeleton w="w-5/6" h="h-3" />
|
||||
<Skeleton w="w-3/4" h="h-3" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if citations.length === 0}
|
||||
<EmptyState title="표시할 근거가 없습니다." class="py-6" />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each citations as citation (citation.n)}
|
||||
{@const isActive = activeCitation === citation.n}
|
||||
<article
|
||||
class="border rounded-card p-4 transition-colors {isActive
|
||||
? 'border-accent ring-2 ring-accent/20 bg-accent/5'
|
||||
: 'border-default'}"
|
||||
use:registerCitation={citation.n}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent font-bold text-sm shrink-0">[{citation.n}]</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<strong class="block text-sm text-text truncate">
|
||||
{citation.title ?? `문서 ${citation.doc_id}`}
|
||||
</strong>
|
||||
{#if citation.section_title}
|
||||
<p class="mt-0.5 text-xs text-dim truncate">{citation.section_title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⚠ span_text 만 렌더. full_snippet 금지 -->
|
||||
<p class="mt-3 text-sm leading-relaxed text-text whitespace-pre-wrap">
|
||||
{citation.span_text}
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex gap-2 text-[10px] text-dim">
|
||||
<span>relevance {citation.relevance.toFixed(2)}</span>
|
||||
<span>rerank {citation.rerank_score.toFixed(2)}</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
78
frontend/src/lib/components/ask/AskResults.svelte
Normal file
78
frontend/src/lib/components/ask/AskResults.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
AskResults.svelte — /ask 페이지 하단 패널.
|
||||
|
||||
검색 결과 리스트. DocumentCard 재사용 X — SearchResult 필드 셋이 달라서
|
||||
의존성 리스크 회피. inline 간단 카드로 title/score/snippet/section_title 표시.
|
||||
클릭 시 `/documents/{id}` 로 이동.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { FileText } from 'lucide-svelte';
|
||||
import type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
let { data, loading }: Props = $props();
|
||||
|
||||
let results = $derived(data?.results ?? []);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
||||
<FileText size={12} /> Search Results
|
||||
</p>
|
||||
<h3 class="mt-1 text-sm font-semibold text-text">검색 결과</h3>
|
||||
</div>
|
||||
{#if data && !loading}
|
||||
<Badge tone="neutral" size="sm">{data.total}개</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="border border-default rounded-card p-4 space-y-2">
|
||||
<Skeleton w="w-2/3" h="h-4" />
|
||||
<Skeleton w="w-full" h="h-3" />
|
||||
<Skeleton w="w-4/5" h="h-3" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if results.length === 0}
|
||||
<EmptyState title="검색 결과가 없습니다." class="py-6" />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each results as result (result.id)}
|
||||
<a
|
||||
href={`/documents/${result.id}`}
|
||||
class="block border border-default rounded-card p-4 hover:border-accent hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<strong class="text-sm text-text flex-1 min-w-0 truncate">
|
||||
{result.title ?? `문서 ${result.id}`}
|
||||
</strong>
|
||||
<div class="flex gap-1.5 text-[10px] text-dim shrink-0">
|
||||
<span>score {result.score.toFixed(2)}</span>
|
||||
{#if result.rerank_score != null}
|
||||
<span>rerank {result.rerank_score.toFixed(2)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if result.section_title}
|
||||
<p class="mt-1 text-xs text-dim truncate">{result.section_title}</p>
|
||||
{/if}
|
||||
{#if result.snippet}
|
||||
<p class="mt-2 text-xs text-dim line-clamp-2">{result.snippet}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
64
frontend/src/lib/types/ask.ts
Normal file
64
frontend/src/lib/types/ask.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Phase 3.4: `/api/search/ask` 응답 타입.
|
||||
*
|
||||
* Backend Pydantic 모델 (`app/api/search.py::AskResponse`) 과 1:1 매칭.
|
||||
* 필드 변경 시 양쪽 동시 수정 필수.
|
||||
*/
|
||||
|
||||
export type SynthesisStatus =
|
||||
| 'completed'
|
||||
| 'timeout'
|
||||
| 'skipped'
|
||||
| 'no_evidence'
|
||||
| 'parse_failed'
|
||||
| 'llm_error';
|
||||
|
||||
export type Confidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface Citation {
|
||||
n: number;
|
||||
chunk_id: number | null;
|
||||
doc_id: number;
|
||||
title: string | null;
|
||||
section_title: string | null;
|
||||
/** LLM이 추출한 50~300자 핵심 span. UI에서 이것만 노출. */
|
||||
span_text: string;
|
||||
/**
|
||||
* 원본 800자 window.
|
||||
*
|
||||
* ⚠ UI 기본 경로에서 절대 렌더 금지. debug 모드에서 hover tooltip 용도로만
|
||||
* 조건부 노출 가능. full_snippet을 보여주면 backend span-precision UX
|
||||
* 가치가 사라진다 (plan §Evidence 표시 규칙).
|
||||
*/
|
||||
full_snippet: string;
|
||||
relevance: number;
|
||||
rerank_score: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: number;
|
||||
title: string | null;
|
||||
ai_domain: string | null;
|
||||
ai_summary: string | null;
|
||||
file_format: string;
|
||||
score: number;
|
||||
snippet: string | null;
|
||||
match_reason: string | null;
|
||||
chunk_id: number | null;
|
||||
chunk_index: number | null;
|
||||
section_title: string | null;
|
||||
rerank_score: number | null;
|
||||
}
|
||||
|
||||
export interface AskResponse {
|
||||
results: SearchResult[];
|
||||
ai_answer: string | null;
|
||||
citations: Citation[];
|
||||
synthesis_status: SynthesisStatus;
|
||||
synthesis_ms: number;
|
||||
confidence: Confidence | null;
|
||||
refused: boolean;
|
||||
no_results_reason: string | null;
|
||||
query: string;
|
||||
total: number;
|
||||
}
|
||||
@@ -80,6 +80,7 @@
|
||||
<SystemStatusDot />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" href="/ask">질문</Button>
|
||||
<Button variant="ghost" size="sm" href="/news">뉴스</Button>
|
||||
<Button variant="ghost" size="sm" href="/inbox">Inbox</Button>
|
||||
<Button variant="ghost" size="sm" href="/settings">설정</Button>
|
||||
|
||||
150
frontend/src/routes/ask/+page.svelte
Normal file
150
frontend/src/routes/ask/+page.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
/ask — Phase 3.4 Ask Pipeline Frontend.
|
||||
|
||||
URL-driven: `/ask?q=<encoded>` 가 진입점. $effect 로 q 변화 감지 →
|
||||
`/api/search/ask` 호출 → 3-panel 렌더 (Answer / Evidence / Results).
|
||||
|
||||
중복 호출 방지: lastQuery 가드 (hydration + reactive trigger 시 같은 q 2번 발동 방지).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, type ApiError } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import AskAnswer from '$lib/components/ask/AskAnswer.svelte';
|
||||
import AskEvidence from '$lib/components/ask/AskEvidence.svelte';
|
||||
import AskResults from '$lib/components/ask/AskResults.svelte';
|
||||
import { Sparkles, Search } from 'lucide-svelte';
|
||||
import type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
// ── state ───────────────────────────────────────────
|
||||
let queryInput = $state('');
|
||||
let data = $state<AskResponse | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
// 중복 호출 방지 가드 (hydration + reactive trigger 이중 발동 방지)
|
||||
let lastQuery = '';
|
||||
|
||||
// citation scroll 연동: Answer 가 [n] 클릭 → Evidence 카드로 이동 + highlight
|
||||
const citationNodes = new Map<number, HTMLElement>();
|
||||
let activeCitation = $state<number | null>(null);
|
||||
|
||||
function registerCitation(n: number, node: HTMLElement) {
|
||||
citationNodes.set(n, node);
|
||||
return {
|
||||
destroy() {
|
||||
citationNodes.delete(n);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scrollToCitation(n: number) {
|
||||
activeCitation = n;
|
||||
const node = citationNodes.get(n);
|
||||
node?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// ── submit (URL-driven, back 자동) ──────────────────
|
||||
function submit() {
|
||||
const q = queryInput.trim();
|
||||
if (!q) return;
|
||||
goto(`/ask?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
// ── API 호출 ───────────────────────────────────────
|
||||
async function runAsk(q: string) {
|
||||
loading = true;
|
||||
activeCitation = null;
|
||||
try {
|
||||
data = await api<AskResponse>(
|
||||
`/search/ask?q=${encodeURIComponent(q)}&limit=10`,
|
||||
);
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
addToast('error', apiErr.detail || '답변 생성 실패');
|
||||
data = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL → runAsk (중복 가드) ────────────────────────
|
||||
$effect(() => {
|
||||
const q = $page.url.searchParams.get('q') ?? '';
|
||||
queryInput = q;
|
||||
if (!q) {
|
||||
data = null;
|
||||
loading = false;
|
||||
lastQuery = '';
|
||||
return;
|
||||
}
|
||||
if (q === lastQuery) return;
|
||||
lastQuery = q;
|
||||
runAsk(q);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>질문 - PKM</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full overflow-auto">
|
||||
<!-- 상단 검색바 (sticky) -->
|
||||
<div class="sticky top-0 z-10 bg-bg/80 backdrop-blur border-b border-default px-4 py-3">
|
||||
<div class="flex items-center gap-2 max-w-5xl mx-auto">
|
||||
<div class="relative flex-1">
|
||||
<Search
|
||||
size={14}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-dim pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
data-search-input
|
||||
type="text"
|
||||
bind:value={queryInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="질문을 입력하세요 (/ 키로 포커스)"
|
||||
class="w-full pl-9 pr-3 py-2 bg-surface border border-default rounded-lg text-text text-sm focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="max-w-5xl mx-auto p-4">
|
||||
{#if !queryInput && !loading && !data}
|
||||
<div class="py-16">
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="근거 기반 답변을 받아보세요"
|
||||
description="질문을 입력하면 문서에서 근거를 찾아 인용 기반 답변을 생성합니다."
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 lg:grid-cols-[1.2fr_0.9fr] items-start">
|
||||
<!-- 좌: Answer + Results -->
|
||||
<div class="flex flex-col gap-4 min-w-0">
|
||||
<AskAnswer {data} {loading} onCitationClick={scrollToCitation} />
|
||||
<AskResults {data} {loading} />
|
||||
</div>
|
||||
|
||||
<!-- 우: Evidence (lg 이상 sticky) -->
|
||||
<div class="lg:sticky lg:top-20 lg:self-start min-w-0">
|
||||
<AskEvidence
|
||||
{data}
|
||||
{loading}
|
||||
{activeCitation}
|
||||
{registerCitation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree, Rows3, Rows2 } from 'lucide-svelte';
|
||||
import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree, Rows3, Rows2, Sparkles } from 'lucide-svelte';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
@@ -441,6 +441,15 @@
|
||||
<option value="trgm">부분매칭</option>
|
||||
<option value="vector">의미검색</option>
|
||||
</select>
|
||||
{#if searchQuery.trim()}
|
||||
<a
|
||||
href={`/ask?q=${encodeURIComponent(searchQuery.trim())}`}
|
||||
class="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors text-xs"
|
||||
title="이 쿼리로 AI 답변 보기"
|
||||
>
|
||||
<Sparkles size={14} /> AI 답변
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={toggleViewMode}
|
||||
class="p-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user