Markdown-rendering og revisjons-toggle for AI-behandlede meldinger

- AI-resultat (markdown) rendres med marked-biblioteket
- «Vis original» / «Vis AI-resultat» toggle under AI-behandlede meldinger
- Nytt API: GET /api/messages/:id/revisions for å hente originaltekst
- Markdown-stiler: overskrifter, lister, blockquotes, kodeblokker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 03:28:13 +01:00
parent ea3b3d5a38
commit d5882c8c45
4 changed files with 327 additions and 83 deletions

13
web/package-lock.json generated
View file

@ -18,6 +18,7 @@
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"marked": "^17.0.4",
"postgres": "^3.4.8",
"spacetimedb": "^2.0.4",
"svelte": "^5.53.12",
@ -2045,6 +2046,18 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",

View file

@ -20,6 +20,7 @@
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"marked": "^17.0.4",
"postgres": "^3.4.8",
"spacetimedb": "^2.0.4",
"svelte": "^5.53.12",

View file

@ -1,19 +1,22 @@
<script lang="ts">
import type { MessageData, MessageBoxMode, MessageBoxCallbacks } from '$lib/types/message';
import Editor from '$lib/components/Editor.svelte';
import { marked } from 'marked';
let {
message,
mode = 'expanded',
showAuthor = true,
showTimestamp = true,
callbacks = {}
callbacks = {},
isReply = false
}: {
message: MessageData;
mode?: MessageBoxMode;
showAuthor?: boolean;
showTimestamp?: boolean;
callbacks?: MessageBoxCallbacks;
isReply?: boolean;
} = $props();
function formatTime(iso: string): string {
@ -69,6 +72,22 @@
let editing = $state(false);
let editBody = $state('');
let expanded = $state(false);
let bodyEl: HTMLDivElement | undefined = $state();
let isClamped = $state(false);
function checkClamped() {
if (bodyEl) {
isClamped = bodyEl.scrollHeight > bodyEl.clientHeight + 2;
}
}
$effect(() => {
if (bodyEl && !editing && !expanded) {
// Check after render
checkClamped();
}
});
let isOwnMessage = $derived(
callbacks.currentUserId != null && message.author_id === callbacks.currentUserId
@ -122,13 +141,61 @@
e.stopPropagation();
callbacks.onConvertToCalendar?.(message.id);
}
function handleMagic(e: MouseEvent) {
e.stopPropagation();
callbacks.onMagic?.(message.id);
}
let isAiProcessing = $derived(message.metadata?.ai_processing === true);
let isAiProcessed = $derived(message.metadata?.ai_processed === true);
// Revisjons-toggle for AI-behandlede meldinger
let showingOriginal = $state(false);
let originalBody = $state<string | null>(null);
let loadingRevision = $state(false);
async function toggleRevision() {
if (showingOriginal) {
showingOriginal = false;
return;
}
if (originalBody === null) {
loadingRevision = true;
try {
const res = await fetch(`/api/messages/${message.id}/revisions`);
if (res.ok) {
const data = await res.json();
originalBody = data.revisions?.[0]?.body ?? null;
}
} finally {
loadingRevision = false;
}
}
showingOriginal = true;
}
// Markdown → HTML for AI-behandlet innhold
function renderBody(body: string, aiProcessed: boolean): string {
if (!aiProcessed) return body;
// AI returnerer markdown — konverter til HTML
const isHtml = /<[a-z][\s\S]*>/i.test(body);
if (isHtml) return body;
return marked.parse(body, { async: false }) as string;
}
let displayBody = $derived(
showingOriginal && originalBody !== null
? originalBody
: renderBody(message.body, isAiProcessed)
);
</script>
{#if mode === 'expanded'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="messagebox messagebox--expanded" id="msg-{message.id}" onclick={handleClick}>
{#if message.reply_to && message.parent_author_name}
<div class="messagebox messagebox--expanded" class:messagebox--ai-processing={isAiProcessing} id="msg-{message.id}" onclick={handleClick}>
{#if message.reply_to && message.parent_author_name && !isReply}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="messagebox__reply-context" onclick={(e) => { e.stopPropagation(); const el = document.getElementById(`msg-${message.reply_to}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }}>
@ -143,41 +210,37 @@
{#if showTimestamp}
<span class="messagebox__time">{formatTime(message.created_at)}</span>
{/if}
<span class="messagebox__header-right">
{#if isOwnMessage && !editing}
<span class="messagebox__actions">
{#if callbacks.onEdit}
<button class="messagebox__action" title="Rediger" onclick={startEdit}>✏️</button>
{/if}
{#if callbacks.onDelete}
<button class="messagebox__action" title="Slett" onclick={handleDelete}>🗑️</button>
{/if}
</span>
{/if}
<span class="messagebox__actions">
{#if callbacks.onReply}
<button class="messagebox__action" title="Svar" onclick={handleReply}>💬</button>
{/if}
{#if callbacks.onConvertToKanban}
<button class="messagebox__action" title="Kanban-kort" onclick={handleConvertToKanban}>📋</button>
{/if}
{#if callbacks.onConvertToCalendar}
<button class="messagebox__action" title="Kalenderhendelse" onclick={handleConvertToCalendar}>📅</button>
{/if}
</span>
{#if callbacks.onTogglePin}
<button
class="messagebox__pin-toggle"
class:messagebox__pin-toggle--active={message.pinned}
title={message.pinned ? 'Løsne' : 'Fest'}
onclick={handleTogglePin}
>📌</button>
{:else if message.pinned}
{#if message.pinned}
<span class="messagebox__pinned" title="Festet">📌</span>
{/if}
</span>
</div>
{/if}
<!-- Flytende toolbar — utenfor layout-flyten -->
<div class="messagebox__toolbar">
{#if callbacks.onReply}
<button class="messagebox__toolbar-btn" title="Svar" onclick={handleReply}>💬</button>
{/if}
{#if callbacks.onConvertToKanban}
<button class="messagebox__toolbar-btn" title="Kanban-kort" onclick={handleConvertToKanban}>📋</button>
{/if}
{#if callbacks.onConvertToCalendar}
<button class="messagebox__toolbar-btn" title="Kalenderhendelse" onclick={handleConvertToCalendar}>📅</button>
{/if}
{#if callbacks.onMagic}
<button class="messagebox__toolbar-btn" title="AI-behandling" onclick={handleMagic} disabled={isAiProcessing}></button>
{/if}
{#if callbacks.onTogglePin}
<button class="messagebox__toolbar-btn" title={message.pinned ? 'Løsne' : 'Fest'} onclick={handleTogglePin}>📌</button>
{/if}
{#if isOwnMessage && !editing}
{#if callbacks.onEdit}
<button class="messagebox__toolbar-btn" title="Rediger" onclick={startEdit}>✏️</button>
{/if}
{#if callbacks.onDelete}
<button class="messagebox__toolbar-btn messagebox__toolbar-btn--danger" title="Slett" onclick={handleDelete}>🗑️</button>
{/if}
{/if}
</div>
{#if editing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="messagebox__edit" onkeydown={(e) => { if (e.key === 'Escape') cancelEdit(); }}>
@ -193,7 +256,38 @@
</div>
</div>
{:else}
<div class="messagebox__body">{@html message.body}</div>
{#if expanded}
<button class="messagebox__expand-btn" onclick={(e) => { e.stopPropagation(); expanded = false; checkClamped(); }}>
Vis mindre
</button>
{/if}
<div class="messagebox__body" class:messagebox__body--clamped={!expanded} bind:this={bodyEl}>{@html displayBody}</div>
{#if isClamped && !expanded}
<button class="messagebox__expand-btn" onclick={(e) => { e.stopPropagation(); expanded = true; }}>
Vis mer
</button>
{:else if expanded}
<button class="messagebox__expand-btn" onclick={(e) => { e.stopPropagation(); expanded = false; checkClamped(); }}>
Vis mindre
</button>
{/if}
{#if isAiProcessing}
<div class="messagebox__ai-status">✨ AI behandler…</div>
{/if}
{#if isAiProcessed && !editing}
<div class="messagebox__ai-footer">
<span class="messagebox__ai-badge">{message.metadata?.ai_action ?? 'AI'}</span>
<button class="messagebox__revision-btn" onclick={(e) => { e.stopPropagation(); toggleRevision(); }} disabled={loadingRevision}>
{#if loadingRevision}
Laster…
{:else if showingOriginal}
Vis AI-resultat
{:else}
Vis original
{/if}
</button>
</div>
{/if}
{/if}
{#if hasReactions || callbacks.onReaction}
<div class="messagebox__reactions">
@ -258,6 +352,7 @@
<style>
/* === Expanded mode (chat) === */
.messagebox--expanded {
position: relative;
padding: 0.3rem 0.5rem;
border-radius: 4px;
}
@ -283,63 +378,49 @@
color: #8b92a5;
}
.messagebox__header-right {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.messagebox__pinned {
font-size: 0.65rem;
}
.messagebox__pin-toggle {
font-size: 0.65rem;
background: none;
border: none;
cursor: pointer;
opacity: 0;
padding: 0;
transition: opacity 0.15s;
}
.messagebox__pin-toggle--active {
opacity: 1;
}
.messagebox--expanded:hover .messagebox__pin-toggle {
opacity: 0.5;
}
.messagebox--expanded:hover .messagebox__pin-toggle:hover {
opacity: 1;
}
/* === Edit/Delete actions === */
.messagebox__actions {
display: none;
gap: 0.15rem;
}
.messagebox--expanded:hover .messagebox__actions {
/* === Flytende toolbar (absolutt-posisjonert, ingen layout-shift) === */
.messagebox__toolbar {
position: absolute;
top: -0.6rem;
right: 0.4rem;
display: flex;
gap: 0.1rem;
background: #1a1d2e;
border: 1px solid #2d3148;
border-radius: 6px;
padding: 0.15rem 0.2rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s;
z-index: 5;
}
.messagebox__action {
font-size: 0.6rem;
.messagebox--expanded:hover .messagebox__toolbar {
opacity: 1;
pointer-events: auto;
}
.messagebox__toolbar-btn {
font-size: 0.7rem;
background: none;
border: none;
cursor: pointer;
padding: 0.05rem 0.15rem;
padding: 0.15rem 0.25rem;
border-radius: 4px;
opacity: 0.4;
transition: opacity 0.15s;
line-height: 1;
transition: background 0.12s;
}
.messagebox__action:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.08);
.messagebox__toolbar-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.messagebox__toolbar-btn--danger:hover {
background: rgba(220, 38, 38, 0.25);
}
/* === Inline edit === */
@ -409,12 +490,14 @@
}
.messagebox__reaction-add {
display: none;
display: flex;
gap: 0.1rem;
opacity: 0;
transition: opacity 0.12s;
}
.messagebox--expanded:hover .messagebox__reaction-add {
display: flex;
opacity: 1;
}
.messagebox__reaction-quick {
@ -457,6 +540,64 @@
word-break: break-word;
}
.messagebox__body--clamped {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.messagebox__expand-btn {
font-size: 0.7rem;
color: #7dd3fc;
background: none;
border: none;
cursor: pointer;
padding: 0;
margin-top: 0.1rem;
font-family: inherit;
}
.messagebox__expand-btn:hover {
color: #bae6fd;
text-decoration: underline;
}
/* === AI footer (badge + revision toggle) === */
.messagebox__ai-footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.3rem;
}
.messagebox__ai-badge {
font-size: 0.65rem;
color: #facc15;
opacity: 0.7;
}
.messagebox__revision-btn {
font-size: 0.65rem;
color: #7dd3fc;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.messagebox__revision-btn:hover {
color: #bae6fd;
text-decoration: underline;
}
.messagebox__revision-btn:disabled {
color: #8b92a5;
cursor: wait;
}
/* Tiptap HTML rendering */
.messagebox__body :global(p) {
margin: 0;
@ -478,6 +619,50 @@
font-size: 0.8rem;
}
.messagebox__body :global(h1),
.messagebox__body :global(h2),
.messagebox__body :global(h3) {
margin: 0.6em 0 0.3em;
color: #f1f3f5;
line-height: 1.3;
}
.messagebox__body :global(h1) { font-size: 1.1rem; }
.messagebox__body :global(h2) { font-size: 1rem; }
.messagebox__body :global(h3) { font-size: 0.9rem; }
.messagebox__body :global(ul),
.messagebox__body :global(ol) {
margin: 0.3em 0;
padding-left: 1.5em;
}
.messagebox__body :global(li) {
margin-bottom: 0.15em;
}
.messagebox__body :global(blockquote) {
border-left: 3px solid #3b82f6;
margin: 0.4em 0;
padding: 0.2em 0.6em;
color: #8b92a5;
}
.messagebox__body :global(pre) {
background: #1e2235;
padding: 0.5em;
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
margin: 0.4em 0;
}
.messagebox__body :global(hr) {
border: none;
border-top: 1px solid #2d3148;
margin: 0.5em 0;
}
.messagebox__body :global(.mention) {
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
@ -517,6 +702,28 @@
background: rgba(16, 185, 129, 0.2);
}
/* === AI-prosessering === */
.messagebox--ai-processing {
animation: ai-pulse 2s ease-in-out infinite;
}
@keyframes ai-pulse {
0%, 100% { background: transparent; }
50% { background: rgba(250, 204, 21, 0.06); }
}
.messagebox__ai-status {
font-size: 0.7rem;
color: #facc15;
margin-top: 0.2rem;
animation: ai-pulse-text 1.5s ease-in-out infinite;
}
@keyframes ai-pulse-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
/* === Compact mode (kanban) === */
.messagebox--compact {
border-left: 3px solid transparent;

View file

@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/**
* GET /api/messages/:messageId/revisions Hent revisjoner for en melding.
* Returnerer nyeste revisjon først (originalen før AI-behandling).
*/
export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const revisions = await sql`
SELECT r.id, r.body, r.created_at
FROM message_revisions r
JOIN messages m ON m.id = r.message_id
JOIN nodes n ON n.id = m.id
WHERE r.message_id = ${params.messageId}::uuid
AND n.workspace_id = ${locals.workspace.id}
ORDER BY r.created_at DESC
`;
return json({ revisions });
};