Compare commits

...

1 Commits

Author SHA1 Message Date
Jake Hillion
0fd28dbe0f treefmt: add Svelte file formatting 2026-01-19 14:52:31 +00:00
20 changed files with 9498 additions and 7489 deletions

View File

@@ -1,3 +0,0 @@
{
"useTabs": true
}

View File

@@ -21,6 +21,8 @@
"@types/d3": "^7.4.3",
"@types/node": "^22",
"d3": "^7.9.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
@@ -863,7 +865,6 @@
"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -903,7 +904,6 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -1520,7 +1520,6 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1530,7 +1529,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1943,7 +1941,6 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2651,7 +2648,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2688,6 +2684,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz",
"integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz",
"integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2839,7 +2862,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz",
"integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2984,7 +3006,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3006,7 +3027,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@@ -11,6 +11,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^5.0.0",

View File

@@ -4,7 +4,7 @@
{ pkgs, lib, ... }:
let
# Filter source to only include dashboard directory
src = lib.cleanSourceWith {
dashboardSrc = lib.cleanSourceWith {
src = inputs.self;
filter =
path: type:
@@ -30,7 +30,7 @@
}
# Inject the filtered source
{
deps.dashboardSrc = lib.mkForce "${src}/dashboard";
deps.dashboardSrc = lib.mkForce "${dashboardSrc}/dashboard";
}
];
};
@@ -40,5 +40,14 @@
packages.dashboard = pkgs.runCommand "exo-dashboard" { } ''
cp -r ${dashboardFull}/build $out
'';
# Prettier with svelte plugin for treefmt
packages.prettier-svelte = pkgs.writeShellScriptBin "prettier-svelte" ''
export NODE_PATH="${dashboardFull}/lib/node_modules/exo-dashboard/node_modules"
exec ${pkgs.nodejs}/bin/node \
${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier/bin/prettier.cjs \
--plugin "${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier-plugin-svelte/plugin.js" \
"$@"
'';
};
}

View File

