"
4 | labels: [provider, feature]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Request integration for a new AI provider. We use [models.dev](https://models.dev) for auto-discovery when possible.
10 |
11 | - type: input
12 | id: provider_name
13 | attributes:
14 | label: Provider Name
15 | description: "Official name of the AI provider"
16 | placeholder: "e.g., Cohere, Together AI, Replicate"
17 | validations:
18 | required: true
19 |
20 | - type: input
21 | id: provider_url
22 | attributes:
23 | label: Provider Website / API Docs
24 | placeholder: "https://..."
25 | validations:
26 | required: true
27 |
28 | - type: dropdown
29 | id: compatibility
30 | attributes:
31 | label: API Compatibility
32 | description: "Is the provider OpenAI-compatible?"
33 | options:
34 | - OpenAI-compatible (drop-in replacement)
35 | - Anthropic-compatible
36 | - Custom API (needs adapter)
37 | - Not sure
38 | validations:
39 | required: true
40 |
41 | - type: input
42 | id: base_url
43 | attributes:
44 | label: Base URL / API Endpoint
45 | placeholder: "https://api.provider.com/v1"
46 |
47 | - type: dropdown
48 | id: models_dev
49 | attributes:
50 | label: Is this provider in models.dev?
51 | description: "Check https://models.dev/api.json to see if it's already listed"
52 | options:
53 | - "Yes - already in models.dev"
54 | - "No - needs to be added"
55 | - "Not sure"
56 | validations:
57 | required: true
58 |
59 | - type: textarea
60 | id: models
61 | attributes:
62 | label: Popular Models to Support
63 | description: "List the key models from this provider"
64 | placeholder: |
65 | - command-r-plus
66 | - command-r
67 | - embed-english-v3.0
68 |
69 | - type: textarea
70 | id: auth
71 | attributes:
72 | label: Authentication Method
73 | description: "How does this provider handle API keys?"
74 | placeholder: |
75 | - API key in Authorization header
76 | - Bearer token
77 | - Custom header
78 | - etc.
79 |
80 | - type: textarea
81 | id: special_features
82 | attributes:
83 | label: Special Features / Considerations
84 | description: "Anything unique about this provider? Rate limits, special capabilities, etc."
85 | placeholder: |
86 | - Supports vision models
87 | - Requires OAuth instead of API key
88 | - Has aggressive rate limiting
89 | - Offers free tier
90 |
91 | - type: checkboxes
92 | id: checklist
93 | attributes:
94 | label: Checklist
95 | options:
96 | - label: I checked the Roadmap to see if this is already planned
97 | - label: I searched existing issues for this provider
98 | - label: I'm willing to help test this integration
99 |
--------------------------------------------------------------------------------
/frontend/src/lib/providersClient.js:
--------------------------------------------------------------------------------
1 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : "";
2 |
3 | class ProvidersClient {
4 | async _fetch(endpoint, options = {}) {
5 | const response = await fetch(`${API_BASE}${endpoint}`, {
6 | ...options,
7 | credentials: "include",
8 | headers: {
9 | "Content-Type": "application/json",
10 | ...options.headers,
11 | },
12 | });
13 |
14 | const data = await response.json();
15 |
16 | if (!response.ok) {
17 | const error = new Error(data.error || "Request failed");
18 | error.response = data;
19 | error.status = response.status;
20 | console.error("API Error:", {
21 | endpoint,
22 | status: response.status,
23 | error: data.error,
24 | details: data.details,
25 | });
26 | throw error;
27 | }
28 |
29 | return data;
30 | }
31 |
32 | async getProviders() {
33 | return this._fetch("/api/admin/providers");
34 | }
35 |
36 | async getAvailableProviders() {
37 | return this._fetch("/api/admin/providers/available");
38 | }
39 |
40 | async createProvider(name, displayName, providerType, baseUrl, apiKey) {
41 | return this._fetch("/api/admin/providers", {
42 | method: "POST",
43 | body: JSON.stringify({ name, displayName, providerType, baseUrl, apiKey }),
44 | });
45 | }
46 |
47 | async updateProvider(providerId, updates) {
48 | return this._fetch(`/api/admin/providers/${providerId}`, {
49 | method: "PUT",
50 | body: JSON.stringify(updates),
51 | });
52 | }
53 |
54 | async refreshModels(providerId) {
55 | return this._fetch(`/api/admin/providers/${providerId}/refresh-models`, {
56 | method: "POST",
57 | });
58 | }
59 |
60 | async deleteProvider(providerId) {
61 | return this._fetch(`/api/admin/providers/${providerId}`, {
62 | method: "DELETE",
63 | });
64 | }
65 |
66 | async setAllModelsEnabled(providerId, enabled) {
67 | return this._fetch(`/api/admin/providers/${providerId}/models/enable`, {
68 | method: "POST",
69 | body: JSON.stringify({ enabled }),
70 | });
71 | }
72 |
73 | async getAllModels() {
74 | return this._fetch("/api/admin/models");
75 | }
76 |
77 | async getEnabledModels() {
78 | return this._fetch("/api/models");
79 | }
80 |
81 | async getEnabledModelsByType(modelType) {
82 | const params = modelType ? `?type=${modelType}` : "";
83 | return this._fetch(`/api/models${params}`);
84 | }
85 |
86 | async updateModel(modelId, updates) {
87 | return this._fetch(`/api/admin/models/${modelId}`, {
88 | method: "PUT",
89 | body: JSON.stringify(updates),
90 | });
91 | }
92 |
93 | async setDefaultModel(modelId) {
94 | return this._fetch(`/api/admin/models/${modelId}/default`, {
95 | method: "PUT",
96 | });
97 | }
98 |
99 | async deleteModel(modelId) {
100 | return this._fetch(`/api/admin/models/${modelId}`, {
101 | method: "DELETE",
102 | });
103 | }
104 | }
105 |
106 | export const providersClient = new ProvidersClient();
107 |
--------------------------------------------------------------------------------
/frontend/src/hooks/voice/useSpeechRecognition.js:
--------------------------------------------------------------------------------
1 | import { useRef } from "preact/hooks";
2 | import { VOICE_CONSTANTS, CHAT_STATES } from "@faster-chat/shared";
3 | import { getSpeechRecognition } from "@/utils/voice/browserSupport";
4 | import { handleVoiceError, ERROR_TYPES, ERROR_MESSAGES } from "@/utils/voice/errorHandler";
5 |
6 | const createRecognitionInstance = (language) => {
7 | const SpeechRecognition = getSpeechRecognition();
8 | const recognition = new SpeechRecognition();
9 | recognition.continuous = true;
10 | recognition.interimResults = true;
11 | recognition.lang = language || VOICE_CONSTANTS.DEFAULT_LANGUAGE;
12 | return recognition;
13 | };
14 |
15 | const parseRecognitionResults = (event) => {
16 | let interimTranscript = "";
17 | let finalTranscript = "";
18 |
19 | for (let i = event.resultIndex; i < event.results.length; i++) {
20 | const transcript = event.results[i][0].transcript;
21 | if (event.results[i].isFinal) {
22 | finalTranscript += transcript;
23 | } else {
24 | interimTranscript += transcript;
25 | }
26 | }
27 |
28 | return { interim: interimTranscript, final: finalTranscript };
29 | };
30 |
31 | const shouldRestartRecognition = (currentStateRef, recognitionRef) => {
32 | return currentStateRef.current === CHAT_STATES.LISTENING && recognitionRef.current;
33 | };
34 |
35 | export function useSpeechRecognition({ onResult, onError, language, currentStateRef }) {
36 | const recognitionRef = useRef(null);
37 |
38 | const initRecognition = () => {
39 | if (recognitionRef.current) return recognitionRef.current;
40 |
41 | const recognition = createRecognitionInstance(language);
42 |
43 | recognition.onresult = (event) => {
44 | const transcripts = parseRecognitionResults(event);
45 | if (onResult) onResult(transcripts);
46 | };
47 |
48 | recognition.onerror = (event) => {
49 | handleVoiceError(event.error, ERROR_TYPES.RECOGNITION, onError);
50 | };
51 |
52 | recognition.onend = () => {
53 | if (shouldRestartRecognition(currentStateRef, recognitionRef)) {
54 | setTimeout(() => {
55 | if (shouldRestartRecognition(currentStateRef, recognitionRef)) {
56 | try {
57 | recognitionRef.current.start();
58 | } catch (err) {
59 | console.error("[useSpeechRecognition] Failed to restart:", err);
60 | }
61 | }
62 | }, VOICE_CONSTANTS.RECOGNITION_RESTART_DELAY_MS);
63 | }
64 | };
65 |
66 | recognitionRef.current = recognition;
67 | return recognition;
68 | };
69 |
70 | const start = () => {
71 | const recognition = initRecognition();
72 | try {
73 | recognition.start();
74 | } catch (err) {
75 | console.error("[useSpeechRecognition] Failed to start:", err);
76 | if (onError) onError(ERROR_MESSAGES.MICROPHONE_START_FAILED);
77 | }
78 | };
79 |
80 | const stop = () => {
81 | if (recognitionRef.current) {
82 | recognitionRef.current.stop();
83 | }
84 | };
85 |
86 | const updateLanguage = (lang) => {
87 | if (recognitionRef.current) {
88 | recognitionRef.current.lang = lang;
89 | }
90 | };
91 |
92 | return {
93 | start,
94 | stop,
95 | updateLanguage,
96 | };
97 | }
98 |
--------------------------------------------------------------------------------
/frontend/src/lib/errorHandler.js:
--------------------------------------------------------------------------------
1 | import { toast } from "sonner";
2 |
3 | /**
4 | * Extract error message from various error formats
5 | */
6 | export function extractErrorMessage(error) {
7 | if (!error) return "An unexpected error occurred.";
8 | if (typeof error === "string") return error;
9 | if (error instanceof Error) return error.message;
10 | if (typeof error === "object") {
11 | if (typeof error.message === "string") return error.message;
12 | if (typeof error.error === "string") return error.error;
13 | if (error.error && typeof error.error.message === "string") return error.error.message;
14 | }
15 |
16 | try {
17 | return String(error);
18 | } catch {
19 | return "An unexpected error occurred.";
20 | }
21 | }
22 |
23 | /**
24 | * Categorize error type for better UX
25 | */
26 | function categorizeError(message) {
27 | if (!message) return "error";
28 |
29 | const msg = message.toLowerCase();
30 | if (msg.includes("network") || msg.includes("offline") || msg.includes("connection")) {
31 | return "network";
32 | }
33 | if (msg.includes("unauthorized") || msg.includes("forbidden") || msg.includes("auth")) {
34 | return "auth";
35 | }
36 | if (msg.includes("validation") || msg.includes("invalid")) {
37 | return "validation";
38 | }
39 | if (msg.includes("not found") || msg.includes("404")) {
40 | return "notfound";
41 | }
42 | if (msg.includes("timeout")) {
43 | return "timeout";
44 | }
45 | return "error";
46 | }
47 |
48 | /**
49 | * Show enhanced error toast with smart categorization and copy action
50 | * @param {string|Error|object} error - The error to display
51 | * @param {number} duration - Toast duration in ms (default: 4000)
52 | */
53 | export function showErrorToast(error, duration = 4000) {
54 | const message = extractErrorMessage(error);
55 | const category = categorizeError(message);
56 |
57 | const copyAction = {
58 | label: "Copy",
59 | onClick: () => navigator.clipboard.writeText(message),
60 | };
61 |
62 | switch (category) {
63 | case "network":
64 | toast.error("Connection Error", {
65 | description: "Check your internet connection or verify the server is running.",
66 | duration,
67 | action: copyAction,
68 | });
69 | break;
70 |
71 | case "timeout":
72 | toast.error("Request Timeout", {
73 | description: "The request took too long. Try again.",
74 | duration,
75 | action: copyAction,
76 | });
77 | break;
78 |
79 | case "auth":
80 | toast.error("Authentication Error", {
81 | description: "Your session may have expired. Please log in again.",
82 | duration,
83 | });
84 | break;
85 |
86 | case "validation":
87 | toast.warning("Invalid Input", {
88 | description: message,
89 | duration,
90 | });
91 | break;
92 |
93 | case "notfound":
94 | toast.error("Not Found", {
95 | description: message,
96 | duration,
97 | });
98 | break;
99 |
100 | default:
101 | toast.error("Error", {
102 | description: message,
103 | duration,
104 | action: copyAction,
105 | });
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/frontend/src/components/chat/MessageAttachment.jsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { Download, File, Sparkles } from "lucide-preact";
3 |
4 | const API_BASE = import.meta.env.DEV ? "http://localhost:3001" : "";
5 |
6 | export default function MessageAttachment({ fileId }) {
7 | const {
8 | data: fileMetadata,
9 | isLoading,
10 | error,
11 | } = useQuery({
12 | queryKey: ["file", fileId],
13 | queryFn: async () => {
14 | const response = await fetch(`${API_BASE}/api/files/${fileId}`, {
15 | credentials: "include",
16 | });
17 | if (!response.ok) throw new Error("Failed to load file");
18 | return response.json();
19 | },
20 | });
21 |
22 | const handleDownload = () => {
23 | window.open(`${API_BASE}/api/files/${fileId}/content`, "_blank");
24 | };
25 |
26 | if (isLoading) {
27 | return (
28 |
29 |
30 | Loading...
31 |
32 | );
33 | }
34 |
35 | if (error || !fileMetadata) {
36 | return (
37 |
38 |
39 | File unavailable
40 |
41 | );
42 | }
43 |
44 | const isImage = fileMetadata.mimeType?.startsWith("image/");
45 | const isGenerated = fileMetadata.meta?.type === "generated";
46 |
47 | // Render images inline
48 | if (isImage) {
49 | return (
50 |
51 |

57 | {/* Overlay with download button and generated badge */}
58 |
59 | {isGenerated && (
60 |
61 |
62 | Generated
63 |
64 | )}
65 |
71 |
72 |
73 | );
74 | }
75 |
76 | // Non-image files: show download button
77 | return (
78 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/frontend/src/pages/authenticated/Chat.jsx:
--------------------------------------------------------------------------------
1 | import ChatInterface from "@/components/chat/ChatInterface";
2 | import ErrorBanner from "@/components/ui/ErrorBanner";
3 | import { useChatQuery, useCreateChatMutation } from "@/hooks/useChatsQuery";
4 | import { useAppSettings } from "@/state/useAppSettings";
5 | import { useNavigate } from "@tanstack/react-router";
6 | import { useLayoutEffect, useRef } from "preact/hooks";
7 |
8 | const Chat = ({ chatId }) => {
9 | const navigate = useNavigate();
10 | const { data: chat, isLoading, isError, error } = useChatQuery(chatId);
11 | const createChatMutation = useCreateChatMutation();
12 | const appName = useAppSettings((state) => state.appName);
13 |
14 | // Update document title when chat changes
15 | useLayoutEffect(() => {
16 | const chatTitle = chat?.title || "New Chat";
17 | document.title = `${chatTitle} | ${appName}`;
18 |
19 | return () => {
20 | document.title = appName;
21 | };
22 | }, [chat?.title, appName]);
23 | const hasAttemptedRedirect = useRef(false);
24 |
25 | // Auto-redirect to new chat if current chat is missing/deleted
26 | if (isError && !hasAttemptedRedirect.current && !createChatMutation.isPending) {
27 | hasAttemptedRedirect.current = true;
28 | createChatMutation.mutate(
29 | {},
30 | {
31 | onSuccess: (newChat) => {
32 | navigate({
33 | to: "/chat/$chatId",
34 | params: { chatId: newChat.id },
35 | replace: true,
36 | });
37 | },
38 | }
39 | );
40 | }
41 |
42 | const handleCreateNewChat = () => {
43 | createChatMutation.mutate(
44 | {},
45 | {
46 | onSuccess: (newChat) => {
47 | navigate({
48 | to: "/chat/$chatId",
49 | params: { chatId: newChat.id },
50 | replace: true,
51 | });
52 | },
53 | }
54 | );
55 | };
56 |
57 | if (isLoading || createChatMutation.isPending) {
58 | return (
59 |
60 |
61 | {createChatMutation.isPending ? "Redirecting to a new chat..." : "Loading chat..."}
62 |
63 |
64 | );
65 | }
66 |
67 | if (isError) {
68 | return (
69 |
70 |
75 |
76 |
82 |
88 |
89 |
90 | );
91 | }
92 |
93 | return ;
94 | };
95 |
96 | export default Chat;
97 |
--------------------------------------------------------------------------------
/frontend/src/components/settings/FontSelector.jsx:
--------------------------------------------------------------------------------
1 | import { useThemeStore, FONT_PRESETS, FONT_SIZE_PRESETS } from "@/state/useThemeStore";
2 | import { Check } from "lucide-preact";
3 |
4 | // Font card component - shows preview in actual font
5 | const FontCard = ({ font, isSelected, onSelect }) => {
6 | return (
7 |
34 | );
35 | };
36 |
37 | // Font size toggle
38 | const FontSizeToggle = ({ currentSize, setSize }) => {
39 | return (
40 |
41 | {FONT_SIZE_PRESETS.map(({ id, name }) => (
42 |
52 | ))}
53 |
54 | );
55 | };
56 |
57 | export const FontSelector = () => {
58 | const chatFont = useThemeStore((state) => state.chatFont);
59 | const chatFontSize = useThemeStore((state) => state.chatFontSize);
60 | const setChatFont = useThemeStore((state) => state.setChatFont);
61 | const setChatFontSize = useThemeStore((state) => state.setChatFontSize);
62 |
63 | return (
64 |
65 | {/* Font Family */}
66 |
67 |
68 |
69 | {FONT_PRESETS.map((font) => (
70 |
76 | ))}
77 |
78 |
79 |
80 | {/* Font Size */}
81 |
82 |
85 |
86 |
87 |
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/frontend/src/components/admin/EditProviderModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "preact/hooks";
2 | import { useMutation, useQueryClient } from "@tanstack/react-query";
3 | import { providersClient } from "@/lib/providersClient";
4 | import { Button } from "@/components/ui/button";
5 | import Modal from "@/components/ui/Modal";
6 |
7 | const EditProviderModal = ({ provider, onClose }) => {
8 | const queryClient = useQueryClient();
9 | const [apiKey, setApiKey] = useState("");
10 | const [baseUrl, setBaseUrl] = useState(provider.base_url || "");
11 | const [error, setError] = useState("");
12 |
13 | const updateMutation = useMutation({
14 | mutationFn: () =>
15 | providersClient.updateProvider(provider.id, {
16 | apiKey: apiKey || undefined,
17 | baseUrl: baseUrl || null,
18 | }),
19 | onSuccess: () => {
20 | queryClient.invalidateQueries({ queryKey: ["admin", "providers"] });
21 | queryClient.invalidateQueries({ queryKey: ["admin", "models"] });
22 | onClose();
23 | setApiKey("");
24 | setError("");
25 | },
26 | onError: (err) => {
27 | const message = err?.message || "Failed to update provider";
28 | setError(message);
29 | },
30 | });
31 |
32 | const handleSubmit = (e) => {
33 | e.preventDefault();
34 | setError("");
35 | if (!apiKey && baseUrl === provider.base_url) {
36 | setError("Enter a new API key or update the base URL.");
37 | return;
38 | }
39 | updateMutation.mutate();
40 | };
41 |
42 | return (
43 |
44 |
84 |
85 | );
86 | };
87 |
88 | export default EditProviderModal;
89 |
--------------------------------------------------------------------------------
/frontend/src/components/admin/ResetPasswordModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "preact/hooks";
2 | import { useMutation, useQueryClient } from "@tanstack/react-query";
3 | import { adminClient } from "@/lib/adminClient";
4 | import { Button } from "@/components/ui/button";
5 | import Modal from "@/components/ui/Modal";
6 |
7 | const ResetPasswordModal = ({ user, isOpen, onClose }) => {
8 | const [password, setPassword] = useState("");
9 | const [confirmPassword, setConfirmPassword] = useState("");
10 | const [error, setError] = useState("");
11 |
12 | const queryClient = useQueryClient();
13 |
14 | const resetMutation = useMutation({
15 | mutationFn: () => adminClient.resetUserPassword(user.id, password),
16 | onSuccess: () => {
17 | queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
18 | onClose();
19 | setPassword("");
20 | setConfirmPassword("");
21 | setError("");
22 | },
23 | onError: (error) => {
24 | setError(error.message);
25 | },
26 | });
27 |
28 | const handleSubmit = (e) => {
29 | e.preventDefault();
30 | setError("");
31 |
32 | if (!password || !confirmPassword) {
33 | setError("Both password fields are required");
34 | return;
35 | }
36 |
37 | if (password.length < 8) {
38 | setError("Password must be at least 8 characters");
39 | return;
40 | }
41 |
42 | if (password !== confirmPassword) {
43 | setError("Passwords do not match");
44 | return;
45 | }
46 |
47 | resetMutation.mutate();
48 | };
49 |
50 | return (
51 |
52 |
91 |
92 | );
93 | };
94 |
95 | export default ResetPasswordModal;
96 |
--------------------------------------------------------------------------------