Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5176a9b
Caching mint info and keysets to reduce mint interactions.
sh1ftred Nov 8, 2025
de97d4d
Fixed last update bug. Now models are selected based on maxBalance in…
sh1ftred Nov 8, 2025
3cec9e4
calculation is done with maXBalance + pending Balances
sh1ftred Nov 8, 2025
072aee7
changed the algorithm for the first model that's picked.
sh1ftred Nov 8, 2025
62dc13d
Fixed a notorious bug that leads to responses being hidden on the fir…
sh1ftred Nov 10, 2025
991bffb
rec models
sh1ftred Nov 10, 2025
8b9a808
model caching and recommended model selection at all times.
sh1ftred Nov 11, 2025
6b4b1ad
Message updates to keep errors. New msw entry. Bug with providerMints…
sh1ftred Nov 11, 2025
ac7493c
Small chaanges balance now does'nt go below the actual balance. but i…
sh1ftred Nov 12, 2025
d69afc9
Fixed the balance bug and also fixed the model id bug with older nodes
sh1ftred Nov 12, 2025
c12cab4
Super stable version.
sh1ftred Nov 12, 2025
68293cf
Image token calculation problems.
sh1ftred Nov 12, 2025
00b56db
Added image tokens calculation.
sh1ftred Nov 12, 2025
b951ee4
made it into a PWA.
sh1ftred Nov 12, 2025
cbd726c
version update
sh1ftred Nov 12, 2025
d1e117d
fixed error where change couldn't be made
sh1ftred Nov 12, 2025
26d1144
added content filtering rendering for images.
sh1ftred Nov 12, 2025
b976bd3
Update icons in manifest files
fanyiy Nov 13, 2025
57a7a60
Update layout and manifest
fanyiy Nov 13, 2025
1d51fae
Updated the image token calculation to reflect the backend logic
sh1ftred Nov 14, 2025
d5329c9
Add image paste functionality to ChatInput component
fanyiy Nov 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "./globals.css";
import ClientProviders from "@/components/ClientProviders";
import { Toaster } from "sonner";
import BitcoinConnectClient from "@/components/bitcoin-connect/BitcoinConnectClient";
import SWUpdater from "@/components/SWUpdater";

