# AI Assistant with SvelteKit

### 1. Introduction

This comprehensive tutorial will guide you through building a full-stack AI chat assistant application using SvelteKit and the JamAI Base TypeScript SDK. The application uses JamAI Base Chat Tables to create an intelligent conversational AI that can process text and images.

#### What We'll Build

An AI-powered chat application that allows users to:

1. Create and manage multiple chat conversations
2. Send text messages to an AI assistant
3. Upload and share images in conversations
4. Receive real-time streaming responses from AI
5. View and organize conversation history
6. Experience a modern, responsive chat interface

#### Key Features

* **Modern SvelteKit** with file-based routing and server-side rendering
* **Svelte 5** with the latest runes API
* **TypeScript** for type safety
* **Tailwind CSS 4** for styling with modern UI
* **Real-time Streaming** for AI responses
* **Image Upload** with preview and validation
* **Conversation Management** with create, rename, and delete
* **Responsive Design** for mobile and desktop
* **JamAI Base Chat Tables** for AI conversation management

#### Prerequisites

Before starting, you'll need:

* Node.js 18.x or higher installed
* Basic knowledge of Svelte and TypeScript
* Project ID and Personal Access Token (PAT) from JamAI Base
* A code editor (VS Code recommended)

### 2. Project Setup

#### 2.1 Create SvelteKit Project

First, create a new SvelteKit project:

```bash
npx sv create ai-chat-assistant
cd ai-chat-assistant
```

When prompted, select:

* Which Svelte app template? **SvelteKit demo app**
* Add type checking with TypeScript? **Yes, using TypeScript syntax**
* Add ESLint? **Yes**
* Add Prettier? **Yes**
* Add Vitest? **No** (optional)
* Add Playwright? **No** (optional)

#### 2.2 Install Dependencies

Install the required packages:

```bash
npm install
npm install jamaibase
```

**Dependencies:**

* `jamaibase` - JamAI Base TypeScript SDK for AI operations
* `@tailwindcss/vite` - Tailwind CSS 4 with Vite integration

#### 2.3 Configure Tailwind CSS

Create `src/routes/layout.css`:

```css
@import "tailwindcss";
@import "@fontsource/fira-mono";

@layer base {
  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
      "Helvetica Neue", Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
}
```

#### 2.4 Project Structure

Your project structure should look like this:

```
ai-chat-assistant/
├── src/
│   ├── lib/
│   │   ├── components/
│   │   │   ├── ChatMessages.svelte
│   │   │   ├── MessageInput.svelte
│   │   │   └── ChatSidebar.svelte
│   │   ├── config/
│   │   │   └── constants.ts
│   │   ├── server/
│   │   │   └── jamai-client.ts
│   │   ├── stores/
│   │   │   └── chat-store.ts
│   │   └── types.ts
│   ├── routes/
│   │   ├── api/
│   │   │   ├── conversations/
│   │   │   │   ├── +server.ts
│   │   │   │   └── [id]/
│   │   │   │       ├── +server.ts
│   │   │   │       └── messages/
│   │   │   │           └── +server.ts
│   │   │   └── upload/
│   │   │       └── +server.ts
│   │   ├── chat/
│   │   │   ├── +page.svelte
│   │   │   └── +layout.svelte
│   │   ├── +page.svelte
│   │   ├── +layout.svelte
│   │   └── layout.css
│   ├── app.d.ts
│   └── app.html
├── .env
├── .env.example
├── package.json
├── svelte.config.js
├── tsconfig.json
└── vite.config.ts
```

### 3. Getting Your Credentials

#### 3.1 Get Your Personal Access Token (PAT)

1. Go to <https://cloud.jamaibase.com/>
2. Click on your username in the top right
3. Select **Account Settings**
4. Navigate to **Personal Access Token** section
5. Click **Create Personal Access Token**
6. Copy and save your token securely

#### 3.2 Get Your Project ID

1. In the JamAI Base dashboard, navigate to your project
2. Look at the browser URL: `https://cloud.jamaibase.com/project/{PROJECT_ID}`
3. Copy the Project ID from the URL

#### 3.3 Configure Environment Variables

Create a `.env` file in your project root:

```bash
# JamAI Base Configuration
# Get your credentials from https://cloud.jamaibase.com/

# Your Personal Access Token (PAT)
JAMAI_API_KEY=your_api_key_here

# Your Project ID
JAMAI_PROJECT_ID=your_project_id_here
```

{% hint style="info" %}
**Important:** Never commit `.env` to version control. Add it to your `.gitignore` file.
{% endhint %}

### 4. Core Configuration

#### 4.1 Create Type Definitions

Create `src/lib/types.ts`:

```typescript
// Message types
export interface Message {
  id: string;
  role: "user" | "assistant";
  content: string;
  imageUri?: string;
  thumbUri?: string;
  timestamp: string;
}
```

#### 4.2 Create Constants Configuration

Create `src/lib/config/constants.ts`:

```typescript
// Agent/Table Configuration
export const AGENT_CONFIG = {
  // The name of the chat agent (chat table)
  AGENT_ID: "simple-chat",

  // Table columns
  COLUMNS: {
    IMAGE: "Image",
    USER: "User",
    AI: "AI",
  },

  // LLM Configuration
  MODEL: "openai/gpt-5-mini",
  SYSTEM_PROMPT: "You are a helpful AI assistant.",
  MAX_TOKENS: 2000,
  TEMPERATURE: 0.7,
};

// File Upload Configuration
export const UPLOAD_CONFIG = {
  // Maximum file size in bytes (10MB)
  MAX_FILE_SIZE: 10 * 1024 * 1024,

  // Allowed image MIME types
  ALLOWED_MIME_TYPES: [
    "image/jpeg",
    "image/jpg",
    "image/png",
    "image/gif",
    "image/webp",
  ],

  // Allowed file extensions
  ALLOWED_EXTENSIONS: [".jpeg", ".jpg", ".png", ".gif", ".webp"],
};

// API Endpoint Paths
export const API_ROUTES = {
  CONVERSATIONS: "/api/conversations",
  UPLOAD: "/api/upload",
  getConversation: (id: string) => `/api/conversations/${id}`,
  getMessages: (id: string) => `/api/conversations/${id}/messages`,
};

// UI Constants
export const UI_CONFIG = {
  // Sidebar width
  SIDEBAR_WIDTH: "280px",

  // Max conversation title length
  MAX_TITLE_LENGTH: 50,

  // Messages per page
  MESSAGES_PER_PAGE: 50,

  // Auto-scroll delay (ms)
  AUTO_SCROLL_DELAY: 100,
};
```

**Key Configuration Points:**

* **AGENT\_ID**: The name of your Chat Table in JamAI Base
* **MODEL**: The LLM model to use (gpt-4o-mini is cost-effective and fast)
* **SYSTEM\_PROMPT**: Defines the AI assistant's personality and behavior
* **COLUMNS**: Maps to the Chat Table columns in JamAI Base

#### 4.3 Initialize JamAI Client

Create `src/lib/server/jamai-client.ts`:

```typescript
import JamAI from "jamaibase";
import { JAMAI_API_KEY, JAMAI_PROJECT_ID } from "$env/static/private";

// Singleton JamAI client instance
let jamaiClient: JamAI | null = null;

/**
 * Get the JamAI client instance (singleton pattern)
 * Initializes the client with environment variables on first call
 */
export function getJamaiClient(): JamAI {
  if (!jamaiClient) {
    if (!JAMAI_API_KEY || !JAMAI_PROJECT_ID) {
      throw new Error(
        "Missing required environment variables: JAMAI_API_KEY and JAMAI_PROJECT_ID must be set"
      );
    }

    jamaiClient = new JamAI({
      token: JAMAI_API_KEY,
      projectId: JAMAI_PROJECT_ID,
    });
  }

  return jamaiClient;
}

/**
 * Helper function to extract values from wrapped row data
 * JamAI SDK wraps values in { value: ... } objects
 */
export function getValue(field: any): any {
  if (field && typeof field === "object" && "value" in field) {
    return field.value;
  }
  return field;
}
```

**Key Points:**

* Uses singleton pattern for efficient client management
* Validates environment variables on initialization
* Uses SvelteKit's `$env/static/private` for secure server-side env vars
* Provides helper function to unwrap JamAI's data structure

### 5. Building the API Routes

#### 5.1 Conversations List & Create Route

Create `src/routes/api/conversations/+server.ts`:

```typescript
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { getJamaiClient } from "$lib/server/jamai-client";
import { AGENT_CONFIG } from "$lib/config/constants";

/**
 * Ensure agent exists before any operation
 */
async function ensureAgentExists() {
  const jamai = getJamaiClient();

  try {
    await jamai.table.getTable({
      table_type: "chat",
      table_id: AGENT_CONFIG.AGENT_ID,
    });
  } catch (_error) {
    // Agent doesn't exist, create it
    await jamai.table.createChatTable({
      id: AGENT_CONFIG.AGENT_ID,
      cols: [
        {
          id: AGENT_CONFIG.COLUMNS.IMAGE,
          dtype: "image",
        },
        {
          id: AGENT_CONFIG.COLUMNS.USER,
          dtype: "str",
        },
        {
          id: AGENT_CONFIG.COLUMNS.AI,
          dtype: "str",
          gen_config: {
            model: AGENT_CONFIG.MODEL,
            system_prompt: AGENT_CONFIG.SYSTEM_PROMPT,
            prompt: `User message: \${${AGENT_CONFIG.COLUMNS.USER}}\n\nImage: \${${AGENT_CONFIG.COLUMNS.IMAGE}}`,
            max_tokens: AGENT_CONFIG.MAX_TOKENS,
            temperature: AGENT_CONFIG.TEMPERATURE,
          },
        },
      ],
    });
  }
}

/**
 * GET /api/conversations
 * List all conversations
 */
export const GET: RequestHandler = async () => {
  try {
    await ensureAgentExists();

    const jamai = getJamaiClient();
    const conversations = await jamai.conversations.listConversations();

    const filteredConversation = conversations?.items?.filter(
      (c) => c.parent_id === AGENT_CONFIG.AGENT_ID
    );

    return json({ conversations: filteredConversation || [] });
  } catch (error) {
    console.error("GET /api/conversations error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};

/**
 * POST /api/conversations
 * Create a new conversation
 * Body: { title?: string }
 */
export const POST: RequestHandler = async ({ request }) => {
  try {
    await ensureAgentExists();

    const body = await request.json();
    const { title } = body;

    const jamai = getJamaiClient();
    const conversationStream = await jamai.conversations.createConversation({
      agent_id: AGENT_CONFIG.AGENT_ID,
      title: title || "New Chat",
      data: {},
    });

    // Read the stream to get conversation ID
    const reader = conversationStream.getReader();
    let conversationId: string | null = null;

    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        if (value && typeof value === "object" && "conversation_id" in value) {
          conversationId = value.conversation_id as string;
          break;
        }
      }
    } finally {
      reader.releaseLock();
    }

    if (!conversationId) {
      throw new Error("Failed to get conversation ID from stream");
    }

    return json(
      {
        conversation: {
          conversation_id: conversationId,
          title: title || "New Chat",
        },
      },
      { status: 201 }
    );
  } catch (error) {
    console.error("POST /api/conversations error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};
```

**What this does:**

* **ensureAgentExists()**: Automatically creates the Chat Table if it doesn't exist
* **GET**: Lists all conversations associated with the agent
* **POST**: Creates a new conversation with an optional title
* Handles streaming response to extract conversation ID

#### 5.2 Single Conversation Route (Get, Delete & Rename)

Create `src/routes/api/conversations/[id]/+server.ts`:

```typescript
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { getJamaiClient } from "$lib/server/jamai-client";

/**
 * GET /api/conversations/[id]
 * Get a specific conversation
 */
export const GET: RequestHandler = async ({ params }) => {
  try {
    const { id } = params;
    const jamai = getJamaiClient();
    const conversation = await jamai.conversations.getConversation(id);
    return json({ conversation });
  } catch (error) {
    console.error("GET /api/conversations/[id] error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};

/**
 * DELETE /api/conversations/[id]
 * Delete a conversation
 */
export const DELETE: RequestHandler = async ({ params }) => {
  try {
    const { id } = params;
    const jamai = getJamaiClient();

    await jamai.conversations.deleteConversation(id);

    return json({ ok: true });
  } catch (error) {
    console.error("DELETE /api/conversations/[id] error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};

/**
 * PATCH /api/conversations/[id]
 * Rename a conversation
 * Body: { title: string }
 */
export const PATCH: RequestHandler = async ({ params, request }) => {
  try {
    const { id } = params;
    const body = await request.json();
    const { title } = body;

    if (!title) {
      return json({ error: "Title is required" }, { status: 400 });
    }

    const jamai = getJamaiClient();
    const conversation = await jamai.conversations.renameConversationTitle(
      id,
      title
    );
    return json({ conversation });
  } catch (error) {
    console.error("PATCH /api/conversations/[id] error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};
```

#### 5.3 Messages Route (List & Send)

Create `src/routes/api/conversations/[id]/messages/+server.ts`:

