Fullfører oppgave 18.4: AI-verktøy panel (frontend)
Svelte-komponent for AI-prosessering i arbeidsflaten: - AiToolPanel.svelte: Viser ai_preset-noder fra STDB som velgbare verktøy, med modell-indikator (flash/standard) og prompt-forhåndsvisning - Drag-and-drop mottak for tekstnoder med visuell feedback - Validerer kompatibilitet (kun content/communication-noder) - Kaller /intentions/ai_process via ny aiProcess()-funksjon i api.ts - Integrert i collection-siden, tilgjengelig for alle innloggede brukere - EditorTrait: innholdsnoder er nå draggable for AI-prosessering - Fritekst-felt for egendefinert instruksjon (backend-støtte kommer i 18.6) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
42384c318d
commit
d4de8b3619
5 changed files with 352 additions and 3 deletions
|
|
@ -1193,6 +1193,27 @@ export function leaveCommunication(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// AI-prosessering
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface AiProcessRequest {
|
||||||
|
source_node_id: string;
|
||||||
|
ai_preset_id: string;
|
||||||
|
direction: 'node_to_tool' | 'tool_to_node';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiProcessResponse {
|
||||||
|
job_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiProcess(
|
||||||
|
accessToken: string,
|
||||||
|
data: AiProcessRequest
|
||||||
|
): Promise<AiProcessResponse> {
|
||||||
|
return post(accessToken, '/intentions/ai_process', data);
|
||||||
|
}
|
||||||
|
|
||||||
/** Hent ressursforbruk for en spesifikk node (kun eier). */
|
/** Hent ressursforbruk for en spesifikk node (kun eier). */
|
||||||
export async function fetchNodeUsage(
|
export async function fetchNodeUsage(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|
|
||||||
312
frontend/src/lib/components/AiToolPanel.svelte
Normal file
312
frontend/src/lib/components/AiToolPanel.svelte
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* AI-verktøy panel for arbeidsflaten.
|
||||||
|
*
|
||||||
|
* Viser standardprompter (ai_preset-noder), fritekst-felt for egendefinert
|
||||||
|
* prompt, modell-indikator (readonly), og mottar tekstnoder via drag-and-drop.
|
||||||
|
*
|
||||||
|
* Retning bestemmes av interaksjon:
|
||||||
|
* - Dra en node HIT → node_to_tool (ny node med AI-output)
|
||||||
|
* - Dra verktøyet PÅ en node → tool_to_node (in-place revisjon)
|
||||||
|
* For nå støtter panelet kun node_to_tool (drop-mottak).
|
||||||
|
*/
|
||||||
|
import type { Node } from '$lib/spacetime';
|
||||||
|
import { nodeStore } from '$lib/spacetime';
|
||||||
|
import { aiProcess } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accessToken?: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { accessToken, userId }: Props = $props();
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let selectedPresetId = $state<string | null>(null);
|
||||||
|
let customPrompt = $state('');
|
||||||
|
let dragOver = $state(false);
|
||||||
|
let processing = $state(false);
|
||||||
|
let lastResult = $state<{ success: boolean; message: string } | null>(null);
|
||||||
|
let droppedNodeId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// --- Derived: AI-preset noder fra STDB ---
|
||||||
|
const presets = $derived.by(() => {
|
||||||
|
const all = nodeStore.byKind('ai_preset');
|
||||||
|
// Sorter: standard først, deretter custom, alfabetisk innenfor
|
||||||
|
return all.sort((a, b) => {
|
||||||
|
const metaA = parseMetadata(a);
|
||||||
|
const metaB = parseMetadata(b);
|
||||||
|
const catA = metaA.category === 'standard' ? 0 : 1;
|
||||||
|
const catB = metaB.category === 'standard' ? 0 : 1;
|
||||||
|
if (catA !== catB) return catA - catB;
|
||||||
|
return (a.title ?? '').localeCompare(b.title ?? '', 'nb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPreset = $derived(
|
||||||
|
selectedPresetId ? nodeStore.get(selectedPresetId) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedMeta = $derived(selectedPreset ? parseMetadata(selectedPreset) : null);
|
||||||
|
|
||||||
|
const modelLabel = $derived.by(() => {
|
||||||
|
if (!selectedMeta) return '';
|
||||||
|
switch (selectedMeta.model_profile) {
|
||||||
|
case 'flash': return 'Rask (flash)';
|
||||||
|
case 'standard': return 'Standard';
|
||||||
|
default: return selectedMeta.model_profile ?? '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultDirection = $derived(
|
||||||
|
(selectedMeta?.default_direction as string) ?? 'node_to_tool'
|
||||||
|
);
|
||||||
|
|
||||||
|
const droppedNode = $derived(droppedNodeId ? nodeStore.get(droppedNodeId) : null);
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function parseMetadata(node: Node): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.metadata ?? '{}');
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetIcon(meta: Record<string, unknown>): string {
|
||||||
|
const icon = meta.icon as string;
|
||||||
|
// Map heroicon names to unicode/emoji approximations
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
sparkles: '✨',
|
||||||
|
check_badge: '✅',
|
||||||
|
document_text: '📄',
|
||||||
|
language: '🌐',
|
||||||
|
pencil_square: '✏️',
|
||||||
|
list_bullet: '📋',
|
||||||
|
arrow_path: '🔄',
|
||||||
|
chat_bubble_left_right: '💬'
|
||||||
|
};
|
||||||
|
return iconMap[icon] ?? '🤖';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag-and-drop ---
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
dragOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
// Only leave if actually leaving the drop zone
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||||
|
dragOver = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOver = false;
|
||||||
|
|
||||||
|
const nodeId = e.dataTransfer?.getData('text/plain');
|
||||||
|
if (!nodeId) return;
|
||||||
|
|
||||||
|
const node = nodeStore.get(nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// Sjekk kompatibilitet: kun tekstnoder
|
||||||
|
if (node.nodeKind !== 'content' && node.nodeKind !== 'communication') {
|
||||||
|
lastResult = {
|
||||||
|
success: false,
|
||||||
|
message: `AI-verktøyet behandler kun tekst. "${node.title ?? 'Noden'}" er av type ${node.nodeKind}.`
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.content?.trim()) {
|
||||||
|
lastResult = {
|
||||||
|
success: false,
|
||||||
|
message: 'Noden har ikke noe tekstinnhold å prosessere.'
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
droppedNodeId = nodeId;
|
||||||
|
lastResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Prosessering ---
|
||||||
|
async function processNode() {
|
||||||
|
if (!accessToken || !selectedPresetId || !droppedNodeId) return;
|
||||||
|
|
||||||
|
const direction = defaultDirection === 'both' ? 'node_to_tool' : defaultDirection;
|
||||||
|
if (direction !== 'node_to_tool' && direction !== 'tool_to_node') return;
|
||||||
|
|
||||||
|
processing = true;
|
||||||
|
lastResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await aiProcess(accessToken, {
|
||||||
|
source_node_id: droppedNodeId,
|
||||||
|
ai_preset_id: selectedPresetId,
|
||||||
|
direction
|
||||||
|
});
|
||||||
|
lastResult = {
|
||||||
|
success: true,
|
||||||
|
message: `Jobb opprettet (${res.job_id.slice(0, 8)}…). Resultatet dukker opp i sanntid.`
|
||||||
|
};
|
||||||
|
droppedNodeId = null;
|
||||||
|
} catch (err) {
|
||||||
|
lastResult = {
|
||||||
|
success: false,
|
||||||
|
message: `Feil: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDropped() {
|
||||||
|
droppedNodeId = null;
|
||||||
|
lastResult = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-gray-200 bg-white shadow-sm" data-component="ai-tool-panel">
|
||||||
|
<div class="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||||
|
<span class="text-lg" aria-hidden="true">🤖</span>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-700">AI-verktøy</h3>
|
||||||
|
{#if modelLabel}
|
||||||
|
<span class="ml-auto rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-600">
|
||||||
|
{modelLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<!-- Prompt-velger -->
|
||||||
|
<div>
|
||||||
|
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
Velg verktøy
|
||||||
|
</label>
|
||||||
|
{#if presets.length === 0}
|
||||||
|
<p class="text-xs text-gray-400">Ingen AI-presets tilgjengelig.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
|
||||||
|
{#each presets as preset (preset.id)}
|
||||||
|
{@const meta = parseMetadata(preset)}
|
||||||
|
<button
|
||||||
|
onclick={() => { selectedPresetId = preset.id; }}
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors
|
||||||
|
{selectedPresetId === preset.id
|
||||||
|
? 'border-purple-400 bg-purple-50 text-purple-800 shadow-sm'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}"
|
||||||
|
style={selectedPresetId === preset.id ? `border-color: ${meta.color}` : ''}
|
||||||
|
>
|
||||||
|
<span class="text-sm shrink-0">{presetIcon(meta)}</span>
|
||||||
|
<span class="truncate">{preset.title}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valgt preset-detaljer -->
|
||||||
|
{#if selectedPreset && selectedMeta}
|
||||||
|
<div class="rounded-lg bg-gray-50 p-3 text-xs text-gray-600">
|
||||||
|
<p class="font-medium text-gray-700 mb-1">{selectedPreset.title}</p>
|
||||||
|
<p class="line-clamp-2">{selectedMeta.prompt}</p>
|
||||||
|
<div class="mt-2 flex items-center gap-3 text-gray-400">
|
||||||
|
<span>Modell: {modelLabel}</span>
|
||||||
|
<span>Retning: {
|
||||||
|
defaultDirection === 'node_to_tool' ? 'Ny node'
|
||||||
|
: defaultDirection === 'tool_to_node' ? 'In-place'
|
||||||
|
: 'Begge'
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Fritekst-felt for egendefinert prompt -->
|
||||||
|
<div>
|
||||||
|
<label for="custom-prompt" class="block text-xs font-medium text-gray-500 mb-1">
|
||||||
|
Egendefinert instruksjon (valgfritt)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="custom-prompt"
|
||||||
|
bind:value={customPrompt}
|
||||||
|
placeholder="Legg til spesifikke instruksjoner..."
|
||||||
|
rows="2"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder:text-gray-300 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-200 resize-none"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-400">
|
||||||
|
Kommer i tillegg til valgt preset (funksjon under utvikling).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drop-sone -->
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Slipp tekstnode her"
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors
|
||||||
|
{dragOver
|
||||||
|
? 'border-purple-400 bg-purple-50'
|
||||||
|
: droppedNode
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-200 bg-gray-50'}"
|
||||||
|
>
|
||||||
|
{#if droppedNode}
|
||||||
|
<!-- Viser droppet node -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-gray-700">
|
||||||
|
{droppedNode.title || 'Uten tittel'}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 line-clamp-2">
|
||||||
|
{droppedNode.content?.slice(0, 120)}{(droppedNode.content?.length ?? 0) > 120 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onclick={processNode}
|
||||||
|
disabled={!selectedPresetId || processing}
|
||||||
|
class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors
|
||||||
|
{!selectedPresetId || processing
|
||||||
|
? 'bg-gray-300 cursor-not-allowed'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-700'}"
|
||||||
|
>
|
||||||
|
{#if processing}
|
||||||
|
Prosesserer…
|
||||||
|
{:else}
|
||||||
|
Kjør {selectedPreset?.title ?? 'AI'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={clearDropped}
|
||||||
|
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Fjern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if dragOver}
|
||||||
|
<p class="text-sm text-purple-600 font-medium">Slipp her for AI-prosessering</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-400">Dra en tekstnode hit</p>
|
||||||
|
<p class="text-xs text-gray-300 mt-1">Støtter innholds- og kommunikasjonsnoder</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultat/feil-melding -->
|
||||||
|
{#if lastResult}
|
||||||
|
<div class="rounded-lg p-3 text-xs {lastResult.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}">
|
||||||
|
{lastResult.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
@ -123,7 +123,16 @@
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each contentItems as item (item.node.id)}
|
{#each contentItems as item (item.node.id)}
|
||||||
<li class="group rounded border border-gray-100 px-3 py-2">
|
<li
|
||||||
|
class="group rounded border border-gray-100 px-3 py-2 cursor-grab active:cursor-grabbing"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => {
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
e.dataTransfer.setData('text/plain', item.node.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4>
|
<h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
|
||||||
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
import TraitAdmin from '$lib/components/traits/TraitAdmin.svelte';
|
||||||
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
||||||
|
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
|
||||||
|
|
||||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||||
const nodeId = $derived(session?.nodeId as string | undefined);
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
||||||
|
|
@ -181,6 +182,13 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- AI-verktøy panel -->
|
||||||
|
{#if connected && accessToken}
|
||||||
|
<div class="mt-6">
|
||||||
|
<AiToolPanel {accessToken} userId={nodeId} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
|
<!-- Ressursforbruk for denne noden (oppgave 15.9) -->
|
||||||
{#if accessToken && collectionId}
|
{#if accessToken && collectionId}
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -204,8 +204,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md`
|
||||||
- [x] 18.1 AI-preset node-type: `node_kind: 'ai_preset'` med metadata (prompt, model_profile, category, icon, color). Maskinrommet validerer ved opprettelse. Seed standardprompter (rens tekst, korrektør, oppsummering, oversett, skriv om, trekk ut fakta, forenkle, endre tone).
|
- [x] 18.1 AI-preset node-type: `node_kind: 'ai_preset'` med metadata (prompt, model_profile, category, icon, color). Maskinrommet validerer ved opprettelse. Seed standardprompter (rens tekst, korrektør, oppsummering, oversett, skriv om, trekk ut fakta, forenkle, endre tone).
|
||||||
- [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log.
|
- [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log.
|
||||||
- [x] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset.
|
- [x] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset.
|
||||||
- [~] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder.
|
- [x] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder.
|
||||||
> Påbegynt: 2026-03-18T06:42
|
|
||||||
- [ ] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring.
|
- [ ] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring.
|
||||||
- [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.
|
- [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue