Skip to content

Commit 0f958a8

Browse files
Revamp AI Tab (#819)
1 parent dc1201f commit 0f958a8

File tree

9 files changed

+993
-575
lines changed

9 files changed

+993
-575
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./llm-view";
2+
export * from "./stt-view";
3+
export * from "./wer-modal";
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { zodResolver } from "@hookform/resolvers/zod";
2+
import { commands as connectorCommands, type Connection } from "@hypr/plugin-connector";
3+
import {
4+
Form,
5+
FormControl,
6+
FormDescription,
7+
FormField,
8+
FormItem,
9+
FormLabel,
10+
FormMessage,
11+
} from "@hypr/ui/components/ui/form";
12+
import { Input } from "@hypr/ui/components/ui/input";
13+
import { Label } from "@hypr/ui/components/ui/label";
14+
import { RadioGroup, RadioGroupItem } from "@hypr/ui/components/ui/radio-group";
15+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select";
16+
import { cn } from "@hypr/ui/lib/utils";
17+
import { Trans } from "@lingui/react/macro";
18+
import { useMutation, useQuery } from "@tanstack/react-query";
19+
import { useEffect } from "react";
20+
import { useForm } from "react-hook-form";
21+
import { z } from "zod";
22+
23+
const endpointSchema = z.object({
24+
model: z.string().min(1),
25+
api_base: z.string().url({ message: "Please enter a valid URL" }).min(1, { message: "URL is required" }).refine(
26+
(value) => {
27+
const v1Needed = ["openai", "openrouter"].some((host) => value.includes(host));
28+
if (v1Needed && !value.endsWith("/v1")) {
29+
return false;
30+
}
31+
32+
return true;
33+
},
34+
{ message: "Should end with '/v1'" },
35+
).refine(
36+
(value) => !value.includes("chat/completions"),
37+
{ message: "`/chat/completions` will be appended automatically" },
38+
),
39+
api_key: z.string().optional(),
40+
});
41+
42+
export type FormValues = z.infer<typeof endpointSchema>;
43+
44+
export function LLMView() {
45+
const customLLMConnection = useQuery({
46+
queryKey: ["custom-llm-connection"],
47+
queryFn: () => connectorCommands.getCustomLlmConnection(),
48+
});
49+
50+
const availableLLMModels = useQuery({
51+
queryKey: ["available-llm-models"],
52+
queryFn: () => connectorCommands.listCustomLlmModels(),
53+
enabled: !!customLLMConnection.data?.api_base,
54+
});
55+
56+
const getCustomLLMModel = useQuery({
57+
queryKey: ["custom-llm-model"],
58+
queryFn: () => connectorCommands.getCustomLlmModel(),
59+
});
60+
61+
const setCustomLLMModel = useMutation({
62+
mutationFn: (model: string) => connectorCommands.setCustomLlmModel(model),
63+
});
64+
65+
const setCustomLLMConnection = useMutation({
66+
mutationFn: (connection: Connection) => connectorCommands.setCustomLlmConnection(connection),
67+
onError: console.error,
68+
onSuccess: () => {
69+
customLLMConnection.refetch();
70+
},
71+
});
72+
73+
const customLLMEnabled = useQuery({
74+
queryKey: ["custom-llm-enabled"],
75+
queryFn: () => connectorCommands.getCustomLlmEnabled(),
76+
});
77+
78+
const setCustomLLMEnabled = useMutation({
79+
mutationFn: (enabled: boolean) => connectorCommands.setCustomLlmEnabled(enabled),
80+
onSuccess: () => {
81+
customLLMEnabled.refetch();
82+
},
83+
});
84+
85+
const form = useForm<FormValues>({
86+
resolver: zodResolver(endpointSchema),
87+
mode: "onChange",
88+
});
89+
90+
useEffect(() => {
91+
if (customLLMConnection.data) {
92+
form.reset({
93+
model: getCustomLLMModel.data || "",
94+
api_base: customLLMConnection.data.api_base,
95+
api_key: customLLMConnection.data.api_key || "",
96+
});
97+
} else {
98+
form.reset({ model: "", api_base: "", api_key: "" });
99+
}
100+
}, [getCustomLLMModel.data, customLLMConnection.data, form.reset]);
101+
102+
useEffect(() => {
103+
const subscription = form.watch((value, { name }) => {
104+
if (!form.formState.errors.model && value.model) {
105+
setCustomLLMModel.mutate(value.model);
106+
}
107+
108+
if (!form.formState.errors.api_base && value.api_base) {
109+
setCustomLLMConnection.mutate({
110+
api_base: value.api_base,
111+
api_key: value.api_key || null,
112+
});
113+
}
114+
});
115+
116+
return () => subscription.unsubscribe();
117+
}, [form]);
118+
119+
const isLocalEndpoint = () => {
120+
const apiBase = form.watch("api_base");
121+
return apiBase && (apiBase.includes("localhost") || apiBase.includes("127.0.0.1"));
122+
};
123+
124+
const currentLLM = customLLMEnabled.data ? "custom" : "llama-3.2-3b-q4";
125+
126+
return (
127+
<RadioGroup
128+
value={currentLLM}
129+
onValueChange={(value) => {
130+
setCustomLLMEnabled.mutate(value === "custom");
131+
}}
132+
className="space-y-4"
133+
>
134+
<Label
135+
htmlFor="llama-3.2-3b-q4"
136+
className={cn(
137+
"p-4 rounded-lg shadow-sm transition-all duration-150 ease-in-out",
138+
currentLLM === "llama-3.2-3b-q4"
139+
? "border border-blue-500 ring-2 ring-blue-500 bg-blue-50"
140+
: "border border-neutral-200 bg-white hover:border-neutral-300",
141+
"cursor-pointer flex flex-col gap-2",
142+
)}
143+
>
144+
<div className="flex items-start justify-between w-full">
145+
<div className="flex items-center">
146+
<RadioGroupItem value="llama-3.2-3b-q4" id="llama-3.2-3b-q4" className="peer sr-only" />
147+
<div className="flex flex-col">
148+
<span className="font-medium">
149+
<Trans>Default (llama-3.2-3b-q4)</Trans>
150+
</span>
151+
<p className="text-xs font-normal text-neutral-500 mt-1">
152+
<Trans>Use the local Llama 3.2 model for enhanced privacy and offline capability.</Trans>
153+
</p>
154+
</div>
155+
</div>
156+
</div>
157+
</Label>
158+
159+
<Label
160+
htmlFor="custom"
161+
className={cn(
162+
"p-4 rounded-lg shadow-sm transition-all duration-150 ease-in-out",
163+
currentLLM === "custom"
164+
? "border border-blue-500 ring-2 ring-blue-500 bg-blue-50"
165+
: "border border-neutral-200 bg-white hover:border-neutral-300",
166+
"cursor-pointer flex flex-col gap-2",
167+
)}
168+
>
169+
<div className="flex items-center justify-between w-full">
170+
<div className="flex items-center">
171+
<RadioGroupItem value="custom" id="custom" className="peer sr-only" />
172+
<div className="flex flex-col">
173+
<span className="font-medium">
174+
<Trans>Custom Endpoint</Trans>
175+
</span>
176+
<p className="text-xs font-normal text-neutral-500 mt-1">
177+
<Trans>Connect to a self-hosted or third-party LLM endpoint (OpenAI API compatible).</Trans>
178+
</p>
179+
</div>
180+
</div>
181+
</div>
182+
183+
<div
184+
className={cn(
185+
"mt-4 pt-4 border-t transition-opacity duration-200",
186+
customLLMEnabled.data ? "opacity-100" : "opacity-50 pointer-events-none",
187+
)}
188+
>
189+
<Form {...form}>
190+
<form className="space-y-4">
191+
<FormField
192+
control={form.control}
193+
name="api_base"
194+
render={({ field }) => (
195+
<FormItem>
196+
<FormLabel className="text-sm font-medium">
197+
<Trans>API Base URL</Trans>
198+
</FormLabel>
199+
<FormDescription className="text-xs">
200+
<Trans>
201+
Enter the base URL for your custom LLM endpoint (e.g., http://localhost:8080/v1)
202+
</Trans>
203+
</FormDescription>
204+
<FormControl>
205+
<Input
206+
{...field}
207+
placeholder="http://localhost:8080/v1"
208+
disabled={!customLLMEnabled.data}
209+
/>
210+
</FormControl>
211+
<FormMessage />
212+
</FormItem>
213+
)}
214+
/>
215+
216+
{form.watch("api_base") && !isLocalEndpoint() && (
217+
<FormField
218+
control={form.control}
219+
name="api_key"
220+
render={({ field }) => (
221+
<FormItem>
222+
<FormLabel className="text-sm font-medium">
223+
<Trans>API Key</Trans>
224+
</FormLabel>
225+
<FormDescription className="text-xs">
226+
<Trans>Enter the API key for your custom LLM endpoint</Trans>
227+
</FormDescription>
228+
<FormControl>
229+
<Input
230+
{...field}
231+
type="password"
232+
placeholder="sk-..."
233+
disabled={!customLLMEnabled.data}
234+
/>
235+
</FormControl>
236+
<FormMessage />
237+
</FormItem>
238+
)}
239+
/>
240+
)}
241+
242+
<FormField
243+
control={form.control}
244+
name="model"
245+
render={({ field }) => (
246+
<FormItem>
247+
<FormLabel className="text-sm font-medium">
248+
<Trans>Model Name</Trans>
249+
</FormLabel>
250+
<FormDescription className="text-xs">
251+
<Trans>
252+
Select or enter the model name required by your endpoint.
253+
</Trans>
254+
</FormDescription>
255+
<FormControl>
256+
{availableLLMModels.isLoading
257+
? (
258+
<div className="py-1 text-sm text-neutral-500">
259+
<Trans>Loading available models...</Trans>
260+
</div>
261+
)
262+
: availableLLMModels.data && availableLLMModels.data.length > 0
263+
? (
264+
<Select
265+
defaultValue={field.value}
266+
onValueChange={(value: string) => {
267+
field.onChange(value);
268+
setCustomLLMModel.mutate(value);
269+
}}
270+
disabled={!customLLMEnabled.data}
271+
>
272+
<SelectTrigger>
273+
<SelectValue placeholder="Select model" />
274+
</SelectTrigger>
275+
<SelectContent>
276+
{availableLLMModels.data.map((model) => (
277+
<SelectItem key={model} value={model}>
278+
{model}
279+
</SelectItem>
280+
))}
281+
</SelectContent>
282+
</Select>
283+
)
284+
: (
285+
<div className="py-1 text-sm text-neutral-500">
286+
<Trans>No models available for this endpoint.</Trans>
287+
</div>
288+
)}
289+
</FormControl>
290+
<FormMessage />
291+
</FormItem>
292+
)}
293+
/>
294+
</form>
295+
</Form>
296+
</div>
297+
</Label>
298+
</RadioGroup>
299+
);
300+
}

0 commit comments

Comments
 (0)