```typescript
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { getJamaiClient, getValue } from "$lib/server/jamai-client";
import { AGENT_CONFIG } from "$lib/config/constants";
import type { Message } from "$lib/types";

/**
 * GET /api/conversations/[id]/messages
 * List messages in a conversation
 */
export const GET: RequestHandler = async ({ params }) => {
  try {
    const { id } = params;
    const jamai = getJamaiClient();
    const response = await jamai.conversations.listMessages(id, {});

    const messages: Message[] = [];
    const imageUris: string[] = [];
    const imageUriToMessageIndex: Map<string, number> = new Map();

    // Process rows to extract user and AI messages
    if (response && response.items) {
      for (const row of response.items) {
        const rowId = getValue(row["ID"]) || row.ID;
        const timestamp = getValue(row["Updated at"]) || row["Updated at"];

        // User message
        const userContent = getValue(row[AGENT_CONFIG.COLUMNS.USER]);
        const imageUri = getValue(row[AGENT_CONFIG.COLUMNS.IMAGE]);

        // Only add user message if content is not empty
        if (userContent && userContent.trim()) {
          const messageIndex = messages.length;
          messages.push({
            id: `${rowId}-user`,
            role: "user",
            content: userContent,
            imageUri: imageUri || undefined,
            timestamp,
          });

          // Track image URI for batch thumbnail fetching
          if (imageUri && imageUri.trim()) {
            imageUris.push(imageUri);
            imageUriToMessageIndex.set(imageUri, messageIndex);
          }
        }

        // AI message
        const aiContent = getValue(row[AGENT_CONFIG.COLUMNS.AI]);
        // Only add AI message if content is not empty
        if (aiContent && aiContent.trim()) {
          messages.push({
            id: `${rowId}-ai`,
            role: "assistant",
            content: aiContent,
            timestamp,
          });
        }
      }
    }

    // Fetch thumbnail URLs in a single batch call if there are any images
    if (imageUris.length > 0) {
      try {
        const thumbUriResponse = await jamai.file.getThumbUrls({
          uris: imageUris,
        });

        // Map thumbnail URLs back to messages
        if (thumbUriResponse.urls) {
          imageUris.forEach((uri, index) => {
            const messageIndex = imageUriToMessageIndex.get(uri);
            if (messageIndex !== undefined && thumbUriResponse.urls[index]) {
              messages[messageIndex].thumbUri = thumbUriResponse.urls[index];
            }
          });
        }
      } catch (thumbError) {
        console.error("Failed to fetch thumbnail URLs:", thumbError);
        // Continue without thumbnail URLs - don't fail the entire request
      }
    }

    return json({ messages });
  } catch (error) {
    console.error("GET /api/conversations/[id]/messages error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};

/**
 * POST /api/conversations/[id]/messages
 * Send a message to a conversation (returns streaming response)
 * Body: { text: string, imageUri?: string }
 */
export const POST: RequestHandler = async ({ params, request }) => {
  try {
    const { id } = params;
    const body = await request.json();
    const { text, imageUri } = body;

    if (!text) {
      return json({ error: "Text is required" }, { status: 400 });
    }

    const jamai = getJamaiClient();

    // Prepare message data
    const messageData: any = {
      [AGENT_CONFIG.COLUMNS.USER]: text,
    };

    // Add image if provided
    if (imageUri) {
      messageData[AGENT_CONFIG.COLUMNS.IMAGE] = imageUri;
    }

    // Send message with streaming
    const stream = await jamai.conversations.sendMessage({
      conversation_id: id,
      data: messageData,
    });

    // Create a ReadableStream to pipe the JamAI response to the client
    const readableStream = new ReadableStream({
      async start(controller) {
        try {
          const reader = stream.getReader();

          while (true) {
            const { done, value } = await reader.read();

            if (done) {
              controller.close();
              break;
            }

            // Convert the chunk to JSON string and send it
            const chunk = JSON.stringify(value) + "\n";
            controller.enqueue(new TextEncoder().encode(chunk));
          }
        } catch (error) {
          console.error("Streaming error:", error);
          controller.error(error);
        }
      },
    });

    // Return the streaming response
    return new Response(readableStream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    });
  } catch (error) {
    console.error("POST /api/conversations/[id]/messages error:", error);
    return json({ error: String(error) }, { status: 500 });
  }
};
```

**What this does:**

* **GET**: Fetches all messages in a conversation, unwrapping JamAI's data structure
* Fetches thumbnail URLs for images in batch for performance
* **POST**: Sends a new message and returns a streaming response
* Handles both text and image inputs
* Streams AI responses in real-time

#### 5.4 File Upload Route

Create `src/routes/api/upload/+server.ts`:

```typescript
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { getJamaiClient } from "$lib/server/jamai-client";
import { UPLOAD_CONFIG } from "$lib/config/constants";

/**
 * POST /api/upload
 * Upload an image file
 * Form data: { file: File }
 */
export const POST: RequestHandler = async ({ request }) => {
  try {
    const formData = await request.formData();
    const file = formData.get("file");

    if (!file || !(file instanceof File)) {
      return json({ error: "File is required" }, { status: 400 });
    }

    // Validate file type
    const fileType = file.type.toLowerCase();
    if (!UPLOAD_CONFIG.ALLOWED_MIME_TYPES.includes(fileType)) {
      return json(
        {
          error: "Invalid file type",
          details: `Allowed types: ${UPLOAD_CONFIG.ALLOWED_MIME_TYPES.join(
            ", "
          )}`,
        },
        { status: 400 }
      );
    }

    // Validate file size
    if (file.size > UPLOAD_CONFIG.MAX_FILE_SIZE) {
      return json(
        {
          error: "File too large",
          details: `Maximum file size is ${
            UPLOAD_CONFIG.MAX_FILE_SIZE / 1024 / 1024
          }MB`,
        },
        { status: 413 }
      );
    }

    const jamai = getJamaiClient();

    // Upload to JamAI Base, passing the File directly
    const response = await jamai.file.uploadFile({
      file, // pass File object directly
    });

    const thumbUriResponse = await jamai.file.getThumbUrls({
      uris: [response.uri],
    });

    return json(
      { uri: response.uri, thumbUri: thumbUriResponse.urls?.[0] ?? "" },
      { status: 200 }
    );
  } catch (error) {
    console.error("POST /api/upload error:", error);

    if (error instanceof Error) {
      return json(
        {
          error: "Upload failed",
          details: error.message,
        },
        { status: 500 }
      );
    }

    return json({ error: "Internal server error" }, { status: 500 });
  }
};
```

**What this does:**

* Accepts multipart form data with an image file
* Validates file type and size
* Uploads to JamAI Base storage
* Generates and returns thumbnail URL for preview

### 6. Building the Store

#### 6.1 Create Chat Store

Create `src/lib/stores/chat-store.ts`:

