From 7d35a9672a9958822aaa03fb3dc787c1ab4a51d7 Mon Sep 17 00:00:00 2001 From: faligam Date: Sun, 6 Sep 2026 07:28:20 +0000 Subject: [PATCH] FE-2: API-Anbindung, Session-Management, Chat-View, Quellen/Claims-Sicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - research-api.ts: Vollständiger Research-API-Client mit allen Funktionen (createResearch, getResearchStatus, getResearchDetail, listResearch, deleteResearch, shareResearch, getShareData, stopResearch, revokeShare) - stores.ts: Neue Stores (currentResearchId, researchData, researchHistory, pollingInterval, pollingActive, lastApiError, shareToken) - formatters.ts: Hilfsfunktionen (truncateId, truncateQuery, formatDate, formatStatus, getConfidenceBadge, getIndependenceBadge, scoreColor, domainFavicon) - SourcesTab.svelte: Quellen mit Titel, URL, Domain, Source-Type, Independence-Score, Favicon-Platzhalter - ClaimsTab.svelte: Claims mit Typ, Evidence-Span, Confidence-Badge - EvidenceTab.svelte: 6D-Evidenz-Scores als Balkendiagramm - ReportTab.svelte: Markdown-Report mit Download (JSON/Markdown/Text) - MethodologyTab.svelte: Methodik als Markdown - ShareModal.svelte: Share-Link-Generierung mit Ablaufzeit + Max-Views - research/{id}/+page.svelte: Echte API-Polling alle 5s, Stop/Delete-Confirm, Error-Banner, Tab-Integration, Share-Modal - share/[token]/+page.svelte: Public Read-Only Share-Link-Ansicht mit Quellen, Claims, Evidence, Report, Ablauf-Warnung --- src/lib/components/ShareModal.svelte | 218 +++++++++ src/lib/research-api.ts | 167 +++++++ src/lib/stores.ts | 28 +- src/lib/utils/formatters.ts | 100 +++++ src/routes/research/{id}/+page.svelte | 420 +++++++++++------- .../research/{id}/components/ClaimsTab.svelte | 112 +++++ .../{id}/components/EvidenceTab.svelte | 113 +++++ .../{id}/components/MethodologyTab.svelte | 35 ++ .../research/{id}/components/ReportTab.svelte | 122 +++++ .../{id}/components/SourcesTab.svelte | 109 +++++ src/routes/share/[token]/+page.svelte | 273 ++++++++++++ 11 files changed, 1534 insertions(+), 163 deletions(-) create mode 100644 src/lib/components/ShareModal.svelte create mode 100644 src/lib/research-api.ts create mode 100644 src/lib/utils/formatters.ts create mode 100644 src/routes/research/{id}/components/ClaimsTab.svelte create mode 100644 src/routes/research/{id}/components/EvidenceTab.svelte create mode 100644 src/routes/research/{id}/components/MethodologyTab.svelte create mode 100644 src/routes/research/{id}/components/ReportTab.svelte create mode 100644 src/routes/research/{id}/components/SourcesTab.svelte create mode 100644 src/routes/share/[token]/+page.svelte diff --git a/src/lib/components/ShareModal.svelte b/src/lib/components/ShareModal.svelte new file mode 100644 index 0000000..81cce3c --- /dev/null +++ b/src/lib/components/ShareModal.svelte @@ -0,0 +1,218 @@ + + + \ No newline at end of file diff --git a/src/lib/research-api.ts b/src/lib/research-api.ts new file mode 100644 index 0000000..082912c --- /dev/null +++ b/src/lib/research-api.ts @@ -0,0 +1,167 @@ +import { apiGet, apiPost, apiDelete } from '$lib/api'; +import type { ResearchStatus, ResearchSummary, SourceInfo, ClaimInfo, EvidenceScore } from '$lib/types'; + +/** + * Erzeugt eine neue Research-Anfrage. + */ +export async function createResearch( + apiKey: string, + query: string, + language: string, + depth: string +): Promise<{ id: string }> { + return await apiPost<{ id: string }>( + '/v1/research', + { query, language, depth }, + { apiKey } + ); +} + +/** + * Fragt den Status einer laufenden Research-Abfrage ab. + */ +export async function getResearchStatus( + apiKey: string, + researchId: string +): Promise { + return await apiGet(`/v1/research/${researchId}/status`, { apiKey }); +} + +/** + * Liefert die vollständigen Details einer Research-Abfrage. + */ +export async function getResearchDetail( + apiKey: string, + researchId: string +): Promise { + return await apiGet(`/v1/research/${researchId}`, { apiKey }); +} + +/** + * Liefert eine Liste aller Research-Einträge des authenticated Users. + */ +export async function listResearch( + apiKey: string, + limit: number = 50 +): Promise { + const list = await apiGet('/v1/research', { apiKey }); + return list.slice(0, limit); +} + +/** + * Liefert den finalen Forschungsbericht einer abgeschlossenen Research-Abfrage. + */ +export async function getResearchReport( + apiKey: string, + researchId: string +): Promise { + return await apiGet(`/v1/research/${researchId}/report`, { apiKey }); +} + +/** + * Löscht eine Research-Abfrage permanent. + */ +export async function deleteResearch( + apiKey: string, + researchId: string +): Promise { + await apiDelete(`/v1/research/${researchId}`, { apiKey }); +} + +/** + * Generiert einen Share-Link für eine Research-Abfrage. + */ +export interface ShareResult { + token: string; + url: string; +} + +export async function shareResearch( + apiKey: string, + researchId: string, + expiresAt?: string, + maxViews?: number +): Promise { + return await apiPost('/v1/research/share', { + research_id: researchId, + expires_at: expiresAt, + max_views: maxViews, + }, { apiKey }); +} + +/** + * Liest die Daten eines geteilten Threads über den Share-Token. + * Keine Authentifizierung nötig. + */ +export async function getShareData( + token: string +): Promise { + return await apiGet(`/v1/research/share/${token}`); +} + +/** + * Beendet eine laufende Research-Abfrage. + */ +export async function stopResearch( + apiKey: string, + researchId: string +): Promise { + await apiPost(`/v1/research/${researchId}/stop`, undefined, { apiKey }); +} + +/** + * Revoke (deaktiviert) einen Share-Token. + */ +export async function revokeShare( + apiKey: string, + token: string +): Promise { + await apiDelete(`/v1/research/share/${token}`, { apiKey }); +} + +// ======================== +// Zusätzliche Typen +// ======================== + +export interface ResearchDetail { + id: string; + query: string; + language?: string; + depth?: string; + status: string; + progress?: number; + created_at: string; + completed_at?: string; + error?: string; + sources?: SourceInfo[]; + claims?: ClaimInfo[]; + evidence?: EvidenceScore[]; + evidence_scores?: Record; + report?: string; + methodology?: string; + username?: string; +} + +export interface ShareData { + id: string; + query: string; + language?: string; + depth?: string; + status: string; + progress?: number; + created_at: string; + completed_at?: string; + error?: string; + sources?: SourceInfo[]; + claims?: ClaimInfo[]; + evidence?: EvidenceScore[]; + evidence_scores?: Record; + report?: string; + methodology?: string; + username?: string; + share_token?: string; + share_expires_at?: string; + share_view_count?: number; + share_max_views?: number; + share_expired?: boolean; +} \ No newline at end of file diff --git a/src/lib/stores.ts b/src/lib/stores.ts index cc266de..ddf57dc 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -1,4 +1,5 @@ import { writable } from 'svelte/store'; +import type { ResearchSummary } from '$lib/types'; // Theme: 'dark' | 'light' export const theme = writable<'dark' | 'light'>('dark'); @@ -10,4 +11,29 @@ export const user = writable<{ apiKey: string; username?: string } | null>(null) export const sidebarOpen = writable(false); // Session IDs der letzten Researches -export const recentResearchIds = writable([]); \ No newline at end of file +export const recentResearchIds = writable([]); + +// ======================== +// Research-Specific Stores +// ======================== + +/** Aktuell selektierte Research-ID */ +export const currentResearchId = writable(null); + +/** Aktuelle Research-Daten (wird durch Polling aktualisiert) */ +export const researchData = writable(null); + +/** Research-Historie (Sidebar-Liste) */ +export const researchHistory = writable([]); + +/** Polling-Intervall in Millisekunden */ +export const pollingInterval = writable(5000); + +/** Ob das Polling currently aktiv ist */ +export const pollingActive = writable(false); + +/** Letzter API-Fehler, falls vorhanden */ +export const lastApiError = writable(null); + +/** Share-Token für den aktuellen Research-Thread */ +export const shareToken = writable(null); \ No newline at end of file diff --git a/src/lib/utils/formatters.ts b/src/lib/utils/formatters.ts new file mode 100644 index 0000000..0b79a22 --- /dev/null +++ b/src/lib/utils/formatters.ts @@ -0,0 +1,100 @@ +/** + * Kürzt eine UUID auf die ersten 8 Zeichen. + */ +export function truncateId(id: string): string { + if (!id) return ''; + return id.length > 8 ? id.slice(0, 8) : id; +} + +/** + * Kürzt einen Query-Text auf max Zeichen mit Auslassungszeichen. + */ +export function truncateQuery(query: string, max: number = 60): string { + if (!query) return ''; + return query.length > max ? query.slice(0, max) + '…' : query; +} + +/** + * Formatiert ein Datum als DD.MM.YYYY HH:mm. + */ +export function formatDate(date: string): string { + if (!date) return ''; + const d = new Date(date); + if (isNaN(d.getTime())) return date; + return d.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + ' ' + d.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Formatiert einen Status-Code in einen lesbaren deutschen Text. + */ +export function formatStatus(status: string): string { + const map: Record = { + PENDING: 'Ausstehend', + PLANNING: 'Planung', + SEARCHING: 'Suche läuft', + EXTRACTING: 'Extraktion', + ANALYZING: 'Analyse', + SYNTHESIZING: 'Synthese', + COMPLETED: 'Abgeschlossen', + FAILED: 'Fehlgeschlagen', + STOPPED: 'Gestoppt', + }; + return map[status] ?? status; +} + +/** + * Liefert ein Badge-Objekt für Confidence-Scores. + */ +export function getConfidenceBadge(confidence: number): { label: string; color: string } { + if (confidence >= 0.8) { + return { label: 'Hoch', color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200' }; + } + if (confidence >= 0.6) { + return { label: 'Mittel', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' }; + } + if (confidence >= 0.4) { + return { label: 'Niedrig', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }; + } + return { label: 'Sehr niedrig', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }; +} + +/** + * Liefert ein Badge-Objekt für Independence-Scores. + */ +export function getIndependenceBadge(score: number): { label: string; color: string } { + if (score >= 0.8) { + return { label: 'Sehr unabhängig', color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200' }; + } + if (score >= 0.6) { + return { label: 'Unabhängig', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' }; + } + if (score >= 0.4) { + return { label: 'Eingeschränkt', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }; + } + return { label: 'Abhängig', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }; +} + +/** + * Liefert eine CSS-Klasse basierend auf einem numerischen Score. + */ +export function scoreColor(score: number): string { + if (score >= 0.8) return 'text-emerald-600 dark:text-emerald-400'; + if (score >= 0.6) return 'text-blue-600 dark:text-blue-400'; + if (score >= 0.4) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-red-600 dark:text-red-400'; +} + +/** + * Liefert einen Favicon-Placeholder-Buchstaben aus einer Domain. + */ +export function domainFavicon(domain: string): string { + if (!domain) return '?'; + return domain.replace(/^(https?:\/\/)?(www\.)?/, '').charAt(0).toUpperCase(); +} \ No newline at end of file diff --git a/src/routes/research/{id}/+page.svelte b/src/routes/research/{id}/+page.svelte index adf1792..5543a49 100644 --- a/src/routes/research/{id}/+page.svelte +++ b/src/routes/research/{id}/+page.svelte @@ -1,55 +1,91 @@ + +
+
+

