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:
parent
ea3b3d5a38
commit
d5882c8c45
4 changed files with 327 additions and 83 deletions
13
web/package-lock.json
generated
13
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<span class="messagebox__pinned" title="Festet">📌</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if message.pinned}
|
||||
<span class="messagebox__pinned" title="Festet">📌</span>
|
||||
{/if}
|
||||
</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;
|
||||
|
|
|
|||
23
web/src/routes/api/messages/[messageId]/revisions/+server.ts
Normal file
23
web/src/routes/api/messages/[messageId]/revisions/+server.ts
Normal 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 });
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue