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:
@@ -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
@@ -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
@@ -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 & 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 & 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;
|
||||
}
|
||||
Reference in New Issue
Block a user