+ Extrahierte Claims ({claims.length}) +

+
+ + {#if loading} +
+ {#each [1, 2, 3] as _} +
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if error} +
+

{error}

+
+ {:else if claims.length === 0} +

Noch keine Claims extrahiert.

+ {:else} + {#each claims as claim, i} +
+
+ {claimTypeIcon(claim.claim_type)} +
+
+ + {claimTypeLabel(claim.claim_type)} + + {#if claim.id} + {claim.id} + {/if} +
+

+ {claim.claim} +

+
+ + {#if claim.confidence !== undefined} + + Confidence: {(claim.confidence * 100).toFixed(0)}% + + {/if} + + {#if claim.evidence_span} + + Evidence Span + +

+ {claim.evidence_span} +

+ {/if} + {#if claim.source_id} + + Quelle: {claim.source_id} + + {/if} +
+
+
+
+ {/each} + {/if} +
\ No newline at end of file diff --git a/src/routes/research/{id}/components/EvidenceTab.svelte b/src/routes/research/{id}/components/EvidenceTab.svelte new file mode 100644 index 0000000..7a3ff8c --- /dev/null +++ b/src/routes/research/{id}/components/EvidenceTab.svelte @@ -0,0 +1,113 @@ + + +
+
+

+ Evidenz-Scores +

+
+ + {#if loading} +
+ {#each DIMENSIONS as dim} +
+
+
+
+ {/each} +
+ {:else if error} +
+

{error}

+
+ {:else if evidence.length === 0 && Object.keys(evidence_scores).length === 0} +

Noch keine Evidenz-Scores berechnet.

+ {:else} + + {#if evidence_scores && Object.keys(evidence_scores).length > 0} +
+ {#each DIMENSIONS as dim} + {#if evidence_scores[dim.key] !== undefined} +
+
+ {dim.label} + + {scorePercent(evidence_scores[dim.key])}% + +
+
+
= 0.8} + class:bg-blue-500={evidence_scores[dim.key] >= 0.6 && evidence_scores[dim.key] < 0.8} + class:bg-yellow-500={evidence_scores[dim.key] >= 0.4 && evidence_scores[dim.key] < 0.6} + class:bg-red-500={evidence_scores[dim.key] < 0.4} + >
+
+
+ {/if} + {/each} +
+ {/if} + + + {#if evidence && evidence.length > 1} +
+

+ Einzelscores +

+ {#each evidence as score, idx} +
+

+ Score {idx + 1} +

+
+ {#each DIMENSIONS as dim} + {#if score[dim.key] !== undefined} +
+ {dim.label} + + {scorePercent(score[dim.key])}% + +
+
+
= 0.8} + class:bg-blue-500={score[dim.key] >= 0.6 && score[dim.key] < 0.8} + class:bg-yellow-500={score[dim.key] >= 0.4 && score[dim.key] < 0.6} + class:bg-red-500={score[dim.key] < 0.4} + >
+
+ {/if} + {/each} +
+
+ {/each} +
+ {/if} + {/if} +
\ No newline at end of file diff --git a/src/routes/research/{id}/components/MethodologyTab.svelte b/src/routes/research/{id}/components/MethodologyTab.svelte new file mode 100644 index 0000000..c3e6c68 --- /dev/null +++ b/src/routes/research/{id}/components/MethodologyTab.svelte @@ -0,0 +1,35 @@ + + +
+
+

+ Methodik-Dokumentation +

+
+ + {#if loading} +
+ {#each [1, 2, 3, 4, 5] as _} +
+ {/each} +
+ {:else if error} +
+

{error}

+
+ {:else if methodology} +
+ {methodology} +
+ {:else} +

+ Methodik wird nach Abschluss der Recherche verfügbar sein. +

+ {/if} +
\ No newline at end of file diff --git a/src/routes/research/{id}/components/ReportTab.svelte b/src/routes/research/{id}/components/ReportTab.svelte new file mode 100644 index 0000000..0cb3554 --- /dev/null +++ b/src/routes/research/{id}/components/ReportTab.svelte @@ -0,0 +1,122 @@ + + +
+
+

+ Forschungsbericht +

+ {#if report} + + {/if} +
+ + + {#if showDownload} +
+ + + +
+ {/if} + + {#if loading} +
+ {#each [1, 2, 3, 4, 5] as _} +
+ {/each} +
+ {:else if error} +
+

{error}

+
+ {:else if report} +
+ {report} +
+ {:else} +

+ Bericht wird generiert, sobald die Recherche abgeschlossen ist. +

+ {/if} +
\ No newline at end of file diff --git a/src/routes/research/{id}/components/SourcesTab.svelte b/src/routes/research/{id}/components/SourcesTab.svelte new file mode 100644 index 0000000..48a73ea --- /dev/null +++ b/src/routes/research/{id}/components/SourcesTab.svelte @@ -0,0 +1,109 @@ + + +
+
+

+ Gefundene Quellen ({sources.length}) +

+
+ + {#if loading} +
+ {#each [1, 2, 3, 4] as _} +
+
+
+
+
+
+
+
+ {/each} +
+ {:else if error} +
+

{error}

+
+ {:else if sources.length === 0} +

Noch keine Quellen gefunden.

+ {:else} + {#each sources as source, i} +
+
+ +
+ {domainFavicon(source.domain)} +
+
+

+ {source.title} +

+ + {source.url} + +
+ {#if source.domain} + + {source.domain} + + {/if} + {#if source.source_type} + + {sourceTypeLabel(source.source_type)} + + {/if} + {#if source.independence_score !== undefined} + + Unabhängigkeit: {(source.independence_score * 100).toFixed(0)}% + + {/if} + {#if source.id} + ID: {source.id} + {/if} +
+
+
+
+ {/each} + {/if} +
\ No newline at end of file diff --git a/src/routes/share/[token]/+page.svelte b/src/routes/share/[token]/+page.svelte new file mode 100644 index 0000000..8823e13 --- /dev/null +++ b/src/routes/share/[token]/+page.svelte @@ -0,0 +1,273 @@ + + +
+ +
+

+ 🔗 Geteilter Research-Thread — Nur lesen +

+
+ + + {#if expired} +
+
+ ⚠️ +

+ Dieser Share-Link ist abgelaufen. Der Thread kann nicht mehr angezeigt werden. +

+
+
+ {/if} + + {#if loading} +
+
+ + + + +

Share-Daten werden geladen…

+
+
+ {:else if error} +
+
+ ⚠️ +

{error}

+ + ← Zurück zur Startseite + +
+
+ {:else if shareData} +
+ +
+
+
+

+ {shareData.query} +

+

+ Research-ID: {truncateId(shareData.id)} +

+
+
+
+ {#if shareData.created_at} + Erstellt: {formatDate(shareData.created_at)} + {/if} + {#if shareData.status} + Status: {shareData.status} + {/if} + {#if shareData.share_view_count !== undefined} + Aufrufe: {shareData.share_view_count} + {/if} + {#if shareData.share_max_views && shareData.share_max_views > 0} + Max: {shareData.share_max_views} + {/if} +
+
+ + +
+

+ Suchanfrage +

+

+ {shareData.query} +

+ {#if shareData.language} +

Sprache: {shareData.language}

+ {/if} +
+ + + {#if shareData.sources && shareData.sources.length > 0} +
+

+ Quellen ({shareData.sources.length}) +

+
+ {#each shareData.sources as source} +
+
+ {domainFavicon(source.domain)} +
+
+

{source.title}

+ {#if source.url} + + {source.url} + + {/if} + {#if source.domain} + + {source.domain} + + {/if} +
+
+ {/each} +
+
+ {/if} + + + {#if shareData.claims && shareData.claims.length > 0} +
+

+ Claims ({shareData.claims.length}) +

+
+ {#each shareData.claims as claim} +
+
+ {claimTypeIcon(claim.claim_type)} + + {claimTypeLabel(claim.claim_type)} + +
+

{claim.claim}

+
+ {#if claim.confidence !== undefined} + + Confidence: {(claim.confidence * 100).toFixed(0)}% + + {/if} + {#if claim.evidence_span} + {claim.evidence_span} + {/if} +
+
+ {/each} +
+
+ {/if} + + + {#if shareData.evidence_scores && Object.keys(shareData.evidence_scores).length > 0} +
+

+ Evidenz-Scores +

+
+ {#each shareData.evidence_scores as (key, value)} +
+
+ + {key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} + + + {(value * 100).toFixed(0)}% + +
+
+
= 0.8} + class:bg-blue-500={value >= 0.6 && value < 0.8} + class:bg-yellow-500={value >= 0.4 && value < 0.6} + class:bg-red-500={value < 0.4} + >
+
+
+ {/each} +
+
+ {/if} + + + {#if shareData.report} +
+

+ Forschungsbericht +

+
+ {shareData.report} +
+
+ {/if} + + +
+ {#if shareData.username} +

Dieser Thread wurde von @{shareData.username} erstellt.

+ {/if} +

Geteilt über NSCT Research · {new Date().toLocaleDateString('de-DE')}

+
+
+ {/if} +
\ No newline at end of file