feat: feature-gate local inference dependencies (#7976)

Signed-off-by: DaeHee Lee <lee111dae11@proton.me>
Signed-off-by: jh-block <jhugo@block.xyz>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jh-block <jhugo@block.xyz>
This commit is contained in:
이대희
2026-03-24 00:49:24 +09:00
committed by GitHub
parent 79f539f8af
commit c493c6160c
23 changed files with 372 additions and 100 deletions
+35
View File
@@ -1922,6 +1922,26 @@
}
}
},
"/features": {
"get": {
"tags": [
"super::routes::features"
],
"operationId": "get_features",
"responses": {
"200": {
"description": "Compile-time feature flags",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FeaturesResponse"
}
}
}
}
}
}
},
"/handle_nanogpt": {
"post": {
"tags": [
@@ -5333,6 +5353,21 @@
}
}
},
"FeaturesResponse": {
"type": "object",
"required": [
"features"
],
"properties": {
"features": {
"type": "object",
"description": "Map of feature name to enabled status",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"ForkRequest": {
"type": "object",
"required": [
+10 -7
View File
@@ -41,6 +41,7 @@ import 'react-toastify/dist/ReactToastify.css';
import { useConfig } from './components/ConfigContext';
import { ModelAndProviderProvider } from './components/ModelAndProviderContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { FeaturesProvider } from './contexts/FeaturesContext';
import PermissionSettingsView from './components/settings/permission/PermissionSetting';
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
@@ -725,13 +726,15 @@ export function AppInner() {
export default function App() {
return (
<ThemeProvider>
<ModelAndProviderProvider>
<HashRouter>
<AppInner />
</HashRouter>
<AnnouncementModal />
<TelemetryOptOutModal controlled={false} />
</ModelAndProviderProvider>
<FeaturesProvider>
<ModelAndProviderProvider>
<HashRouter>
<AppInner />
</HashRouter>
<AnnouncementModal />
<TelemetryOptOutModal controlled={false} />
</ModelAndProviderProvider>
</FeaturesProvider>
</ThemeProvider>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+25
View File
@@ -473,6 +473,15 @@ export type ExtensionResponse = {
warnings?: Array<string>;
};
export type FeaturesResponse = {
/**
* Map of feature name to enabled status
*/
features: {
[key: string]: boolean;
};
};
export type ForkRequest = {
copy: boolean;
timestamp?: number | null;
@@ -3124,6 +3133,22 @@ export type TranscribeDictationResponses = {
export type TranscribeDictationResponse = TranscribeDictationResponses[keyof TranscribeDictationResponses];
export type GetFeaturesData = {
body?: never;
path?: never;
query?: never;
url: '/features';
};
export type GetFeaturesResponses = {
/**
* Compile-time feature flags
*/
200: FeaturesResponse;
};
export type GetFeaturesResponse = GetFeaturesResponses[keyof GetFeaturesResponses];
export type StartNanogptSetupData = {
body?: never;
path?: never;
@@ -4,6 +4,7 @@ import { startTetrateSetup } from '../../utils/tetrateSetup';
import { Tetrate } from '../icons';
import LocalModelPicker from './LocalModelPicker';
import { HardDrive } from 'lucide-react';
import { useFeatures } from '../../contexts/FeaturesContext';
const TETRATE = 'tetrate' as const;
const NANOGPT = 'nano-gpt' as const;
@@ -26,6 +27,7 @@ const cardClass = (isSelected: boolean) =>
}`;
export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps) {
const { localInference } = useFeatures();
const [error, setError] = useState<{
message: string;
type: typeof TETRATE | typeof NANOGPT;
@@ -109,23 +111,25 @@ export default function FreeOptionCards({ onConfigured }: FreeOptionCardsProps)
</p>
</div>
<div onClick={handleRunLocallyClick} className={cardClass(false)}>
<div className="flex items-start justify-between mb-1">
<div className="flex items-center gap-2">
<HardDrive className="w-5 h-5 text-text-default" />
<span className="font-medium text-text-default text-base">Use a Local Model</span>
<span className="inline-block px-1.5 py-0.5 text-[10px] font-medium bg-green-600 text-white rounded-full">
Free &amp; Private
</span>
</div>
<div className="text-text-muted group-hover:text-text-default transition-colors">
<ChevronRight />
{localInference && (
<div onClick={handleRunLocallyClick} className={cardClass(false)}>
<div className="flex items-start justify-between mb-1">
<div className="flex items-center gap-2">
<HardDrive className="w-5 h-5 text-text-default" />
<span className="font-medium text-text-default text-base">Use a Local Model</span>
<span className="inline-block px-1.5 py-0.5 text-[10px] font-medium bg-green-600 text-white rounded-full">
Free &amp; Private
</span>
</div>
<div className="text-text-muted group-hover:text-text-default transition-colors">
<ChevronRight />
</div>
</div>
<p className="text-text-muted text-sm">
Download a model and run entirely on your machine. No API keys, no accounts.
</p>
</div>
<p className="text-text-muted text-sm">
Download a model and run entirely on your machine. No API keys, no accounts.
</p>
</div>
)}
</div>
{error && (
@@ -19,6 +19,7 @@ import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
import { CONFIGURATION_ENABLED } from '../../updates';
import { trackSettingsTabViewed } from '../../utils/analytics';
import { useFeatures } from '../../contexts/FeaturesContext';
export type SettingsViewOptions = {
deepLinkConfig?: ExtensionConfig;
@@ -39,6 +40,7 @@ export default function SettingsView({
const [activeTab, setActiveTab] = useState('models');
const [tunnelDisabled, setTunnelDisabled] = useState(false);
const hasTrackedInitialTab = useRef(false);
const { localInference } = useFeatures();
const handleTabChange = (tab: string) => {
setActiveTab(tab);
@@ -65,11 +67,18 @@ export default function SettingsView({
};
const targetTab = sectionToTab[viewOptions.section];
if (targetTab) {
if (targetTab && (targetTab !== 'local-inference' || localInference)) {
setActiveTab(targetTab);
}
}
}, [viewOptions.section]);
}, [viewOptions.section, localInference]);
// Reset active tab if local-inference becomes unavailable
useEffect(() => {
if (!localInference && activeTab === 'local-inference') {
setActiveTab('models');
}
}, [localInference, activeTab]);
useEffect(() => {
if (!hasTrackedInitialTab.current) {
@@ -130,14 +139,16 @@ export default function SettingsView({
<Bot className="h-4 w-4" />
Models
</TabsTrigger>
<TabsTrigger
value="local-inference"
className="flex gap-2"
data-testid="settings-local-inference-tab"
>
<HardDrive className="h-4 w-4" />
Local Inference
</TabsTrigger>
{localInference && (
<TabsTrigger
value="local-inference"
className="flex gap-2"
data-testid="settings-local-inference-tab"
>
<HardDrive className="h-4 w-4" />
Local Inference
</TabsTrigger>
)}
<TabsTrigger value="chat" className="flex gap-2" data-testid="settings-chat-tab">
<MessageSquare className="h-4 w-4" />
Chat
@@ -181,12 +192,14 @@ export default function SettingsView({
<ModelsSection setView={setView} />
</TabsContent>
<TabsContent
value="local-inference"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<LocalInferenceSection />
</TabsContent>
{localInference && (
<TabsContent
value="local-inference"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<LocalInferenceSection />
</TabsContent>
)}
<TabsContent
value="chat"
@@ -8,6 +8,7 @@ import { trackSettingToggled } from '../../../utils/analytics';
import { LocalModelManager } from './LocalModelManager';
import { MicrophoneSelector } from './MicrophoneSelector';
import { DICTATION_ALLOWED_PROVIDERS } from '../../../updates';
import { useFeatures } from '../../../contexts/FeaturesContext';
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,6 +18,7 @@ import {
} from '../../ui/dropdown-menu';
export const DictationSettings = () => {
const { localInference, isLoading: isFeaturesLoading } = useFeatures();
const [provider, setProvider] = useState<DictationProvider | null>(null);
const [providerStatuses, setProviderStatuses] = useState<Record<string, DictationProviderStatus>>(
{}
@@ -32,6 +34,8 @@ export const DictationSettings = () => {
};
useEffect(() => {
if (isFeaturesLoading) return;
const loadSettings = async () => {
const providerValue = await read('voice_dictation_provider', false);
let loadedProvider: DictationProvider | null = (providerValue as DictationProvider) || null;
@@ -45,6 +49,11 @@ export const DictationSettings = () => {
await upsert('voice_dictation_provider', '', false);
}
if (!localInference && loadedProvider === 'local') {
loadedProvider = null;
await upsert('voice_dictation_provider', '', false);
}
setProvider(loadedProvider);
const micValue = await read('voice_dictation_preferred_mic', false);
@@ -54,7 +63,7 @@ export const DictationSettings = () => {
};
loadSettings();
}, [read, upsert]);
}, [read, upsert, localInference, isFeaturesLoading]);
const handleProviderChange = (value: string) => {
const newProvider = value === 'disabled' ? null : (value as DictationProvider);
@@ -0,0 +1,49 @@
import { createContext, useContext, useEffect, useState, useMemo } from 'react';
import { getFeatures } from '../api';
interface FeaturesContextValue {
localInference: boolean;
codeMode: boolean;
isLoading: boolean;
}
const FeaturesContext = createContext<FeaturesContextValue | null>(null);
export function FeaturesProvider({ children }: { children: React.ReactNode }) {
const [features, setFeatures] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const response = await getFeatures({ throwOnError: false });
if (response.data) {
setFeatures(response.data.features);
}
} catch (error) {
console.warn('[FeaturesContext] Failed to fetch features:', error);
} finally {
setIsLoading(false);
}
})();
}, []);
const value = useMemo<FeaturesContextValue>(
() => ({
localInference: features['local-inference'] ?? false,
codeMode: features['code-mode'] ?? true,
isLoading,
}),
[features, isLoading]
);
return <FeaturesContext.Provider value={value}>{children}</FeaturesContext.Provider>;
}
export function useFeatures(): FeaturesContextValue {
const context = useContext(FeaturesContext);
if (!context) {
throw new Error('useFeatures must be used within a FeaturesProvider');
}
return context;
}