diff --git a/README.md b/README.md index ab1a08d..a2cb01c 100644 --- a/README.md +++ b/README.md @@ -1018,6 +1018,10 @@ Releases all resources associated with the model. Automatically calls `stop()` f Returns available models. +**`getModelName(): string`** + +Returns the model slug or path the instance was created with. + ### useCactusLM Hook The `useCactusLM` hook manages a `CactusLM` instance with reactive state. When model parameters (`model`, `corpusDir`, `cacheIndex`, `options`) change, the hook creates a new instance and resets all state. The hook automatically cleans up resources when the component unmounts. @@ -1111,6 +1115,15 @@ Feeds audio samples into the streaming session and returns the current transcrip Stops the streaming session and returns the final confirmed transcription text. Throws an error if no session is active. +**`detectLanguage(params: CactusSTTDetectLanguageParams): Promise`** + +Detects the spoken language in the given audio. Automatically calls `init()` if not already initialized. Throws an error if a generation is already in progress. + +**Parameters:** +- `audio` - Path to the audio file or raw PCM samples as a byte array. +- `options`: + - `useVad` - Whether to apply VAD before detection (default: `true`). + **`audioEmbed(params: CactusSTTAudioEmbedParams): Promise`** Generates embeddings for the given audio file. Automatically calls `init()` if not already initialized. Throws an error if a generation is already in progress. @@ -1134,6 +1147,10 @@ Releases all resources associated with the model. Stops any active streaming ses Returns available speech-to-text models. +**`getModelName(): string`** + +Returns the model slug or path the instance was created with. + ### useCactusSTT Hook The `useCactusSTT` hook manages a `CactusSTT` instance with reactive state. When model parameters (`model`, `options`) change, the hook creates a new instance and resets all state. The hook automatically cleans up resources when the component unmounts. @@ -1216,6 +1233,10 @@ Releases all resources associated with the model. Safe to call even if the model Returns available VAD models. +**`getModelName(): string`** + +Returns the model slug or path the instance was created with. + ### useCactusVAD Hook The `useCactusVAD` hook manages a `CactusVAD` instance with reactive state. When model parameters (`model`, `options`) change, the hook creates a new instance and resets all state. The hook automatically cleans up resources when the component unmounts. @@ -1314,6 +1335,20 @@ The `useCactusIndex` hook manages a `CactusIndex` instance with reactive state. - `compact(): Promise` - Optimizes the index. Sets `isProcessing` to `true` during operation. - `destroy(): Promise` - Releases all resources. Automatically called when the component unmounts. +### getRegistry + +**`getRegistry(): Promise<{ [key: string]: CactusModel }>`** + +Returns all available models from HuggingFace, keyed by model slug. Result is cached across calls. + +```typescript +import { getRegistry } from 'cactus-react-native'; + +const registry = await getRegistry(); +const model = registry['qwen3-0.6b']; +console.log(model.quantization.int4.url); +``` + ## Type Definitions ### CactusLMParams @@ -1647,6 +1682,32 @@ interface CactusSTTStreamTranscribeStopResult { } ``` +### CactusSTTDetectLanguageOptions + +```typescript +interface CactusSTTDetectLanguageOptions { + useVad?: boolean; +} +``` + +### CactusSTTDetectLanguageParams + +```typescript +interface CactusSTTDetectLanguageParams { + audio: string | number[]; + options?: CactusSTTDetectLanguageOptions; +} +``` + +### CactusSTTDetectLanguageResult + +```typescript +interface CactusSTTDetectLanguageResult { + language: string; + confidence?: number; +} +``` + ### CactusVADParams ```typescript diff --git a/android/gradle.properties b/android/gradle.properties index 0737c10..69c5ad7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,4 +2,4 @@ Cactus_kotlinVersion=2.0.21 Cactus_minSdkVersion=24 Cactus_targetSdkVersion=34 Cactus_compileSdkVersion=35 -Cactus_ndkVersion=27.1.12297006 +Cactus_ndkVersion=28.2.13676358 diff --git a/example/android/build.gradle b/example/android/build.gradle index dad99b0..1c03e79 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -4,7 +4,7 @@ buildscript { minSdkVersion = 24 compileSdkVersion = 36 targetSdkVersion = 36 - ndkVersion = "27.1.12297006" + ndkVersion = "28.2.13676358" kotlinVersion = "2.1.20" } repositories { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index d6f7e96..a5cc65e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - boost (1.84.0) - - Cactus (1.10.0): + - Cactus (1.10.1): - boost - DoubleConversion - fast_float @@ -2643,7 +2643,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - Cactus: 88585f8a152312dcb391526d839133d72d054031 + Cactus: 10a32b41ac3f49c1b47f75a7ade47145c38aceb9 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: b8f1312d48447cca7b4abc21ed155db14742bd03 diff --git a/example/src/App.tsx b/example/src/App.tsx index 5793441..f2e0805 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -14,6 +14,7 @@ import STTScreen from './STTScreen'; import StreamSTTScreen from './StreamSTTScreen'; import ChatScreen from './ChatScreen'; import IndexScreen from './IndexScreen'; +import ModelBrowserScreen from './ModelBrowserScreen'; type Screen = | 'Home' @@ -23,7 +24,8 @@ type Screen = | 'STT' | 'StreamSTT' | 'Chat' - | 'Index'; + | 'Index' + | 'ModelBrowser'; const App = () => { const [selectedScreen, setSelectedScreen] = useState('Home'); @@ -60,6 +62,10 @@ const App = () => { setSelectedScreen('Index'); }; + const handleGoToModelBrowser = () => { + setSelectedScreen('ModelBrowser'); + }; + const renderScreen = () => { switch (selectedScreen) { case 'Completion': @@ -76,6 +82,8 @@ const App = () => { return ; case 'Index': return ; + case 'ModelBrowser': + return ; default: return null; } @@ -158,6 +166,16 @@ const App = () => { CactusIndex with embeddings + + + Model Browser + + Browse available models from the registry + + diff --git a/example/src/ModelBrowserScreen.tsx b/example/src/ModelBrowserScreen.tsx new file mode 100644 index 0000000..9e64d9e --- /dev/null +++ b/example/src/ModelBrowserScreen.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react'; +import { + View, + Text, + TextInput, + ScrollView, + StyleSheet, + ActivityIndicator, +} from 'react-native'; +import { getRegistry, type CactusModel } from 'cactus-react-native'; + +type RegistryEntry = { key: string; model: CactusModel }; + +const ModelBrowserScreen = () => { + const [entries, setEntries] = useState([]); + const [filtered, setFiltered] = useState([]); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getRegistry() + .then((registry) => { + const list = Object.entries(registry).map(([key, model]) => ({ + key, + model, + })); + list.sort((a, b) => a.key.localeCompare(b.key)); + setEntries(list); + setFiltered(list); + }) + .catch((e: unknown) => + setError(e instanceof Error ? e.message : String(e)) + ) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + const q = search.trim().toLowerCase(); + setFiltered(q ? entries.filter((e) => e.key.includes(q)) : entries); + }, [search, entries]); + + if (loading) { + return ( + + + Loading model registry... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + + {filtered.length} of {entries.length} models + + + {filtered.map(({ key, model }) => { + const { int4, int8 } = model.quantization; + return ( + + {key} + + + + + + ); + })} + + + ); +}; + +const VariantRow = ({ + label, + url, + pro, +}: { + label: string; + url: string; + pro?: string; +}) => ( + + + {label} + + + {url.replace('https://huggingface.co/', '')} + + {pro && ( + + Apple + + )} + +); + +export default ModelBrowserScreen; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + loadingText: { + marginTop: 12, + fontSize: 14, + color: '#666', + }, + errorText: { + fontSize: 14, + color: '#c00', + textAlign: 'center', + }, + search: { + margin: 16, + marginBottom: 8, + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + color: '#000', + }, + count: { + marginHorizontal: 16, + marginBottom: 8, + fontSize: 12, + color: '#666', + }, + list: { + padding: 16, + paddingTop: 0, + }, + card: { + backgroundColor: '#f3f3f3', + borderRadius: 8, + padding: 12, + marginBottom: 12, + }, + modelName: { + fontSize: 15, + fontWeight: '600', + color: '#000', + marginBottom: 8, + }, + variants: { + gap: 6, + }, + variantRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + variantBadge: { + backgroundColor: '#000', + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + variantLabel: { + fontSize: 11, + color: '#fff', + fontWeight: '600', + }, + variantUrl: { + flex: 1, + fontSize: 11, + color: '#666', + }, + proBadge: { + backgroundColor: '#e8f0fe', + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + proLabel: { + fontSize: 11, + color: '#1a73e8', + fontWeight: '600', + }, +}); diff --git a/package.json b/package.json index 9707d4b..90a3988 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cactus-react-native", - "version": "1.10.0", + "version": "1.10.1", "description": "Run AI models locally on mobile devices", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/index.tsx b/src/index.tsx index 113e341..299705f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,9 @@ export { useCactusSTT } from './hooks/useCactusSTT'; export { useCactusVAD } from './hooks/useCactusVAD'; export { useCactusIndex } from './hooks/useCactusIndex'; +// Registry +export { getRegistry } from './modelRegistry'; + // Types export type { CactusModel, CactusModelOptions } from './types/common'; export type { diff --git a/src/modelRegistry.ts b/src/modelRegistry.ts index c594525..bf1ad8b 100644 --- a/src/modelRegistry.ts +++ b/src/modelRegistry.ts @@ -1,6 +1,6 @@ import type { CactusModel } from './types/common'; -const VERSION = 'v1.7'; +const RUNTIME_VERSION = '1.10.1'; let registryPromise: Promise<{ [key: string]: CactusModel }> | null = null; @@ -8,6 +8,39 @@ export function getRegistry(): Promise<{ [key: string]: CactusModel }> { return (registryPromise ??= fetchRegistry()); } +function parseVersionTag(tag: string): [number, number, number] | null { + const m = tag.match(/^v(\d+)\.(\d+)(?:\.(\d+))?$/); + if (!m) return null; + return [+m[1]!, +m[2]!, +(m[3] ?? '0')]; +} + +function compareVersions( + a: [number, number, number], + b: [number, number, number] +): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +async function resolveWeightVersion(modelId: string): Promise { + const runtime = parseVersionTag(`v${RUNTIME_VERSION}`); + if (!runtime) throw new Error(`Invalid runtime version: ${RUNTIME_VERSION}`); + + const res = await fetch(`https://huggingface.co/api/models/${modelId}/refs`); + if (!res.ok) + throw new Error(`Failed to fetch refs for ${modelId}: ${res.status}`); + + const { tags = [] } = (await res.json()) as { tags: { name: string }[] }; + + const compatible = tags + .map((t) => t.name) + .filter((name) => parseVersionTag(name) !== null) + .filter((name) => compareVersions(parseVersionTag(name)!, runtime) <= 0) + .sort((a, b) => compareVersions(parseVersionTag(b)!, parseVersionTag(a)!)); + + if (!compatible.length) throw new Error('No compatible weight version found'); + return compatible[0]!; +} + async function fetchRegistry(): Promise<{ [key: string]: CactusModel }> { const response = await fetch( 'https://huggingface.co/api/models?author=Cactus-Compute&full=true' @@ -21,6 +54,13 @@ async function fetchRegistry(): Promise<{ [key: string]: CactusModel }> { } const models: any[] = await response.json(); + if (!models.length) return {}; + + const version = await resolveWeightVersion(models[0]!.id).catch((e) => { + registryPromise = null; + throw e; + }); + const registry: { [key: string]: CactusModel } = {}; for (const { id, siblings = [] } of models) { @@ -39,7 +79,7 @@ async function fetchRegistry(): Promise<{ [key: string]: CactusModel }> { .replace('weights/', '') .replace('-int4.zip', ''); - const base = `https://huggingface.co/${id}/resolve/${VERSION}/weights/${key}`; + const base = `https://huggingface.co/${id}/resolve/${version}/weights/${key}`; registry[key] = { quantization: {