feat: more llm provider options (google, mistral) (#28)

* feat: add google provider

* fix: default for google model

* feat: multiple providers

* fix: defaults from env for login form

* fix: add mistral to env files

* chore: delete unused code

* chore: revert database url to original

* fix: render default value for api key from env on server

* fix: type errors during compilation

---------

Co-authored-by: Vasily Zubarev <me@vas3k.ru>
This commit is contained in:
Dmitrii Anfimov
2025-07-22 21:49:54 +02:00
committed by GitHub
parent 9903325f17
commit dee915ffd6
21 changed files with 1185 additions and 150 deletions

View File

@@ -3,44 +3,91 @@
import { fieldsToJsonSchema } from "@/ai/schema"
import { saveSettingsAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Card, CardTitle } from "@/components/ui/card"
import { Field } from "@/prisma/client"
import { CircleCheckBig, Edit } from "lucide-react"
import { CircleCheckBig, Edit, GripVertical } from "lucide-react"
import Link from "next/link"
import { useActionState } from "react"
import { useState, useActionState } from "react"
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors
} from "@dnd-kit/core"
import type { DragEndEvent } from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy
} from "@dnd-kit/sortable"
import { PROVIDERS } from "@/lib/llm-providers";
function getInitialProviderOrder(settings: Record<string, string>) {
let order: string[] = []
if (!settings.llm_providers) {
order = ['openai', 'google', 'mistral']
} else {
order = settings.llm_providers.split(",").map(p => p.trim())
}
// Remove duplicates and keep only valid providers
return order.filter((key, idx) => PROVIDERS.some(p => p.key === key) && order.indexOf(key) === idx)
}
export default function LLMSettingsForm({
settings,
fields,
showApiKey = true,
}: {
settings: Record<string, string>
fields: Field[]
showApiKey?: boolean
}) {
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
const [providerOrder, setProviderOrder] = useState<string[]>(getInitialProviderOrder(settings))
// Controlled values for each provider
const [providerValues, setProviderValues] = useState(() => {
const values: Record<string, { apiKey: string; model: string }> = {}
PROVIDERS.forEach((provider) => {
values[provider.key] = {
apiKey: settings[provider.apiKeyName],
model: settings[provider.modelName] || provider.defaultModelName,
}
})
return values
})
function handleProviderValueChange(providerKey: string, field: "apiKey" | "model", value: string) {
setProviderValues((prev) => ({
...prev,
[providerKey]: {
...prev[providerKey],
[field]: value,
},
}))
}
return (
<>
<form action={saveAction} className="space-y-4">
{showApiKey && (
<>
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
<small className="text-muted-foreground">
Get your API key from{" "}
<a
href="https://platform.openai.com/settings/organization/api-keys"
target="_blank"
className="underline"
>
OpenAI Platform Console
</a>
</small>
</>
)}
<div className="space-y-2">
<label className="text-sm font-medium">LLM providers</label>
<DndProviderBlocks
providerOrder={providerOrder}
setProviderOrder={setProviderOrder}
providerValues={providerValues}
handleProviderValueChange={handleProviderValueChange}
/>
<small className="text-muted-foreground">
Drag provider blocks to reorder. First is highest priority.
</small>
</div>
<input type="hidden" name="llm_providers" value={providerOrder.join(",")} />
<FormTextarea
title="Prompt for File Analysis Form"
@@ -90,3 +137,106 @@ export default function LLMSettingsForm({
</>
)
}
type DndProviderBlocksProps = {
providerOrder: string[];
setProviderOrder: React.Dispatch<React.SetStateAction<string[]>>;
providerValues: Record<string, { apiKey: string; model: string }>;
handleProviderValueChange: (providerKey: string, field: "apiKey" | "model", value: string) => void;
};
function DndProviderBlocks({ providerOrder, setProviderOrder, providerValues, handleProviderValueChange }: DndProviderBlocksProps) {
const sensors = useSensors(useSensor(PointerSensor))
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = providerOrder.indexOf(active.id as string)
const newIndex = providerOrder.indexOf(over.id as string)
setProviderOrder(arrayMove(providerOrder, oldIndex, newIndex))
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={providerOrder} strategy={verticalListSortingStrategy}>
{providerOrder.map((providerKey, idx) => (
<SortableProviderBlock
key={providerKey}
id={providerKey}
idx={idx}
providerKey={providerKey}
value={providerValues[providerKey]}
handleValueChange={handleProviderValueChange}
/>
))}
</SortableContext>
</DndContext>
)
}
type SortableProviderBlockProps = {
id: string;
idx: number;
providerKey: string;
value: { apiKey: string; model: string };
handleValueChange: (providerKey: string, field: "apiKey" | "model", value: string) => void;
};
function SortableProviderBlock({ id, idx, providerKey, value, handleValueChange }: SortableProviderBlockProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
const provider = PROVIDERS.find(p => p.key === providerKey)
if (!provider) return null
return (
<div
ref={setNodeRef}
style={{
transform: transform ? `translateY(${transform.y}px)` : undefined,
transition,
opacity: isDragging ? 0.6 : 1,
}}
className={`bg-muted rounded-lg p-4 shadow flex flex-col gap-2 mb-2`}
>
<div className="flex flex-row items-center gap-2 mb-2">
{/* Drag handle */}
<span
{...attributes}
{...listeners}
className="cursor-grab p-1 rounded hover:bg-accent transition inline-flex items-center"
aria-label="Drag to reorder"
>
<GripVertical className="w-5 h-5 text-muted-foreground" />
</span>
<span className="font-semibold">{provider.label}</span>
</div>
<div className="flex flex-row gap-4 items-center">
<input
type="text"
name={provider.apiKeyName}
value={value.apiKey}
onChange={e => handleValueChange(provider.key, "apiKey", e.target.value)}
className="flex-1 border rounded px-2 py-1"
placeholder="API key"
/>
<input
type="text"
name={provider.modelName}
value={value.model}
onChange={e => handleValueChange(provider.key, "model", e.target.value)}
className="flex-1 border rounded px-2 py-1"
placeholder="Model name"
/>
</div>
{provider.apiDoc && (
<small className="text-muted-foreground">
Get your API key from{" "}
<a
href={provider.apiDoc}
target="_blank"
className="underline"
>
{provider.apiDocLabel}
</a>
</small>
)}
</div>
)
}