AI-promptvelger i chat: velg mellom forhåndsdefinerte AI-handlinger

- -knappen åpner nå en meny med tilgjengelige prompts (vask, sammendrag, skriv om, oversett)
- Prompts er workspace-konfigurerte via ny tabell workspace_ai_prompts
- Nytt API GET /api/ai/prompts returnerer tilgjengelige prompts for workspace
- AI-badge viser prompt-label og ikon i stedet for rå action-navn
- ai_prompts utvidet med label, icon og sort_order kolonner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 07:42:30 +01:00
parent ba9218e594
commit 85f0cd9d30
5 changed files with 222 additions and 17 deletions

View file

@ -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;

View file

@ -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<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) {
if (!chat || sending) return;
sending = true;
@ -218,15 +250,17 @@
<div class="date-divider">
<span>{group.date}</span>
</div>
{#each group.threads as thread (thread.root.id)}
<MessageBox message={thread.root} mode="expanded" callbacks={chatCallbacks} />
{#if thread.replies.length > 0}
<div class="thread-replies">
{#each thread.replies as reply (reply.id)}
<MessageBox message={reply} mode="expanded" callbacks={chatCallbacks} isReply={true} />
{/each}
</div>
{/if}
{#each group.threads as thread, i (thread.root.id)}
<div class="thread-group" class:thread-group--first={i === 0}>
<MessageBox message={thread.root} mode="expanded" callbacks={chatCallbacks} />
{#if thread.replies.length > 0}
<div class="thread-replies">
{#each thread.replies as reply (reply.id)}
<MessageBox message={reply} mode="expanded" callbacks={chatCallbacks} isReply={true} />
{/each}
</div>
{/if}
</div>
{/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;

View file

@ -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<AiPrompt[]>([]);
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 @@
);
</script>
<svelte:window onclick={handleWindowClick} />
{#if mode === 'expanded'}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@ -278,19 +321,34 @@
<button class="messagebox__toolbar-btn" title="Opprett kalenderhendelse" onclick={handleConvertToCalendar}>📅</button>
{/if}
{#if callbacks.onMagic}
<button class="messagebox__toolbar-btn" title="AI-behandling" onclick={handleMagic} disabled={isAiProcessing}></button>
<span class="messagebox__ai-wrapper">
<button class="messagebox__toolbar-btn" title="AI-behandling" onclick={handleMagic} disabled={isAiProcessing}></button>
{#if showAiMenu && aiPrompts.length > 1}
<div class="messagebox__ai-menu">
{#each aiPrompts as prompt}
<button
class="messagebox__ai-menu-item"
onclick={(e) => { e.stopPropagation(); selectAiAction(prompt.action); }}
>
<span class="ai-menu-icon">{prompt.icon ?? '✨'}</span>
<span class="ai-menu-label">{prompt.label ?? prompt.action}</span>
</button>
{/each}
</div>
{/if}
</span>
{/if}
</div>
<!-- Nedre toolbar: samtale-handlinger -->
<div class="messagebox__toolbar messagebox__toolbar--bottom">
{#if callbacks.onReply}
<button class="messagebox__toolbar-btn" title="Svar" onclick={handleReply}>💬</button>
{#if isOwnMessage && !editing && callbacks.onDelete}
<button class="messagebox__toolbar-btn messagebox__toolbar-btn--danger" title="Slett" onclick={handleDelete}><svg class="messagebox__icon-delete" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke-linecap="round"><path d="M2.5 4h11M5.5 4V2.5h5V4M4 4l.7 9.5h6.6L12 4" stroke="#8b92a5" stroke-width="1.3"/><line x1="1" y1="15" x2="15" y2="1" stroke="#ef4444" stroke-width="2.5"/><line x1="1" y1="1" x2="15" y2="15" stroke="#ef4444" stroke-width="2.5"/></svg></button>
{/if}
{#if callbacks.onTogglePin}
<button class="messagebox__toolbar-btn" title={message.pinned ? 'Løsne' : 'Fest'} onclick={handleTogglePin}>📌</button>
{/if}
{#if isOwnMessage && !editing && callbacks.onDelete}
<button class="messagebox__toolbar-btn messagebox__toolbar-btn--danger" title="Slett" onclick={handleDelete}>🗑️</button>
{#if callbacks.onReply}
<button class="messagebox__toolbar-btn" title="Svar" onclick={handleReply}>💬</button>
{/if}
</div>
{#if confirmingDelete}
@ -354,7 +412,8 @@
{/if}
{/if}
{#if message.metadata?.ai_action}
<span class="messagebox__ai-badge">{message.metadata.ai_action}</span>
{@const promptInfo = aiPrompts.find(p => p.action === message.metadata?.ai_action)}
<span class="messagebox__ai-badge">{promptInfo?.icon ?? '✨'} {promptInfo?.label ?? message.metadata.ai_action}</span>
{/if}
</div>
{/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;

View file

@ -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;
}

View file

@ -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);
};