mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-25 02:44:38 +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
194
apps/api/src/modules/notes/routes.ts
Normal file
194
apps/api/src/modules/notes/routes.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* Notes module — server-side helpers.
|
||||
*
|
||||
* 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 { consumeCredits, validateCredits } from '@mana/shared-hono/credits';
|
||||
import type { AuthVariables } from '@mana/shared-hono';
|
||||
import { MANA_LLM } from '@mana/shared-ai';
|
||||
|
||||
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
|
||||
const CRAWLER_URL = process.env.MANA_CRAWLER_URL || 'http://localhost:3023';
|
||||
const DEFAULT_SUMMARY_MODEL = MANA_LLM.FAST_TEXT;
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
const DEEP_MAX_PAGES = 20;
|
||||
const CRAWL_POLL_INTERVAL_MS = 1500;
|
||||
const CRAWL_TIMEOUT_MS = 90_000;
|
||||
|
||||
/**
|
||||
* Local LLMs love to wrap Markdown in ```markdown fences or prepend
|
||||
* a "Hier ist die Zusammenfassung:" preamble. Strip those so the
|
||||
* output renders correctly when dropped into a Note body.
|
||||
*/
|
||||
function sanitizeSummary(raw: string): string {
|
||||
let s = raw.trim();
|
||||
const fenceMatch = s.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n?```\s*$/i);
|
||||
if (fenceMatch) s = fenceMatch[1].trim();
|
||||
const lines = s.split('\n');
|
||||
if (lines.length > 2 && /^[^#\n].{0,80}:\s*$/.test(lines[0].trim())) {
|
||||
s = lines.slice(1).join('\n').trim();
|
||||
}
|
||||
s = s.replace(/^#\s+/, '## ');
|
||||
return s;
|
||||
}
|
||||
|
||||
async function pollCrawlJob(jobId: string) {
|
||||
const deadline = Date.now() + CRAWL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, CRAWL_POLL_INTERVAL_MS));
|
||||
const res = await fetch(`${CRAWLER_URL}/api/v1/crawl/${jobId}`);
|
||||
if (!res.ok) throw new Error(`crawl status ${res.status}`);
|
||||
const job = (await res.json()) as { status: string; error?: string };
|
||||
if (job.status === 'completed') return;
|
||||
if (job.status === 'failed') throw new Error(job.error || 'crawl failed');
|
||||
}
|
||||
throw new Error('crawl timeout');
|
||||
}
|
||||
|
||||
routes.post('/import-url', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const {
|
||||
url,
|
||||
mode = 'single',
|
||||
summarize = false,
|
||||
} = (await c.req.json()) as {
|
||||
url?: string;
|
||||
mode?: 'single' | 'deep';
|
||||
summarize?: boolean;
|
||||
};
|
||||
|
||||
if (!url || !/^https?:\/\//i.test(url)) {
|
||||
return c.json({ error: 'valid http(s) url required' }, 400);
|
||||
}
|
||||
|
||||
const creditCost = summarize ? 5 : 1;
|
||||
const validation = await validateCredits(userId, 'NOTES_IMPORT_URL', creditCost);
|
||||
if (!validation.hasCredits) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Insufficient credits',
|
||||
required: creditCost,
|
||||
available: validation.availableCredits,
|
||||
},
|
||||
402
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const crawlBody = {
|
||||
startUrl: url,
|
||||
config: {
|
||||
maxDepth: mode === 'deep' ? 3 : 0,
|
||||
maxPages: mode === 'deep' ? DEEP_MAX_PAGES : 1,
|
||||
rateLimit: 2,
|
||||
respectRobots: true,
|
||||
outputFormat: 'markdown',
|
||||
},
|
||||
};
|
||||
|
||||
const startRes = await fetch(`${CRAWLER_URL}/api/v1/crawl`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(crawlBody),
|
||||
});
|
||||
if (!startRes.ok) return c.json({ error: 'crawler unreachable' }, 502);
|
||||
const { jobId } = (await startRes.json()) as { jobId: string };
|
||||
|
||||
await pollCrawlJob(jobId);
|
||||
|
||||
const resultsRes = await fetch(
|
||||
`${CRAWLER_URL}/api/v1/crawl/${jobId}/results?page=1&limit=${DEEP_MAX_PAGES}`
|
||||
);
|
||||
if (!resultsRes.ok) return c.json({ error: 'crawl results failed' }, 502);
|
||||
const results = (await resultsRes.json()) as {
|
||||
results: Array<{
|
||||
url: string;
|
||||
title?: string | null;
|
||||
markdown?: string | null;
|
||||
content?: string | null;
|
||||
depth: number;
|
||||
}>;
|
||||
};
|
||||
const items = (results.results || []).filter((it) => it.markdown || it.content);
|
||||
if (items.length === 0) return c.json({ error: 'no content crawled' }, 422);
|
||||
|
||||
items.sort((a, b) => a.depth - b.depth);
|
||||
const root = items[0];
|
||||
const pageTitle = root.title || new URL(url).hostname;
|
||||
|
||||
let content: string;
|
||||
if (mode === 'deep' && items.length > 1) {
|
||||
content = items
|
||||
.map((it) => `# ${it.title || it.url}\n\n_${it.url}_\n\n${it.markdown || it.content}`)
|
||||
.join('\n\n---\n\n');
|
||||
} else {
|
||||
content = root.markdown || root.content || '';
|
||||
}
|
||||
|
||||
if (summarize) {
|
||||
const summaryRes = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_SUMMARY_MODEL,
|
||||
max_tokens: 2000,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Du bist ein Assistent, der Web-Inhalte in strukturierte Notiz-Dokumente zusammenfasst. ' +
|
||||
'Antworte ausschließlich in sauberem Markdown. Gliedere in H2-Abschnitte: ' +
|
||||
'"## Überblick", "## Kernaussagen", "## Details". Nutze die Sprache der Quelle. ' +
|
||||
'Schreibe die Antwort direkt, ohne Einleitung ("Hier ist…"), ohne Schlussformel, ' +
|
||||
'und OHNE Code-Fences (```) um die Antwort.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Quelle: ${url}\n\n${content.slice(0, 60_000)}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (summaryRes.ok) {
|
||||
const data = (await summaryRes.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const raw = data.choices?.[0]?.message?.content?.trim();
|
||||
if (raw) {
|
||||
content = sanitizeSummary(raw);
|
||||
}
|
||||
} else {
|
||||
return c.json({ error: 'summary failed' }, 502);
|
||||
}
|
||||
}
|
||||
|
||||
await consumeCredits(
|
||||
userId,
|
||||
'NOTES_IMPORT_URL',
|
||||
creditCost,
|
||||
`URL import (${mode}${summarize ? ' + summary' : ''})`
|
||||
);
|
||||
|
||||
return c.json({
|
||||
title: pageTitle,
|
||||
content,
|
||||
sourceUrl: url,
|
||||
crawlMode: mode,
|
||||
crawledAt: new Date().toISOString(),
|
||||
pageCount: items.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'import failed';
|
||||
return c.json({ error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { routes as notesRoutes };
|
||||
Loading…
Add table
Add a link
Reference in a new issue