feat(questions): deep-research module — mana-search + mana-llm pipeline

End-to-end deep-research feature for the questions module: a fire-and-
forget orchestrator in apps/api that plans sub-queries with mana-llm,
retrieves sources via mana-search (with optional Readability extraction),
and streams a structured synthesis back to the web app over SSE.

Backend (apps/api/src/modules/research):
- schema.ts: pgSchema('research') with research_results + sources
- orchestrator.ts: three-phase pipeline (plan / retrieve / synthesise)
  with depth-aware config (quick=1×, standard=3×, deep=6× sub-queries)
- pubsub.ts: in-process event bus, single-node, swappable for Redis
- routes.ts: POST /start (202, fire-and-forget), GET /:id/stream (SSE),
  POST /start-sync (test only), GET /:id, GET /:id/sources
- Credit gating via @mana/shared-hono/credits — validate up-front,
  consume best-effort on `done`. Failed runs cost nothing.

Helpers (apps/api/src/lib):
- llm.ts: llmJson() + llmStream() over mana-llm OpenAI-compat API
- search.ts: webSearch() + bulkExtract() over mana-search Go service
- responses.ts: shared errorResponse / listResponse / validationError

Schema deployment:
- drizzle.config.ts (research-scoped) + drizzle/research/0000_init.sql
  hand-authored migration, deployable via psql -f or drizzle-kit push.
- drizzle-kit added as devDep with db:generate / db:push scripts.

Web client (apps/mana/apps/web/src/lib/api/research.ts):
- Typed start() / get() / listSources() / streamProgress(). The stream
  uses fetch + ReadableStream (not EventSource) so we can attach the
  JWT via Authorization header. Special-cases 402 for friendly toast.
- New PUBLIC_MANA_API_URL plumbing in hooks.server.ts + config.ts.

Module store (modules/questions/stores/answers.svelte.ts):
- New write-side store with createManual / startResearch / accept /
  softDelete. startResearch creates an optimistic empty answer, opens
  the SSE stream, debounces token deltas in 100ms batches into the
  encrypted local row, and on `done` replaces the streamed text with
  the parsed { summary, keyPoints, followUps } payload + citations
  resolved against research.sources.id.

Citation rendering (modules/questions/components/AnswerCitations.svelte):
- Tokenises [n] markers in the answer body into clickable pills with
  hover popovers showing title / host / snippet / external link.
- Lazy-loaded via a session-scoped source cache (stores/sources.svelte.ts)
  that deduplicates concurrent fetches.

UI (routes/(app)/questions/[id]/+page.svelte):
- Recherche card with three-state button (start / cancel / re-run),
  animated phase indicator, source counter.
- Confirmation dialog warning about web/LLM transmission since the
  question itself is locally encrypted.
- Toasts for success / error / cancel via @mana/shared-ui/toast.
- Re-run flow soft-deletes prior research-driven answers but keeps
  manual ones intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 22:15:35 +02:00
parent 30787e36d2
commit e82851985b
18 changed files with 2221 additions and 4 deletions

View file

@ -43,6 +43,8 @@ const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
const PUBLIC_MANA_EVENTS_URL_CLIENT =
process.env.PUBLIC_MANA_EVENTS_URL_CLIENT || process.env.PUBLIC_MANA_EVENTS_URL || '';
const PUBLIC_MANA_API_URL_CLIENT =
process.env.PUBLIC_MANA_API_URL_CLIENT || process.env.PUBLIC_MANA_API_URL || '';
// Map of app subdomains to internal paths
const APP_SUBDOMAINS = new Set([
@ -92,6 +94,7 @@ window.__PUBLIC_ULOAD_SERVER_URL__ = ${JSON.stringify(PUBLIC_ULOAD_SERVER_URL_CL
window.__PUBLIC_MANA_MEDIA_URL__ = ${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};
window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};
window.__PUBLIC_MANA_EVENTS_URL__ = ${JSON.stringify(PUBLIC_MANA_EVENTS_URL_CLIENT)};
window.__PUBLIC_MANA_API_URL__ = ${JSON.stringify(PUBLIC_MANA_API_URL_CLIENT)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
@ -107,6 +110,7 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
PUBLIC_MANA_MEDIA_URL_CLIENT,
PUBLIC_MANA_LLM_URL_CLIENT,
PUBLIC_MANA_EVENTS_URL_CLIENT,
PUBLIC_MANA_API_URL_CLIENT,
'wss://sync.mana.how',
// @mana/local-llm (WebLLM) downloads model weights + config from
// the mlc-ai HuggingFace repos and the WebGPU model library WASM

View file

@ -33,3 +33,16 @@ export function getManaEventsUrl(): string {
}
return process.env.PUBLIC_MANA_EVENTS_URL || 'http://localhost:3065';
}
/**
* Get the unified mana-api URL (Hono/Bun, port 3060 in dev).
* Hosts module-specific compute endpoints under /api/v1/{module}/*.
*/
export function getManaApiUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_API_URL__?: string })
.__PUBLIC_MANA_API_URL__;
return injected || 'http://localhost:3060';
}
return process.env.PUBLIC_MANA_API_URL || 'http://localhost:3060';
}

View file

@ -0,0 +1,255 @@
/**
* Research API client talks to mana-api `/api/v1/research/*`.
*
* Backed by the unified deep-research pipeline (mana-search retrieval +
* mana-llm synthesis). See apps/api/src/modules/research/ for the server.
*
* The streaming endpoint uses `fetch` + a ReadableStream parser instead of
* `EventSource` because EventSource cannot send Authorization headers,
* and we don't want to leak the JWT into a query string.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from './config';
// ─── Types — mirror apps/api/src/modules/research/{schema,orchestrator}.ts
export type ResearchDepth = 'quick' | 'standard' | 'deep';
export type ResearchStatus =
| 'planning'
| 'searching'
| 'extracting'
| 'synthesizing'
| 'done'
| 'error';
export interface ResearchResult {
id: string;
userId: string;
questionId: string;
depth: ResearchDepth;
status: ResearchStatus;
subQueries: string[] | null;
summary: string | null;
keyPoints: string[] | null;
followUpQuestions: string[] | null;
errorMessage: string | null;
startedAt: string;
finishedAt: string | null;
}
export interface ResearchSource {
id: string;
researchResultId: string;
url: string;
title: string | null;
snippet: string | null;
extractedContent: string | null;
category: string | null;
rank: number;
createdAt: string;
}
export interface StartResearchInput {
questionId: string;
title: string;
description?: string;
depth: ResearchDepth;
}
/**
* Live progress events forwarded from the server pubsub. Mirrors the
* `ProgressEvent` union in apps/api/src/modules/research/orchestrator.ts.
*
* `snapshot` is a synthetic event the server emits once at the start of
* the SSE stream so late subscribers see the current DB state immediately.
*/
export type ResearchEvent =
| { type: 'snapshot'; snapshot: ResearchResult }
| { type: 'status'; status: 'planning' | 'searching' | 'extracting' | 'synthesizing' }
| { type: 'plan'; subQueries: string[] }
| { type: 'sources'; count: number }
| { type: 'token'; delta: string }
| { type: 'done'; researchResultId: string }
| { type: 'error'; message: string };
export class ResearchApiError extends Error {
constructor(
message: string,
public readonly status?: number
) {
super(message);
this.name = 'ResearchApiError';
}
}
// ─── Internal helpers ───────────────────────────────────────
async function authHeaders(extra: HeadersInit = {}): Promise<HeadersInit> {
const token = await authStore.getAccessToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extra,
};
}
async function jsonRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${getManaApiUrl()}${path}`, {
...init,
headers: await authHeaders(init.headers),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
// Special-case the structured errorResponse() body so the UI can
// show a friendly message for the most common failure modes.
if (res.status === 402) {
try {
const parsed = JSON.parse(body) as {
details?: { required?: number; available?: number };
};
const required = parsed.details?.required;
const available = parsed.details?.available;
if (required !== undefined) {
throw new ResearchApiError(
`Nicht genug Credits (benötigt: ${required}, verfügbar: ${available ?? 0})`,
402
);
}
} catch (err) {
if (err instanceof ResearchApiError) throw err;
// Fall through to generic message
}
throw new ResearchApiError('Nicht genug Credits für diese Recherche', 402);
}
throw new ResearchApiError(`mana-api ${path} returned ${res.status}: ${body}`, res.status);
}
return (await res.json()) as T;
}
// ─── Public API ─────────────────────────────────────────────
export const researchApi = {
/**
* Kick off a research run. Returns immediately with the new
* researchResultId the pipeline runs in the background.
*/
async start(input: StartResearchInput): Promise<{ researchResultId: string }> {
return jsonRequest('/api/v1/research/start', {
method: 'POST',
body: JSON.stringify(input),
});
},
/** Fetch a single research result row by id. */
async get(researchResultId: string): Promise<ResearchResult> {
return jsonRequest(`/api/v1/research/${researchResultId}`);
},
/** Fetch all sources consumed by a research run, ordered by rank. */
async listSources(researchResultId: string): Promise<ResearchSource[]> {
const body = await jsonRequest<{ items: ResearchSource[] } | ResearchSource[]>(
`/api/v1/research/${researchResultId}/sources`
);
// listResponse() in apps/api wraps results as { items, total } —
// fall back to a bare array for forward-compat.
if (Array.isArray(body)) return body;
return body.items ?? [];
},
/**
* Subscribe to live progress for a research run. Calls onEvent for
* each parsed SSE event. Returns a cleanup function that aborts the
* underlying fetch.
*
* Uses fetch+ReadableStream rather than EventSource so we can attach
* the JWT via Authorization header.
*/
streamProgress(researchResultId: string, onEvent: (event: ResearchEvent) => void): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await fetch(`${getManaApiUrl()}/api/v1/research/${researchResultId}/stream`, {
headers: await authHeaders({ Accept: 'text/event-stream' }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
onEvent({
type: 'error',
message: `Stream connect failed: ${res.status}`,
});
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by blank lines.
let sep: number;
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const parsed = parseSseFrame(frame);
if (parsed) onEvent(parsed);
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') return;
onEvent({
type: 'error',
message: (err as Error).message ?? 'stream failed',
});
}
})();
return () => controller.abort();
},
};
/**
* Parse a single SSE frame (one or more `event:` / `data:` lines) into a
* ResearchEvent. Returns null for keepalives, comments, or unknown shapes.
*
* The server always emits both an `event:` line (the type) and a `data:`
* line (JSON-encoded full event). The data field is the source of truth
* we use the event line only as a sanity check.
*/
function parseSseFrame(frame: string): ResearchEvent | null {
let dataLine = '';
let eventLine = '';
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) dataLine = line.slice(5).trim();
else if (line.startsWith('event:')) eventLine = line.slice(6).trim();
}
if (!dataLine) return null;
try {
const parsed = JSON.parse(dataLine) as Record<string, unknown>;
// snapshot uses a non-discriminated DB row shape — wrap it.
if (eventLine === 'snapshot') {
return { type: 'snapshot', snapshot: parsed as unknown as ResearchResult };
}
// All other events already carry their `type` field.
if (typeof parsed.type === 'string') {
return parsed as unknown as ResearchEvent;
}
} catch {
// malformed frame — ignore
}
return null;
}

View file

@ -0,0 +1,195 @@
<!--
AnswerCitations — render an answer body that contains [n]-style
citation tokens (where n is 1-indexed and maps to source.rank).
- Splits content into text/citation segments via a regex tokenizer
- Lazy-loads sources for `researchResultId` on first hover (session cache)
- Shows a small popover with title, snippet, host, and an external link
- Falls back to plain text rendering when there is no researchResultId
(manual answers don't have citations)
-->
<script lang="ts">
import { ArrowSquareOut } from '@mana/shared-icons';
import { loadSources } from '../stores/sources.svelte';
import type { ResearchSource } from '$lib/api/research';
type Props = {
content: string;
researchResultId: string | null;
};
let { content, researchResultId }: Props = $props();
let sources = $state<ResearchSource[]>([]);
let loaded = $state(false);
let loading = $state(false);
let loadError = $state<string | null>(null);
let hoveredRank = $state<number | null>(null);
// Tokenize once per content change. Citation tokens are matched as
// [n] surrounded by non-word characters or string boundaries so we
// don't accidentally pick up [foo] or markdown link labels.
type Segment = { kind: 'text'; text: string } | { kind: 'cite'; rank: number };
let segments = $derived<Segment[]>(tokenize(content));
function tokenize(input: string): Segment[] {
const result: Segment[] = [];
const re = /\[(\d+)\]/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(input)) !== null) {
if (match.index > last) {
result.push({ kind: 'text', text: input.slice(last, match.index) });
}
result.push({ kind: 'cite', rank: parseInt(match[1], 10) });
last = match.index + match[0].length;
}
if (last < input.length) {
result.push({ kind: 'text', text: input.slice(last) });
}
return result;
}
async function ensureLoaded() {
if (loaded || loading || !researchResultId) return;
loading = true;
try {
sources = await loadSources(researchResultId);
loaded = true;
} catch (err) {
loadError = (err as Error).message;
} finally {
loading = false;
}
}
function sourceForRank(rank: number): ResearchSource | undefined {
return sources.find((s) => s.rank === rank);
}
function hostFromUrl(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}
function showPopover(rank: number) {
hoveredRank = rank;
void ensureLoaded();
}
function hidePopover() {
hoveredRank = null;
}
</script>
<div class="answer-citations whitespace-pre-wrap text-[hsl(var(--foreground))]">
{#each segments as segment, i (i)}
{#if segment.kind === 'text'}{segment.text}{:else}
{@const src = sourceForRank(segment.rank)}
<span class="citation-wrap">
<button
type="button"
class="citation-pill"
onmouseenter={() => showPopover(segment.rank)}
onmouseleave={hidePopover}
onfocus={() => showPopover(segment.rank)}
onblur={hidePopover}
aria-label="Quelle {segment.rank}"
>
[{segment.rank}]
</button>
{#if hoveredRank === segment.rank}
<span
class="citation-popover"
role="tooltip"
onmouseenter={() => showPopover(segment.rank)}
onmouseleave={hidePopover}
>
{#if loading && !src}
<span class="text-xs text-[hsl(var(--muted-foreground))]">Lade Quelle…</span>
{:else if loadError}
<span class="text-xs text-red-500">Fehler: {loadError}</span>
{:else if src}
<span class="block text-xs font-semibold text-[hsl(var(--foreground))]">
{src.title ?? hostFromUrl(src.url)}
</span>
<span class="mt-1 block text-[10px] uppercase text-[hsl(var(--muted-foreground))]">
{hostFromUrl(src.url)}
</span>
{#if src.snippet}
<span class="mt-2 block text-xs text-[hsl(var(--muted-foreground))]">
{src.snippet}
</span>
{/if}
<a
href={src.url}
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-flex items-center gap-1 text-xs text-[hsl(var(--primary))] hover:underline"
>
Öffnen <ArrowSquareOut class="h-3 w-3" />
</a>
{:else if loaded}
<span class="text-xs text-[hsl(var(--muted-foreground))]">
Quelle {segment.rank} nicht gefunden
</span>
{/if}
</span>
{/if}
</span>
{/if}
{/each}
</div>
<style>
.citation-wrap {
position: relative;
display: inline;
}
.citation-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4em;
padding: 0 0.35em;
margin: 0 0.1em;
border-radius: 9999px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
font-size: 0.7em;
font-weight: 600;
line-height: 1.5;
vertical-align: super;
cursor: help;
transition: background 120ms ease;
border: none;
}
.citation-pill:hover,
.citation-pill:focus-visible {
background: hsl(var(--primary) / 0.3);
outline: none;
}
.citation-popover {
position: absolute;
bottom: calc(100% + 0.4rem);
left: 50%;
transform: translateX(-50%);
z-index: 50;
display: block;
width: 18rem;
max-width: calc(100vw - 2rem);
padding: 0.75rem 0.85rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--border));
background: hsl(var(--card));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
text-align: left;
white-space: normal;
}
</style>

View file

@ -0,0 +1,265 @@
/**
* Answers store write-side mutations for question answers.
*
* Per the apps/mana/CLAUDE.md module pattern, reads happen in queries.ts;
* this file only mutates. Two flavours of answer creation:
*
* createManual() user types an answer themselves. Plain Dexie write,
* encrypted before persist.
*
* startResearch() kicks off the deep-research pipeline against
* mana-api, creates an optimistic empty answer row,
* and streams synthesis tokens into it as they arrive.
* Marks the question as 'researching' for the duration.
*
* Encryption note: every write goes through encryptRecord('answers', )
* because the `answers` table is in the crypto registry. The token-stream
* path decrypts appends re-encrypts on each tick. That's wasteful for
* very chatty streams but keeps invariants simple, and synthesis output
* runs at LLM speed, not keystroke speed.
*/
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { researchApi, type ResearchEvent, type ResearchSource } from '$lib/api/research';
import type { LocalAnswer, LocalQuestion } from '../types';
// ─── Manual answer creation ─────────────────────────────────
export interface CreateManualAnswerInput {
questionId: string;
content: string;
}
async function createManual(input: CreateManualAnswerInput): Promise<string> {
const now = new Date().toISOString();
const id = crypto.randomUUID();
const row: Record<string, unknown> = {
id,
questionId: input.questionId,
researchResultId: null,
content: input.content,
citations: [],
rating: null,
isAccepted: false,
createdAt: now,
updatedAt: now,
};
await encryptRecord('answers', row);
await db.table('answers').add(row);
return id;
}
// ─── Research-driven answer ─────────────────────────────────
export interface ResearchHandle {
answerId: string;
researchResultId: string;
/** Cancel the SSE subscription. Does NOT cancel the server-side run. */
cancel: () => void;
}
export interface StartResearchOptions {
question: LocalQuestion;
/** Optional progress callback for the UI (phase indicator etc.). */
onEvent?: (event: ResearchEvent) => void;
}
/**
* Start a research run for `question`. Creates an optimistic empty answer
* locally, opens an SSE stream to mana-api, and appends each streamed
* token into the answer row. When the run completes (`done`), the row is
* finalised with citations and the question is flipped to 'answered'.
*
* Failures flip the question back to 'open' and surface the error message
* via onEvent. The optimistic answer row is left in place so the user can
* see what was produced before things went sideways.
*/
async function startResearch(opts: StartResearchOptions): Promise<ResearchHandle> {
const { question, onEvent } = opts;
// 1. Mark the question as researching so the UI flips immediately.
await db.table('questions').update(question.id, {
status: 'researching',
updatedAt: new Date().toISOString(),
});
// 2. Kick off the server-side pipeline.
const { researchResultId } = await researchApi.start({
questionId: question.id,
title: question.title,
description: question.description ?? undefined,
depth: question.researchDepth,
});
// 3. Create the optimistic, empty answer row that the stream will fill in.
const answerId = crypto.randomUUID();
const now = new Date().toISOString();
const draft: Record<string, unknown> = {
id: answerId,
questionId: question.id,
researchResultId,
content: '',
citations: [],
rating: null,
isAccepted: false,
createdAt: now,
updatedAt: now,
};
await encryptRecord('answers', draft);
await db.table('answers').add(draft);
// 4. Subscribe to SSE. Buffer streamed tokens locally and flush them
// in small batches to avoid encrypting on every single token.
let pendingDelta = '';
let flushScheduled = false;
const flush = async () => {
flushScheduled = false;
if (!pendingDelta) return;
const delta = pendingDelta;
pendingDelta = '';
const existing = (await db.table<LocalAnswer>('answers').get(answerId)) as
| LocalAnswer
| undefined;
if (!existing) return;
const decrypted = (await decryptRecord('answers', { ...existing })) as LocalAnswer;
const updated: Record<string, unknown> = {
content: (decrypted.content ?? '') + delta,
updatedAt: new Date().toISOString(),
};
await encryptRecord('answers', updated);
await db.table('answers').update(answerId, updated);
};
const scheduleFlush = () => {
if (flushScheduled) return;
flushScheduled = true;
// 100ms debounce — synthesis output is fast enough that batching
// makes a real difference, slow enough that the UI still feels live.
setTimeout(() => {
void flush();
}, 100);
};
const cancel = researchApi.streamProgress(researchResultId, async (event) => {
onEvent?.(event);
switch (event.type) {
case 'token': {
pendingDelta += event.delta;
scheduleFlush();
break;
}
case 'done': {
await flush();
await finaliseAnswer(answerId, researchResultId, question.id);
cancel();
break;
}
case 'error': {
await flush();
await db.table('questions').update(question.id, {
status: 'open',
updatedAt: new Date().toISOString(),
});
cancel();
break;
}
}
});
return { answerId, researchResultId, cancel };
}
/**
* Replace the streamed-in raw text with the structured server-side
* payload (parsed summary) and attach citations resolved from the
* server-side sources table. Flips the parent question to 'answered'.
*/
async function finaliseAnswer(
answerId: string,
researchResultId: string,
questionId: string
): Promise<void> {
let result;
let sources: ResearchSource[];
try {
[result, sources] = await Promise.all([
researchApi.get(researchResultId),
researchApi.listSources(researchResultId),
]);
} catch (err) {
console.error('[answers] failed to finalise research answer:', err);
return;
}
// Build the final content from the structured payload. We prefer the
// server-side parsed summary over the raw streamed tokens because the
// stream may contain JSON scaffolding (`{ "summary": "...`) that
// shouldn't be shown to the user.
const parts: string[] = [];
if (result.summary) parts.push(result.summary);
if (result.keyPoints && result.keyPoints.length > 0) {
parts.push('', '**Kernpunkte:**', ...result.keyPoints.map((k) => `- ${k}`));
}
if (result.followUpQuestions && result.followUpQuestions.length > 0) {
parts.push('', '**Weiterführende Fragen:**', ...result.followUpQuestions.map((q) => `- ${q}`));
}
const content = parts.join('\n');
// Citations[n].sourceId points at the server-side source UUID with rank n.
const citations = sources.map((s) => ({
sourceId: s.id,
text: s.title ?? s.url,
}));
const update: Record<string, unknown> = {
content,
citations,
updatedAt: new Date().toISOString(),
};
await encryptRecord('answers', update);
await db.table('answers').update(answerId, update);
await db.table('questions').update(questionId, {
status: 'answered',
updatedAt: new Date().toISOString(),
});
}
// ─── Other mutations (acceptance / deletion) ────────────────
async function accept(answerId: string, questionId: string): Promise<void> {
const all = (await db.table<LocalAnswer>('answers').toArray()).filter(
(a) => a.questionId === questionId && !a.deletedAt
);
for (const a of all) {
if (a.isAccepted) {
await db.table('answers').update(a.id, {
isAccepted: false,
updatedAt: new Date().toISOString(),
});
}
}
await db.table('answers').update(answerId, {
isAccepted: true,
updatedAt: new Date().toISOString(),
});
}
async function softDelete(answerId: string): Promise<void> {
await db.table('answers').update(answerId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
export const answersStore = {
createManual,
startResearch,
accept,
softDelete,
};

View file

@ -0,0 +1,49 @@
/**
* Session-scoped cache for research sources.
*
* Sources live exclusively on the server (research.sources table) they
* are public web content and we deliberately don't sync them into
* IndexedDB. The downside: every time the user opens an answer with
* citations we'd otherwise re-hit /api/v1/research/:id/sources.
*
* This little store keeps the result in memory for the lifetime of the
* tab (no persistence) and de-duplicates concurrent fetches so opening
* three citation popovers in a row only triggers one network round-trip.
*/
import { researchApi, type ResearchSource } from '$lib/api/research';
const cache = new Map<string, ResearchSource[]>();
const inFlight = new Map<string, Promise<ResearchSource[]>>();
/**
* Fetch (or return cached) sources for a research run. Concurrent calls
* for the same id share the same underlying fetch.
*/
export async function loadSources(researchResultId: string): Promise<ResearchSource[]> {
const cached = cache.get(researchResultId);
if (cached) return cached;
const pending = inFlight.get(researchResultId);
if (pending) return pending;
const promise = researchApi
.listSources(researchResultId)
.then((sources) => {
cache.set(researchResultId, sources);
inFlight.delete(researchResultId);
return sources;
})
.catch((err) => {
inFlight.delete(researchResultId);
throw err;
});
inFlight.set(researchResultId, promise);
return promise;
}
/** Drop a single research run from the cache (e.g. after re-running). */
export function invalidateSources(researchResultId: string): void {
cache.delete(researchResultId);
}

View file

@ -9,6 +9,11 @@
getQuestionById,
} from '$lib/modules/questions/queries';
import type { Question, Answer } from '$lib/modules/questions/queries';
import { answersStore } from '$lib/modules/questions/stores/answers.svelte';
import AnswerCitations from '$lib/modules/questions/components/AnswerCitations.svelte';
import type { LocalQuestion } from '$lib/modules/questions/types';
import type { ResearchEvent } from '$lib/api/research';
import { toastStore } from '@mana/shared-ui/toast';
import {
ArrowLeft,
Clock,
@ -17,6 +22,8 @@
Archive,
PencilSimple,
Trash,
MagnifyingGlass,
ArrowCounterClockwise,
} from '@mana/shared-icons';
const allQuestions = useAllQuestions();
@ -34,6 +41,92 @@
let newAnswer = $state('');
let savingAnswer = $state(false);
// ─── Deep-research state ─────────────────────────────────
let researchHandle = $state<{ cancel: () => void } | null>(null);
let researchPhase = $state<string | null>(null);
let researchSourceCount = $state<number | null>(null);
const phaseLabels: Record<string, string> = {
planning: 'Plane Recherche…',
searching: 'Suche im Web…',
extracting: 'Lese Quellen…',
synthesizing: 'Schreibe Antwort…',
};
function resetResearchState() {
researchPhase = null;
researchSourceCount = null;
researchHandle = null;
}
function handleResearchEvent(event: ResearchEvent) {
switch (event.type) {
case 'snapshot':
if (event.snapshot.status !== 'done' && event.snapshot.status !== 'error') {
researchPhase = event.snapshot.status;
}
break;
case 'status':
researchPhase = event.status;
break;
case 'sources':
researchSourceCount = event.count;
break;
case 'done':
resetResearchState();
toastStore.success('Recherche abgeschlossen');
break;
case 'error':
resetResearchState();
toastStore.error(`Recherche fehlgeschlagen: ${event.message}`);
break;
}
}
async function startResearchRun() {
if (!question || researchHandle) return;
const confirmed = confirm(
'Diese Frage wird an Web-Suchmaschinen und LLM-Anbieter übermittelt. Lokale Verschlüsselung gilt nur für die Speicherung auf diesem Gerät. Recherche starten?'
);
if (!confirmed) return;
try {
researchHandle = await answersStore.startResearch({
question: question as unknown as LocalQuestion,
onEvent: handleResearchEvent,
});
} catch (err) {
researchHandle = null;
toastStore.error(`Recherche konnte nicht gestartet werden: ${(err as Error).message}`);
}
}
/**
* Re-run research for a question that already has an answer. Soft-deletes
* any prior research-driven answers (manual ones are kept) and kicks off
* a fresh pipeline. Old sources stay on the server but are no longer
* referenced from the local store.
*/
async function rerunResearch() {
if (!question || researchHandle) return;
const confirmed = confirm(
'Vorherige Recherche-Antworten werden in den Papierkorb verschoben. Erneut recherchieren?'
);
if (!confirmed) return;
const previous = (answers as Answer[]).filter((a) => a.researchResultId);
for (const a of previous) {
await answersStore.softDelete(a.id);
}
await startResearchRun();
}
function cancelResearch() {
researchHandle?.cancel();
resetResearchState();
toastStore.info('Recherche-Stream beendet');
}
const statusLabels: Record<string, { label: string; color: string }> = {
open: {
label: 'Offen',
@ -287,6 +380,58 @@
{/each}
</div>
<!-- Deep Research -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h3 class="text-sm font-semibold text-[hsl(var(--foreground))]">Recherche</h3>
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{#if question.researchDepth === 'quick'}
Schnell · 5 Quellen · keine Volltext-Extraktion
{:else if question.researchDepth === 'standard'}
Standard · bis zu 15 Quellen · mit Volltext-Extraktion
{:else}
Tiefgehend · bis zu 30 Quellen · alle Kategorien
{/if}
</p>
</div>
{#if researchHandle}
<button
onclick={cancelResearch}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
Stream beenden
</button>
{:else if (answers as Answer[]).some((a) => a.researchResultId)}
<button
onclick={rerunResearch}
class="inline-flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm font-medium text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
<ArrowCounterClockwise class="h-4 w-4" />
Erneut recherchieren
</button>
{:else}
<button
onclick={startResearchRun}
class="inline-flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-3 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<MagnifyingGlass class="h-4 w-4" />
Recherche starten
</button>
{/if}
</div>
{#if researchPhase}
<div class="mt-3 flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]">
<CircleNotch class="h-4 w-4 animate-spin" />
<span>{phaseLabels[researchPhase] ?? researchPhase}</span>
{#if researchSourceCount !== null}
<span class="text-xs">· {researchSourceCount} Quellen</span>
{/if}
</div>
{/if}
</div>
<!-- Answers -->
<div class="space-y-4">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
@ -315,9 +460,10 @@
</div>
{/if}
<div class="whitespace-pre-wrap text-[hsl(var(--foreground))]">
{answer.content}
</div>
<AnswerCitations
content={answer.content}
researchResultId={answer.researchResultId ?? null}
/>
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-[hsl(var(--muted-foreground))]">