Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"allow": [
{
"url": "https://generativelanguage.googleapis.com/**"
},
{
"url": "http://localhost:11434/**"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion src/features/ai/components/history/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function ChatHistorySidebar({
onClose();
}}
isSelected={index === selectedIndex}
className="px-3 py-1.5"
className="group px-3 py-1.5"
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs">{chat.title}</div>
Expand Down
15 changes: 12 additions & 3 deletions src/features/ai/components/input/chat-input-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useAIChatStore } from "@/features/ai/store/store";
import type { AIChatInputBarProps } from "@/features/ai/types/ai-chat";
import { getModelById } from "@/features/ai/types/providers";
import { useEditorSettingsStore } from "@/features/editor/stores/settings-store";
import { useToast } from "@/features/layout/contexts/toast-context";
import { useSettingsStore } from "@/features/settings/store";
import { useUIState } from "@/stores/ui-state-store";
import Button from "@/ui/button";
Expand Down Expand Up @@ -528,9 +529,17 @@ const AIChatInputBar = memo(function AIChatInputBar({
<ModelSelectorDropdown
currentProviderId={settings.aiProviderId}
currentModelId={settings.aiModelId}
currentModelName={
getModelById(settings.aiProviderId, settings.aiModelId)?.name || "Select Model"
}
currentModelName={(() => {
const { dynamicModels } = useAIChatStore.getState();
const providerModels = dynamicModels[settings.aiProviderId];
const dynamicModel = providerModels?.find((m) => m.id === settings.aiModelId);
if (dynamicModel) return dynamicModel.name;

return (
getModelById(settings.aiProviderId, settings.aiModelId)?.name ||
settings.aiModelId
);
})()}
onSelect={(providerId, modelId) => {
updateSetting("aiProviderId", providerId);
updateSetting("aiModelId", modelId);
Expand Down
49 changes: 45 additions & 4 deletions src/features/ai/components/selectors/model-selector-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Check, ChevronDown, Key, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAIChatStore } from "@/features/ai/store/store";
import { getAvailableProviders } from "@/features/ai/types/providers";
import { cn } from "@/utils/cn";
import { getProvider } from "@/utils/providers";
import type { ProviderModel } from "@/utils/providers/provider-interface";

interface ModelSelectorDropdownProps {
currentProviderId: string;
Expand All @@ -23,12 +26,41 @@ export function ModelSelectorDropdown({
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const { dynamicModels, setDynamicModels } = useAIChatStore();
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

const providers = getAvailableProviders();

// Fetch dynamic models on mount
useEffect(() => {
const fetchModels = async () => {
for (const provider of providers) {
// Skip if we already have models for this provider
if (dynamicModels[provider.id]?.length > 0) continue;

// Only fetch dynamic models if provider supports it AND does not require an API key
// This enforces static lists for cloud providers like OpenAI
if (provider.requiresApiKey) continue;

const providerInstance = getProvider(provider.id);
if (providerInstance?.getModels) {
try {
const models = await providerInstance.getModels();
if (models.length > 0) {
setDynamicModels(provider.id, models);
}
} catch (error) {
console.error(`Failed to fetch models for ${provider.id}:`, error);
}
}
}
};

fetchModels();
}, [providers, dynamicModels, setDynamicModels]);

const filteredItems = useMemo(() => {
const items: Array<{
type: "provider" | "model";
Expand All @@ -45,7 +77,11 @@ export function ModelSelectorDropdown({
for (const provider of providers) {
const providerMatches = provider.name.toLowerCase().includes(searchLower);
const providerHasKey = !provider.requiresApiKey || hasApiKey(provider.id);
const matchingModels = provider.models.filter(

// Use dynamic models if available, otherwise use static models
const models = dynamicModels[provider.id] || provider.models;

const matchingModels = models.filter(
(model) =>
providerMatches ||
model.name.toLowerCase().includes(searchLower) ||
Expand All @@ -63,7 +99,7 @@ export function ModelSelectorDropdown({

// Only show models if provider has API key or doesn't require one
if (providerHasKey) {
const modelsToShow = search ? matchingModels : provider.models;
const modelsToShow = search ? matchingModels : models;
for (const model of modelsToShow) {
items.push({
type: "model",
Expand All @@ -78,7 +114,7 @@ export function ModelSelectorDropdown({
}

return items;
}, [providers, search, hasApiKey]);
}, [providers, search, hasApiKey, dynamicModels]);

const selectableItems = useMemo(
() => filteredItems.filter((item) => item.type === "model"),
Expand Down Expand Up @@ -183,7 +219,12 @@ export function ModelSelectorDropdown({

<div className="max-h-[340px] overflow-y-auto p-1">
{filteredItems.length === 0 ? (
<div className="p-4 text-center text-text-lighter text-xs">No models found</div>
<div className="p-4 text-center text-text-lighter text-xs">
{providers.find((p) => p.id === currentProviderId)?.id === "ollama" &&
!dynamicModels["ollama"]?.length
? "No models detected. Please install a model."
: "No models found"}
</div>
) : (
filteredItems.map((item) => {
if (item.type === "provider") {
Expand Down
27 changes: 19 additions & 8 deletions src/features/ai/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
loadChatFromDb,
saveChatToDb,
} from "@/utils/chat-history-db";
import type { ProviderModel } from "@/utils/providers/provider-interface";
import type { AIChatActions, AIChatState } from "./types";

export const useAIChatStore = create<AIChatState & AIChatActions>()(
Expand Down Expand Up @@ -45,6 +46,7 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(

providerApiKeys: new Map<string, boolean>(),
apiKeyModalState: { isOpen: false, providerId: null },
dynamicModels: {},

mentionState: {
active: false,
Expand Down Expand Up @@ -313,8 +315,10 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(

checkApiKey: async (providerId) => {
try {
// Claude Code doesn't require an API key in the frontend
if (providerId === "claude-code") {
const provider = AI_PROVIDERS.find((p) => p.id === providerId);

// If provider doesn't require an API key, set hasApiKey to true
if (provider && !provider.requiresApiKey) {
set((state) => {
state.hasApiKey = true;
});
Expand All @@ -338,8 +342,8 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(

for (const provider of AI_PROVIDERS) {
try {
// Claude Code doesn't require an API key in the frontend
if (provider.id === "claude-code") {
// If provider doesn't require an API key, mark it as having one
if (!provider.requiresApiKey) {
newApiKeyMap.set(provider.id, true);
continue;
}
Expand All @@ -366,7 +370,7 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(
const newApiKeyMap = new Map<string, boolean>();
for (const provider of AI_PROVIDERS) {
try {
if (provider.id === "claude-code") {
if (!provider.requiresApiKey) {
newApiKeyMap.set(provider.id, true);
continue;
}
Expand All @@ -381,7 +385,8 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(
});

// Update hasApiKey for current provider
if (providerId === "claude-code") {
const currentProvider = AI_PROVIDERS.find((p) => p.id === providerId);
if (currentProvider && !currentProvider.requiresApiKey) {
set((state) => {
state.hasApiKey = true;
});
Expand Down Expand Up @@ -409,7 +414,7 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(
const newApiKeyMap = new Map<string, boolean>();
for (const provider of AI_PROVIDERS) {
try {
if (provider.id === "claude-code") {
if (!provider.requiresApiKey) {
newApiKeyMap.set(provider.id, true);
continue;
}
Expand All @@ -424,7 +429,8 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(
});

// Update hasApiKey for current provider
if (providerId === "claude-code") {
const currentProvider = AI_PROVIDERS.find((p) => p.id === providerId);
if (currentProvider && !currentProvider.requiresApiKey) {
set((state) => {
state.hasApiKey = true;
});
Expand All @@ -443,6 +449,11 @@ export const useAIChatStore = create<AIChatState & AIChatActions>()(
return get().providerApiKeys.get(providerId) || false;
},

setDynamicModels: (providerId, models) =>
set((state) => {
state.dynamicModels[providerId] = models;
}),

// Mention actions
showMention: (position, search, startIndex) =>
set((state) => {
Expand Down
7 changes: 7 additions & 0 deletions src/features/ai/store/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Chat, Message } from "@/features/ai/types/ai-chat";
import type { FileEntry } from "@/features/file-system/types/app";
import type { ProviderModel } from "@/utils/providers/provider-interface";

export type OutputStyle = "default" | "explanatory" | "learning" | "custom";
export type ChatMode = "chat" | "plan";
Expand Down Expand Up @@ -34,6 +35,9 @@ export interface AIChatState {
providerApiKeys: Map<string, boolean>;
apiKeyModalState: { isOpen: boolean; providerId: string | null };

// Dynamic models state
dynamicModels: Record<string, ProviderModel[]>;

// Mention state
mentionState: {
active: boolean;
Expand Down Expand Up @@ -93,6 +97,9 @@ export interface AIChatActions {
removeApiKey: (providerId: string) => Promise<void>;
hasProviderApiKey: (providerId: string) => boolean;

// Dynamic models actions
setDynamicModels: (providerId: string, models: ProviderModel[]) => void;

// Mention actions
showMention: (
position: { top: number; left: number },
Expand Down
7 changes: 7 additions & 0 deletions src/features/ai/types/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,13 @@ export const AI_PROVIDERS: ModelProvider[] = [
},
],
},
{
id: "ollama",
name: "Ollama (Local)",
apiUrl: "http://localhost:11434/v1/chat/completions",
requiresApiKey: false,
models: [],
},
];

// Track Claude Code availability
Expand Down
Loading