const geistSans = Geist({
variable: "--font-geist-sans",
Expand All @@ -18,12 +19,27 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Routstr",
description: "The future of AI access is permissionless, private, and decentralized",
manifest: "/manifest.webmanifest",
icons: {
icon: [
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: "/icons/apple-touch-icon.png",
shortcut: "/icons/icon-192.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Routstr",
},
};

export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
themeColor: "#111111",
};

export default function RootLayout({
Expand All @@ -38,6 +54,7 @@ export default function RootLayout({
suppressHydrationWarning={true}
>
<ClientProviders>
<SWUpdater />
{children}
<Toaster theme="dark" />
<BitcoinConnectClient />
Expand Down
24 changes: 24 additions & 0 deletions app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MetadataRoute } from 'next';

// Required for static export with Next.js when using output: "export"
export const dynamic = 'force-static';

export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Routstr',
short_name: 'Routstr',
description: 'The future of AI access is permissionless, private, and decentralized',
start_url: '/',
scope: '/',
id: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#111111',
icons: [
{ src: '/icons/apple-touch-icon.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
};
}
25 changes: 25 additions & 0 deletions app/offline/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export default function Offline() {
return (
<main
style={{
minHeight: "100dvh",
display: "grid",
placeItems: "center",
backgroundColor: "#181818",
color: "white",
padding: "2rem",
textAlign: "center",
}}
>
<div>
<h1 style={{ fontSize: "2rem", fontWeight: 700, marginBottom: "0.75rem" }}>
Offline
</h1>
<p style={{ opacity: 0.8 }}>
You’re offline. Some features may be unavailable. We’ll reconnect as soon as
you’re back online.
</p>
</div>
</main>
);
}
32 changes: 32 additions & 0 deletions components/SWUpdater.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import { useEffect } from 'react';
import { Workbox } from 'workbox-window';

export default function SWUpdater() {
useEffect(() => {
if (typeof window === 'undefined') return;
if (!('serviceWorker' in navigator)) return;

const wb = new Workbox('/sw.js');
let prompted = false;

wb.addEventListener('waiting', () => {
// TODO: Replace with your own toast/dialog UX. For now, auto-activate.
if (!prompted) {
prompted = true;
wb.messageSkipWaiting();
}
});

wb.addEventListener('controlling', () => {
window.location.reload();
});

wb.register().catch(() => {
// no-op: ignore registration failure in dev or unsupported contexts
});
}, []);

return null;
}
50 changes: 50 additions & 0 deletions components/chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,55 @@ export default function ChatInput({
setUploadedAttachments((prev) => prev.filter((item) => item.id !== id));
};

const handlePaste = async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = event.clipboardData?.items;
if (!items) return;

const imageItems: DataTransferItem[] = [];

// Collect all image items from clipboard
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
imageItems.push(items[i]);
}
}

if (imageItems.length === 0) return;

// Prevent default paste behavior for images
event.preventDefault();

const attachmentsToAdd: { attachment: MessageAttachment; file: File }[] = [];

for (const item of imageItems) {
const file = item.getAsFile();
if (!file) continue;

try {
const dataUrl = await convertFileToBase64(file);
const attachment: MessageAttachment = {
id: createAttachmentId(),
name: file.name || `pasted-image-${Date.now()}.${file.type.split('/')[1]}`,
mimeType: file.type,
size: file.size,
dataUrl,
type: 'image'
};

attachmentsToAdd.push({ attachment, file });
} catch (error) {
console.error('Error converting pasted image to base64:', error);
}
}

if (attachmentsToAdd.length > 0) {
setUploadedAttachments((prev) => [
...prev,
...attachmentsToAdd.map(item => item.attachment)
]);
}
};

return (
<>
{/* Greeting message when centered */}
Expand Down Expand Up @@ -276,6 +325,7 @@ export default function ChatInput({
ref={textareaRef}
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
Expand Down
118 changes: 59 additions & 59 deletions components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useCashuWithXYZ } from '@/hooks/useCashuWithXYZ';
import { loadModelProviderMap, loadDisabledProviders } from '@/utils/storageUtils';
import { parseModelKey, normalizeBaseUrl, upsertCachedProviderModels, getCachedProviderModels, getRequiredSatsForModel, isModelAvailable } from '@/utils/modelUtils';
import { recommendedModels } from '@/lib/recommendedModels';

interface ModelSelectorProps {
selectedModel: Model | null;
Expand Down Expand Up @@ -84,39 +85,7 @@ export default function ModelSelector({
}
}, [models]);

// Normalize base URL to ensure trailing slash and protocol (moved to utils)

// Fetch and cache models for a specific provider base URL
const fetchAndCacheProviderModels = async (baseRaw: string): Promise<void> => {
const base = normalizeBaseUrl(baseRaw);
if (!base) return;
// Avoid duplicate fetches
if (loadingProviderBases.has(base)) return;
setLoadingProviderBases(prev => new Set(prev).add(base));
try {
const res = await fetch(`${base}v1/models`);
if (!res.ok) throw new Error(`Failed to fetch models for ${base}: ${res.status}`);
const json = await res.json();
const list: readonly Model[] = Array.isArray(json?.data) ? json.data : [];
const map: Record<string, Model> = {};
for (const m of list) {
map[m.id] = m;
}
setProviderModelCache(prev => ({ ...prev, [base]: map }));
// Also persist into localStorage modelsFromAllProviders so other parts can read it
upsertCachedProviderModels(base, list as Model[]);
} catch (e) {
console.error(e);
} finally {
setLoadingProviderBases(prev => {
const next = new Set(prev);
next.delete(base);
return next;
});
}
};

// Helpers to parse provider-qualified keys (moved to utils)
// Helpers to parse provider-qualified keys (moved to utils)

// Current model helpers for top-of-list section
const currentConfiguredKeyMemo: string | undefined = useMemo(() => {
Expand Down Expand Up @@ -174,16 +143,6 @@ export default function ModelSelector({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [configuredModels, models, modelProviderMap, currentConfiguredKeyMemo, detailsBaseUrl]);

useEffect(() => {
// Prefetch any needed bases not yet cached
for (const base of neededProviderBases) {
if (!providerModelCache[base]) {
void fetchAndCacheProviderModels(base);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [neededProviderBases]);


// Deduplicate models across providers by picking the best-priced variant per id
const dedupedModels = useMemo(() => {
Expand Down Expand Up @@ -289,6 +248,9 @@ export default function ModelSelector({
// Split into configured and all (remaining) models
const configuredModelsList = filteredModels.filter(model => isConfiguredModel(model.id));
const remainingModelsList = filteredModels.filter(model => !isConfiguredModel(model.id));
const recommendedModelsList = recommendedModels
.map(modelId => filteredModels.find(model => model.id === modelId))
.filter((model): model is Model => model !== undefined);

// Calculate unique models and providers for display (excluding disabled providers)
const { uniqueModelCount, uniqueProviderCount } = useMemo(() => {
Expand Down Expand Up @@ -870,6 +832,25 @@ export default function ModelSelector({
<div className="border-t border-white/10 my-1" />
)}

{/* Recommended Models Section */}
<div className="p-1">
<div className="px-2 py-1 text-xs font-medium text-white/60">
Recommended Models
</div>
{recommendedModelsList.length > 0 ? (
<div className="space-y-1">
{recommendedModelsList.map((model) => renderModelItem(model, false))}
</div>
) : (
<div className="p-2 text-sm text-white/50 text-center">No models found</div>
)}
</div>

{/* Separator */}
{(!!selectedModel || favoriteEntries.length > 0) && remainingModelsList.length > 0 && (
<div className="border-t border-white/10 my-1" />
)}

{/* All Models Section */}
<div className="p-1">
<div className="px-2 py-1 text-xs font-medium text-white/60">
Expand Down Expand Up @@ -979,26 +960,45 @@ export default function ModelSelector({
</div>
)}

{/* Separator */}
{(!!selectedModel || favoriteEntries.length > 0) && remainingModelsList.length > 0 && (
<div className="border-t border-white/10 my-1" />
)}
{/* Separator */}
{(!!selectedModel || favoriteEntries.length > 0) && remainingModelsList.length > 0 && (
<div className="border-t border-white/10 my-1" />
)}

{/* All Models Section */}
<div className="p-1">
<div className="px-2 py-1 text-xs font-medium text-white/60">
All Models {uniqueModelCount > 0 && uniqueProviderCount > 0 && (
<span className="text-white/40">({uniqueModelCount} models from {uniqueProviderCount} providers)</span>
{/* Recommended Models Section */}
<div className="p-1">
<div className="px-2 py-1 text-xs font-medium text-white/60">
Recommended Models
</div>
{recommendedModelsList.length > 0 ? (
<div className="space-y-1">
{recommendedModelsList.map((model) => renderModelItem(model, false))}
</div>
) : (
<div className="p-2 text-sm text-white/50 text-center">No models found</div>
)}
</div>
{remainingModelsList.length > 0 ? (
<div className="space-y-1">
{remainingModelsList.filter(m => m.id !== selectedModel?.id).map((model) => renderModelItem(model, false))}
</div>
) : (
<div className="p-2 text-sm text-white/50 text-center">No models found</div>

{/* Separator */}
{(!!selectedModel || favoriteEntries.length > 0) && remainingModelsList.length > 0 && (
<div className="border-t border-white/10 my-1" />
)}
</div>

{/* All Models Section */}
<div className="p-1">
<div className="px-2 py-1 text-xs font-medium text-white/60">
All Models {uniqueModelCount > 0 && uniqueProviderCount > 0 && (
<span className="text-white/40">({uniqueModelCount} models from {uniqueProviderCount} providers)</span>
)}
</div>
{remainingModelsList.length > 0 ? (
<div className="space-y-1">
{remainingModelsList.filter(m => m.id !== selectedModel?.id).map((model) => renderModelItem(model, false))}
</div>
) : (
<div className="p-2 text-sm text-white/50 text-center">No models found</div>
)}
</div>
</div>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/settings/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({
{/* Version Information */}
<div className="mt-8 pt-4 border-t border-white/10">
<div className="text-xs text-white/40 text-center">
Version 0.1.0-beta
Version 0.1.0
</div>
</div>
</>
Expand Down
2 changes: 1 addition & 1 deletion context/ChatProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
const conversationState = useConversationState();
const cashuWithXYZ = useCashuWithXYZ();
const chatActions = useChatActions(); // Move chatActions declaration before apiState
const apiState = useApiState(isAuthenticated, cashuWithXYZ.balance);
const apiState = useApiState(isAuthenticated, cashuWithXYZ.balance, cashuWithXYZ.maxBalance, cashuWithXYZ.pendingCashuAmountState, cashuWithXYZ.isWalletLoading);
const uiState = useUiState(isAuthenticated);
const modelState = useModelState();

Expand Down
Loading