feat(insights): model selector with favorites, provider filtering, and configured-providers-only API
- Wire up CustomModelDropdown in InsightsView with favorites support: favoriteProviders, favoriteModels, and toggle callbacks - Auto-promote resolved planning provider as implicit favorite when no explicit favorites are set - Filter /models API to only return providers with credentials in Fusion's own auth stores (primary + legacy .pi + models.json), excluding supplemental stores (Codex CLI, Claude Code, env vars) that surface unconfigured providers - Still allow enabled extension providers (Claude CLI, Droid CLI, llama.cpp) when their settings toggles are on Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { fetchModels, type ModelInfo } from "../api";
|
||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
||||
import type { InsightCategory } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -90,12 +90,57 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
|
||||
// Fetch models internally if not provided via prop
|
||||
const [fetchedModels, setFetchedModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [resolvedPlanningProvider, setResolvedPlanningProvider] = useState<string | undefined>();
|
||||
useEffect(() => {
|
||||
if (modelsProp) return;
|
||||
fetchModels().then((res) => setFetchedModels(res.models)).catch(() => {});
|
||||
fetchModels()
|
||||
.then((res) => {
|
||||
setFetchedModels(res.models);
|
||||
setFavoriteProviders(res.favoriteProviders);
|
||||
setFavoriteModels(res.favoriteModels);
|
||||
setResolvedPlanningProvider(res.resolvedPlanningProvider);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [modelsProp]);
|
||||
const models = modelsProp ?? fetchedModels;
|
||||
|
||||
// Auto-promote the resolved planning provider as a favorite when the user
|
||||
// hasn't explicitly starred any providers. This ensures the provider they
|
||||
// actively use always appears at the top of the dropdown.
|
||||
const effectiveFavoriteProviders = useMemo(() => {
|
||||
if (favoriteProviders.length > 0) return favoriteProviders;
|
||||
if (resolvedPlanningProvider) return [resolvedPlanningProvider];
|
||||
return [];
|
||||
}, [favoriteProviders, resolvedPlanningProvider]);
|
||||
|
||||
const handleToggleProviderFavorite = useCallback(async (provider: string) => {
|
||||
const isFavorite = favoriteProviders.includes(provider);
|
||||
const next = isFavorite
|
||||
? favoriteProviders.filter((p) => p !== provider)
|
||||
: [provider, ...favoriteProviders];
|
||||
setFavoriteProviders(next);
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: next, favoriteModels });
|
||||
} catch {
|
||||
setFavoriteProviders(favoriteProviders);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const isFavorite = favoriteModels.includes(modelId);
|
||||
const next = isFavorite
|
||||
? favoriteModels.filter((m) => m !== modelId)
|
||||
: [modelId, ...favoriteModels];
|
||||
setFavoriteModels(next);
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: next });
|
||||
} catch {
|
||||
setFavoriteModels(favoriteModels);
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders]);
|
||||
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setSelectedModel(value);
|
||||
if (value) {
|
||||
@@ -483,6 +528,10 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
||||
label="Insight generation model"
|
||||
disabled={isRunInFlight}
|
||||
id="insight-model-select"
|
||||
favoriteProviders={effectiveFavoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleProviderFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,67 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolvePlanningSettingsModel } from "@fusion/core";
|
||||
import { ApiError } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read provider names from Fusion's own auth stores (primary + legacy .pi).
|
||||
* These represent providers the user has explicitly configured in Fusion,
|
||||
* as opposed to supplemental credentials inherited from Codex CLI,
|
||||
* Claude Code, or environment variables.
|
||||
*/
|
||||
function getConfiguredProviderNames(): Set<string> {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
const providers = new Set<string>();
|
||||
|
||||
// Fusion primary + legacy .pi auth files
|
||||
const authPaths = [
|
||||
join(home, ".fusion", "agent", "auth.json"),
|
||||
join(home, ".pi", "agent", "auth.json"),
|
||||
join(home, ".pi", "auth.json"),
|
||||
];
|
||||
|
||||
for (const authPath of authPaths) {
|
||||
if (!existsSync(authPath)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, unknown>;
|
||||
for (const key of Object.keys(parsed)) {
|
||||
providers.add(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid auth files
|
||||
}
|
||||
}
|
||||
|
||||
// Check models.json for providers with inline API keys
|
||||
const modelsPaths = [
|
||||
join(home, ".fusion", "agent", "models.json"),
|
||||
join(home, ".pi", "agent", "models.json"),
|
||||
join(home, ".pi", "models.json"),
|
||||
];
|
||||
for (const modelsPath of modelsPaths) {
|
||||
if (!existsSync(modelsPath)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
|
||||
providers?: Record<string, { apiKey?: string }>;
|
||||
};
|
||||
const provs = parsed?.providers;
|
||||
if (provs) {
|
||||
for (const [providerId, config] of Object.entries(provs)) {
|
||||
if (config.apiKey) {
|
||||
providers.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid models.json
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, runtimeLogger } = ctx;
|
||||
|
||||
@@ -93,6 +153,20 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
models = models.filter((m) => m.provider !== "cursor-cli");
|
||||
}
|
||||
|
||||
// Filter to only providers the user has explicitly configured in Fusion.
|
||||
// getAvailable() checks supplemental credential stores (Codex CLI,
|
||||
// Claude Code, env vars) which surface providers the user may not
|
||||
// have set up in Fusion. We restrict to providers with credentials
|
||||
// in Fusion's own auth stores (primary + legacy .pi + models.json),
|
||||
// plus any providers enabled via settings toggles (Claude CLI, etc.).
|
||||
const configuredProviders = getConfiguredProviderNames();
|
||||
if (useClaudeCli) configuredProviders.add("pi-claude-cli");
|
||||
if (useDroidCli) configuredProviders.add("droid-cli");
|
||||
if (useLlamaCpp) configuredProviders.add("llama-server");
|
||||
if (configuredProviders.size > 0) {
|
||||
models = models.filter((m) => configuredProviders.has(m.provider));
|
||||
}
|
||||
|
||||
res.json({
|
||||
models,
|
||||
favoriteProviders,
|
||||
|
||||
Reference in New Issue
Block a user