Compare commits

...

17 Commits

Author SHA1 Message Date
ItzCrazyKns
2c9012e99c feat(app): lint & beautify 2025-10-18 18:50:08 +05:30
ItzCrazyKns
d43ef9e43d feat(emptyChatInput): add model selector 2025-10-18 18:49:26 +05:30
ItzCrazyKns
53a1b3bc56 feat(focus): update size & icon 2025-10-18 18:48:52 +05:30
ItzCrazyKns
f28ea8cee2 feat(attach): update size & icons 2025-10-18 18:48:42 +05:30
ItzCrazyKns
716629f6fe feat(emptyChatInput): update UI 2025-10-18 18:48:23 +05:30
ItzCrazyKns
190b6aa79a feat(sidebar): improve UI, use new settings dialog 2025-10-18 15:10:57 +05:30
ItzCrazyKns
97e542acf8 feat(app): add new settings dialog 2025-10-18 15:10:43 +05:30
ItzCrazyKns
97bee75e39 feat(select): add loading spinner 2025-10-18 15:08:49 +05:30
ItzCrazyKns
16b31fe34f feat(uploads): use new model registry 2025-10-18 15:08:31 +05:30
ItzCrazyKns
8a1052e82b feat(providers): add ollama 2025-10-18 15:08:06 +05:30
ItzCrazyKns
43f23e21a3 feat(config-route): fix issues with duplicate models 2025-10-18 15:06:36 +05:30
ItzCrazyKns
5a7f45cace feat(app): add provider methods 2025-10-18 15:06:00 +05:30
ItzCrazyKns
e02b9a5efc feat(settings-page): remove page 2025-10-17 14:38:36 +05:30
ItzCrazyKns
09dd8dba5a feat(config-route): add POST handler 2025-10-17 14:38:05 +05:30
ItzCrazyKns
ca8b74b695 feat(components): add loader 2025-10-17 14:37:17 +05:30
ItzCrazyKns
ac7cfac784 feat(config): add theme 2025-10-17 14:36:04 +05:30
ItzCrazyKns
861d50674a feat(theme): fix colors 2025-10-17 14:35:49 +05:30
34 changed files with 2481 additions and 1227 deletions

View File