@@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,75 +1,94 @@
<script lang="ts">
import type { ChatUploadedFile } from '$lib/types/files';
import { formatFileSize, getFileCategory } from '$lib/types/files';
import type { ChatUploadedFile } from "$lib/types/files";
import { formatFileSize, getFileCategory } from "$lib/types/files";
interface Props {
files: ChatUploadedFile[];
readonly?: boolean;
onRemove?: (fileId: string) => void;
}
interface Props {
files: ChatUploadedFile[];
readonly?: boolean;
onRemove?: (fileId: string) => void;
}
let { files, readonly = false, onRemove }: Props = $props();
let { files, readonly = false, onRemove }: Props = $props();
function getFileIcon(file: ChatUploadedFile): string {
const category = getFileCategory(file.type, file.name);
switch (category) {
case 'image': return '🖼';
case 'text': return '📄';
case 'pdf': return '📑';
case 'audio': return '🎵';
default: return '📎';
}
}
function getFileIcon(file: ChatUploadedFile): string {
const category = getFileCategory(file.type, file.name);
switch (category) {
case "image":
return "🖼";
case "text":
return "📄";
case "pdf":
return "📑";
case "audio":
return "🎵";
default:
return "📎";
}
}
function truncateName(name: string, maxLen: number = 20): string {
if (name.length <= maxLen) return name;
const ext = name.slice(name.lastIndexOf('.'));
const base = name.slice(0, name.lastIndexOf('.'));
const available = maxLen - ext.length - 3;
return base.slice(0, available) + '...' + ext;
}
function truncateName(name: string, maxLen: number = 20): string {
if (name.length <= maxLen) return name;
const ext = name.slice(name.lastIndexOf("."));
const base = name.slice(0, name.lastIndexOf("."));
const available = maxLen - ext.length - 3;
return base.slice(0, available) + "..." + ext;
}
</script>
{#if files.length > 0}
<div class="flex flex-wrap gap-2 mb-3 px-1">
{#each files as file (file.id)}
<div class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]">
<!-- File preview or icon -->
{#if file.preview && getFileCategory(file.type, file.name) === 'image'}
<img
src={file.preview}
alt={file.name}
class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
/>
{:else}
<span class="text-base">{getFileIcon(file)}</span>
{/if}
<!-- File info -->
<div class="flex flex-col min-w-0">
<span class="text-exo-yellow truncate max-w-[120px]" title={file.name}>
{truncateName(file.name)}
</span>
<span class="text-exo-light-gray text-xs">
{formatFileSize(file.size)}
</span>
</div>
<!-- Remove button -->
{#if !readonly && onRemove}
<button
type="button"
onclick={() => onRemove?.(file.id)}
class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
title="Remove file"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-2 mb-3 px-1">
{#each files as file (file.id)}
<div
class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]"
>
<!-- File preview or icon -->
{#if file.preview && getFileCategory(file.type, file.name) === "image"}
<img
src={file.preview}
alt={file.name}
class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
/>
{:else}
<span class="text-base">{getFileIcon(file)}</span>
{/if}
<!-- File info -->
<div class="flex flex-col min-w-0">
<span
class="text-exo-yellow truncate max-w-[120px]"
title={file.name}
>
{truncateName(file.name)}
</span>
<span class="text-exo-light-gray text-xs">
{formatFileSize(file.size)}
</span>
</div>
<!-- Remove button -->
{#if !readonly && onRemove}
<button
type="button"
onclick={() => onRemove?.(file.id)}
class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
title="Remove file"
>
<svg
class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}

View File

@@ -1,430 +1,525 @@
<script lang="ts">
import { isLoading, sendMessage, selectedChatModel, setSelectedChatModel, instances, ttftMs, tps, totalTokens } from '$lib/stores/app.svelte';
import ChatAttachments from './ChatAttachments.svelte';
import type { ChatUploadedFile } from '$lib/types/files';
import { processUploadedFiles, getAcceptString } from '$lib/types/files';
import {
isLoading,
sendMessage,
selectedChatModel,
setSelectedChatModel,
instances,
ttftMs,
tps,
totalTokens,
} from "$lib/stores/app.svelte";
import ChatAttachments from "./ChatAttachments.svelte";
import type { ChatUploadedFile } from "$lib/types/files";
import { processUploadedFiles, getAcceptString } from "$lib/types/files";
interface Props {
class?: string;
placeholder?: string;
showHelperText?: boolean;
autofocus?: boolean;
showModelSelector?: boolean;
}
interface Props {
class?: string;
placeholder?: string;
showHelperText?: boolean;
autofocus?: boolean;
showModelSelector?: boolean;
}
let {
class: className = '',
placeholder = 'Ask anything',
showHelperText = false,
autofocus = true,
showModelSelector = false
}: Props = $props();
let {
class: className = "",
placeholder = "Ask anything",
showHelperText = false,
autofocus = true,
showModelSelector = false,
}: Props = $props();
let message = $state('');
let textareaRef: HTMLTextAreaElement | undefined = $state();
let fileInputRef: HTMLInputElement | undefined = $state();
let uploadedFiles = $state<ChatUploadedFile[]>([]);
let isDragOver = $state(false);
let loading = $derived(isLoading());
const currentModel = $derived(selectedChatModel());
const instanceData = $derived(instances());
const currentTtft = $derived(ttftMs());
const currentTps = $derived(tps());
const currentTokens = $derived(totalTokens());
// Custom dropdown state
let isModelDropdownOpen = $state(false);
let dropdownButtonRef: HTMLButtonElement | undefined = $state();
let dropdownPosition = $derived(() => {
if (!dropdownButtonRef || !isModelDropdownOpen) return { top: 0, left: 0, width: 0 };
const rect = dropdownButtonRef.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width
};
});
let message = $state("");
let textareaRef: HTMLTextAreaElement | undefined = $state();
let fileInputRef: HTMLInputElement | undefined = $state();
let uploadedFiles = $state<ChatUploadedFile[]>([]);
let isDragOver = $state(false);
let loading = $derived(isLoading());
const currentModel = $derived(selectedChatModel());
const instanceData = $derived(instances());
const currentTtft = $derived(ttftMs());
const currentTps = $derived(tps());
const currentTokens = $derived(totalTokens());
// Accept all supported file types
const acceptString = getAcceptString(['image', 'text', 'pdf']);
// Custom dropdown state
let isModelDropdownOpen = $state(false);
let dropdownButtonRef: HTMLButtonElement | undefined = $state();
let dropdownPosition = $derived(() => {
if (!dropdownButtonRef || !isModelDropdownOpen)
return { top: 0, left: 0, width: 0 };
const rect = dropdownButtonRef.getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
width: rect.width,
};
});
// Extract available models from running instances
const availableModels = $derived(() => {
const models: Array<{id: string, label: string}> = [];
for (const [, instance] of Object.entries(instanceData)) {
const modelId = getInstanceModelId(instance);
if (modelId && modelId !== 'Unknown' && !models.some(m => m.id === modelId)) {
models.push({ id: modelId, label: modelId.split('/').pop() || modelId });
}
}
return models;
});
// Accept all supported file types
const acceptString = getAcceptString(["image", "text", "pdf"]);
// Track previous model IDs to detect newly added models (plain variable to avoid reactive loop)
let previousModelIds: Set<string> = new Set();
// Extract available models from running instances
const availableModels = $derived(() => {
const models: Array<{ id: string; label: string }> = [];
for (const [, instance] of Object.entries(instanceData)) {
const modelId = getInstanceModelId(instance);
if (
modelId &&
modelId !== "Unknown" &&
!models.some((m) => m.id === modelId)
) {
models.push({
id: modelId,
label: modelId.split("/").pop() || modelId,
});
}
}
return models;
});
// Auto-select the first available model if none is selected, if current selection is stale, or if a new model is added
$effect(() => {
const models = availableModels();
const currentModelIds = new Set(models.map(m => m.id));
// Track previous model IDs to detect newly added models (plain variable to avoid reactive loop)
let previousModelIds: Set<string> = new Set();
if (models.length > 0) {
// Find newly added models (in current but not in previous)
const newModels = models.filter(m => !previousModelIds.has(m.id));
// Auto-select the first available model if none is selected, if current selection is stale, or if a new model is added
$effect(() => {
const models = availableModels();
const currentModelIds = new Set(models.map((m) => m.id));
// If no model selected, select the first available
if (!currentModel) {
setSelectedChatModel(models[0].id);
}
// If current model is stale (no longer has a running instance), reset to first available
else if (!models.some(m => m.id === currentModel)) {
setSelectedChatModel(models[0].id);
}
// If a new model was just added, select it
else if (newModels.length > 0 && previousModelIds.size > 0) {
setSelectedChatModel(newModels[0].id);
}
} else {
// No instances running - clear the selected model
if (currentModel) {
setSelectedChatModel('');
}
}
if (models.length > 0) {
// Find newly added models (in current but not in previous)
const newModels = models.filter((m) => !previousModelIds.has(m.id));
// Update previous model IDs for next comparison
previousModelIds = currentModelIds;
});
// If no model selected, select the first available
if (!currentModel) {
setSelectedChatModel(models[0].id);
}
// If current model is stale (no longer has a running instance), reset to first available
else if (!models.some((m) => m.id === currentModel)) {
setSelectedChatModel(models[0].id);
}
// If a new model was just added, select it
else if (newModels.length > 0 && previousModelIds.size > 0) {
setSelectedChatModel(newModels[0].id);
}
} else {
// No instances running - clear the selected model
if (currentModel) {
setSelectedChatModel("");
}
}
function getInstanceModelId(instanceWrapped: unknown): string {
if (!instanceWrapped || typeof instanceWrapped !== 'object') return '';
const keys = Object.keys(instanceWrapped as Record<string, unknown>);
if (keys.length === 1) {
const instance = (instanceWrapped as Record<string, unknown>)[keys[0]] as { shardAssignments?: { modelId?: string } };
return instance?.shardAssignments?.modelId || '';
}
return '';
}
// Update previous model IDs for next comparison
previousModelIds = currentModelIds;
});
async function handleFiles(files: File[]) {
if (files.length === 0) return;
const processed = await processUploadedFiles(files);
uploadedFiles = [...uploadedFiles, ...processed];
}
function getInstanceModelId(instanceWrapped: unknown): string {
if (!instanceWrapped || typeof instanceWrapped !== "object") return "";
const keys = Object.keys(instanceWrapped as Record<string, unknown>);
if (keys.length === 1) {
const instance = (instanceWrapped as Record<string, unknown>)[
keys[0]
] as {
shardAssignments?: { modelId?: string };
};
return instance?.shardAssignments?.modelId || "";
}
return "";
}
function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(Array.from(input.files));
input.value = ''; // Reset for next selection
}
}
async function handleFiles(files: File[]) {
if (files.length === 0) return;
const processed = await processUploadedFiles(files);
uploadedFiles = [...uploadedFiles, ...processed];
}
function handleFileRemove(fileId: string) {
uploadedFiles = uploadedFiles.filter(f => f.id !== fileId);
}
function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(Array.from(input.files));
input.value = ""; // Reset for next selection
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
handleFiles(files);
return;
}
// Handle long text paste as file
const text = event.clipboardData.getData('text/plain');
if (text.length > 2500) {
event.preventDefault();
const textFile = new File([text], 'pasted-text.txt', { type: 'text/plain' });
handleFiles([textFile]);
}
}
function handleFileRemove(fileId: string) {
uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragOver = true;
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
function handleDragLeave(event: DragEvent) {
event.preventDefault();
isDragOver = false;
}
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
if (event.dataTransfer?.files) {
handleFiles(Array.from(event.dataTransfer.files));
}
}
if (files.length > 0) {
event.preventDefault();
handleFiles(files);
return;
}
function handleKeydown(event: KeyboardEvent) {
// Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
if (event.isComposing || event.keyCode === 229) {
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
// Handle long text paste as file
const text = event.clipboardData.getData("text/plain");
if (text.length > 2500) {
event.preventDefault();
const textFile = new File([text], "pasted-text.txt", {
type: "text/plain",
});
handleFiles([textFile]);
}
}
function handleSubmit() {
if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
const content = message.trim();
const files = [...uploadedFiles];
message = '';
uploadedFiles = [];
resetTextareaHeight();
sendMessage(content, files);
// Refocus the textarea after sending
setTimeout(() => textareaRef?.focus(), 10);
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragOver = true;
}
function handleInput() {
if (!textareaRef) return;
textareaRef.style.height = 'auto';
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + 'px';
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
isDragOver = false;
}
function resetTextareaHeight() {
if (textareaRef) {
textareaRef.style.height = 'auto';
}
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
function openFilePicker() {
fileInputRef?.click();
}
if (event.dataTransfer?.files) {
handleFiles(Array.from(event.dataTransfer.files));
}
}
// Track previous loading state to detect when loading completes
let wasLoading = $state(false);
$effect(() => {
if (autofocus && textareaRef) {
setTimeout(() => textareaRef?.focus(), 10);
}
});
// Refocus after loading completes (AI response finished)
$effect(() => {
if (wasLoading && !loading && textareaRef) {
setTimeout(() => textareaRef?.focus(), 50);
}
wasLoading = loading;
});
function handleKeydown(event: KeyboardEvent) {
// Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
if (event.isComposing || event.keyCode === 229) {
return;
}
const canSend = $derived(message.trim().length > 0 || uploadedFiles.length > 0);
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
function handleSubmit() {
if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
const content = message.trim();
const files = [...uploadedFiles];
message = "";
uploadedFiles = [];
resetTextareaHeight();
sendMessage(content, files);
// Refocus the textarea after sending
setTimeout(() => textareaRef?.focus(), 10);
}
function handleInput() {
if (!textareaRef) return;
textareaRef.style.height = "auto";
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
}
function resetTextareaHeight() {
if (textareaRef) {
textareaRef.style.height = "auto";
}
}
function openFilePicker() {
fileInputRef?.click();
}
// Track previous loading state to detect when loading completes
let wasLoading = $state(false);
$effect(() => {
if (autofocus && textareaRef) {
setTimeout(() => textareaRef?.focus(), 10);
}
});
// Refocus after loading completes (AI response finished)
$effect(() => {
if (wasLoading && !loading && textareaRef) {
setTimeout(() => textareaRef?.focus(), 50);
}
wasLoading = loading;
});
const canSend = $derived(
message.trim().length > 0 || uploadedFiles.length > 0,
);
</script>
<!-- Hidden file input -->
<input
bind:this={fileInputRef}
type="file"
accept={acceptString}
multiple
class="hidden"
onchange={handleFileInputChange}
bind:this={fileInputRef}
type="file"
accept={acceptString}
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<form
onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}
class="w-full {className}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="w-full {className}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div
class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver ? 'ring-2 ring-exo-yellow ring-opacity-50' : ''}"
>
<!-- Top accent line -->
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"></div>
<!-- Drag overlay -->
{#if isDragOver}
<div class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center">
<div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
DROP FILES HERE
</div>
</div>
{/if}
<!-- Model selector (when enabled) -->
{#if showModelSelector && availableModels().length > 0}
<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30">
<div class="flex items-center gap-2 flex-1">
<span class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0">MODEL:</span>
<!-- Custom dropdown -->
<div class="relative flex-1 max-w-xs">
<button
bind:this={dropdownButtonRef}
type="button"
onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
>
{#if availableModels().find(m => m.id === currentModel)}
<span class="text-exo-yellow truncate">{availableModels().find(m => m.id === currentModel)?.label}</span>
{:else if availableModels().length > 0}
<span class="text-exo-yellow truncate">{availableModels()[0].label}</span>
{:else}
<span class="text-exo-light-gray/50">— SELECT MODEL —</span>
{/if}
</button>
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
<svg class="w-3 h-3 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{#if isModelDropdownOpen}
<!-- Backdrop to close dropdown -->
<button
type="button"
class="fixed inset-0 z-[9998] cursor-default"
onclick={() => isModelDropdownOpen = false}
aria-label="Close dropdown"
></button>
<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
<div
class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
style="bottom: calc(100vh - {dropdownPosition().top}px + 4px); left: {dropdownPosition().left}px; width: {dropdownPosition().width}px;"
>
<div class="py-1">
{#each availableModels() as model}
<button
type="button"
onclick={() => {
setSelectedChatModel(model.id);
isModelDropdownOpen = false;
}}
class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
currentModel === model.id
? 'bg-transparent text-exo-yellow'
: 'text-exo-light-gray hover:text-exo-yellow'
}"
>
{#if currentModel === model.id}
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{:else}
<span class="w-3"></span>
{/if}
<span class="truncate">{model.label}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Performance stats -->
{#if currentTtft !== null || currentTps !== null}
<div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
{#if currentTtft !== null}
<span class="text-exo-light-gray">
<span class="text-white/70">TTFT</span> <span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
</span>
{/if}
{#if currentTps !== null}
<span class="text-exo-light-gray">
<span class="text-white/70">TPS</span> <span class="text-exo-yellow">{currentTps.toFixed(1)}</span> <span class="text-white/60">tok/s</span>
<span class="text-white/50">({(1000 / currentTps).toFixed(1)} ms/tok)</span>
</span>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Attached files preview -->
{#if uploadedFiles.length > 0}
<div class="px-3 pt-3">
<ChatAttachments
files={uploadedFiles}
onRemove={handleFileRemove}
/>
</div>
{/if}
<!-- Input area -->
<div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
<!-- Attach file button -->
<button
type="button"
onclick={openFilePicker}
disabled={loading}
class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
title="Attach file"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
</button>
<!-- Terminal prompt -->
<span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"></span>
<textarea
bind:this={textareaRef}
bind:value={message}
onkeydown={handleKeydown}
oninput={handleInput}
onpaste={handlePaste}
{placeholder}
disabled={loading}
rows={1}
class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
style="min-height: 28px; max-height: 150px;"
></textarea>
<button
type="submit"
disabled={!canSend || loading}
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
{!canSend || loading
? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
aria-label="Send message"
>
{#if loading}
<span class="inline-flex items-center gap-1 sm:gap-2">
<span class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"></span>
<span class="hidden sm:inline">PROCESSING</span>
<span class="sm:hidden">...</span>
</span>
{:else}
SEND
{/if}
</button>
</div>
<!-- Bottom accent line -->
<div class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"></div>
</div>
{#if showHelperText}
<p class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">
<kbd class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50">ENTER</kbd>
<span class="mx-0.5 sm:mx-1">TO SEND</span>
<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
<kbd class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50">SHIFT+ENTER</kbd>
<span class="mx-0.5 sm:mx-1">NEW LINE</span>
<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
<span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
</p>
{/if}
<div
class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver
? 'ring-2 ring-exo-yellow ring-opacity-50'
: ''}"
>
<!-- Top accent line -->
<div
class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"
></div>
<!-- Drag overlay -->
{#if isDragOver}
<div
class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center"
>
<div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
DROP FILES HERE
</div>
</div>
{/if}
<!-- Model selector (when enabled) -->
{#if showModelSelector && availableModels().length > 0}
<div
class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30"
>
<div class="flex items-center gap-2 flex-1">
<span
class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0"
>MODEL:</span
>
<!-- Custom dropdown -->
<div class="relative flex-1 max-w-xs">
<button
bind:this={dropdownButtonRef}
type="button"
onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen
? 'border-exo-yellow/70'
: ''}"
>
{#if availableModels().find((m) => m.id === currentModel)}
<span class="text-exo-yellow truncate"
>{availableModels().find((m) => m.id === currentModel)
?.label}</span
>
{:else if availableModels().length > 0}
<span class="text-exo-yellow truncate"
>{availableModels()[0].label}</span
>
{:else}
<span class="text-exo-light-gray/50">— SELECT MODEL —</span>
{/if}
</button>
<div
class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
? 'rotate-180'
: ''}"
>
<svg
class="w-3 h-3 text-exo-yellow/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
{#if isModelDropdownOpen}
<!-- Backdrop to close dropdown -->
<button
type="button"
class="fixed inset-0 z-[9998] cursor-default"
onclick={() => (isModelDropdownOpen = false)}
aria-label="Close dropdown"
></button>
<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
<div
class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
style="bottom: calc(100vh - {dropdownPosition()
.top}px + 4px); left: {dropdownPosition()
.left}px; width: {dropdownPosition().width}px;"
>
<div class="py-1">
{#each availableModels() as model}
<button
type="button"
onclick={() => {
setSelectedChatModel(model.id);
isModelDropdownOpen = false;
}}
class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {currentModel ===
model.id
? 'bg-transparent text-exo-yellow'
: 'text-exo-light-gray hover:text-exo-yellow'}"
>
{#if currentModel === model.id}
<svg
class="w-3 h-3 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
{:else}
<span class="w-3"></span>
{/if}
<span class="truncate">{model.label}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Performance stats -->
{#if currentTtft !== null || currentTps !== null}
<div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
{#if currentTtft !== null}
<span class="text-exo-light-gray">
<span class="text-white/70">TTFT</span>
<span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
</span>
{/if}
{#if currentTps !== null}
<span class="text-exo-light-gray">
<span class="text-white/70">TPS</span>
<span class="text-exo-yellow">{currentTps.toFixed(1)}</span>
<span class="text-white/60">tok/s</span>
<span class="text-white/50"
>({(1000 / currentTps).toFixed(1)} ms/tok)</span
>
</span>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Attached files preview -->
{#if uploadedFiles.length > 0}
<div class="px-3 pt-3">
<ChatAttachments files={uploadedFiles} onRemove={handleFileRemove} />
</div>
{/if}
<!-- Input area -->
<div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
<!-- Attach file button -->
<button
type="button"
onclick={openFilePicker}
disabled={loading}
class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
title="Attach file"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
</button>
<!-- Terminal prompt -->
<span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"
></span
>
<textarea
bind:this={textareaRef}
bind:value={message}
onkeydown={handleKeydown}
oninput={handleInput}
onpaste={handlePaste}
{placeholder}
disabled={loading}
rows={1}
class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
style="min-height: 28px; max-height: 150px;"
></textarea>
<button
type="submit"
disabled={!canSend || loading}
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
{!canSend || loading
? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
aria-label="Send message"
>
{#if loading}
<span class="inline-flex items-center gap-1 sm:gap-2">
<span
class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
></span>
<span class="hidden sm:inline">PROCESSING</span>
<span class="sm:hidden">...</span>
</span>
{:else}
SEND
{/if}
</button>
</div>
<!-- Bottom accent line -->
<div
class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"
></div>
</div>
{#if showHelperText}
<p
class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
>
<kbd
class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
>ENTER</kbd
>
<span class="mx-0.5 sm:mx-1">TO SEND</span>
<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
<kbd
class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
>SHIFT+ENTER</kbd
>
<span class="mx-0.5 sm:mx-1">NEW LINE</span>
<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
<span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
</p>
{/if}
</form>

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +1,129 @@
<script lang="ts">
import { browser } from '$app/environment';
import { browser } from "$app/environment";
export let showHome = true;
export let onHome: (() => void) | null = null;
export let showSidebarToggle = false;
export let sidebarVisible = true;
export let onToggleSidebar: (() => void) | null = null;
export let showHome = true;
export let onHome: (() => void) | null = null;
export let showSidebarToggle = false;
export let sidebarVisible = true;
export let onToggleSidebar: (() => void) | null = null;
function handleHome(): void {
if (onHome) {
onHome();
return;
}
if (browser) {
// Hash router: send to root
window.location.hash = '/';
}
}
function handleHome(): void {
if (onHome) {
onHome();
return;
}
if (browser) {
// Hash router: send to root
window.location.hash = "/";
}
}
function handleToggleSidebar(): void {
if (onToggleSidebar) {
onToggleSidebar();
}
}
function handleToggleSidebar(): void {
if (onToggleSidebar) {
onToggleSidebar();
}
}
</script>
<header class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray">
<!-- Left: Sidebar Toggle -->
{#if showSidebarToggle}
<div class="absolute left-6 top-1/2 -translate-y-1/2">
<button
onclick={handleToggleSidebar}
class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
title={sidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
>
<svg class="w-5 h-5 {sidebarVisible ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
{#if sidebarVisible}
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
{/if}
</svg>
</button>
</div>
{/if}
<header
class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray"
>
<!-- Left: Sidebar Toggle -->
{#if showSidebarToggle}
<div class="absolute left-6 top-1/2 -translate-y-1/2">
<button
onclick={handleToggleSidebar}
class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
>
<svg
class="w-5 h-5 {sidebarVisible
? 'text-exo-yellow'
: 'text-exo-medium-gray'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{#if sidebarVisible}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
{/if}
</svg>
</button>
</div>
{/if}
<!-- Center: Logo (clickable to go home) -->
<button
onclick={handleHome}
class="bg-transparent border-none outline-none focus:outline-none transition-opacity duration-200 hover:opacity-90 {showHome ? 'cursor-pointer' : 'cursor-default'}"
title={showHome ? 'Go to home' : ''}
disabled={!showHome}
>
<img src="/exo-logo.png" alt="EXO" class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]" />
</button>
<!-- Center: Logo (clickable to go home) -->
<button
onclick={handleHome}
class="bg-transparent border-none outline-none focus:outline-none transition-opacity duration-200 hover:opacity-90 {showHome
? 'cursor-pointer'
: 'cursor-default'}"
title={showHome ? "Go to home" : ""}
disabled={!showHome}
>
<img
src="/exo-logo.png"
alt="EXO"
class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]"
/>
</button>
<!-- Right: Home + Downloads -->
<div class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4">
{#if showHome}
<button
onclick={handleHome}
class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
title="Back to topology view"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</button>
{/if}
<a
href="/#/downloads"
class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
title="View downloads overview"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12" />
<path d="M7 12l5 5 5-5" />
<path d="M5 21h14" />
</svg>
Downloads
</a>
</div>
<!-- Right: Home + Downloads -->
<div
class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
>
{#if showHome}
<button
onclick={handleHome}
class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
title="Back to topology view"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
Home
</button>
{/if}
<a
href="/#/downloads"
class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
title="View downloads overview"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v12" />
<path d="M7 12l5 5 5-5" />
<path d="M5 21h14" />
</svg>
Downloads
</a>
</div>
</header>

View File

@@ -1,35 +1,35 @@
<script lang="ts">
import { marked } from 'marked';
import hljs from 'highlight.js';
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { browser } from '$app/environment';
import { marked } from "marked";
import hljs from "highlight.js";
import katex from "katex";
import "katex/dist/katex.min.css";
import { browser } from "$app/environment";
interface Props {
content: string;
class?: string;
}
interface Props {
content: string;
class?: string;
}
let { content, class: className = '' }: Props = $props();
let { content, class: className = "" }: Props = $props();
let containerRef = $state<HTMLDivElement>();
let processedHtml = $state('');
let containerRef = $state<HTMLDivElement>();
let processedHtml = $state("");
// Configure marked with syntax highlighting
marked.setOptions({
gfm: true,
breaks: true
});
// Configure marked with syntax highlighting
marked.setOptions({
gfm: true,
breaks: true,
});
// Custom renderer for code blocks
const renderer = new marked.Renderer();
// Custom renderer for code blocks
const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(text, { language }).value;
const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
return `
return `
<div class="code-block-wrapper">
<div class="code-block-header">
<span class="code-language">${language}</span>
@@ -43,409 +43,417 @@
<pre><code class="hljs language-${language}" data-code-id="${codeId}">${highlighted}</code></pre>
</div>
`;
};
};
// Inline code
renderer.codespan = function ({ text }: { text: string }) {
return `<code class="inline-code">${text}</code>`;
};
// Inline code
renderer.codespan = function ({ text }: { text: string }) {
return `<code class="inline-code">${text}</code>`;
};
marked.use({ renderer });
marked.use({ renderer });
/**
* Preprocess LaTeX: convert \(...\) to $...$ and \[...\] to $$...$$
* Also protect code blocks from LaTeX processing
*/
function preprocessLaTeX(text: string): string {
// Protect code blocks
const codeBlocks: string[] = [];
let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => {
codeBlocks.push(match);
return `<<CODE_${codeBlocks.length - 1}>>`;
});
/**
* Preprocess LaTeX: convert \(...\) to $...$ and \[...\] to $$...$$
* Also protect code blocks from LaTeX processing
*/
function preprocessLaTeX(text: string): string {
// Protect code blocks
const codeBlocks: string[] = [];
let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => {
codeBlocks.push(match);
return `<<CODE_${codeBlocks.length - 1}>>`;
});
// Convert \(...\) to $...$
processed = processed.replace(/\\\((.+?)\\\)/g, '$$$1$');
// Convert \[...\] to $$...$$
processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, '$$$$$1$$$$');
// Convert \(...\) to $...$
processed = processed.replace(/\\\((.+?)\\\)/g, "$$$1$");
// Restore code blocks
processed = processed.replace(/<<CODE_(\d+)>>/g, (_, index) => codeBlocks[parseInt(index)]);
// Convert \[...\] to $$...$$
processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, "$$$$$1$$$$");
return processed;
}
// Restore code blocks
processed = processed.replace(
/<<CODE_(\d+)>>/g,
(_, index) => codeBlocks[parseInt(index)],
);
/**
* Render math expressions with KaTeX after HTML is generated
*/
function renderMath(html: string): string {
// Render display math ($$...$$)
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
try {
return katex.renderToString(math.trim(), {
displayMode: true,
throwOnError: false,
output: 'html'
});
} catch {
return `<span class="math-error">$$${math}$$</span>`;
}
});
return processed;
}
// Render inline math ($...$) but avoid matching currency like $5
html = html.replace(/\$([^\$\n]+?)\$/g, (match, math) => {
// Skip if it looks like currency ($ followed by number)
if (/^\d/.test(math.trim())) {
return match;
}
try {
return katex.renderToString(math.trim(), {
displayMode: false,
throwOnError: false,
output: 'html'
});
} catch {
return `<span class="math-error">$${math}$</span>`;
}
});
/**
* Render math expressions with KaTeX after HTML is generated
*/
function renderMath(html: string): string {
// Render display math ($$...$$)
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
try {
return katex.renderToString(math.trim(), {
displayMode: true,
throwOnError: false,
output: "html",
});
} catch {
return `<span class="math-error">$$${math}$$</span>`;
}
});
return html;
}
// Render inline math ($...$) but avoid matching currency like $5
html = html.replace(/\$([^\$\n]+?)\$/g, (match, math) => {
// Skip if it looks like currency ($ followed by number)
if (/^\d/.test(math.trim())) {
return match;
}
try {
return katex.renderToString(math.trim(), {
displayMode: false,
throwOnError: false,
output: "html",
});
} catch {
return `<span class="math-error">$${math}$</span>`;
}
});
function processMarkdown(text: string): string {
try {
// Preprocess LaTeX notation
const preprocessed = preprocessLaTeX(text);
// Parse markdown
let html = marked.parse(preprocessed) as string;
// Render math expressions
html = renderMath(html);
return html;
} catch (error) {
console.error('Markdown processing error:', error);
return text.replace(/\n/g, '<br>');
}
}
return html;
}
async function handleCopyClick(event: Event) {
const target = event.currentTarget as HTMLButtonElement;
const encodedCode = target.getAttribute('data-code');
if (!encodedCode) return;
function processMarkdown(text: string): string {
try {
// Preprocess LaTeX notation
const preprocessed = preprocessLaTeX(text);
// Parse markdown
let html = marked.parse(preprocessed) as string;
// Render math expressions
html = renderMath(html);
return html;
} catch (error) {
console.error("Markdown processing error:", error);
return text.replace(/\n/g, "<br>");
}
}
const code = decodeURIComponent(encodedCode);
async function handleCopyClick(event: Event) {
const target = event.currentTarget as HTMLButtonElement;
const encodedCode = target.getAttribute("data-code");
if (!encodedCode) return;
try {
await navigator.clipboard.writeText(code);
// Show copied feedback
const originalHtml = target.innerHTML;
target.innerHTML = `
const code = decodeURIComponent(encodedCode);
try {
await navigator.clipboard.writeText(code);
// Show copied feedback
const originalHtml = target.innerHTML;
target.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5"/>
</svg>
`;
target.classList.add('copied');
setTimeout(() => {
target.innerHTML = originalHtml;
target.classList.remove('copied');
}, 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
}
target.classList.add("copied");
setTimeout(() => {
target.innerHTML = originalHtml;
target.classList.remove("copied");
}, 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
}
function setupCopyButtons() {
if (!containerRef || !browser) return;
function setupCopyButtons() {
if (!containerRef || !browser) return;
const buttons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
for (const button of buttons) {
if (button.dataset.listenerBound !== 'true') {
button.dataset.listenerBound = 'true';
button.addEventListener('click', handleCopyClick);
}
}
}
const buttons =
containerRef.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
for (const button of buttons) {
if (button.dataset.listenerBound !== "true") {
button.dataset.listenerBound = "true";
button.addEventListener("click", handleCopyClick);
}
}
}
$effect(() => {
if (content) {
processedHtml = processMarkdown(content);
} else {
processedHtml = '';
}
});
$effect(() => {
if (content) {
processedHtml = processMarkdown(content);
} else {
processedHtml = "";
}
});
$effect(() => {
if (containerRef && processedHtml) {
setupCopyButtons();
}
});
$effect(() => {
if (containerRef && processedHtml) {
setupCopyButtons();
}
});
</script>
<div bind:this={containerRef} class="markdown-content {className}">
{@html processedHtml}
{@html processedHtml}
</div>
<style>
.markdown-content {
line-height: 1.6;
}
.markdown-content {
line-height: 1.6;
}
/* Paragraphs */
.markdown-content :global(p) {
margin-bottom: 1rem;
}
/* Paragraphs */
.markdown-content :global(p) {
margin-bottom: 1rem;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
/* Headers */
.markdown-content :global(h1) {
font-size: 1.5rem;
font-weight: 700;
margin: 1.5rem 0 0.75rem 0;
color: var(--exo-yellow, #ffd700);
}
/* Headers */
.markdown-content :global(h1) {
font-size: 1.5rem;
font-weight: 700;
margin: 1.5rem 0 0.75rem 0;
color: var(--exo-yellow, #ffd700);
}
.markdown-content :global(h2) {
font-size: 1.25rem;
font-weight: 600;
margin: 1.25rem 0 0.5rem 0;
color: var(--exo-yellow, #ffd700);
}
.markdown-content :global(h2) {
font-size: 1.25rem;
font-weight: 600;
margin: 1.25rem 0 0.5rem 0;
color: var(--exo-yellow, #ffd700);
}
.markdown-content :global(h3) {
font-size: 1.125rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.markdown-content :global(h3) {
font-size: 1.125rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.markdown-content :global(h4),
.markdown-content :global(h5),
.markdown-content :global(h6) {
font-size: 1rem;
font-weight: 600;
margin: 0.75rem 0 0.25rem 0;
}
.markdown-content :global(h4),
.markdown-content :global(h5),
.markdown-content :global(h6) {
font-size: 1rem;
font-weight: 600;
margin: 0.75rem 0 0.25rem 0;
}
/* Bold and italic */
.markdown-content :global(strong) {
font-weight: 600;
}
/* Bold and italic */
.markdown-content :global(strong) {
font-weight: 600;
}
.markdown-content :global(em) {
font-style: italic;
}
.markdown-content :global(em) {
font-style: italic;
}
/* Inline code */
.markdown-content :global(.inline-code) {
background: rgba(255, 215, 0, 0.1);
color: var(--exo-yellow, #ffd700);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.875em;
}
/* Inline code */
.markdown-content :global(.inline-code) {
background: rgba(255, 215, 0, 0.1);
color: var(--exo-yellow, #ffd700);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
font-size: 0.875em;
}
/* Links */
.markdown-content :global(a) {
color: var(--exo-yellow, #ffd700);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Links */
.markdown-content :global(a) {
color: var(--exo-yellow, #ffd700);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-content :global(a:hover) {
opacity: 0.8;
}
.markdown-content :global(a:hover) {
opacity: 0.8;
}
/* Lists */
.markdown-content :global(ul) {
list-style-type: disc;
margin-left: 1.5rem;
margin-bottom: 1rem;
}
/* Lists */
.markdown-content :global(ul) {
list-style-type: disc;
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-content :global(ol) {
list-style-type: decimal;
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-content :global(ol) {
list-style-type: decimal;
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-content :global(li) {
margin-bottom: 0.25rem;
}
.markdown-content :global(li) {
margin-bottom: 0.25rem;
}
.markdown-content :global(li::marker) {
color: var(--exo-light-gray, #9ca3af);
}
.markdown-content :global(li::marker) {
color: var(--exo-light-gray, #9ca3af);
}
/* Blockquotes */
.markdown-content :global(blockquote) {
border-left: 3px solid var(--exo-yellow, #ffd700);
padding: 0.5rem 1rem;
margin: 1rem 0;
background: rgba(255, 215, 0, 0.05);
border-radius: 0 0.25rem 0.25rem 0;
}
/* Blockquotes */
.markdown-content :global(blockquote) {
border-left: 3px solid var(--exo-yellow, #ffd700);
padding: 0.5rem 1rem;
margin: 1rem 0;
background: rgba(255, 215, 0, 0.05);
border-radius: 0 0.25rem 0.25rem 0;
}
/* Tables */
.markdown-content :global(table) {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
font-size: 0.875rem;
}
/* Tables */
.markdown-content :global(table) {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
font-size: 0.875rem;
}
.markdown-content :global(th) {
background: rgba(255, 215, 0, 0.1);
border: 1px solid rgba(255, 215, 0, 0.2);
padding: 0.5rem;
text-align: left;
font-weight: 600;
}
.markdown-content :global(th) {
background: rgba(255, 215, 0, 0.1);
border: 1px solid rgba(255, 215, 0, 0.2);
padding: 0.5rem;
text-align: left;
font-weight: 600;
}
.markdown-content :global(td) {
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.5rem;
}
.markdown-content :global(td) {
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.5rem;
}
/* Horizontal rule */
.markdown-content :global(hr) {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 1.5rem 0;
}
/* Horizontal rule */
.markdown-content :global(hr) {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 1.5rem 0;
}
/* Code block wrapper */
.markdown-content :global(.code-block-wrapper) {
margin: 1rem 0;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid rgba(255, 215, 0, 0.2);
background: rgba(0, 0, 0, 0.4);
}
/* Code block wrapper */
.markdown-content :global(.code-block-wrapper) {
margin: 1rem 0;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid rgba(255, 215, 0, 0.2);
background: rgba(0, 0, 0, 0.4);
}
.markdown-content :global(.code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: rgba(255, 215, 0, 0.05);
border-bottom: 1px solid rgba(255, 215, 0, 0.1);
}
.markdown-content :global(.code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: rgba(255, 215, 0, 0.05);
border-bottom: 1px solid rgba(255, 215, 0, 0.1);
}
.markdown-content :global(.code-language) {
color: var(--exo-yellow, #ffd700);
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
}
.markdown-content :global(.code-language) {
color: var(--exo-yellow, #ffd700);
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
}
.markdown-content :global(.copy-code-btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: none;
color: var(--exo-light-gray, #9ca3af);
cursor: pointer;
transition: color 0.2s;
border-radius: 0.25rem;
}
.markdown-content :global(.copy-code-btn) {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: none;
color: var(--exo-light-gray, #9ca3af);
cursor: pointer;
transition: color 0.2s;
border-radius: 0.25rem;
}
.markdown-content :global(.copy-code-btn:hover) {
color: var(--exo-yellow, #ffd700);
}
.markdown-content :global(.copy-code-btn:hover) {
color: var(--exo-yellow, #ffd700);
}
.markdown-content :global(.copy-code-btn.copied) {
color: #22c55e;
}
.markdown-content :global(.copy-code-btn.copied) {
color: #22c55e;
}
.markdown-content :global(.code-block-wrapper pre) {
margin: 0;
padding: 1rem;
overflow-x: auto;
background: transparent;
}
.markdown-content :global(.code-block-wrapper pre) {
margin: 0;
padding: 1rem;
overflow-x: auto;
background: transparent;
}
.markdown-content :global(.code-block-wrapper code) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.5;
background: transparent;
}
.markdown-content :global(.code-block-wrapper code) {
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.5;
background: transparent;
}
/* Syntax highlighting - dark theme matching EXO style */
.markdown-content :global(.hljs) {
color: #e5e7eb;
}
/* Syntax highlighting - dark theme matching EXO style */
.markdown-content :global(.hljs) {
color: #e5e7eb;
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-literal),
.markdown-content :global(.hljs-section),
.markdown-content :global(.hljs-link) {
color: #c084fc;
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-literal),
.markdown-content :global(.hljs-section),
.markdown-content :global(.hljs-link) {
color: #c084fc;
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-title),
.markdown-content :global(.hljs-name),
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-attribute),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet),
.markdown-content :global(.hljs-addition),
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-tag),
.markdown-content :global(.hljs-template-variable) {
color: #fbbf24;
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-title),
.markdown-content :global(.hljs-name),
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-attribute),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet),
.markdown-content :global(.hljs-addition),
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-tag),
.markdown-content :global(.hljs-template-variable) {
color: #fbbf24;
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote),
.markdown-content :global(.hljs-deletion),
.markdown-content :global(.hljs-meta) {
color: #6b7280;
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote),
.markdown-content :global(.hljs-deletion),
.markdown-content :global(.hljs-meta) {
color: #6b7280;
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-regexp),
.markdown-content :global(.hljs-literal),
.markdown-content :global(.hljs-built_in) {
color: #34d399;
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-regexp),
.markdown-content :global(.hljs-literal),
.markdown-content :global(.hljs-built_in) {
color: #34d399;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-class .hljs-title) {
color: #60a5fa;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-class .hljs-title) {
color: #60a5fa;
}
/* KaTeX math styling */
.markdown-content :global(.katex) {
font-size: 1.1em;
}
/* KaTeX math styling */
.markdown-content :global(.katex) {
font-size: 1.1em;
}
.markdown-content :global(.katex-display) {
margin: 1rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0;
}
.markdown-content :global(.katex-display) {
margin: 1rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0;
}
.markdown-content :global(.katex-display > .katex) {
text-align: center;
}
.markdown-content :global(.katex-display > .katex) {
text-align: center;
}
.markdown-content :global(.math-error) {
color: #f87171;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.875em;
background: rgba(248, 113, 113, 0.1);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.markdown-content :global(.math-error) {
color: #f87171;
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
font-size: 0.875em;
background: rgba(248, 113, 113, 0.1);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
</style>

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,89 +3,89 @@
*/
export interface ChatUploadedFile {
id: string;
name: string;
size: number;
type: string;
file: File;
preview?: string;
textContent?: string;
id: string;
name: string;
size: number;
type: string;
file: File;
preview?: string;
textContent?: string;
}
export interface ChatAttachment {
type: "image" | "text" | "pdf" | "audio";
name: string;
content?: string;
base64Url?: string;
mimeType?: string;
type: "image" | "text" | "pdf" | "audio";
name: string;
content?: string;
base64Url?: string;
mimeType?: string;
}
export type FileCategory = "image" | "text" | "pdf" | "audio" | "unknown";
export const IMAGE_EXTENSIONS = [
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg",
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg",
];
export const IMAGE_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
];
export const TEXT_EXTENSIONS = [
".txt",
".md",
".json",
".xml",
".yaml",
".yml",
".csv",
".log",
".js",
".ts",
".jsx",
".tsx",
".py",
".java",
".cpp",
".c",
".h",
".css",
".html",
".htm",
".sql",
".sh",
".bat",
".rs",
".go",
".rb",
".php",
".swift",
".kt",
".scala",
".r",
".dart",
".vue",
".svelte",
".txt",
".md",
".json",
".xml",
".yaml",
".yml",
".csv",
".log",
".js",
".ts",
".jsx",
".tsx",
".py",
".java",
".cpp",
".c",
".h",
".css",
".html",
".htm",
".sql",
".sh",
".bat",
".rs",
".go",
".rb",
".php",
".swift",
".kt",
".scala",
".r",
".dart",
".vue",
".svelte",
];
export const TEXT_MIME_TYPES = [
"text/plain",
"text/markdown",
"text/csv",
"text/html",
"text/css",
"application/json",
"application/xml",
"text/xml",
"application/javascript",
"text/javascript",
"application/typescript",
"text/plain",
"text/markdown",
"text/csv",
"text/html",
"text/css",
"application/json",
"application/xml",
"text/xml",
"application/javascript",
"text/javascript",
"application/typescript",
];
export const PDF_EXTENSIONS = [".pdf"];
@@ -93,148 +93,148 @@ export const PDF_MIME_TYPES = ["application/pdf"];
export const AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a"];
export const AUDIO_MIME_TYPES = [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/mp4",
];
/**
* Get file category based on MIME type and extension
*/
export function getFileCategory(
mimeType: string,
fileName: string,
mimeType: string,
fileName: string,
): FileCategory {
const extension = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
const extension = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
if (
IMAGE_MIME_TYPES.includes(mimeType) ||
IMAGE_EXTENSIONS.includes(extension)
) {
return "image";
}
if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
return "pdf";
}
if (
AUDIO_MIME_TYPES.includes(mimeType) ||
AUDIO_EXTENSIONS.includes(extension)
) {
return "audio";
}
if (
TEXT_MIME_TYPES.includes(mimeType) ||
TEXT_EXTENSIONS.includes(extension) ||
mimeType.startsWith("text/")
) {
return "text";
}
return "unknown";
if (
IMAGE_MIME_TYPES.includes(mimeType) ||
IMAGE_EXTENSIONS.includes(extension)
) {
return "image";
}
if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
return "pdf";
}
if (
AUDIO_MIME_TYPES.includes(mimeType) ||
AUDIO_EXTENSIONS.includes(extension)
) {
return "audio";
}
if (
TEXT_MIME_TYPES.includes(mimeType) ||
TEXT_EXTENSIONS.includes(extension) ||
mimeType.startsWith("text/")
) {
return "text";
}
return "unknown";
}
/**
* Get accept string for file input based on categories
*/
export function getAcceptString(categories: FileCategory[]): string {
const accepts: string[] = [];
const accepts: string[] = [];
for (const category of categories) {
switch (category) {
case "image":
accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
break;
case "text":
accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
break;
case "pdf":
accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
break;
case "audio":
accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
break;
}
}
for (const category of categories) {
switch (category) {
case "image":
accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
break;
case "text":
accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
break;
case "pdf":
accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
break;
case "audio":
accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
break;
}
}
return accepts.join(",");
return accepts.join(",");
}
/**
* Format file size for display
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
/**
* Read file as data URL (base64)
*/
export function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
/**
* Read file as text
*/
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
/**
* Process uploaded files into ChatUploadedFile format
*/
export async function processUploadedFiles(
files: File[],
files: File[],
): Promise<ChatUploadedFile[]> {
const results: ChatUploadedFile[] = [];
const results: ChatUploadedFile[] = [];
for (const file of files) {
const id =
Date.now().toString() + Math.random().toString(36).substring(2, 9);
const category = getFileCategory(file.type, file.name);
for (const file of files) {
const id =
Date.now().toString() + Math.random().toString(36).substring(2, 9);
const category = getFileCategory(file.type, file.name);
const base: ChatUploadedFile = {
id,
name: file.name,
size: file.size,
type: file.type,
file,
};
const base: ChatUploadedFile = {
id,
name: file.name,
size: file.size,
type: file.type,
file,
};
try {
if (category === "image") {
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else if (category === "text" || category === "unknown") {
const textContent = await readFileAsText(file);
results.push({ ...base, textContent });
} else if (category === "pdf") {
results.push(base);
} else if (category === "audio") {
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else {
results.push(base);
}
} catch (error) {
console.error("Error processing file:", file.name, error);
results.push(base);
}
}
try {
if (category === "image") {
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else if (category === "text" || category === "unknown") {
const textContent = await readFileAsText(file);
results.push({ ...base, textContent });
} else if (category === "pdf") {
results.push(base);
} else if (category === "audio") {
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else {
results.push(base);
}
} catch (error) {
console.error("Error processing file:", file.name, error);
results.push(base);
}
}
return results;
return results;
}

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
import "../app.css";
let { children } = $props();
</script>
<svelte:head>
<title>EXO</title>
<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
<title>EXO</title>
<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
</svelte:head>
<div class="min-h-screen bg-background text-foreground">
{@render children?.()}
{@render children?.()}
</div>

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,13 @@ import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
"/v1": "http://localhost:52415",
"/state": "http://localhost:52415",
"/models": "http://localhost:52415",
"/instance": "http://localhost:52415",
},
},
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
"/v1": "http://localhost:52415",
"/state": "http://localhost:52415",
"/models": "http://localhost:52415",
"/instance": "http://localhost:52415",
},
},
});

View File

@@ -72,7 +72,8 @@
};
prettier = {
enable = true;
includes = [ "*.ts" ];
package = self'.packages.prettier-svelte;
includes = [ "*.ts" "*.svelte" ];
};
swift-format = {
enable = true;