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:
parent
ba9218e594
commit
85f0cd9d30
5 changed files with 222 additions and 17 deletions
28
migrations/0014_workspace_ai_prompts.sql
Normal file
28
migrations/0014_workspace_ai_prompts.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
web/src/routes/api/ai/prompts/+server.ts
Normal file
18
web/src/routes/api/ai/prompts/+server.ts
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue