Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 566273415f | |||
| ab5025694f | |||
| 7adf5922ba | |||
| b656859b10 | |||
| d9b009ce0a | |||
| c048b1343d | |||
| 558c035b84 | |||
| f8fb5ce172 | |||
| 4084c9a361 | |||
| 26b58fbd50 |
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// Version is set at build time via -ldflags, or defaults to dev
|
||||
var Version = "0.4.11"
|
||||
var Version = "0.4.15"
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vessel",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
270
frontend/src/lib/components/chat/AttachmentDisplay.svelte
Normal file
270
frontend/src/lib/components/chat/AttachmentDisplay.svelte
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* AttachmentDisplay - Shows attached files on messages
|
||||
* Displays compact badges with file info, supports image preview and download
|
||||
*/
|
||||
|
||||
import { getAttachmentMetaByIds, getAttachment, createDownloadUrl } from '$lib/storage';
|
||||
import type { AttachmentMeta, StoredAttachment } from '$lib/storage';
|
||||
|
||||
interface Props {
|
||||
/** Array of attachment IDs to display */
|
||||
attachmentIds: string[];
|
||||
}
|
||||
|
||||
const { attachmentIds }: Props = $props();
|
||||
|
||||
// Attachment metadata loaded from IndexedDB
|
||||
let attachments = $state<AttachmentMeta[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Image preview modal state
|
||||
let previewImage = $state<{ url: string; filename: string } | null>(null);
|
||||
let downloadingId = $state<string | null>(null);
|
||||
|
||||
// Load attachments when IDs change
|
||||
$effect(() => {
|
||||
if (attachmentIds.length > 0) {
|
||||
loadAttachments();
|
||||
} else {
|
||||
attachments = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAttachments(): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await getAttachmentMetaByIds(attachmentIds);
|
||||
if (result.success) {
|
||||
attachments = result.data;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for attachment type
|
||||
*/
|
||||
function getTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return 'M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z';
|
||||
case 'pdf':
|
||||
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
|
||||
case 'text':
|
||||
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
|
||||
default:
|
||||
return 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for attachment type
|
||||
*/
|
||||
function getTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return 'text-violet-400 bg-violet-500/20';
|
||||
case 'pdf':
|
||||
return 'text-red-400 bg-red-500/20';
|
||||
case 'text':
|
||||
return 'text-emerald-400 bg-emerald-500/20';
|
||||
default:
|
||||
return 'text-slate-400 bg-slate-500/20';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle attachment click - preview for images, download for others
|
||||
*/
|
||||
async function handleClick(attachment: AttachmentMeta): Promise<void> {
|
||||
if (attachment.type === 'image') {
|
||||
await showImagePreview(attachment);
|
||||
} else {
|
||||
await downloadAttachment(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show image preview modal
|
||||
*/
|
||||
async function showImagePreview(attachment: AttachmentMeta): Promise<void> {
|
||||
const result = await getAttachment(attachment.id);
|
||||
if (result.success && result.data) {
|
||||
const url = createDownloadUrl(result.data);
|
||||
previewImage = { url, filename: attachment.filename };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close image preview and revoke URL
|
||||
*/
|
||||
function closePreview(): void {
|
||||
if (previewImage) {
|
||||
URL.revokeObjectURL(previewImage.url);
|
||||
previewImage = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download attachment
|
||||
*/
|
||||
async function downloadAttachment(attachment: AttachmentMeta): Promise<void> {
|
||||
downloadingId = attachment.id;
|
||||
|
||||
try {
|
||||
const result = await getAttachment(attachment.id);
|
||||
if (result.success && result.data) {
|
||||
const url = createDownloadUrl(result.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = attachment.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} finally {
|
||||
downloadingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard events for attachment buttons
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent, attachment: AttachmentMeta): void {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleClick(attachment);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 text-sm text-theme-muted">
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>Loading attachments...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-sm text-red-400">
|
||||
Failed to load attachments: {error}
|
||||
</div>
|
||||
{:else if attachments.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each attachments as attachment (attachment.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleClick(attachment)}
|
||||
onkeydown={(e) => handleKeydown(e, attachment)}
|
||||
class="group flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors hover:brightness-110 {getTypeColor(attachment.type)}"
|
||||
title={attachment.type === 'image' ? 'Click to preview' : 'Click to download'}
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-4 w-4 flex-shrink-0"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={getTypeIcon(attachment.type)} />
|
||||
</svg>
|
||||
|
||||
<!-- Filename (truncated) -->
|
||||
<span class="max-w-[150px] truncate">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
|
||||
<!-- Size -->
|
||||
<span class="text-xs opacity-70">
|
||||
{formatSize(attachment.size)}
|
||||
</span>
|
||||
|
||||
<!-- Analyzed badge -->
|
||||
{#if attachment.analyzed}
|
||||
<span class="rounded bg-amber-500/30 px-1 py-0.5 text-[10px] font-medium text-amber-300" title="Content was summarized by AI">
|
||||
analyzed
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Download/loading indicator -->
|
||||
{#if downloadingId === attachment.id}
|
||||
<svg class="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{:else if attachment.type !== 'image'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Image preview modal -->
|
||||
{#if previewImage}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image preview"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePreview}
|
||||
class="absolute inset-0"
|
||||
aria-label="Close preview"
|
||||
></button>
|
||||
|
||||
<div class="relative max-h-[90vh] max-w-[90vw]">
|
||||
<img
|
||||
src={previewImage.url}
|
||||
alt={previewImage.filename}
|
||||
class="max-h-[85vh] max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={closePreview}
|
||||
class="absolute -right-3 -top-3 flex h-8 w-8 items-center justify-center rounded-full bg-slate-800 text-white shadow-lg hover:bg-slate-700"
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Filename -->
|
||||
<div class="mt-2 text-center text-sm text-white/70">
|
||||
{previewImage.filename}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,13 +7,14 @@
|
||||
|
||||
import { modelsState } from '$lib/stores';
|
||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||
import { formatAttachmentsForMessage, processFile } from '$lib/utils/file-processor.js';
|
||||
import { processFile } from '$lib/utils/file-processor.js';
|
||||
import { isImageMimeType } from '$lib/types/attachment.js';
|
||||
import { estimateMessageTokens, formatTokenCount } from '$lib/memory/tokenizer';
|
||||
import FileUpload from './FileUpload.svelte';
|
||||
|
||||
interface Props {
|
||||
onSend?: (content: string, images?: string[]) => void;
|
||||
/** Callback when message is sent. Includes content, images for vision models, and pending file attachments to persist */
|
||||
onSend?: (content: string, images?: string[], pendingAttachments?: FileAttachment[]) => void;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -47,6 +48,7 @@
|
||||
// Drag overlay state
|
||||
let isDragOver = $state(false);
|
||||
let dragCounter = 0; // Track enter/leave for nested elements
|
||||
let dragResetTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Derived state
|
||||
const hasContent = $derived(
|
||||
@@ -98,20 +100,14 @@
|
||||
|
||||
/**
|
||||
* Send the message
|
||||
* Passes raw attachments to ChatWindow which handles analysis
|
||||
*/
|
||||
function handleSend(): void {
|
||||
if (!canSend) return;
|
||||
|
||||
let content = inputValue.trim();
|
||||
const content = inputValue.trim();
|
||||
const images = pendingImages.length > 0 ? [...pendingImages] : undefined;
|
||||
|
||||
// Prepend file attachments content to the message
|
||||
if (pendingAttachments.length > 0) {
|
||||
const attachmentContent = formatAttachmentsForMessage(pendingAttachments);
|
||||
if (attachmentContent) {
|
||||
content = attachmentContent + (content ? '\n\n' + content : '');
|
||||
}
|
||||
}
|
||||
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
|
||||
|
||||
// Clear input, images, and attachments
|
||||
inputValue = '';
|
||||
@@ -123,7 +119,8 @@
|
||||
textareaElement.style.height = 'auto';
|
||||
}
|
||||
|
||||
onSend?.(content, images);
|
||||
// Pass raw content + attachments to parent (ChatWindow handles analysis)
|
||||
onSend?.(content, images, attachments);
|
||||
|
||||
// Keep focus on input after sending
|
||||
requestAnimationFrame(() => focusInput());
|
||||
@@ -172,19 +169,44 @@
|
||||
$effect(() => {
|
||||
if (disabled) return;
|
||||
|
||||
// Helper to reset drag state with optional delay
|
||||
function resetDragState(): void {
|
||||
dragCounter = 0;
|
||||
isDragOver = false;
|
||||
if (dragResetTimeout) {
|
||||
clearTimeout(dragResetTimeout);
|
||||
dragResetTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule a fallback reset in case events get lost
|
||||
function scheduleFallbackReset(): void {
|
||||
if (dragResetTimeout) {
|
||||
clearTimeout(dragResetTimeout);
|
||||
}
|
||||
// Reset after 100ms of no drag activity
|
||||
dragResetTimeout = setTimeout(() => {
|
||||
if (isDragOver) {
|
||||
resetDragState();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function onDragEnter(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
dragCounter++;
|
||||
isDragOver = true;
|
||||
scheduleFallbackReset();
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
isDragOver = false;
|
||||
resetDragState();
|
||||
} else {
|
||||
scheduleFallbackReset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,12 +216,13 @@
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
// Keep resetting the timeout while actively dragging
|
||||
scheduleFallbackReset();
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
isDragOver = false;
|
||||
dragCounter = 0;
|
||||
resetDragState();
|
||||
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
@@ -216,6 +239,9 @@
|
||||
document.removeEventListener('dragleave', onDragLeave);
|
||||
document.removeEventListener('dragover', onDragOver);
|
||||
document.removeEventListener('drop', onDrop);
|
||||
if (dragResetTimeout) {
|
||||
clearTimeout(dragResetTimeout);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -252,8 +278,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Process other files
|
||||
// Process other files (limit to 5 total attachments)
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
for (const file of otherFiles) {
|
||||
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
|
||||
console.warn(`Maximum ${MAX_ATTACHMENTS} files reached, skipping remaining files`);
|
||||
break;
|
||||
}
|
||||
const result = await processFile(file);
|
||||
if (result.success) {
|
||||
pendingAttachments = [...pendingAttachments, result.attachment];
|
||||
@@ -360,7 +391,7 @@
|
||||
></textarea>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if showStopButton}
|
||||
<!-- Stop button -->
|
||||
<button
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
import { serverConversationsState } from '$lib/stores/server-conversations.svelte';
|
||||
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
||||
import { ollamaClient } from '$lib/ollama';
|
||||
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation } from '$lib/storage';
|
||||
import { addMessage as addStoredMessage, updateConversation, createConversation as createStoredConversation, saveAttachments } from '$lib/storage';
|
||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||
import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js';
|
||||
import { attachmentService } from '$lib/services/attachmentService.js';
|
||||
import {
|
||||
contextManager,
|
||||
generateSummary,
|
||||
@@ -42,7 +45,7 @@
|
||||
*/
|
||||
interface Props {
|
||||
mode?: 'new' | 'conversation';
|
||||
onFirstMessage?: (content: string, images?: string[]) => Promise<void>;
|
||||
onFirstMessage?: (content: string, images?: string[], attachments?: FileAttachment[]) => Promise<void>;
|
||||
conversation?: Conversation | null;
|
||||
/** Bindable prop for thinking mode - synced with parent in 'new' mode */
|
||||
thinkingEnabled?: boolean;
|
||||
@@ -63,11 +66,15 @@
|
||||
|
||||
// Context full modal state
|
||||
let showContextFullModal = $state(false);
|
||||
let pendingMessage: { content: string; images?: string[] } | null = $state(null);
|
||||
let pendingMessage: { content: string; images?: string[]; attachments?: FileAttachment[] } | null = $state(null);
|
||||
|
||||
// Tool execution state
|
||||
let isExecutingTools = $state(false);
|
||||
|
||||
// File analysis state
|
||||
let isAnalyzingFiles = $state(false);
|
||||
let analyzingFileNames = $state<string[]>([]);
|
||||
|
||||
// RAG (Retrieval-Augmented Generation) state
|
||||
let ragEnabled = $state(true);
|
||||
let hasKnowledgeBase = $state(false);
|
||||
@@ -217,13 +224,38 @@
|
||||
/**
|
||||
* Convert chat state messages to Ollama API format
|
||||
* Uses messagesForContext to exclude summarized originals but include summaries
|
||||
* Now includes attachment content loaded from IndexedDB
|
||||
*/
|
||||
function getMessagesForApi(): OllamaMessage[] {
|
||||
return chatState.messagesForContext.map((node) => ({
|
||||
role: node.message.role as OllamaMessage['role'],
|
||||
content: node.message.content,
|
||||
images: node.message.images
|
||||
}));
|
||||
async function getMessagesForApi(): Promise<OllamaMessage[]> {
|
||||
const messages: OllamaMessage[] = [];
|
||||
|
||||
for (const node of chatState.messagesForContext) {
|
||||
let content = node.message.content;
|
||||
let images = node.message.images;
|
||||
|
||||
// Load attachment content if present
|
||||
if (node.message.attachmentIds && node.message.attachmentIds.length > 0) {
|
||||
// Load text content from attachments
|
||||
const attachmentContent = await attachmentService.buildOllamaContent(node.message.attachmentIds);
|
||||
if (attachmentContent) {
|
||||
content = content + '\n\n' + attachmentContent;
|
||||
}
|
||||
|
||||
// Load image base64 from attachments
|
||||
const attachmentImages = await attachmentService.buildOllamaImages(node.message.attachmentIds);
|
||||
if (attachmentImages.length > 0) {
|
||||
images = [...(images || []), ...attachmentImages];
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: node.message.role as OllamaMessage['role'],
|
||||
content,
|
||||
images
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,9 +360,9 @@
|
||||
|
||||
// After summarization, try to send the pending message
|
||||
if (pendingMessage && contextManager.contextUsage.percentage < 100) {
|
||||
const { content, images } = pendingMessage;
|
||||
const { content, images, attachments } = pendingMessage;
|
||||
pendingMessage = null;
|
||||
await handleSendMessage(content, images);
|
||||
await handleSendMessage(content, images, attachments);
|
||||
} else if (pendingMessage) {
|
||||
// Still full after summarization - show toast
|
||||
toastState.warning('Context still full after summarization. Try starting a new chat.');
|
||||
@@ -357,10 +389,10 @@
|
||||
|
||||
// Try to send the message anyway (may fail or get truncated)
|
||||
if (pendingMessage) {
|
||||
const { content, images } = pendingMessage;
|
||||
const { content, images, attachments } = pendingMessage;
|
||||
pendingMessage = null;
|
||||
// Bypass the context check by calling the inner logic directly
|
||||
await sendMessageInternal(content, images);
|
||||
await sendMessageInternal(content, images, attachments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +404,7 @@
|
||||
/**
|
||||
* Send a message - checks context and may show modal
|
||||
*/
|
||||
async function handleSendMessage(content: string, images?: string[]): Promise<void> {
|
||||
async function handleSendMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
||||
const selectedModel = modelsState.selectedId;
|
||||
|
||||
if (!selectedModel) {
|
||||
@@ -383,24 +415,24 @@
|
||||
// Check if context is full (100%+)
|
||||
if (contextManager.contextUsage.percentage >= 100) {
|
||||
// Store pending message and show modal
|
||||
pendingMessage = { content, images };
|
||||
pendingMessage = { content, images, attachments };
|
||||
showContextFullModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessageInternal(content, images);
|
||||
await sendMessageInternal(content, images, attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Send message and stream response (bypasses context check)
|
||||
*/
|
||||
async function sendMessageInternal(content: string, images?: string[]): Promise<void> {
|
||||
async function sendMessageInternal(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
||||
const selectedModel = modelsState.selectedId;
|
||||
if (!selectedModel) return;
|
||||
|
||||
// In 'new' mode with no messages yet, create conversation first
|
||||
if (mode === 'new' && !hasMessages && onFirstMessage) {
|
||||
await onFirstMessage(content, images);
|
||||
await onFirstMessage(content, images, attachments);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -423,36 +455,161 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message to tree
|
||||
// Collect attachment IDs if we have attachments to save
|
||||
let attachmentIds: string[] | undefined;
|
||||
if (attachments && attachments.length > 0) {
|
||||
attachmentIds = attachments.map(a => a.id);
|
||||
}
|
||||
|
||||
// Add user message to tree (including attachmentIds for display)
|
||||
const userMessageId = chatState.addMessage({
|
||||
role: 'user',
|
||||
content,
|
||||
images
|
||||
images,
|
||||
attachmentIds
|
||||
});
|
||||
|
||||
// Persist user message to IndexedDB with the SAME ID as chatState
|
||||
// Persist user message and attachments to IndexedDB
|
||||
if (conversationId) {
|
||||
const parentId = chatState.activePath.length >= 2
|
||||
? chatState.activePath[chatState.activePath.length - 2]
|
||||
: null;
|
||||
await addStoredMessage(conversationId, { role: 'user', content, images }, parentId, userMessageId);
|
||||
|
||||
// Save attachments first (they need the messageId)
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Use original File objects for storage (preserves binary data)
|
||||
const files = attachments.map((a) => {
|
||||
if (a.originalFile) {
|
||||
return a.originalFile;
|
||||
}
|
||||
// Fallback: reconstruct from processed data (shouldn't be needed normally)
|
||||
if (a.base64Data) {
|
||||
const binary = atob(a.base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new File([bytes], a.filename, { type: a.mimeType });
|
||||
}
|
||||
// For text/PDF without original, create placeholder (download won't work)
|
||||
console.warn(`No original file for attachment ${a.filename}, download may not work`);
|
||||
return new File([a.textContent || ''], a.filename, { type: a.mimeType });
|
||||
});
|
||||
|
||||
const saveResult = await saveAttachments(userMessageId, files, attachments);
|
||||
if (!saveResult.success) {
|
||||
console.error('Failed to save attachments:', saveResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save message with attachmentIds
|
||||
await addStoredMessage(conversationId, { role: 'user', content, images, attachmentIds }, parentId, userMessageId);
|
||||
}
|
||||
|
||||
// Stream assistant message with optional tool support
|
||||
await streamAssistantResponse(selectedModel, userMessageId, conversationId);
|
||||
// Process attachments if any
|
||||
let contentForOllama = content;
|
||||
let processingMessageId: string | undefined;
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Show processing indicator - this message will become the assistant response
|
||||
isAnalyzingFiles = true;
|
||||
analyzingFileNames = attachments.map(a => a.filename);
|
||||
processingMessageId = chatState.startStreaming();
|
||||
const fileCount = attachments.length;
|
||||
const fileLabel = fileCount === 1 ? 'file' : 'files';
|
||||
chatState.setStreamContent(`Processing ${fileCount} ${fileLabel}...`);
|
||||
|
||||
try {
|
||||
// Check if any files need actual LLM analysis
|
||||
// Force analysis when >3 files to prevent context overflow (max 5 files allowed)
|
||||
const forceAnalysis = attachments.length > 3;
|
||||
const filesToAnalyze = forceAnalysis
|
||||
? attachments.filter(a => a.textContent && a.textContent.length > 2000)
|
||||
: attachments.filter(a => fileAnalyzer.shouldAnalyze(a));
|
||||
|
||||
if (filesToAnalyze.length > 0) {
|
||||
// Update indicator to show analysis
|
||||
chatState.setStreamContent(`Analyzing ${filesToAnalyze.length} ${filesToAnalyze.length === 1 ? 'file' : 'files'}...`);
|
||||
|
||||
const analysisResults = await analyzeFilesInBatches(filesToAnalyze, selectedModel, 3);
|
||||
|
||||
// Update attachments with results
|
||||
filesToAnalyze.forEach((file) => {
|
||||
const result = analysisResults.get(file.id);
|
||||
if (result) {
|
||||
file.analyzed = result.analyzed;
|
||||
file.summary = result.summary;
|
||||
}
|
||||
});
|
||||
|
||||
// Build formatted content with file summaries
|
||||
const formattedParts: string[] = [content];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const result = analysisResults.get(attachment.id);
|
||||
if (result) {
|
||||
formattedParts.push(formatAnalyzedAttachment(attachment, result));
|
||||
} else if (attachment.textContent) {
|
||||
// Non-analyzed text attachment
|
||||
formattedParts.push(`<file name="${attachment.filename}">\n${attachment.textContent}\n</file>`);
|
||||
}
|
||||
}
|
||||
|
||||
contentForOllama = formattedParts.join('\n\n');
|
||||
} else {
|
||||
// No files need analysis, format with content
|
||||
const parts: string[] = [content];
|
||||
for (const a of attachments) {
|
||||
if (a.textContent) {
|
||||
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
|
||||
}
|
||||
}
|
||||
contentForOllama = parts.join('\n\n');
|
||||
}
|
||||
|
||||
// Keep "Processing..." visible - LLM streaming will replace it
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ChatWindow] File processing failed:', error);
|
||||
chatState.setStreamContent('Processing failed, proceeding with original content...');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Fallback: use original content with raw file text
|
||||
const parts: string[] = [content];
|
||||
for (const a of attachments) {
|
||||
if (a.textContent) {
|
||||
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
|
||||
}
|
||||
}
|
||||
contentForOllama = parts.join('\n\n');
|
||||
} finally {
|
||||
isAnalyzingFiles = false;
|
||||
analyzingFileNames = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Stream assistant message (reuse processing message if it exists)
|
||||
await streamAssistantResponse(selectedModel, userMessageId, conversationId, contentForOllama, processingMessageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream assistant response with tool call handling and RAG context
|
||||
* @param contentOverride Optional content to use instead of the last user message content (for formatted attachments)
|
||||
*/
|
||||
async function streamAssistantResponse(
|
||||
model: string,
|
||||
parentMessageId: string,
|
||||
conversationId: string | null
|
||||
conversationId: string | null,
|
||||
contentOverride?: string,
|
||||
existingMessageId?: string
|
||||
): Promise<void> {
|
||||
const assistantMessageId = chatState.startStreaming();
|
||||
// Reuse existing message (e.g., from "Processing..." indicator) or create new one
|
||||
const assistantMessageId = existingMessageId || chatState.startStreaming();
|
||||
abortController = new AbortController();
|
||||
|
||||
// Track if we need to clear the "Processing..." text on first token
|
||||
let needsClearOnFirstToken = !!existingMessageId;
|
||||
|
||||
// Start streaming metrics tracking
|
||||
streamingMetricsState.startStream();
|
||||
|
||||
@@ -460,9 +617,21 @@
|
||||
let pendingToolCalls: OllamaToolCall[] | null = null;
|
||||
|
||||
try {
|
||||
let messages = getMessagesForApi();
|
||||
let messages = await getMessagesForApi();
|
||||
const tools = getToolsForApi();
|
||||
|
||||
// If we have a content override (formatted attachments), replace the last user message content
|
||||
if (contentOverride && messages.length > 0) {
|
||||
const lastUserIndex = messages.findLastIndex(m => m.role === 'user');
|
||||
if (lastUserIndex !== -1) {
|
||||
messages = [
|
||||
...messages.slice(0, lastUserIndex),
|
||||
{ ...messages[lastUserIndex], content: contentOverride },
|
||||
...messages.slice(lastUserIndex + 1)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Build system prompt from resolution service + RAG context
|
||||
const systemParts: string[] = [];
|
||||
|
||||
@@ -494,6 +663,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Always add language instruction
|
||||
systemParts.push('Always respond in the same language the user writes in. Default to English if unclear.');
|
||||
|
||||
// Inject combined system message
|
||||
if (systemParts.length > 0) {
|
||||
const systemMessage: OllamaMessage = {
|
||||
@@ -525,6 +697,11 @@
|
||||
},
|
||||
{
|
||||
onThinkingToken: (token) => {
|
||||
// Clear "Processing..." on first token
|
||||
if (needsClearOnFirstToken) {
|
||||
chatState.setStreamContent('');
|
||||
needsClearOnFirstToken = false;
|
||||
}
|
||||
// Accumulate thinking and update the message
|
||||
if (!streamingThinking) {
|
||||
// Start the thinking block
|
||||
@@ -536,6 +713,11 @@
|
||||
streamingMetricsState.incrementTokens();
|
||||
},
|
||||
onToken: (token) => {
|
||||
// Clear "Processing..." on first token
|
||||
if (needsClearOnFirstToken) {
|
||||
chatState.setStreamContent('');
|
||||
needsClearOnFirstToken = false;
|
||||
}
|
||||
// Close thinking block when content starts
|
||||
if (streamingThinking && !thinkingClosed) {
|
||||
chatState.appendToStreaming('</think>\n\n');
|
||||
@@ -591,6 +773,9 @@
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Streaming error:', error);
|
||||
// Show error to user instead of leaving "Processing..."
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||
chatState.finishStreaming();
|
||||
streamingMetricsState.endStream();
|
||||
abortController = null;
|
||||
@@ -599,6 +784,10 @@
|
||||
abortController.signal
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
// Show error to user
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||
toastState.error('Failed to send message. Please try again.');
|
||||
chatState.finishStreaming();
|
||||
streamingMetricsState.endStream();
|
||||
@@ -747,7 +936,7 @@
|
||||
|
||||
try {
|
||||
// Get messages for API - excludes the current empty assistant message being streamed
|
||||
const messages = getMessagesForApi().filter(m => m.content !== '');
|
||||
const messages = (await getMessagesForApi()).filter(m => m.content !== '');
|
||||
const tools = getToolsForApi();
|
||||
|
||||
// Use function model for tool routing if enabled and tools are present
|
||||
@@ -804,6 +993,8 @@
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Regenerate error:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||
chatState.finishStreaming();
|
||||
streamingMetricsState.endStream();
|
||||
abortController = null;
|
||||
@@ -812,6 +1003,9 @@
|
||||
abortController.signal
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to regenerate:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||
toastState.error('Failed to regenerate. Please try again.');
|
||||
chatState.finishStreaming();
|
||||
streamingMetricsState.endStream();
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* FilePreview.svelte - Preview for attached text/PDF files
|
||||
* Shows filename, size, and expandable content preview
|
||||
* Includes remove button on hover
|
||||
* FilePreview.svelte - Compact preview badge for attached files
|
||||
* Shows filename, size, and type - no raw content dump
|
||||
*/
|
||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||
import { formatFileSize, getFileIcon } from '$lib/utils/file-processor.js';
|
||||
import { formatFileSize } from '$lib/utils/file-processor.js';
|
||||
|
||||
interface Props {
|
||||
attachment: FileAttachment;
|
||||
@@ -13,99 +12,124 @@
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
const { attachment, onRemove, readonly = false }: Props = $props();
|
||||
const props: Props = $props();
|
||||
|
||||
// Expansion state for content preview
|
||||
let isExpanded = $state(false);
|
||||
|
||||
// Truncate preview to first N characters
|
||||
const PREVIEW_LENGTH = 200;
|
||||
const hasContent = attachment.textContent && attachment.textContent.length > 0;
|
||||
const previewText = $derived(
|
||||
attachment.textContent
|
||||
? attachment.textContent.slice(0, PREVIEW_LENGTH) +
|
||||
(attachment.textContent.length > PREVIEW_LENGTH ? '...' : '')
|
||||
: ''
|
||||
);
|
||||
// Derived values to ensure reactivity
|
||||
const attachment = $derived(props.attachment);
|
||||
const onRemove = $derived(props.onRemove);
|
||||
const readonly = $derived(props.readonly ?? false);
|
||||
|
||||
function handleRemove() {
|
||||
onRemove?.(attachment.id);
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
if (hasContent) {
|
||||
isExpanded = !isExpanded;
|
||||
/**
|
||||
* Get icon path for attachment type
|
||||
*/
|
||||
function getIconPath(type: string): string {
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
|
||||
case 'text':
|
||||
return 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z';
|
||||
default:
|
||||
return 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color classes for attachment type
|
||||
*/
|
||||
function getTypeStyle(type: string): { icon: string; badge: string; badgeText: string } {
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return {
|
||||
icon: 'text-red-400',
|
||||
badge: 'bg-red-500/20',
|
||||
badgeText: 'text-red-300'
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
icon: 'text-emerald-400',
|
||||
badge: 'bg-emerald-500/20',
|
||||
badgeText: 'text-emerald-300'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'text-slate-400',
|
||||
badge: 'bg-slate-500/20',
|
||||
badgeText: 'text-slate-300'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension for display
|
||||
*/
|
||||
function getExtension(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toUpperCase();
|
||||
return ext || 'FILE';
|
||||
}
|
||||
|
||||
const style = $derived(getTypeStyle(attachment.type));
|
||||
const extension = $derived(getExtension(attachment.filename));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative flex items-start gap-3 rounded-lg border border-theme/50 bg-theme-secondary/50 p-3 transition-colors hover:bg-theme-secondary"
|
||||
class="group relative inline-flex items-center gap-2.5 rounded-xl border border-theme/30 bg-theme-secondary/60 px-3 py-2 transition-all hover:border-theme/50 hover:bg-theme-secondary"
|
||||
>
|
||||
<!-- File icon -->
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-theme-tertiary/50 text-lg">
|
||||
{getFileIcon(attachment.type)}
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg {style.badge}">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-4 w-4 {style.icon}"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={getIconPath(attachment.type)} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-theme-secondary" title={attachment.filename}>
|
||||
{attachment.filename}
|
||||
</p>
|
||||
<p class="text-xs text-theme-muted">
|
||||
{formatFileSize(attachment.size)}
|
||||
{#if attachment.type === 'pdf'}
|
||||
<span class="text-theme-muted">·</span>
|
||||
<span class="text-violet-400">PDF</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove button (only when not readonly) -->
|
||||
{#if !readonly && onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
class="shrink-0 rounded p-1 text-theme-muted opacity-0 transition-all hover:bg-red-900/30 hover:text-red-400 group-hover:opacity-100"
|
||||
aria-label="Remove file"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="max-w-[180px] truncate text-sm font-medium text-theme-primary" title={attachment.filename}>
|
||||
{attachment.filename}
|
||||
</p>
|
||||
<div class="flex items-center gap-1.5 text-xs text-theme-muted">
|
||||
<span>{formatFileSize(attachment.size)}</span>
|
||||
<span class="opacity-50">·</span>
|
||||
<span class="rounded px-1 py-0.5 text-[10px] font-medium {style.badge} {style.badgeText}">
|
||||
{extension}
|
||||
</span>
|
||||
{#if attachment.truncated}
|
||||
<span class="rounded bg-amber-500/20 px-1 py-0.5 text-[10px] font-medium text-amber-300" title="Content was truncated due to size">
|
||||
truncated
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content preview (expandable) -->
|
||||
{#if hasContent}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleExpand}
|
||||
class="mt-2 w-full text-left"
|
||||
>
|
||||
<div
|
||||
class="rounded border border-theme/50 bg-theme-primary/50 p-2 text-xs text-theme-muted transition-colors hover:border-theme-subtle"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<pre class="max-h-60 overflow-auto whitespace-pre-wrap break-words font-mono">{attachment.textContent}</pre>
|
||||
{:else}
|
||||
<p class="truncate font-mono">{previewText}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-[10px] text-theme-muted">
|
||||
{isExpanded ? 'Click to collapse' : 'Click to expand'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button -->
|
||||
{#if !readonly && onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
class="ml-1 shrink-0 rounded-lg p-1.5 text-theme-muted opacity-0 transition-all hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
aria-label="Remove file"
|
||||
title="Remove"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let fileInputRef: HTMLInputElement | null = $state(null);
|
||||
|
||||
// Constants
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
|
||||
// Derived states
|
||||
const hasAttachments = $derived(attachments.length > 0);
|
||||
const hasImages = $derived(images.length > 0);
|
||||
@@ -73,17 +76,21 @@
|
||||
|
||||
/**
|
||||
* Process multiple files
|
||||
* @param files - Files to process
|
||||
* @param fromPaste - Whether files came from a paste event (affects image handling)
|
||||
*/
|
||||
async function processFiles(files: File[]) {
|
||||
async function processFiles(files: File[], fromPaste = false) {
|
||||
isProcessing = true;
|
||||
errorMessage = null;
|
||||
|
||||
const newAttachments: FileAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// Skip images - they're handled by ImageUpload
|
||||
// Collect images separately
|
||||
if (isImageMimeType(file.type)) {
|
||||
imageFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -95,8 +102,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle collected image files
|
||||
if (imageFiles.length > 0) {
|
||||
if (supportsVision) {
|
||||
// Forward to image processing
|
||||
await processImageFiles(imageFiles);
|
||||
} else if (!fromPaste) {
|
||||
// Only show error if user explicitly selected images (not paste)
|
||||
errors.push(`Images require a vision-capable model (e.g., llava, bakllava)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
onAttachmentsChange([...attachments, ...newAttachments]);
|
||||
const combined = [...attachments, ...newAttachments];
|
||||
if (combined.length > MAX_ATTACHMENTS) {
|
||||
const kept = combined.slice(0, MAX_ATTACHMENTS);
|
||||
const dropped = combined.length - MAX_ATTACHMENTS;
|
||||
onAttachmentsChange(kept);
|
||||
errors.push(`Maximum ${MAX_ATTACHMENTS} files allowed. ${dropped} file(s) not added.`);
|
||||
} else {
|
||||
onAttachmentsChange(combined);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -152,7 +178,8 @@
|
||||
|
||||
// Handle non-image files
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
event.preventDefault(); // Prevent browser from pasting as text
|
||||
processFiles(files, true);
|
||||
}
|
||||
|
||||
// Handle image files
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
* Process and add files to the images array
|
||||
*/
|
||||
async function handleFiles(files: FileList | File[]): Promise<void> {
|
||||
if (!canAddMore) return;
|
||||
if (!canAddMore) {
|
||||
// Show error when max reached
|
||||
errorMessage = `Maximum ${maxImages} images allowed`;
|
||||
setTimeout(() => { errorMessage = null; }, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
const validFiles = fileArray.filter(isValidImageType);
|
||||
@@ -63,13 +68,15 @@
|
||||
const remainingSlots = maxImages - images.length;
|
||||
const filesToProcess = validFiles.slice(0, remainingSlots);
|
||||
|
||||
// Clear previous error when starting new upload
|
||||
errorMessage = null;
|
||||
|
||||
if (filesToProcess.length < validFiles.length) {
|
||||
errorMessage = `Only ${remainingSlots} image${remainingSlots === 1 ? '' : 's'} can be added. Maximum: ${maxImages}`;
|
||||
setTimeout(() => { errorMessage = null; }, 3000);
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
const newImages: string[] = [];
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
// Supports both <thinking>...</thinking> and <think>...</think> (qwen3 format)
|
||||
const THINKING_PATTERN = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/g;
|
||||
|
||||
// Pattern to find file attachment blocks (content shown via AttachmentDisplay badges instead)
|
||||
const FILE_BLOCK_PATTERN = /<file\s+[^>]*>[\s\S]*?<\/file>/g;
|
||||
|
||||
// Pattern to detect JSON tool call objects (for models that output them as text)
|
||||
// Matches: {"name": "...", "arguments": {...}}
|
||||
const JSON_TOOL_CALL_PATTERN = /^(\s*\{[\s\S]*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[\s\S]*\}\s*\}\s*)$/;
|
||||
@@ -72,6 +75,13 @@
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip file attachment blocks (content shown via AttachmentDisplay)
|
||||
*/
|
||||
function stripFileBlocks(text: string): string {
|
||||
return text.replace(FILE_BLOCK_PATTERN, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains tool execution results
|
||||
*/
|
||||
@@ -319,7 +329,8 @@
|
||||
}
|
||||
|
||||
// Clean and parse content into parts
|
||||
const cleanedContent = $derived(cleanToolText(content));
|
||||
// Strip file blocks (shown via AttachmentDisplay) and tool text
|
||||
const cleanedContent = $derived(stripFileBlocks(cleanToolText(content)));
|
||||
const parsedContent = $derived.by(() => {
|
||||
const result = parseContent(cleanedContent);
|
||||
// Debug: Log if thinking blocks were found
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import BranchNavigator from './BranchNavigator.svelte';
|
||||
import StreamingIndicator from './StreamingIndicator.svelte';
|
||||
import ToolCallDisplay from './ToolCallDisplay.svelte';
|
||||
import AttachmentDisplay from './AttachmentDisplay.svelte';
|
||||
|
||||
interface Props {
|
||||
node: MessageNode;
|
||||
@@ -43,6 +44,7 @@
|
||||
const isSystem = $derived(node.message.role === 'system');
|
||||
const hasContent = $derived(node.message.content.length > 0);
|
||||
const hasToolCalls = $derived(node.message.toolCalls && node.message.toolCalls.length > 0);
|
||||
const hasAttachments = $derived(node.message.attachmentIds && node.message.attachmentIds.length > 0);
|
||||
|
||||
// Detect summary messages (compressed conversation history)
|
||||
const isSummaryMessage = $derived(node.message.isSummary === true);
|
||||
@@ -228,6 +230,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if hasAttachments && node.message.attachmentIds}
|
||||
<AttachmentDisplay attachmentIds={node.message.attachmentIds} />
|
||||
{/if}
|
||||
|
||||
{#if hasToolCalls && node.message.toolCalls}
|
||||
<ToolCallDisplay toolCalls={node.message.toolCalls} />
|
||||
{/if}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
let { toolCalls }: Props = $props();
|
||||
|
||||
// Tool metadata for icons and colors
|
||||
const toolMeta: Record<string, { icon: string; color: string; label: string }> = {
|
||||
// Tool metadata for built-in tools (exact matches)
|
||||
const builtinToolMeta: Record<string, { icon: string; color: string; label: string }> = {
|
||||
get_location: {
|
||||
icon: '📍',
|
||||
color: 'from-rose-500 to-pink-600',
|
||||
@@ -41,12 +41,103 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Pattern-based styling for custom tools (checked in order, first match wins)
|
||||
const toolPatterns: Array<{ patterns: string[]; icon: string; color: string; label: string }> = [
|
||||
// Agentic Tools (check first for specific naming)
|
||||
{ patterns: ['task_manager', 'task-manager', 'taskmanager'], icon: '📋', color: 'from-indigo-500 to-purple-600', label: 'Tasks' },
|
||||
{ patterns: ['memory_store', 'memory-store', 'memorystore', 'scratchpad'], icon: '🧠', color: 'from-violet-500 to-purple-600', label: 'Memory' },
|
||||
{ patterns: ['think_step', 'structured_thinking', 'reasoning'], icon: '💭', color: 'from-cyan-500 to-blue-600', label: 'Thinking' },
|
||||
{ patterns: ['decision_matrix', 'decision-matrix', 'evaluate'], icon: '⚖️', color: 'from-amber-500 to-orange-600', label: 'Decision' },
|
||||
{ patterns: ['project_planner', 'project-planner', 'breakdown'], icon: '📊', color: 'from-emerald-500 to-teal-600', label: 'Planning' },
|
||||
// Design & UI
|
||||
{ patterns: ['design', 'brief', 'ui', 'ux', 'layout', 'wireframe'], icon: '🎨', color: 'from-pink-500 to-rose-600', label: 'Design' },
|
||||
{ patterns: ['color', 'palette', 'theme', 'style'], icon: '🎨', color: 'from-fuchsia-500 to-pink-600', label: 'Color' },
|
||||
// Search & Discovery
|
||||
{ patterns: ['search', 'find', 'lookup', 'query'], icon: '🔍', color: 'from-blue-500 to-cyan-600', label: 'Search' },
|
||||
// Web & API
|
||||
{ patterns: ['fetch', 'http', 'api', 'request', 'webhook'], icon: '🌐', color: 'from-violet-500 to-purple-600', label: 'API' },
|
||||
{ patterns: ['url', 'link', 'web', 'scrape'], icon: '🔗', color: 'from-indigo-500 to-violet-600', label: 'Web' },
|
||||
// Data & Analysis
|
||||
{ patterns: ['data', 'analyze', 'stats', 'chart', 'graph', 'metric'], icon: '📊', color: 'from-cyan-500 to-blue-600', label: 'Analysis' },
|
||||
{ patterns: ['json', 'transform', 'parse', 'convert', 'format'], icon: '🔄', color: 'from-sky-500 to-cyan-600', label: 'Transform' },
|
||||
// Math & Calculation
|
||||
{ patterns: ['calc', 'math', 'compute', 'formula', 'number'], icon: '🧮', color: 'from-emerald-500 to-teal-600', label: 'Calculate' },
|
||||
// Time & Date
|
||||
{ patterns: ['time', 'date', 'clock', 'schedule', 'calendar'], icon: '🕐', color: 'from-amber-500 to-orange-600', label: 'Time' },
|
||||
// Location & Maps
|
||||
{ patterns: ['location', 'geo', 'place', 'address', 'map', 'coord'], icon: '📍', color: 'from-rose-500 to-pink-600', label: 'Location' },
|
||||
// Text & String
|
||||
{ patterns: ['text', 'string', 'word', 'sentence', 'paragraph'], icon: '📝', color: 'from-slate-500 to-gray-600', label: 'Text' },
|
||||
// Files & Storage
|
||||
{ patterns: ['file', 'read', 'write', 'save', 'load', 'export', 'import'], icon: '📁', color: 'from-yellow-500 to-amber-600', label: 'File' },
|
||||
// Communication
|
||||
{ patterns: ['email', 'mail', 'send', 'message', 'notify', 'alert'], icon: '📧', color: 'from-red-500 to-rose-600', label: 'Message' },
|
||||
// User & Auth
|
||||
{ patterns: ['user', 'auth', 'login', 'account', 'profile', 'session'], icon: '👤', color: 'from-blue-500 to-indigo-600', label: 'User' },
|
||||
// Database
|
||||
{ patterns: ['database', 'db', 'sql', 'table', 'record', 'store'], icon: '🗄️', color: 'from-orange-500 to-red-600', label: 'Database' },
|
||||
// Code & Execution
|
||||
{ patterns: ['code', 'script', 'execute', 'run', 'shell', 'command'], icon: '💻', color: 'from-green-500 to-emerald-600', label: 'Code' },
|
||||
// Images & Media
|
||||
{ patterns: ['image', 'photo', 'picture', 'screenshot', 'media', 'video'], icon: '🖼️', color: 'from-purple-500 to-fuchsia-600', label: 'Media' },
|
||||
// Weather
|
||||
{ patterns: ['weather', 'forecast', 'temperature', 'climate'], icon: '🌤️', color: 'from-sky-400 to-blue-500', label: 'Weather' },
|
||||
// Translation & Language
|
||||
{ patterns: ['translate', 'language', 'i18n', 'locale'], icon: '🌍', color: 'from-teal-500 to-cyan-600', label: 'Translate' },
|
||||
// Security & Encryption
|
||||
{ patterns: ['encrypt', 'decrypt', 'hash', 'encode', 'decode', 'secure', 'password'], icon: '🔐', color: 'from-red-600 to-orange-600', label: 'Security' },
|
||||
// Random & Generation
|
||||
{ patterns: ['random', 'generate', 'uuid', 'create', 'make'], icon: '🎲', color: 'from-violet-500 to-purple-600', label: 'Generate' },
|
||||
// Lists & Collections
|
||||
{ patterns: ['list', 'array', 'collection', 'filter', 'sort'], icon: '📋', color: 'from-blue-400 to-indigo-500', label: 'List' },
|
||||
// Validation & Check
|
||||
{ patterns: ['valid', 'check', 'verify', 'test', 'assert'], icon: '✅', color: 'from-green-500 to-teal-600', label: 'Validate' }
|
||||
];
|
||||
|
||||
const defaultMeta = {
|
||||
icon: '⚙️',
|
||||
color: 'from-gray-500 to-gray-600',
|
||||
color: 'from-slate-500 to-slate-600',
|
||||
label: 'Tool'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tool metadata - checks builtin tools first, then pattern matches, then default
|
||||
*/
|
||||
function getToolMeta(toolName: string): { icon: string; color: string; label: string } {
|
||||
// Check builtin tools first (exact match)
|
||||
if (builtinToolMeta[toolName]) {
|
||||
return builtinToolMeta[toolName];
|
||||
}
|
||||
|
||||
// Pattern match for custom tools
|
||||
const lowerName = toolName.toLowerCase();
|
||||
for (const pattern of toolPatterns) {
|
||||
if (pattern.patterns.some((p) => lowerName.includes(p))) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return defaultMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tool name to human-readable label
|
||||
*/
|
||||
function formatToolLabel(toolName: string, detectedLabel: string): string {
|
||||
// If it's a known builtin or detected pattern, use that label
|
||||
if (detectedLabel !== 'Tool') {
|
||||
return detectedLabel;
|
||||
}
|
||||
// Otherwise, humanize the tool name
|
||||
return toolName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse arguments to display-friendly format
|
||||
*/
|
||||
@@ -200,7 +291,8 @@
|
||||
|
||||
<div class="my-3 space-y-2">
|
||||
{#each toolCalls as call (call.id)}
|
||||
{@const meta = toolMeta[call.name] || defaultMeta}
|
||||
{@const meta = getToolMeta(call.name)}
|
||||
{@const displayLabel = formatToolLabel(call.name, meta.label)}
|
||||
{@const args = parseArgs(call.arguments)}
|
||||
{@const argEntries = Object.entries(args).filter(([_, v]) => v !== undefined && v !== null)}
|
||||
{@const isExpanded = expandedCalls.has(call.id)}
|
||||
@@ -216,12 +308,12 @@
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-slate-100/50 dark:hover:bg-slate-700/50"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<span class="text-xl" role="img" aria-label={meta.label}>{meta.icon}</span>
|
||||
<span class="text-xl" role="img" aria-label={displayLabel}>{meta.icon}</span>
|
||||
|
||||
<!-- Tool name and summary -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-slate-800 dark:text-slate-100">{meta.label}</span>
|
||||
<span class="font-medium text-slate-800 dark:text-slate-100">{displayLabel}</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">{call.name}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export { default as ChatInput } from './ChatInput.svelte';
|
||||
export { default as ImageUpload } from './ImageUpload.svelte';
|
||||
export { default as ImagePreview } from './ImagePreview.svelte';
|
||||
|
||||
// Attachment display
|
||||
export { default as AttachmentDisplay } from './AttachmentDisplay.svelte';
|
||||
|
||||
// Code display
|
||||
export { default as CodeBlock } from './CodeBlock.svelte';
|
||||
|
||||
|
||||
@@ -513,6 +513,29 @@ print(json.dumps(result))`;
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test button for HTTP -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => showTest = !showTest}
|
||||
class="flex items-center gap-2 text-sm {showTest ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{showTest ? 'Hide Test Panel' : 'Test Tool'}
|
||||
</button>
|
||||
|
||||
<!-- Tool tester for HTTP -->
|
||||
<ToolTester
|
||||
{implementation}
|
||||
code=""
|
||||
{endpoint}
|
||||
{httpMethod}
|
||||
parameters={buildParameterSchema()}
|
||||
isOpen={showTest}
|
||||
onclose={() => showTest = false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
implementation: ToolImplementation;
|
||||
code: string;
|
||||
parameters: JSONSchema;
|
||||
endpoint?: string;
|
||||
httpMethod?: 'GET' | 'POST';
|
||||
isOpen?: boolean;
|
||||
onclose?: () => void;
|
||||
}
|
||||
|
||||
const { implementation, code, parameters, isOpen = false, onclose }: Props = $props();
|
||||
const { implementation, code, parameters, endpoint = '', httpMethod = 'POST', isOpen = false, onclose }: Props = $props();
|
||||
|
||||
let testInput = $state('{}');
|
||||
let testResult = $state<{ success: boolean; result?: unknown; error?: string } | null>(null);
|
||||
@@ -116,8 +118,54 @@
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
} else if (implementation === 'http') {
|
||||
// HTTP endpoint execution
|
||||
if (!endpoint.trim()) {
|
||||
testResult = { success: false, error: 'HTTP endpoint URL is required' };
|
||||
isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
const options: RequestInit = {
|
||||
method: httpMethod,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (httpMethod === 'GET') {
|
||||
// Add args as query parameters
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
} else {
|
||||
options.body = JSON.stringify(args);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), options);
|
||||
|
||||
if (!response.ok) {
|
||||
testResult = {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`
|
||||
};
|
||||
} else {
|
||||
const contentType = response.headers.get('content-type');
|
||||
const result = contentType?.includes('application/json')
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
testResult = { success: true, result };
|
||||
}
|
||||
} catch (error) {
|
||||
testResult = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
testResult = { success: false, error: 'HTTP tools cannot be tested in the editor' };
|
||||
testResult = { success: false, error: 'Unknown implementation type' };
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
@@ -169,7 +217,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={runTest}
|
||||
disabled={isRunning || !code.trim()}
|
||||
disabled={isRunning || (implementation === 'http' ? !endpoint.trim() : !code.trim())}
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if isRunning}
|
||||
|
||||
433
frontend/src/lib/prompts/templates.ts
Normal file
433
frontend/src/lib/prompts/templates.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Curated prompt templates for the Prompt Browser
|
||||
*
|
||||
* These templates are inspired by patterns from popular AI tools and can be
|
||||
* added to the user's prompt library with one click.
|
||||
*/
|
||||
|
||||
export type PromptCategory = 'coding' | 'writing' | 'analysis' | 'creative' | 'assistant';
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
category: PromptCategory;
|
||||
targetCapabilities?: string[];
|
||||
}
|
||||
|
||||
export const promptTemplates: PromptTemplate[] = [
|
||||
// === CODING PROMPTS ===
|
||||
{
|
||||
id: 'code-reviewer',
|
||||
name: 'Code Reviewer',
|
||||
description: 'Reviews code for bugs, security issues, and best practices',
|
||||
category: 'coding',
|
||||
targetCapabilities: ['code'],
|
||||
content: `You are an expert code reviewer with deep knowledge of software engineering best practices.
|
||||
|
||||
When reviewing code:
|
||||
1. **Correctness**: Identify bugs, logic errors, and edge cases
|
||||
2. **Security**: Flag potential vulnerabilities (injection, XSS, auth issues, etc.)
|
||||
3. **Performance**: Spot inefficiencies and suggest optimizations
|
||||
4. **Readability**: Evaluate naming, structure, and documentation
|
||||
5. **Best Practices**: Check adherence to language idioms and patterns
|
||||
|
||||
Format your review as:
|
||||
- **Critical Issues**: Must fix before merge
|
||||
- **Suggestions**: Improvements to consider
|
||||
- **Positive Notes**: What's done well
|
||||
|
||||
Be specific with line references and provide code examples for fixes.`
|
||||
},
|
||||
{
|
||||
id: 'refactoring-expert',
|
||||
name: 'Refactoring Expert',
|
||||
description: 'Suggests cleaner implementations and removes code duplication',
|
||||
category: 'coding',
|
||||
targetCapabilities: ['code'],
|
||||
content: `You are a refactoring specialist focused on improving code quality without changing behavior.
|
||||
|
||||
Your approach:
|
||||
1. Identify code smells (duplication, long methods, large classes, etc.)
|
||||
2. Suggest appropriate design patterns when beneficial
|
||||
3. Simplify complex conditionals and nested logic
|
||||
4. Extract reusable functions and components
|
||||
5. Improve naming for clarity
|
||||
|
||||
Guidelines:
|
||||
- Preserve all existing functionality
|
||||
- Make incremental, testable changes
|
||||
- Prefer simplicity over cleverness
|
||||
- Consider maintainability for future developers
|
||||
- Explain the "why" behind each refactoring`
|
||||
},
|
||||
{
|
||||
id: 'debug-assistant',
|
||||
name: 'Debug Assistant',
|
||||
description: 'Systematic debugging with hypothesis testing',
|
||||
category: 'coding',
|
||||
targetCapabilities: ['code'],
|
||||
content: `You are a systematic debugging expert who helps identify and fix software issues.
|
||||
|
||||
Debugging methodology:
|
||||
1. **Reproduce**: Understand the exact steps to trigger the bug
|
||||
2. **Isolate**: Narrow down where the problem occurs
|
||||
3. **Hypothesize**: Form theories about the root cause
|
||||
4. **Test**: Suggest ways to verify each hypothesis
|
||||
5. **Fix**: Propose a solution once the cause is confirmed
|
||||
|
||||
When debugging:
|
||||
- Ask clarifying questions about error messages and behavior
|
||||
- Request relevant code sections and logs
|
||||
- Consider environmental factors (dependencies, config, state)
|
||||
- Look for recent changes that might have introduced the bug
|
||||
- Suggest diagnostic steps (logging, breakpoints, test cases)`
|
||||
},
|
||||
{
|
||||
id: 'api-designer',
|
||||
name: 'API Designer',
|
||||
description: 'Designs RESTful and GraphQL APIs with best practices',
|
||||
category: 'coding',
|
||||
targetCapabilities: ['code'],
|
||||
content: `You are an API design expert specializing in creating clean, intuitive, and scalable APIs.
|
||||
|
||||
Design principles:
|
||||
1. **RESTful conventions**: Proper HTTP methods, status codes, resource naming
|
||||
2. **Consistency**: Uniform patterns across all endpoints
|
||||
3. **Versioning**: Strategies for backwards compatibility
|
||||
4. **Authentication**: OAuth, JWT, API keys - when to use each
|
||||
5. **Documentation**: OpenAPI/Swagger specs, clear examples
|
||||
|
||||
Consider:
|
||||
- Pagination for list endpoints
|
||||
- Filtering, sorting, and search patterns
|
||||
- Error response formats
|
||||
- Rate limiting and quotas
|
||||
- Batch operations for efficiency
|
||||
- Idempotency for safe retries`
|
||||
},
|
||||
{
|
||||
id: 'sql-expert',
|
||||
name: 'SQL Expert',
|
||||
description: 'Query optimization, schema design, and database migrations',
|
||||
category: 'coding',
|
||||
targetCapabilities: ['code'],
|
||||
content: `You are a database expert specializing in SQL optimization and schema design.
|
||||
|
||||
Areas of expertise:
|
||||
1. **Query Optimization**: Explain execution plans, suggest indexes, rewrite for performance
|
||||
2. **Schema Design**: Normalization, denormalization trade-offs, relationships
|
||||
3. **Migrations**: Safe schema changes, zero-downtime deployments
|
||||
4. **Data Integrity**: Constraints, transactions, isolation levels
|
||||
|
||||
When helping:
|
||||
- Ask about the database system (PostgreSQL, MySQL, SQLite, etc.)
|
||||
- Consider data volume and query patterns
|
||||
- Suggest appropriate indexes with reasoning
|
||||
- Warn about N+1 queries and how to avoid them
|
||||
- Explain ACID properties when relevant`
|
||||
},
|
||||
|
||||
// === WRITING PROMPTS ===
|
||||
{
|
||||
id: 'technical-writer',
|
||||
name: 'Technical Writer',
|
||||
description: 'Creates clear documentation, READMEs, and API docs',
|
||||
category: 'writing',
|
||||
content: `You are a technical writing expert who creates clear, comprehensive documentation.
|
||||
|
||||
Documentation principles:
|
||||
1. **Audience-aware**: Adjust complexity for the target reader
|
||||
2. **Task-oriented**: Focus on what users need to accomplish
|
||||
3. **Scannable**: Use headings, lists, and code blocks effectively
|
||||
4. **Complete**: Cover setup, usage, examples, and troubleshooting
|
||||
5. **Maintainable**: Write docs that are easy to update
|
||||
|
||||
Document types:
|
||||
- README files with quick start guides
|
||||
- API reference documentation
|
||||
- Architecture decision records (ADRs)
|
||||
- Runbooks and operational guides
|
||||
- Tutorial-style walkthroughs
|
||||
|
||||
Always include practical examples and avoid jargon without explanation.`
|
||||
},
|
||||
{
|
||||
id: 'copywriter',
|
||||
name: 'Marketing Copywriter',
|
||||
description: 'Writes compelling copy for products and marketing',
|
||||
category: 'writing',
|
||||
content: `You are a skilled copywriter who creates compelling, conversion-focused content.
|
||||
|
||||
Writing approach:
|
||||
1. **Hook**: Grab attention with a strong opening
|
||||
2. **Problem**: Identify the pain point or desire
|
||||
3. **Solution**: Present your offering as the answer
|
||||
4. **Proof**: Back claims with evidence or social proof
|
||||
5. **Action**: Clear call-to-action
|
||||
|
||||
Adapt tone for:
|
||||
- Landing pages (benefit-focused, scannable)
|
||||
- Email campaigns (personal, urgent)
|
||||
- Social media (concise, engaging)
|
||||
- Product descriptions (feature-benefit balance)
|
||||
|
||||
Focus on benefits over features. Use active voice and concrete language.`
|
||||
},
|
||||
|
||||
// === ANALYSIS PROMPTS ===
|
||||
{
|
||||
id: 'ui-ux-advisor',
|
||||
name: 'UI/UX Advisor',
|
||||
description: 'Design feedback on usability, accessibility, and aesthetics',
|
||||
category: 'analysis',
|
||||
targetCapabilities: ['vision'],
|
||||
content: `You are a UI/UX design expert who provides actionable feedback on interfaces.
|
||||
|
||||
Evaluation criteria:
|
||||
1. **Usability**: Is it intuitive? Can users accomplish their goals?
|
||||
2. **Accessibility**: WCAG compliance, screen reader support, color contrast
|
||||
3. **Visual Hierarchy**: Does the layout guide attention appropriately?
|
||||
4. **Consistency**: Do patterns repeat predictably?
|
||||
5. **Responsiveness**: How does it adapt to different screen sizes?
|
||||
|
||||
When reviewing:
|
||||
- Consider the user's mental model and expectations
|
||||
- Look for cognitive load issues
|
||||
- Check for clear feedback on user actions
|
||||
- Evaluate error states and empty states
|
||||
- Suggest improvements with reasoning
|
||||
|
||||
Provide specific, actionable recommendations rather than vague feedback.`
|
||||
},
|
||||
{
|
||||
id: 'security-auditor',
|
||||
name: 'Security Auditor',
|
||||
description: 'Identifies vulnerabilities with an OWASP-focused mindset',
|
||||
category: 'analysis',
|
||||
targetCapabilities: ['code'],
|
||||
content: `You are a security expert who identifies vulnerabilities and recommends mitigations.
|
||||
|
||||
Focus areas (OWASP Top 10):
|
||||
1. **Injection**: SQL, NoSQL, OS command, LDAP injection
|
||||
2. **Broken Authentication**: Session management, credential exposure
|
||||
3. **Sensitive Data Exposure**: Encryption, data classification
|
||||
4. **XXE**: XML external entity attacks
|
||||
5. **Broken Access Control**: Authorization bypasses, IDOR
|
||||
6. **Security Misconfiguration**: Default credentials, exposed endpoints
|
||||
7. **XSS**: Reflected, stored, DOM-based cross-site scripting
|
||||
8. **Insecure Deserialization**: Object injection attacks
|
||||
9. **Vulnerable Components**: Outdated dependencies
|
||||
10. **Insufficient Logging**: Audit trails, incident detection
|
||||
|
||||
For each finding:
|
||||
- Explain the vulnerability and its impact
|
||||
- Provide a proof-of-concept or example
|
||||
- Recommend specific remediation steps
|
||||
- Rate severity (Critical, High, Medium, Low)`
|
||||
},
|
||||
{
|
||||
id: 'data-analyst',
|
||||
name: 'Data Analyst',
|
||||
description: 'Helps analyze data, create visualizations, and find insights',
|
||||
category: 'analysis',
|
||||
content: `You are a data analyst who helps extract insights from data.
|
||||
|
||||
Capabilities:
|
||||
1. **Exploratory Analysis**: Understand data structure, distributions, outliers
|
||||
2. **Statistical Analysis**: Correlations, hypothesis testing, trends
|
||||
3. **Visualization**: Chart selection, design best practices
|
||||
4. **SQL Queries**: Complex aggregations, window functions
|
||||
5. **Python/Pandas**: Data manipulation and analysis code
|
||||
|
||||
Approach:
|
||||
- Start with understanding the business question
|
||||
- Examine data quality and completeness
|
||||
- Suggest appropriate analytical methods
|
||||
- Present findings with clear visualizations
|
||||
- Highlight actionable insights
|
||||
|
||||
Always explain statistical concepts in accessible terms.`
|
||||
},
|
||||
|
||||
// === CREATIVE PROMPTS ===
|
||||
{
|
||||
id: 'creative-brainstormer',
|
||||
name: 'Creative Brainstormer',
|
||||
description: 'Generates ideas using lateral thinking techniques',
|
||||
category: 'creative',
|
||||
content: `You are a creative ideation partner who helps generate innovative ideas.
|
||||
|
||||
Brainstorming techniques:
|
||||
1. **SCAMPER**: Substitute, Combine, Adapt, Modify, Put to other uses, Eliminate, Reverse
|
||||
2. **Lateral Thinking**: Challenge assumptions, random entry points
|
||||
3. **Mind Mapping**: Explore connections and associations
|
||||
4. **Reverse Brainstorming**: How to cause the problem, then invert
|
||||
5. **Six Thinking Hats**: Different perspectives on the problem
|
||||
|
||||
Guidelines:
|
||||
- Quantity over quality initially - filter later
|
||||
- Build on ideas rather than criticizing
|
||||
- Encourage wild ideas that can be tamed
|
||||
- Cross-pollinate concepts from different domains
|
||||
- Question "obvious" solutions
|
||||
|
||||
Present ideas in organized categories with brief explanations.`
|
||||
},
|
||||
{
|
||||
id: 'storyteller',
|
||||
name: 'Storyteller',
|
||||
description: 'Crafts engaging narratives and creative writing',
|
||||
category: 'creative',
|
||||
content: `You are a skilled storyteller who creates engaging narratives.
|
||||
|
||||
Story elements:
|
||||
1. **Character**: Compelling protagonists with clear motivations
|
||||
2. **Conflict**: Internal and external challenges that drive the plot
|
||||
3. **Setting**: Vivid world-building that supports the story
|
||||
4. **Plot**: Beginning hook, rising action, climax, resolution
|
||||
5. **Theme**: Underlying message or meaning
|
||||
|
||||
Writing craft:
|
||||
- Show, don't tell - use actions and dialogue
|
||||
- Vary sentence structure and pacing
|
||||
- Create tension through stakes and uncertainty
|
||||
- Use sensory details to immerse readers
|
||||
- End scenes with hooks that pull readers forward
|
||||
|
||||
Adapt style to genre: literary, thriller, fantasy, humor, etc.`
|
||||
},
|
||||
|
||||
// === ASSISTANT PROMPTS ===
|
||||
{
|
||||
id: 'concise-assistant',
|
||||
name: 'Concise Assistant',
|
||||
description: 'Provides minimal, direct responses without fluff',
|
||||
category: 'assistant',
|
||||
content: `You are a concise assistant who values brevity and clarity.
|
||||
|
||||
Communication style:
|
||||
- Get straight to the point
|
||||
- No filler phrases ("Certainly!", "Great question!", "I'd be happy to...")
|
||||
- Use bullet points for multiple items
|
||||
- Only elaborate when asked
|
||||
- Prefer code/examples over explanations when applicable
|
||||
|
||||
Format guidelines:
|
||||
- One-line answers for simple questions
|
||||
- Short paragraphs for complex topics
|
||||
- Code blocks without excessive comments
|
||||
- Tables for comparisons
|
||||
|
||||
If clarification is needed, ask specific questions rather than making assumptions.`
|
||||
},
|
||||
{
|
||||
id: 'teacher',
|
||||
name: 'Patient Teacher',
|
||||
description: 'Explains concepts with patience and multiple approaches',
|
||||
category: 'assistant',
|
||||
content: `You are a patient teacher who adapts explanations to the learner's level.
|
||||
|
||||
Teaching approach:
|
||||
1. **Assess understanding**: Ask what they already know
|
||||
2. **Build foundations**: Ensure prerequisites are clear
|
||||
3. **Use analogies**: Connect new concepts to familiar ones
|
||||
4. **Provide examples**: Concrete illustrations of abstract ideas
|
||||
5. **Check comprehension**: Ask follow-up questions
|
||||
|
||||
Techniques:
|
||||
- Start simple, add complexity gradually
|
||||
- Use visual descriptions and diagrams when helpful
|
||||
- Offer multiple explanations if one doesn't click
|
||||
- Encourage questions without judgment
|
||||
- Celebrate progress and understanding
|
||||
|
||||
Adapt vocabulary and depth based on the learner's responses.`
|
||||
},
|
||||
{
|
||||
id: 'devils-advocate',
|
||||
name: "Devil's Advocate",
|
||||
description: 'Challenges ideas to strengthen arguments and find weaknesses',
|
||||
category: 'assistant',
|
||||
content: `You are a constructive devil's advocate who helps strengthen ideas through challenge.
|
||||
|
||||
Your role:
|
||||
1. **Question assumptions**: "What if the opposite were true?"
|
||||
2. **Find weaknesses**: Identify logical gaps and vulnerabilities
|
||||
3. **Present counterarguments**: Steel-man opposing viewpoints
|
||||
4. **Stress test**: Push ideas to their limits
|
||||
5. **Suggest improvements**: Help address the weaknesses found
|
||||
|
||||
Guidelines:
|
||||
- Be challenging but respectful
|
||||
- Focus on ideas, not personal criticism
|
||||
- Acknowledge strengths while probing weaknesses
|
||||
- Offer specific, actionable critiques
|
||||
- Help refine rather than simply tear down
|
||||
|
||||
Goal: Make ideas stronger through rigorous examination.`
|
||||
},
|
||||
{
|
||||
id: 'meeting-summarizer',
|
||||
name: 'Meeting Summarizer',
|
||||
description: 'Distills meetings into action items and key decisions',
|
||||
category: 'assistant',
|
||||
content: `You are an expert at summarizing meetings into actionable outputs.
|
||||
|
||||
Summary structure:
|
||||
1. **Key Decisions**: What was decided and by whom
|
||||
2. **Action Items**: Tasks with owners and deadlines
|
||||
3. **Discussion Points**: Main topics covered
|
||||
4. **Open Questions**: Unresolved issues for follow-up
|
||||
5. **Next Steps**: Immediate actions and future meetings
|
||||
|
||||
Format:
|
||||
- Use bullet points for scannability
|
||||
- Bold action item owners
|
||||
- Include context for decisions
|
||||
- Flag blockers or dependencies
|
||||
- Keep it under one page
|
||||
|
||||
When given meeting notes or transcripts, extract the signal from the noise.`
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all prompt templates
|
||||
*/
|
||||
export function getAllPromptTemplates(): PromptTemplate[] {
|
||||
return promptTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompt templates by category
|
||||
*/
|
||||
export function getPromptTemplatesByCategory(category: PromptCategory): PromptTemplate[] {
|
||||
return promptTemplates.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prompt template by ID
|
||||
*/
|
||||
export function getPromptTemplateById(id: string): PromptTemplate | undefined {
|
||||
return promptTemplates.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique categories from templates
|
||||
*/
|
||||
export function getPromptCategories(): PromptCategory[] {
|
||||
return [...new Set(promptTemplates.map((t) => t.category))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Category display information
|
||||
*/
|
||||
export const categoryInfo: Record<PromptCategory, { label: string; icon: string; color: string }> = {
|
||||
coding: { label: 'Coding', icon: '💻', color: 'bg-blue-500/20 text-blue-400' },
|
||||
writing: { label: 'Writing', icon: '✍️', color: 'bg-green-500/20 text-green-400' },
|
||||
analysis: { label: 'Analysis', icon: '🔍', color: 'bg-purple-500/20 text-purple-400' },
|
||||
creative: { label: 'Creative', icon: '🎨', color: 'bg-pink-500/20 text-pink-400' },
|
||||
assistant: { label: 'Assistant', icon: '🤖', color: 'bg-amber-500/20 text-amber-400' }
|
||||
};
|
||||
372
frontend/src/lib/services/attachmentService.ts
Normal file
372
frontend/src/lib/services/attachmentService.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Attachment Service
|
||||
* Coordinates file processing, analysis, and storage for message attachments.
|
||||
* Acts as the main API for attachment operations in the chat flow.
|
||||
*/
|
||||
|
||||
import { processFile } from '$lib/utils/file-processor.js';
|
||||
import { fileAnalyzer, type AnalysisResult } from './fileAnalyzer.js';
|
||||
import {
|
||||
saveAttachment,
|
||||
saveAttachments as saveAttachmentsToDb,
|
||||
getAttachment,
|
||||
getAttachmentsByIds,
|
||||
getAttachmentMetaForMessage,
|
||||
getAttachmentMetaByIds,
|
||||
getAttachmentBase64,
|
||||
getAttachmentTextContent,
|
||||
createDownloadUrl,
|
||||
deleteAttachment,
|
||||
deleteAttachmentsByIds,
|
||||
updateAttachmentAnalysis
|
||||
} from '$lib/storage/index.js';
|
||||
import type { StoredAttachment, AttachmentMeta, StorageResult } from '$lib/storage/index.js';
|
||||
import type { FileAttachment, AttachmentType } from '$lib/types/attachment.js';
|
||||
|
||||
/**
|
||||
* Pending attachment before it's saved to IndexedDB.
|
||||
* Contains the processed file data and original File object.
|
||||
*/
|
||||
export interface PendingAttachment {
|
||||
file: File;
|
||||
attachment: FileAttachment;
|
||||
analysisResult?: AnalysisResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success result of preparing an attachment.
|
||||
*/
|
||||
export interface PrepareSuccess {
|
||||
success: true;
|
||||
pending: PendingAttachment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error result of preparing an attachment.
|
||||
*/
|
||||
export interface PrepareError {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of preparing an attachment for sending.
|
||||
*/
|
||||
export type PrepareResult = PrepareSuccess | PrepareError;
|
||||
|
||||
/**
|
||||
* Content formatted for inclusion in a message.
|
||||
*/
|
||||
export interface FormattedContent {
|
||||
/** The formatted text content (XML-style tags) */
|
||||
text: string;
|
||||
/** Whether any content was analyzed by the sub-agent */
|
||||
hasAnalyzed: boolean;
|
||||
/** Total original size of all attachments */
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
class AttachmentService {
|
||||
/**
|
||||
* Prepare a single file as a pending attachment.
|
||||
* Processes the file but does not persist to storage.
|
||||
*/
|
||||
async prepareAttachment(file: File): Promise<PrepareResult> {
|
||||
const result = await processFile(file);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
pending: { file, attachment: result.attachment }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare multiple files as pending attachments.
|
||||
* Returns both successful preparations and any errors.
|
||||
*/
|
||||
async prepareAttachments(files: File[]): Promise<{
|
||||
pending: PendingAttachment[];
|
||||
errors: string[];
|
||||
}> {
|
||||
const pending: PendingAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const result = await this.prepareAttachment(file);
|
||||
if (result.success) {
|
||||
pending.push(result.pending);
|
||||
} else {
|
||||
errors.push(`${file.name}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { pending, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze pending attachments that exceed size thresholds.
|
||||
* Spawns sub-agent for large files to summarize content.
|
||||
*/
|
||||
async analyzeIfNeeded(
|
||||
pending: PendingAttachment[],
|
||||
model: string
|
||||
): Promise<PendingAttachment[]> {
|
||||
const analyzed: PendingAttachment[] = [];
|
||||
|
||||
for (const item of pending) {
|
||||
const result = await fileAnalyzer.analyzeIfNeeded(item.attachment, model);
|
||||
analyzed.push({
|
||||
...item,
|
||||
analysisResult: result
|
||||
});
|
||||
}
|
||||
|
||||
return analyzed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save pending attachments to IndexedDB, linking them to a message.
|
||||
* Returns the attachment IDs for storing in the message.
|
||||
*/
|
||||
async savePendingAttachments(
|
||||
messageId: string,
|
||||
pending: PendingAttachment[]
|
||||
): Promise<StorageResult<string[]>> {
|
||||
const files = pending.map(p => p.file);
|
||||
const attachments = pending.map(p => p.attachment);
|
||||
|
||||
const result = await saveAttachmentsToDb(messageId, files, attachments);
|
||||
|
||||
if (result.success) {
|
||||
// Update analysis status if any were analyzed
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const item = pending[i];
|
||||
if (item.analysisResult?.analyzed) {
|
||||
await updateAttachmentAnalysis(
|
||||
attachments[i].id,
|
||||
true,
|
||||
item.analysisResult.summary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pending attachments for inclusion in message content.
|
||||
* Uses analysis summaries for large files, raw content for small ones.
|
||||
*/
|
||||
formatForMessage(pending: PendingAttachment[]): FormattedContent {
|
||||
let hasAnalyzed = false;
|
||||
let totalSize = 0;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const item of pending) {
|
||||
const { attachment, analysisResult } = item;
|
||||
totalSize += attachment.size;
|
||||
|
||||
// Skip images - they go in the images array, not text content
|
||||
if (attachment.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if no text content to include
|
||||
if (!attachment.textContent && !analysisResult?.summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sizeAttr = ` size="${formatFileSize(attachment.size)}"`;
|
||||
const typeAttr = ` type="${attachment.type}"`;
|
||||
|
||||
if (analysisResult && !analysisResult.useOriginal && analysisResult.summary) {
|
||||
// Use analyzed summary for large files
|
||||
hasAnalyzed = true;
|
||||
parts.push(
|
||||
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr} analyzed="true">\n` +
|
||||
`${analysisResult.summary}\n` +
|
||||
`[Full content (${formatFileSize(analysisResult.originalLength)}) stored locally]\n` +
|
||||
`</file>`
|
||||
);
|
||||
} else {
|
||||
// Use raw content for small files
|
||||
const content = analysisResult?.content || attachment.textContent || '';
|
||||
const truncatedAttr = attachment.truncated ? ' truncated="true"' : '';
|
||||
parts.push(
|
||||
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${truncatedAttr}>\n` +
|
||||
`${content}\n` +
|
||||
`</file>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: parts.join('\n\n'),
|
||||
hasAnalyzed,
|
||||
totalSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image base64 data for Ollama from pending attachments.
|
||||
* Returns array of base64 strings (without data: prefix).
|
||||
*/
|
||||
getImagesFromPending(pending: PendingAttachment[]): string[] {
|
||||
return pending
|
||||
.filter(p => p.attachment.type === 'image' && p.attachment.base64Data)
|
||||
.map(p => p.attachment.base64Data!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load attachment metadata for display (without binary data).
|
||||
*/
|
||||
async getMetaForMessage(messageId: string): Promise<StorageResult<AttachmentMeta[]>> {
|
||||
return getAttachmentMetaForMessage(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load attachment metadata by IDs.
|
||||
*/
|
||||
async getMetaByIds(ids: string[]): Promise<StorageResult<AttachmentMeta[]>> {
|
||||
return getAttachmentMetaByIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full attachment data by ID.
|
||||
*/
|
||||
async getFullAttachment(id: string): Promise<StorageResult<StoredAttachment | null>> {
|
||||
return getAttachment(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple full attachments by IDs.
|
||||
*/
|
||||
async getFullAttachments(ids: string[]): Promise<StorageResult<StoredAttachment[]>> {
|
||||
return getAttachmentsByIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base64 data for an image attachment (for Ollama).
|
||||
*/
|
||||
async getImageBase64(id: string): Promise<StorageResult<string | null>> {
|
||||
return getAttachmentBase64(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content from an attachment.
|
||||
*/
|
||||
async getTextContent(id: string): Promise<StorageResult<string | null>> {
|
||||
return getAttachmentTextContent(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a download URL for an attachment.
|
||||
* Remember to call URL.revokeObjectURL() when done.
|
||||
*/
|
||||
async createDownloadUrl(id: string): Promise<string | null> {
|
||||
const result = await getAttachment(id);
|
||||
if (!result.success || !result.data) {
|
||||
return null;
|
||||
}
|
||||
return createDownloadUrl(result.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single attachment.
|
||||
*/
|
||||
async deleteAttachment(id: string): Promise<StorageResult<void>> {
|
||||
return deleteAttachment(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple attachments.
|
||||
*/
|
||||
async deleteAttachments(ids: string[]): Promise<StorageResult<void>> {
|
||||
return deleteAttachmentsByIds(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build images array for Ollama from stored attachment IDs.
|
||||
* Loads image base64 data from IndexedDB.
|
||||
*/
|
||||
async buildOllamaImages(attachmentIds: string[]): Promise<string[]> {
|
||||
const images: string[] = [];
|
||||
|
||||
for (const id of attachmentIds) {
|
||||
const result = await getAttachmentBase64(id);
|
||||
if (result.success && result.data) {
|
||||
images.push(result.data);
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build text content for Ollama from stored attachment IDs.
|
||||
* Returns formatted XML-style content for non-image attachments.
|
||||
*/
|
||||
async buildOllamaContent(attachmentIds: string[]): Promise<string> {
|
||||
const attachments = await getAttachmentsByIds(attachmentIds);
|
||||
if (!attachments.success) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const attachment of attachments.data) {
|
||||
// Skip images - they go in images array
|
||||
if (attachment.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = attachment.textContent || await attachment.data.text().catch(() => null);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sizeAttr = ` size="${formatFileSize(attachment.size)}"`;
|
||||
const typeAttr = ` type="${attachment.type}"`;
|
||||
const analyzedAttr = attachment.analyzed ? ' analyzed="true"' : '';
|
||||
const truncatedAttr = attachment.truncated ? ' truncated="true"' : '';
|
||||
|
||||
if (attachment.analyzed && attachment.summary) {
|
||||
// Use stored summary
|
||||
parts.push(
|
||||
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${analyzedAttr}>\n` +
|
||||
`${attachment.summary}\n` +
|
||||
`[Full content stored locally]\n` +
|
||||
`</file>`
|
||||
);
|
||||
} else {
|
||||
// Use raw content
|
||||
parts.push(
|
||||
`<file name="${escapeXmlAttr(attachment.filename)}"${sizeAttr}${typeAttr}${truncatedAttr}>\n` +
|
||||
`${content}\n` +
|
||||
`</file>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function escapeXmlAttr(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Singleton export
|
||||
export const attachmentService = new AttachmentService();
|
||||
407
frontend/src/lib/services/fileAnalyzer.ts
Normal file
407
frontend/src/lib/services/fileAnalyzer.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* File Analyzer Service
|
||||
*
|
||||
* Spawns a separate Ollama request to analyze/summarize large files
|
||||
* before adding them to the main conversation context.
|
||||
* This keeps the main context clean for conversation while still
|
||||
* allowing the model to understand file contents.
|
||||
*/
|
||||
|
||||
import { ollamaClient } from '$lib/ollama';
|
||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||
import { ANALYSIS_THRESHOLD, MAX_EXTRACTED_CONTENT } from '$lib/types/attachment.js';
|
||||
import { formatFileSize } from '$lib/utils/file-processor.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AnalysisResult {
|
||||
/** Whether to use the original content (file was small enough) */
|
||||
useOriginal: boolean;
|
||||
/** The content to use in the message (original or summary) */
|
||||
content: string;
|
||||
/** Summary generated by the analysis agent (if analyzed) */
|
||||
summary?: string;
|
||||
/** Original content size in characters */
|
||||
originalLength: number;
|
||||
/** Whether the file was analyzed by the sub-agent */
|
||||
analyzed: boolean;
|
||||
/** Error message if analysis failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FileAnalyzerConfig {
|
||||
/** Size thresholds for different file types (in bytes) */
|
||||
thresholds: {
|
||||
text: number;
|
||||
pdf: number;
|
||||
json: number;
|
||||
};
|
||||
/** Timeout for analysis request (ms) */
|
||||
timeout: number;
|
||||
/** Maximum tokens for analysis response */
|
||||
maxResponseTokens: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Configuration
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_CONFIG: FileAnalyzerConfig = {
|
||||
thresholds: {
|
||||
text: 500 * 1024, // 500KB for general text
|
||||
pdf: 1024 * 1024, // 1MB for PDFs
|
||||
json: 300 * 1024, // 300KB for JSON (dense data)
|
||||
},
|
||||
timeout: 10000, // 10 seconds - fail fast, fall back to truncated
|
||||
maxResponseTokens: 256, // Keep summaries very concise for speed
|
||||
};
|
||||
|
||||
/** Maximum content to read from file for analysis (50KB) */
|
||||
const MAX_ANALYSIS_CONTENT = 50 * 1024;
|
||||
|
||||
/** If content is within this % of threshold, skip analysis */
|
||||
const BORDERLINE_THRESHOLD_PERCENT = 0.2; // 20%
|
||||
|
||||
// ============================================================================
|
||||
// Content Reading
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read full content from original file for analysis
|
||||
* Returns up to MAX_ANALYSIS_CONTENT chars
|
||||
*/
|
||||
async function readFullContentForAnalysis(attachment: FileAttachment): Promise<string> {
|
||||
// If we have the original file, read from it
|
||||
if (attachment.originalFile) {
|
||||
try {
|
||||
const text = await attachment.originalFile.text();
|
||||
// Limit to max analysis content
|
||||
if (text.length > MAX_ANALYSIS_CONTENT) {
|
||||
return text.slice(0, MAX_ANALYSIS_CONTENT);
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
console.warn('[FileAnalyzer] Failed to read original file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to stored textContent
|
||||
return attachment.textContent || '';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Cleaning
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Remove base64 blobs and large binary data from JSON content
|
||||
* Replaces them with descriptive placeholders
|
||||
*/
|
||||
function cleanJsonForAnalysis(content: string): { cleaned: string; blobsRemoved: number } {
|
||||
let blobsRemoved = 0;
|
||||
|
||||
// Pattern to match base64 data (common patterns in JSON)
|
||||
// Matches: "data:image/...;base64,..." or long base64 strings (>100 chars of base64 alphabet)
|
||||
const base64DataUrlPattern = /"data:[^"]*;base64,[A-Za-z0-9+/=]+"/g;
|
||||
const longBase64Pattern = /"[A-Za-z0-9+/=]{100,}"/g;
|
||||
|
||||
let cleaned = content;
|
||||
|
||||
// Replace data URLs
|
||||
cleaned = cleaned.replace(base64DataUrlPattern, (match) => {
|
||||
blobsRemoved++;
|
||||
// Extract mime type if possible
|
||||
const mimeMatch = match.match(/data:([^;]+);/);
|
||||
const mime = mimeMatch ? mimeMatch[1] : 'binary';
|
||||
return `"[BLOB: ${mime} data removed]"`;
|
||||
});
|
||||
|
||||
// Replace remaining long base64 strings
|
||||
cleaned = cleaned.replace(longBase64Pattern, () => {
|
||||
blobsRemoved++;
|
||||
return '"[BLOB: large binary data removed]"';
|
||||
});
|
||||
|
||||
return { cleaned, blobsRemoved };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Analysis Prompts
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build an analysis prompt based on file type
|
||||
* @param attachment The file attachment metadata
|
||||
* @param rawContent The full content to analyze (from original file)
|
||||
*/
|
||||
function buildAnalysisPrompt(attachment: FileAttachment, rawContent: string): string {
|
||||
let content = rawContent;
|
||||
const fileTypeHint = getFileTypeHint(attachment);
|
||||
let blobNote = '';
|
||||
|
||||
// For JSON files, remove blobs to reduce size
|
||||
const ext = attachment.filename.split('.').pop()?.toLowerCase();
|
||||
const mime = attachment.mimeType.toLowerCase();
|
||||
if (mime === 'application/json' || ext === 'json') {
|
||||
const { cleaned, blobsRemoved } = cleanJsonForAnalysis(content);
|
||||
content = cleaned;
|
||||
if (blobsRemoved > 0) {
|
||||
blobNote = `\n(Note: ${blobsRemoved} binary blob(s) were removed from the JSON for analysis)`;
|
||||
}
|
||||
}
|
||||
|
||||
return `Summarize this ${fileTypeHint} in 2-3 sentences. Focus on: what it is, key data/content, and structure.${blobNote}
|
||||
|
||||
<file name="${attachment.filename}">
|
||||
${content}
|
||||
</file>
|
||||
|
||||
Summary:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable hint about the file type
|
||||
*/
|
||||
function getFileTypeHint(attachment: FileAttachment): string {
|
||||
const ext = attachment.filename.split('.').pop()?.toLowerCase();
|
||||
const mime = attachment.mimeType.toLowerCase();
|
||||
|
||||
if (mime === 'application/json' || ext === 'json') {
|
||||
return 'JSON data file';
|
||||
}
|
||||
if (mime === 'application/pdf' || ext === 'pdf') {
|
||||
return 'PDF document';
|
||||
}
|
||||
if (ext === 'md' || ext === 'markdown') {
|
||||
return 'Markdown document';
|
||||
}
|
||||
if (['js', 'ts', 'jsx', 'tsx', 'py', 'go', 'rs', 'java', 'c', 'cpp'].includes(ext || '')) {
|
||||
return `${ext?.toUpperCase()} source code file`;
|
||||
}
|
||||
if (['yaml', 'yml', 'toml', 'ini', 'cfg', 'conf'].includes(ext || '')) {
|
||||
return 'configuration file';
|
||||
}
|
||||
if (ext === 'csv') {
|
||||
return 'CSV data file';
|
||||
}
|
||||
if (ext === 'xml') {
|
||||
return 'XML document';
|
||||
}
|
||||
if (ext === 'html' || ext === 'htm') {
|
||||
return 'HTML document';
|
||||
}
|
||||
|
||||
return 'text file';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File Analyzer Class
|
||||
// ============================================================================
|
||||
|
||||
export class FileAnalyzer {
|
||||
private config: FileAnalyzerConfig;
|
||||
|
||||
constructor(config: Partial<FileAnalyzerConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size threshold for a given attachment type
|
||||
*/
|
||||
private getThreshold(attachment: FileAttachment): number {
|
||||
const ext = attachment.filename.split('.').pop()?.toLowerCase();
|
||||
const mime = attachment.mimeType.toLowerCase();
|
||||
|
||||
// JSON files are dense, use lower threshold
|
||||
if (mime === 'application/json' || ext === 'json') {
|
||||
return this.config.thresholds.json;
|
||||
}
|
||||
|
||||
// PDFs can be larger
|
||||
if (mime === 'application/pdf' || ext === 'pdf') {
|
||||
return this.config.thresholds.pdf;
|
||||
}
|
||||
|
||||
// Default text threshold
|
||||
return this.config.thresholds.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be analyzed (based on content size)
|
||||
* Skips analysis for borderline files (within 20% of threshold)
|
||||
*/
|
||||
shouldAnalyze(attachment: FileAttachment): boolean {
|
||||
const contentLength = attachment.textContent?.length || 0;
|
||||
|
||||
// Below threshold - no analysis needed
|
||||
if (contentLength <= ANALYSIS_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if borderline (within 20% of threshold)
|
||||
// These files are small enough to just use directly
|
||||
const borderlineLimit = ANALYSIS_THRESHOLD * (1 + BORDERLINE_THRESHOLD_PERCENT);
|
||||
if (contentLength <= borderlineLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a file attachment if needed
|
||||
* Returns either the original content (for small files) or a summary (for large files)
|
||||
*/
|
||||
async analyzeIfNeeded(
|
||||
attachment: FileAttachment,
|
||||
model: string
|
||||
): Promise<AnalysisResult> {
|
||||
const contentLength = attachment.textContent?.length || 0;
|
||||
|
||||
// Small files or borderline: use original content
|
||||
if (!this.shouldAnalyze(attachment)) {
|
||||
return {
|
||||
useOriginal: true,
|
||||
content: attachment.textContent || '',
|
||||
originalLength: contentLength,
|
||||
analyzed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Large files: spawn analysis agent with timeout
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Race between analysis and timeout
|
||||
const summary = await Promise.race([
|
||||
this.spawnAnalysisAgent(attachment, model),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Analysis timeout')), this.config.timeout)
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
useOriginal: false,
|
||||
content: summary,
|
||||
summary,
|
||||
originalLength: contentLength,
|
||||
analyzed: true,
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const isTimeout = error instanceof Error && error.message === 'Analysis timeout';
|
||||
console.warn(`[FileAnalyzer] Analysis ${isTimeout ? 'TIMEOUT' : 'FAILED'} for ${attachment.filename} after ${elapsed}ms`);
|
||||
|
||||
// Fallback: use truncated content (faster than waiting for slow analysis)
|
||||
const truncated = attachment.textContent?.slice(0, ANALYSIS_THRESHOLD) || '';
|
||||
const reason = isTimeout ? 'timed out' : 'failed';
|
||||
return {
|
||||
useOriginal: false,
|
||||
content: truncated + `\n\n[Analysis ${reason} - showing first ${formatFileSize(ANALYSIS_THRESHOLD)} of ${formatFileSize(contentLength)}]`,
|
||||
originalLength: contentLength,
|
||||
analyzed: false,
|
||||
error: error instanceof Error ? error.message : 'Analysis failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a separate Ollama request to analyze the file
|
||||
*/
|
||||
private async spawnAnalysisAgent(
|
||||
attachment: FileAttachment,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
// Read full content from original file if available
|
||||
const fullContent = await readFullContentForAnalysis(attachment);
|
||||
const prompt = buildAnalysisPrompt(attachment, fullContent);
|
||||
|
||||
// Use generate for a simple completion
|
||||
const response = await ollamaClient.generate({
|
||||
model,
|
||||
prompt,
|
||||
options: {
|
||||
temperature: 0.3, // Lower temperature for consistent summaries
|
||||
num_predict: this.config.maxResponseTokens,
|
||||
}
|
||||
});
|
||||
|
||||
return response.response.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Singleton Instance
|
||||
// ============================================================================
|
||||
|
||||
export const fileAnalyzer = new FileAnalyzer();
|
||||
|
||||
// ============================================================================
|
||||
// Batch Analysis Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Analyze multiple files with concurrency limit
|
||||
* @param files Files to analyze
|
||||
* @param model Model to use
|
||||
* @param maxConcurrent Maximum parallel analyses (default 2)
|
||||
*/
|
||||
export async function analyzeFilesInBatches(
|
||||
files: FileAttachment[],
|
||||
model: string,
|
||||
maxConcurrent: number = 2
|
||||
): Promise<Map<string, AnalysisResult>> {
|
||||
const results = new Map<string, AnalysisResult>();
|
||||
|
||||
// Process in batches of maxConcurrent
|
||||
for (let i = 0; i < files.length; i += maxConcurrent) {
|
||||
const batch = files.slice(i, i + maxConcurrent);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(file => fileAnalyzer.analyzeIfNeeded(file, model))
|
||||
);
|
||||
|
||||
batch.forEach((file, idx) => {
|
||||
results.set(file.id, batchResults[idx]);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format an analyzed attachment for inclusion in a message
|
||||
*/
|
||||
export function formatAnalyzedAttachment(
|
||||
attachment: FileAttachment,
|
||||
result: AnalysisResult
|
||||
): string {
|
||||
if (result.analyzed && result.summary) {
|
||||
return `<file name="${escapeXmlAttr(attachment.filename)}" size="${formatFileSize(attachment.size)}" analyzed="true">
|
||||
## Summary (original: ${formatFileSize(result.originalLength)} chars)
|
||||
${result.summary}
|
||||
</file>`;
|
||||
}
|
||||
|
||||
// Not analyzed, use content directly
|
||||
return `<file name="${escapeXmlAttr(attachment.filename)}" size="${formatFileSize(attachment.size)}">
|
||||
${result.content}
|
||||
</file>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters for XML attribute values
|
||||
*/
|
||||
function escapeXmlAttr(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
@@ -4,18 +4,15 @@
|
||||
*/
|
||||
|
||||
import { db, withErrorHandling, generateId } from './db.js';
|
||||
import type { StoredAttachment, StorageResult } from './db.js';
|
||||
import type { StoredAttachment, AttachmentMeta, StorageResult } from './db.js';
|
||||
import type { FileAttachment, AttachmentType } from '$lib/types/attachment.js';
|
||||
|
||||
/**
|
||||
* Attachment metadata without the binary data
|
||||
*/
|
||||
export interface AttachmentMeta {
|
||||
id: string;
|
||||
messageId: string;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
// Re-export AttachmentMeta for convenience
|
||||
export type { AttachmentMeta };
|
||||
|
||||
// ============================================================================
|
||||
// Query Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all attachments for a message
|
||||
@@ -29,20 +26,14 @@ export async function getAttachmentsForMessage(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment metadata (without data) for a message
|
||||
* Get attachment metadata (without binary data) for a message
|
||||
*/
|
||||
export async function getAttachmentMetaForMessage(
|
||||
messageId: string
|
||||
): Promise<StorageResult<AttachmentMeta[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const attachments = await db.attachments.where('messageId').equals(messageId).toArray();
|
||||
return attachments.map((a) => ({
|
||||
id: a.id,
|
||||
messageId: a.messageId,
|
||||
mimeType: a.mimeType,
|
||||
filename: a.filename,
|
||||
size: a.data.size
|
||||
}));
|
||||
return attachments.map(toAttachmentMeta);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,25 +47,92 @@ export async function getAttachment(id: string): Promise<StorageResult<StoredAtt
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an attachment to a message
|
||||
* Get multiple attachments by IDs
|
||||
*/
|
||||
export async function addAttachment(
|
||||
messageId: string,
|
||||
file: File
|
||||
): Promise<StorageResult<StoredAttachment>> {
|
||||
export async function getAttachmentsByIds(ids: string[]): Promise<StorageResult<StoredAttachment[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const id = generateId();
|
||||
const attachments = await db.attachments.where('id').anyOf(ids).toArray();
|
||||
// Maintain order of input IDs
|
||||
const attachmentMap = new Map(attachments.map(a => [a.id, a]));
|
||||
return ids.map(id => attachmentMap.get(id)).filter((a): a is StoredAttachment => a !== undefined);
|
||||
});
|
||||
}
|
||||
|
||||
const attachment: StoredAttachment = {
|
||||
id,
|
||||
/**
|
||||
* Get metadata for multiple attachments by IDs
|
||||
*/
|
||||
export async function getAttachmentMetaByIds(ids: string[]): Promise<StorageResult<AttachmentMeta[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const attachments = await db.attachments.where('id').anyOf(ids).toArray();
|
||||
const attachmentMap = new Map(attachments.map(a => [a.id, a]));
|
||||
return ids
|
||||
.map(id => attachmentMap.get(id))
|
||||
.filter((a): a is StoredAttachment => a !== undefined)
|
||||
.map(toAttachmentMeta);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Create Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Save a FileAttachment to IndexedDB with the original file data
|
||||
* Returns the attachment ID for linking to the message
|
||||
*/
|
||||
export async function saveAttachment(
|
||||
messageId: string,
|
||||
file: File,
|
||||
attachment: FileAttachment
|
||||
): Promise<StorageResult<string>> {
|
||||
return withErrorHandling(async () => {
|
||||
const stored: StoredAttachment = {
|
||||
id: attachment.id,
|
||||
messageId,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
mimeType: attachment.mimeType,
|
||||
data: file,
|
||||
filename: file.name
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
type: attachment.type,
|
||||
createdAt: Date.now(),
|
||||
textContent: attachment.textContent,
|
||||
truncated: attachment.truncated,
|
||||
analyzed: attachment.analyzed,
|
||||
summary: attachment.summary,
|
||||
};
|
||||
|
||||
await db.attachments.add(attachment);
|
||||
return attachment;
|
||||
await db.attachments.add(stored);
|
||||
return attachment.id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save multiple attachments at once
|
||||
* Returns array of attachment IDs
|
||||
*/
|
||||
export async function saveAttachments(
|
||||
messageId: string,
|
||||
files: File[],
|
||||
attachments: FileAttachment[]
|
||||
): Promise<StorageResult<string[]>> {
|
||||
return withErrorHandling(async () => {
|
||||
const storedAttachments: StoredAttachment[] = attachments.map((attachment, index) => ({
|
||||
id: attachment.id,
|
||||
messageId,
|
||||
mimeType: attachment.mimeType,
|
||||
data: files[index],
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
type: attachment.type,
|
||||
createdAt: Date.now(),
|
||||
textContent: attachment.textContent,
|
||||
truncated: attachment.truncated,
|
||||
analyzed: attachment.analyzed,
|
||||
summary: attachment.summary,
|
||||
}));
|
||||
|
||||
await db.attachments.bulkAdd(storedAttachments);
|
||||
return attachments.map(a => a.id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,7 +143,14 @@ export async function addAttachmentFromBlob(
|
||||
messageId: string,
|
||||
data: Blob,
|
||||
filename: string,
|
||||
mimeType?: string
|
||||
type: AttachmentType,
|
||||
options?: {
|
||||
mimeType?: string;
|
||||
textContent?: string;
|
||||
truncated?: boolean;
|
||||
analyzed?: boolean;
|
||||
summary?: string;
|
||||
}
|
||||
): Promise<StorageResult<StoredAttachment>> {
|
||||
return withErrorHandling(async () => {
|
||||
const id = generateId();
|
||||
@@ -93,9 +158,16 @@ export async function addAttachmentFromBlob(
|
||||
const attachment: StoredAttachment = {
|
||||
id,
|
||||
messageId,
|
||||
mimeType: mimeType ?? data.type ?? 'application/octet-stream',
|
||||
mimeType: options?.mimeType ?? data.type ?? 'application/octet-stream',
|
||||
data,
|
||||
filename
|
||||
filename,
|
||||
size: data.size,
|
||||
type,
|
||||
createdAt: Date.now(),
|
||||
textContent: options?.textContent,
|
||||
truncated: options?.truncated,
|
||||
analyzed: options?.analyzed,
|
||||
summary: options?.summary,
|
||||
};
|
||||
|
||||
await db.attachments.add(attachment);
|
||||
@@ -103,6 +175,27 @@ export async function addAttachmentFromBlob(
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Update Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Update attachment with analysis results
|
||||
*/
|
||||
export async function updateAttachmentAnalysis(
|
||||
id: string,
|
||||
analyzed: boolean,
|
||||
summary?: string
|
||||
): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
await db.attachments.update(id, { analyzed, summary });
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Delete Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Delete an attachment by ID
|
||||
*/
|
||||
@@ -121,6 +214,19 @@ export async function deleteAttachmentsForMessage(messageId: string): Promise<St
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple attachments by IDs
|
||||
*/
|
||||
export async function deleteAttachmentsByIds(ids: string[]): Promise<StorageResult<void>> {
|
||||
return withErrorHandling(async () => {
|
||||
await db.attachments.where('id').anyOf(ids).delete();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Conversion
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the data URL for an attachment (for displaying images)
|
||||
*/
|
||||
@@ -140,6 +246,66 @@ export async function getAttachmentDataUrl(id: string): Promise<StorageResult<st
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base64 data for an image attachment (without data: prefix, for Ollama)
|
||||
*/
|
||||
export async function getAttachmentBase64(id: string): Promise<StorageResult<string | null>> {
|
||||
return withErrorHandling(async () => {
|
||||
const attachment = await db.attachments.get(id);
|
||||
if (!attachment || !attachment.mimeType.startsWith('image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
// Remove the data:image/xxx;base64, prefix
|
||||
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read attachment data'));
|
||||
reader.readAsDataURL(attachment.data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content from an attachment (reads from cache or blob)
|
||||
*/
|
||||
export async function getAttachmentTextContent(id: string): Promise<StorageResult<string | null>> {
|
||||
return withErrorHandling(async () => {
|
||||
const attachment = await db.attachments.get(id);
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return cached text content if available
|
||||
if (attachment.textContent) {
|
||||
return attachment.textContent;
|
||||
}
|
||||
|
||||
// For text files, read from blob
|
||||
if (attachment.type === 'text' || attachment.mimeType.startsWith('text/')) {
|
||||
return await attachment.data.text();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a download URL for an attachment
|
||||
* Remember to call URL.revokeObjectURL() when done
|
||||
*/
|
||||
export function createDownloadUrl(attachment: StoredAttachment): string {
|
||||
return URL.createObjectURL(attachment.data);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Statistics
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get total storage size used by attachments
|
||||
*/
|
||||
@@ -171,3 +337,33 @@ export async function getConversationAttachmentSize(
|
||||
return attachments.reduce((total, a) => total + a.data.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment count for a message
|
||||
*/
|
||||
export async function getAttachmentCountForMessage(messageId: string): Promise<StorageResult<number>> {
|
||||
return withErrorHandling(async () => {
|
||||
return await db.attachments.where('messageId').equals(messageId).count();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert StoredAttachment to AttachmentMeta (strips binary data)
|
||||
*/
|
||||
function toAttachmentMeta(attachment: StoredAttachment): AttachmentMeta {
|
||||
return {
|
||||
id: attachment.id,
|
||||
messageId: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
type: attachment.type,
|
||||
createdAt: attachment.createdAt,
|
||||
truncated: attachment.truncated,
|
||||
analyzed: attachment.analyzed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface StoredMessage {
|
||||
siblingIndex: number;
|
||||
createdAt: number;
|
||||
syncVersion?: number;
|
||||
/** References to attachments stored in the attachments table */
|
||||
attachmentIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +87,36 @@ export interface StoredAttachment {
|
||||
mimeType: string;
|
||||
data: Blob;
|
||||
filename: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Attachment type category */
|
||||
type: 'image' | 'text' | 'pdf' | 'audio' | 'video' | 'binary';
|
||||
/** Timestamp when attachment was created */
|
||||
createdAt: number;
|
||||
/** Cached extracted text (for text/PDF files) */
|
||||
textContent?: string;
|
||||
/** Whether the text content was truncated */
|
||||
truncated?: boolean;
|
||||
/** Whether this attachment was analyzed by the file analyzer */
|
||||
analyzed?: boolean;
|
||||
/** Summary from file analyzer (if analyzed) */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment metadata (without the binary data)
|
||||
* Used for displaying attachment info without loading the full blob
|
||||
*/
|
||||
export interface AttachmentMeta {
|
||||
id: string;
|
||||
messageId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
type: 'image' | 'text' | 'pdf' | 'audio' | 'video' | 'binary';
|
||||
createdAt: number;
|
||||
truncated?: boolean;
|
||||
analyzed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,16 +48,31 @@ export type { MessageSearchResult } from './messages.js';
|
||||
|
||||
// Attachment operations
|
||||
export {
|
||||
// Query
|
||||
getAttachmentsForMessage,
|
||||
getAttachmentMetaForMessage,
|
||||
getAttachment,
|
||||
addAttachment,
|
||||
getAttachmentsByIds,
|
||||
getAttachmentMetaByIds,
|
||||
// Create
|
||||
saveAttachment,
|
||||
saveAttachments,
|
||||
addAttachmentFromBlob,
|
||||
// Update
|
||||
updateAttachmentAnalysis,
|
||||
// Delete
|
||||
deleteAttachment,
|
||||
deleteAttachmentsForMessage,
|
||||
deleteAttachmentsByIds,
|
||||
// Data conversion
|
||||
getAttachmentDataUrl,
|
||||
getAttachmentBase64,
|
||||
getAttachmentTextContent,
|
||||
createDownloadUrl,
|
||||
// Statistics
|
||||
getTotalAttachmentSize,
|
||||
getConversationAttachmentSize
|
||||
getConversationAttachmentSize,
|
||||
getAttachmentCountForMessage
|
||||
} from './attachments.js';
|
||||
export type { AttachmentMeta } from './attachments.js';
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ function toMessageNode(stored: StoredMessage, childIds: string[]): MessageNode {
|
||||
role: stored.role,
|
||||
content: stored.content,
|
||||
images: stored.images,
|
||||
toolCalls: stored.toolCalls
|
||||
toolCalls: stored.toolCalls,
|
||||
attachmentIds: stored.attachmentIds
|
||||
},
|
||||
parentId: stored.parentId,
|
||||
childIds,
|
||||
@@ -131,6 +132,7 @@ export async function addMessage(
|
||||
content: message.content,
|
||||
images: message.images,
|
||||
toolCalls: message.toolCalls,
|
||||
attachmentIds: message.attachmentIds,
|
||||
siblingIndex,
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
@@ -149,6 +149,28 @@ export class ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content of the currently streaming message (replaces entirely)
|
||||
* @param content The new content
|
||||
*/
|
||||
setStreamContent(content: string): void {
|
||||
if (!this.streamingMessageId) return;
|
||||
|
||||
this.streamBuffer = content;
|
||||
|
||||
const node = this.messageTree.get(this.streamingMessageId);
|
||||
if (node) {
|
||||
const updatedNode: MessageNode = {
|
||||
...node,
|
||||
message: {
|
||||
...node.message,
|
||||
content
|
||||
}
|
||||
};
|
||||
this.messageTree = new Map(this.messageTree).set(this.streamingMessageId, updatedNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the streaming process
|
||||
*/
|
||||
@@ -510,6 +532,41 @@ export class ChatState {
|
||||
this.streamBuffer = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message from the tree
|
||||
* Used for temporary messages (like analysis progress indicators)
|
||||
* @param messageId The message ID to remove
|
||||
*/
|
||||
removeMessage(messageId: string): void {
|
||||
const node = this.messageTree.get(messageId);
|
||||
if (!node) return;
|
||||
|
||||
const newTree = new Map(this.messageTree);
|
||||
|
||||
// Remove from parent's childIds
|
||||
if (node.parentId) {
|
||||
const parent = newTree.get(node.parentId);
|
||||
if (parent) {
|
||||
newTree.set(node.parentId, {
|
||||
...parent,
|
||||
childIds: parent.childIds.filter((id) => id !== messageId)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update root if this was the root
|
||||
if (this.rootMessageId === messageId) {
|
||||
this.rootMessageId = null;
|
||||
}
|
||||
|
||||
// Remove the node
|
||||
newTree.delete(messageId);
|
||||
this.messageTree = newTree;
|
||||
|
||||
// Remove from active path
|
||||
this.activePath = this.activePath.filter((id) => id !== messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a conversation into the chat state
|
||||
* @param conversationId The conversation ID
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface ToolTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'api' | 'data' | 'utility' | 'integration';
|
||||
category: 'api' | 'data' | 'utility' | 'integration' | 'agentic';
|
||||
language: ToolImplementation;
|
||||
code: string;
|
||||
parameters: JSONSchema;
|
||||
@@ -166,6 +166,184 @@ return {
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'js-design-brief',
|
||||
name: 'Design Brief Generator',
|
||||
description: 'Generate structured design briefs from project requirements',
|
||||
category: 'utility',
|
||||
language: 'javascript',
|
||||
code: `// Generate a structured design brief from requirements
|
||||
const projectType = args.project_type || 'website';
|
||||
const style = args.style_preferences || 'modern, clean';
|
||||
const features = args.key_features || '';
|
||||
const audience = args.target_audience || 'general users';
|
||||
const brand = args.brand_keywords || '';
|
||||
|
||||
const brief = {
|
||||
project_type: projectType,
|
||||
design_direction: {
|
||||
style: style,
|
||||
mood: style.includes('playful') ? 'energetic and fun' :
|
||||
style.includes('corporate') ? 'professional and trustworthy' :
|
||||
style.includes('minimal') ? 'clean and focused' :
|
||||
'balanced and approachable',
|
||||
inspiration_keywords: [
|
||||
...style.split(',').map(s => s.trim()),
|
||||
projectType,
|
||||
...(brand ? brand.split(',').map(s => s.trim()) : [])
|
||||
].filter(Boolean)
|
||||
},
|
||||
target_audience: audience,
|
||||
key_sections: features ? features.split(',').map(f => f.trim()) : [
|
||||
'Hero section with clear value proposition',
|
||||
'Features/Benefits overview',
|
||||
'Social proof or testimonials',
|
||||
'Call to action'
|
||||
],
|
||||
ui_recommendations: {
|
||||
typography: style.includes('modern') ? 'Sans-serif (Inter, Geist, or similar)' :
|
||||
style.includes('elegant') ? 'Serif accents with sans-serif body' :
|
||||
'Clean sans-serif for readability',
|
||||
color_approach: style.includes('minimal') ? 'Monochromatic with single accent' :
|
||||
style.includes('bold') ? 'High contrast with vibrant accents' :
|
||||
'Balanced palette with primary and secondary colors',
|
||||
spacing: 'Generous whitespace for visual breathing room',
|
||||
imagery: style.includes('corporate') ? 'Professional photography or abstract graphics' :
|
||||
style.includes('playful') ? 'Illustrations or playful iconography' :
|
||||
'High-quality, contextual imagery'
|
||||
},
|
||||
accessibility_notes: [
|
||||
'Ensure 4.5:1 contrast ratio for text',
|
||||
'Include focus states for keyboard navigation',
|
||||
'Use semantic HTML structure',
|
||||
'Provide alt text for all images'
|
||||
]
|
||||
};
|
||||
|
||||
return brief;`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_type: {
|
||||
type: 'string',
|
||||
description: 'Type of project (landing page, dashboard, mobile app, e-commerce, portfolio, etc.)'
|
||||
},
|
||||
style_preferences: {
|
||||
type: 'string',
|
||||
description: 'Preferred style keywords (modern, minimal, playful, corporate, elegant, bold, etc.)'
|
||||
},
|
||||
key_features: {
|
||||
type: 'string',
|
||||
description: 'Comma-separated list of main features or sections needed'
|
||||
},
|
||||
target_audience: {
|
||||
type: 'string',
|
||||
description: 'Description of target users (developers, enterprise, consumers, etc.)'
|
||||
},
|
||||
brand_keywords: {
|
||||
type: 'string',
|
||||
description: 'Keywords that describe the brand personality'
|
||||
}
|
||||
},
|
||||
required: ['project_type']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-color-palette',
|
||||
name: 'Color Palette Generator',
|
||||
description: 'Generate harmonious color palettes from a base color',
|
||||
category: 'utility',
|
||||
language: 'javascript',
|
||||
code: `// Generate color palette from base color
|
||||
const hexToHsl = (hex) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||
};
|
||||
|
||||
const hslToHex = (h, s, l) => {
|
||||
s /= 100; l /= 100;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = n => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
};
|
||||
return '#' + f(0) + f(8) + f(4);
|
||||
};
|
||||
|
||||
const baseColor = args.base_color || '#3b82f6';
|
||||
const harmony = args.harmony || 'complementary';
|
||||
|
||||
const base = hexToHsl(baseColor);
|
||||
const colors = { primary: baseColor };
|
||||
|
||||
switch (harmony) {
|
||||
case 'complementary':
|
||||
colors.secondary = hslToHex((base.h + 180) % 360, base.s, base.l);
|
||||
colors.accent = hslToHex((base.h + 30) % 360, base.s, base.l);
|
||||
break;
|
||||
case 'analogous':
|
||||
colors.secondary = hslToHex((base.h + 30) % 360, base.s, base.l);
|
||||
colors.accent = hslToHex((base.h - 30 + 360) % 360, base.s, base.l);
|
||||
break;
|
||||
case 'triadic':
|
||||
colors.secondary = hslToHex((base.h + 120) % 360, base.s, base.l);
|
||||
colors.accent = hslToHex((base.h + 240) % 360, base.s, base.l);
|
||||
break;
|
||||
case 'split-complementary':
|
||||
colors.secondary = hslToHex((base.h + 150) % 360, base.s, base.l);
|
||||
colors.accent = hslToHex((base.h + 210) % 360, base.s, base.l);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add neutrals
|
||||
colors.background = hslToHex(base.h, 10, 98);
|
||||
colors.surface = hslToHex(base.h, 10, 95);
|
||||
colors.text = hslToHex(base.h, 10, 15);
|
||||
colors.muted = hslToHex(base.h, 10, 45);
|
||||
|
||||
// Add primary shades
|
||||
colors.primary_light = hslToHex(base.h, base.s, Math.min(base.l + 20, 95));
|
||||
colors.primary_dark = hslToHex(base.h, base.s, Math.max(base.l - 20, 15));
|
||||
|
||||
return {
|
||||
harmony,
|
||||
palette: colors,
|
||||
css_variables: Object.entries(colors).map(([k, v]) => \`--color-\${k.replace('_', '-')}: \${v};\`).join('\\n')
|
||||
};`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
base_color: {
|
||||
type: 'string',
|
||||
description: 'Base color in hex format (e.g., #3b82f6)'
|
||||
},
|
||||
harmony: {
|
||||
type: 'string',
|
||||
description: 'Color harmony: complementary, analogous, triadic, split-complementary'
|
||||
}
|
||||
},
|
||||
required: ['base_color']
|
||||
}
|
||||
},
|
||||
|
||||
// Python Templates
|
||||
{
|
||||
id: 'py-api-fetch',
|
||||
@@ -336,6 +514,531 @@ print(json.dumps(result))`,
|
||||
},
|
||||
required: ['text', 'operation']
|
||||
}
|
||||
},
|
||||
|
||||
// Agentic Templates
|
||||
{
|
||||
id: 'js-task-manager',
|
||||
name: 'Task Manager',
|
||||
description: 'Create, update, list, and complete tasks with persistent storage',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Task Manager with localStorage persistence
|
||||
const STORAGE_KEY = 'vessel_agent_tasks';
|
||||
|
||||
const loadTasks = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const saveTasks = (tasks) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
|
||||
};
|
||||
|
||||
const action = args.action;
|
||||
let tasks = loadTasks();
|
||||
|
||||
switch (action) {
|
||||
case 'create': {
|
||||
const task = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
title: args.title,
|
||||
description: args.description || '',
|
||||
priority: args.priority || 'medium',
|
||||
status: 'pending',
|
||||
created: new Date().toISOString(),
|
||||
due: args.due || null,
|
||||
tags: args.tags || []
|
||||
};
|
||||
tasks.push(task);
|
||||
saveTasks(tasks);
|
||||
return { success: true, task, message: 'Task created' };
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
let filtered = tasks;
|
||||
if (args.status) filtered = filtered.filter(t => t.status === args.status);
|
||||
if (args.priority) filtered = filtered.filter(t => t.priority === args.priority);
|
||||
if (args.tag) filtered = filtered.filter(t => t.tags?.includes(args.tag));
|
||||
return {
|
||||
tasks: filtered,
|
||||
total: tasks.length,
|
||||
pending: tasks.filter(t => t.status === 'pending').length,
|
||||
completed: tasks.filter(t => t.status === 'completed').length
|
||||
};
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const idx = tasks.findIndex(t => t.id === args.id);
|
||||
if (idx === -1) return { error: 'Task not found' };
|
||||
if (args.title) tasks[idx].title = args.title;
|
||||
if (args.description !== undefined) tasks[idx].description = args.description;
|
||||
if (args.priority) tasks[idx].priority = args.priority;
|
||||
if (args.status) tasks[idx].status = args.status;
|
||||
if (args.due !== undefined) tasks[idx].due = args.due;
|
||||
if (args.tags) tasks[idx].tags = args.tags;
|
||||
tasks[idx].updated = new Date().toISOString();
|
||||
saveTasks(tasks);
|
||||
return { success: true, task: tasks[idx], message: 'Task updated' };
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
const idx = tasks.findIndex(t => t.id === args.id);
|
||||
if (idx === -1) return { error: 'Task not found' };
|
||||
tasks[idx].status = 'completed';
|
||||
tasks[idx].completedAt = new Date().toISOString();
|
||||
saveTasks(tasks);
|
||||
return { success: true, task: tasks[idx], message: 'Task completed' };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const idx = tasks.findIndex(t => t.id === args.id);
|
||||
if (idx === -1) return { error: 'Task not found' };
|
||||
const deleted = tasks.splice(idx, 1)[0];
|
||||
saveTasks(tasks);
|
||||
return { success: true, deleted, message: 'Task deleted' };
|
||||
}
|
||||
|
||||
case 'clear_completed': {
|
||||
const before = tasks.length;
|
||||
tasks = tasks.filter(t => t.status !== 'completed');
|
||||
saveTasks(tasks);
|
||||
return { success: true, removed: before - tasks.length, remaining: tasks.length };
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: 'Unknown action. Use: create, list, update, complete, delete, clear_completed' };
|
||||
}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action: create, list, update, complete, delete, clear_completed'
|
||||
},
|
||||
id: { type: 'string', description: 'Task ID (for update/complete/delete)' },
|
||||
title: { type: 'string', description: 'Task title (for create/update)' },
|
||||
description: { type: 'string', description: 'Task description' },
|
||||
priority: { type: 'string', description: 'Priority: low, medium, high, urgent' },
|
||||
status: { type: 'string', description: 'Filter/set status: pending, in_progress, completed' },
|
||||
due: { type: 'string', description: 'Due date (ISO format)' },
|
||||
tags: { type: 'array', description: 'Tags for categorization' },
|
||||
tag: { type: 'string', description: 'Filter by tag (for list)' }
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-memory-store',
|
||||
name: 'Memory Store',
|
||||
description: 'Store and recall information across conversation turns',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Memory Store - persistent key-value storage for agent context
|
||||
const STORAGE_KEY = 'vessel_agent_memory';
|
||||
|
||||
const loadMemory = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const saveMemory = (mem) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mem));
|
||||
};
|
||||
|
||||
const action = args.action;
|
||||
let memory = loadMemory();
|
||||
|
||||
switch (action) {
|
||||
case 'store': {
|
||||
const key = args.key;
|
||||
const value = args.value;
|
||||
const category = args.category || 'general';
|
||||
|
||||
if (!memory[category]) memory[category] = {};
|
||||
memory[category][key] = {
|
||||
value,
|
||||
stored: new Date().toISOString(),
|
||||
accessCount: 0
|
||||
};
|
||||
saveMemory(memory);
|
||||
return { success: true, key, category, message: 'Memory stored' };
|
||||
}
|
||||
|
||||
case 'recall': {
|
||||
const key = args.key;
|
||||
const category = args.category;
|
||||
|
||||
if (category && key) {
|
||||
const item = memory[category]?.[key];
|
||||
if (!item) return { found: false, key, category };
|
||||
item.accessCount++;
|
||||
item.lastAccess = new Date().toISOString();
|
||||
saveMemory(memory);
|
||||
return { found: true, key, category, value: item.value, stored: item.stored };
|
||||
}
|
||||
|
||||
if (category) {
|
||||
return { category, items: memory[category] || {} };
|
||||
}
|
||||
|
||||
if (key) {
|
||||
// Search across all categories
|
||||
for (const cat in memory) {
|
||||
if (memory[cat][key]) {
|
||||
memory[cat][key].accessCount++;
|
||||
saveMemory(memory);
|
||||
return { found: true, key, category: cat, value: memory[cat][key].value };
|
||||
}
|
||||
}
|
||||
return { found: false, key };
|
||||
}
|
||||
|
||||
return { error: 'Provide key and/or category' };
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const category = args.category;
|
||||
if (category) {
|
||||
return {
|
||||
category,
|
||||
keys: Object.keys(memory[category] || {}),
|
||||
count: Object.keys(memory[category] || {}).length
|
||||
};
|
||||
}
|
||||
const summary = {};
|
||||
for (const cat in memory) {
|
||||
summary[cat] = Object.keys(memory[cat]).length;
|
||||
}
|
||||
return { categories: summary, totalCategories: Object.keys(memory).length };
|
||||
}
|
||||
|
||||
case 'forget': {
|
||||
const key = args.key;
|
||||
const category = args.category;
|
||||
|
||||
if (category && key) {
|
||||
if (memory[category]?.[key]) {
|
||||
delete memory[category][key];
|
||||
if (Object.keys(memory[category]).length === 0) delete memory[category];
|
||||
saveMemory(memory);
|
||||
return { success: true, forgotten: key, category };
|
||||
}
|
||||
return { error: 'Memory not found' };
|
||||
}
|
||||
|
||||
if (category) {
|
||||
delete memory[category];
|
||||
saveMemory(memory);
|
||||
return { success: true, forgotten: category, type: 'category' };
|
||||
}
|
||||
|
||||
return { error: 'Provide key and/or category to forget' };
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
const before = Object.keys(memory).length;
|
||||
memory = {};
|
||||
saveMemory(memory);
|
||||
return { success: true, cleared: before, message: 'All memory cleared' };
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: 'Unknown action. Use: store, recall, list, forget, clear' };
|
||||
}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action: store, recall, list, forget, clear'
|
||||
},
|
||||
key: { type: 'string', description: 'Memory key/identifier' },
|
||||
value: { type: 'string', description: 'Value to store (for store action)' },
|
||||
category: { type: 'string', description: 'Category for organizing memories (facts, preferences, context, etc.)' }
|
||||
},
|
||||
required: ['action']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-think-step-by-step',
|
||||
name: 'Structured Thinking',
|
||||
description: 'Break down problems into explicit reasoning steps',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Structured Thinking - explicit step-by-step reasoning
|
||||
const problem = args.problem;
|
||||
const steps = args.steps || [];
|
||||
const conclusion = args.conclusion;
|
||||
const confidence = args.confidence || 'medium';
|
||||
|
||||
const analysis = {
|
||||
problem: problem,
|
||||
reasoning: {
|
||||
steps: steps.map((step, i) => ({
|
||||
step: i + 1,
|
||||
thought: step,
|
||||
type: step.toLowerCase().includes('assume') ? 'assumption' :
|
||||
step.toLowerCase().includes('if') ? 'conditional' :
|
||||
step.toLowerCase().includes('because') ? 'justification' :
|
||||
step.toLowerCase().includes('therefore') ? 'inference' :
|
||||
'observation'
|
||||
})),
|
||||
stepCount: steps.length
|
||||
},
|
||||
conclusion: conclusion,
|
||||
confidence: confidence,
|
||||
confidenceScore: confidence === 'high' ? 0.9 :
|
||||
confidence === 'medium' ? 0.7 :
|
||||
confidence === 'low' ? 0.4 : 0.5,
|
||||
metadata: {
|
||||
hasAssumptions: steps.some(s => s.toLowerCase().includes('assume')),
|
||||
hasConditionals: steps.some(s => s.toLowerCase().includes('if')),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
// Add quality indicators
|
||||
analysis.quality = {
|
||||
hasMultipleSteps: steps.length >= 3,
|
||||
hasConclusion: !!conclusion,
|
||||
isWellStructured: steps.length >= 2 && !!conclusion,
|
||||
suggestions: []
|
||||
};
|
||||
|
||||
if (steps.length < 2) {
|
||||
analysis.quality.suggestions.push('Consider breaking down into more steps');
|
||||
}
|
||||
if (!conclusion) {
|
||||
analysis.quality.suggestions.push('Add a clear conclusion');
|
||||
}
|
||||
if (confidence === 'low') {
|
||||
analysis.quality.suggestions.push('Identify what additional information would increase confidence');
|
||||
}
|
||||
|
||||
return analysis;`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
problem: {
|
||||
type: 'string',
|
||||
description: 'The problem or question to reason about'
|
||||
},
|
||||
steps: {
|
||||
type: 'array',
|
||||
description: 'Array of reasoning steps, each a string explaining one step of thought'
|
||||
},
|
||||
conclusion: {
|
||||
type: 'string',
|
||||
description: 'The final conclusion reached'
|
||||
},
|
||||
confidence: {
|
||||
type: 'string',
|
||||
description: 'Confidence level: low, medium, high'
|
||||
}
|
||||
},
|
||||
required: ['problem', 'steps']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-decision-matrix',
|
||||
name: 'Decision Matrix',
|
||||
description: 'Evaluate options against weighted criteria for better decisions',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Decision Matrix - weighted multi-criteria decision analysis
|
||||
const options = args.options || [];
|
||||
const criteria = args.criteria || [];
|
||||
const scores = args.scores || {};
|
||||
|
||||
if (options.length === 0) {
|
||||
return { error: 'Provide at least one option' };
|
||||
}
|
||||
if (criteria.length === 0) {
|
||||
return { error: 'Provide at least one criterion with name and weight' };
|
||||
}
|
||||
|
||||
// Normalize weights
|
||||
const totalWeight = criteria.reduce((sum, c) => sum + (c.weight || 1), 0);
|
||||
const normalizedCriteria = criteria.map(c => ({
|
||||
name: c.name,
|
||||
weight: (c.weight || 1) / totalWeight,
|
||||
originalWeight: c.weight || 1
|
||||
}));
|
||||
|
||||
// Calculate weighted scores for each option
|
||||
const results = options.map(option => {
|
||||
let totalScore = 0;
|
||||
const breakdown = [];
|
||||
|
||||
for (const criterion of normalizedCriteria) {
|
||||
const score = scores[option]?.[criterion.name] ?? 5; // Default to 5/10
|
||||
const weighted = score * criterion.weight;
|
||||
totalScore += weighted;
|
||||
breakdown.push({
|
||||
criterion: criterion.name,
|
||||
rawScore: score,
|
||||
weight: Math.round(criterion.weight * 100) + '%',
|
||||
weightedScore: Math.round(weighted * 100) / 100
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
option,
|
||||
totalScore: Math.round(totalScore * 100) / 100,
|
||||
maxPossible: 10,
|
||||
percentage: Math.round(totalScore * 10) + '%',
|
||||
breakdown
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score
|
||||
results.sort((a, b) => b.totalScore - a.totalScore);
|
||||
|
||||
// Identify winner and insights
|
||||
const winner = results[0];
|
||||
const runnerUp = results[1];
|
||||
const margin = runnerUp ? Math.round((winner.totalScore - runnerUp.totalScore) * 100) / 100 : null;
|
||||
|
||||
return {
|
||||
recommendation: winner.option,
|
||||
confidence: margin > 1.5 ? 'high' : margin > 0.5 ? 'medium' : 'low',
|
||||
margin: margin,
|
||||
rankings: results,
|
||||
criteria: normalizedCriteria.map(c => ({
|
||||
name: c.name,
|
||||
weight: Math.round(c.weight * 100) + '%'
|
||||
})),
|
||||
insight: margin && margin < 0.5 ?
|
||||
'Options are very close - consider additional criteria or qualitative factors' :
|
||||
margin && margin > 2 ?
|
||||
\`\${winner.option} is a clear winner with significant margin\` :
|
||||
'Decision is reasonably clear but review the breakdown for nuance'
|
||||
};`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Array of option names to evaluate (e.g., ["Option A", "Option B"])'
|
||||
},
|
||||
criteria: {
|
||||
type: 'array',
|
||||
description: 'Array of criteria objects with name and weight (e.g., [{"name": "Cost", "weight": 3}, {"name": "Quality", "weight": 2}])'
|
||||
},
|
||||
scores: {
|
||||
type: 'object',
|
||||
description: 'Scores object: { "Option A": { "Cost": 8, "Quality": 7 }, "Option B": { "Cost": 6, "Quality": 9 } }'
|
||||
}
|
||||
},
|
||||
required: ['options', 'criteria', 'scores']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'js-project-planner',
|
||||
name: 'Project Planner',
|
||||
description: 'Break down projects into phases, tasks, and dependencies',
|
||||
category: 'agentic',
|
||||
language: 'javascript',
|
||||
code: `// Project Planner - decompose projects into actionable plans
|
||||
const projectName = args.project_name;
|
||||
const goal = args.goal;
|
||||
const phases = args.phases || [];
|
||||
const constraints = args.constraints || [];
|
||||
|
||||
if (!projectName || !goal) {
|
||||
return { error: 'Provide project_name and goal' };
|
||||
}
|
||||
|
||||
const plan = {
|
||||
project: projectName,
|
||||
goal: goal,
|
||||
created: new Date().toISOString(),
|
||||
constraints: constraints,
|
||||
phases: phases.map((phase, phaseIdx) => ({
|
||||
id: \`phase-\${phaseIdx + 1}\`,
|
||||
name: phase.name,
|
||||
description: phase.description || '',
|
||||
order: phaseIdx + 1,
|
||||
tasks: (phase.tasks || []).map((task, taskIdx) => ({
|
||||
id: \`\${phaseIdx + 1}.\${taskIdx + 1}\`,
|
||||
title: task.title || task,
|
||||
description: task.description || '',
|
||||
dependencies: task.dependencies || [],
|
||||
status: 'pending',
|
||||
priority: task.priority || 'medium'
|
||||
})),
|
||||
deliverables: phase.deliverables || []
|
||||
})),
|
||||
summary: {
|
||||
totalPhases: phases.length,
|
||||
totalTasks: phases.reduce((sum, p) => sum + (p.tasks?.length || 0), 0),
|
||||
hasConstraints: constraints.length > 0
|
||||
}
|
||||
};
|
||||
|
||||
// Identify critical path (tasks with most dependents)
|
||||
const allTasks = plan.phases.flatMap(p => p.tasks);
|
||||
const dependencyCounts = {};
|
||||
allTasks.forEach(t => {
|
||||
t.dependencies.forEach(dep => {
|
||||
dependencyCounts[dep] = (dependencyCounts[dep] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
plan.criticalTasks = Object.entries(dependencyCounts)
|
||||
.filter(([_, count]) => count > 1)
|
||||
.map(([id, count]) => ({ taskId: id, dependentCount: count }))
|
||||
.sort((a, b) => b.dependentCount - a.dependentCount);
|
||||
|
||||
// Generate next actions (tasks with no pending dependencies)
|
||||
const completedTasks = new Set();
|
||||
plan.nextActions = allTasks
|
||||
.filter(t => t.dependencies.every(d => completedTasks.has(d)))
|
||||
.slice(0, 5)
|
||||
.map(t => ({ id: t.id, title: t.title, phase: t.id.split('.')[0] }));
|
||||
|
||||
// Validation
|
||||
plan.validation = {
|
||||
isValid: phases.length > 0 && plan.summary.totalTasks > 0,
|
||||
warnings: []
|
||||
};
|
||||
|
||||
if (phases.length === 0) {
|
||||
plan.validation.warnings.push('No phases defined');
|
||||
}
|
||||
if (plan.summary.totalTasks === 0) {
|
||||
plan.validation.warnings.push('No tasks defined');
|
||||
}
|
||||
if (constraints.length === 0) {
|
||||
plan.validation.warnings.push('Consider adding constraints (time, budget, resources)');
|
||||
}
|
||||
|
||||
return plan;`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the project'
|
||||
},
|
||||
goal: {
|
||||
type: 'string',
|
||||
description: 'The main goal or outcome of the project'
|
||||
},
|
||||
phases: {
|
||||
type: 'array',
|
||||
description: 'Array of phase objects: [{ name, description, tasks: [{ title, dependencies, priority }], deliverables }]'
|
||||
},
|
||||
constraints: {
|
||||
type: 'array',
|
||||
description: 'Array of constraints (e.g., ["Budget: $10k", "Timeline: 2 weeks"])'
|
||||
}
|
||||
},
|
||||
required: ['project_name', 'goal']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -28,6 +28,16 @@ export interface FileAttachment {
|
||||
base64Data?: string;
|
||||
/** Preview thumbnail for images (data URI with prefix for display) */
|
||||
previewUrl?: string;
|
||||
/** Whether content was truncated due to size limits */
|
||||
truncated?: boolean;
|
||||
/** Original content length before truncation */
|
||||
originalLength?: number;
|
||||
/** Original File object for storage (not serializable, transient) */
|
||||
originalFile?: File;
|
||||
/** Whether this file was analyzed by the FileAnalyzer agent */
|
||||
analyzed?: boolean;
|
||||
/** AI-generated summary from FileAnalyzer (for large/truncated files) */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -121,6 +131,16 @@ export const MAX_PDF_SIZE = 10 * 1024 * 1024;
|
||||
/** Maximum image dimensions (LLaVA limit) */
|
||||
export const MAX_IMAGE_DIMENSION = 1344;
|
||||
|
||||
/** Maximum extracted content length (chars) - prevents context overflow
|
||||
* 8K chars ≈ 2K tokens per file, allowing ~3 files in an 8K context model
|
||||
*/
|
||||
export const MAX_EXTRACTED_CONTENT = 8000;
|
||||
|
||||
/** Threshold for auto-analysis of large files (chars)
|
||||
* Files larger than this would benefit from summarization (future feature)
|
||||
*/
|
||||
export const ANALYSIS_THRESHOLD = 8000;
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards
|
||||
// ============================================================================
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Message {
|
||||
isSummarized?: boolean;
|
||||
/** If true, this is a summary message representing compressed conversation history */
|
||||
isSummary?: boolean;
|
||||
/** References to attachments stored in IndexedDB */
|
||||
attachmentIds?: string[];
|
||||
}
|
||||
|
||||
/** A node in the message tree structure (for branching conversations) */
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
MAX_IMAGE_SIZE,
|
||||
MAX_TEXT_SIZE,
|
||||
MAX_PDF_SIZE,
|
||||
MAX_IMAGE_DIMENSION
|
||||
MAX_IMAGE_DIMENSION,
|
||||
MAX_EXTRACTED_CONTENT
|
||||
} from '$lib/types/attachment.js';
|
||||
|
||||
// ============================================================================
|
||||
@@ -51,21 +52,74 @@ export function detectFileType(file: File): AttachmentType | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Truncation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of content truncation
|
||||
*/
|
||||
interface TruncateResult {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
originalLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content to maximum allowed length
|
||||
* Tries to truncate at a natural boundary (newline or space)
|
||||
*/
|
||||
function truncateContent(content: string, maxLength: number = MAX_EXTRACTED_CONTENT): TruncateResult {
|
||||
const originalLength = content.length;
|
||||
|
||||
if (originalLength <= maxLength) {
|
||||
return { content, truncated: false, originalLength };
|
||||
}
|
||||
|
||||
// Try to find a natural break point (newline or space) near the limit
|
||||
let cutPoint = maxLength;
|
||||
const searchStart = Math.max(0, maxLength - 500);
|
||||
|
||||
// Look for last newline before cutoff
|
||||
const lastNewline = content.lastIndexOf('\n', maxLength);
|
||||
if (lastNewline > searchStart) {
|
||||
cutPoint = lastNewline;
|
||||
} else {
|
||||
// Look for last space
|
||||
const lastSpace = content.lastIndexOf(' ', maxLength);
|
||||
if (lastSpace > searchStart) {
|
||||
cutPoint = lastSpace;
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedContent = content.slice(0, cutPoint) +
|
||||
`\n\n[... content truncated: ${formatFileSize(originalLength)} total, showing first ${formatFileSize(cutPoint)} ...]`;
|
||||
|
||||
return {
|
||||
content: truncatedContent,
|
||||
truncated: true,
|
||||
originalLength
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text File Processing
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read a text file and return its content
|
||||
* Read a text file and return its content with truncation info
|
||||
*/
|
||||
export async function readTextFile(file: File): Promise<string> {
|
||||
export async function readTextFile(file: File): Promise<TruncateResult> {
|
||||
if (file.size > MAX_TEXT_SIZE) {
|
||||
throw new Error(`File too large. Maximum size is ${MAX_TEXT_SIZE / 1024 / 1024}MB`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onload = () => {
|
||||
const rawContent = reader.result as string;
|
||||
resolve(truncateContent(rawContent));
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
@@ -170,9 +224,9 @@ async function loadPdfJs(): Promise<typeof import('pdfjs-dist')> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from a PDF file
|
||||
* Extract text content from a PDF file with error handling and content limits
|
||||
*/
|
||||
export async function extractPdfText(file: File): Promise<string> {
|
||||
export async function extractPdfText(file: File): Promise<TruncateResult> {
|
||||
if (file.size > MAX_PDF_SIZE) {
|
||||
throw new Error(`PDF too large. Maximum size is ${MAX_PDF_SIZE / 1024 / 1024}MB`);
|
||||
}
|
||||
@@ -183,20 +237,63 @@ export async function extractPdfText(file: File): Promise<string> {
|
||||
const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
|
||||
|
||||
const textParts: string[] = [];
|
||||
let totalChars = 0;
|
||||
let stoppedEarly = false;
|
||||
const failedPages: number[] = [];
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
const pageText = textContent.items
|
||||
.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem =>
|
||||
'str' in item
|
||||
)
|
||||
.map((item) => item.str)
|
||||
.join(' ');
|
||||
textParts.push(pageText);
|
||||
// Stop if we've already collected enough content
|
||||
if (totalChars >= MAX_EXTRACTED_CONTENT) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
// Null check for textContent.items
|
||||
if (!textContent?.items) {
|
||||
console.warn(`PDF page ${i}: No text content items`);
|
||||
failedPages.push(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageText = textContent.items
|
||||
.filter((item): item is import('pdfjs-dist/types/src/display/api').TextItem =>
|
||||
'str' in item && typeof item.str === 'string'
|
||||
)
|
||||
.map((item) => item.str)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
if (pageText) {
|
||||
textParts.push(pageText);
|
||||
totalChars += pageText.length;
|
||||
}
|
||||
} catch (pageError) {
|
||||
console.warn(`PDF page ${i} extraction failed:`, pageError);
|
||||
failedPages.push(i);
|
||||
// Continue with other pages instead of failing entirely
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.join('\n\n');
|
||||
let rawContent = textParts.join('\n\n');
|
||||
|
||||
// Add metadata about extraction issues
|
||||
const metadata: string[] = [];
|
||||
if (failedPages.length > 0) {
|
||||
metadata.push(`[Note: Failed to extract pages: ${failedPages.join(', ')}]`);
|
||||
}
|
||||
if (stoppedEarly) {
|
||||
metadata.push(`[Note: Extraction stopped at page ${textParts.length} of ${pdf.numPages} due to content limit]`);
|
||||
}
|
||||
|
||||
if (metadata.length > 0) {
|
||||
rawContent = metadata.join('\n') + '\n\n' + rawContent;
|
||||
}
|
||||
|
||||
return truncateContent(rawContent);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -236,29 +333,36 @@ export async function processFile(file: File): Promise<ProcessFileOutcome> {
|
||||
attachment: {
|
||||
...baseAttachment,
|
||||
base64Data: base64,
|
||||
previewUrl
|
||||
previewUrl,
|
||||
originalFile: file
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const textContent = await readTextFile(file);
|
||||
const result = await readTextFile(file);
|
||||
return {
|
||||
success: true,
|
||||
attachment: {
|
||||
...baseAttachment,
|
||||
textContent
|
||||
textContent: result.content,
|
||||
truncated: result.truncated,
|
||||
originalLength: result.originalLength,
|
||||
originalFile: file
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case 'pdf': {
|
||||
const textContent = await extractPdfText(file);
|
||||
const result = await extractPdfText(file);
|
||||
return {
|
||||
success: true,
|
||||
attachment: {
|
||||
...baseAttachment,
|
||||
textContent
|
||||
textContent: result.content,
|
||||
truncated: result.truncated,
|
||||
originalLength: result.originalLength,
|
||||
originalFile: file
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -312,11 +416,27 @@ export function getFileIcon(type: AttachmentType): string {
|
||||
|
||||
/**
|
||||
* Format attachment content for inclusion in message
|
||||
* Prepends file content with a header showing filename
|
||||
* Uses XML-style tags for cleaner parsing by LLMs
|
||||
*/
|
||||
export function formatAttachmentsForMessage(attachments: FileAttachment[]): string {
|
||||
return attachments
|
||||
.filter((a) => a.textContent)
|
||||
.map((a) => `--- ${a.filename} ---\n${a.textContent}`)
|
||||
.map((a) => {
|
||||
const truncatedAttr = a.truncated ? ' truncated="true"' : '';
|
||||
const sizeAttr = ` size="${formatFileSize(a.size)}"`;
|
||||
return `<file name="${escapeXmlAttr(a.filename)}"${sizeAttr}${truncatedAttr}>\n${a.textContent}\n</file>`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters for XML attribute values
|
||||
*/
|
||||
function escapeXmlAttr(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
import { resolveSystemPrompt } from '$lib/services/prompt-resolution.js';
|
||||
import { streamingMetricsState } from '$lib/stores/streaming-metrics.svelte';
|
||||
import { settingsState } from '$lib/stores/settings.svelte';
|
||||
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation } from '$lib/storage';
|
||||
import { createConversation as createStoredConversation, addMessage as addStoredMessage, updateConversation, saveAttachments } from '$lib/storage';
|
||||
import { ollamaClient } from '$lib/ollama';
|
||||
import type { OllamaMessage, OllamaToolDefinition, OllamaToolCall } from '$lib/ollama';
|
||||
import { getFunctionModel, USE_FUNCTION_MODEL, runToolCalls, formatToolResultsForChat } from '$lib/tools';
|
||||
import { searchSimilar, formatResultsAsContext, getKnowledgeBaseStats } from '$lib/memory';
|
||||
import ChatWindow from '$lib/components/chat/ChatWindow.svelte';
|
||||
import type { Conversation } from '$lib/types/conversation';
|
||||
import type { FileAttachment } from '$lib/types/attachment.js';
|
||||
import { fileAnalyzer, analyzeFilesInBatches, formatAnalyzedAttachment, type AnalysisResult } from '$lib/services/fileAnalyzer.js';
|
||||
import { replaceState } from '$app/navigation';
|
||||
|
||||
// RAG state
|
||||
let ragEnabled = $state(true);
|
||||
@@ -25,6 +28,10 @@
|
||||
// Thinking mode state (for reasoning models)
|
||||
let thinkingEnabled = $state(true);
|
||||
|
||||
// File analysis state
|
||||
let isAnalyzingFiles = $state(false);
|
||||
let analyzingFileNames = $state<string[]>([]);
|
||||
|
||||
// Derived: Check if selected model supports thinking
|
||||
const supportsThinking = $derived.by(() => {
|
||||
const caps = modelsState.selectedCapabilities;
|
||||
@@ -70,7 +77,7 @@
|
||||
* Handle first message submission
|
||||
* Creates a new conversation and starts streaming the response
|
||||
*/
|
||||
async function handleFirstMessage(content: string, images?: string[]): Promise<void> {
|
||||
async function handleFirstMessage(content: string, images?: string[], attachments?: FileAttachment[]): Promise<void> {
|
||||
const model = modelsState.selectedId;
|
||||
if (!model) {
|
||||
console.error('No model selected');
|
||||
@@ -104,21 +111,136 @@
|
||||
// Set up chat state for the new conversation
|
||||
chatState.conversationId = conversationId;
|
||||
|
||||
// Add user message to tree
|
||||
// Collect attachment IDs if we have attachments to save
|
||||
let attachmentIds: string[] | undefined;
|
||||
if (attachments && attachments.length > 0) {
|
||||
attachmentIds = attachments.map(a => a.id);
|
||||
}
|
||||
|
||||
// Add user message to tree (including attachmentIds for display)
|
||||
const userMessageId = chatState.addMessage({
|
||||
role: 'user',
|
||||
content,
|
||||
images
|
||||
images,
|
||||
attachmentIds
|
||||
});
|
||||
|
||||
// Save attachments to IndexedDB
|
||||
if (attachments && attachments.length > 0) {
|
||||
const files = await Promise.all(attachments.map(async (a) => {
|
||||
if (a.base64Data) {
|
||||
const binary = atob(a.base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new File([bytes], a.filename, { type: a.mimeType });
|
||||
} else if (a.textContent) {
|
||||
return new File([a.textContent], a.filename, { type: a.mimeType });
|
||||
} else {
|
||||
return new File([], a.filename, { type: a.mimeType });
|
||||
}
|
||||
}));
|
||||
|
||||
const saveResult = await saveAttachments(userMessageId, files, attachments);
|
||||
if (!saveResult.success) {
|
||||
console.error('Failed to save attachments:', saveResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist user message to IndexedDB with the SAME ID as chatState
|
||||
await addStoredMessage(conversationId, { role: 'user', content, images }, null, userMessageId);
|
||||
await addStoredMessage(conversationId, { role: 'user', content, images, attachmentIds }, null, userMessageId);
|
||||
|
||||
// Update URL without navigation (keeps ChatWindow mounted)
|
||||
history.replaceState({}, '', `/chat/${conversationId}`);
|
||||
// NOTE: URL update moved to onComplete to avoid aborting the stream
|
||||
// The URL will update after the first response completes
|
||||
|
||||
// Start streaming response
|
||||
const assistantMessageId = chatState.startStreaming();
|
||||
// Process attachments if any
|
||||
let contentForOllama = content;
|
||||
let assistantMessageId: string | null = null;
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
// Show processing indicator - this message will become the assistant response
|
||||
isAnalyzingFiles = true;
|
||||
analyzingFileNames = attachments.map(a => a.filename);
|
||||
assistantMessageId = chatState.startStreaming();
|
||||
const fileCount = attachments.length;
|
||||
const fileLabel = fileCount === 1 ? 'file' : 'files';
|
||||
chatState.setStreamContent(`Processing ${fileCount} ${fileLabel}...`);
|
||||
|
||||
try {
|
||||
// Check if any files need actual LLM analysis
|
||||
// Force analysis when >3 files to prevent context overflow (max 5 files allowed)
|
||||
const forceAnalysis = attachments.length > 3;
|
||||
const filesToAnalyze = forceAnalysis
|
||||
? attachments.filter(a => a.textContent && a.textContent.length > 2000)
|
||||
: attachments.filter(a => fileAnalyzer.shouldAnalyze(a));
|
||||
|
||||
if (filesToAnalyze.length > 0) {
|
||||
// Update indicator to show analysis
|
||||
chatState.setStreamContent(`Analyzing ${filesToAnalyze.length} ${filesToAnalyze.length === 1 ? 'file' : 'files'}...`);
|
||||
|
||||
const analysisResults = await analyzeFilesInBatches(filesToAnalyze, model, 3);
|
||||
|
||||
// Update attachments with results
|
||||
filesToAnalyze.forEach((file) => {
|
||||
const result = analysisResults.get(file.id);
|
||||
if (result) {
|
||||
file.analyzed = result.analyzed;
|
||||
file.summary = result.summary;
|
||||
}
|
||||
});
|
||||
|
||||
// Build formatted content with file summaries
|
||||
const formattedParts: string[] = [content];
|
||||
for (const attachment of attachments) {
|
||||
const result = analysisResults.get(attachment.id);
|
||||
if (result) {
|
||||
formattedParts.push(formatAnalyzedAttachment(attachment, result));
|
||||
} else if (attachment.textContent) {
|
||||
formattedParts.push(`<file name="${attachment.filename}">\n${attachment.textContent}\n</file>`);
|
||||
}
|
||||
}
|
||||
contentForOllama = formattedParts.join('\n\n');
|
||||
} else {
|
||||
// No files need analysis, format with content
|
||||
const parts: string[] = [content];
|
||||
for (const a of attachments) {
|
||||
if (a.textContent) {
|
||||
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
|
||||
}
|
||||
}
|
||||
contentForOllama = parts.join('\n\n');
|
||||
}
|
||||
|
||||
// Keep "Processing..." visible - LLM streaming will replace it
|
||||
|
||||
} catch (error) {
|
||||
console.error('[NewChat] File processing failed:', error);
|
||||
chatState.setStreamContent('Processing failed, proceeding with original content...');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Fallback: use original content with raw file text
|
||||
const parts: string[] = [content];
|
||||
for (const a of attachments) {
|
||||
if (a.textContent) {
|
||||
parts.push(`<file name="${a.filename}">\n${a.textContent}\n</file>`);
|
||||
}
|
||||
}
|
||||
contentForOllama = parts.join('\n\n');
|
||||
} finally {
|
||||
isAnalyzingFiles = false;
|
||||
analyzingFileNames = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Start streaming response (reuse existing message if processing files)
|
||||
const hadProcessingMessage = !!assistantMessageId;
|
||||
if (!assistantMessageId) {
|
||||
assistantMessageId = chatState.startStreaming();
|
||||
}
|
||||
|
||||
// Track if we need to clear the "Processing..." text on first token
|
||||
let needsClearOnFirstToken = hadProcessingMessage;
|
||||
|
||||
// Start streaming metrics tracking
|
||||
streamingMetricsState.startStream();
|
||||
@@ -129,7 +251,7 @@
|
||||
try {
|
||||
let messages: OllamaMessage[] = [{
|
||||
role: 'user',
|
||||
content,
|
||||
content: contentForOllama,
|
||||
images
|
||||
}];
|
||||
|
||||
@@ -148,6 +270,9 @@
|
||||
systemParts.push(`You have access to a knowledge base. Use the following relevant context to help answer the user's question. If the context isn't relevant, you can ignore it.\n\n${ragContext}`);
|
||||
}
|
||||
|
||||
// Always add language instruction
|
||||
systemParts.push('Always respond in the same language the user writes in. Default to English if unclear.');
|
||||
|
||||
// Inject combined system message
|
||||
if (systemParts.length > 0) {
|
||||
const systemMessage: OllamaMessage = {
|
||||
@@ -175,6 +300,11 @@
|
||||
{ model: chatModel, messages, tools, think: useNativeThinking, options: settingsState.apiParameters },
|
||||
{
|
||||
onThinkingToken: (token) => {
|
||||
// Clear "Processing..." on first token
|
||||
if (needsClearOnFirstToken) {
|
||||
chatState.setStreamContent('');
|
||||
needsClearOnFirstToken = false;
|
||||
}
|
||||
// Accumulate thinking and update the message
|
||||
if (!streamingThinking) {
|
||||
// Start the thinking block
|
||||
@@ -185,6 +315,11 @@
|
||||
streamingMetricsState.incrementTokens();
|
||||
},
|
||||
onToken: (token) => {
|
||||
// Clear "Processing..." on first token
|
||||
if (needsClearOnFirstToken) {
|
||||
chatState.setStreamContent('');
|
||||
needsClearOnFirstToken = false;
|
||||
}
|
||||
// Close thinking block when content starts
|
||||
if (streamingThinking && !thinkingClosed) {
|
||||
chatState.appendToStreaming('</think>\n\n');
|
||||
@@ -232,10 +367,16 @@
|
||||
|
||||
// Generate a smarter title in the background (don't await)
|
||||
generateSmartTitle(conversationId, content, node.message.content);
|
||||
|
||||
// Update URL now that streaming is complete
|
||||
replaceState(`/chat/${conversationId}`, {});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Streaming error:', error);
|
||||
// Show error to user instead of leaving "Processing..."
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||
chatState.finishStreaming();
|
||||
streamingMetricsState.endStream();
|
||||
}
|
||||
@@ -243,6 +384,9 @@
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
// Show error to user
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
chatState.setStreamContent(`⚠️ Error: ${errorMsg}`);
|
||||
chatState.finishStreaming();
|
||||
streamingMetricsState.endStream();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
*/
|
||||
|
||||
import { promptsState, type Prompt } from '$lib/stores';
|
||||
import {
|
||||
getAllPromptTemplates,
|
||||
getPromptCategories,
|
||||
categoryInfo,
|
||||
type PromptTemplate,
|
||||
type PromptCategory
|
||||
} from '$lib/prompts/templates';
|
||||
|
||||
// Tab state
|
||||
type Tab = 'my-prompts' | 'browse-templates';
|
||||
let activeTab = $state<Tab>('my-prompts');
|
||||
|
||||
// Editor state
|
||||
let showEditor = $state(false);
|
||||
@@ -18,6 +29,22 @@
|
||||
let formTargetCapabilities = $state<string[]>([]);
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Template browser state
|
||||
let selectedCategory = $state<PromptCategory | 'all'>('all');
|
||||
let previewTemplate = $state<PromptTemplate | null>(null);
|
||||
let addingTemplateId = $state<string | null>(null);
|
||||
|
||||
// Get templates and categories
|
||||
const templates = getAllPromptTemplates();
|
||||
const categories = getPromptCategories();
|
||||
|
||||
// Filtered templates
|
||||
const filteredTemplates = $derived(
|
||||
selectedCategory === 'all'
|
||||
? templates
|
||||
: templates.filter((t) => t.category === selectedCategory)
|
||||
);
|
||||
|
||||
// Available capabilities for targeting
|
||||
const CAPABILITIES = [
|
||||
{ id: 'code', label: 'Code', description: 'Auto-use with coding models' },
|
||||
@@ -82,7 +109,7 @@
|
||||
|
||||
function toggleCapability(capId: string): void {
|
||||
if (formTargetCapabilities.includes(capId)) {
|
||||
formTargetCapabilities = formTargetCapabilities.filter(c => c !== capId);
|
||||
formTargetCapabilities = formTargetCapabilities.filter((c) => c !== capId);
|
||||
} else {
|
||||
formTargetCapabilities = [...formTargetCapabilities, capId];
|
||||
}
|
||||
@@ -110,6 +137,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function addTemplateToLibrary(template: PromptTemplate): Promise<void> {
|
||||
addingTemplateId = template.id;
|
||||
try {
|
||||
await promptsState.add({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
content: template.content,
|
||||
isDefault: false,
|
||||
targetCapabilities: template.targetCapabilities
|
||||
});
|
||||
// Switch to My Prompts tab to show the new prompt
|
||||
activeTab = 'my-prompts';
|
||||
} finally {
|
||||
addingTemplateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
@@ -123,7 +167,7 @@
|
||||
<div class="h-full overflow-y-auto bg-theme-primary p-6">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-theme-primary">System Prompts</h1>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
@@ -131,168 +175,461 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active prompt indicator -->
|
||||
{#if promptsState.activePrompt}
|
||||
<div class="mb-6 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-blue-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Active system prompt for new chats: <strong class="text-blue-300">{promptsState.activePrompt.name}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prompts list -->
|
||||
{#if promptsState.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-theme-subtle border-t-blue-500"></div>
|
||||
</div>
|
||||
{:else if promptsState.prompts.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-theme-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">No system prompts yet</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Create a system prompt to customize AI behavior
|
||||
</p>
|
||||
{#if activeTab === 'my-prompts'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-theme-tertiary"
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create your first prompt
|
||||
Create Prompt
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each promptsState.prompts as prompt (prompt.id)}
|
||||
<div
|
||||
class="rounded-lg border bg-theme-secondary p-4 transition-colors {promptsState.activePromptId === prompt.id ? 'border-blue-500/50' : 'border-theme'}"
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mb-6 flex gap-1 rounded-lg bg-theme-tertiary p-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'my-prompts')}
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'my-prompts'
|
||||
? 'bg-theme-secondary text-theme-primary shadow'
|
||||
: 'text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
My Prompts
|
||||
{#if promptsState.prompts.length > 0}
|
||||
<span
|
||||
class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab ===
|
||||
'my-prompts'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium text-theme-primary">{prompt.name}</h3>
|
||||
{#if prompt.isDefault}
|
||||
<span class="rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">
|
||||
default
|
||||
</span>
|
||||
{/if}
|
||||
{#if promptsState.activePromptId === prompt.id}
|
||||
<span class="rounded bg-emerald-900 px-2 py-0.5 text-xs text-emerald-300">
|
||||
active
|
||||
</span>
|
||||
{/if}
|
||||
{#if prompt.targetCapabilities && prompt.targetCapabilities.length > 0}
|
||||
{#each prompt.targetCapabilities as cap (cap)}
|
||||
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">
|
||||
{cap}
|
||||
{promptsState.prompts.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'browse-templates')}
|
||||
class="flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors {activeTab ===
|
||||
'browse-templates'
|
||||
? 'bg-theme-secondary text-theme-primary shadow'
|
||||
: 'text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
Browse Templates
|
||||
<span
|
||||
class="ml-1.5 rounded-full bg-theme-tertiary px-2 py-0.5 text-xs {activeTab ===
|
||||
'browse-templates'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: ''}"
|
||||
>
|
||||
{templates.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- My Prompts Tab -->
|
||||
{#if activeTab === 'my-prompts'}
|
||||
<!-- Active prompt indicator -->
|
||||
{#if promptsState.activePrompt}
|
||||
<div class="mb-6 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<div class="flex items-center gap-2 text-sm text-blue-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
>Active system prompt for new chats: <strong class="text-blue-300"
|
||||
>{promptsState.activePrompt.name}</strong
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Prompts list -->
|
||||
{#if promptsState.isLoading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-theme-subtle border-t-blue-500"
|
||||
></div>
|
||||
</div>
|
||||
{:else if promptsState.prompts.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-theme bg-theme-secondary/50 p-8 text-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mx-auto h-12 w-12 text-theme-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-sm font-medium text-theme-muted">No system prompts yet</h3>
|
||||
<p class="mt-1 text-sm text-theme-muted">
|
||||
Create a prompt or browse templates to get started
|
||||
</p>
|
||||
<div class="mt-4 flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={openCreateEditor}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-theme-tertiary px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-theme-tertiary"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create from scratch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = 'browse-templates')}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-purple-700"
|
||||
>
|
||||
Browse templates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each promptsState.prompts as prompt (prompt.id)}
|
||||
<div
|
||||
class="rounded-lg border bg-theme-secondary p-4 transition-colors {promptsState.activePromptId ===
|
||||
prompt.id
|
||||
? 'border-blue-500/50'
|
||||
: 'border-theme'}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-medium text-theme-primary">{prompt.name}</h3>
|
||||
{#if prompt.isDefault}
|
||||
<span class="rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">
|
||||
default
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if promptsState.activePromptId === prompt.id}
|
||||
<span class="rounded bg-emerald-900 px-2 py-0.5 text-xs text-emerald-300">
|
||||
active
|
||||
</span>
|
||||
{/if}
|
||||
{#if prompt.targetCapabilities && prompt.targetCapabilities.length > 0}
|
||||
{#each prompt.targetCapabilities as cap (cap)}
|
||||
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">
|
||||
{cap}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if prompt.description}
|
||||
<p class="mt-1 text-sm text-theme-muted">{prompt.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">
|
||||
{prompt.content}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-theme-muted">
|
||||
Updated {formatDate(prompt.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{#if prompt.description}
|
||||
<p class="mt-1 text-sm text-theme-muted">{prompt.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 line-clamp-2 text-sm text-theme-muted">
|
||||
{prompt.content}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-theme-muted">
|
||||
Updated {formatDate(prompt.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Use/Active toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSetActive(prompt)}
|
||||
class="rounded p-1.5 transition-colors {promptsState.activePromptId === prompt.id ? 'bg-emerald-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
|
||||
title={promptsState.activePromptId === prompt.id ? 'Deactivate' : 'Use for new chats'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Use/Active toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSetActive(prompt)}
|
||||
class="rounded p-1.5 transition-colors {promptsState.activePromptId === prompt.id
|
||||
? 'bg-emerald-600 text-theme-primary'
|
||||
: 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
|
||||
title={promptsState.activePromptId === prompt.id
|
||||
? 'Deactivate'
|
||||
: 'Use for new chats'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Set as default -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSetDefault(prompt)}
|
||||
class="rounded p-1.5 transition-colors {prompt.isDefault ? 'bg-blue-600 text-theme-primary' : 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
|
||||
title={prompt.isDefault ? 'Remove as default' : 'Set as default'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill={prompt.isDefault ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Set as default -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSetDefault(prompt)}
|
||||
class="rounded p-1.5 transition-colors {prompt.isDefault
|
||||
? 'bg-blue-600 text-theme-primary'
|
||||
: 'text-theme-muted hover:bg-theme-tertiary hover:text-theme-primary'}"
|
||||
title={prompt.isDefault ? 'Remove as default' : 'Set as default'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill={prompt.isDefault ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Edit -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditEditor(prompt)}
|
||||
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
title="Edit"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Edit -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditEditor(prompt)}
|
||||
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
title="Edit"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(prompt)}
|
||||
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDelete(prompt)}
|
||||
class="rounded p-1.5 text-theme-muted transition-colors hover:bg-red-900/30 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info section -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
|
||||
<h3 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
How System Prompts Work
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
System prompts define the AI's behavior, personality, and constraints. They're sent at
|
||||
the beginning of each conversation to set the context. Use them to create specialized
|
||||
assistants (e.g., code reviewer, writing helper) or to enforce specific response formats.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
<strong class="text-theme-secondary">Default prompt:</strong> Used for all new chats unless
|
||||
overridden.
|
||||
<strong class="text-theme-secondary">Active prompt:</strong> Currently selected for your session.
|
||||
<strong class="text-theme-secondary">Capability targeting:</strong> Auto-matches prompts to
|
||||
models with specific capabilities (code, vision, thinking, tools).
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Browse Templates Tab -->
|
||||
{#if activeTab === 'browse-templates'}
|
||||
<!-- Category filter -->
|
||||
<div class="mb-6 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
|
||||
'all'
|
||||
? 'bg-theme-secondary text-theme-primary'
|
||||
: 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each categories as category (category)}
|
||||
{@const info = categoryInfo[category]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedCategory = category)}
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
|
||||
category
|
||||
? info.color
|
||||
: 'bg-theme-tertiary text-theme-muted hover:text-theme-secondary'}"
|
||||
>
|
||||
<span>{info.icon}</span>
|
||||
{info.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Templates grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each filteredTemplates as template (template.id)}
|
||||
{@const info = categoryInfo[template.category]}
|
||||
<div class="rounded-lg border border-theme bg-theme-secondary p-4">
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-theme-primary">{template.name}</h3>
|
||||
<span class="mt-1 inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
|
||||
<span>{info.icon}</span>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addTemplateToLibrary(template)}
|
||||
disabled={addingTemplateId === template.id}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{#if addingTemplateId === template.id}
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-theme-muted">{template.description}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (previewTemplate = template)}
|
||||
class="mt-3 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Preview prompt
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Info section -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
|
||||
<h3 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
How System Prompts Work
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
System prompts define the AI's behavior, personality, and constraints. They're sent at the
|
||||
beginning of each conversation to set the context. Use them to create specialized assistants
|
||||
(e.g., code reviewer, writing helper) or to enforce specific response formats.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
<strong class="text-theme-secondary">Default prompt:</strong> Used for all new chats unless overridden.
|
||||
<strong class="text-theme-secondary">Active prompt:</strong> Currently selected for your session.
|
||||
<strong class="text-theme-secondary">Capability targeting:</strong> Auto-matches prompts to models with specific capabilities (code, vision, thinking, tools).
|
||||
</p>
|
||||
</section>
|
||||
<!-- Info about templates -->
|
||||
<section class="mt-8 rounded-lg border border-theme bg-theme-secondary/50 p-4">
|
||||
<h3 class="flex items-center gap-2 text-sm font-medium text-theme-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"
|
||||
/>
|
||||
</svg>
|
||||
About Templates
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-theme-muted">
|
||||
These curated templates are designed for common use cases. When you add a template, it
|
||||
creates a copy in your library that you can customize. Templates with capability tags
|
||||
will auto-match with compatible models.
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-theme-muted">
|
||||
Inspired by prompts from the
|
||||
<a
|
||||
href="https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-purple-400 hover:text-purple-300 hover:underline"
|
||||
>
|
||||
system-prompts-and-models-of-ai-tools
|
||||
</a>
|
||||
collection.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -300,8 +637,12 @@
|
||||
{#if showEditor}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeEditor(); }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }}
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) closeEditor();
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') closeEditor();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="editor-title"
|
||||
@@ -316,13 +657,26 @@
|
||||
onclick={closeEditor}
|
||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="p-6">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
class="p-6"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
@@ -341,7 +695,10 @@
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="prompt-description" class="mb-1 block text-sm font-medium text-theme-secondary">
|
||||
<label
|
||||
for="prompt-description"
|
||||
class="mb-1 block text-sm font-medium text-theme-secondary"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
@@ -390,14 +747,19 @@
|
||||
Auto-use for model types
|
||||
</label>
|
||||
<p class="mb-3 text-xs text-theme-muted">
|
||||
When a model has these capabilities and no other prompt is selected, this prompt will be used automatically.
|
||||
When a model has these capabilities and no other prompt is selected, this prompt will
|
||||
be used automatically.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each CAPABILITIES as cap (cap.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleCapability(cap.id)}
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(cap.id) ? 'border-blue-500 bg-blue-500/20 text-blue-300' : 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}"
|
||||
class="rounded-lg border px-3 py-1.5 text-sm transition-colors {formTargetCapabilities.includes(
|
||||
cap.id
|
||||
)
|
||||
? 'border-blue-500 bg-blue-500/20 text-blue-300'
|
||||
: 'border-theme-subtle bg-theme-tertiary text-theme-muted hover:border-theme hover:text-theme-secondary'}"
|
||||
title={cap.description}
|
||||
>
|
||||
{cap.label}
|
||||
@@ -428,3 +790,94 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Template Preview Modal -->
|
||||
{#if previewTemplate}
|
||||
{@const info = categoryInfo[previewTemplate.category]}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) previewTemplate = null;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') previewTemplate = null;
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-xl bg-theme-secondary shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-theme px-6 py-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-theme-primary">{previewTemplate.name}</h2>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs {info.color}">
|
||||
<span>{info.icon}</span>
|
||||
{info.label}
|
||||
</span>
|
||||
{#if previewTemplate.targetCapabilities}
|
||||
{#each previewTemplate.targetCapabilities as cap}
|
||||
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">
|
||||
{cap}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (previewTemplate = null)}
|
||||
class="rounded p-1 text-theme-muted transition-colors hover:bg-theme-tertiary hover:text-theme-primary"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<p class="mb-4 text-sm text-theme-muted">{previewTemplate.description}</p>
|
||||
<pre
|
||||
class="whitespace-pre-wrap rounded-lg bg-theme-tertiary p-4 font-mono text-sm text-theme-primary">{previewTemplate.content}</pre>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 border-t border-theme px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (previewTemplate = null)}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-theme-secondary transition-colors hover:bg-theme-tertiary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (previewTemplate) {
|
||||
addTemplateToLibrary(previewTemplate);
|
||||
previewTemplate = null;
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-theme-primary transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add to Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user