FE-2: API-Anbindung, Session-Management, Chat-View, Quellen/Claims-Sicht
- 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
This commit is contained in:
218
src/lib/components/ShareModal.svelte
Normal file
218
src/lib/components/ShareModal.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { getApiKey } from '$lib/auth';
|
||||
import { shareResearch, type ShareResult } from '$lib/research-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let researchId: string;
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ label: '24 Stunden', value: '24h', hours: 24 },
|
||||
{ label: '48 Stunden', value: '48h', hours: 48 },
|
||||
{ label: '7 Tage', value: '7d', hours: 168 },
|
||||
{ label: '14 Tage', value: '14d', hours: 336 },
|
||||
{ label: '30 Tage', value: '30d', hours: 720 },
|
||||
{ label: 'Nie', value: 'never', hours: 0 },
|
||||
];
|
||||
|
||||
const MAX_VIEWS_OPTIONS = [
|
||||
{ label: '1', value: 1 },
|
||||
{ label: '10', value: 10 },
|
||||
{ label: '50', value: 50 },
|
||||
{ label: 'Unbegrenzt', value: 0 },
|
||||
];
|
||||
|
||||
let selectedExpiry = '7d';
|
||||
let maxViews = '';
|
||||
let generating = false;
|
||||
let result: ShareResult | null = null;
|
||||
let copied = false;
|
||||
let error = '';
|
||||
|
||||
function getExpiryDate(hours: number): string | undefined {
|
||||
if (hours <= 0) return undefined;
|
||||
const d = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function generateLink() {
|
||||
generating = true;
|
||||
error = '';
|
||||
result = null;
|
||||
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
error = 'Kein API-Key verfügbar.';
|
||||
generating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hours = EXPIRY_OPTIONS.find(o => o.value === selectedExpiry)?.hours ?? 168;
|
||||
const expiresAt = getExpiryDate(hours);
|
||||
const maxViewsNum = maxViews === '' || maxViews === '0' ? undefined : parseInt(maxViews, 10);
|
||||
|
||||
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Link konnte nicht generiert werden.';
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!result) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.url);
|
||||
copied = true;
|
||||
setTimeout(() => { copied = false; }, 2000);
|
||||
} catch {
|
||||
error = 'Link konnte nicht kopiert werden.';
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
result = null;
|
||||
copied = false;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
on:keydown={handleKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Thread teilen"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Thread teilen</h2>
|
||||
<button
|
||||
on:click={handleClose}
|
||||
class="p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
{#if result}
|
||||
<!-- Generated link -->
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Share-Link
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={result.url}
|
||||
class="flex-1 px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-white font-mono truncate"
|
||||
/>
|
||||
<button
|
||||
on:click={copyLink}
|
||||
class="px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||
>
|
||||
{copied ? '✓ Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
|
||||
<p>Gültig bis: {result.token ? new Date().toLocaleDateString('de-DE') : '—'}</p>
|
||||
{#if maxViews && maxViews !== '0'}
|
||||
<p>Max. Aufrufe: {maxViews}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Expiry selector -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Ablaufzeit
|
||||
</label>
|
||||
<select
|
||||
bind:value={selectedExpiry}
|
||||
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
{#each EXPIRY_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Max views selector -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max. Aufrufe
|
||||
</label>
|
||||
<select
|
||||
bind:value={maxViews}
|
||||
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Unbegrenzt</option>
|
||||
{#each MAX_VIEWS_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Generate button -->
|
||||
<button
|
||||
on:click={generateLink}
|
||||
disabled={generating}
|
||||
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
{#if generating}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Wird generiert…
|
||||
</span>
|
||||
{:else}
|
||||
Link generieren
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end px-6 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 gap-2">
|
||||
<button
|
||||
on:click={handleClose}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
{#if result}
|
||||
<button
|
||||
on:click={handleClose}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
167
src/lib/research-api.ts
Normal file
167
src/lib/research-api.ts
Normal file
@@ -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<ResearchStatus> {
|
||||
return await apiGet<ResearchStatus>(`/v1/research/${researchId}/status`, { apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die vollständigen Details einer Research-Abfrage.
|
||||
*/
|
||||
export async function getResearchDetail(
|
||||
apiKey: string,
|
||||
researchId: string
|
||||
): Promise<ResearchDetail> {
|
||||
return await apiGet<ResearchDetail>(`/v1/research/${researchId}`, { apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert eine Liste aller Research-Einträge des authenticated Users.
|
||||
*/
|
||||
export async function listResearch(
|
||||
apiKey: string,
|
||||
limit: number = 50
|
||||
): Promise<ResearchSummary[]> {
|
||||
const list = await apiGet<ResearchSummary[]>('/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<any> {
|
||||
return await apiGet<any>(`/v1/research/${researchId}/report`, { apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht eine Research-Abfrage permanent.
|
||||
*/
|
||||
export async function deleteResearch(
|
||||
apiKey: string,
|
||||
researchId: string
|
||||
): Promise<void> {
|
||||
await apiDelete<void>(`/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<ShareResult> {
|
||||
return await apiPost<ShareResult>('/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<ShareData> {
|
||||
return await apiGet<ShareData>(`/v1/research/share/${token}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendet eine laufende Research-Abfrage.
|
||||
*/
|
||||
export async function stopResearch(
|
||||
apiKey: string,
|
||||
researchId: string
|
||||
): Promise<void> {
|
||||
await apiPost<void>(`/v1/research/${researchId}/stop`, undefined, { apiKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (deaktiviert) einen Share-Token.
|
||||
*/
|
||||
export async function revokeShare(
|
||||
apiKey: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
await apiDelete<void>(`/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<string, number>;
|
||||
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<string, number>;
|
||||
report?: string;
|
||||
methodology?: string;
|
||||
username?: string;
|
||||
share_token?: string;
|
||||
share_expires_at?: string;
|
||||
share_view_count?: number;
|
||||
share_max_views?: number;
|
||||
share_expired?: boolean;
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
export const recentResearchIds = writable<string[]>([]);
|
||||
|
||||
// ========================
|
||||
// Research-Specific Stores
|
||||
// ========================
|
||||
|
||||
/** Aktuell selektierte Research-ID */
|
||||
export const currentResearchId = writable<string | null>(null);
|
||||
|
||||
/** Aktuelle Research-Daten (wird durch Polling aktualisiert) */
|
||||
export const researchData = writable<any>(null);
|
||||
|
||||
/** Research-Historie (Sidebar-Liste) */
|
||||
export const researchHistory = writable<ResearchSummary[]>([]);
|
||||
|
||||
/** Polling-Intervall in Millisekunden */
|
||||
export const pollingInterval = writable<number>(5000);
|
||||
|
||||
/** Ob das Polling currently aktiv ist */
|
||||
export const pollingActive = writable<boolean>(false);
|
||||
|
||||
/** Letzter API-Fehler, falls vorhanden */
|
||||
export const lastApiError = writable<string | null>(null);
|
||||
|
||||
/** Share-Token für den aktuellen Research-Thread */
|
||||
export const shareToken = writable<string | null>(null);
|
||||
100
src/lib/utils/formatters.ts
Normal file
100
src/lib/utils/formatters.ts
Normal file
@@ -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<string, string> = {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user