00edd6bff8
PR-3 of DS AI routing policy (2026-05-23, see plan
~/.claude/plans/document-server-ai-cheeky-reddy.md +
memory project_document_server_ai_routing_policy).
기존 BackendSelector (PR-DocSrv-Web-Ask-Selector-1, 2 옵션 default
qwen-macbook) 확장 — 4 옵션 + DeviceToggle inline.
UI 변경 (frontend/src/routes/ask/+page.svelte):
- BackendChoice = auto | mac-mini-default | qwen-macbook | claude-cloud
(기존 default 는 legacy alias, auto 또는 mac-mini-default 로 자동 매핑).
- select 4 옵션 (Auto router / Mac mini default / This device /
Claude Cloud) + tooltip.
- DeviceToggle (checkbox 'This is M5 Max') inline — localStorage
ds_device_self_label = macbook-m5-max | null. mount 시 복원.
- This device 옵션 disabled state = !isMacBookM5Max (토글 off 시
grey-out). 토글 off 시 qwen-macbook 선택돼 있었으면 auto 복귀.
- Claude Cloud 옵션 disabled state = !CLOUD_DEV_ENABLED (build-time
flag VITE_ENABLE_CLOUD_BACKEND_DEV, default false). 운영 토글
불가 — 후속 PR DS runtime feature flag API 로 migrate 예정.
- friendlyErrorMessage(reason) — 503 error_reason 매핑
(macbook_unavailable / provider_not_configured / router_* / upstream_*).
- retryWithDefault → retryWithMacMiniDefault 명명 정정.
- parseBackend backward-compat: default / gemma-macmini →
mac-mini-default.
source IP 의존 0 (PR-0 round 2 발견: caddy 2-hop + X-Forwarded-For
미설정 → DS 가 보는 source IP = LAN gateway, 신뢰 불가).
사용자 명시 토글 + localStorage 방식 채택 (Q3=C).
Closure (build + bundle string + lint):
- frontend build PASS (SvelteKit/TS syntax + svelte compile 모두 OK).
- 컴파일된 bundle 에 9 핵심 string 박혀있음 (mac-mini-default /
qwen-macbook / claude-cloud / Auto router / This is M5 Max /
ds_device_self_label / provider_not_configured / This device /
Cloud backend not configured).
- lint:tokens 본 PR 변경 위반 0 (기존 62 stale debt 는 별 chore
PR-DocSrv-Frontend-Token-Cleanup-1).
Backup: ~/.local/share/ds-routing-pr2-backups/20260523/
ask-page.svelte.pre-pr3.
선행: PR-1 (llm-router alias scaffold) + PR-2 (RouterBackend
dispatcher, refactor commit bcf644f) closed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
306 lines
12 KiB
Svelte
306 lines
12 KiB
Svelte
<!--
|
|
/ask — Phase 3.4 Ask Pipeline Frontend.
|
|
|
|
URL-driven: `/ask?q=<encoded>[&backend=<alias>]` 가 진입점.
|
|
$effect 로 (q, backend) 변화 감지 → `/api/search/ask` 호출 →
|
|
3-panel 렌더 (Answer / Evidence / Results).
|
|
|
|
중복 호출 방지: lastKey (q+backend) 가드.
|
|
|
|
Backend selector (PR-3 of DS AI routing policy, 2026-05-23,
|
|
[[document-server-ai-routing-policy]]) — PR-DocSrv-Web-Ask-Selector-1 확장:
|
|
- `auto` (기본, URL param 없음 → router 의 rule + LLM triage)
|
|
- `mac-mini-default` (명시, Mac mini gemma-4-26b)
|
|
- `qwen-macbook` (명시, M5 Max Qwen3.6-27B; "This device" 토글 on 시만)
|
|
- `claude-cloud` (명시, 503 scaffold; VITE_ENABLE_CLOUD_BACKEND_DEV=true 시만)
|
|
- "This device" 토글: localStorage `ds_device_self_label = 'macbook-m5-max' | null`.
|
|
source IP 의존 0 (PR-0 round 2 발견: caddy 2-hop + X-Forwarded-For 미설정 →
|
|
DS 가 보는 source IP = LAN gateway, 신뢰 불가).
|
|
- 503 + error_reason ∈ {macbook_unavailable, provider_not_configured, router_*}
|
|
시 자동 fallback 금지. UI 가 친절한 메시지 + "Mac mini default 로 재요청" 버튼.
|
|
- legacy URL `?backend=default|gemma-macmini` 는 그대로 받아서 mac-mini-default 와 동등 처리.
|
|
-->
|
|
<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 Button from '$lib/components/ui/Button.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, AlertCircle } from 'lucide-svelte';
|
|
import type { AskResponse } from '$lib/types/ask';
|
|
|
|
type BackendChoice = 'auto' | 'mac-mini-default' | 'qwen-macbook' | 'claude-cloud';
|
|
|
|
function parseBackend(raw: string | null): BackendChoice {
|
|
if (raw === 'qwen-macbook') return 'qwen-macbook';
|
|
if (raw === 'mac-mini-default') return 'mac-mini-default';
|
|
if (raw === 'claude-cloud') return 'claude-cloud';
|
|
// legacy aliases (?backend=default | gemma-macmini) → mac-mini-default 와 동등
|
|
if (raw === 'default' || raw === 'gemma-macmini') return 'mac-mini-default';
|
|
return 'auto';
|
|
}
|
|
|
|
// Build-time feature flag — Claude Cloud UI 노출 여부 (default false).
|
|
// VITE_ENABLE_CLOUD_BACKEND_DEV=true npm run build 시만 활성. 운영 토글 X
|
|
// (build-time 한계). DS runtime feature flag API migrate 는 후속 PR.
|
|
const CLOUD_DEV_ENABLED = import.meta.env.VITE_ENABLE_CLOUD_BACKEND_DEV === 'true';
|
|
const DEVICE_LABEL_KEY = 'ds_device_self_label';
|
|
const DEVICE_LABEL_M5_MAX = 'macbook-m5-max';
|
|
|
|
// ── state ───────────────────────────────────────────
|
|
let queryInput = $state('');
|
|
let selectedBackend = $state<BackendChoice>('auto');
|
|
let data = $state<AskResponse | null>(null);
|
|
let loading = $state(false);
|
|
let backendUnavailable = $state(false);
|
|
let backendUnavailableMessage = $state('');
|
|
// "I am on MacBook M5 Max" 토글. mount 시 localStorage 에서 복원.
|
|
let isMacBookM5Max = $state(false);
|
|
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const stored = window.localStorage.getItem(DEVICE_LABEL_KEY);
|
|
isMacBookM5Max = stored === DEVICE_LABEL_M5_MAX;
|
|
});
|
|
|
|
function toggleMacBookM5Max() {
|
|
isMacBookM5Max = !isMacBookM5Max;
|
|
if (typeof window === 'undefined') return;
|
|
if (isMacBookM5Max) {
|
|
window.localStorage.setItem(DEVICE_LABEL_KEY, DEVICE_LABEL_M5_MAX);
|
|
} else {
|
|
window.localStorage.removeItem(DEVICE_LABEL_KEY);
|
|
// 토글 off 시 qwen-macbook 선택돼 있었으면 auto 로 복귀 (선택권 박탈 X 명시 신호).
|
|
if (selectedBackend === 'qwen-macbook') {
|
|
selectedBackend = 'auto';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 중복 호출 방지 가드 (hydration + reactive trigger 이중 발동 방지)
|
|
let lastKey = '';
|
|
|
|
// 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' });
|
|
}
|
|
|
|
// ── URL 빌더: backend !== 'auto' 일 때만 param 추가 ─────
|
|
function buildAskUrl(q: string, backend: BackendChoice): string {
|
|
const params = new URLSearchParams({ q });
|
|
if (backend !== 'auto') params.set('backend', backend);
|
|
return `/ask?${params.toString()}`;
|
|
}
|
|
|
|
// ── submit (URL-driven, back 자동) ──────────────────
|
|
function submit() {
|
|
const q = queryInput.trim();
|
|
if (!q) return;
|
|
goto(buildAskUrl(q, selectedBackend));
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter' && !e.isComposing) {
|
|
e.preventDefault();
|
|
submit();
|
|
}
|
|
}
|
|
|
|
// 503 후 "Mac mini default 로 재요청" — auto 로 reset (param 명시 제거).
|
|
function retryWithMacMiniDefault() {
|
|
const q = $page.url.searchParams.get('q') ?? queryInput.trim();
|
|
if (!q) return;
|
|
goto(`/ask?q=${encodeURIComponent(q)}`);
|
|
}
|
|
|
|
// PR-3 of routing policy — error_reason → 친절 메시지 매핑.
|
|
// silent fallback 금지 ([[feedback_no_silent_fallback_explicit_opt_in]]):
|
|
// 모든 503 case 는 명시 표시, 다른 backend 자동 재호출 X.
|
|
function friendlyErrorMessage(reason: string | undefined, detail: string): string {
|
|
switch (reason) {
|
|
case 'macbook_unavailable':
|
|
return 'MacBook M5 Max 가 응답하지 않습니다. 깨우거나 (launchctl start) Mac mini default 로 다시 요청하세요.';
|
|
case 'provider_not_configured':
|
|
return 'Claude Cloud 백엔드는 아직 구성되지 않았습니다 (2026-06-15 이후 별 PR 활성화 예정).';
|
|
default:
|
|
if (reason && reason.startsWith('router_')) {
|
|
return `라우터 호출 실패 (${reason}). Mac mini default 로 다시 요청하거나 잠시 후 재시도하세요.`;
|
|
}
|
|
if (reason && reason.startsWith('upstream_')) {
|
|
return `Upstream backend 가 응답하지 않습니다 (${reason}). 잠시 후 재시도하세요.`;
|
|
}
|
|
return detail || '답변 생성 실패';
|
|
}
|
|
}
|
|
|
|
// ── API 호출 ───────────────────────────────────────
|
|
async function runAsk(q: string, backend: BackendChoice) {
|
|
loading = true;
|
|
activeCitation = null;
|
|
backendUnavailable = false;
|
|
backendUnavailableMessage = '';
|
|
const path = backend !== 'auto'
|
|
? `/search/ask?q=${encodeURIComponent(q)}&backend=${encodeURIComponent(backend)}&limit=10`
|
|
: `/search/ask?q=${encodeURIComponent(q)}&limit=10`;
|
|
try {
|
|
data = await api<AskResponse>(path);
|
|
} catch (err) {
|
|
const apiErr = err as ApiError;
|
|
if (apiErr.status === 503) {
|
|
backendUnavailable = true;
|
|
backendUnavailableMessage = friendlyErrorMessage(apiErr.errorReason, apiErr.detail);
|
|
data = null;
|
|
} else {
|
|
addToast('error', apiErr.detail || '답변 생성 실패');
|
|
data = null;
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// ── URL → runAsk (중복 가드) ────────────────────────
|
|
$effect(() => {
|
|
const q = $page.url.searchParams.get('q') ?? '';
|
|
const backend = parseBackend($page.url.searchParams.get('backend'));
|
|
queryInput = q;
|
|
selectedBackend = backend;
|
|
if (!q) {
|
|
data = null;
|
|
loading = false;
|
|
backendUnavailable = false;
|
|
lastKey = '';
|
|
return;
|
|
}
|
|
const key = `${q}|${backend}`;
|
|
if (key === lastKey) return;
|
|
lastKey = key;
|
|
runAsk(q, backend);
|
|
});
|
|
</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>
|
|
<label
|
|
class="flex items-center gap-1.5 text-xs text-dim cursor-pointer select-none"
|
|
title="이 디바이스가 MacBook M5 Max 인 경우 체크 — This device (qwen-macbook) 옵션 활성화됩니다. localStorage 저장."
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isMacBookM5Max}
|
|
onchange={toggleMacBookM5Max}
|
|
class="accent-accent"
|
|
/>
|
|
<span>This is M5 Max</span>
|
|
</label>
|
|
<select
|
|
bind:value={selectedBackend}
|
|
title="Backend 선택 — silent fallback 0 정책 (선택한 backend 만 시도, 실패 시 503)."
|
|
class="py-2 px-2 bg-surface border border-default rounded-lg text-text text-xs focus:border-accent outline-none"
|
|
>
|
|
<option value="auto">Auto (router)</option>
|
|
<option value="mac-mini-default">Mac mini (default)</option>
|
|
<option
|
|
value="qwen-macbook"
|
|
disabled={!isMacBookM5Max}
|
|
title={isMacBookM5Max
|
|
? 'MacBook M5 Max Qwen3.6-27B (직접 호출)'
|
|
: 'Check "This is M5 Max" toggle to enable'}
|
|
>
|
|
{isMacBookM5Max ? 'This device (Qwen MacBook)' : 'This device (unavailable)'}
|
|
</option>
|
|
<option
|
|
value="claude-cloud"
|
|
disabled={!CLOUD_DEV_ENABLED}
|
|
title={CLOUD_DEV_ENABLED
|
|
? 'Claude Cloud (dev mode — returns 503 until activation PR)'
|
|
: 'Cloud backend not configured yet'}
|
|
>
|
|
Claude Cloud {CLOUD_DEV_ENABLED ? '(dev)' : '(unavailable)'}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 본문 -->
|
|
<div class="max-w-5xl mx-auto p-4">
|
|
{#if backendUnavailable}
|
|
<div class="py-16">
|
|
<EmptyState
|
|
icon={AlertCircle}
|
|
title="Backend 가 응답하지 않습니다"
|
|
description={backendUnavailableMessage}
|
|
>
|
|
<Button variant="primary" size="sm" onclick={retryWithMacMiniDefault}>
|
|
Mac mini (default) 로 재요청
|
|
</Button>
|
|
</EmptyState>
|
|
</div>
|
|
{:else 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>
|