mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +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
|
|
@ -28,7 +28,7 @@ import { calendarRoutes } from './modules/calendar/routes';
|
||||||
import { contactsRoutes } from './modules/contacts/routes';
|
import { contactsRoutes } from './modules/contacts/routes';
|
||||||
import { musicRoutes } from './modules/music/routes';
|
import { musicRoutes } from './modules/music/routes';
|
||||||
import { chatRoutes } from './modules/chat/routes';
|
import { chatRoutes } from './modules/chat/routes';
|
||||||
import { contextRoutes } from './modules/context/routes';
|
import { notesRoutes } from './modules/notes/routes';
|
||||||
import { pictureRoutes } from './modules/picture/routes';
|
import { pictureRoutes } from './modules/picture/routes';
|
||||||
import { profileRoutes } from './modules/profile/routes';
|
import { profileRoutes } from './modules/profile/routes';
|
||||||
import { wardrobeRoutes } from './modules/wardrobe/routes';
|
import { wardrobeRoutes } from './modules/wardrobe/routes';
|
||||||
|
|
@ -94,10 +94,10 @@ app.use('/api/*', authMiddleware());
|
||||||
// their own records.
|
// their own records.
|
||||||
const RESOURCE_MODULES = [
|
const RESOURCE_MODULES = [
|
||||||
'chat',
|
'chat',
|
||||||
'context',
|
|
||||||
'food',
|
'food',
|
||||||
'guides',
|
'guides',
|
||||||
'news-research',
|
'news-research',
|
||||||
|
'notes',
|
||||||
'picture',
|
'picture',
|
||||||
'plants',
|
'plants',
|
||||||
'research',
|
'research',
|
||||||
|
|
@ -121,7 +121,7 @@ app.route('/api/v1/calendar', calendarRoutes);
|
||||||
app.route('/api/v1/contacts', contactsRoutes);
|
app.route('/api/v1/contacts', contactsRoutes);
|
||||||
app.route('/api/v1/music', musicRoutes);
|
app.route('/api/v1/music', musicRoutes);
|
||||||
app.route('/api/v1/chat', chatRoutes);
|
app.route('/api/v1/chat', chatRoutes);
|
||||||
app.route('/api/v1/context', contextRoutes);
|
app.route('/api/v1/notes', notesRoutes);
|
||||||
app.route('/api/v1/picture', pictureRoutes);
|
app.route('/api/v1/picture', pictureRoutes);
|
||||||
app.route('/api/v1/profile', profileRoutes);
|
app.route('/api/v1/profile', profileRoutes);
|
||||||
app.route('/api/v1/wardrobe', wardrobeRoutes);
|
app.route('/api/v1/wardrobe', wardrobeRoutes);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Context module — AI text generation + token estimation
|
* Notes module — server-side helpers.
|
||||||
* Ported from apps/context/apps/server
|
|
||||||
*
|
*
|
||||||
* CRUD for spaces/documents handled by mana-sync.
|
* Today: a single `POST /import-url` endpoint that crawls a URL via
|
||||||
|
* mana-crawler and optionally summarises the result with mana-llm. The
|
||||||
|
* client treats the response as the body of a new Note (title +
|
||||||
|
* markdown content). The same endpoint is reused by the (planned)
|
||||||
|
* Brand/Firma-Space onboarding wizard to seed the Space-context note.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
|
@ -16,8 +19,6 @@ const DEFAULT_SUMMARY_MODEL = MANA_LLM.FAST_TEXT;
|
||||||
|
|
||||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||||
|
|
||||||
// ─── URL Import (crawler → optional LLM summary → document) ──
|
|
||||||
|
|
||||||
const DEEP_MAX_PAGES = 20;
|
const DEEP_MAX_PAGES = 20;
|
||||||
const CRAWL_POLL_INTERVAL_MS = 1500;
|
const CRAWL_POLL_INTERVAL_MS = 1500;
|
||||||
const CRAWL_TIMEOUT_MS = 90_000;
|
const CRAWL_TIMEOUT_MS = 90_000;
|
||||||
|
|
@ -25,20 +26,16 @@ const CRAWL_TIMEOUT_MS = 90_000;
|
||||||
/**
|
/**
|
||||||
* Local LLMs love to wrap Markdown in ```markdown fences or prepend
|
* Local LLMs love to wrap Markdown in ```markdown fences or prepend
|
||||||
* a "Hier ist die Zusammenfassung:" preamble. Strip those so the
|
* a "Hier ist die Zusammenfassung:" preamble. Strip those so the
|
||||||
* output renders correctly when dropped into the Kontext document.
|
* output renders correctly when dropped into a Note body.
|
||||||
*/
|
*/
|
||||||
function sanitizeSummary(raw: string): string {
|
function sanitizeSummary(raw: string): string {
|
||||||
let s = raw.trim();
|
let s = raw.trim();
|
||||||
// Strip a leading ```markdown / ```md / ``` fence and its closing ```.
|
|
||||||
const fenceMatch = s.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n?```\s*$/i);
|
const fenceMatch = s.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n?```\s*$/i);
|
||||||
if (fenceMatch) s = fenceMatch[1].trim();
|
if (fenceMatch) s = fenceMatch[1].trim();
|
||||||
// Drop a single-line preamble that ends with a colon (LLM chatter).
|
|
||||||
const lines = s.split('\n');
|
const lines = s.split('\n');
|
||||||
if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) {
|
if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) {
|
||||||
s = lines.slice(1).join('\n').trim();
|
s = lines.slice(1).join('\n').trim();
|
||||||
}
|
}
|
||||||
// Demote a solitary leading H1 to H2 so it doesn't clash with our
|
|
||||||
// section header that the frontend prepends.
|
|
||||||
s = s.replace(/^#\s+/, '## ');
|
s = s.replace(/^#\s+/, '## ');
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +70,7 @@ routes.post('/import-url', async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const creditCost = summarize ? 5 : 1;
|
const creditCost = summarize ? 5 : 1;
|
||||||
const validation = await validateCredits(userId, 'AI_CONTEXT_IMPORT_URL', creditCost);
|
const validation = await validateCredits(userId, 'NOTES_IMPORT_URL', creditCost);
|
||||||
if (!validation.hasCredits) {
|
if (!validation.hasCredits) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
|
|
@ -147,7 +144,7 @@ routes.post('/import-url', async (c) => {
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content:
|
content:
|
||||||
'Du bist ein Assistent, der Web-Inhalte in strukturierte Kontext-Dokumente zusammenfasst. ' +
|
'Du bist ein Assistent, der Web-Inhalte in strukturierte Notiz-Dokumente zusammenfasst. ' +
|
||||||
'Antworte ausschließlich in sauberem Markdown. Gliedere in H2-Abschnitte: ' +
|
'Antworte ausschließlich in sauberem Markdown. Gliedere in H2-Abschnitte: ' +
|
||||||
'"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' +
|
'"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' +
|
||||||
'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' +
|
'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' +
|
||||||
|
|
@ -175,7 +172,7 @@ routes.post('/import-url', async (c) => {
|
||||||
|
|
||||||
await consumeCredits(
|
await consumeCredits(
|
||||||
userId,
|
userId,
|
||||||
'AI_CONTEXT_IMPORT_URL',
|
'NOTES_IMPORT_URL',
|
||||||
creditCost,
|
creditCost,
|
||||||
`URL import (${mode}${summarize ? ' + summary' : ''})`
|
`URL import (${mode}${summarize ? ' + summary' : ''})`
|
||||||
);
|
);
|
||||||
|
|
@ -194,74 +191,4 @@ routes.post('/import-url', async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── AI Generation (server-only: mana-llm) ──────────────────
|
export { routes as notesRoutes };
|
||||||
|
|
||||||
routes.post('/ai/generate', async (c) => {
|
|
||||||
const userId = c.get('userId');
|
|
||||||
const { prompt, documents, model, maxTokens } = await c.req.json();
|
|
||||||
|
|
||||||
if (!prompt) return c.json({ error: 'prompt required' }, 400);
|
|
||||||
|
|
||||||
// Validate credits
|
|
||||||
const validation = await validateCredits(userId, 'AI_CONTEXT_GENERATE', 5);
|
|
||||||
if (!validation.hasCredits) {
|
|
||||||
return c.json(
|
|
||||||
{ error: 'Insufficient credits', required: 5, available: validation.availableCredits },
|
|
||||||
402
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build messages with document context
|
|
||||||
const messages: Array<{ role: string; content: string }> = [];
|
|
||||||
|
|
||||||
if (documents?.length) {
|
|
||||||
const contextText = documents
|
|
||||||
.map((d: { title: string; content: string }) => `--- ${d.title} ---\n${d.content}`)
|
|
||||||
.join('\n\n');
|
|
||||||
messages.push({
|
|
||||||
role: 'system',
|
|
||||||
content: `Verwende diese Dokumente als Kontext:\n\n${contextText}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({ role: 'user', content: prompt });
|
|
||||||
|
|
||||||
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
messages,
|
|
||||||
model: model || MANA_LLM.FAST_TEXT,
|
|
||||||
max_tokens: maxTokens || 2000,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) return c.json({ error: 'AI generation failed' }, 502);
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const content = data.choices?.[0]?.message?.content || '';
|
|
||||||
const tokensUsed = data.usage?.total_tokens || 0;
|
|
||||||
|
|
||||||
// Consume credits
|
|
||||||
await consumeCredits(userId, 'AI_CONTEXT_GENERATE', 5, `AI generation (${tokensUsed} tokens)`);
|
|
||||||
|
|
||||||
return c.json({ content, tokensUsed, model: model || MANA_LLM.FAST_TEXT });
|
|
||||||
} catch (_err) {
|
|
||||||
return c.json({ error: 'Generation failed' }, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
routes.post('/ai/estimate', async (c) => {
|
|
||||||
const { prompt, documents } = await c.req.json();
|
|
||||||
const charCount =
|
|
||||||
(prompt?.length || 0) +
|
|
||||||
(documents || []).reduce(
|
|
||||||
(sum: number, d: { content: string }) => sum + (d.content?.length || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const estimatedTokens = Math.ceil(charCount / 4);
|
|
||||||
return c.json({ estimatedTokens, estimatedCost: 5 });
|
|
||||||
});
|
|
||||||
|
|
||||||
export { routes as contextRoutes };
|
|
||||||
|
|
@ -68,7 +68,6 @@ import {
|
||||||
Question,
|
Question,
|
||||||
ChatCircleDots,
|
ChatCircleDots,
|
||||||
SquaresFour,
|
SquaresFour,
|
||||||
Scroll,
|
|
||||||
Spiral,
|
Spiral,
|
||||||
Crown,
|
Crown,
|
||||||
ShootingStar,
|
ShootingStar,
|
||||||
|
|
@ -576,16 +575,6 @@ registerApp({
|
||||||
paramKey: 'conversationId',
|
paramKey: 'conversationId',
|
||||||
});
|
});
|
||||||
|
|
||||||
registerApp({
|
|
||||||
id: 'kontext',
|
|
||||||
name: 'Web-Context',
|
|
||||||
color: '#A78B6F',
|
|
||||||
icon: Scroll,
|
|
||||||
views: {
|
|
||||||
list: { load: () => import('$lib/modules/kontext/KontextView.svelte') },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
registerApp({
|
registerApp({
|
||||||
id: 'times',
|
id: 'times',
|
||||||
name: 'Times',
|
name: 'Times',
|
||||||
|
|
|
||||||
|
|
@ -175,18 +175,6 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
||||||
'Nutze Vorlagen für wiederkehrende Aufgaben',
|
'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: {
|
context: {
|
||||||
description:
|
description:
|
||||||
'Strukturiertes Profil — Interessen, Tagesablauf, Ziele, Ernährung. Hilft der AI dich besser zu verstehen.',
|
'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
|
* sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission
|
||||||
* referencing any of these tables triggers the dialog.
|
* referencing any of these tables triggers the dialog.
|
||||||
*/
|
*/
|
||||||
const ENCRYPTED_SERVER_TABLES = new Set([
|
const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
|
||||||
'notes',
|
|
||||||
'tasks',
|
|
||||||
'events',
|
|
||||||
'journalEntries',
|
|
||||||
'kontextDoc',
|
|
||||||
]);
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Mission to issue the grant for. Required — the dialog reads
|
/** Mission to issue the grant for. Required — the dialog reads
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@
|
||||||
* Default input resolvers.
|
* Default input resolvers.
|
||||||
*
|
*
|
||||||
* Registered from `setup.ts` so the production MissionRunner can load
|
* Registered from `setup.ts` so the production MissionRunner can load
|
||||||
* notes / kontext / goals without every module having to know about the
|
* notes / goals / profile / todo / calendar without every module having
|
||||||
* AI subsystem. Modules that need special projection logic register their
|
* to know about the AI subsystem. Modules that need special projection
|
||||||
* own resolver on init and override these defaults.
|
* 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 { db } from '../../database';
|
||||||
import { decryptRecords } from '../../crypto';
|
import { decryptRecords } from '../../crypto';
|
||||||
import { scopedTable } from '../../scope/scoped-db';
|
|
||||||
import { registerInputResolver } from './input-resolvers';
|
import { registerInputResolver } from './input-resolvers';
|
||||||
import { registerInputIndexer } from './input-index';
|
import { registerInputIndexer } from './input-index';
|
||||||
import type { InputResolver } from './input-resolvers';
|
import type { InputResolver } from './input-resolvers';
|
||||||
|
|
@ -20,6 +25,7 @@ interface NoteLike {
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
|
isSpaceContext?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notesResolver: InputResolver = async (ref) => {
|
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) ──────────
|
// ── User Context (structured profile + freeform) ──────────
|
||||||
|
|
||||||
interface UserContextLike {
|
interface UserContextLike {
|
||||||
|
|
@ -155,35 +143,25 @@ const notesIndexer: InputIndexer = async () => {
|
||||||
const all = await db.table<NoteLike>('notes').toArray();
|
const all = await db.table<NoteLike>('notes').toArray();
|
||||||
const visible = all.filter((n) => !n.deletedAt);
|
const visible = all.filter((n) => !n.deletedAt);
|
||||||
const decrypted = await decryptRecords('notes', visible);
|
const decrypted = await decryptRecords('notes', visible);
|
||||||
return decrypted
|
const candidates = decrypted.map<InputCandidate>((n) => ({
|
||||||
.map<InputCandidate>((n) => ({
|
module: 'notes',
|
||||||
module: 'notes',
|
table: 'notes',
|
||||||
table: 'notes',
|
id: n.id,
|
||||||
id: n.id,
|
label: (n.isSpaceContext ? '★ ' : '') + ((n.title && n.title.trim()) || '(ohne Titel)'),
|
||||||
label: (n.title && n.title.trim()) || '(ohne Titel)',
|
hint: n.isSpaceContext
|
||||||
hint: n.content ? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…` : undefined,
|
? 'Space-Kontext (auto-injected)'
|
||||||
}))
|
: n.content
|
||||||
.slice(0, 200); // cap — Mission picker isn't meant to list thousands
|
? `${n.content.slice(0, 60).replace(/\s+/g, ' ')}…`
|
||||||
};
|
: undefined,
|
||||||
|
}));
|
||||||
const kontextIndexer: InputIndexer = async () => {
|
// Sort: space-context-flagged notes first, then alphabetical.
|
||||||
// Per-Space since Phase 2d.2: the kontextDoc for the active Space is
|
candidates.sort((a, b) => {
|
||||||
// the only candidate we surface to the picker. Personal-Space's legacy
|
const aFirst = a.label.startsWith('★ ');
|
||||||
// singleton row is matched via the `_personal:<userId>` sentinel in
|
const bFirst = b.label.startsWith('★ ');
|
||||||
// scopedTable's getInScopeSpaceIds(); Shared/Brand/Family Spaces that
|
if (aFirst !== bFirst) return aFirst ? -1 : 1;
|
||||||
// haven't yet authored a kontextDoc simply return an empty list.
|
return a.label.localeCompare(b.label);
|
||||||
const rows = await scopedTable<KontextDocLike, string>('kontextDoc').toArray();
|
});
|
||||||
const match = rows[0];
|
return candidates.slice(0, 200); // cap — Mission picker isn't meant to list thousands
|
||||||
if (!match) return [];
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
module: 'kontext',
|
|
||||||
table: 'kontextDoc',
|
|
||||||
id: match.id,
|
|
||||||
label: 'Kontext-Dokument',
|
|
||||||
hint: 'Dein zentrales Markdown-Dokument für diesen Space',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const goalsIndexer: InputIndexer = async () => {
|
const goalsIndexer: InputIndexer = async () => {
|
||||||
|
|
@ -303,13 +281,11 @@ let registered = false;
|
||||||
export function registerDefaultInputResolvers(): void {
|
export function registerDefaultInputResolvers(): void {
|
||||||
if (registered) return;
|
if (registered) return;
|
||||||
registerInputResolver('notes', notesResolver);
|
registerInputResolver('notes', notesResolver);
|
||||||
registerInputResolver('kontext', kontextResolver);
|
|
||||||
registerInputResolver('profile', userContextResolver);
|
registerInputResolver('profile', userContextResolver);
|
||||||
registerInputResolver('goals', goalsResolver);
|
registerInputResolver('goals', goalsResolver);
|
||||||
registerInputResolver('todo', tasksResolver);
|
registerInputResolver('todo', tasksResolver);
|
||||||
registerInputResolver('calendar', calendarResolver);
|
registerInputResolver('calendar', calendarResolver);
|
||||||
registerInputIndexer('notes', notesIndexer);
|
registerInputIndexer('notes', notesIndexer);
|
||||||
registerInputIndexer('kontext', kontextIndexer);
|
|
||||||
registerInputIndexer('profile', userContextIndexer);
|
registerInputIndexer('profile', userContextIndexer);
|
||||||
registerInputIndexer('goals', goalsIndexer);
|
registerInputIndexer('goals', goalsIndexer);
|
||||||
registerInputIndexer('todo', tasksIndexer);
|
registerInputIndexer('todo', tasksIndexer);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
*
|
*
|
||||||
* Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated
|
* Calls `POST /api/v1/me/bootstrap-singletons` on every authenticated
|
||||||
* boot. The server-side endpoint provisions any missing per-user
|
* boot. The server-side endpoint provisions any missing per-user
|
||||||
* (`userContext`) and per-Space (`kontextDoc`) singletons in
|
* `userContext` singleton in `mana_sync.sync_changes`. Idempotent — a
|
||||||
* `mana_sync.sync_changes`. Idempotent — a second call is a no-op.
|
* second call is a no-op.
|
||||||
*
|
*
|
||||||
* Why call it on boot when the signup-time hooks already do this work:
|
* 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
|
* the hooks are fire-and-forget and a transient mana_sync outage during
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
* the first write.
|
* the first write.
|
||||||
*
|
*
|
||||||
* Best-effort: failures are swallowed and logged. The webapp's
|
* Best-effort: failures are swallowed and logged. The webapp's
|
||||||
* fallback paths (`getOrCreateLocalDoc()` in `userContextStore` /
|
* fallback path (`getOrCreateLocalDoc()` in `userContextStore`) still
|
||||||
* `kontextStore`) still cover the rare race where a write happens
|
* covers the rare race where a write happens before the bootstrap row
|
||||||
* before the bootstrap row arrives.
|
* arrives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
|
||||||
|
|
@ -559,9 +559,6 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
|
moodEntries: { enabled: true, fields: ['withWhom', 'notes'] },
|
||||||
moodSettings: { enabled: false, fields: [] },
|
moodSettings: { enabled: false, fields: [] },
|
||||||
|
|
||||||
// ─── Kontext (legacy — now web-context, URL-crawl only) ──
|
|
||||||
kontextDoc: { enabled: true, fields: ['content'] },
|
|
||||||
|
|
||||||
// ─── User Context (profile hub) ──────────────────────────
|
// ─── User Context (profile hub) ──────────────────────────
|
||||||
// Structured profile sections + freeform markdown. Everything
|
// Structured profile sections + freeform markdown. Everything
|
||||||
// except the fixed id and interview progress is user-typed content.
|
// except the fixed id and interview progress is user-typed content.
|
||||||
|
|
@ -690,8 +687,9 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||||
'livingOracleSnapshot',
|
'livingOracleSnapshot',
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
// Per-agent kontext documents — free-form markdown, keyed per agent.
|
||||||
// per agent. Content is free-form markdown.
|
// 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'] },
|
agentKontextDocs: { enabled: true, fields: ['content'] },
|
||||||
|
|
||||||
// ─── Quiz ────────────────────────────────────────────────
|
// ─── Quiz ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1445,6 +1445,18 @@ db.version(57).stores({
|
||||||
formResponses: 'id, formId, status, submittedAt, _updatedAtIndex, [formId+status]',
|
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 Routing ──────────────────────────────────────────
|
||||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||||
// toSyncName() and fromSyncName() are now derived from per-module
|
// 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 { meditateModuleConfig } from '$lib/modules/meditate/module.config';
|
||||||
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
|
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
|
||||||
import { moodModuleConfig } from '$lib/modules/mood/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 { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
||||||
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||||
|
|
@ -158,7 +157,6 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||||
meditateModuleConfig,
|
meditateModuleConfig,
|
||||||
sleepModuleConfig,
|
sleepModuleConfig,
|
||||||
moodModuleConfig,
|
moodModuleConfig,
|
||||||
kontextModuleConfig,
|
|
||||||
quizModuleConfig,
|
quizModuleConfig,
|
||||||
profileModuleConfig,
|
profileModuleConfig,
|
||||||
libraryModuleConfig,
|
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 () => {
|
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'.
|
// 1. Client-side fallback creates an empty row stamped origin='system'.
|
||||||
// This is what `getOrCreateLocalDoc()` does in userContextStore /
|
// This is what `getOrCreateLocalDoc()` does in userContextStore
|
||||||
// kontextStore when a write lands before the first sync pull.
|
// when a write lands before the first sync pull.
|
||||||
const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP);
|
const bootstrapActor = makeSystemActor(SYSTEM_BOOTSTRAP);
|
||||||
await runAsAsync(bootstrapActor, async () => {
|
await runAsAsync(bootstrapActor, async () => {
|
||||||
await db.table('tasks').add({
|
await db.table('tasks').add({
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Policy editor ───────────────────────────────────────
|
// ── 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'];
|
const POLICY_CHOICES: PolicyDecision[] = ['auto', 'propose', 'deny'];
|
||||||
function policyLabel(c: PolicyDecision): string {
|
function policyLabel(c: PolicyDecision): string {
|
||||||
return $_('ai-agents.list_view.policy_label_' + c);
|
return $_('ai-agents.list_view.policy_label_' + c);
|
||||||
|
|
|
||||||
|
|
@ -113,13 +113,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Key-Grant (server-side execution) ──────────────────
|
// ── Key-Grant (server-side execution) ──────────────────
|
||||||
const ENCRYPTED_SERVER_TABLES = new Set([
|
const ENCRYPTED_SERVER_TABLES = new Set(['notes', 'tasks', 'events', 'journalEntries']);
|
||||||
'notes',
|
|
||||||
'tasks',
|
|
||||||
'events',
|
|
||||||
'journalEntries',
|
|
||||||
'kontextDoc',
|
|
||||||
]);
|
|
||||||
function hasEncryptedInputs(m: Mission): boolean {
|
function hasEncryptedInputs(m: Mission): boolean {
|
||||||
return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table));
|
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 type { ViewProps } from '$lib/app-registry';
|
||||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||||
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
|
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 FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
|
||||||
import AgentDot from '$lib/components/ai/AgentDot.svelte';
|
import AgentDot from '$lib/components/ai/AgentDot.svelte';
|
||||||
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
|
import ScopeEmptyState from '$lib/components/workbench/ScopeEmptyState.svelte';
|
||||||
import { hasActiveSceneScope } from '$lib/stores/scene-scope.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();
|
let { navigate, goBack, params }: ViewProps = $props();
|
||||||
|
|
||||||
|
|
@ -100,6 +102,60 @@
|
||||||
await notesStore.togglePin(id);
|
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>();
|
const ctxMenu = useItemContextMenu<Note>();
|
||||||
|
|
||||||
let ctxMenuItems = $derived<ContextMenuItem[]>(
|
let ctxMenuItems = $derived<ContextMenuItem[]>(
|
||||||
|
|
@ -123,6 +179,17 @@
|
||||||
if (target) notesStore.togglePin(target.id);
|
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: 'div', label: '', type: 'divider' as const },
|
||||||
{
|
{
|
||||||
id: 'delete',
|
id: 'delete',
|
||||||
|
|
@ -140,6 +207,67 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-view">
|
<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 -->
|
<!-- Search -->
|
||||||
{#if notes.length > 5}
|
{#if notes.length > 5}
|
||||||
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
|
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
|
||||||
|
|
@ -192,6 +320,14 @@
|
||||||
<div class="note-top">
|
<div class="note-top">
|
||||||
<span class="note-title">{note.title || 'Unbenannt'}</span>
|
<span class="note-title">{note.title || 'Unbenannt'}</span>
|
||||||
<AgentDot record={note} />
|
<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}
|
{#if note.isPinned}<span class="pin">📌</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if note.content}
|
{#if note.content}
|
||||||
|
|
@ -250,6 +386,116 @@
|
||||||
position: relative;
|
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 {
|
.search-input {
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border-radius: 0.375rem;
|
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
|
* Crawls a URL via mana-crawler, optionally summarises the result with
|
||||||
* crawler + LLM wrapper). Only the kontext singleton consumes it.
|
* 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';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
@ -25,10 +27,10 @@ export interface ImportResponse {
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function crawlUrlViaApi(input: ImportInput): Promise<ImportResponse> {
|
export async function crawlUrl(input: ImportInput): Promise<ImportResponse> {
|
||||||
const token = await authStore.getValidToken();
|
const token = await authStore.getValidToken();
|
||||||
if (!token) throw new Error('not authenticated');
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -35,6 +35,7 @@ export function toNote(local: LocalNote): Note {
|
||||||
transcriptModel: local.transcriptModel ?? null,
|
transcriptModel: local.transcriptModel ?? null,
|
||||||
isPinned: local.isPinned,
|
isPinned: local.isPinned,
|
||||||
isArchived: local.isArchived,
|
isArchived: local.isArchived,
|
||||||
|
isSpaceContext: local.isSpaceContext ?? false,
|
||||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||||
updatedAt: deriveUpdatedAt(local),
|
updatedAt: deriveUpdatedAt(local),
|
||||||
};
|
};
|
||||||
|
|
@ -63,6 +64,25 @@ export function useAllNotes() {
|
||||||
}, [] as Note[]);
|
}, [] 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. */
|
/** Single note by id, decrypted. Used by detail views. */
|
||||||
export function useNote(id: string) {
|
export function useNote(id: string) {
|
||||||
return useScopedLiveQuery(
|
return useScopedLiveQuery(
|
||||||
|
|
|
||||||
|
|
@ -121,4 +121,41 @@ export const notesStore = {
|
||||||
isArchived: true,
|
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;
|
transcriptModel?: string | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isArchived: 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 ─────────────────────────────────────────
|
// ─── Domain Types ─────────────────────────────────────────
|
||||||
|
|
@ -28,6 +37,7 @@ export interface Note {
|
||||||
transcriptModel: string | null;
|
transcriptModel: string | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
|
isSpaceContext: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { useUserContext } from './queries';
|
import { useUserContext } from './queries';
|
||||||
import { userContextStore } from './stores/user-context.svelte';
|
import { userContextStore } from './stores/user-context.svelte';
|
||||||
import { PencilSimple, Eye, LinkSimple, X } from '@mana/shared-icons';
|
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 { requireAuth } from '$lib/auth/require-auth.svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
|
|
||||||
|
|
@ -116,7 +116,7 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await crawlUrlViaApi({
|
const result = await crawlUrl({
|
||||||
url: trimmed,
|
url: trimmed,
|
||||||
mode: importMode,
|
mode: importMode,
|
||||||
summarize: importSummarize,
|
summarize: importSummarize,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
- note → searchable list of notes
|
- note → searchable list of notes
|
||||||
- library → searchable list of library entries
|
- library → searchable list of library entries
|
||||||
- url → freeform URL input + optional context note
|
- 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
|
- goal → searchable list of goals
|
||||||
- me-image → searchable list of profile reference images
|
- me-image → searchable list of profile reference images
|
||||||
|
|
||||||
|
|
@ -18,9 +18,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { useAllArticles } from '$lib/modules/articles/queries';
|
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 { useAllEntries as useAllLibraryEntries } from '$lib/modules/library/queries';
|
||||||
import { useKontextDoc } from '$lib/modules/kontext/queries';
|
|
||||||
import { useAllMeImages } from '$lib/modules/profile/queries';
|
import { useAllMeImages } from '$lib/modules/profile/queries';
|
||||||
import { useAllGoals } from '$lib/companion/goals/queries';
|
import { useAllGoals } from '$lib/companion/goals/queries';
|
||||||
import ReferenceChip from './ReferenceChip.svelte';
|
import ReferenceChip from './ReferenceChip.svelte';
|
||||||
|
|
@ -36,8 +35,8 @@
|
||||||
'me-image',
|
'me-image',
|
||||||
];
|
];
|
||||||
const MAX_REFERENCES = 6;
|
const MAX_REFERENCES = 6;
|
||||||
/** Sentinel targetId for the kontext-singleton — the resolver doesn't
|
/** Sentinel targetId — the resolver finds the flagged note by scope-scan,
|
||||||
* use it, but a non-null id keeps the de-dupe + chip-key logic uniform. */
|
* not by id, but a non-null id keeps the de-dupe + chip-key logic uniform. */
|
||||||
const KONTEXT_SINGLETON_ID = 'kontext:singleton';
|
const KONTEXT_SINGLETON_ID = 'kontext:singleton';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -51,7 +50,7 @@
|
||||||
const articles$ = useAllArticles();
|
const articles$ = useAllArticles();
|
||||||
const notes$ = useAllNotes();
|
const notes$ = useAllNotes();
|
||||||
const library$ = useAllLibraryEntries();
|
const library$ = useAllLibraryEntries();
|
||||||
const kontext$ = useKontextDoc();
|
const kontext$ = useSpaceContextNote();
|
||||||
const meImages$ = useAllMeImages();
|
const meImages$ = useAllMeImages();
|
||||||
const goals$ = useAllGoals();
|
const goals$ = useAllGoals();
|
||||||
|
|
||||||
|
|
@ -374,7 +373,7 @@
|
||||||
<div class="search">
|
<div class="search">
|
||||||
{#if !kontextDoc}
|
{#if !kontextDoc}
|
||||||
<p class="muted small">
|
<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'
|
'writing.reference_picker.kontext_empty_post'
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,6 @@ vi.mock('$lib/modules/library/queries', () => ({
|
||||||
...local,
|
...local,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
vi.mock('$lib/modules/kontext/queries', () => ({
|
|
||||||
toKontextDoc: vi.fn((local) => ({ ...local })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { scopedGet, scopedForModule } from '$lib/data/scope';
|
import { scopedGet, scopedForModule } from '$lib/data/scope';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
import { db } from '$lib/data/database';
|
import { db } from '$lib/data/database';
|
||||||
|
|
@ -242,14 +238,25 @@ describe('resolveReference - url', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveReference - kontext (singleton)', () => {
|
describe('resolveReference - kontext (Space-Kontext Note)', () => {
|
||||||
it('reads the singleton via scopedForModule and ignores the targetId', async () => {
|
it('reads the isSpaceContext-flagged Note via scopedForModule and ignores the targetId', async () => {
|
||||||
const toArrayMock = vi
|
const toArrayMock = vi.fn().mockResolvedValue([
|
||||||
.fn()
|
{ id: 'note-1', title: 'Random', content: 'no flag', isSpaceContext: false },
|
||||||
.mockResolvedValue([{ id: 'kontext-uuid', content: 'mein laufender kontext' }]);
|
{
|
||||||
|
id: 'note-2',
|
||||||
|
title: 'Brand-Profil',
|
||||||
|
content: 'mein laufender kontext',
|
||||||
|
isSpaceContext: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||||
mockDecryptRecords.mockResolvedValue([
|
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({
|
const result = await resolveReference({
|
||||||
|
|
@ -257,16 +264,30 @@ describe('resolveReference - kontext (singleton)', () => {
|
||||||
targetId: 'irrelevant',
|
targetId: 'irrelevant',
|
||||||
note: null,
|
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');
|
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
|
const toArrayMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue([
|
.mockResolvedValue([{ id: 'note-1', title: 'Random', content: 'no flag' }]);
|
||||||
{ id: 'kontext-uuid', content: 'old', deletedAt: '2026-01-01T00:00:00Z' },
|
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 });
|
mockScopedForModule.mockReturnValue({ toArray: toArrayMock });
|
||||||
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
|
const result = await resolveReference({ kind: 'kontext', targetId: 'x', note: null });
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,9 @@ import { db } from '$lib/data/database';
|
||||||
import { toArticle } from '$lib/modules/articles/queries';
|
import { toArticle } from '$lib/modules/articles/queries';
|
||||||
import { toNote } from '$lib/modules/notes/queries';
|
import { toNote } from '$lib/modules/notes/queries';
|
||||||
import { toLibraryEntry } from '$lib/modules/library/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 { LocalArticle } from '$lib/modules/articles/types';
|
||||||
import type { LocalNote } from '$lib/modules/notes/types';
|
import type { LocalNote } from '$lib/modules/notes/types';
|
||||||
import type { LocalLibraryEntry } from '$lib/modules/library/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 { LocalMeImage } from '$lib/modules/profile/types';
|
||||||
import type { LocalGoal } from '$lib/companion/goals/types';
|
import type { LocalGoal } from '$lib/companion/goals/types';
|
||||||
import type { DraftReference } from '../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
|
* Kontext = the active Space's standing-context Note (the one with
|
||||||
* targetId ('singleton'), and the resolver ignores it and picks the
|
* `isSpaceContext: true`). Picker stores a sentinel targetId since the
|
||||||
* first non-deleted row scoped to the active space. Legacy rows use
|
* resolver ignores it and finds the flagged note via scope-scan.
|
||||||
* id='singleton' explicitly; fresh rows use a uuid but are still
|
* Replaces the retired per-space `kontextDoc` singleton table — same
|
||||||
* singular per space.
|
* concept, regular Note as the storage.
|
||||||
*/
|
*/
|
||||||
async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
|
async function resolveKontext(): Promise<Omit<ResolvedReference, 'kind' | 'note'> | null> {
|
||||||
const rows = await scopedForModule<LocalKontextDoc, string>('kontext', 'kontextDoc').toArray();
|
const rows = await scopedForModule<LocalNote, string>('notes', 'notes').toArray();
|
||||||
const local = rows.find((r) => !r.deletedAt);
|
const local = rows.find((r) => !r.deletedAt && r.isSpaceContext === true);
|
||||||
if (!local) return null;
|
if (!local) return null;
|
||||||
const [decrypted] = await decryptRecords('kontextDoc', [local]);
|
const [decrypted] = await decryptRecords('notes', [local]);
|
||||||
if (!decrypted) return null;
|
if (!decrypted) return null;
|
||||||
const doc = toKontextDoc(decrypted);
|
const note = toNote(decrypted);
|
||||||
return {
|
return {
|
||||||
sourceLabel: 'Kontext-Dokument des Spaces',
|
sourceLabel: 'Space-Kontext (Notiz)',
|
||||||
title: 'Kontext',
|
title: note.title || 'Kontext',
|
||||||
content: truncate(doc.content ?? ''),
|
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>
|
|
||||||
|
|
@ -50,8 +50,8 @@ export enum CreditOperationType {
|
||||||
// Traces - City guide generation
|
// Traces - City guide generation
|
||||||
AI_GUIDE_GENERATION = 'ai_guide_generation',
|
AI_GUIDE_GENERATION = 'ai_guide_generation',
|
||||||
|
|
||||||
// Context - AI text generation
|
// Notes - URL crawl + optional summary into a Note
|
||||||
AI_CONTEXT_GENERATION = 'ai_context_generation',
|
NOTES_IMPORT_URL = 'notes_import_url',
|
||||||
|
|
||||||
// General AI features
|
// General AI features
|
||||||
AI_SMART_SCHEDULING = 'ai_smart_scheduling',
|
AI_SMART_SCHEDULING = 'ai_smart_scheduling',
|
||||||
|
|
@ -104,7 +104,7 @@ export const CREDIT_COSTS: Record<CreditOperationType, number> = {
|
||||||
|
|
||||||
[CreditOperationType.AI_PLANT_ANALYSIS]: 2,
|
[CreditOperationType.AI_PLANT_ANALYSIS]: 2,
|
||||||
[CreditOperationType.AI_GUIDE_GENERATION]: 5,
|
[CreditOperationType.AI_GUIDE_GENERATION]: 5,
|
||||||
[CreditOperationType.AI_CONTEXT_GENERATION]: 2,
|
[CreditOperationType.NOTES_IMPORT_URL]: 1,
|
||||||
|
|
||||||
[CreditOperationType.AI_SMART_SCHEDULING]: 2,
|
[CreditOperationType.AI_SMART_SCHEDULING]: 2,
|
||||||
[CreditOperationType.AI_SUGGESTIONS]: 2,
|
[CreditOperationType.AI_SUGGESTIONS]: 2,
|
||||||
|
|
@ -259,12 +259,12 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
|
||||||
app: 'traces',
|
app: 'traces',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Context
|
// Notes
|
||||||
[CreditOperationType.AI_CONTEXT_GENERATION]: {
|
[CreditOperationType.NOTES_IMPORT_URL]: {
|
||||||
name: 'AI Text Generation',
|
name: 'URL Import (Note)',
|
||||||
description: 'Generate or transform text with AI',
|
description: 'Crawl a URL and create a Markdown Note (optional AI summary)',
|
||||||
category: CreditCategory.AI,
|
category: CreditCategory.AI,
|
||||||
app: 'context',
|
app: 'notes',
|
||||||
},
|
},
|
||||||
|
|
||||||
// General AI
|
// General AI
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,9 @@ export const SYSTEM_STREAM = 'system:stream';
|
||||||
export const SYSTEM_MISSION_RUNNER = 'system:mission-runner';
|
export const SYSTEM_MISSION_RUNNER = 'system:mission-runner';
|
||||||
/**
|
/**
|
||||||
* Client-side singleton bootstrap. Stamped on the rare race-window
|
* Client-side singleton bootstrap. Stamped on the rare race-window
|
||||||
* `getOrCreateLocalDoc()` insert in `userContextStore` / `kontextStore`
|
* `getOrCreateLocalDoc()` insert in `userContextStore` — a structural
|
||||||
* — a structural twin of mana-auth's server-side bootstrap (which uses
|
* twin of mana-auth's server-side bootstrap (which uses the
|
||||||
* the `'system:bootstrap'` principalId on the wire). Maps to
|
* `'system:bootstrap'` principalId on the wire). Maps to
|
||||||
* `origin='system'` via `originFromActor`, so the conflict-gate exempts
|
* `origin='system'` via `originFromActor`, so the conflict-gate exempts
|
||||||
* it from the user-write codepath.
|
* it from the user-write codepath.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,6 @@ const track = {
|
||||||
contacts: createModuleTracker('contacts'),
|
contacts: createModuleTracker('contacts'),
|
||||||
cards: createModuleTracker('cards'),
|
cards: createModuleTracker('cards'),
|
||||||
mana: createModuleTracker('mana'),
|
mana: createModuleTracker('mana'),
|
||||||
context: createModuleTracker('context'),
|
|
||||||
skilltree: createModuleTracker('skilltree'),
|
skilltree: createModuleTracker('skilltree'),
|
||||||
food: createModuleTracker('food'),
|
food: createModuleTracker('food'),
|
||||||
plants: createModuleTracker('plants'),
|
plants: createModuleTracker('plants'),
|
||||||
|
|
@ -352,18 +351,6 @@ export const ManaEvents = {
|
||||||
track.mana('feature_blocked_by_auth', params),
|
track.mana('feature_blocked_by_auth', params),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Context App Events
|
|
||||||
*/
|
|
||||||
export const ContextEvents = {
|
|
||||||
documentCreated: (type: string) => track.context('document_created', { type }),
|
|
||||||
documentDeleted: () => track.context('document_deleted'),
|
|
||||||
documentPinned: (pinned: boolean) => track.context('document_pinned', { pinned }),
|
|
||||||
spaceCreated: () => track.context('space_created'),
|
|
||||||
spaceDeleted: () => track.context('space_deleted'),
|
|
||||||
aiGenerated: () => track.context('ai_generated'),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SkillTree App Events
|
* SkillTree App Events
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ import {
|
||||||
assertSpaceIsDeletable,
|
assertSpaceIsDeletable,
|
||||||
createPersonalSpaceFor,
|
createPersonalSpaceFor,
|
||||||
} from '../spaces';
|
} from '../spaces';
|
||||||
import { bootstrapSpaceSingletons } from '../services/bootstrap-singletons';
|
|
||||||
|
|
||||||
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
|
// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`)
|
||||||
// keep working. New code should import from './sso-origins' directly.
|
// keep working. New code should import from './sso-origins' directly.
|
||||||
|
|
@ -98,10 +97,10 @@ export interface BetterAuthWebAuthnOptions {
|
||||||
* Create Better Auth instance
|
* Create Better Auth instance
|
||||||
*
|
*
|
||||||
* @param databaseUrl - PostgreSQL connection URL for the auth DB
|
* @param databaseUrl - PostgreSQL connection URL for the auth DB
|
||||||
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. The
|
* @param syncDatabaseUrl - PostgreSQL connection URL for `mana_sync`. Held
|
||||||
* personal-space + organization hooks bootstrap per-Space singletons
|
* for use by the per-user `userContext` bootstrap; currently no
|
||||||
* into `sync_changes` so fresh clients pull the row instead of racing
|
* per-Space singletons are written here (the kontextDoc that used to
|
||||||
* on a local insert. See `bootstrapSpaceSingletons`.
|
* live here was retired in the Option-B cleanup).
|
||||||
* @param webauthn - WebAuthn settings for the passkey plugin
|
* @param webauthn - WebAuthn settings for the passkey plugin
|
||||||
* @returns Better Auth instance
|
* @returns Better Auth instance
|
||||||
*/
|
*/
|
||||||
|
|
@ -272,24 +271,6 @@ export function createBetterAuth(
|
||||||
name: user.name,
|
name: user.name,
|
||||||
accessTier: (user as { accessTier?: string | null }).accessTier,
|
accessTier: (user as { accessTier?: string | null }).accessTier,
|
||||||
});
|
});
|
||||||
// Bootstrap the personal Space's kontextDoc only on a
|
|
||||||
// real first-time creation. The `created: false` path
|
|
||||||
// means a previous signup retry already provisioned it
|
|
||||||
// and the doc has been bootstrapped before. Failures
|
|
||||||
// are logged but do not abort signup — the webapp's
|
|
||||||
// `ensureDoc()` fallback still creates the row on the
|
|
||||||
// first write attempt.
|
|
||||||
if (result.created) {
|
|
||||||
bootstrapSpaceSingletons(result.organizationId, user.id, getSyncSql()).catch(
|
|
||||||
(err: unknown) => {
|
|
||||||
logger.error('[auth] bootstrapSpaceSingletons (personal) failed', {
|
|
||||||
userId: user.id,
|
|
||||||
organizationId: result.organizationId,
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -378,32 +359,16 @@ export function createBetterAuth(
|
||||||
/**
|
/**
|
||||||
* Spaces — enforce that every organization carries a valid
|
* Spaces — enforce that every organization carries a valid
|
||||||
* `metadata.type` (the Space type), and block deletion of the
|
* `metadata.type` (the Space type), and block deletion of the
|
||||||
* user's personal space. After-create bootstraps per-Space
|
* user's personal space. The per-Space `kontextDoc` singleton
|
||||||
* singletons (currently `kontextDoc`) into mana_sync so fresh
|
* that used to be bootstrapped here was retired in favour of
|
||||||
* clients pull the row instead of racing on a local insert.
|
* the user-driven `notes.isSpaceContext` flag (Option B
|
||||||
* Personal-space gets the same bootstrap, but from
|
* cleanup), so the after-create hook is currently empty —
|
||||||
* `databaseHooks.user.create.after` because Better Auth's
|
* kept as a hook anchor for future per-Space bootstrap needs.
|
||||||
* `afterCreateOrganization` does not fire on the implicit
|
|
||||||
* personal-space creation that runs inside the user-create
|
|
||||||
* hook (createPersonalSpaceFor writes to `organizations`
|
|
||||||
* directly via Drizzle). See docs/plans/spaces-foundation.md
|
|
||||||
* and ../spaces/metadata.ts.
|
|
||||||
*/
|
*/
|
||||||
organizationHooks: {
|
organizationHooks: {
|
||||||
beforeCreateOrganization: async ({ organization }) => {
|
beforeCreateOrganization: async ({ organization }) => {
|
||||||
assertValidSpaceMetadataForCreate(organization.metadata);
|
assertValidSpaceMetadataForCreate(organization.metadata);
|
||||||
},
|
},
|
||||||
afterCreateOrganization: async ({ organization, user }) => {
|
|
||||||
bootstrapSpaceSingletons(organization.id, user.id, getSyncSql()).catch(
|
|
||||||
(err: unknown) => {
|
|
||||||
logger.error('[auth] bootstrapSpaceSingletons (org-hook) failed', {
|
|
||||||
userId: user.id,
|
|
||||||
organizationId: organization.id,
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
beforeDeleteOrganization: async ({ organization }) => {
|
beforeDeleteOrganization: async ({ organization }) => {
|
||||||
assertSpaceIsDeletable(organization.metadata);
|
assertSpaceIsDeletable(organization.metadata);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -149,10 +149,10 @@ app.route('/api/v1/me/ai-mission-grant', createAiMissionGrantRoutes(missionGrant
|
||||||
app.route('/api/v1/me/onboarding', createOnboardingRoutes(db));
|
app.route('/api/v1/me/onboarding', createOnboardingRoutes(db));
|
||||||
|
|
||||||
// ─── Singleton Bootstrap ────────────────────────────────────
|
// ─── Singleton Bootstrap ────────────────────────────────────
|
||||||
// Idempotent reconciliation endpoint for per-user + per-Space sync
|
// Idempotent reconciliation endpoint for the per-user `userContext`
|
||||||
// singletons (userContext, kontextDoc). Webapp boot calls this once;
|
// singleton. Webapp boot calls this once; the signup-time hook remains
|
||||||
// signup-time hooks remain the happy path. See
|
// the happy path. See docs/plans/sync-field-meta-overhaul.md and
|
||||||
// docs/plans/sync-field-meta-overhaul.md and routes/me-bootstrap.ts.
|
// routes/me-bootstrap.ts.
|
||||||
app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl));
|
app.route('/api/v1/me/bootstrap-singletons', createMeBootstrapRoutes(db, config.syncDatabaseUrl));
|
||||||
|
|
||||||
// ─── Settings ──────────────────────────────────────────────
|
// ─── Settings ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,34 @@
|
||||||
* Singleton bootstrap endpoint.
|
* Singleton bootstrap endpoint.
|
||||||
*
|
*
|
||||||
* `POST /api/v1/me/bootstrap-singletons` — idempotently provisions the
|
* `POST /api/v1/me/bootstrap-singletons` — idempotently provisions the
|
||||||
* per-user `userContext` singleton and the per-Space `kontextDoc` for
|
* per-user `userContext` singleton. Called once per webapp boot as a
|
||||||
* every Space the caller is a member of. Called once per webapp boot
|
* reconciliation belt-and-suspenders for the signup-time hook
|
||||||
* as a reconciliation belt-and-suspenders for the signup-time hooks
|
* (databaseHooks.user.create.after).
|
||||||
* (databaseHooks.user.create.after + organizationHooks.afterCreateOrganization).
|
|
||||||
*
|
*
|
||||||
* Why both: the signup hooks are zero-latency happy-path bootstraps but
|
* Why both: the signup hook is a zero-latency happy-path bootstrap but
|
||||||
* fire-and-forget — a transient mana_sync outage during signup leaves
|
* fire-and-forget — a transient mana_sync outage during signup leaves
|
||||||
* the user with no singleton and no signal that anything is wrong. The
|
* the user with no singleton and no signal that anything is wrong. The
|
||||||
* boot-time endpoint converges to the right state on every load.
|
* boot-time endpoint converges to the right state on every load.
|
||||||
* Idempotency in the bootstrap functions makes double-invocation
|
* Idempotency in the bootstrap function makes double-invocation
|
||||||
* harmless.
|
* harmless.
|
||||||
*
|
*
|
||||||
* The endpoint is deliberately simple: no body, no parameters. The
|
* The endpoint is deliberately simple: no body, no parameters. The
|
||||||
* caller's identity (and thus the userId + space-membership list)
|
* caller's identity (and thus the userId) comes from the JWT.
|
||||||
* comes from the JWT.
|
*
|
||||||
|
* Per-Space singletons used to be bootstrapped here too (kontextDoc),
|
||||||
|
* but the kontextDoc table was retired in favour of the user-driven
|
||||||
|
* `notes.isSpaceContext` flag — there is nothing to bootstrap per
|
||||||
|
* Space anymore. The response shape keeps the `spaces` map for
|
||||||
|
* backwards compatibility with older webapp builds; it is always
|
||||||
|
* empty now.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { logger } from '@mana/shared-hono';
|
import { logger } from '@mana/shared-hono';
|
||||||
import type { AuthUser } from '../middleware/jwt-auth';
|
import type { AuthUser } from '../middleware/jwt-auth';
|
||||||
import type { Database } from '../db/connection';
|
import type { Database } from '../db/connection';
|
||||||
import { members } from '../db/schema/organizations';
|
import { bootstrapUserSingletons } from '../services/bootstrap-singletons';
|
||||||
import {
|
|
||||||
bootstrapUserSingletons,
|
|
||||||
bootstrapSpaceSingletons,
|
|
||||||
} from '../services/bootstrap-singletons';
|
|
||||||
|
|
||||||
export interface BootstrapResponse {
|
export interface BootstrapResponse {
|
||||||
ok: true;
|
ok: true;
|
||||||
|
|
@ -40,7 +40,7 @@ export interface BootstrapResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMeBootstrapRoutes(
|
export function createMeBootstrapRoutes(
|
||||||
db: Database,
|
_db: Database,
|
||||||
syncDatabaseUrl: string
|
syncDatabaseUrl: string
|
||||||
): Hono<{ Variables: { user: AuthUser } }> {
|
): Hono<{ Variables: { user: AuthUser } }> {
|
||||||
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and
|
// Lazy module-scoped postgres pool. Mirrors routes/auth.ts and
|
||||||
|
|
@ -71,38 +71,6 @@ export function createMeBootstrapRoutes(
|
||||||
return c.json({ ok: false, error: 'userContext bootstrap failed' }, 500);
|
return c.json({ ok: false, error: 'userContext bootstrap failed' }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap every Space the user is a member of. The owner of a
|
|
||||||
// Space is the canonical writer for its singletons, but RLS
|
|
||||||
// only gates by user_id (writer); the membership-aware pull
|
|
||||||
// delivers the row to every member regardless of which member
|
|
||||||
// inserted it. If the owner's bootstrap failed at signup time
|
|
||||||
// and a non-owner member calls this endpoint first, the
|
|
||||||
// member's bootstrap stands in.
|
|
||||||
const memberRows = await db
|
|
||||||
.select({ organizationId: members.organizationId })
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.userId));
|
|
||||||
|
|
||||||
for (const row of memberRows) {
|
|
||||||
const spaceId = row.organizationId;
|
|
||||||
try {
|
|
||||||
result.bootstrapped.spaces[spaceId] = await bootstrapSpaceSingletons(
|
|
||||||
spaceId,
|
|
||||||
user.userId,
|
|
||||||
syncSql
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('[me/bootstrap-singletons] space failed', {
|
|
||||||
userId: user.userId,
|
|
||||||
spaceId,
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
// Don't abort — surface the per-space outcome and
|
|
||||||
// continue. The caller can retry on next boot.
|
|
||||||
result.bootstrapped.spaces[spaceId] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,27 @@
|
||||||
/**
|
/**
|
||||||
* Server-side singleton bootstrap.
|
* Server-side singleton bootstrap.
|
||||||
*
|
*
|
||||||
* On first user-creation and Space-creation, write the singleton records
|
* On first user-creation, write the singleton records that the webapp
|
||||||
* that the webapp would otherwise create on demand via `ensureDoc()` /
|
* would otherwise create on demand via `ensureDoc()` /
|
||||||
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic — every
|
* `getOrCreateLocalDoc()`. This makes the bootstrap deterministic —
|
||||||
* fresh client pulls the singleton from mana-sync instead of racing on a
|
* every fresh client pulls the singleton from mana-sync instead of
|
||||||
* local insert that the next pull would clobber.
|
* racing on a local insert that the next pull would clobber.
|
||||||
*
|
*
|
||||||
* Currently bootstrapped:
|
* Currently bootstrapped:
|
||||||
* - `userContext` — per-user. The structured profile + freeform markdown
|
* - `userContext` — per-user. The structured profile + freeform markdown
|
||||||
* blob keyed by `id='singleton'`. Default shape mirrors the webapp's
|
* blob keyed by `id='singleton'`. Default shape mirrors the webapp's
|
||||||
* `emptyUserContext()` factory in `profile/types.ts`.
|
* `emptyUserContext()` factory in `profile/types.ts`.
|
||||||
* - `kontextDoc` — per-Space. The freeform markdown context document
|
|
||||||
* (encrypted at rest in normal client writes; bootstrap writes the
|
|
||||||
* empty default in plaintext, the client's `decryptRecord` skips
|
|
||||||
* non-envelope strings so this is safe).
|
|
||||||
*
|
*
|
||||||
* Idempotency: each function performs an existence-check on
|
* (The per-Space `kontextDoc` singleton was retired — the
|
||||||
|
* notes.isSpaceContext flag now carries the same role, and a flagged
|
||||||
|
* Note is created on demand by the user, not bootstrapped empty.)
|
||||||
|
*
|
||||||
|
* Idempotency: the function performs an existence-check on
|
||||||
* `sync_changes` before inserting — if a row matching the singleton's
|
* `sync_changes` before inserting — if a row matching the singleton's
|
||||||
* scope already exists, the call is a no-op. This makes the bootstrap
|
* scope already exists, the call is a no-op. This makes the bootstrap
|
||||||
* safe to run from multiple sources without producing duplicate rows:
|
* safe to run from multiple sources without producing duplicate rows:
|
||||||
* - signup-time hooks (databaseHooks.user.create.after,
|
* - signup-time hook (databaseHooks.user.create.after) — fires on the
|
||||||
* organizationHooks.afterCreateOrganization) — fire on the happy
|
* happy path
|
||||||
* path
|
|
||||||
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires
|
* - boot-time endpoint (POST /api/v1/me/bootstrap-singletons) — fires
|
||||||
* on every webapp boot as a reconciliation belt-and-suspenders
|
* on every webapp boot as a reconciliation belt-and-suspenders
|
||||||
*
|
*
|
||||||
|
|
@ -88,19 +87,6 @@ function emptyUserContextData(userId: string): Record<string, unknown> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Default content for a new Space's `kontextDoc`. Just an id + empty
|
|
||||||
* content — the user fills in the markdown later. Encryption is skipped
|
|
||||||
* (empty string reveals nothing); the client's `decryptRecord` is
|
|
||||||
* tolerant of plaintext values for encrypted-registry fields.
|
|
||||||
*/
|
|
||||||
function emptyKontextDocData(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
content: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert the per-user singletons into mana_sync.sync_changes. Idempotent
|
* Insert the per-user singletons into mana_sync.sync_changes. Idempotent
|
||||||
* — skips the insert if a row for `(userContext, 'singleton', userId)`
|
* — skips the insert if a row for `(userContext, 'singleton', userId)`
|
||||||
|
|
@ -149,62 +135,3 @@ export async function bootstrapUserSingletons(
|
||||||
`;
|
`;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert the per-Space singletons into mana_sync.sync_changes. Idempotent
|
|
||||||
* — skips the insert if any `kontextDoc` row already exists for the
|
|
||||||
* given `spaceId` (regardless of writer). Called from:
|
|
||||||
* - `databaseHooks.user.create.after` once `createPersonalSpaceFor`
|
|
||||||
* returns `created: true` (personal-space happy path)
|
|
||||||
* - `organizationHooks.afterCreateOrganization` (brand / club /
|
|
||||||
* family / team / practice happy path)
|
|
||||||
* - `POST /api/v1/me/bootstrap-singletons` for every space the
|
|
||||||
* caller is a member of (boot-time reconciliation)
|
|
||||||
*
|
|
||||||
* `ownerUserId` is the writer (RLS guard); `spaceId` is the data scope.
|
|
||||||
* For non-personal Spaces the inviting user remains the writer — joining
|
|
||||||
* members will receive the row via the membership-aware pull. If the
|
|
||||||
* inviter's bootstrap somehow failed and a member triggers it later via
|
|
||||||
* the endpoint, the member becomes the writer; the row is still
|
|
||||||
* delivered to all members via the membership-aware pull.
|
|
||||||
*
|
|
||||||
* Returns true if an insert was actually written, false if the
|
|
||||||
* idempotency check skipped it.
|
|
||||||
*/
|
|
||||||
export async function bootstrapSpaceSingletons(
|
|
||||||
spaceId: string,
|
|
||||||
ownerUserId: string,
|
|
||||||
syncSql: ReturnType<typeof postgres>
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!spaceId) throw new Error('bootstrapSpaceSingletons: empty spaceId');
|
|
||||||
if (!ownerUserId) throw new Error('bootstrapSpaceSingletons: empty ownerUserId');
|
|
||||||
|
|
||||||
const existing = await syncSql<Array<{ exists: number }>>`
|
|
||||||
SELECT 1 AS exists
|
|
||||||
FROM sync_changes
|
|
||||||
WHERE table_name = 'kontextDoc'
|
|
||||||
AND space_id = ${spaceId}
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
if (existing.length > 0) return false;
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const data = emptyKontextDocData();
|
|
||||||
const fieldMeta = buildFieldMeta(data, now);
|
|
||||||
|
|
||||||
await syncSql`
|
|
||||||
INSERT INTO sync_changes (
|
|
||||||
app_id, table_name, record_id, user_id, space_id, op, data,
|
|
||||||
field_meta, client_id, schema_version, actor, origin
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
'kontext', 'kontextDoc', ${data.id as string}, ${ownerUserId}, ${spaceId}, 'insert',
|
|
||||||
${syncSql.json(data as never)},
|
|
||||||
${syncSql.json(fieldMeta as never)},
|
|
||||||
${BOOTSTRAP_CLIENT_ID}, 1,
|
|
||||||
${syncSql.json(BOOTSTRAP_ACTOR as never)},
|
|
||||||
${BOOTSTRAP_ORIGIN}
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue