mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 14:49:24 +02:00
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:
parent
30787e36d2
commit
e82851985b
18 changed files with 2221 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
255
apps/mana/apps/web/src/lib/api/research.ts
Normal file
255
apps/mana/apps/web/src/lib/api/research.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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))]">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue