diff --git a/web/src/routes/admin/entities/+page.server.ts b/web/src/routes/admin/entities/+page.server.ts
new file mode 100644
index 0000000..ebe1512
--- /dev/null
+++ b/web/src/routes/admin/entities/+page.server.ts
@@ -0,0 +1,19 @@
+import { error } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+import { sql } from '$lib/server/db';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!locals.workspace) error(401);
+
+ // Hent alle entiteter med antall edges (for å identifisere duplikater)
+ const entities = await sql`
+ SELECT e.id, e.name, e.type, e.aliases, e.avatar_url,
+ (SELECT COUNT(*) FROM graph_edges WHERE source_id = e.id OR target_id = e.id) AS edge_count
+ FROM entities e
+ JOIN nodes n ON n.id = e.id
+ WHERE n.workspace_id = ${locals.workspace.id}
+ ORDER BY e.name
+ `;
+
+ return { entities };
+};
diff --git a/web/src/routes/admin/entities/+page.svelte b/web/src/routes/admin/entities/+page.svelte
new file mode 100644
index 0000000..90a6a28
--- /dev/null
+++ b/web/src/routes/admin/entities/+page.svelte
@@ -0,0 +1,347 @@
+
+
+
+
Entiteter
+
+
+
+ {#if message}
+
{message}
+ {/if}
+
+
+
+
+
diff --git a/web/src/routes/api/ai/process/+server.ts b/web/src/routes/api/ai/process/+server.ts
new file mode 100644
index 0000000..c53f6a1
--- /dev/null
+++ b/web/src/routes/api/ai/process/+server.ts
@@ -0,0 +1,50 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/**
+ * POST /api/ai/process — Opprett AI-behandlingsjobb for en melding.
+ *
+ * Body: { message_id, action?, prompt_override?, model? }
+ *
+ * Oppretter en jobb i job_queue som Rust-workeren plukker opp.
+ * Returnerer jobb-ID umiddelbart (asynkront).
+ */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+ const workspace = locals.workspace;
+
+ const body = await request.json();
+ const { message_id, action, prompt_override, model } = body;
+
+ if (!message_id || typeof message_id !== 'string') {
+ error(400, 'message_id er påkrevd');
+ }
+
+ // Verifiser at meldingen finnes og tilhører workspace
+ const [msg] = await sql`
+ SELECT m.id FROM messages m
+ JOIN nodes n ON n.id = m.id
+ WHERE m.id = ${message_id} AND n.workspace_id = ${workspace.id}
+ `;
+ if (!msg) error(404, 'Melding ikke funnet');
+
+ // Opprett jobb i køen
+ const [job] = await sql`
+ INSERT INTO job_queue (workspace_id, job_type, payload, priority)
+ VALUES (
+ ${workspace.id},
+ 'ai_text_process',
+ ${JSON.stringify({
+ message_id,
+ action: action ?? 'fix_text',
+ prompt_override: prompt_override ?? null,
+ model: model ?? null
+ })}::jsonb,
+ 10
+ )
+ RETURNING id, status, created_at
+ `;
+
+ return json({ job_id: job.id, status: job.status }, { status: 202 });
+};
diff --git a/web/src/routes/api/entities/merge/+server.ts b/web/src/routes/api/entities/merge/+server.ts
new file mode 100644
index 0000000..8267af8
--- /dev/null
+++ b/web/src/routes/api/entities/merge/+server.ts
@@ -0,0 +1,133 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { sql } from '$lib/server/db';
+
+/**
+ * POST /api/entities/merge — Slå sammen duplikate entiteter.
+ *
+ * Body: { target_id: "uuid", source_ids: ["uuid", ...] }
+ *
+ * target_id = autoritativ entitet som beholdes
+ * source_ids = duplikater som absorberes og slettes
+ *
+ * For hver source:
+ * 1. Flytt alle graph_edges (source_id og target_id) til target
+ * 2. Oppdater mentions i messages.body (data-id attributter)
+ * 3. Legg til source.name som alias på target
+ * 4. Slett source-noden (cascader til entities, edges)
+ */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+ const workspace = locals.workspace;
+
+ const body = await request.json();
+ const { target_id, source_ids } = body;
+
+ if (!target_id || !Array.isArray(source_ids) || source_ids.length === 0) {
+ error(400, 'target_id og source_ids (array) er påkrevd');
+ }
+
+ if (source_ids.includes(target_id)) {
+ error(400, 'target_id kan ikke være i source_ids');
+ }
+
+ // Verifiser at alle entiteter finnes og tilhører workspace
+ const allIds = [target_id, ...source_ids];
+ const entities = await sql`
+ SELECT e.id, e.name, e.aliases
+ FROM entities e
+ JOIN nodes n ON n.id = e.id
+ WHERE e.id = ANY(${allIds}) AND n.workspace_id = ${workspace.id}
+ `;
+
+ if (entities.length !== allIds.length) {
+ error(404, 'En eller flere entiteter ikke funnet i workspace');
+ }
+
+ const targetEntity = entities.find((e) => (e as { id: string }).id === target_id) as {
+ id: string;
+ name: string;
+ aliases: string[];
+ };
+
+ const merged: string[] = [];
+
+ for (const sourceId of source_ids) {
+ const sourceEntity = entities.find((e) => (e as { id: string }).id === sourceId) as {
+ id: string;
+ name: string;
+ aliases: string[];
+ };
+
+ // 1. Flytt graph_edges der source er source_id → target
+ // Bruk ON CONFLICT for å unngå duplikate edges
+ await sql`
+ UPDATE graph_edges
+ SET source_id = ${target_id}
+ WHERE source_id = ${sourceId}
+ AND NOT EXISTS (
+ SELECT 1 FROM graph_edges existing
+ WHERE existing.source_id = ${target_id}
+ AND existing.target_id = graph_edges.target_id
+ AND existing.relation_type = graph_edges.relation_type
+ )
+ `;
+ // Slett eventuelle gjenværende (duplikater som ikke ble flyttet)
+ await sql`
+ DELETE FROM graph_edges WHERE source_id = ${sourceId}
+ `;
+
+ // 2. Flytt graph_edges der source er target_id → target
+ await sql`
+ UPDATE graph_edges
+ SET target_id = ${target_id}
+ WHERE target_id = ${sourceId}
+ AND source_id != ${target_id}
+ AND NOT EXISTS (
+ SELECT 1 FROM graph_edges existing
+ WHERE existing.source_id = graph_edges.source_id
+ AND existing.target_id = ${target_id}
+ AND existing.relation_type = graph_edges.relation_type
+ )
+ `;
+ await sql`
+ DELETE FROM graph_edges WHERE target_id = ${sourceId}
+ `;
+
+ // 3. Oppdater mentions i messages.body (data-id="source" → data-id="target")
+ await sql`
+ UPDATE messages
+ SET body = REPLACE(body, ${`data-id="${sourceId}"`}, ${`data-id="${target_id}"`})
+ WHERE body LIKE ${`%${sourceId}%`}
+ `;
+
+ // 4. Legg til source.name + aliases som alias på target
+ const newAliases = [sourceEntity.name, ...(sourceEntity.aliases ?? [])].filter(
+ (a) => a !== targetEntity.name && !targetEntity.aliases?.includes(a)
+ );
+ if (newAliases.length > 0) {
+ await sql`
+ UPDATE entities
+ SET aliases = aliases || ${newAliases}::text[]
+ WHERE id = ${target_id}
+ `;
+ targetEntity.aliases = [...(targetEntity.aliases ?? []), ...newAliases];
+ }
+
+ // 5. Slett source-noden (cascader til entities via FK)
+ await sql`DELETE FROM nodes WHERE id = ${sourceId}`;
+
+ merged.push(sourceEntity.name);
+ }
+
+ // Hent oppdatert target
+ const [result] = await sql`
+ SELECT e.id, e.name, e.type, e.aliases, e.avatar_url
+ FROM entities e WHERE e.id = ${target_id}
+ `;
+
+ return json({
+ merged: merged,
+ target: result
+ });
+};
diff --git a/web/src/routes/entities/[id]/+page.server.ts b/web/src/routes/entities/[id]/+page.server.ts
new file mode 100644
index 0000000..74edc2d
--- /dev/null
+++ b/web/src/routes/entities/[id]/+page.server.ts
@@ -0,0 +1,71 @@
+import { error } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+import { sql } from '$lib/server/db';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!locals.workspace || !locals.user) error(401);
+ const workspace = locals.workspace;
+ const entityId = params.id;
+
+ // 1. Hent entiteten
+ const [entity] = await sql`
+ SELECT e.id, e.name, e.type, e.aliases, e.avatar_url,
+ n.created_at, n.updated_at
+ FROM entities e
+ JOIN nodes n ON n.id = e.id
+ WHERE e.id = ${entityId} AND n.workspace_id = ${workspace.id}
+ `;
+ if (!entity) error(404, 'Entitet ikke funnet');
+
+ // 2. Hent relasjoner (edges) med info om tilkoblede noder
+ const edges = await sql`
+ SELECT
+ ge.id AS edge_id,
+ ge.source_id,
+ ge.target_id,
+ ge.relation_type,
+ ge.confidence,
+ ge.origin,
+ ge.created_at,
+ CASE
+ WHEN ge.source_id = ${entityId} THEN target_e.name
+ ELSE source_e.name
+ END AS connected_name,
+ CASE
+ WHEN ge.source_id = ${entityId} THEN target_e.type
+ ELSE source_e.type
+ END AS connected_type,
+ CASE
+ WHEN ge.source_id = ${entityId} THEN ge.target_id
+ ELSE ge.source_id
+ END AS connected_id,
+ CASE
+ WHEN ge.source_id = ${entityId} THEN 'outgoing'
+ ELSE 'incoming'
+ END AS direction
+ FROM graph_edges ge
+ LEFT JOIN entities source_e ON source_e.id = ge.source_id
+ LEFT JOIN entities target_e ON target_e.id = ge.target_id
+ WHERE ge.workspace_id = ${workspace.id}
+ AND (ge.source_id = ${entityId} OR ge.target_id = ${entityId})
+ ORDER BY ge.created_at DESC
+ `;
+
+ // 3. Hent meldinger som nevner denne entiteten (via MENTIONS-edges)
+ const mentions = await sql`
+ SELECT m.id, m.body, m.title, m.message_type, m.created_at,
+ u.display_name AS author_name,
+ c.id AS channel_id
+ FROM graph_edges ge
+ JOIN messages m ON m.id = ge.source_id
+ LEFT JOIN users u ON u.authentik_id = m.author_id
+ LEFT JOIN channels c ON c.id = m.channel_id
+ WHERE ge.target_id = ${entityId}
+ AND ge.relation_type = 'MENTIONS'
+ AND ge.workspace_id = ${workspace.id}
+ ORDER BY m.created_at DESC
+ LIMIT 50
+ `;
+
+ return { entity, edges, mentions };
+};
diff --git a/web/src/routes/entities/[id]/+page.svelte b/web/src/routes/entities/[id]/+page.svelte
new file mode 100644
index 0000000..cf1189a
--- /dev/null
+++ b/web/src/routes/entities/[id]/+page.svelte
@@ -0,0 +1,560 @@
+
+
+
+
← Alle entiteter
+
+
+
+
+
+ {#if entityEdges.length > 0}
+
+ Relasjoner ({entityEdges.length})
+
+
+ {/if}
+
+
+
+ Nevnt i {mentions.length} {mentions.length === 1 ? 'melding' : 'meldinger'}
+ {#if mentions.length === 0}
+ Ingen meldinger nevner denne entiteten ennå.
+ {:else}
+
+ {/if}
+
+
+
+
+
diff --git a/worker/src/handlers/ai_text_process.rs b/worker/src/handlers/ai_text_process.rs
new file mode 100644
index 0000000..cef6281
--- /dev/null
+++ b/worker/src/handlers/ai_text_process.rs
@@ -0,0 +1,257 @@
+use super::JobHandler;
+use anyhow::{anyhow, Context};
+use serde_json::{json, Value};
+use sqlx::{PgPool, Row};
+use tracing::{info, warn};
+use uuid::Uuid;
+
+/// Handler for AI-behandling av tekst i editoren.
+///
+/// Payload:
+/// {
+/// "message_id": "uuid",
+/// "action": "fix_text" | "extract_facts" | "rewrite" | "translate" | "custom",
+/// "prompt_override": "optional custom prompt",
+/// "model": "sidelinja/rutine" (optional, default basert på action)
+/// }
+///
+/// Flyten:
+/// 1. Hent meldingens nåværende body fra PG
+/// 2. Lagre originalen som revisjon i message_revisions
+/// 3. Send til AI Gateway med riktig prompt
+/// 4. Oppdater messages.body med AI-resultatet
+/// 5. Sett metadata.ai_processed = true
+pub struct AiTextProcessHandler {
+ http: reqwest::Client,
+ ai_gateway_url: String,
+}
+
+impl AiTextProcessHandler {
+ pub fn new(http: reqwest::Client, ai_gateway_url: String) -> Self {
+ Self {
+ http,
+ ai_gateway_url,
+ }
+ }
+}
+
+#[async_trait::async_trait]
+impl JobHandler for AiTextProcessHandler {
+ async fn handle(
+ &self,
+ pool: &PgPool,
+ workspace_id: &Uuid,
+ payload: &Value,
+ ) -> anyhow::Result