```typescript
import { writable, derived, get } from "svelte/store";
import { API_ROUTES } from "$lib/config/constants";
import type { Message } from "$lib/types";

export interface Conversation {
  conversation_id: string;
  title: string;
  agent_id?: string;
  created_at?: string;
  updated_at?: string;
}

// Store for all conversations
export const conversations = writable<Conversation[]>([]);

// Store for current active conversation ID
export const currentConversationId = writable<string | null>(null);

// Store for messages in the current conversation
export const messages = writable<Message[]>([]);

// Loading state
export const isLoading = writable(false);

// Error state
export const error = writable<string | null>(null);

// Streaming state
export const isStreaming = writable(false);

/**
 * Load all conversations from the API
 */
export async function loadConversations() {
  try {
    isLoading.set(true);
    error.set(null);

    const response = await fetch(API_ROUTES.CONVERSATIONS);
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "Failed to load conversations");
    }

    conversations.set(data.conversations || []);
  } catch (err) {
    console.error("Failed to load conversations:", err);
    error.set(String(err));
  } finally {
    isLoading.set(false);
  }
}

/**
 * Select a conversation and load its messages
 */
export async function selectConversation(conversationId: string) {
  try {
    isLoading.set(true);
    error.set(null);

    currentConversationId.set(conversationId);

    // Load messages for this conversation
    const response = await fetch(API_ROUTES.getMessages(conversationId));
    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "Failed to load messages");
    }

    messages.set(data.messages || []);
  } catch (err) {
    console.error("Failed to select conversation:", err);
    error.set(String(err));
  } finally {
    isLoading.set(false);
  }
}

/**
 * Create a new conversation
 */
export async function createNewConversation(title?: string) {
  try {
    isLoading.set(true);
    error.set(null);

    const response = await fetch(API_ROUTES.CONVERSATIONS, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: title || "New Chat" }),
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "Failed to create conversation");
    }

    // Add to conversations list
    const newConversation: Conversation = {
      conversation_id: data.conversation.conversation_id,
      title: data.conversation.title || "New Chat",
    };

    // Refetch all conversations to ensure we have the latest state from backend
    await loadConversations();

    // Select the new conversation and load its messages
    await selectConversation(newConversation.conversation_id);

    // Wait a bit for backend to initialize the conversation, then refetch
    setTimeout(async () => {
      const currentId = get(currentConversationId);
      if (currentId === newConversation.conversation_id) {
        await selectConversation(currentId);
      }
    }, 1000);

    return newConversation;
  } catch (err) {
    console.error("Failed to create conversation:", err);
    error.set(String(err));
    throw err;
  } finally {
    isLoading.set(false);
  }
}

/**
 * Delete a conversation
 */
export async function deleteConversation(conversationId: string) {
  try {
    error.set(null);

    const response = await fetch(API_ROUTES.getConversation(conversationId), {
      method: "DELETE",
    });

    if (!response.ok) {
      const data = await response.json();
      throw new Error(data.error || "Failed to delete conversation");
    }

    // Remove from conversations list
    conversations.update((convs) =>
      convs.filter((c) => c.conversation_id !== conversationId)
    );

    // If this was the current conversation, clear it
    const current = get(currentConversationId);
    if (current === conversationId) {
      currentConversationId.set(null);
      messages.set([]);
    }
  } catch (err) {
    console.error("Failed to delete conversation:", err);
    error.set(String(err));
    throw err;
  }
}

/**
 * Rename a conversation
 */
export async function renameConversation(
  conversationId: string,
  newTitle: string
) {
  try {
    error.set(null);

    const response = await fetch(API_ROUTES.getConversation(conversationId), {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: newTitle }),
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error || "Failed to rename conversation");
    }

    // Update in conversations list
    conversations.update((convs) =>
      convs.map((c) =>
        c.conversation_id === conversationId ? { ...c, title: newTitle } : c
      )
    );
  } catch (err) {
    console.error("Failed to rename conversation:", err);
    error.set(String(err));
    throw err;
  }
}

/**
 * Send a message to the current conversation
 */
export async function sendMessage(text: string, imageFile?: File) {
  const conversationId = get(currentConversationId);

  if (!conversationId) {
    error.set("No conversation selected");
    return;
  }

  try {
    isStreaming.set(true);
    error.set(null);

    // Upload image if provided
    let imageUri: string | undefined;
    let thumbUri: string | undefined;
    if (imageFile) {
      const formData = new FormData();
      formData.append("file", imageFile);

      const uploadResponse = await fetch(API_ROUTES.UPLOAD, {
        method: "POST",
        body: formData,
      });

      const uploadData = await uploadResponse.json();

      if (!uploadResponse.ok) {
        throw new Error(uploadData.error || "Failed to upload image");
      }

      imageUri = uploadData.uri;
      thumbUri = uploadData.thumbUri;
    }

    // Add user message to UI immediately
    const userMessage: Message = {
      id: `temp-${Date.now()}`,
      role: "user",
      content: text,
      imageUri: thumbUri,
      timestamp: new Date().toISOString(),
    };

    messages.update((msgs) => [...msgs, userMessage]);

    // Send message with streaming
    const response = await fetch(API_ROUTES.getMessages(conversationId), {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text, imageUri }),
    });

    if (!response.ok) {
      throw new Error("Failed to send message");
    }

    // Handle streaming response
    const reader = response.body?.getReader();
    if (!reader) {
      throw new Error("No response stream");
    }

    const decoder = new TextDecoder();
    let aiMessageContent = "";

    // Add placeholder for AI message
    const aiMessageId = `ai-${Date.now()}`;
    messages.update((msgs) => [
      ...msgs,
      {
        id: aiMessageId,
        role: "assistant",
        content: "",
        timestamp: new Date().toISOString(),
      },
    ]);

    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        break;
      }

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split("\n").filter((line) => line.trim());

      for (const line of lines) {
        try {
          const data = JSON.parse(line);

          // Extract content from streaming chunk
          if (
            data.object === "gen_table.completion.chunk" &&
            data.choices?.[0]?.message?.content
          ) {
            aiMessageContent += data.choices[0].message.content;

            // Update AI message content in real-time
            messages.update((msgs) =>
              msgs.map((msg) =>
                msg.id === aiMessageId
                  ? { ...msg, content: aiMessageContent }
                  : msg
              )
            );
          }
        } catch (parseError) {
          console.error("Failed to parse chunk:", parseError);
        }
      }
    }

    // Reload messages to get the final state with proper IDs
    await selectConversation(conversationId);
  } catch (err) {
    console.error("Failed to send message:", err);
    error.set(String(err));
  } finally {
    isStreaming.set(false);
  }
}

/**
 * Derived store for current conversation details
 */
export const currentConversation = derived(
  [conversations, currentConversationId],
  ([$conversations, $currentConversationId]) => {
    if (!$currentConversationId) return null;
    return (
      $conversations.find(
        (c) => c.conversation_id === $currentConversationId
      ) || null
    );
  }
);
```

**Key Features:**

* Manages all application state in a centralized store
* Provides functions for all chat operations
* Handles real-time streaming with message updates
* Uses Svelte's reactive stores for automatic UI updates

### 7. Building the UI Components

#### 7.1 Chat Messages Component

Create `src/lib/components/ChatMessages.svelte`:

```svelte
<script lang="ts">
	import { messages, isStreaming } from '$lib/stores/chat-store';
	import { tick } from 'svelte';

	let messagesContainer: HTMLDivElement;

	// Auto-scroll to bottom when new messages arrive
	$effect(() => {
		if ($messages.length > 0) {
			tick().then(() => {
				scrollToBottom();
			});
		}
	});

	function scrollToBottom() {
		if (messagesContainer) {
			messagesContainer.scrollTop = messagesContainer.scrollHeight;
		}
	}

	function formatTime(timestamp: string) {
		const date = new Date(timestamp);
		return date.toLocaleTimeString('en-US', {
			hour: '2-digit',
			minute: '2-digit'
		});
	}

	function formatMessageContent(content: string) {
		// Basic markdown rendering
		return content
			.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
			.replace(/\*(.*?)\*/g, '<em>$1</em>')
			.replace(/`(.*?)`/g, '<code>$1</code>')
			.replace(/\n/g, '<br>');
	}
</script>

<div
	class="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 flex-1 overflow-y-auto bg-white p-6"
	bind:this={messagesContainer}
>
	{#if $messages.length === 0}
		<div class="flex h-full flex-col items-center justify-center p-8 text-center text-gray-600">
			<div class="mb-6 text-gray-300">
				<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
					<path
						d="M32 8C18.7452 8 8 18.7452 8 32C8 38.5097 10.5226 44.4839 14.6452 48.9677L12.129 54.5806C11.8387 55.1613 12.2903 55.8387 12.9355 55.7742L21.0323 54.9677C24.2581 56.6129 27.9677 57.5484 32 57.5484C45.2548 57.5484 56 46.8032 56 33.5484C56 20.2935 45.2548 9.54839 32 9.54839V8Z"
						stroke="currentColor"
						stroke-width="2"
					/>
					<circle cx="24" cy="32" r="2" fill="currentColor" />
					<circle cx="32" cy="32" r="2" fill="currentColor" />
					<circle cx="40" cy="32" r="2" fill="currentColor" />
				</svg>
			</div>
			<h3 class="m-0 mb-2 text-2xl font-semibold text-gray-900">Start a Conversation</h3>
			<p class="m-0 text-base">Send a message to begin chatting with the AI assistant</p>
		</div>
	{:else}
		<div class="mx-auto max-w-3xl">
			{#each $messages as message (message.id)}
				<div class="mb-6 flex animate-[fadeIn_0.3s_ease-in] gap-4">
					<div
						class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-white {message.role ===
						'user'
							? 'bg-linear-to-br from-gray-600 to-gray-800'
							: 'bg-linear-to-br from-gray-500 to-gray-700'}"
					>
						{#if message.role === 'user'}
							<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
								<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2" />
								<path
									d="M6 21C6 17.6863 8.68629 15 12 15C15.3137 15 18 17.6863 18 21"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
								/>
							</svg>
						{:else}
							<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
								<rect
									x="3"
									y="3"
									width="18"
									height="18"
									rx="4"
									stroke="currentColor"
									stroke-width="2"
								/>
								<circle cx="9" cy="10" r="1.5" fill="currentColor" />
								<circle cx="15" cy="10" r="1.5" fill="currentColor" />
								<path
									d="M9 15C9 15 10 16 12 16C14 16 15 15 15 15"
									stroke="currentColor"
									stroke-width="1.5"
									stroke-linecap="round"
								/>
							</svg>
						{/if}
					</div>

					<div class="min-w-0 flex-1">
						<div class="mb-2 flex items-center gap-3">
							<span class="text-sm font-semibold text-gray-900"
								>{message.role === 'user' ? 'You' : 'AI Assistant'}</span
							>
							<span class="text-xs text-gray-500">{formatTime(message.timestamp)}</span>
						</div>

						{#if message.thumbUri}
							<div class="mb-3">
								<img
									src={message.thumbUri}
									alt="Uploaded"
									class="max-h-75 max-w-xs rounded-lg border border-gray-200"
								/>
							</div>
						{/if}

						<div class="message-text leading-relaxed wrap-break-word text-gray-900">
							{@html formatMessageContent(message.content)}
						</div>
					</div>
				</div>
			{/each}

			{#if $isStreaming}
				<div class="mb-6 flex gap-4">
					<div
						class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-gray-500 to-gray-700 text-white"
					>
						<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
							<rect
								x="3"
								y="3"
								width="18"
								height="18"
								rx="4"
								stroke="currentColor"
								stroke-width="2"
							/>
						</svg>
					</div>
					<div class="min-w-0 flex-1">
						<div class="flex gap-1.5 py-3">
							<span
								class="h-2 w-2 animate-[bounce_1.4s_infinite_ease-in-out_both] rounded-full bg-gray-300 [animation-delay:-0.32s]"
							></span>
							<span
								class="h-2 w-2 animate-[bounce_1.4s_infinite_ease-in-out_both] rounded-full bg-gray-300 [animation-delay:-0.16s]"
							></span>
							<span
								class="h-2 w-2 animate-[bounce_1.4s_infinite_ease-in-out_both] rounded-full bg-gray-300"
							></span>
						</div>
					</div>
				</div>
			{/if}
		</div>
	{/if}
</div>

<style>
	.message-text :global(strong) {
		font-weight: 600;
	}

	.message-text :global(code) {
		background-color: #f5f5f5;
		padding: 0.125rem 0.375rem;
		border-radius: 0.25rem;
		font-family: 'Fira Mono', monospace;
		font-size: 0.9em;
	}

	/* Scrollbar styling for webkit browsers */
	.scrollbar-thin::-webkit-scrollbar {
		width: 8px;
	}

	.scrollbar-track-gray-100::-webkit-scrollbar-track {
		background: #f7f7f8;
	}

	.scrollbar-thumb-gray-300::-webkit-scrollbar-thumb {
		background: #d1d1d6;
		border-radius: 4px;
	}

	.scrollbar-thumb-gray-300::-webkit-scrollbar-thumb:hover {
		background: #b0b0b6;
	}
</style>
```

#### 7.2 Message Input Component

Create `src/lib/components/MessageInput.svelte`:

```svelte
<script lang="ts">
	import { sendMessage, isStreaming, currentConversationId } from '$lib/stores/chat-store';
	import { UPLOAD_CONFIG } from '$lib/config/constants';

	let textInput = $state('');
	let textareaElement: HTMLTextAreaElement;
	let selectedImage: File | null = $state(null);
	let imagePreviewUrl: string | null = $state(null);
	let fileInputElement: HTMLInputElement;
	let isSending = $state(false);
	let uploadError = $state<string | null>(null);

	function handleImageSelect(event: Event) {
		const target = event.target as HTMLInputElement;
		const file = target.files?.[0];

		if (!file) return;

		// Validate file type
		if (!UPLOAD_CONFIG.ALLOWED_MIME_TYPES.includes(file.type)) {
			uploadError = `Invalid file type. Allowed: ${UPLOAD_CONFIG.ALLOWED_EXTENSIONS.join(', ')}`;
			return;
		}

		// Validate file size
		const maxSizeMB = UPLOAD_CONFIG.MAX_FILE_SIZE / (1024 * 1024);
		if (file.size > UPLOAD_CONFIG.MAX_FILE_SIZE) {
			uploadError = `File too large. Max size: ${maxSizeMB}MB`;
			return;
		}

		uploadError = null;
		selectedImage = file;

		// Create preview URL
		const reader = new FileReader();
		reader.onload = (e) => {
			imagePreviewUrl = e.target?.result as string;
		};
		reader.readAsDataURL(file);
	}

	function removeImage() {
		selectedImage = null;
		imagePreviewUrl = null;
		uploadError = null;
		if (fileInputElement) {
			fileInputElement.value = '';
		}
	}

	async function handleSubmit() {
		if (isSending || $isStreaming || !$currentConversationId) return;

		const text = textInput.trim();
		if (!text && !selectedImage) return;

		isSending = true;
		uploadError = null;

		// Store image reference and clear input immediately
		const imageToSend = selectedImage || undefined;
		textInput = '';
		removeImage();

		// Reset textarea height
		if (textareaElement) {
			textareaElement.style.height = 'auto';
		}

		try {
			await sendMessage(text, imageToSend);
		} catch (error) {
			console.error('Failed to send message:', error);
			uploadError = String(error);
		} finally {
			isSending = false;
		}
	}

	function handleKeyDown(event: KeyboardEvent) {
		// Send on Enter (without Shift)
		if (event.key === 'Enter' && !event.shiftKey) {
			event.preventDefault();
			handleSubmit();
		}
	}

	function autoResize() {
		if (textareaElement) {
			textareaElement.style.height = 'auto';
			textareaElement.style.height = textareaElement.scrollHeight + 'px';
		}
	}

	function triggerFileInput() {
		fileInputElement?.click();
	}

	$effect(() => {
		autoResize();
	});
</script>

<div class="border-t border-gray-200 bg-white p-4">
	{#if uploadError}
		<div
			class="mb-3 flex items-center justify-between rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700"
		>
			{uploadError}
			<button
				class="flex h-6 w-6 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-2xl text-red-700 hover:opacity-70"
				onclick={() => (uploadError = null)}>×</button
			>
		</div>
	{/if}

	{#if imagePreviewUrl}
		<div class="relative mb-3 inline-block">
			<img
				src={imagePreviewUrl}
				alt="Preview"
				class="max-h-[200px] max-w-[200px] rounded-lg border border-gray-200"
			/>
			<button
				class="absolute top-2 right-2 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-none bg-black/60 text-white transition-colors duration-200 hover:bg-black/80"
				onclick={removeImage}
				aria-label="Remove image"
			>
				<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
					<path
						d="M5 5L15 15M5 15L15 5"
						stroke="currentColor"
						stroke-width="2"
						stroke-linecap="round"
					/>
				</svg>
			</button>
		</div>
	{/if}

	<div class="mx-auto flex max-w-3xl items-end gap-3 rounded-xl bg-gray-100 p-3">
		<button
			class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-gray-600 transition-all duration-200 hover:bg-black/5 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
			onclick={triggerFileInput}
			disabled={isSending || $isStreaming || !$currentConversationId}
			aria-label="Attach image"
		>
			<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
				<path
					d="M3 12.5V5C3 3.34315 4.34315 2 6 2H14C15.6569 2 17 3.34315 17 5V12.5C17 15.5376 14.5376 18 11.5 18C8.46243 18 6 15.5376 6 12.5V7"
					stroke="currentColor"
					stroke-width="1.5"
					stroke-linecap="round"
				/>
			</svg>
		</button>

		<input
			type="file"
			bind:this={fileInputElement}
			onchange={handleImageSelect}
			accept={UPLOAD_CONFIG.ALLOWED_MIME_TYPES.join(',')}
			style="display: none;"
		/>

		<textarea
			bind:this={textareaElement}
			bind:value={textInput}
			onkeydown={handleKeyDown}
			placeholder={$currentConversationId
				? 'Type a message... (Shift + Enter for new line)'
				: 'Select or create a conversation to start chatting'}
			disabled={isSending || $isStreaming || !$currentConversationId}
			rows="1"
			class="font-inherit textarea-scrollbar max-h-[200px] min-h-9 flex-1 resize-none overflow-y-auto border-none bg-transparent py-2 text-base text-gray-900 outline-none placeholder:text-gray-500 disabled:cursor-not-allowed disabled:opacity-60"
		></textarea>

		<button
			class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-gray-700 text-white transition-all duration-200 hover:scale-105 hover:bg-gray-800 disabled:scale-100 disabled:cursor-not-allowed disabled:opacity-50"
			onclick={handleSubmit}
			disabled={isSending || $isStreaming || !textInput.trim() || !$currentConversationId}
			aria-label="Send message"
		>
			{#if isSending || $isStreaming}
				<svg class="animate-spin" width="20" height="20" viewBox="0 0 20 20" fill="none">
					<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" opacity="0.25" />
					<path
						d="M10 2C14.4183 2 18 5.58172 18 10"
						stroke="currentColor"
						stroke-width="2"
						stroke-linecap="round"
					/>
				</svg>
			{:else}
				<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
					<path
						d="M2 10L18 2L10 18L8 11L2 10Z"
						stroke="currentColor"
						stroke-width="2"
						stroke-linecap="round"
						stroke-linejoin="round"
					/>
				</svg>
			{/if}
		</button>
	</div>
</div>

<style>
	/* Scrollbar for textarea */
	.textarea-scrollbar::-webkit-scrollbar {
		width: 6px;
	}

	.textarea-scrollbar::-webkit-scrollbar-track {
		background: transparent;
	}

	.textarea-scrollbar::-webkit-scrollbar-thumb {
		background: #d1d1d6;
		border-radius: 3px;
	}
</style>
```

