Files
OSIT-AE-App-Svelte/src/lib/ae_elements/AE_AITools.svelte
Scott Idem 54707a00e3 Refine journal entry config
Polish the Journal Entry Config modal to match the desired section outline, hide alert messaging unless enabled, update the shared draft typing for entry flows, and replace deprecated privacy icons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 17:14:20 -04:00

347 lines
14 KiB
Svelte

<script lang="ts">
/**
* AE_AITools.svelte
* GENERIC Aether AI Toolset (Runes/Svelte 5)
* Extracted logic from Journals module to be system-wide.
*/
import OpenAI from 'openai';
import { Modal } from 'flowbite-svelte';
import {
Bot,
BotMessageSquare,
Eye,
EyeOff,
Globe,
Copy,
Loader,
Save,
FilePenLine,
RotateCcw,
Settings,
} from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
interface Props {
// Core Props
content: string | null | undefined; // The text to summarize/analyze
summary: string | null | undefined; // The result (bindable)
// Configuration (Bindable for global settings persistence)
model?: string;
baseUrl?: string;
token?: string;
systemPrompt?: string;
maxTokens?: number;
temperature?: number;
// Callbacks
onSave?: (newSummary: string) => void;
onSyncConfig?: () => void; // Optional: callback to sync from global site config
// UI Customization
buttonClass?: string;
log_lvl?: number;
}
let {
content,
summary = $bindable(),
model = $bindable(),
baseUrl = $bindable(),
token = $bindable(),
systemPrompt = $bindable(),
maxTokens = $bindable(),
temperature = $bindable(),
onSave,
onSyncConfig,
buttonClass = 'btn btn-sm preset-tonal-primary shadow-lg hover:scale-105 transition-all',
log_lvl = 0
}: Props = $props();
// Apply defaults if undefined (Safe for Svelte 5 Runes)
if (model === undefined) model = 'dgrzone-deepseek-8b-quick';
if (baseUrl === undefined) baseUrl = 'https://ai.dgrzone.com/api';
if (token === undefined) token = '';
if (systemPrompt === undefined) systemPrompt = 'You are a helpful assistant.';
if (maxTokens === undefined) maxTokens = 512;
if (temperature === undefined) temperature = 0.7;
// Internal State
let ae_promises = $state<Promise<unknown> | null>(null);
let show_modal = $state(false);
let active_tab: 'result' | 'settings' = $state('result');
let tmp_summary = $state('');
let show_api_token = $state(false);
const panel_class = 'space-y-4 rounded-xl border border-surface-500/20 bg-surface-500/5 p-4 shadow-sm';
const panel_title_class = 'flex items-center gap-2 border-b border-surface-500/20 pb-2 text-lg font-bold';
const tab_button_base_class = 'btn btn-sm border transition-all duration-200';
const tab_button_active_class = 'border-surface-200-800 bg-surface-200-800 text-surface-950-50 shadow-sm';
const tab_button_inactive_class = 'border-transparent bg-surface-50-900/60 text-surface-600-400 hover:border-surface-200-800 hover:bg-surface-100-900 hover:text-surface-950-50';
function tab_button_class(is_active: boolean): string {
return `${tab_button_base_class} ${is_active ? tab_button_active_class : tab_button_inactive_class}`;
}
async function generate_ai_result() {
if (!content) {
alert('No content available to analyze.');
return;
}
active_tab = 'result';
// If no token is provided, trigger a "Demo Mode" placeholder after a fake delay
if (!token || token === '') {
if (log_lvl > 0) {
console.log('AE_AITools: No token provided. Entering Demo Mode.');
}
ae_promises = new Promise((resolve) => {
setTimeout(() => {
tmp_summary = `### AI Summary (DEMO MODE)\n\nThis is a placeholder summary because no API token was provided in the settings. \n\n**Original Content Length:** ${content.length} characters.\n\n**System Prompt:** ${systemPrompt}\n\n**Model:** ${model}`;
show_modal = true;
resolve(true);
}, 1500);
});
return;
}
const ai_client = new OpenAI({
apiKey: token,
baseURL: baseUrl,
dangerouslyAllowBrowser: true
});
try {
ae_promises = ai_client.chat.completions
.create({
model: model || 'dgrzone-deepseek-8b-quick',
max_tokens: maxTokens,
temperature: temperature,
messages: [
{
role: 'system',
content: systemPrompt || 'You are a helpful assistant.'
},
{ role: 'user', content: content }
]
})
.then((resp) => {
const result =
resp?.choices?.[0]?.message?.content ||
'No result generated.';
tmp_summary = result;
show_modal = true;
});
} catch (err: unknown) {
if (log_lvl > 0) {
console.error('AE_AITools: AI Error:', err);
}
const err_msg = err instanceof Error ? err.message : String(err);
// Even on error, show the modal with the error message so the UI can be inspected
tmp_summary = `### AI Error\n\nFailed to connect to the AI service.\n\n**Error:** ${err_msg}\n\nCheck your Settings tab for Base URL and Token configuration.`;
show_modal = true;
ae_promises = Promise.resolve();
}
}
function handle_save() {
summary = tmp_summary;
if (onSave) onSave(tmp_summary);
show_modal = false;
}
</script>
<div class="ae-ai-tools-wrapper inline-flex items-center gap-1">
<!-- Trigger Button -->
<button
type="button"
onclick={generate_ai_result}
class={buttonClass}
title="Generate AI summary/analysis">
{#await ae_promises}
<Loader class="mr-1 inline-block animate-spin" size="1.2em" />
<span class="text-sm">Processing...</span>
{:then}
<BotMessageSquare class="mr-1 inline-block" size="1.2em" />
<span class="text-sm">Summarize</span>
{:catch}
<span class="text-sm text-red-500">Error</span>
{/await}
</button>
<!-- Quick Settings Button -->
<button
type="button"
onclick={() => {
active_tab = 'settings';
show_modal = true;
}}
class="btn btn-sm preset-tonal-surface shadow-md"
title="AI Settings">
<Settings size="1.2em" />
</button>
<!-- Unified AI Modal -->
{#if show_modal}
<Modal
title="Aether AI Assistant"
bind:open={show_modal}
size="lg"
class="relative mx-auto flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] w-full flex-col rounded-xl border border-surface-200-800 bg-surface-50-900 text-surface-950-50 shadow-xl">
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-3">
<!-- Tab Navigation -->
<div class="bg-surface-500/10 sticky top-0 z-10 mx-auto flex max-w-fit justify-center gap-1 rounded-lg p-1 backdrop-blur-sm">
<button
class={tab_button_class(active_tab === 'result')}
onclick={() => (active_tab = 'result')}>
<Bot size="1.1em" class="mr-1" /> Result
</button>
<button
class={tab_button_class(active_tab === 'settings')}
onclick={() => (active_tab = 'settings')}>
<Settings size="1.1em" class="mr-1" /> Settings
</button>
</div>
{#if active_tab === 'result'}
<div class="animate-in fade-in space-y-4 duration-200">
<div class="flex justify-start gap-2">
<button
class="btn btn-sm preset-filled-success"
onclick={handle_save}>
<Save size="1.1em" class="mr-1" /> Save Result
</button>
<button
class="btn btn-sm preset-tonal-primary"
onclick={generate_ai_result}>
<RotateCcw size="1.1em" class="mr-1" /> Re-run
</button>
</div>
<AE_Comp_Editor_CodeMirror
content={tmp_summary}
bind:new_content={tmp_summary}
theme_mode={$ae_loc.theme_mode}
placeholder="AI Result will appear here..."
class_li="p-2 border rounded-lg h-96 shadow-inner bg-surface-500/5" />
</div>
{:else}
<div
class="animate-in slide-in-from-left-4 space-y-6 duration-200">
<!-- Connection Settings -->
<section class={panel_class}>
<h3 class={panel_title_class}>
<Globe size="1.1em" /> API Connection
</h3>
{#if onSyncConfig}
<button
class="btn btn-sm preset-tonal-primary"
onclick={onSyncConfig}>
<Copy size="1.1em" class="mr-1" /> Sync Global
Defaults
</button>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label">
<span>Base URL</span>
<input
type="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
bind:value={baseUrl}
class="input input-sm" />
</label>
<label class="label">
<span>Model</span>
<input
type="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
name="ai_model"
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
bind:value={model}
class="input input-sm" />
</label>
</div>
<label class="label">
<span>API Token</span>
<div class="flex gap-2">
<input
type={show_api_token ? 'text' : 'password'}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
name="ai_api_token"
data-bwignore="true"
data-lpignore="true"
data-1p-ignore="true"
bind:value={token}
class="input input-sm font-mono" />
<button
type="button"
class="btn btn-sm preset-tonal-surface"
onclick={() =>
(show_api_token = !show_api_token)}
title={show_api_token
? 'Hide API Token'
: 'Show API Token'}>
{#if show_api_token}
<EyeOff size="1.1em" />
{:else}
<Eye size="1.1em" />
{/if}
</button>
</div>
</label>
</section>
<!-- Model Parameters -->
<section class={panel_class}>
<h3 class={panel_title_class}>
<FilePenLine size="1.1em" /> Inference Parameters
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label">
<span>Temperature ({temperature})</span>
<input
type="range"
bind:value={temperature}
min="0"
max="1"
step="0.1"
class="range" />
</label>
<label class="label">
<span>Max Tokens</span>
<input
type="number"
bind:value={maxTokens}
class="input input-sm" />
</label>
</div>
<label class="label">
<span>System Prompt</span>
<textarea
bind:value={systemPrompt}
class="textarea h-24 font-mono text-xs bg-surface-50-900"
></textarea>
</label>
</section>
</div>
{/if}
</div>
</Modal>
{/if}
</div>