mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(notes): isSpaceContext flag replaces kontext module (Option B)
Retire the kontext module entirely; the per-Space standing-context
document is now a regular Note flagged with `isSpaceContext: true`.
Daily use ("URL → Notiz") moves to the notes module as a first-class
action; the same primitive is reused by the (planned) Brand/Firma-Space
onboarding wizard to seed a Space-context Note from a URL.
Why: kontext was inconsistent — its UI was a URL-crawler that wrote
to userContext.freeform (profile module), while its kontextDoc table
+ AI-Mission-Runner auto-injection was a write-only shell with no
real editor. One concept (Notes) now carries both ad-hoc noting and
Space-context, with mutex (max 1 flagged Note per Space).
Notes module:
- types: add `isSpaceContext?: boolean` to LocalNote + Note
- queries: add `useSpaceContextNote()` (the active Space's flagged note)
- store: `markAsSpaceContext(id | null)` with mutex sweep across Space
- ListView: "Aus URL importieren" inline form (URL + crawl-mode +
KI-Zusammenfassung toggle); "Als Space-Kontext markieren" /
"Space-Kontext lösen" context-menu item; ★-Badge on flagged notes
- new api.ts: `crawlUrl()` client for POST /api/v1/notes/import-url
Notes API (apps/api):
- new modules/notes/routes.ts with /import-url (ported from kontext;
same crawler + LLM summary pipeline, NOTES_IMPORT_URL credit op)
- mount at /api/v1/notes; add 'notes' to RESOURCE_MODULES (beta+ tier)
- delete modules/context (UI-less /ai/generate + /ai/estimate had no
consumers; /import-url moved to notes)
- packages/credits: rename AI_CONTEXT_GENERATION → NOTES_IMPORT_URL
AI Mission Runner:
- default-resolvers: drop kontextResolver + kontextIndexer; the
notesIndexer flags `isSpaceContext` notes with "★ " prefix and
bubbles them to the top of the picker
- writing reference-resolver: `kind: 'kontext'` now reads the flagged
Note via scope-scan instead of the kontextDoc table; tests updated
- writing ReferencePicker: useSpaceContextNote replaces useKontextDoc
- AiDebugBlock + MissionGrantDialog + ai-missions ListView: drop
'kontextDoc' from ENCRYPTED_SERVER_TABLES set
- ai-agents ListView: drop 'kontext' from POLICY_MODULES
Profile module:
- ContextFreeform.svelte: switch import from kontext/api to notes/api
(the URL-crawl is the same primitive; it still writes to
userContext.freeform — only the import path changed)
Dexie:
- v58: notes index gains `isSpaceContext`; kontextDoc table dropped
Kontext module deletion:
- delete apps/mana/apps/web/src/lib/modules/kontext/ entirely
- delete (app)/kontext/ route
- drop registerApp + Scroll icon from app-registry/apps.ts
- drop kontext entry from help-content
- drop kontextModuleConfig from data/module-registry.ts
- drop kontextDoc from crypto registry
mana-auth:
- bootstrap-singletons: drop bootstrapSpaceSingletons function entirely
(kontextDoc was the only per-Space singleton); userContext bootstrap
unchanged
- better-auth.config: drop kontextDoc bootstrap call from personal-space
hook + organizationHooks.afterCreateOrganization
- me-bootstrap: drop per-space bootstrap loop; response shape kept
(always-empty `spaces: {}`) for backwards-compat with older clients
Note: the still-existing legacy `context` module (CMS-style docs/spaces,
unrelated to kontext) is left in place; its cleanup landed on the
articles-bulk-import branch and is out of scope for this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
054b9e5beb
commit
8fbdc6db77
37 changed files with 496 additions and 983 deletions
|
|
@ -68,7 +68,6 @@ import {
|
|||
Question,
|
||||
ChatCircleDots,
|
||||
SquaresFour,
|
||||
Scroll,
|
||||
Spiral,
|
||||
Crown,
|
||||
ShootingStar,
|
||||
|
|
@ -576,16 +575,6 @@ registerApp({
|
|||
paramKey: 'conversationId',
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'kontext',
|
||||
name: 'Web-Context',
|
||||
color: '#A78B6F',
|
||||
icon: Scroll,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/kontext/KontextView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'times',
|
||||
name: 'Times',
|
||||
|
|
|
|||
|
|
@ -175,18 +175,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
|||
'Nutze Vorlagen für wiederkehrende Aufgaben',
|
||||
],
|
||||
},
|
||||
kontext: {
|
||||
description: 'Persönliches Markdown-Dokument das der AI als Hintergrundwissen mitgegeben wird.',
|
||||
features: [
|
||||
'Freitext-Markdown',
|
||||
'Wird automatisch in AI-Missionen als Kontext injiziert',
|
||||
'Pro Agent individuell konfigurierbar',
|
||||
],
|
||||
tips: [
|
||||
'Schreibe hier Dinge die die AI über dich wissen sollte: Vorlieben, Arbeitsweise, Projekte',
|
||||
'Jeder Agent kann ein eigenes Kontext-Dokument haben',
|
||||
],
|
||||
},
|
||||
context: {
|
||||
description:
|
||||
'Strukturiertes Profil — Interessen, Tagesablauf, Ziele, Ernährung. Hilft der AI dich besser zu verstehen.',
|
||||
|
|
|
|||
|
|
@ -36,13 +36,7 @@
|
|||
* sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission
|
||||
* referencing any of these tables triggers the dialog.
|
||||
*/
|
||||
const ENCRYPTED_SERVER_TABLES = new Set([
|
||||
'notes',
|
||||
'tasks',
|
||||
'events',
|
||||
'journalEntries',
|
||||
'kontextDoc',
|
||||
]);
|
||||
const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
|
||||
|
||||
interface Props {
|
||||
/** Mission to issue the grant for. Required — the dialog reads
|
||||
|
|
|
|||
|
|
@ -2,14 +2,19 @@
|
|||
* Default input resolvers.
|
||||
*
|
||||
* Registered from `setup.ts` so the production MissionRunner can load
|
||||
* notes / kontext / goals without every module having to know about the
|
||||
* AI subsystem. Modules that need special projection logic register their
|
||||
* own resolver on init and override these defaults.
|
||||
* notes / goals / profile / todo / calendar without every module having
|
||||
* to know about the AI subsystem. Modules that need special projection
|
||||
* logic register their own resolver on init and override these defaults.
|
||||
*
|
||||
* Space-Kontext: since the kontextDoc table was retired in favour of a
|
||||
* `notes.isSpaceContext` flag, the "this is the standing context for
|
||||
* this Space" candidate is just a regular Note marked with the flag.
|
||||
* The notesResolver handles it like any other note; the notesIndexer
|
||||
* surfaces it with a star prefix so the picker bubbles it to the top.
|
||||
*/
|
||||
|
||||
import { db } from '../../database';
|
||||
import { decryptRecords } from '../../crypto';
|
||||
import { scopedTable } from '../../scope/scoped-db';
|
||||
import { registerInputResolver } from './input-resolvers';
|
||||
import { registerInputIndexer } from './input-index';
|
||||
import type { InputResolver } from './input-resolvers';
|
||||
|
|
@ -20,6 +25,7 @@ interface NoteLike {
|
|||
title?: string;
|
||||
content?: string;
|
||||
deletedAt?: string;
|
||||
isSpaceContext?: boolean;
|
||||
}
|
||||
|
||||
const notesResolver: InputResolver = async (ref) => {
|
||||
|
|
@ -35,24 +41,6 @@ const notesResolver: InputResolver = async (ref) => {
|
|||
};
|
||||
};
|
||||
|
||||
interface KontextDocLike {
|
||||
id: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const kontextResolver: InputResolver = async (ref) => {
|
||||
const doc = await db.table<KontextDocLike>('kontextDoc').get(ref.id);
|
||||
if (!doc) return null;
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [doc]);
|
||||
return {
|
||||
id: ref.id,
|
||||
module: ref.module,
|
||||
table: ref.table,
|
||||
title: 'Kontext',
|
||||
content: decrypted.content ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
// ── User Context (structured profile + freeform) ──────────
|
||||
|
||||
interface UserContextLike {
|
||||
|
|
@ -155,35 +143,25 @@ const notesIndexer: InputIndexer = async () => {
|
|||
const all = await db.table<NoteLike>('notes').toArray();
|
||||
const visible = all.filter((n) => !n.deletedAt);
|
||||
const decrypted = await decryptRecords('notes', visible);
|
||||
return decrypted
|
||||
.map<InputCandidate>((n) => ({
|
||||
module: 'notes',
|
||||
table: 'notes',
|
||||
id: n.id,
|
||||
label: (n.title && n.title.trim()) || '(ohne Titel)',
|
||||
hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` : undefined,
|
||||
}))
|
||||
.slice(0, 200); // cap — Mission picker isn't meant to list thousands
|
||||
};
|
||||
|
||||
const kontextIndexer: InputIndexer = async () => {
|
||||
// Per-Space since Phase 2d.2: the kontextDoc for the active Space is
|
||||
// the only candidate we surface to the picker. Personal-Space's legacy
|
||||
// singleton row is matched via the `_personal:<userId>` sentinel in
|
||||
// scopedTable's getInScopeSpaceIds(); Shared/Brand/Family Spaces that
|
||||
// haven't yet authored a kontextDoc simply return an empty list.
|
||||
const rows = await scopedTable<KontextDocLike, string>('kontextDoc').toArray();
|
||||
const match = rows[0];
|
||||
if (!match) return [];
|
||||
return [
|
||||
{
|
||||
module: 'kontext',
|
||||
table: 'kontextDoc',
|
||||
id: match.id,
|
||||
label: 'Kontext-Dokument',
|
||||
hint: 'Dein zentrales Markdown-Dokument für diesen Space',
|
||||
},
|
||||
];
|
||||
const candidates = decrypted.map<InputCandidate>((n) => ({
|
||||
module: 'notes',
|
||||
table: 'notes',
|
||||
id: n.id,
|
||||
label: (n.isSpaceContext ? '★ ' : '') + ((n.title && n.title.trim()) || '(ohne Titel)'),
|
||||
hint: n.isSpaceContext
|
||||
? 'Space-Kontext (auto-injected)'
|
||||
: n.content
|
||||
? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…`
|
||||
: undefined,
|
||||
}));
|
||||
// Sort: space-context-flagged notes first, then alphabetical.
|
||||
candidates.sort((a, b) => {
|
||||
const aFirst = a.label.startsWith('★ ');
|
||||
const bFirst = b.label.startsWith('★ ');
|
||||
if (aFirst !== bFirst) return aFirst ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return candidates.slice(0, 200); // cap — Mission picker isn't meant to list thousands
|
||||
};
|
||||
|
||||
const goalsIndexer: InputIndexer = async () => {
|
||||
|
|
@ -303,13 +281,11 @@ let registered = false;
|
|||
export function registerDefaultInputResolvers(): void {
|
||||
if (registered) return;
|
||||
registerInputResolver('notes', notesResolver);
|
||||
registerInputResolver('kontext', kontextResolver);
|
||||
registerInputResolver('profile', userContextResolver);
|
||||
registerInputResolver('goals', goalsResolver);
|
||||
registerInputResolver('todo', tasksResolver);
|
||||
registerInputResolver('calendar', calendarResolver);
|
||||
registerInputIndexer('notes', notesIndexer);
|
||||
registerInputIndexer('kontext', kontextIndexer);
|
||||
registerInputIndexer('profile', userContextIndexer);
|
||||
registerInputIndexer('goals', goalsIndexer);
|
||||
registerInputIndexer('todo', tasksIndexer);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
*
|
||||
* Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated
|
||||
* boot. The server-side endpoint provisions any missing per-user
|
||||
* (`userContext`) and per-Space (`kontextDoc`) singletons in
|
||||
* `mana_sync.sync_changes`. Idempotent — a second call is a no-op.
|
||||
* `userContext` singleton in `mana_sync.sync_changes`. Idempotent — a
|
||||
* second call is a no-op.
|
||||
*
|
||||
* Why call it on boot when the signup-time hooks already do this work:
|
||||
* the hooks are fire-and-forget and a transient mana_sync outage during
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
* the first write.
|
||||
*
|
||||
* Best-effort: failures are swallowed and logged. The webapp's
|
||||
* fallback paths (`getOrCreateLocalDoc()` in `userContextStore` /
|
||||
* `kontextStore`) still cover the rare race where a write happens
|
||||
* before the bootstrap row arrives.
|
||||
* fallback path (`getOrCreateLocalDoc()` in `userContextStore`) still
|
||||
* covers the rare race where a write happens before the bootstrap row
|
||||
* arrives.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
|
|
|||
|
|
@ -559,9 +559,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
|
||||
moodSettings: { enabled: false, fields: [] },
|
||||
|
||||
// ─── Kontext (legacy — now web-context, URL-crawl only) ──
|
||||
kontextDoc: { enabled: true, fields: ['content'] },
|
||||
|
||||
// ─── User Context (profile hub) ──────────────────────────
|
||||
// Structured profile sections + freeform markdown. Everything
|
||||
// except the fixed id and interview progress is user-typed content.
|
||||
|
|
@ -690,8 +687,9 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
'livingOracleSnapshot',
|
||||
]),
|
||||
|
||||
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
||||
// per agent. Content is free-form markdown.
|
||||
// Per-agent kontext documents — free-form markdown, keyed per agent.
|
||||
// Distinct from the (retired) per-Space kontextDoc; this one stays
|
||||
// because per-agent context is still injected by the persona-runner.
|
||||
agentKontextDocs: { enabled: true, fields: ['content'] },
|
||||
|
||||
// ─── Quiz ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1445,6 +1445,18 @@ db.version(57).stores({
|
|||
formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]',
|
||||
});
|
||||
|
||||
// v58 — Replace the per-Space `kontextDoc` singleton with a flagged
|
||||
// Note. The `notes` table gets an `isSpaceContext` index so the AI
|
||||
// Mission Runner's resolver can find the flagged row without scanning
|
||||
// every note; the kontextDoc table is dropped entirely (the kontext
|
||||
// module's UI was a write-only shell, the table was never edit-able
|
||||
// from a real surface). Mutex (max 1 flagged note per Space) is
|
||||
// enforced by `notesStore.markAsSpaceContext`, not by Dexie.
|
||||
db.version(58).stores({
|
||||
notes: 'id, isPinned, isArchived, isSpaceContext, color, title, _updatedAtIndex',
|
||||
kontextDoc: null,
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ import { mailModuleConfig } from '$lib/modules/mail/module.config';
|
|||
import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
|
||||
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
|
||||
import { moodModuleConfig } from '$lib/modules/mood/module.config';
|
||||
import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
|
||||
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
||||
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||
|
|
@ -158,7 +157,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
meditateModuleConfig,
|
||||
sleepModuleConfig,
|
||||
moodModuleConfig,
|
||||
kontextModuleConfig,
|
||||
quizModuleConfig,
|
||||
profileModuleConfig,
|
||||
libraryModuleConfig,
|
||||
|
|
|
|||
|
|
@ -763,8 +763,8 @@ describe('applyServerChanges (Dexie integration)', () => {
|
|||
|
||||
it('bootstrap-twin race: local SYSTEM_BOOTSTRAP row + later server insert → no conflict, LWW wins', async () => {
|
||||
// 1. Client-side fallback creates an empty row stamped origin='system'.
|
||||
// This is what `getOrCreateLocalDoc()` does in userContextStore /
|
||||
// kontextStore when a write lands before the first sync pull.
|
||||
// This is what `getOrCreateLocalDoc()` does in userContextStore
|
||||
// when a write lands before the first sync pull.
|
||||
const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP);
|
||||
await runAsAsync(bootstrapActor, async () => {
|
||||
await db.table('tasks').add({
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@
|
|||
}
|
||||
|
||||
// ── Policy editor ───────────────────────────────────────
|
||||
const POLICY_MODULES = ['todo', 'calendar', 'notes', 'kontext', 'finance', 'drink', 'food'];
|
||||
const POLICY_MODULES = ['todo', 'calendar', 'notes', 'finance', 'drink', 'food'];
|
||||
const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny'];
|
||||
function policyLabel(c: PolicyDecision): string {
|
||||
return $_('ai-agents.list_view.policy_label_' + c);
|
||||
|
|
|
|||
|
|
@ -113,13 +113,7 @@
|
|||
}
|
||||
|
||||
// ── Key-Grant (server-side execution) ──────────────────
|
||||
const ENCRYPTED_SERVER_TABLES = new Set([
|
||||
'notes',
|
||||
'tasks',
|
||||
'events',
|
||||
'journalEntries',
|
||||
'kontextDoc',
|
||||
]);
|
||||
const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
|
||||
function hasEncryptedInputs(m: Mission): boolean {
|
||||
return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,360 +0,0 @@
|
|||
<!--
|
||||
Web-Context — URL crawler tool.
|
||||
Crawls web pages and appends the content to the user's profile freeform context.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { LinkSimple, X } from '@mana/shared-icons';
|
||||
import { crawlUrlViaApi, type CrawlMode } from './api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
import { userContextStore } from '$lib/modules/profile/stores/user-context.svelte';
|
||||
|
||||
let importUrl = $state('');
|
||||
let importMode = $state<CrawlMode>('single');
|
||||
let importSummarize = $state(false);
|
||||
let importing = $state(false);
|
||||
let importPhase = $state<'idle' | 'crawling' | 'summarizing' | 'appending'>('idle');
|
||||
let importElapsed = $state(0);
|
||||
let importError = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
let importPhases = $derived.by(() => {
|
||||
const steps: Array<{ key: 'crawling' | 'summarizing' | 'appending'; label: string }> = [
|
||||
{
|
||||
key: 'crawling',
|
||||
label: importMode === 'deep' ? 'Website crawlen (bis 20 Seiten)' : 'Seite laden',
|
||||
},
|
||||
...(importSummarize ? [{ key: 'summarizing' as const, label: 'Mit KI zusammenfassen' }] : []),
|
||||
{ key: 'appending', label: 'In Profil-Kontext speichern' },
|
||||
];
|
||||
const order = steps.map((s) => s.key);
|
||||
const currentIdx = order.indexOf(importPhase as never);
|
||||
return steps.map((s, i) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
active: currentIdx === i,
|
||||
done: currentIdx > i,
|
||||
}));
|
||||
});
|
||||
|
||||
function reset() {
|
||||
importUrl = '';
|
||||
importMode = 'single';
|
||||
importSummarize = false;
|
||||
importPhase = 'idle';
|
||||
importElapsed = 0;
|
||||
importError = null;
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
e.preventDefault();
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await requireAuth({
|
||||
feature: 'web-context-import',
|
||||
reason:
|
||||
'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.',
|
||||
});
|
||||
if (!ok) return;
|
||||
importing = true;
|
||||
importError = null;
|
||||
successMessage = null;
|
||||
importPhase = 'crawling';
|
||||
importElapsed = 0;
|
||||
const started = performance.now();
|
||||
const tick = setInterval(() => {
|
||||
importElapsed = Math.floor((performance.now() - started) / 1000);
|
||||
}, 250);
|
||||
let phaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (importSummarize) {
|
||||
const crawlBudgetMs = importMode === 'deep' ? 25_000 : 4_000;
|
||||
phaseTimer = setTimeout(() => {
|
||||
if (importing) importPhase = 'summarizing';
|
||||
}, crawlBudgetMs);
|
||||
}
|
||||
try {
|
||||
const result = await crawlUrlViaApi({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
});
|
||||
if (phaseTimer) clearTimeout(phaseTimer);
|
||||
importPhase = 'appending';
|
||||
const header = `## ${result.title}\n\n_Quelle: ${result.sourceUrl}_\n\n`;
|
||||
await userContextStore.appendFreeform(header + result.content);
|
||||
successMessage = `"${result.title}" wurde deinem Profil-Kontext hinzugefügt (${result.pageCount} Seite${result.pageCount > 1 ? 'n' : ''})`;
|
||||
reset();
|
||||
} catch (err) {
|
||||
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
|
||||
} finally {
|
||||
if (phaseTimer) clearTimeout(phaseTimer);
|
||||
clearInterval(tick);
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-view">
|
||||
<header class="header">
|
||||
<div class="header-icon">
|
||||
<LinkSimple size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="title">Web-Context</h2>
|
||||
<p class="subtitle">Crawle Webseiten und speichere den Inhalt in deinem Profil-Kontext</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="crawl-form" onsubmit={handleImport}>
|
||||
<div class="url-row">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
required
|
||||
placeholder="https://example.com/article"
|
||||
disabled={importing}
|
||||
class="url-input"
|
||||
/>
|
||||
<button type="submit" disabled={importing || !importUrl.trim()} class="url-submit">
|
||||
{#if importing}
|
||||
{importPhase === 'crawling'
|
||||
? 'Crawle…'
|
||||
: importPhase === 'summarizing'
|
||||
? 'Fasse zusammen…'
|
||||
: 'Speichere…'}
|
||||
{:else}
|
||||
Importieren
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="url-opts">
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
|
||||
Nur diese Seite
|
||||
</label>
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
|
||||
Ganze Website (max. 20)
|
||||
</label>
|
||||
<span class="url-sep">·</span>
|
||||
<label class:disabled={importing}>
|
||||
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
|
||||
Mit KI zusammenfassen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if importing || importPhase !== 'idle'}
|
||||
<ol class="phase-list" aria-live="polite">
|
||||
{#each importPhases as phase (phase.key)}
|
||||
<li class="phase" class:active={phase.active} class:done={phase.done}>
|
||||
<span class="phase-dot" aria-hidden="true">
|
||||
{#if phase.done}
|
||||
✓
|
||||
{:else if phase.active}
|
||||
<span class="phase-spinner"></span>
|
||||
{:else}
|
||||
·
|
||||
{/if}
|
||||
</span>
|
||||
<span class="phase-label">{phase.label}</span>
|
||||
{#if phase.active}
|
||||
<span class="phase-elapsed">{importElapsed}s</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if importError}
|
||||
<p class="error">{importError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success">
|
||||
<p>{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info">
|
||||
<p>Importierte Inhalte werden im <strong>Freitext-Tab</strong> deines Profils gespeichert.</p>
|
||||
<p>Der Crawler respektiert robots.txt und nutzt Rate-Limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.crawl-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.url-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.url-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
.url-input:focus {
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-submit {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.url-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.url-opts label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-opts label.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.url-sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.phase-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.phase {
|
||||
display: grid;
|
||||
grid-template-columns: 1rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.phase.active {
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
.phase.done {
|
||||
color: hsl(var(--color-success, var(--color-primary)));
|
||||
}
|
||||
.phase-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.phase-spinner {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.phase-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(142 71% 45% / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(142 71% 45% / 0.08);
|
||||
color: hsl(142 71% 45%);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.success p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.info p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* Kontext module — Dexie table accessor for the singleton document.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalKontextDoc } from './types';
|
||||
|
||||
export const kontextDocTable = db.table<LocalKontextDoc>('kontextDoc');
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Kontext module — barrel exports.
|
||||
*/
|
||||
|
||||
export { kontextStore } from './stores/kontext.svelte';
|
||||
export { useKontextDoc, toKontextDoc } from './queries';
|
||||
export { kontextDocTable } from './collections';
|
||||
export { KONTEXT_SINGLETON_ID } from './types';
|
||||
export type { LocalKontextDoc, KontextDoc } from './types';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const kontextModuleConfig: ModuleConfig = {
|
||||
appId: 'kontext',
|
||||
tables: [{ name: 'kontextDoc' }],
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* Kontext module — reactive query for the active-Space document.
|
||||
*
|
||||
* Content is encrypted at rest. Returns the row as soon as it's been
|
||||
* pulled from mana-sync (every Space-creation server-bootstraps an empty
|
||||
* kontextDoc — see `bootstrap-singletons.ts`); returns null only during
|
||||
* the brief window before the first pull lands or for legacy Spaces
|
||||
* created before the bootstrap shipped.
|
||||
*
|
||||
* Per-Space since Phase 2d.2: each Space has its own kontextDoc;
|
||||
* Personal-Space's legacy singleton row is matched by the in-scope
|
||||
* set's inclusion of the `_personal:<userId>` sentinel.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedTable } from '$lib/data/scope/scoped-db';
|
||||
import type { KontextDoc, LocalKontextDoc } from './types';
|
||||
|
||||
export function toKontextDoc(local: LocalKontextDoc): KontextDoc {
|
||||
return {
|
||||
id: local.id,
|
||||
content: local.content ?? '',
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
export function useKontextDoc() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const rows = await scopedTable<LocalKontextDoc, string>('kontextDoc').toArray();
|
||||
const match = rows.find((r) => !r.deletedAt);
|
||||
if (!match) return null;
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [match]);
|
||||
return decrypted ? toKontextDoc(decrypted) : null;
|
||||
},
|
||||
null as KontextDoc | null
|
||||
);
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
/**
|
||||
* Kontext Store — per-Space markdown document.
|
||||
*
|
||||
* Since Phase 2d.2 the module is Space-scoped: each Space has its own
|
||||
* kontextDoc. Since 2026-04-26 (sync-field-meta-overhaul Punkt 2) every
|
||||
* Space-creation also bootstraps an empty kontextDoc server-side via
|
||||
* `bootstrapSpaceSingletons` — fresh clients pull the row from mana-sync
|
||||
* instead of racing on a local insert. `getOrCreateLocalDoc()` below is
|
||||
* kept as a fallback for the brief window before the first pull lands
|
||||
* (and for legacy Spaces created before the bootstrap shipped).
|
||||
*
|
||||
* The store finds the row via `getInScopeSpaceIds()` (which matches the
|
||||
* active Space plus the legacy `_personal:<userId>` sentinel so
|
||||
* Personal-Space's pre-migration singleton row still renders).
|
||||
*
|
||||
* `content` is encrypted at rest. The Dexie creating hook stamps
|
||||
* `spaceId` on new rows automatically — we just pick a fresh UUID.
|
||||
*/
|
||||
|
||||
import { kontextDocTable } from '../collections';
|
||||
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedTable } from '$lib/data/scope/scoped-db';
|
||||
import { makeSystemActor, SYSTEM_BOOTSTRAP } from '@mana/shared-ai';
|
||||
import { runAsAsync } from '$lib/data/events/actor';
|
||||
import type { LocalKontextDoc } from '../types';
|
||||
|
||||
const BOOTSTRAP_ACTOR = makeSystemActor(SYSTEM_BOOTSTRAP);
|
||||
|
||||
async function findForActiveSpace(): Promise<LocalKontextDoc | undefined> {
|
||||
const rows = await scopedTable<LocalKontextDoc, string>('kontextDoc').toArray();
|
||||
return rows.find((r) => !r.deletedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Race-window fallback for the narrow window between "server bootstrap
|
||||
* provisioned the row in mana_sync" and "first pull landed it in
|
||||
* IndexedDB". Without this, `setContent` / `appendContent` would hit
|
||||
* `update(missing-id, diff)` — a Dexie no-op that silently swallows the
|
||||
* write. Insert is stamped `origin: 'system'` (via SYSTEM_BOOTSTRAP)
|
||||
* so the server's bootstrap pull won't fight with it. Subsequent
|
||||
* `setContent` writes stamp `origin: 'user'` as usual.
|
||||
*/
|
||||
async function getOrCreateLocalDoc(): Promise<LocalKontextDoc> {
|
||||
const existing = await findForActiveSpace();
|
||||
if (existing) return existing;
|
||||
const newLocal: LocalKontextDoc = {
|
||||
id: crypto.randomUUID(),
|
||||
content: '',
|
||||
};
|
||||
await encryptRecord('kontextDoc', newLocal);
|
||||
await runAsAsync(BOOTSTRAP_ACTOR, async () => {
|
||||
await kontextDocTable.add(newLocal);
|
||||
});
|
||||
// Reload — the creating-hook stamped spaceId/authorId/actor fields.
|
||||
const created = await kontextDocTable.get(newLocal.id);
|
||||
if (!created) throw new Error('Failed to create kontextDoc');
|
||||
return created;
|
||||
}
|
||||
|
||||
export const kontextStore = {
|
||||
async setContent(content: string): Promise<void> {
|
||||
const row = await getOrCreateLocalDoc();
|
||||
const diff: Partial<LocalKontextDoc> = {
|
||||
content,
|
||||
};
|
||||
await encryptRecord('kontextDoc', diff);
|
||||
await kontextDocTable.update(row.id, diff);
|
||||
},
|
||||
|
||||
async appendContent(chunk: string): Promise<void> {
|
||||
const row = await getOrCreateLocalDoc();
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [row]);
|
||||
const current = decrypted?.content ?? '';
|
||||
const separator = current.trim() ? '\n\n---\n\n' : '';
|
||||
await this.setContent(`${current}${separator}${chunk}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
/**
|
||||
* Kontext module types — per-Space markdown document.
|
||||
*
|
||||
* Since Phase 2d.2 of the space-scoped rollout, each Space can have its
|
||||
* own kontextDoc (was: user-level singleton keyed by id='singleton').
|
||||
* Personal-Space's pre-migration singleton row stays usable because its
|
||||
* stamped spaceId falls inside the in-scope set returned by
|
||||
* getInScopeSpaceIds(); fresh rows use random UUIDs.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
/**
|
||||
* Legacy singleton id — pre-Phase-2d.2 the whole module was one row
|
||||
* keyed by this. Kept for backward-compat lookups on Personal-Space
|
||||
* records that predate the refactor; new rows use crypto.randomUUID().
|
||||
*/
|
||||
export const KONTEXT_SINGLETON_ID = 'singleton' as const;
|
||||
|
||||
export interface LocalKontextDoc extends BaseRecord {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface KontextDoc {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@
|
|||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
|
||||
import { PencilSimple, Trash, PushPin } from '@mana/shared-icons';
|
||||
import { PencilSimple, Trash, PushPin, LinkSimple, Scroll } from '@mana/shared-icons';
|
||||
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
|
||||
import AgentDot from '$lib/components/ai/AgentDot.svelte';
|
||||
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
|
||||
import { hasActiveSceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { crawlUrl, type CrawlMode } from './api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
|
|
@ -100,6 +102,60 @@
|
|||
await notesStore.togglePin(id);
|
||||
}
|
||||
|
||||
// ─── URL Import ──────────────────────────────────────────
|
||||
let urlImportOpen = $state(false);
|
||||
let importUrl = $state('');
|
||||
let importMode = $state<CrawlMode>('single');
|
||||
let importSummarize = $state(false);
|
||||
let importing = $state(false);
|
||||
let importError = $state<string | null>(null);
|
||||
|
||||
function resetImport() {
|
||||
importUrl = '';
|
||||
importMode = 'single';
|
||||
importSummarize = false;
|
||||
importError = null;
|
||||
}
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
e.preventDefault();
|
||||
const trimmed = importUrl.trim();
|
||||
if (!trimmed) return;
|
||||
const ok = await requireAuth({
|
||||
feature: 'notes-url-import',
|
||||
reason:
|
||||
'Das Crawlen einer Web-Seite läuft serverseitig (robots.txt, Rate-Limits, optionale KI-Zusammenfassung) und erfordert ein Mana-Konto.',
|
||||
});
|
||||
if (!ok) return;
|
||||
importing = true;
|
||||
importError = null;
|
||||
try {
|
||||
const result = await crawlUrl({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
});
|
||||
const header = `_Quelle: ${result.sourceUrl}_\n\n`;
|
||||
const note = await notesStore.createNote({
|
||||
title: result.title,
|
||||
content: header + result.content,
|
||||
});
|
||||
urlImportOpen = false;
|
||||
resetImport();
|
||||
startEdit(note);
|
||||
} catch (err) {
|
||||
importError = err instanceof Error ? err.message : 'Import fehlgeschlagen';
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Space-Kontext Mutex ─────────────────────────────────
|
||||
async function handleToggleSpaceContext(note: Note) {
|
||||
// Mutex enforced inside the store; flagged → unset, unflagged → set.
|
||||
await notesStore.markAsSpaceContext(note.isSpaceContext ? null : note.id);
|
||||
}
|
||||
|
||||
const ctxMenu = useItemContextMenu<Note>();
|
||||
|
||||
let ctxMenuItems = $derived<ContextMenuItem[]>(
|
||||
|
|
@ -123,6 +179,17 @@
|
|||
if (target) notesStore.togglePin(target.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'space-context',
|
||||
label: ctxMenu.state.target.isSpaceContext
|
||||
? 'Space-Kontext lösen'
|
||||
: 'Als Space-Kontext markieren',
|
||||
icon: Scroll,
|
||||
action: () => {
|
||||
const target = ctxMenu.state.target;
|
||||
if (target) handleToggleSpaceContext(target);
|
||||
},
|
||||
},
|
||||
{ id: 'div', label: '', type: 'divider' as const },
|
||||
{
|
||||
id: 'delete',
|
||||
|
|
@ -140,6 +207,67 @@
|
|||
</script>
|
||||
|
||||
<div class="app-view">
|
||||
<!-- URL import: collapsed pill, expands to inline form on click -->
|
||||
<div class="url-import">
|
||||
{#if !urlImportOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="url-import-toggle"
|
||||
onclick={() => (urlImportOpen = true)}
|
||||
aria-label="Notiz aus URL erstellen"
|
||||
>
|
||||
<LinkSimple size={14} />
|
||||
<span>Aus URL importieren</span>
|
||||
</button>
|
||||
{:else}
|
||||
<form class="url-import-form" onsubmit={handleImport}>
|
||||
<div class="url-import-row">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={importUrl}
|
||||
required
|
||||
placeholder="https://example.com/article"
|
||||
disabled={importing}
|
||||
class="url-import-input"
|
||||
/>
|
||||
<button type="submit" class="url-import-submit" disabled={importing || !importUrl.trim()}>
|
||||
{importing ? 'Lade…' : 'Importieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="url-import-cancel"
|
||||
onclick={() => {
|
||||
urlImportOpen = false;
|
||||
resetImport();
|
||||
}}
|
||||
disabled={importing}
|
||||
aria-label="Abbrechen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="url-import-opts">
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="single" disabled={importing} />
|
||||
Nur diese Seite
|
||||
</label>
|
||||
<label class:disabled={importing}>
|
||||
<input type="radio" bind:group={importMode} value="deep" disabled={importing} />
|
||||
Ganze Website (max. 20)
|
||||
</label>
|
||||
<span class="sep">·</span>
|
||||
<label class:disabled={importing}>
|
||||
<input type="checkbox" bind:checked={importSummarize} disabled={importing} />
|
||||
KI-Zusammenfassung
|
||||
</label>
|
||||
</div>
|
||||
{#if importError}
|
||||
<p class="url-import-error">{importError}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
{#if notes.length > 5}
|
||||
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
|
||||
|
|
@ -192,6 +320,14 @@
|
|||
<div class="note-top">
|
||||
<span class="note-title">{note.title || 'Unbenannt'}</span>
|
||||
<AgentDot record={note} />
|
||||
{#if note.isSpaceContext}
|
||||
<span
|
||||
class="space-context-badge"
|
||||
title="Space-Kontext — Quelle für AI-Referenzen in diesem Space"
|
||||
>
|
||||
<Scroll size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if note.isPinned}<span class="pin">📌</span>{/if}
|
||||
</div>
|
||||
{#if note.content}
|
||||
|
|
@ -250,6 +386,116 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.url-import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.url-import-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
align-self: flex-start;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.url-import-toggle:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-import-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.url-import-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.url-import-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
.url-import-input:focus {
|
||||
border-color: hsl(var(--color-ring));
|
||||
}
|
||||
.url-import-submit {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-import-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.url-import-cancel {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-import-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.url-import-opts label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.url-import-opts label.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.url-import-opts .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.url-import-error {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-destructive, 0 84% 60%));
|
||||
}
|
||||
|
||||
.space-context-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--color-primary));
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/**
|
||||
* Kontext API client — talks to apps/api `POST /api/v1/context/import-url`.
|
||||
* Notes API client — talks to apps/api `POST /api/v1/notes/import-url`.
|
||||
*
|
||||
* The server route lives under /context for historical reasons (shared
|
||||
* crawler + LLM wrapper). Only the kontext singleton consumes it.
|
||||
* Crawls a URL via mana-crawler, optionally summarises the result with
|
||||
* mana-llm, and returns the markdown payload. The caller decides what
|
||||
* to do with the result — typically `notesStore.createNote({ title,
|
||||
* content })` to materialise it as a Note.
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -25,10 +27,10 @@ export interface ImportResponse {
|
|||
pageCount: number;
|
||||
}
|
||||
|
||||
export async function crawlUrlViaApi(input: ImportInput): Promise<ImportResponse> {
|
||||
export async function crawlUrl(input: ImportInput): Promise<ImportResponse> {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) throw new Error('not authenticated');
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/context/import-url`, {
|
||||
const res = await fetch(`${getManaApiUrl()}/api/v1/notes/import-url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -35,6 +35,7 @@ export function toNote(local: LocalNote): Note {
|
|||
transcriptModel: local.transcriptModel ?? null,
|
||||
isPinned: local.isPinned,
|
||||
isArchived: local.isArchived,
|
||||
isSpaceContext: local.isSpaceContext ?? false,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
|
|
@ -63,6 +64,25 @@ export function useAllNotes() {
|
|||
}, [] as Note[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single note marked `isSpaceContext: true` in the active Space, or
|
||||
* null if no note holds that flag yet. Used by the AI Mission Runner to
|
||||
* auto-inject "what's this Space about" into every planner call. The
|
||||
* store guarantees mutex (max 1 per Space), so `find` is enough.
|
||||
*/
|
||||
export function useSpaceContextNote() {
|
||||
return useScopedLiveQuery(
|
||||
async () => {
|
||||
const raw = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
|
||||
const match = raw.find((n) => !n.deletedAt && n.isSpaceContext === true);
|
||||
if (!match) return null;
|
||||
const [decrypted] = await decryptRecords('notes', [match]);
|
||||
return decrypted ? toNote(decrypted) : null;
|
||||
},
|
||||
null as Note | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Single note by id, decrypted. Used by detail views. */
|
||||
export function useNote(id: string) {
|
||||
return useScopedLiveQuery(
|
||||
|
|
|
|||
|
|
@ -121,4 +121,41 @@ export const notesStore = {
|
|||
isArchived: true,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark `id` as the active Space's standing-context note for AI-Runner
|
||||
* auto-injection. Mutex: clears `isSpaceContext` on every other note
|
||||
* in the same Space first so the index can assume at most one `true`
|
||||
* row per `spaceId`. Pass `null` to unmark without setting a new one.
|
||||
*
|
||||
* The mutex sweep deliberately writes to *every* currently-flagged
|
||||
* row even though there should only ever be one — that way a sync
|
||||
* race that briefly let two rows hold the flag self-heals on the
|
||||
* next mark.
|
||||
*/
|
||||
async markAsSpaceContext(id: string | null): Promise<void> {
|
||||
const target = id ? await noteTable.get(id) : null;
|
||||
const targetSpaceId = (target as { spaceId?: string } | undefined)?.spaceId;
|
||||
|
||||
// Clear the flag on every currently-flagged note. If we have a target
|
||||
// Space, restrict to that Space; if not (id === null), the caller
|
||||
// asked for an unset of the active Space's flagged note(s) — same query
|
||||
// scoped via Dexie filter. spaceId is auto-stamped by the Dexie
|
||||
// creating-hook so it's always present at runtime even though the
|
||||
// LocalNote interface doesn't declare it.
|
||||
const flagged = await noteTable
|
||||
.filter((n) => {
|
||||
const noteSpaceId = (n as { spaceId?: string }).spaceId;
|
||||
return n.isSpaceContext === true && (!targetSpaceId || noteSpaceId === targetSpaceId);
|
||||
})
|
||||
.toArray();
|
||||
for (const n of flagged) {
|
||||
if (n.id === id) continue;
|
||||
await noteTable.update(n.id, { isSpaceContext: false });
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await noteTable.update(id, { isSpaceContext: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ export interface LocalNote extends BaseRecord {
|
|||
transcriptModel?: string | null;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
/**
|
||||
* Marks this note as the active Space's standing-context for AI Mission
|
||||
* Runner auto-injection. Mutually exclusive within a Space — the store's
|
||||
* markAsSpaceContext() unsets the flag on every other note in the same
|
||||
* Space before setting it here, so the index can assume at most one
|
||||
* `true` row per `spaceId`. Optional on the type because legacy rows
|
||||
* predate the field; absent === false.
|
||||
*/
|
||||
isSpaceContext?: boolean;
|
||||
}
|
||||
|
||||
// ─── Domain Types ─────────────────────────────────────────
|
||||
|
|
@ -28,6 +37,7 @@ export interface Note {
|
|||
transcriptModel: string | null;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
isSpaceContext: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { useUserContext } from './queries';
|
||||
import { userContextStore } from './stores/user-context.svelte';
|
||||
import { PencilSimple, Eye, LinkSimple, X } from '@mana/shared-icons';
|
||||
import { crawlUrlViaApi, type CrawlMode } from '$lib/modules/kontext/api';
|
||||
import { crawlUrl, type CrawlMode } from '$lib/modules/notes/api';
|
||||
import { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
);
|
||||
}
|
||||
try {
|
||||
const result = await crawlUrlViaApi({
|
||||
const result = await crawlUrl({
|
||||
url: trimmed,
|
||||
mode: importMode,
|
||||
summarize: importSummarize,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
- note → searchable list of notes
|
||||
- library → searchable list of library entries
|
||||
- url → freeform URL input + optional context note
|
||||
- kontext → space's kontext-doc singleton (one-click add)
|
||||
- kontext → space's standing-context Note (one-click add; the Note flagged isSpaceContext)
|
||||
- goal → searchable list of goals
|
||||
- me-image → searchable list of profile reference images
|
||||
|
||||
|
|
@ -18,9 +18,8 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { useAllArticles } from '$lib/modules/articles/queries';
|
||||
import { useAllNotes } from '$lib/modules/notes/queries';
|
||||
import { useAllNotes, useSpaceContextNote } from '$lib/modules/notes/queries';
|
||||
import { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
|
||||
import { useKontextDoc } from '$lib/modules/kontext/queries';
|
||||
import { useAllMeImages } from '$lib/modules/profile/queries';
|
||||
import { useAllGoals } from '$lib/companion/goals/queries';
|
||||
import ReferenceChip from './ReferenceChip.svelte';
|
||||
|
|
@ -36,8 +35,8 @@
|
|||
'me-image',
|
||||
];
|
||||
const MAX_REFERENCES = 6;
|
||||
/** Sentinel targetId for the kontext-singleton — the resolver doesn't
|
||||
* use it, but a non-null id keeps the de-dupe + chip-key logic uniform. */
|
||||
/** Sentinel targetId — the resolver finds the flagged note by scope-scan,
|
||||
* not by id, but a non-null id keeps the de-dupe + chip-key logic uniform. */
|
||||
const KONTEXT_SINGLETON_ID = 'kontext:singleton';
|
||||
|
||||
let {
|
||||
|
|
@ -51,7 +50,7 @@
|
|||
const articles$ = useAllArticles();
|
||||
const notes$ = useAllNotes();
|
||||
const library$ = useAllLibraryEntries();
|
||||
const kontext$ = useKontextDoc();
|
||||
const kontext$ = useSpaceContextNote();
|
||||
const meImages$ = useAllMeImages();
|
||||
const goals$ = useAllGoals();
|
||||
|
||||
|
|
@ -374,7 +373,7 @@
|
|||
<div class="search">
|
||||
{#if !kontextDoc}
|
||||
<p class="muted small">
|
||||
{$_('writing.reference_picker.kontext_empty_pre')}<a href="/kontext">/kontext</a>{$_(
|
||||
{$_('writing.reference_picker.kontext_empty_pre')}<a href="/notes">/notes</a>{$_(
|
||||
'writing.reference_picker.kontext_empty_post'
|
||||
)}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -41,10 +41,6 @@ vi.mock('$lib/modules/library/queries', () => ({
|
|||
...local,
|
||||
})),
|
||||
}));
|
||||
vi.mock('$lib/modules/kontext/queries', () => ({
|
||||
toKontextDoc: vi.fn((local) => ({ ...local })),
|
||||
}));
|
||||
|
||||
import { scopedGet, scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { db } from '$lib/data/database';
|
||||
|
|
@ -242,14 +238,25 @@ describe('resolveReference - url', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveReference - kontext (singleton)', () => {
|
||||
it('reads the singleton via scopedForModule and ignores the targetId', async () => {
|
||||
const toArrayMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'kontext-uuid', content: 'mein laufender kontext' }]);
|
||||
describe('resolveReference - kontext (Space-Kontext Note)', () => {
|
||||
it('reads the isSpaceContext-flagged Note via scopedForModule and ignores the targetId', async () => {
|
||||
const toArrayMock = vi.fn().mockResolvedValue([
|
||||
{ id: 'note-1', title: 'Random', content: 'no flag', isSpaceContext: false },
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'Brand-Profil',
|
||||
content: 'mein laufender kontext',
|
||||
isSpaceContext: true,
|
||||
},
|
||||
]);
|
||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||
mockDecryptRecords.mockResolvedValue([
|
||||
{ id: 'kontext-uuid', content: 'mein laufender kontext' },
|
||||
{
|
||||
id: 'note-2',
|
||||
title: 'Brand-Profil',
|
||||
content: 'mein laufender kontext',
|
||||
isSpaceContext: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await resolveReference({
|
||||
|
|
@ -257,16 +264,30 @@ describe('resolveReference - kontext (singleton)', () => {
|
|||
targetId: 'irrelevant',
|
||||
note: null,
|
||||
});
|
||||
expect(result?.sourceLabel).toBe('Kontext-Dokument des Spaces');
|
||||
expect(result?.sourceLabel).toBe('Space-Kontext (Notiz)');
|
||||
expect(result?.title).toBe('Brand-Profil');
|
||||
expect(result?.content).toBe('mein laufender kontext');
|
||||
});
|
||||
|
||||
it('skips deleted singleton rows', async () => {
|
||||
it('returns null when no Note is flagged as Space-Kontext', async () => {
|
||||
const toArrayMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ id: 'kontext-uuid', content: 'old', deletedAt: '2026-01-01T00:00:00Z' },
|
||||
]);
|
||||
.mockResolvedValue([{ id: 'note-1', title: 'Random', content: 'no flag' }]);
|
||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('skips deleted Space-Kontext Notes', async () => {
|
||||
const toArrayMock = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'note-1',
|
||||
title: 'old context',
|
||||
content: 'old',
|
||||
isSpaceContext: true,
|
||||
deletedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
|
||||
expect(result).toBeNull();
|
||||
|
|
|
|||
|
|
@ -21,11 +21,9 @@ import { db } from '$lib/data/database';
|
|||
import { toArticle } from '$lib/modules/articles/queries';
|
||||
import { toNote } from '$lib/modules/notes/queries';
|
||||
import { toLibraryEntry } from '$lib/modules/library/queries';
|
||||
import { toKontextDoc } from '$lib/modules/kontext/queries';
|
||||
import type { LocalArticle } from '$lib/modules/articles/types';
|
||||
import type { LocalNote } from '$lib/modules/notes/types';
|
||||
import type { LocalLibraryEntry } from '$lib/modules/library/types';
|
||||
import type { LocalKontextDoc } from '$lib/modules/kontext/types';
|
||||
import type { LocalMeImage } from '$lib/modules/profile/types';
|
||||
import type { LocalGoal } from '$lib/companion/goals/types';
|
||||
import type { DraftReference } from '../types';
|
||||
|
|
@ -134,23 +132,23 @@ function resolveUrl(ref: DraftReference): Omit<ResolvedReference, 'kind' | 'note
|
|||
}
|
||||
|
||||
/**
|
||||
* Kontext is a per-space singleton — the picker stores a sentinel
|
||||
* targetId ('singleton'), and the resolver ignores it and picks the
|
||||
* first non-deleted row scoped to the active space. Legacy rows use
|
||||
* id='singleton' explicitly; fresh rows use a uuid but are still
|
||||
* singular per space.
|
||||
* Kontext = the active Space's standing-context Note (the one with
|
||||
* `isSpaceContext: true`). Picker stores a sentinel targetId since the
|
||||
* resolver ignores it and finds the flagged note via scope-scan.
|
||||
* Replaces the retired per-space `kontextDoc` singleton table — same
|
||||
* concept, regular Note as the storage.
|
||||
*/
|
||||
async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
|
||||
const rows = await scopedForModule<LocalKontextDoc, string>('kontext', 'kontextDoc').toArray();
|
||||
const local = rows.find((r) => !r.deletedAt);
|
||||
const rows = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
|
||||
const local = rows.find((r) => !r.deletedAt && r.isSpaceContext === true);
|
||||
if (!local) return null;
|
||||
const [decrypted] = await decryptRecords('kontextDoc', [local]);
|
||||
const [decrypted] = await decryptRecords('notes', [local]);
|
||||
if (!decrypted) return null;
|
||||
const doc = toKontextDoc(decrypted);
|
||||
const note = toNote(decrypted);
|
||||
return {
|
||||
sourceLabel: 'Kontext-Dokument des Spaces',
|
||||
title: 'Kontext',
|
||||
content: truncate(doc.content ?? ''),
|
||||
sourceLabel: 'Space-Kontext (Notiz)',
|
||||
title: note.title || 'Kontext',
|
||||
content: truncate(note.content ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
<script lang="ts">
|
||||
import KontextView from '$lib/modules/kontext/KontextView.svelte';
|
||||
import { RoutePage } from '$lib/components/shell';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Web-Context - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<RoutePage appId="kontext">
|
||||
<KontextView />
|
||||
</RoutePage>
|
||||
Loading…
Add table
Add a link
Reference in a new issue