#### 7.3 Chat Sidebar Component

Create `src/lib/components/ChatSidebar.svelte`:

```svelte
<script lang="ts">
	import {
		conversations,
		currentConversationId,
		createNewConversation,
		deleteConversation,
		selectConversation
	} from '$lib/stores/chat-store';
	import { UI_CONFIG } from '$lib/config/constants';

	let isCreating = $state(false);
	let deletingId = $state<string | null>(null);

	async function handleNewChat() {
		if (isCreating) return;

		isCreating = true;
		try {
			await createNewConversation();
		} catch (error) {
			console.error('Failed to create new chat:', error);
		} finally {
			isCreating = false;
		}
	}

	async function handleDelete(conversationId: string, event: Event) {
		event.stopPropagation();

		if (!confirm('Delete this conversation?')) return;

		deletingId = conversationId;
		try {
			await deleteConversation(conversationId);
		} catch (error) {
			console.error('Failed to delete conversation:', error);
		} finally {
			deletingId = null;
		}
	}

	function handleSelect(conversationId: string) {
		selectConversation(conversationId);
	}

	function truncateTitle(title: string, maxLength = UI_CONFIG.MAX_TITLE_LENGTH) {
		if (title.length <= maxLength) return title;
		return title.substring(0, maxLength) + '...';
	}
</script>

<aside
	class="flex h-screen w-70 flex-col overflow-hidden border-r border-gray-200 bg-gray-50 max-md:w-full max-md:max-w-[280px]"
>
	<div class="border-b border-gray-200 p-4">
		<button
			class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-900 transition-all duration-200 hover:border-gray-400 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60"
			onclick={handleNewChat}
			disabled={isCreating}
		>
			<svg
				width="20"
				height="20"
				viewBox="0 0 20 20"
				fill="none"
				xmlns="http://www.w3.org/2000/svg"
			>
				<path d="M10 4V16M4 10H16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
			</svg>
			{isCreating ? 'Creating...' : 'New Chat'}
		</button>
	</div>

	<div class="flex-1 overflow-y-auto p-2">
		{#if $conversations.length === 0}
			<div class="px-4 py-8 text-center text-gray-600">
				<p class="my-2">No conversations yet</p>
				<p class="my-2 text-xs opacity-70">Click "New Chat" to start</p>
			</div>
		{:else}
			{#each $conversations as conversation (conversation.conversation_id)}
				<div
					class="mb-1 flex w-full cursor-pointer items-center justify-between rounded-md border-none bg-transparent px-3 py-3 text-left transition-colors duration-200 hover:bg-gray-200 {$currentConversationId ===
					conversation.conversation_id
						? 'bg-white shadow-sm'
						: ''}"
					role="button"
					tabindex="0"
					onclick={() => handleSelect(conversation.conversation_id)}
					onkeydown={(e) => {
						if (e.key === 'Enter' || e.key === ' ') handleSelect(conversation.conversation_id);
					}}
				>
					<div class="flex min-w-0 flex-1 items-center gap-2.5">
						<svg
							class="shrink-0 text-gray-600"
							width="16"
							height="16"
							viewBox="0 0 16 16"
							fill="none"
							xmlns="http://www.w3.org/2000/svg"
						>
							<path
								d="M2 4C2 2.89543 2.89543 2 4 2H12C13.1046 2 14 2.89543 14 4V10C14 11.1046 13.1046 12 12 12H8L4 14V12H4C2.89543 12 2 11.1046 2 10V4Z"
								stroke="currentColor"
								stroke-width="1.5"
							/>
						</svg>
						<span class="overflow-hidden text-sm text-ellipsis whitespace-nowrap text-gray-900"
							>{truncateTitle(conversation.title)}</span
						>
					</div>

					<button
						class="shrink-0 cursor-pointer rounded border-none bg-transparent p-1 text-gray-500 opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
						style="opacity: {$currentConversationId === conversation.conversation_id ? '1' : ''}"
						onclick={(e) => {
							e.stopPropagation();
							handleDelete(conversation.conversation_id, e);
						}}
						disabled={deletingId === conversation.conversation_id}
						aria-label="Delete conversation"
						tabindex="0"
					>
						{#if deletingId === conversation.conversation_id}
							<svg
								class="animate-spin"
								width="16"
								height="16"
								viewBox="0 0 16 16"
								fill="none"
								xmlns="http://www.w3.org/2000/svg"
							>
								<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="2" opacity="0.25" />
								<path
									d="M8 2C11.3137 2 14 4.68629 14 8"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
								/>
							</svg>
						{:else}
							<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
								<path
									d="M5 2H11M2 4H14M12.6667 4L12.1991 11.0129C12.129 12.065 12.0939 12.5911 11.8667 12.99C11.6666 13.3412 11.3648 13.6235 11.0011 13.7998C10.588 14 10.0607 14 9.00623 14H6.99377C5.93927 14 5.41202 14 4.99889 13.7998C4.63517 13.6235 4.33339 13.3412 4.13332 12.99C3.90607 12.5911 3.871 12.065 3.80086 11.0129L3.33333 4"
									stroke="currentColor"
									stroke-width="1.5"
									stroke-linecap="round"
									stroke-linejoin="round"
								/>
							</svg>
						{/if}
					</button>
				</div>
			{/each}
		{/if}
	</div>
</aside>

<style>
	.w-70 {
		width: 280px;
	}

	/* Show delete button on hover */
	.w-full:hover button[aria-label='Delete conversation'] {
		opacity: 1;
	}
</style>
```

### 8. Building the Pages

#### 8.1 Create Chat Page

Create `src/routes/chat/+page.svelte`:

```svelte
<script lang="ts">
	import ChatMessages from '$lib/components/ChatMessages.svelte';
	import MessageInput from '$lib/components/MessageInput.svelte';
	import { currentConversation, error } from '$lib/stores/chat-store';
</script>

<svelte:head>
	<title>AI Chat Assistant</title>
</svelte:head>

<div class="flex flex-col h-full bg-white">
	<!-- Header -->
	<header class="shrink-0 border-b border-gray-200 bg-white px-6 py-4 max-md:px-4 max-md:pl-16">
		<div class="max-w-3xl mx-auto">
			{#if $currentConversation}
				<h1 class="m-0 text-lg font-semibold text-gray-900 max-md:text-base">
					{$currentConversation.title}
				</h1>
			{:else}
				<h1 class="m-0 text-lg font-semibold text-gray-900 max-md:text-base">AI Chat Assistant</h1>
			{/if}
		</div>
	</header>

	<!-- Error banner -->
	{#if $error}
		<div
			class="shrink-0 bg-red-50 text-red-600 px-6 py-3 flex items-center gap-3 border-b border-red-200"
		>
			<svg class="shrink-0" width="20" height="20" viewBox="0 0 20 20" fill="none">
				<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" />
				<path d="M10 6V10M10 14V14.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
			</svg>
			<span class="text-sm">{$error}</span>
		</div>
	{/if}

	<!-- Messages area -->
	<ChatMessages />

	<!-- Input area -->
	<MessageInput />
</div>
```

#### 8.2 Create Chat Layout

Create `src/routes/chat/+layout.svelte`:

```svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import ChatSidebar from '$lib/components/ChatSidebar.svelte';
	import { loadConversations } from '$lib/stores/chat-store';

	let { children } = $props();
	let sidebarOpen = $state(true);

	onMount(() => {
		// Load conversations on mount
		loadConversations();
	});

	function toggleSidebar() {
		sidebarOpen = !sidebarOpen;
	}
</script>

<div class="relative flex h-screen w-full overflow-hidden">
	<!-- Mobile sidebar toggle -->
	<button
		class="fixed top-4 left-4 z-150 hidden h-10 w-10 cursor-pointer items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-900 shadow-md transition-colors hover:bg-gray-50 max-md:flex md:hidden"
		onclick={toggleSidebar}
		aria-label="Toggle sidebar"
	>
		<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
			<path
				d="M4 6H20M4 12H20M4 18H20"
				stroke="currentColor"
				stroke-width="2"
				stroke-linecap="round"
			/>
		</svg>
	</button>

	<!-- Sidebar -->
	<div
		class="shrink-0 transition-transform duration-300 ease-in-out max-md:fixed max-md:top-0 max-md:left-0 max-md:z-110 max-md:h-screen {sidebarOpen
			? 'max-md:translate-x-0'
			: 'max-md:-translate-x-full'}"
	>
		<ChatSidebar />
	</div>

	<!-- Mobile overlay -->
	{#if sidebarOpen}
		<button
			class="fixed top-0 right-0 bottom-0 left-0 z-100 hidden bg-black/50 max-md:block"
			onclick={toggleSidebar}
			aria-label="Close sidebar"
		></button>
	{/if}

	<!-- Main content -->
	<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
		{@render children()}
	</main>
</div>
```

#### 8.3 Create Home Page

Create `src/routes/+page.svelte`:

```svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import { goto } from '$app/navigation';

	// Redirect to chat page on mount
	onMount(() => {
		goto('/chat');
	});
</script>

<svelte:head>
	<title>AI Chat Assistant</title>
	<meta name="description" content="AI Chat Assistant powered by JamAI Base" />
</svelte:head>

<div class="flex flex-col items-center justify-center h-screen gap-4">
	<div
		class="w-10 h-10 border-4 border-gray-200 border-t-gray-700 rounded-full animate-spin"
	></div>
	<p class="text-gray-600 text-base">Redirecting to chat...</p>
</div>
```

#### 8.4 Create Root Layout

Create `src/routes/+layout.svelte`:

```svelte
<script lang="ts">
	import './layout.css';

	let { children } = $props();
</script>

{@render children()}
```

### 9. Running the Application

#### 9.1 Start Development Server

```bash
npm run dev
```

The application will start at <http://localhost:5173>

#### 9.2 Test the Application

1. **Visit Home Page**
   * Go to <http://localhost:5173>
   * Click "Start Chatting"
2. **Create a Conversation**
   * Click "New Chat" button in the sidebar
   * Wait for conversation to be created
3. **Send Messages**
   * Type a message in the input box
   * Press Enter or click Send button
   * Watch the AI response stream in real-time
4. **Upload Images**
   * Click the attachment icon
   * Select an image file (JPEG, PNG, GIF, or WebP)
   * Add text if desired
   * Send the message
5. **Manage Conversations**
   * Click on any conversation in sidebar to switch
   * Hover over conversation to see Rename and Delete buttons
   * Rename by clicking the edit icon
   * Delete by clicking the trash icon

#### 9.3 First Run

On the first API call, the application will automatically:

1. Connect to JamAI Base using your credentials
2. Create the "simple-chat" Chat Table if it doesn't exist
3. Configure the AI column with the specified model and prompts

Check your terminal logs to see the table creation process.

### 10. How It Works

#### 10.1 Architecture Overview

```
┌─────────────┐
│   Browser   │
│ (SvelteKit) │
└──────┬──────┘
       │
       │ 1. Create Conversation
       ↓
┌─────────────┐
│Conversations│
│  API Route  │
└──────┬──────┘
       │
       │ 2. Initialize Chat Table
       ↓
┌─────────────┐
│  JamAI Base │
│ Chat Table  │
└──────┬──────┘
       │
       │ 3. Upload Image (if any)
       ↓
┌─────────────┐
│  Upload API │
│   Route     │
└──────┬──────┘
       │
       │ 4. Send Message
       ↓
┌─────────────┐
│ Messages API│
│   Route     │
└──────┬──────┘
       │
       │ 5. Stream AI Response
       ↓
┌─────────────┐
│  LLM Model  │
│ (GPT-4o-mini)│
└──────┬──────┘
       │
       │ 6. Real-time Response
       ↓
┌─────────────┐
│   Browser   │
│  (Display)  │
└─────────────┘
```

#### 10.2 Data Flow

1. **Conversation Creation**
   * User clicks "New Chat"
   * API ensures Chat Table exists
   * Creates conversation in JamAI Base
   * Returns conversation ID
2. **Message Sending**
   * User types message and optionally attaches image
   * If image: uploads to JamAI Base, gets URI
   * Sends message to conversation
   * JamAI Base processes through LLM
   * Streams response back to client
3. **Streaming Response**
   * Server creates ReadableStream
   * Chunks are sent as they're generated
   * Client updates UI in real-time via Svelte stores
   * Final message stored in Chat Table

#### 10.3 JamAI Base Chat Table Structure

The "simple-chat" table has the following schema:

| Column Name | Type             | Description              |
| ----------- | ---------------- | ------------------------ |
| Image       | Input (image)    | Optional image from user |
| User        | Input (str)      | User's message text      |
| AI          | LLM Output (str) | AI-generated response    |

Each conversation is stored separately, with messages as rows in the table.

**Chat Table Configuration:**

* **Model**: openai/gpt-4o-mini (fast and cost-effective)
* **System Prompt**: Defines AI behavior
* **Prompt Template**: References user message and image using variables
* **Temperature**: 0.7 (balanced creativity and consistency)
* **Max Tokens**: 2000 (sufficient for most responses)

### 11. Customization

#### 11.1 Modify AI Behavior

To change the AI's personality, edit src/lib/config/constants.ts:

```typescript
export const AGENT_CONFIG = {
  // ...
  SYSTEM_PROMPT:
    "You are a friendly and helpful AI assistant specializing in technical support.",
  TEMPERATURE: 0.5, // Lower for more consistent responses
};
```

#### 11.2 Change LLM Model

You can use different models for different capabilities:

```typescript
export const AGENT_CONFIG = {
  // ...
  MODEL: "openai/gpt-4o", // More capable but more expensive
  // or
  MODEL: "anthropic/claude-3-5-sonnet-20241022", // Claude Sonnet
  // or
  MODEL: "ellm/meta-llama/Llama-3.3-70B-Instruct", // Llama 3.3
};
```

**Model Recommendations:**

* **gpt-4o-mini**: Best balance of cost and performance
* **gpt-4o**: Highest quality, best for complex tasks
* **claude-3-5-sonnet**: Excellent for code and analysis
* **Llama-3.3-70B**: Open-source, cost-effective

#### 11.3 Add File Type Support

To support more file types, edit src/lib/config/constants.ts:

```typescript
export const UPLOAD_CONFIG = {
  MAX_FILE_SIZE: 20 * 1024 * 1024, // Increase to 20MB
  ALLOWED_MIME_TYPES: [
    "image/jpeg",
    "image/png",
    "application/pdf", // Add PDF support
  ],
  ALLOWED_EXTENSIONS: [".jpeg", ".jpg", ".png", ".pdf"],
};
```

#### 11.4 Customize UI Styling

The application uses Tailwind CSS. Customize colors and styles by editing components. For example, to change the primary color:

```svelte
<!-- Change button colors in ChatSidebar.svelte -->
<button class="bg-blue-600 hover:bg-blue-700 text-white ...">
```

### 12. Deployment

#### 12.1 Deploy to Vercel

1. **Push to GitHub**

   ```bash
   git init
   git add .
   git commit -m "Initial commit"
   git remote add origin YOUR_REPO_URL
   git push -u origin main
   ```
2. **Deploy on Vercel**
   * Go to [vercel.com](https://vercel.com)
   * Click "Import Project"
   * Select your GitHub repository
   * Add environment variables:
     * `JAMAI_API_KEY`
     * `JAMAI_PROJECT_ID`
   * Click "Deploy"
3. **Verify Deployment**
   * Visit your deployment URL
   * Test conversation creation and messaging

#### 12.2 Environment Variables in Production

{% hint style="warning" %}
**Never commit** your `.env` file. Always set environment variables in your deployment platform's dashboard.
{% endhint %}

### 13. Troubleshooting

#### Common Issues

**Issue:** "Missing JamAI credentials" error

**Solution:**

* Verify `.env` exists in project root
* Check variable names: `JAMAI_API_KEY` and `JAMAI_PROJECT_ID`
* Restart development server after adding env variables

***

**Issue:** Conversation creation fails

**Solution:**

* Check your JamAI Base credentials are valid
* Ensure Project ID is correct
* Check browser console for detailed error messages

***

**Issue:** Image upload fails

**Solution:**

* Ensure image is under 10MB
* Check file type is supported (JPEG, PNG, GIF, WebP)
* Verify upload endpoint is accessible

***

**Issue:** Streaming response not working

**Solution:**

* Check browser supports Server-Sent Events
* Verify API route returns proper streaming headers
* Check network tab for streaming data

***

**Issue:** Messages not displaying

**Solution:**

* Check conversation ID is valid
* Verify messages API returns data
* Check browser console for errors
* Try refreshing the page

### 14. Best Practices

#### Security

1. **Protect API Keys**
   * Never commit `.env`
   * Use environment variables in production
   * Rotate API keys periodically
2. **Validate Input**
   * Always validate file types and sizes
   * Sanitize user inputs
   * Implement rate limiting for production
3. **Error Handling**
   * Never expose sensitive error details
   * Log errors server-side
   * Provide user-friendly error messages

#### Performance

1. **Optimize API Calls**
   * Use batch operations when possible
   * Implement proper caching strategies
   * Minimize unnecessary re-fetches
2. **Image Optimization**
   * Compress images before upload
   * Use thumbnail URLs for previews
   * Lazy load images in message history
3. **Streaming**
   * Always use streaming for AI responses
   * Handle connection errors gracefully
   * Show loading indicators during streaming

#### Code Quality

1. **TypeScript**
   * Define interfaces for all data structures
   * Avoid `any` types
   * Use strict mode
2. **Components**
   * Keep components focused and reusable
   * Use Svelte 5 runes for reactivity
   * Proper TypeScript types for props
3. **Testing**
   * Add unit tests for utility functions
   * Test API routes
   * Implement E2E tests for critical flows

### 15. Next Steps

#### Enhancements to Try

1. **User Authentication**
   * Add user accounts with SvelteKit auth
   * Personal conversation history per user
   * Multi-user support
2. **Advanced Features**
   * Message search functionality
   * Export conversation to PDF
   * Voice input support
   * Code syntax highlighting
3. **File Support**
   * Support PDF documents
   * Handle multiple file uploads
   * Document analysis capabilities
4. **UI Enhancements**
   * Dark mode toggle
   * Emoji picker
   * Message reactions
   * Typing indicators
5. **Analytics**
   * Track conversation metrics
   * Monitor API usage
   * User engagement analytics

### 16. Resources

* [JamAI Base Documentation](https://docs.jamaibase.com)
* [JamAI Base Chat Tables Guide](https://docs.jamaibase.com/concepts/tables/chat)
* [SvelteKit Documentation](https://kit.svelte.dev/docs)
* [Svelte 5 Documentation](https://svelte.dev/docs/svelte/overview)
* [TypeScript Documentation](https://www.typescriptlang.org/docs)
* [Tailwind CSS Documentation](https://tailwindcss.com/docs)

### 17. Support

If you encounter issues or have questions:

1. Check the [JamAI Base Documentation](https://docs.jamaibase.com)
2. Review this tutorial's troubleshooting section
3. Visit [JamAI Base Community](https://discord.gg/jamaibase)
4. Contact JamAI Base support

### Conclusion

You've successfully built a full-stack AI chat assistant application using:

* **SvelteKit** with file-based routing and SSR
* **Svelte 5** with modern runes API
* **JamAI Base** for AI conversation management
* **TypeScript** for type safety
* **Tailwind CSS 4** for modern UI
* **Real-time Streaming** for instant AI responses

The application demonstrates how to leverage JamAI Base Chat Tables to create sophisticated AI-powered applications with minimal backend complexity. You can now extend this foundation to build more complex conversational AI systems.

Happy building!


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.jamaibase.com/developer-reference/typescript-sdk-documentation/examples/ai-assistant-with-sveltekit.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