@@ -1,29 +1,72 @@
import configManager from '@/lib/config';
import ModelRegistry from '@/lib/models/registry';
import { NextRequest, NextResponse } from 'next/server';
import { ConfigModelProvider } from '@/lib/config/types';
type SaveConfigBody = {
key: string;
value: string;
};
export const GET = async (req: NextRequest) => {
try {
const values = configManager.currentConfig;
const values = configManager.getCurrentConfig();
const fields = configManager.getUIConfigSections();
const modelRegistry = new ModelRegistry();
const modelProviders = await modelRegistry.getActiveProviders();
values.modelProviders = values.modelProviders.map((mp) => {
const activeProvider = modelProviders.find((p) => p.id === mp.id)
values.modelProviders = values.modelProviders.map(
(mp: ConfigModelProvider) => {
const activeProvider = modelProviders.find((p) => p.id === mp.id);
return {
...mp,
chatModels: activeProvider?.chatModels ?? mp.chatModels,
embeddingModels: activeProvider?.embeddingModels ?? mp.embeddingModels
}
})
return {
...mp,
chatModels: activeProvider?.chatModels ?? mp.chatModels,
embeddingModels:
activeProvider?.embeddingModels ?? mp.embeddingModels,
};
},
);
return NextResponse.json({
values,
fields,
})
});
} catch (err) {
console.error('Error in getting config: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};
export const POST = async (req: NextRequest) => {
try {
const body: SaveConfigBody = await req.json();
if (!body.key || !body.value) {
return Response.json(
{
message: 'Key and value are required.',
},
{
status: 400,
},
);
}
configManager.updateConfig(body.key, body.value);
return Response.json(
{
message: 'Config updated successfully.',
},
{
status: 200,
},
);
} catch (err) {
console.error('Error in getting config: ', err);
return Response.json(

View File

@@ -0,0 +1,94 @@
import ModelRegistry from '@/lib/models/registry';
import { Model } from '@/lib/models/types';
import { NextRequest } from 'next/server';
export const POST = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const body: Partial<Model> & { type: 'embedding' | 'chat' } =
await req.json();
if (!body.key || !body.name) {
return Response.json(
{
message: 'Key and name must be provided',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
await registry.addProviderModel(id, body.type, body);
return Response.json(
{
message: 'Model added successfully',
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while adding provider model', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};
export const DELETE = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const body: { key: string; type: 'embedding' | 'chat' } = await req.json();
if (!body.key) {
return Response.json(
{
message: 'Key and name must be provided',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
await registry.removeProviderModel(id, body.type, body.key);
return Response.json(
{
message: 'Model added successfully',
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while deleting provider model', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -0,0 +1,89 @@
import ModelRegistry from '@/lib/models/registry';
import { NextRequest } from 'next/server';
export const DELETE = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
if (!id) {
return Response.json(
{
message: 'Provider ID is required.',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
await registry.removeProvider(id);
return Response.json(
{
message: 'Provider deleted successfully.',
},
{
status: 200,
},
);
} catch (err: any) {
console.error('An error occurred while deleting provider', err.message);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};
export const PATCH = async (
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const body = await req.json();
const { name, config } = body;
const { id } = await params;
if (!id || !name || !config) {
return Response.json(
{
message: 'Missing required fields.',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
const updatedProvider = await registry.updateProvider(id, name, config);
return Response.json(
{
provider: updatedProvider,
},
{
status: 200,
},
);
} catch (err: any) {
console.error('An error occurred while updating provider', err.message);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -1,4 +1,5 @@
import ModelRegistry from '@/lib/models/registry';
import { NextRequest } from 'next/server';
export const GET = async (req: Request) => {
try {
@@ -6,9 +7,13 @@ export const GET = async (req: Request) => {
const activeProviders = await registry.getActiveProviders();
const filteredProviders = activeProviders.filter((p) => {
return !p.chatModels.some((m) => m.key === 'error');
});
return Response.json(
{
providers: activeProviders,
providers: filteredProviders,
},
{
status: 200,
@@ -26,3 +31,44 @@ export const GET = async (req: Request) => {
);
}
};
export const POST = async (req: NextRequest) => {
try {
const body = await req.json();
const { type, name, config } = body;
if (!type || !name || !config) {
return Response.json(
{
message: 'Missing required fields.',
},
{
status: 400,
},
);
}
const registry = new ModelRegistry();
const newProvider = await registry.addProvider(type, name, config);
return Response.json(
{
provider: newProvider,
},
{
status: 200,
},
);
} catch (err) {
console.error('An error occurred while creating provider', err);
return Response.json(
{
message: 'An error has occurred.',
},
{
status: 500,
},
);
}
};

View File

@@ -2,11 +2,11 @@ import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { getAvailableEmbeddingModelProviders } from '@/lib/providers';
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { DocxLoader } from '@langchain/community/document_loaders/fs/docx';
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { Document } from 'langchain/document';
import ModelRegistry from '@/lib/models/registry';
interface FileRes {
fileName: string;
@@ -30,8 +30,8 @@ export async function POST(req: Request) {
const formData = await req.formData();
const files = formData.getAll('files') as File[];
const embedding_model = formData.get('embedding_model');
const embedding_model_provider = formData.get('embedding_model_provider');
const embedding_model = formData.get('embedding_model_key') as string;
const embedding_model_provider = formData.get('embedding_model_provider_id') as string;
if (!embedding_model || !embedding_model_provider) {
return NextResponse.json(
@@ -40,20 +40,9 @@ export async function POST(req: Request) {
);
}
const embeddingModels = await getAvailableEmbeddingModelProviders();
const provider =
embedding_model_provider ?? Object.keys(embeddingModels)[0];
const embeddingModel =
embedding_model ?? Object.keys(embeddingModels[provider as string])[0];
const registry = new ModelRegistry();
let embeddingsModel =
embeddingModels[provider as string]?.[embeddingModel as string]?.model;
if (!embeddingsModel) {
return NextResponse.json(
{ message: 'Invalid embedding model selected' },
{ status: 400 },
);
}
const model = await registry.loadEmbeddingModel(embedding_model_provider, embedding_model);
const processedFiles: FileRes[] = [];
@@ -98,7 +87,7 @@ export async function POST(req: Request) {
}),
);
const embeddings = await embeddingsModel.embedDocuments(
const embeddings = await model.embedDocuments(
splitted.map((doc) => doc.pageContent),
);
const embeddingsDataPath = filePath.replace(

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import { Settings } from 'lucide-react';
import Link from 'next/link';
import NextError from 'next/error';
import { useChat } from '@/lib/hooks/useChat';
import Loader from './ui/Loader';
export interface BaseMessage {
chatId: string;
@@ -85,22 +86,7 @@ const ChatWindow = () => {
)
) : (
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<Loader />
</div>
);
};

View File

@@ -5,6 +5,8 @@ import Focus from './MessageInputActions/Focus';
import Optimization from './MessageInputActions/Optimization';
import Attach from './MessageInputActions/Attach';
import { useChat } from '@/lib/hooks/useChat';
import AttachSmall from './MessageInputActions/AttachSmall';
import ModelSelector from './MessageInputActions/ModelSelector';
const EmptyChatMessageInput = () => {
const { sendMessage } = useChat();
@@ -54,25 +56,25 @@ const EmptyChatMessageInput = () => {
}}
className="w-full"
>
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-5 pt-5 pb-2 rounded-2xl w-full border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300">
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-3 pt-5 pb-3 rounded-2xl w-full border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300">
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder="Ask anything..."
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center space-x-2 lg:space-x-4">
<Focus />
<Attach showText />
</div>
<div className="flex flex-row items-center space-x-1 sm:space-x-4">
<Optimization />
<Optimization />
<div className="flex flex-row items-center space-x-2">
<div className="flex flex-row items-center space-x-1">
<ModelSelector />
<Attach />
</div>
<button
disabled={message.trim().length === 0}
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
className="bg-sky-500 text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
>
<ArrowRight className="bg-background" size={17} />
</button>

View File

@@ -5,11 +5,19 @@ import {
PopoverPanel,
Transition,
} from '@headlessui/react';
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
import {
CopyPlus,
File,
Link,
LoaderCircle,
Paperclip,
Plus,
Trash,
} from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { useChat } from '@/lib/hooks/useChat';
const Attach = ({ showText }: { showText?: boolean }) => {
const Attach = () => {
const { files, setFiles, setFileIds, fileIds } = useChat();
const [loading, setLoading] = useState(false);
@@ -24,12 +32,12 @@ const Attach = ({ showText }: { showText?: boolean }) => {
}
const embeddingModelProvider = localStorage.getItem(
'embeddingModelProvider',
'embeddingModelProviderId',
);
const embeddingModel = localStorage.getItem('embeddingModel');
const embeddingModel = localStorage.getItem('embeddingModelKey');
data.append('embedding_model_provider', embeddingModelProvider!);
data.append('embedding_model', embeddingModel!);
data.append('embedding_model_provider_id', embeddingModelProvider!);
data.append('embedding_model_key', embeddingModel!);
const res = await fetch(`/api/uploads`, {
method: 'POST',
@@ -44,42 +52,16 @@ const Attach = ({ showText }: { showText?: boolean }) => {
};
return loading ? (
<div className="flex flex-row items-center justify-between space-x-1">
<LoaderCircle size={18} className="text-sky-400 animate-spin" />
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
Uploading..
</p>
<div className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 transition duration-200">
<LoaderCircle size={16} className="text-sky-400 animate-spin" />
</div>
) : files.length > 0 ? (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
<PopoverButton
type="button"
className={cn(
'flex flex-row items-center justify-between space-x-1 p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
files.length > 0 ? '-ml-2 lg:-ml-3' : '',
)}
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
{files.length > 1 && (
<>
<File size={19} className="text-sky-400" />
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
{files.length} files
</p>
</>
)}
{files.length === 1 && (
<>
<File size={18} className="text-sky-400" />
<p className="text-sky-400 text-xs font-medium">
{files[0].fileName.length > 10
? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) +
'...' +
files[0].fileExtension
: files[0].fileName}
</p>
</>
)}
<File size={16} className="text-sky-400" />
</PopoverButton>
<Transition
as={Fragment}
@@ -100,7 +82,7 @@ const Attach = ({ showText }: { showText?: boolean }) => {
<button
type="button"
onClick={() => fileInputRef.current.click()}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
>
<input
type="file"
@@ -110,7 +92,7 @@ const Attach = ({ showText }: { showText?: boolean }) => {
multiple
hidden
/>
<Plus size={18} />
<Plus size={16} />
<p className="text-xs">Add</p>
</button>
<button
@@ -118,7 +100,7 @@ const Attach = ({ showText }: { showText?: boolean }) => {
setFiles([]);
setFileIds([]);
}}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
>
<Trash size={14} />
<p className="text-xs">Clear</p>
@@ -157,8 +139,7 @@ const Attach = ({ showText }: { showText?: boolean }) => {
type="button"
onClick={() => fileInputRef.current.click()}
className={cn(
'flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white',
showText ? '' : 'p-2',
'flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
)}
>
<input
@@ -169,8 +150,7 @@ const Attach = ({ showText }: { showText?: boolean }) => {
multiple
hidden
/>
<CopyPlus size={showText ? 18 : undefined} />
{showText && <p className="text-xs font-medium pl-[1px]">Attach</p>}
<Paperclip size={16} />
</button>
);
};

View File

@@ -5,7 +5,14 @@ import {
PopoverPanel,
Transition,
} from '@headlessui/react';
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
import {
CopyPlus,
File,
LoaderCircle,
Paperclip,
Plus,
Trash,
} from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { File as FileType } from '../ChatWindow';
import { useChat } from '@/lib/hooks/useChat';
@@ -25,12 +32,12 @@ const AttachSmall = () => {
}
const embeddingModelProvider = localStorage.getItem(
'embeddingModelProvider',
'embeddingModelProviderId',
);
const embeddingModel = localStorage.getItem('embeddingModel');
const embeddingModel = localStorage.getItem('embeddingModelKey');
data.append('embedding_model_provider', embeddingModelProvider!);
data.append('embedding_model', embeddingModel!);
data.append('embedding_model_provider_id', embeddingModelProvider!);
data.append('embedding_model_key', embeddingModel!);
const res = await fetch(`/api/uploads`, {
method: 'POST',
@@ -45,7 +52,7 @@ const AttachSmall = () => {
};
return loading ? (
<div className="flex flex-row items-center justify-between space-x-1 p-1">
<div className="flex flex-row items-center justify-between space-x-1 p-1 ">
<LoaderCircle size={20} className="text-sky-400 animate-spin" />
</div>
) : files.length > 0 ? (
@@ -141,7 +148,7 @@ const AttachSmall = () => {
multiple
hidden
/>
<CopyPlus size={20} />
<Paperclip size={16} />
</button>
);
};

View File

@@ -22,13 +22,13 @@ const focusModes = [
key: 'webSearch',
title: 'All',
description: 'Searches across all of the internet',
icon: <Globe size={20} />,
icon: <Globe size={16} />,
},
{
key: 'academicSearch',
title: 'Academic',
description: 'Search in published academic papers',
icon: <SwatchBook size={20} />,
icon: <SwatchBook size={16} />,
},
{
key: 'writingAssistant',
@@ -40,19 +40,19 @@ const focusModes = [
key: 'wolframAlphaSearch',
title: 'Wolfram Alpha',
description: 'Computational knowledge engine',
icon: <BadgePercent size={20} />,
icon: <BadgePercent size={16} />,
},
{
key: 'youtubeSearch',
title: 'Youtube',
description: 'Search and watch videos',
icon: <SiYoutube className="h-5 w-auto mr-0.5" />,
icon: <SiYoutube className="h-[16px] w-auto mr-0.5" />,
},
{
key: 'redditSearch',
title: 'Reddit',
description: 'Search for discussions and opinions',
icon: <SiReddit className="h-5 w-auto mr-0.5" />,
icon: <SiReddit className="h-[16px] w-auto mr-0.5" />,
},
];
@@ -60,23 +60,18 @@ const Focus = () => {
const { focusMode, setFocusMode } = useChat();
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg mt-[6.5px]">
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
<PopoverButton
type="button"
className="active:border-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
{focusMode !== 'webSearch' ? (
<div className="flex flex-row items-center space-x-1">
{focusModes.find((mode) => mode.key === focusMode)?.icon}
<p className="text-xs font-medium hidden lg:block">
{focusModes.find((mode) => mode.key === focusMode)?.title}
</p>
<ChevronDown size={20} className="-translate-x-1" />
</div>
) : (
<div className="flex flex-row items-center space-x-1">
<ScanEye size={20} />
<p className="text-xs font-medium hidden lg:block">Focus</p>
<Globe size={16} />
</div>
)}
</PopoverButton>
@@ -89,14 +84,14 @@ const Focus = () => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] left-0">
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] -right-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
{focusModes.map((mode, i) => (
<PopoverButton
onClick={() => setFocusMode(mode.key)}
key={i}
className={cn(
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-2 duration-200 cursor-pointer transition',
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-2 duration-200 cursor-pointer transition focus:outline-none',
focusMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',

View File

@@ -0,0 +1,198 @@
'use client';
import { Cpu, Loader2, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react';
import { MinimalProvider } from '@/lib/models/types';
const ModelSelector = () => {
const [providers, setProviders] = useState<MinimalProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedModel, setSelectedModel] = useState<{
providerId: string;
modelKey: string;
} | null>(null);
useEffect(() => {
const loadProviders = async () => {
try {
setIsLoading(true);
const res = await fetch('/api/providers');
if (!res.ok) {
throw new Error('Failed to fetch providers');
}
const data = await res.json();
setProviders(data.providers || []);
const savedProviderId = localStorage.getItem('chatModelProviderId');
const savedModelKey = localStorage.getItem('chatModelKey');
if (savedProviderId && savedModelKey) {
setSelectedModel({
providerId: savedProviderId,
modelKey: savedModelKey,
});
} else if (data.providers && data.providers.length > 0) {
const firstProvider = data.providers.find(
(p: MinimalProvider) => p.chatModels.length > 0,
);
if (firstProvider && firstProvider.chatModels[0]) {
setSelectedModel({
providerId: firstProvider.id,
modelKey: firstProvider.chatModels[0].key,
});
}
}
} catch (error) {
console.error('Error loading providers:', error);
} finally {
setIsLoading(false);
}
};
loadProviders();
}, []);
const handleModelSelect = (providerId: string, modelKey: string) => {
setSelectedModel({ providerId, modelKey });
localStorage.setItem('chatModelProviderId', providerId);
localStorage.setItem('chatModelKey', modelKey);
};
const filteredProviders = providers
.map((provider) => ({
...provider,
chatModels: provider.chatModels.filter(
(model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
provider.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
}))
.filter((provider) => provider.chatModels.length > 0);
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
<PopoverButton
type="button"
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<Cpu size={16} className="text-sky-500" />
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] -right-4">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden">
<div className="p-4 border-b border-light-200 dark:border-dark-200">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
/>
<input
type="text"
placeholder="Search models..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg text-sm text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-sky-500/20 border border-transparent focus:border-sky-500/30 transition duration-200"
/>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2
className="animate-spin text-black/40 dark:text-white/40"
size={24}
/>
</div>
) : filteredProviders.length === 0 ? (
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
{searchQuery
? 'No models found'
: 'No chat models configured'}
</div>
) : (
<div className="flex flex-col">
{filteredProviders.map((provider, providerIndex) => (
<div key={provider.id}>
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
{provider.name}
</p>
</div>
<div className="flex flex-col px-2 py-2 space-y-0.5">
{provider.chatModels.map((model) => (
<PopoverButton
key={model.key}
onClick={() =>
handleModelSelect(provider.id, model.key)
}
className={cn(
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
selectedModel?.providerId === provider.id &&
selectedModel?.modelKey === model.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
<Cpu
size={15}
className={cn(
'shrink-0',
selectedModel?.providerId === provider.id &&
selectedModel?.modelKey === model.key
? 'text-sky-500'
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
)}
/>
<p
className={cn(
'text-sm truncate',
selectedModel?.providerId === provider.id &&
selectedModel?.modelKey === model.key
? 'text-sky-500 font-medium'
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
)}
>
{model.name}
</p>
</div>
</PopoverButton>
))}
</div>
{providerIndex < filteredProviders.length - 1 && (
<div className="h-px bg-light-200 dark:bg-dark-200" />
)}
</div>
))}
</div>
)}
</div>
</div>
</PopoverPanel>
</Transition>
</Popover>
);
};
export default ModelSelector;

View File

@@ -14,13 +14,13 @@ const OptimizationModes = [
key: 'speed',
title: 'Speed',
description: 'Prioritize speed and get the quickest possible answer.',
icon: <Zap size={20} className="text-[#FF9800]" />,
icon: <Zap size={16} className="text-[#FF9800]" />,
},
{
key: 'balanced',
title: 'Balanced',
description: 'Find the right balance between speed and accuracy',
icon: <Sliders size={20} className="text-[#4CAF50]" />,
icon: <Sliders size={16} className="text-[#4CAF50]" />,
},
{
key: 'quality',
@@ -40,60 +40,64 @@ const Optimization = () => {
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
<PopoverButton
type="button"
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<div className="flex flex-row items-center space-x-1">
{
OptimizationModes.find((mode) => mode.key === optimizationMode)
?.icon
}
<p className="text-xs font-medium">
{
OptimizationModes.find((mode) => mode.key === optimizationMode)
?.title
}
</p>
<ChevronDown size={20} />
</div>
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] right-0">
<div className="flex flex-col gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
{OptimizationModes.map((mode, i) => (
<PopoverButton
onClick={() => setOptimizationMode(mode.key)}
key={i}
disabled={mode.key === 'quality'}
{({ open }) => (
<>
<PopoverButton
type="button"
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
>
<div className="flex flex-row items-center space-x-1">
{
OptimizationModes.find((mode) => mode.key === optimizationMode)
?.icon
}
<ChevronDown
size={16}
className={cn(
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition',
optimizationMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
mode.key === 'quality' && 'opacity-50 cursor-not-allowed',
open ? 'rotate-180' : 'rotate-0',
'transition duration:200',
)}
>
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
{mode.icon}
<p className="text-sm font-medium">{mode.title}</p>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{mode.description}
</p>
</PopoverButton>
))}
</div>
</PopoverPanel>
</Transition>
/>
</div>
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-64 md:w-[250px] left-0">
<div className="flex flex-col gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
{OptimizationModes.map((mode, i) => (
<PopoverButton
onClick={() => setOptimizationMode(mode.key)}
key={i}
disabled={mode.key === 'quality'}
className={cn(
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
optimizationMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
mode.key === 'quality' && 'opacity-50 cursor-not-allowed',
)}
>
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
{mode.icon}
<p className="text-sm font-medium">{mode.title}</p>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{mode.description}
</p>
</PopoverButton>
))}
</div>
</PopoverPanel>
</Transition>
</>
)}
</Popover>
);
};

View File

@@ -0,0 +1,29 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const General = ({
fields,
values,
}: {
fields: UIConfigField[];
values: Record<string, any>;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(field.scope === 'client'
? localStorage.getItem(field.key)
: values[field.key]) ?? field.default
}
dataAdd="general"
/>
))}
</div>
);
};
export default General;

View File

@@ -0,0 +1,163 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Loader2, Plus } from 'lucide-react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ConfigModelProvider } from '@/lib/config/types';
import { toast } from 'sonner';
const AddModel = ({
providerId,
setProviders,
type,
}: {
providerId: string;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
type: 'chat' | 'embedding';
}) => {
const [open, setOpen] = useState(false);
const [modelName, setModelName] = useState('');
const [modelKey, setModelKey] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${providerId}/models`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: modelName,
key: modelKey,
type: type,
}),
});
if (!res.ok) {
throw new Error('Failed to add model');
}
setProviders((prev) =>
prev.map((provider) => {
if (provider.id === providerId) {
const newModel = { name: modelName, key: modelKey };
return {
...provider,
chatModels:
type === 'chat'
? [...provider.chatModels, newModel]
: provider.chatModels,
embeddingModels:
type === 'embedding'
? [...provider.embeddingModels, newModel]
: provider.embeddingModels,
};
}
return provider;
}),
);
toast.success('Model added successfully.');
setModelName('');
setModelKey('');
setOpen(false);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model.');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={() => setOpen(true)}
className="text-xs text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
<Plus size={12} />
<span>Add</span>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium">
Add new {type === 'chat' ? 'chat' : 'embedding'} model
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<form
onSubmit={handleSubmit}
className="flex flex-col h-full"
>
<div className="flex flex-col space-y-4 flex-1">
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Model name*
</label>
<input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder="e.g., GPT-4"
type="text"
required
/>
</div>
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Model key*
</label>
<input
value={modelKey}
onChange={(e) => setModelKey(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder="e.g., gpt-4"
type="text"
required
/>
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200 -mx-6 my-4" />
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-sm bg-sky-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? (
<Loader2 className="animate-spin" size={16} />
) : (
'Add Model'
)}
</button>
</div>
</form>
</div>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default AddModel;

View File

@@ -0,0 +1,216 @@
import {
Description,
Dialog,
DialogPanel,
DialogTitle,
} from '@headlessui/react';
import { Loader2, Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ConfigModelProvider,
ModelProviderUISection,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import Select from '@/components/ui/Select';
import { toast } from 'sonner';
const AddProvider = ({
modelProviders,
setProviders,
}: {
modelProviders: ModelProviderUISection[];
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<null | string>(
modelProviders[0]?.key || null,
);
const [config, setConfig] = useState<Record<string, any>>({});
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const providerConfigMap = useMemo(() => {
const map: Record<string, { name: string; fields: UIConfigField[] }> = {};
modelProviders.forEach((p) => {
map[p.key] = {
name: p.name,
fields: p.fields,
};
});
return map;
}, [modelProviders]);
const selectedProviderFields = useMemo(() => {
if (!selectedProvider) return [];
const providerFields = providerConfigMap[selectedProvider]?.fields || [];
const config: Record<string, any> = {};
providerFields.forEach((field) => {
config[field.key] = field.default || '';
});
setConfig(config);
return providerFields;
}, [selectedProvider, providerConfigMap]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch('/api/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: selectedProvider,
name: name,
config: config,
}),
});
if (!res.ok) {
throw new Error('Failed to add provider');
}
const data: ConfigModelProvider = (await res.json()).provider;
setProviders((prev) => [...prev, data]);
toast.success('Provider added successfully.');
} catch (error) {
console.error('Error adding provider:', error);
toast.error('Failed to add provider.');
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<>
<button
onClick={() => setOpen(true)}
className="px-4 py-2 rounded-lg text-sm border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
<Plus size={16} />
<span>Add Provider</span>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium">
Add new provider
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex flex-col space-y-4">
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Select provider type
</label>
<Select
value={selectedProvider ?? ''}
onChange={(e) => setSelectedProvider(e.target.value)}
options={Object.entries(providerConfigMap).map(
([key, val]) => {
return {
label: val.name,
value: key,
};
},
)}
/>
</div>
<div
key="name"
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
Name*
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={'Provider Name'}
type="text"
required={true}
/>
</div>
{selectedProviderFields.map((field: UIConfigField) => (
<div
key={field.key}
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
{field.name}
{field.required && '*'}
</label>
<input
value={config[field.key] ?? field.default ?? ''}
onChange={(event) =>
setConfig((prev) => ({
...prev,
[field.key]: event.target.value,
}))
}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={
(field as StringUIConfigField).placeholder
}
type="text"
required={field.required}
/>
</div>
))}
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="px-6 py-4 flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-sm bg-sky-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? (
<Loader2 className="animate-spin" size={16} />
) : (
'Add Provider'
)}
</button>
</div>
</form>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default AddProvider;

View File

@@ -0,0 +1,118 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Loader2, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ConfigModelProvider } from '@/lib/config/types';
import { toast } from 'sonner';
const DeleteProvider = ({
modelProvider,
setProviders,
}: {
modelProvider: ConfigModelProvider;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${modelProvider.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error('Failed to delete provider');
}
setProviders((prev) => {
return prev.filter((p) => p.id !== modelProvider.id);
});
toast.success('Provider deleted successfully.');
} catch (error) {
console.error('Error deleting provider:', error);
toast.error('Failed to delete provider.');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
title="Delete provider"
>
<Trash2
size={14}
className="text-black/60 dark:text-white/60 group-hover:text-red-500 group-hover:dark:text-red-400"
/>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium">
Delete provider
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<p className="text-SM text-black/60 dark:text-white/60">
Are you sure you want to delete the provider &quot;
{modelProvider.name}&quot;? This action cannot be undone.
</p>
</div>
<div className="px-6 py-6 flex justify-end space-x-2">
<button
disabled={loading}
onClick={() => setOpen(false)}
className="px-4 py-2 rounded-lg text-sm border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
Cancel
</button>
<button
disabled={loading}
onClick={handleDelete}
className="px-4 py-2 rounded-lg text-sm bg-red-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? (
<Loader2 className="animate-spin" size={16} />
) : (
'Delete'
)}
</button>
</div>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default DeleteProvider;

View File

@@ -0,0 +1,217 @@
import { UIConfigField, ConfigModelProvider } from '@/lib/config/types';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertCircle, ChevronDown, Pencil, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import AddModel from './AddModelDialog';
import UpdateProvider from './UpdateProviderDialog';
import DeleteProvider from './DeleteProviderDialog';
const ModelProvider = ({
modelProvider,
setProviders,
fields,
}: {
modelProvider: ConfigModelProvider;
fields: UIConfigField[];
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const handleModelDelete = async (
type: 'chat' | 'embedding',
modelKey: string,
) => {
try {
const res = await fetch(`/api/providers/${modelProvider.id}/models`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: modelKey, type: type }),
});
if (!res.ok) {
throw new Error('Failed to delete model: ' + (await res.text()));
}
setProviders(
(prev) =>
prev.map((provider) => {
if (provider.id === modelProvider.id) {
return {
...provider,
...(type === 'chat'
? {
chatModels: provider.chatModels.filter(
(m) => m.key !== modelKey,
),
}
: {
embeddingModels: provider.embeddingModels.filter(
(m) => m.key !== modelKey,
),
}),
};
}
return provider;
}) as ConfigModelProvider[],
);
toast.success('Model deleted successfully.');
} catch (err) {
console.error('Failed to delete model', err);
toast.error('Failed to delete model.');
}
};
return (
<div
key={modelProvider.id}
className="border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden"
>
<div
className={cn(
'group px-5 py-4 flex flex-row justify-between w-full cursor-pointer hover:bg-light-secondary hover:dark:bg-dark-secondary transition duration-200 items-center',
!open && 'rounded-lg',
)}
onClick={() => setOpen(!open)}
>
<p className="text-black dark:text-white font-medium">
{modelProvider.name}
</p>
<div className="flex items-center gap-4">
<div className="flex flex-row items-center">
<UpdateProvider
fields={fields}
modelProvider={modelProvider}
setProviders={setProviders}
/>
<DeleteProvider
modelProvider={modelProvider}
setProviders={setProviders}
/>
</div>
<ChevronDown
size={16}
className={cn(
open ? 'rotate-180' : '',
'transition duration-200 text-black/70 dark:text-white/70 group-hover:text-sky-500',
)}
/>
</div>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.1 }}
>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex flex-col gap-y-4 px-5 py-4">
{modelProvider.chatModels.length > 0 && (
<div className="flex flex-col gap-y-2">
<div className="flex flex-row w-full justify-between items-center">
<p className="text-xs text-black/70 dark:text-white/70">
Chat models
</p>
<AddModel
providerId={modelProvider.id}
setProviders={setProviders}
type="chat"
/>
</div>
<div className="flex flex-col gap-2">
{modelProvider.chatModels.some((m) => m.key === 'error') ? (
<div className="flex flex-row items-center gap-2 text-sm text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
<AlertCircle size={16} className="shrink-0" />
<span className="break-words">
{
modelProvider.chatModels.find(
(m) => m.key === 'error',
)?.name
}
</span>
</div>
) : (
<div className="flex flex-row flex-wrap gap-2">
{modelProvider.chatModels.map((model, index) => (
<div
key={`${modelProvider.id}-chat-${model.key}-${index}`}
className="flex flex-row items-center space-x-1 text-sm text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5"
>
<span>{model.name}</span>
<button
onClick={() => {
handleModelDelete('chat', model.key);
}}
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{modelProvider.embeddingModels.length > 0 && (
<div className="flex flex-col gap-y-2">
<div className="flex flex-row w-full justify-between items-center">
<p className="text-xs text-black/70 dark:text-white/70">
Embedding models
</p>
<AddModel
providerId={modelProvider.id}
setProviders={setProviders}
type="embedding"
/>
</div>
<div className="flex flex-col gap-2">
{modelProvider.embeddingModels.some(
(m) => m.key === 'error',
) ? (
<div className="flex flex-row items-center gap-2 text-sm text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
<AlertCircle size={16} className="shrink-0" />
<span className="break-words">
{
modelProvider.embeddingModels.find(
(m) => m.key === 'error',
)?.name
}
</span>
</div>
) : (
<div className="flex flex-row flex-wrap gap-2">
{modelProvider.embeddingModels.map((model, index) => (
<div
key={`${modelProvider.id}-embedding-${model.key}-${index}`}
className="flex flex-row items-center space-x-1 text-sm text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5"
>
<span>{model.name}</span>
<button
onClick={() => {
handleModelDelete('embedding', model.key);
}}
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default ModelProvider;

View File

@@ -0,0 +1,44 @@
import React, { useState } from 'react';
import AddProvider from './AddProviderDialog';
import {
ConfigModelProvider,
ModelProviderUISection,
UIConfigField,
} from '@/lib/config/types';
import ModelProvider from './ModelProvider';
const Models = ({
fields,
values,
}: {
fields: ModelProviderUISection[];
values: ConfigModelProvider[];
}) => {
const [providers, setProviders] = useState<ConfigModelProvider[]>(values);
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
<div className="flex flex-row justify-between items-center">
<p className="text-sm text-black/70 dark:text-white/70">
Manage model provider
</p>
<AddProvider modelProviders={fields} setProviders={setProviders} />
</div>
<div className="flex flex-col gap-y-4">
{providers.map((provider) => (
<ModelProvider
key={`provider-${provider.id}`}
fields={
(fields.find((f) => f.key === provider.type)?.fields ??
[]) as UIConfigField[]
}
modelProvider={provider}
setProviders={setProviders}
/>
))}
</div>
</div>
);
};
export default Models;

View File

@@ -0,0 +1,188 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Loader2, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ConfigModelProvider,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import { toast } from 'sonner';
const UpdateProvider = ({
modelProvider,
fields,
setProviders,
}: {
fields: UIConfigField[];
modelProvider: ConfigModelProvider;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<Record<string, any>>({});
const [name, setName] = useState(modelProvider.name);
const [loading, setLoading] = useState(false);
useEffect(() => {
const config: Record<string, any> = {
name: modelProvider.name,
};
fields.forEach((field) => {
config[field.key] =
modelProvider.config[field.key] || field.default || '';
});
setConfig(config);
}, [fields]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${modelProvider.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
config: config,
}),
});
if (!res.ok) {
throw new Error('Failed to update provider');
}
const data: ConfigModelProvider = (await res.json()).provider;
setProviders((prev) => {
return prev.map((p) => {
if (p.id === modelProvider.id) {
return data;
}
return p;
});
});
toast.success('Provider updated successfully.');
} catch (error) {
console.error('Error updating provider:', error);
toast.error('Failed to update provider.');
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
>
<Pencil
size={14}
className="text-black/60 dark:text-white/60 group-hover:text-black group-hover:dark:text-white"
/>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium">
Update provider
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex flex-col space-y-4">
<div
key="name"
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
Name*
</label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={'Provider Name'}
type="text"
required={true}
/>
</div>
{fields.map((field: UIConfigField) => (
<div
key={field.key}
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
{field.name}
{field.required && '*'}
</label>
<input
value={config[field.key] ?? field.default ?? ''}
onChange={(event) =>
setConfig((prev) => ({
...prev,
[field.key]: event.target.value,
}))
}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={
(field as StringUIConfigField).placeholder
}
type="text"
required={field.required}
/>
</div>
))}
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="px-6 py-4 flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-sm bg-sky-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? (
<Loader2 className="animate-spin" size={16} />
) : (
'Update Provider'
)}
</button>
</div>
</form>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default UpdateProvider;

View File

@@ -0,0 +1,29 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Search = ({
fields,
values,
}: {
fields: UIConfigField[];
values: Record<string, any>;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(field.scope === 'client'
? localStorage.getItem(field.key)
: values[field.key]) ?? field.default
}
dataAdd="search"
/>
))}
</div>
);
};
export default Search;

View File

@@ -0,0 +1,24 @@
import { Settings } from 'lucide-react';
import { useState } from 'react';
import SettingsDialogue from './SettingsDialogue';
import { AnimatePresence } from 'framer-motion';
const SettingsButton = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<>
<div
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer active:scale-95"
onClick={() => setIsOpen(true)}
>
<Settings size={19} className="cursor-pointer" />
</div>
<AnimatePresence>
{isOpen && <SettingsDialogue isOpen={isOpen} setIsOpen={setIsOpen} />}
</AnimatePresence>
</>
);
};
export default SettingsButton;

View File

@@ -0,0 +1,152 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { BrainCog, ChevronLeft, Search, Settings } from 'lucide-react';
import General from './Sections/General';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import Loader from '../ui/Loader';
import { cn } from '@/lib/utils';
import Models from './Sections/Models/Section';
import SearchSection from './Sections/Search';
const sections = [
{
name: 'General',
description: 'Adjust common settings.',
icon: Settings,
component: General,
dataAdd: 'general',
},
{
name: 'Models',
description: 'Configure model settings.',
icon: BrainCog,
component: Models,
dataAdd: 'modelProviders',
},
{
name: 'Search',
description: 'Manage search settings.',
icon: Search,
component: SearchSection,
dataAdd: 'search',
},
];
const SettingsDialogue = ({
isOpen,
setIsOpen,
}: {
isOpen: boolean;
setIsOpen: (active: boolean) => void;
}) => {
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<any>(null);
const [activeSection, setActiveSection] = useState(sections[0]);
useEffect(() => {
if (isOpen) {
const fetchConfig = async () => {
try {
const res = await fetch('/api/config', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await res.json();
setConfig(data);
} catch (error) {
console.error('Error fetching config:', error);
toast.error('Failed to load configuration.');
} finally {
setIsLoading(false);
}
};
fetchConfig();
}
}, [isOpen]);
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm h-screen"
>
<DialogPanel className="space-y-4 border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary backdrop-blur-lg rounded-xl h-[calc(100vh-2%)] w-[calc(100vw-2%)] md:h-[calc(100vh-7%)] md:w-[calc(100vw-7%)] lg:h-[calc(100vh-20%)] lg:w-[calc(100vw-30%)]">
{isLoading ? (
<div className="flex items-center justify-center h-full w-full">
<Loader />
</div>
) : (
<div className="flex flex-1 inset-0 h-full">
<div className="w-[240px] border-r border-white-200 dark:border-dark-200 h-full px-3 pt-3 flex flex-col">
<button
onClick={() => setIsOpen(false)}
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg"
>
<ChevronLeft
size={18}
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
/>
<p className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70 text-[14px]">
Back
</p>
</button>
<div className="flex flex-col items-start space-y-1 mt-8">
{sections.map((section) => (
<button
key={section.dataAdd}
className={cn(
`flex flex-row items-center space-x-2 px-2 py-1.5 rounded-lg w-full text-sm hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200 active:scale-95`,
activeSection.name === section.name
? 'bg-light-200 dark:bg-dark-200 text-black/90 dark:text-white/90'
: ' text-black/70 dark:text-white/70',
)}
onClick={() => setActiveSection(section)}
>
<section.icon size={17} />
<p>{section.name}</p>
</button>
))}
</div>
</div>
<div className="w-full">
{activeSection.component && (
<div className="flex h-full flex-col">
<div className="border-b border-light-200/60 px-6 pb-6 pt-8 dark:border-dark-200/60">
<div className="flex flex-col">
<h4 className="font-medium text-black dark:text-white">
{activeSection.name}
</h4>
<p className="text-xs text-black/50 dark:text-white/50">
{activeSection.description}
</p>
</div>
</div>
<activeSection.component
fields={config.fields[activeSection.dataAdd]}
values={config.values[activeSection.dataAdd]}
/>
</div>
)}
</div>
</div>
)}
</DialogPanel>
</motion.div>
</Dialog>
);
};
export default SettingsDialogue;

View File

@@ -0,0 +1,194 @@
import {
SelectUIConfigField,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import { useState } from 'react';
import Select from '../ui/Select';
import { toast } from 'sonner';
import { useTheme } from 'next-themes';
import { Loader2 } from 'lucide-react';
const SettingsSelect = ({
field,
value,
setValue,
dataAdd,
}: {
field: SelectUIConfigField;
value?: any;
setValue: (value: any) => void;
dataAdd: string;
}) => {
const [loading, setLoading] = useState(false);
const { setTheme } = useTheme();
const handleSave = async (newValue: any) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, newValue);
if (field.key === 'theme') {
setTheme(newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-5">
<div>
<h4 className="text-base text-black dark:text-white">{field.name}</h4>
<p className="text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<Select
value={value}
onChange={(event) => handleSave(event.target.value)}
options={field.options.map((option) => ({
value: option.value,
label: option.name,
}))}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer capitalize pr-12"
loading={loading}
disabled={loading}
/>
</div>
</section>
);
};
const SettingsInput = ({
field,
value,
setValue,
dataAdd,
}: {
field: StringUIConfigField;
value?: any;
setValue: (value: any) => void;
dataAdd: string;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: any) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, newValue);
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-5">
<div>
<h4 className="text-base text-black dark:text-white">{field.name}</h4>
<p className="text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<div className="relative">
<input
value={value ?? field.default ?? ''}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => handleSave(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={field.placeholder}
type="text"
disabled={loading}
/>
{loading && (
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40">
<Loader2 className="h-4 w-4 animate-spin" />
</span>
)}
</div>
</div>
</section>
);
};
const SettingsField = ({
field,
value,
dataAdd,
}: {
field: UIConfigField;
value: any;
dataAdd: string;
}) => {
const [val, setVal] = useState(value);
switch (field.type) {
case 'select':
return (
<SettingsSelect
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
/>
);
case 'string':
return (
<SettingsInput
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
/>
);
default:
return <div>Unsupported field type: {field.type}</div>;
}
};
export default SettingsField;

View File

@@ -1,20 +1,34 @@
'use client';
import { cn } from '@/lib/utils';
import { BookOpenText, Home, Search, SquarePen, Settings } from 'lucide-react';
import {
BookOpenText,
Home,
Search,
SquarePen,
Settings,
Plus,
ArrowLeft,
} from 'lucide-react';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useState, type ReactNode } from 'react';
import Layout from './Layout';
import {
Description,
Dialog,
DialogPanel,
DialogTitle,
} from '@headlessui/react';
import SettingsButton from './Settings/SettingsButton';
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
return (
<div className="flex flex-col items-center gap-y-3 w-full">{children}</div>
);
return <div className="flex flex-col items-center w-full">{children}</div>;
};
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const [isOpen, setIsOpen] = useState<boolean>(true);
const navLinks = [
{
@@ -39,10 +53,13 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-20 lg:flex-col">
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-8 mx-2 my-2 rounded-2xl shadow-sm shadow-light-200/10 dark:shadow-black/25">
<a href="/">
<SquarePen className="cursor-pointer" />
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[72px] lg:flex-col border-r border-light-200 dark:border-dark-200">
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-8 shadow-sm shadow-light-200/10 dark:shadow-black/25">
<a
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 tansition duration-200"
href="/"
>
<Plus size={19} className="cursor-pointer" />
</a>
<VerticalIconContainer>
{navLinks.map((link, i) => (
@@ -50,23 +67,41 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
key={i}
href={link.href}
className={cn(
'relative flex flex-row items-center justify-center cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 duration-150 transition w-full py-2 rounded-lg',
'relative flex flex-col items-center justify-center space-y-0.5 cursor-pointer w-full py-2 rounded-lg',
link.active
? 'text-black dark:text-white'
: 'text-black/70 dark:text-white/70',
? 'text-black/70 dark:text-white/70 '
: 'text-black/60 dark:text-white/60',
)}
>
<link.icon />
{link.active && (
<div className="absolute right-0 -mr-2 h-full w-1 rounded-l-lg bg-black dark:bg-white" />
)}
<div
className={cn(
link.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<link.icon
size={25}
className={cn(
!link.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
<p
className={cn(
link.active
? 'text-black/80 dark:text-white/80'
: 'text-black/60 dark:text-white/60',
'text-[10px]',
)}
>
{link.label}
</p>
</Link>
))}
</VerticalIconContainer>
<Link href="/settings">
<Settings className="cursor-pointer" />
</Link>
<SettingsButton />
</div>
</div>

View File

@@ -0,0 +1,22 @@
const Loader = () => {
return (
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
};
export default Loader;

View File

@@ -1,28 +1,50 @@
import { cn } from '@/lib/utils';
import { SelectHTMLAttributes } from 'react';
import { Loader2, ChevronDown } from 'lucide-react';
import { SelectHTMLAttributes, forwardRef } from 'react';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: { value: string; label: string; disabled?: boolean }[];
loading?: boolean;
}
export const Select = ({ className, options, ...restProps }: SelectProps) => {
return (
<select
{...restProps}
className={cn(
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
className,
)}
>
{options.map(({ label, value, disabled }) => {
return (
<option key={value} value={value} disabled={disabled}>
{label}
</option>
);
})}
</select>
);
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, options, loading = false, disabled, ...restProps }, ref) => {
return (
<div
className={cn(
'relative inline-flex w-full items-center',
disabled && 'opacity-60',
)}
>
<select
{...restProps}
ref={ref}
disabled={disabled || loading}
className={cn(
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm appearance-none w-full pr-10',
className,
)}
>
{options.map(({ label, value, disabled: optionDisabled }) => {
return (
<option key={value} value={value} disabled={optionDisabled}>
{label}
</option>
);
})}
</select>
<span className="pointer-events-none absolute right-3 flex h-4 w-4 items-center justify-center text-black/50 dark:text-white/60">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</span>
</div>
);
},
);
Select.displayName = 'Select';
export default Select;

View File

@@ -20,7 +20,27 @@ class ConfigManager {
},
};
uiConfigSections: UIConfigSections = {
general: [],
general: [
{
name: 'Theme',
key: 'theme',
type: 'select',
options: [
{
name: 'Light',
value: 'light',
},
{
name: 'Dark',
value: 'dark',
},
],
required: false,
description: 'Choose between light and dark layouts for the app.',
default: 'dark',
scope: 'client',
},
],
modelProviders: [],
search: [
{
@@ -205,6 +225,8 @@ class ConfigManager {
this.currentConfig.modelProviders.push(newModelProvider);
this.saveConfig();
return newModelProvider;
}
public removeModelProvider(id: string) {
@@ -220,6 +242,69 @@ class ConfigManager {
this.saveConfig();
}
public async updateModelProvider(id: string, name: string, config: any) {
const provider = this.currentConfig.modelProviders.find((p) => {
return p.id === id;
});
if (!provider) throw new Error('Provider not found');
provider.name = name;
provider.config = config;
this.saveConfig();
return provider;
}
public addProviderModel(
providerId: string,
type: 'embedding' | 'chat',
model: any,
) {
const provider = this.currentConfig.modelProviders.find(
(p) => p.id === providerId,
);
if (!provider) throw new Error('Invalid provider id');
delete model.type;
if (type === 'chat') {
provider.chatModels.push(model);
} else {
provider.embeddingModels.push(model);
}
this.saveConfig();
return model;
}
public removeProviderModel(
providerId: string,
type: 'embedding' | 'chat',
modelKey: string,
) {
const provider = this.currentConfig.modelProviders.find(
(p) => p.id === providerId,
);
if (!provider) throw new Error('Invalid provider id');
if (type === 'chat') {
provider.chatModels = provider.chatModels.filter(
(m) => m.key !== modelKey,
);
} else {
provider.embeddingModels = provider.embeddingModels.filter(
(m) => m.key != modelKey,
);
}
this.saveConfig();
}
public isSetupComplete() {
return this.currentConfig.setupComplete;
}
@@ -235,6 +320,10 @@ class ConfigManager {
public getUIConfigSections(): UIConfigSections {
return this.uiConfigSections;
}
public getCurrentConfig(): Config {
return JSON.parse(JSON.stringify(this.currentConfig));
}
}
const configManager = new ConfigManager();

View File

@@ -11,4 +11,5 @@ export const getConfiguredModelProviderById = (
return getConfiguredModelProviders().find((p) => p.id === id) ?? undefined;
};
export const getSearxngURL = () => configManager.getConfig('search.searxngURL', '')
export const getSearxngURL = () =>
configManager.getConfig('search.searxngURL', '');

View File

@@ -17,7 +17,6 @@ type StringUIConfigField = BaseUIConfigField & {
type SelectUIConfigFieldOptions = {
name: string;
key: string;
value: string;
};
@@ -56,8 +55,8 @@ type Config = {
};
modelProviders: ConfigModelProvider[];
search: {
[key: string]: any
}
[key: string]: any;
};
};
type EnvMap = {
@@ -76,7 +75,7 @@ type ModelProviderUISection = {
type UIConfigSections = {
general: UIConfigField[];
modelProviders: ModelProviderUISection[];
search: UIConfigField[];
search: UIConfigField[];
};
export type {
@@ -84,6 +83,8 @@ export type {
Config,
EnvMap,
UIConfigSections,
SelectUIConfigField,
StringUIConfigField,
ModelProviderUISection,
ConfigModelProvider,
};

View File

@@ -1,9 +1,11 @@
import { ModelProviderUISection } from '@/lib/config/types';
import { ProviderConstructor } from './baseProvider';
import OpenAIProvider from './openai';
import OllamaProvider from './ollama';
export const providers: Record<string, ProviderConstructor<any>> = {
openai: OpenAIProvider,
ollama: OllamaProvider,
};
export const getModelProvidersUIConfigSection =

View File

@@ -0,0 +1,137 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { Model, ModelList, ProviderMetadata } from '../types';
import BaseModelProvider from './baseProvider';
import { ChatOllama, OllamaEmbeddings } from '@langchain/ollama';
import { Embeddings } from '@langchain/core/embeddings';
import { UIConfigField } from '@/lib/config/types';
import { getConfiguredModelProviderById } from '@/lib/config/serverRegistry';
interface OllamaConfig {
baseURL: string;
}
const providerConfigFields: UIConfigField[] = [
{
type: 'string',
name: 'Base URL',
key: 'baseURL',
description: 'The base URL for the Ollama',
required: true,
placeholder: 'Ollama Base URL',
default: process.env.DOCKER
? 'http://host.docker.internal:11434'
: 'http://localhost:11434',
env: 'OLLAMA_BASE_URL',
scope: 'server',
},
];
class OllamaProvider extends BaseModelProvider<OllamaConfig> {
constructor(id: string, name: string, config: OllamaConfig) {
super(id, name, config);
}
async getDefaultModels(): Promise<ModelList> {
try {
const res = await fetch(`${this.config.baseURL}/api/tags`, {
method: 'GET',
headers: {
'Content-type': 'application/json',
},
});
const data = await res.json();
const models: Model[] = data.models.map((m: any) => {
return {
name: m.name,
key: m.model,
};
});
return {
embedding: models,
chat: models,
};
} catch (err) {
if (err instanceof TypeError) {
throw new Error(
'Error connecting to Ollama API. Please ensure the base URL is correct and the Ollama server is running.',
);
}
throw err;
}
}
async getModelList(): Promise<ModelList> {
const defaultModels = await this.getDefaultModels();
const configProvider = getConfiguredModelProviderById(this.id)!;
return {
embedding: [
...defaultModels.embedding,
...configProvider.embeddingModels,
],
chat: [...defaultModels.chat, ...configProvider.chatModels],
};
}
async loadChatModel(key: string): Promise<BaseChatModel> {
const modelList = await this.getModelList();
const exists = modelList.chat.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Ollama Chat Model. Invalid Model Selected',
);
}
return new ChatOllama({
temperature: 0.7,
model: key,
baseUrl: this.config.baseURL,
});
}
async loadEmbeddingModel(key: string): Promise<Embeddings> {
const modelList = await this.getModelList();
const exists = modelList.embedding.find((m) => m.key === key);
if (!exists) {
throw new Error(
'Error Loading Ollama Embedding Model. Invalid Model Selected.',
);
}
return new OllamaEmbeddings({
model: key,
baseUrl: this.config.baseURL,
});
}
static parseAndValidate(raw: any): OllamaConfig {
if (!raw || typeof raw !== 'object')
throw new Error('Invalid config provided. Expected object');
if (!raw.baseURL)
throw new Error('Invalid config provided. Base URL must be provided');
return {
baseURL: String(raw.baseURL),
};
}
static getProviderConfigFields(): UIConfigField[] {
return providerConfigFields;
}
static getProviderMetadata(): ProviderMetadata {
return {
key: 'ollama',
name: 'Ollama',
};
}
}
export default OllamaProvider;

View File

@@ -4,7 +4,8 @@ import BaseModelProvider, {
} from './providers/baseProvider';
import { getConfiguredModelProviders } from '../config/serverRegistry';
import { providers } from './providers';
import { MinimalProvider, Model } from './types';
import { MinimalProvider, ModelList } from './types';
import configManager from '../config';
class ModelRegistry {
activeProviders: (ConfigModelProvider & {
@@ -40,7 +41,25 @@ class ModelRegistry {
await Promise.all(
this.activeProviders.map(async (p) => {
const m = await p.provider.getModelList();
let m: ModelList = { chat: [], embedding: [] };
try {
m = await p.provider.getModelList();
} catch (err: any) {
console.error(
`Failed to get model list. Type: ${p.type}, ID: ${p.id}, Error: ${err.message}`,
);
m = {
chat: [
{
key: 'error',
name: err.message,
},
],
embedding: [],
};
}
providers.push({
id: p.id,
@@ -73,6 +92,132 @@ class ModelRegistry {
return model;
}
async addProvider(
type: string,
name: string,
config: Record<string, any>,
): Promise<ConfigModelProvider> {
const provider = providers[type];
if (!provider) throw new Error('Invalid provider type');
const newProvider = configManager.addModelProvider(type, name, config);
const instance = createProviderInstance(
provider,
newProvider.id,
newProvider.name,
newProvider.config,
);
let m: ModelList = { chat: [], embedding: [] };
try {
m = await instance.getModelList();
} catch (err: any) {
console.error(
`Failed to get model list for newly added provider. Type: ${type}, ID: ${newProvider.id}, Error: ${err.message}`,
);
m = {
chat: [
{
key: 'error',
name: err.message,
},
],
embedding: [],
};
}
this.activeProviders.push({
...newProvider,
provider: instance,
});
return {
...newProvider,
chatModels: m.chat || [],
embeddingModels: m.embedding || [],
};
}
async removeProvider(providerId: string): Promise<void> {
configManager.removeModelProvider(providerId);
this.activeProviders = this.activeProviders.filter(
(p) => p.id !== providerId,
);
return;
}
async updateProvider(
providerId: string,
name: string,
config: any,
): Promise<ConfigModelProvider> {
const updated = await configManager.updateModelProvider(
providerId,
name,
config,
);
const instance = createProviderInstance(
providers[updated.type],
providerId,
name,
config,
);
let m: ModelList = { chat: [], embedding: [] };
try {
m = await instance.getModelList();
} catch (err: any) {
console.error(
`Failed to get model list for updated provider. Type: ${updated.type}, ID: ${updated.id}, Error: ${err.message}`,
);
m = {
chat: [
{
key: 'error',
name: err.message,
},
],
embedding: [],
};
}
this.activeProviders.push({
...updated,
provider: instance,
});
return {
...updated,
chatModels: m.chat || [],
embeddingModels: m.embedding || [],
};
}
/* Using async here because maybe in the future we might want to add some validation?? */
async addProviderModel(
providerId: string,
type: 'embedding' | 'chat',
model: any,
): Promise<any> {
const addedModel = configManager.addProviderModel(providerId, type, model);
return addedModel;
}
async removeProviderModel(
providerId: string,
type: 'embedding' | 'chat',
modelKey: string,
): Promise<void> {
configManager.removeProviderModel(providerId, type, modelKey);
return;
}
}
export default ModelRegistry;

View File

@@ -11,8 +11,8 @@ const themeDark = (colors: DefaultColors) => ({
const themeLight = (colors: DefaultColors) => ({
50: '#ffffff',
100: '#f6f8fa',
200: '#d0d7de',
300: '#afb8c1',
200: '#e8edf1',
300: '#d0d7de',
});
const config: Config = {