diff --git a/migrations/0014_workspace_ai_prompts.sql b/migrations/0014_workspace_ai_prompts.sql new file mode 100644 index 0000000..e8740af --- /dev/null +++ b/migrations/0014_workspace_ai_prompts.sql @@ -0,0 +1,28 @@ +-- 0014_workspace_ai_prompts.sql +-- Per-workspace valg av hvilke AI-prompts som er tilgjengelige i chat. + +BEGIN; + +-- Legg til visningsnavn og sortering på ai_prompts +ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS label TEXT; +ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; +ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS icon TEXT; + +UPDATE ai_prompts SET label = 'Vask tekst', sort_order = 1, icon = '🧹' WHERE action = 'fix_text'; +UPDATE ai_prompts SET label = 'Sammendrag', sort_order = 2, icon = '📋' WHERE action = 'extract_facts'; +UPDATE ai_prompts SET label = 'Skriv om', sort_order = 3, icon = '✍️' WHERE action = 'rewrite'; +UPDATE ai_prompts SET label = 'Oversett', sort_order = 4, icon = '🌐' WHERE action = 'translate'; + +-- Workspace → prompt kobling (hvilke prompts er tilgjengelige) +CREATE TABLE workspace_ai_prompts ( + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + action TEXT NOT NULL REFERENCES ai_prompts(action) ON DELETE CASCADE, + PRIMARY KEY (workspace_id, action) +); + +-- Seed: aktiver alle prompts for default workspace +INSERT INTO workspace_ai_prompts (workspace_id, action) +SELECT 'a0000000-0000-0000-0000-000000000001'::uuid, action +FROM ai_prompts; + +COMMIT; diff --git a/web/src/lib/blocks/ChatBlock.svelte b/web/src/lib/blocks/ChatBlock.svelte index 1bef39e..f807071 100644 --- a/web/src/lib/blocks/ChatBlock.svelte +++ b/web/src/lib/blocks/ChatBlock.svelte @@ -53,9 +53,41 @@ }, onConvertToCalendar: (messageId: string) => { convertTarget = { messageId, type: 'calendar' }; + }, + onMagic: async (messageId: string, action?: string) => { + try { + const res = await fetch('/api/ai/process', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message_id: messageId, action: action ?? 'fix_text' }) + }); + if (!res.ok) return; + const { job_id } = await res.json(); + // Sett lokal processing-state umiddelbart + const msg = chat?.messages.find(m => m.id === messageId); + if (msg) msg.metadata = { ...msg.metadata, ai_processing: true }; + // Poll jobb-status + pollJob(job_id); + } catch { /* stille feil */ } } }; + function pollJob(jobId: string) { + const interval = setInterval(async () => { + try { + const res = await fetch(`/api/jobs/${jobId}`); + if (!res.ok) { clearInterval(interval); return; } + const job = await res.json(); + if (job.status === 'completed' || job.status === 'failed') { + clearInterval(interval); + await chat?.refresh(); + } + } catch { + clearInterval(interval); + } + }, 2000); + } + async function handleSubmit(html: string, json: Record, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) { if (!chat || sending) return; sending = true; @@ -218,15 +250,17 @@
{group.date}
- {#each group.threads as thread (thread.root.id)} - - {#if thread.replies.length > 0} -
- {#each thread.replies as reply (reply.id)} - - {/each} -
- {/if} + {#each group.threads as thread, i (thread.root.id)} +
+ + {#if thread.replies.length > 0} +
+ {#each thread.replies as reply (reply.id)} + + {/each} +
+ {/if} +
{/each} {/each} @@ -298,6 +332,18 @@ border-radius: 10px; } + .thread-group { + border-top: 1px solid #2d3148; + padding-top: 0.3rem; + margin-top: 0.3rem; + } + + .thread-group--first { + border-top: none; + padding-top: 0; + margin-top: 0; + } + .thread-replies { margin-left: 1.5rem; border-left: 2px solid #2d3148; diff --git a/web/src/lib/components/MessageBox.svelte b/web/src/lib/components/MessageBox.svelte index 9c83ee0..89221c6 100644 --- a/web/src/lib/components/MessageBox.svelte +++ b/web/src/lib/components/MessageBox.svelte @@ -155,9 +155,50 @@ callbacks.onConvertToCalendar?.(message.id); } + // --- AI-prompt-meny --- + interface AiPrompt { + action: string; + label: string; + icon: string | null; + description: string | null; + } + + let showAiMenu = $state(false); + let aiPrompts = $state([]); + let aiPromptsLoaded = $state(false); + + async function loadAiPrompts() { + if (aiPromptsLoaded) return; + try { + const res = await fetch('/api/ai/prompts'); + if (res.ok) { + aiPrompts = await res.json(); + aiPromptsLoaded = true; + } + } catch { /* stille */ } + } + function handleMagic(e: MouseEvent) { e.stopPropagation(); - callbacks.onMagic?.(message.id); + if (aiPrompts.length <= 1) { + // Bare én prompt — kjør direkte + callbacks.onMagic?.(message.id, aiPrompts[0]?.action); + return; + } + showAiMenu = !showAiMenu; + } + + function selectAiAction(action: string) { + showAiMenu = false; + callbacks.onMagic?.(message.id, action); + } + + // Last prompts ved mount + loadAiPrompts(); + + // Lukk AI-meny ved klikk utenfor + function handleWindowClick() { + if (showAiMenu) showAiMenu = false; } let isAiProcessing = $derived(message.metadata?.ai_processing === true); @@ -242,6 +283,8 @@ ); + + {#if mode === 'expanded'} @@ -278,19 +321,34 @@ {/if} {#if callbacks.onMagic} - + + + {#if showAiMenu && aiPrompts.length > 1} +
+ {#each aiPrompts as prompt} + + {/each} +
+ {/if} +
{/if}
- {#if callbacks.onReply} - + {#if isOwnMessage && !editing && callbacks.onDelete} + {/if} {#if callbacks.onTogglePin} {/if} - {#if isOwnMessage && !editing && callbacks.onDelete} - + {#if callbacks.onReply} + {/if}
{#if confirmingDelete} @@ -354,7 +412,8 @@ {/if} {/if} {#if message.metadata?.ai_action} - ✨ {message.metadata.ai_action} + {@const promptInfo = aiPrompts.find(p => p.action === message.metadata?.ai_action)} + {promptInfo?.icon ?? '✨'} {promptInfo?.label ?? message.metadata.ai_action} {/if} {/if} @@ -501,10 +560,18 @@ background: rgba(255, 255, 255, 0.1); } + .messagebox__toolbar-btn--danger { + color: #f87171; + } + .messagebox__toolbar-btn--danger:hover { background: rgba(220, 38, 38, 0.25); } + .messagebox__icon-delete { + display: block; + } + .messagebox__confirm-delete { display: flex; align-items: center; @@ -736,6 +803,52 @@ opacity: 0.7; } + .messagebox__ai-wrapper { + position: relative; + } + + .messagebox__ai-menu { + position: absolute; + bottom: 100%; + right: 0; + background: #1e1e2e; + border: 1px solid #2d3148; + border-radius: 6px; + padding: 0.25rem; + display: flex; + flex-direction: column; + gap: 1px; + min-width: 140px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 100; + } + + .messagebox__ai-menu-item { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.5rem; + border: none; + background: none; + color: #cdd6f4; + font-size: 0.8rem; + cursor: pointer; + border-radius: 4px; + white-space: nowrap; + } + + .messagebox__ai-menu-item:hover { + background: #2d3148; + } + + .ai-menu-icon { + font-size: 0.9rem; + } + + .ai-menu-label { + flex: 1; + } + /* Tiptap HTML rendering */ .messagebox__body :global(p) { margin: 0; diff --git a/web/src/lib/types/message.ts b/web/src/lib/types/message.ts index 36f7060..ffdf940 100644 --- a/web/src/lib/types/message.ts +++ b/web/src/lib/types/message.ts @@ -49,6 +49,6 @@ export interface MessageBoxCallbacks { onReply?: (messageId: string) => void; onConvertToKanban?: (messageId: string) => void; onConvertToCalendar?: (messageId: string) => void; - onMagic?: (messageId: string) => void; + onMagic?: (messageId: string, action?: string) => void; currentUserId?: string; } diff --git a/web/src/routes/api/ai/prompts/+server.ts b/web/src/routes/api/ai/prompts/+server.ts new file mode 100644 index 0000000..73f2c9f --- /dev/null +++ b/web/src/routes/api/ai/prompts/+server.ts @@ -0,0 +1,18 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** GET /api/ai/prompts — hent tilgjengelige AI-prompts for gjeldende workspace */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.workspace || !locals.user) error(401); + + const prompts = await sql` + SELECT p.action, p.label, p.icon, p.description + FROM ai_prompts p + JOIN workspace_ai_prompts wp ON wp.action = p.action + WHERE wp.workspace_id = ${locals.workspace.id} + ORDER BY p.sort_order, p.action + `; + + return json(prompts); +};