diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..45d27c7 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,30 @@ +name: 'Test Pull Request' + +on: pull_request + +jobs: + commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install commitlint + run: npm install -D @commitlint/cli @commitlint/config-conventional + - name: Validate pull request title + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.title }}" | npx commitlint --verbose + verify: + runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: 'npm' + - run: npm ci + - run: | + npm run lint + npm run verify diff --git a/CLAUDE.md b/AGENTS.md similarity index 63% rename from CLAUDE.md rename to AGENTS.md index 62879ed..a1664e1 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -1,9 +1,13 @@ ## Project Overview Capacitor Local LLM is a Capacitor plugin that wraps on-device LLM functionality on iOS and Android. -- iOS uses on-device Foundation Models introduced in iOS 26 (a very new API — avoid assumptions about its behavior; prefer checking Apple docs) +- iOS uses Foundation Models (Apple Intelligence) for text LLM (iOS 26+) and Image Playground for image generation (iOS 18.4+). Foundation Models is a new API — avoid assumptions about its behavior; prefer checking Apple docs. - Android uses on-device Gemini Nano via the ML Kit packages - Web is unsupported — plugin methods should throw a "not implemented" error on Web +## Platform Requirements +- iOS: minimum **18.4**. Image generation works on iOS 18.4+. Text LLM (Foundation Models) requires iOS 26+. +- Android: minimum **API 29** (Android 10). Do not lower these — they reflect hard requirements of the underlying native APIs. + ## Tech Stack - This package is a Capacitor plugin, as well as an SPM and CocoaPods package - Languages: TypeScript, Swift 6, Kotlin @@ -45,9 +49,10 @@ This plugin follows standard Capacitor conventions: 2. `src/web.ts` — Web stub (throw `unimplemented()`) 3. iOS Swift plugin class 4. Android Kotlin plugin class +- iOS methods that require iOS 26+ must be wrapped in `#available(iOS 26.0, *)` guards and throw `LocalLLMError.unsupported` in the `else` branch. Do not assume a feature is available just because the deployment target allows the code to compile. ## Running the Example App -Note: on-device LLMs cannot run in simulators or emulators — a physical device is required. +Note: Android emulators are not supported — Gemini Nano requires a physical Android device. iOS simulators are supported as long as the host Mac supports Apple Intelligence and has it enabled. ```bash cd example-app npm install @@ -60,7 +65,7 @@ ionic cap sync - All TypeScript interfaces, types, and functions intended for public API consumption should be documented using JSDoc comments, including: - The version the feature was introduced (`@since`) - A one-line usage example - - Platform availability for platform-exclusive features (e.g., `@platform ios`) + - Platform availability for platform-exclusive features — note this in prose within the JSDoc description, as Capacitor's docgen does not recognize a `@platform` tag - New functionality should be added for all platforms unless unavailable due to platform limitations. Platform-exclusive features must be noted in documentation. ## Testing @@ -68,6 +73,7 @@ ionic cap sync - When making changes, test on both iOS and Android physical devices before considering work complete. ## Things to Avoid -- Do not attempt to run or test using simulators or emulators — on-device LLMs require physical hardware. +- Do not attempt to run or test Android using emulators — Gemini Nano requires physical hardware. iOS simulators work with caveats (see Running the Example App above). - Avoid adding new dependencies where possible. If a dependency is needed, flag it for review before adding. -- Do not make assumptions about Foundation Models API behavior — it is new in iOS 26 and documentation may be limited. +- Do not make assumptions about Foundation Models API behavior — it is a new API and documentation may be limited. +- Do not lower the minimum platform versions (iOS 18.4 / Android API 29) — they are set to match the minimum requirements of the underlying native APIs. diff --git a/CapacitorLocalLlm.podspec b/CapacitorLocalLlm.podspec index 3efb054..917ec3d 100644 --- a/CapacitorLocalLlm.podspec +++ b/CapacitorLocalLlm.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.author = package['author'] s.source = { :git => package['repository']['url'], :tag => s.version.to_s } s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}' - s.ios.deployment_target = '26.0' + s.ios.deployment_target = '18.4' s.dependency 'Capacitor' s.swift_version = '5.1' end diff --git a/Package.swift b/Package.swift index 46794d1..636c782 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "CapacitorLocalLlm", - platforms: [.iOS(.v15)], + platforms: [.iOS("18.4.0")], products: [ .library( name: "CapacitorLocalLlm", diff --git a/README.md b/README.md index 03b13c2..a692435 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # @capacitor/local-llm -Capacitor Local LLM plugin +> [!WARNING] +> CapacitorLABS - This project is experimental. Support is not provided. Please open issues when needed. + +Run large language models entirely on-device using Apple Intelligence (Foundation Models) on iOS and Gemini Nano on Android. No network requests, no API keys, no data leaving the device. + +> **Note:** On-device LLMs require physical hardware. Android emulators are not supported. iOS simulators are supported so long as the host device is capable of running Apple Intelligence and has it enabled. ## Install @@ -9,6 +14,121 @@ npm install @capacitor/local-llm npx cap sync ``` +## Platform Requirements + +| Platform | Minimum OS | Notes | +|----------|------------|-------| +| iOS | **15** | Image generation requires iOS 18.4+. Text LLM (Foundation Models / Apple Intelligence) requires iOS 26+. | +| Android | **10 (API 29)** | Gemini Nano via ML Kit requires a device that supports on-device AI (e.g. Pixel 6+). | + +## iOS Setup + +No additional configuration is required. Foundation Models and Image Playground are system frameworks available automatically on supported devices with Apple Intelligence enabled. + +Call [`systemAvailability()`](#systemavailability) at runtime to check whether the model is ready before sending prompts. + +On iOS 18, `systemAvailability()` returns `'unavailable'` for the text LLM. If `prompt()` or `warmup()` are called anyway, the promise will reject with an error. Image generation via `generateImage()` is fully functional on iOS 18.4+. + +## Android Setup + +Gemini Nano is distributed via Google Play Services and must be downloaded to the device before use. The model is not bundled with your app. + +### Check availability and download + +Call [`systemAvailability()`](#systemavailability) to inspect the current state. If the status is `downloadable`, trigger the download with [`download()`](#download) and poll `systemAvailability()` until the status becomes `available`. + +```typescript +import { LocalLLM } from '@capacitor/local-llm'; + +const { status } = await LocalLLM.systemAvailability(); + +if (status === 'downloadable') { + await LocalLLM.download(); + // Poll systemAvailability() until status === 'available' +} +``` + +## Platform Limitations + +### iOS + +- **Text LLM requires iOS 26 and Apple Intelligence.** On iOS 18, `systemAvailability()` returns `'unavailable'` for the text LLM and `prompt()` / `warmup()` will reject. +- **`download()` is not available on iOS.** The model is managed by the OS; use `systemAvailability()` to check readiness. +- **Context limit is 4096 tokens.** This applies to the combined length of system instructions, conversation history, and the current prompt. + +### Android + +- **`maximumOutputTokens` is clamped to 1–256** by the ML Kit API. Values outside this range will be coerced. +- **Multi-turn session context is managed in-memory** by manually assembling conversation history into each prompt. It is not a native session API and does not persist across app restarts. +- **`warmup()` ignores `sessionId` and `promptPrefix`** on Android — it warms up the model globally. +- **Not all Android 10+ devices support Gemini Nano.** The device must have a compatible on-device AI chip (e.g. Pixel 6 and later). +- **On-device models cannot be used while the app is in the background.** Inference requests made while the app is backgrounded will fail. +- **AICore enforces an inference quota per app.** Making too many requests in a short period will result in an `BUSY` error response — consider exponential backoff when retrying. An `PER_APP_BATTERY_USE_QUOTA_EXCEEDED` error can be returned if an app exceeds a longer-duration quota (e.g. a daily limit). + +## Usage + +### Basic prompt + +```typescript +import { LocalLLM } from '@capacitor/local-llm'; + +const { text } = await LocalLLM.prompt({ + prompt: 'Summarize the theory of relativity in one paragraph.', +}); + +console.log(text); +``` + +### Multi-turn conversation + +Use a `sessionId` to maintain context across multiple prompts. + +```typescript +import { LocalLLM } from '@capacitor/local-llm'; + +const sessionId = 'my-chat-session'; + +await LocalLLM.prompt({ + sessionId, + instructions: 'You are a helpful assistant.', + prompt: 'What is the capital of France?', +}); + +const { text } = await LocalLLM.prompt({ + sessionId, + prompt: 'What is the population of that city?', +}); + +// Clean up when done +await LocalLLM.endSession({ sessionId }); +``` + +### Reduce first-response latency with warmup + +```typescript +import { LocalLLM } from '@capacitor/local-llm'; + +// Pre-initialize the model before the user starts typing +await LocalLLM.warmup({ + sessionId: 'my-session', + promptPrefix: 'You are a customer support agent for Acme Corp.', +}); +``` + +### Image generation (iOS only) + +```typescript +import { LocalLLM } from '@capacitor/local-llm'; + +const { pngBase64Images } = await LocalLLM.generateImage({ + prompt: 'A serene mountain lake at sunrise, photorealistic', + count: 2, +}); + +// Use directly in an tag +const src = `data:image/png;base64,${pngBase64Images[0]}`; +``` + ## API @@ -18,6 +138,9 @@ npx cap sync * [`prompt(...)`](#prompt) * [`endSession(...)`](#endsession) * [`generateImage(...)`](#generateimage) +* [`warmup(...)`](#warmup) +* [`addListener('systemAvailabilityChange', ...)`](#addlistenersystemavailabilitychange-) +* [`removeAllListeners()`](#removealllisteners) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) @@ -127,6 +250,60 @@ as base64-encoded PNG strings in an array. -------------------- +### warmup(...) + +```typescript +warmup(options: WarmupOptions) => Promise +``` + +Warms up the on-device LLM for faster initial responses. + +Use this method to pre-initialize the LLM with a prompt prefix, reducing latency +for the first actual prompt. This is useful when you know in advance the type of +prompts you'll be sending. + +| Param | Type | Description | +| ------------- | ------------------------------------------------------- | ------------------------------------------------ | +| **`options`** | WarmupOptions | - The warmup options including the prompt prefix | + +**Since:** 1.0.0 + +-------------------- + + +### addListener('systemAvailabilityChange', ...) + +```typescript +addListener(eventName: 'systemAvailabilityChange', listenerFunc: SystemAvailabilityChangeListener) => Promise +``` + +Registers a listener that is called whenever the on-device LLM availability status changes. + +The listener is invoked with the new availability status each time it changes. Polling +begins when the first listener is added and stops when all listeners are removed via +`removeAllListeners()`. + +| Param | Type | Description | +| ------------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| **`eventName`** | 'systemAvailabilityChange' | - The event name to listen for | +| **`listenerFunc`** | SystemAvailabilityChangeListener | - The callback invoked with the new availability status on each change | + +**Returns:** Promise<PluginListenerHandle> + +**Since:** 1.0.0 + +-------------------- + + +### removeAllListeners() + +```typescript +removeAllListeners() => Promise +``` + +-------------------- + + ### Interfaces @@ -167,7 +344,7 @@ Configuration options for LLM inference behavior. | Prop | Type | Description | Since | | ------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | **`temperature`** | number | Controls randomness in the model's output. Higher values (e.g., 0.8) make output more random, while lower values (e.g., 0.2) make it more focused and deterministic. | 1.0.0 | -| **`maximumOutputTokens`** | number | The maximum number of tokens to generate in the response. * | 1.0.0 | +| **`maximumOutputTokens`** | number | The maximum number of tokens to generate in the response. On Android, this must be between 1 and 256. | 1.0.0 | #### EndSessionOptions @@ -199,6 +376,23 @@ Options for generating an image from a text prompt. | **`count`** | number | The number of image variations to generate. Defaults to 1 if not specified. | 1 | 1.0.0 | +#### WarmupOptions + +Options for warming up the on-device LLM. + +| Prop | Type | Description | Since | +| ------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| **`sessionId`** | string | The session identifier for the warmup. This identifier will be associated with the warmed-up session, allowing you to use the same session for subsequent prompts. | 1.0.0 | +| **`promptPrefix`** | string | The prompt prefix to use for warming up the LLM. This text will be used to pre-initialize the model, reducing latency for subsequent prompts with similar prefixes. | 1.0.0 | + + +#### PluginListenerHandle + +| Prop | Type | +| ------------ | ----------------------------------------- | +| **`remove`** | () => Promise<void> | + + ### Type Aliases @@ -208,4 +402,11 @@ Availability status of the on-device LLM. 'available' | 'unavailable' | 'notready' | 'downloadable' | 'responding' + +#### SystemAvailabilityChangeListener + +Callback invoked when the on-device LLM availability status changes. + +(availability: LLMAvailability): void + diff --git a/android/build.gradle b/android/build.gradle index 4058143..bb24fd3 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,7 +26,7 @@ android { namespace "io.ionic.localllm.plugin" compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35 defaultConfig { - minSdkVersion 26 + minSdkVersion 29 targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35 versionCode 1 versionName "1.0" @@ -59,7 +59,7 @@ repositories { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':capacitor-android') - implementation("com.google.mlkit:genai-prompt:1.0.0-beta1") + implementation("com.google.mlkit:genai-prompt:1.0.0-beta2") implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation 'androidx.core:core-ktx:1.16.0' testImplementation "junit:junit:$junitVersion" diff --git a/android/src/main/java/io/ionic/localllm/plugin/LocalLLMPlugin.kt b/android/src/main/java/io/ionic/localllm/plugin/LocalLLMPlugin.kt index b009780..bc75f36 100644 --- a/android/src/main/java/io/ionic/localllm/plugin/LocalLLMPlugin.kt +++ b/android/src/main/java/io/ionic/localllm/plugin/LocalLLMPlugin.kt @@ -5,17 +5,66 @@ import com.getcapacitor.Plugin import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @CapacitorPlugin(name = "LocalLLM") class LocalLLMPlugin : Plugin() { private var implementation: LocalLLM? = null + private val pollingScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var availabilityPollingJob: Job? = null override fun load() { super.load() implementation = LocalLLM(context) } + @PluginMethod(returnType = PluginMethod.RETURN_CALLBACK) + override fun addListener(call: PluginCall) { + super.addListener(call) + if (call.getString("eventName") == "systemAvailabilityChange") { + startAvailabilityPolling() + } + } + + @PluginMethod + override fun removeAllListeners(call: PluginCall) { + super.removeAllListeners(call) + stopAvailabilityPolling() + } + + override fun handleOnDestroy() { + super.handleOnDestroy() + availabilityPollingJob?.cancel() + } + + private fun startAvailabilityPolling() { + if (availabilityPollingJob?.isActive == true) return + availabilityPollingJob = pollingScope.launch { + var lastAvailability: LLMAvailability? = null + while (isActive) { + val impl = implementation ?: break + val current = impl.availability() + if (current != lastAvailability) { + lastAvailability = current + notifyListeners("systemAvailabilityChange", JSObject().put("status", current.value)) + } + delay(2000) + } + } + } + + private fun stopAvailabilityPolling() { + availabilityPollingJob?.cancel() + availabilityPollingJob = null + } + @PluginMethod fun systemAvailability(call: PluginCall) { runBlocking { try { diff --git a/commitlint.config.mjs b/commitlint.config.mjs new file mode 100644 index 0000000..3f5e287 --- /dev/null +++ b/commitlint.config.mjs @@ -0,0 +1 @@ +export default { extends: ['@commitlint/config-conventional'] }; diff --git a/example-app/ios/App/App.xcodeproj/project.pbxproj b/example-app/ios/App/App.xcodeproj/project.pbxproj index 7b8b298..9b5d903 100644 --- a/example-app/ios/App/App.xcodeproj/project.pbxproj +++ b/example-app/ios/App/App.xcodeproj/project.pbxproj @@ -230,7 +230,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -281,7 +281,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -299,7 +299,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5UVU4Y5S3P; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -322,7 +322,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5UVU4Y5S3P; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example-app/ios/App/CapApp-SPM/Package.swift b/example-app/ios/App/CapApp-SPM/Package.swift index 14a6b37..d9cc438 100644 --- a/example-app/ios/App/CapApp-SPM/Package.swift +++ b/example-app/ios/App/CapApp-SPM/Package.swift @@ -4,7 +4,7 @@ import PackageDescription // DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands let package = Package( name: "CapApp-SPM", - platforms: [.iOS(.v15)], + platforms: [.iOS("18.4.0")], products: [ .library( name: "CapApp-SPM", diff --git a/ios/Sources/LocalLLMPlugin/LocalLLM.swift b/ios/Sources/LocalLLMPlugin/LocalLLM.swift index 2c25005..efe289f 100644 --- a/ios/Sources/LocalLLMPlugin/LocalLLM.swift +++ b/ios/Sources/LocalLLMPlugin/LocalLLM.swift @@ -5,189 +5,188 @@ import ImagePlayground import MobileCoreServices public enum LLMAvailability: String, Sendable { - case available = "available" - case unavailable = "unavailable" - case notReady = "notready" - case responding = "responding" + case available = "available" + case unavailable = "unavailable" + case notReady = "notready" + case responding = "responding" } public struct LLMOptions: Sendable { - let temperature: Double? - let maximumOutputTokens: Int? + let temperature: Double? + let maximumOutputTokens: Int? } public struct LLMPromptOptions: Sendable { - let sessionId: String? - let instructions: String? - let options: LLMOptions? - let prompt: String + let sessionId: String? + let instructions: String? + let options: LLMOptions? + let prompt: String } public enum LocalLLMError: Error { - case responseInProgress - case sessionNotFound - case unsupported + case responseInProgress + case sessionNotFound + case unsupported } public class LocalLLM { - private var _sessions: Any? - - @available(iOS 26.0, *) - private var sessions: [String: LanguageModelSession] { - get { _sessions as? [String: LanguageModelSession] ?? [:] } - set { _sessions = newValue } - } - - static func availability() -> LLMAvailability { - if #available(iOS 26.0, *) { - let status = SystemLanguageModel.default.availability - - switch status { - case .available: - return .available - case .unavailable(.deviceNotEligible): - return .unavailable - case .unavailable(.appleIntelligenceNotEnabled): - return .unavailable - case .unavailable(.modelNotReady): - return .notReady - case .unavailable: - return .unavailable - } - } + private var _sessions: Any? - return .unavailable - } + @available(iOS 26.0, *) + private var sessions: [String: LanguageModelSession] { + get { _sessions as? [String: LanguageModelSession] ?? [:] } + set { _sessions = newValue } + } - func warmup(sessionId: String, promptPrefix: String?) throws { - if #available(iOS 26.0, *) { - let session = getOrCreateSession(sessionId: sessionId) + static func availability() -> LLMAvailability { + if #available(iOS 26.0, *) { + let status = SystemLanguageModel.default.availability + + switch status { + case .available: + return .available + case .unavailable(.deviceNotEligible): + return .unavailable + case .unavailable(.appleIntelligenceNotEnabled): + return .unavailable + case .unavailable(.modelNotReady): + return .notReady + case .unavailable: + return .unavailable + } + } - session.prewarm(promptPrefix: .init(promptPrefix)) - } else { - throw LocalLLMError.unsupported - } - } - - func prompt(options: LLMPromptOptions) async throws -> String { - if #available(iOS 26.0, *) { - let session: LanguageModelSession - - if let sessionId = options.sessionId { - session = getOrCreateSession(sessionId: sessionId, instructions: options.instructions) - } else { - // No session ID - create temporary session - session = LanguageModelSession(instructions: options.instructions) - } - - if session.isResponding { - throw LocalLLMError.responseInProgress - } - - let response = try await session.respond( - to: options.prompt, - options: GenerationOptions( - sampling: nil, - temperature: options.options?.temperature, - maximumResponseTokens: options.options?.maximumOutputTokens - ) - ) - - return response.content + return .unavailable } - throw LocalLLMError.unsupported - } + func warmup(sessionId: String, promptPrefix: String?) throws { + if #available(iOS 26.0, *) { + let session = getOrCreateSession(sessionId: sessionId, instructions: promptPrefix) - func endSession(_ sessionId: String) { - if #available(iOS 26.0, *) { - sessions[sessionId] = nil - sessions.removeValue(forKey: sessionId) + session.prewarm(promptPrefix: .init(promptPrefix)) + } else { + throw LocalLLMError.unsupported + } } - } - - func generateImage(prompt: String, promptImages: [String], variations: Int) - async throws -> [String] - { - if #available(iOS 18.4, *) { - let creator = try await ImageCreator() - guard let style = creator.availableStyles.first else { + + func prompt(options: LLMPromptOptions) async throws -> String { + if #available(iOS 26.0, *) { + let session: LanguageModelSession + + if let sessionId = options.sessionId { + session = getOrCreateSession(sessionId: sessionId, instructions: options.instructions) + } else { + // No session ID - create temporary session + session = LanguageModelSession(instructions: options.instructions) + } + + if session.isResponding { + throw LocalLLMError.responseInProgress + } + + let response = try await session.respond( + to: options.prompt, + options: GenerationOptions( + sampling: nil, + temperature: options.options?.temperature, + maximumResponseTokens: options.options?.maximumOutputTokens + ) + ) + + return response.content + } + throw LocalLLMError.unsupported - } + } + + func endSession(_ sessionId: String) { + if #available(iOS 26.0, *) { + sessions[sessionId] = nil + sessions.removeValue(forKey: sessionId) + } + } - var concept: [ImagePlaygroundConcept] = [ - .text(prompt) - ] + func generateImage(prompt: String, promptImages: [String], variations: Int) + async throws -> [String] { + if #available(iOS 18.4, *) { + let creator = try await ImageCreator() + guard let style = creator.availableStyles.first else { + throw LocalLLMError.unsupported + } + + var concept: [ImagePlaygroundConcept] = [ + .text(prompt) + ] + + promptImages.compactMap { b64 in + return base64StringToCGImage(base64String: b64) + }.forEach { image in + concept.append(.image(image)) + } + + let images = creator.images(for: concept, style: style, limit: variations) + + var imageData: [String] = [] + + for try await image in images { + if let imagePNGData = image.cgImage.toPNGData() { + imageData.append(imagePNGData.base64EncodedString()) + } + } + + return imageData + } else { + throw LocalLLMError.unsupported + } + } - promptImages.compactMap { b64 in - return base64StringToCGImage(base64String: b64) - }.forEach { image in - concept.append(.image(image)) - } + @available(iOS 26.0, *) + private func getOrCreateSession(sessionId: String, instructions: String? = nil) -> LanguageModelSession { + if let existingSession = sessions[sessionId] { + return existingSession + } else { + let newSession = LanguageModelSession(instructions: instructions) + sessions[sessionId] = newSession + return newSession + } + } - let images = creator.images(for: concept, style: style, limit: variations) + private func base64StringToCGImage(base64String: String) -> CGImage? { + let cleanedString = + base64String.components(separatedBy: ",").last ?? base64String - var imageData: [String] = [] + guard + let data = Data( + base64Encoded: cleanedString.trimmingCharacters( + in: .whitespacesAndNewlines + ) + ) + else { return nil } - for try await image in images { - if let imagePNGData = image.cgImage.toPNGData() { - imageData.append(imagePNGData.base64EncodedString()) + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil } - } - return imageData - } else { - throw LocalLLMError.unsupported - } - } - - @available(iOS 26.0, *) - private func getOrCreateSession(sessionId: String, instructions: String? = nil) -> LanguageModelSession { - if let existingSession = sessions[sessionId] { - return existingSession - } else { - let newSession = LanguageModelSession(instructions: instructions) - sessions[sessionId] = newSession - return newSession + return CGImageSourceCreateImageAtIndex(source, 0, nil) } - } - - private func base64StringToCGImage(base64String: String) -> CGImage? { - let cleanedString = - base64String.components(separatedBy: ",").last ?? base64String - - guard - let data = Data( - base64Encoded: cleanedString.trimmingCharacters( - in: .whitespacesAndNewlines - ) - ) - else { return nil } - - guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { - return nil - } - - return CGImageSourceCreateImageAtIndex(source, 0, nil) - } } extension CGImage { - func toPNGData() -> Data? { - let pngData = NSMutableData() + func toPNGData() -> Data? { + let pngData = NSMutableData() - guard - let dest = CGImageDestinationCreateWithData(pngData, kUTTypePNG, 1, nil) - else { - return nil - } + guard + let dest = CGImageDestinationCreateWithData(pngData, kUTTypePNG, 1, nil) + else { + return nil + } - CGImageDestinationAddImage(dest, self, nil) + CGImageDestinationAddImage(dest, self, nil) - if CGImageDestinationFinalize(dest) { - return pngData as Data - } + if CGImageDestinationFinalize(dest) { + return pngData as Data + } - return nil - } + return nil + } } diff --git a/ios/Sources/LocalLLMPlugin/LocalLLMPlugin.swift b/ios/Sources/LocalLLMPlugin/LocalLLMPlugin.swift index 423bca5..a5dedfe 100644 --- a/ios/Sources/LocalLLMPlugin/LocalLLMPlugin.swift +++ b/ios/Sources/LocalLLMPlugin/LocalLLMPlugin.swift @@ -4,152 +4,188 @@ import Foundation @objc(LocalLLMPlugin) @preconcurrency public class LocalLLMPlugin: CAPPlugin, CAPBridgedPlugin { - public let identifier = "LocalLLMPlugin" - public let jsName = "LocalLLM" - public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod( - name: "systemAvailability", - returnType: CAPPluginReturnPromise - ), - CAPPluginMethod(name: "warmup", returnType: CAPPluginReturnNone), - CAPPluginMethod(name: "prompt", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "endSession", returnType: CAPPluginReturnNone), - CAPPluginMethod(name: "generateImage", returnType: CAPPluginReturnPromise), - ] - - private let implementation = LocalLLM() - - @objc func systemAvailability(_ call: CAPPluginCall) { - call.resolve([ - "status": LocalLLM.availability().rawValue - ]) - } - - @objc func warmup(_ call: CAPPluginCall) { - do { - guard let sessionId = call.getString("sessionId") else { - call.reject("sessionId is required") - return - } - - let promptPrefix = call.getString("promptPrefix") - - try implementation.warmup( - sessionId: sessionId, - promptPrefix: promptPrefix - ) - - call.resolve() - } catch { - call.reject(error.localizedDescription) + public let identifier = "LocalLLMPlugin" + public let jsName = "LocalLLM" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod( + name: "systemAvailability", + returnType: CAPPluginReturnPromise + ), + CAPPluginMethod(name: "warmup", returnType: CAPPluginReturnNone), + CAPPluginMethod(name: "prompt", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "endSession", returnType: CAPPluginReturnNone), + CAPPluginMethod(name: "generateImage", returnType: CAPPluginReturnPromise) + ] + + private let implementation = LocalLLM() + private var availabilityPollingTask: Task? + + override public func load() { + + } + + @objc override public func addListener(_ call: CAPPluginCall) { + super.addListener(call) + if call.getString("eventName") == "systemAvailabilityChange" { + startAvailabilityPolling() + } + } + + @objc override public func removeAllListeners(_ call: CAPPluginCall) { + super.removeAllListeners(call) + stopAvailabilityPolling() + } + + private func startAvailabilityPolling() { + guard availabilityPollingTask == nil else { return } + availabilityPollingTask = Task { + var lastAvailability: LLMAvailability? + while !Task.isCancelled { + let current = LocalLLM.availability() + if current != lastAvailability { + lastAvailability = current + notifyListeners("systemAvailabilityChange", data: ["status": current.rawValue]) + } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } } - } - @objc func prompt(_ call: CAPPluginCall) { - let options = getLLMPromptOptionsFromCall(call) + private func stopAvailabilityPolling() { + availabilityPollingTask?.cancel() + availabilityPollingTask = nil + } - promptAsyncCallback(options: options) { result in - switch result { - case .success(let responseText): + @objc func systemAvailability(_ call: CAPPluginCall) { call.resolve([ - "text": responseText + "status": LocalLLM.availability().rawValue ]) - case .failure(let error): - call.reject(error.localizedDescription) - } } - } - @objc func endSession(_ call: CAPPluginCall) { - guard let sessionId = call.getString("sessionId") else { - call.reject("sessionId is required") - return + @objc func warmup(_ call: CAPPluginCall) { + do { + guard let sessionId = call.getString("sessionId") else { + call.reject("sessionId is required") + return + } + + let promptPrefix = call.getString("promptPrefix") + + try implementation.warmup( + sessionId: sessionId, + promptPrefix: promptPrefix + ) + + call.resolve() + } catch { + call.reject(error.localizedDescription) + } } - implementation.endSession(sessionId) - call.resolve() - } + @objc func prompt(_ call: CAPPluginCall) { + let options = getLLMPromptOptionsFromCall(call) + + promptAsyncCallback(options: options) { result in + switch result { + case .success(let responseText): + call.resolve([ + "text": responseText + ]) + case .failure(let error): + call.reject(error.localizedDescription) + } + } + } - @objc func generateImage(_ call: CAPPluginCall) { - guard let prompt = call.getString("prompt") else { - call.reject("prompt is required") - return + @objc func endSession(_ call: CAPPluginCall) { + guard let sessionId = call.getString("sessionId") else { + call.reject("sessionId is required") + return + } + + implementation.endSession(sessionId) + call.resolve() } - let count = call.getInt("count", 1) - let promptImages: [String] = - call.getArray("promptImages")?.compactMap({ val in - return val as? String - }) ?? [] - - generateImageAsyncCallback( - prompt: prompt, - promptImages: promptImages, - count: count, - ) { result in - switch result { - case .success(let base64Images): - call.resolve([ - "pngBase64Images": base64Images - ]) - case .failure(let error): - call.reject(error.localizedDescription) - } + + @objc func generateImage(_ call: CAPPluginCall) { + guard let prompt = call.getString("prompt") else { + call.reject("prompt is required") + return + } + let count = call.getInt("count", 1) + let promptImages: [String] = + call.getArray("promptImages")?.compactMap({ val in + return val as? String + }) ?? [] + + generateImageAsyncCallback( + prompt: prompt, + promptImages: promptImages, + count: count, + ) { result in + switch result { + case .success(let base64Images): + call.resolve([ + "pngBase64Images": base64Images + ]) + case .failure(let error): + call.reject(error.localizedDescription) + } + } } - } - - private func promptAsyncCallback( - options: LLMPromptOptions, - completion: @escaping @Sendable (Result) -> Void - ) { - Task { - do { - let responseText = try await implementation.prompt(options: options) - completion(.success(responseText)) - } catch { - completion(.failure(error)) - } + + private func promptAsyncCallback( + options: LLMPromptOptions, + completion: @escaping @Sendable (Result) -> Void + ) { + Task { + do { + let responseText = try await implementation.prompt(options: options) + completion(.success(responseText)) + } catch { + completion(.failure(error)) + } + } } - } - - private func generateImageAsyncCallback( - prompt: String, - promptImages: [String], - count: Int, - completion: @escaping @Sendable (Result<[String], Error>) -> Void - ) { - Task { - do { - let images = try await implementation.generateImage( - prompt: prompt, - promptImages: promptImages, - variations: count - ) - completion(.success(images)) - } catch { - completion(.failure(error)) - } + + private func generateImageAsyncCallback( + prompt: String, + promptImages: [String], + count: Int, + completion: @escaping @Sendable (Result<[String], Error>) -> Void + ) { + Task { + do { + let images = try await implementation.generateImage( + prompt: prompt, + promptImages: promptImages, + variations: count + ) + completion(.success(images)) + } catch { + completion(.failure(error)) + } + } } - } - - private func getLLMPromptOptionsFromCall(_ call: CAPPluginCall) - -> LLMPromptOptions - { - return LLMPromptOptions( - sessionId: call.getString("sessionId"), - instructions: call.getString("instructions"), - options: getLLMOptionsFromCall(call), - prompt: call.getString("prompt", "") - ) - } - - private func getLLMOptionsFromCall(_ call: CAPPluginCall) -> LLMOptions? { - guard let optionsObject = call.getObject("options") else { - return nil + + private func getLLMPromptOptionsFromCall(_ call: CAPPluginCall) + -> LLMPromptOptions { + return LLMPromptOptions( + sessionId: call.getString("sessionId"), + instructions: call.getString("instructions"), + options: getLLMOptionsFromCall(call), + prompt: call.getString("prompt", "") + ) } - return LLMOptions( - temperature: optionsObject["temperature"] as? Double, - maximumOutputTokens: optionsObject["maximumOutputTokens"] as? Int, - ) - } + private func getLLMOptionsFromCall(_ call: CAPPluginCall) -> LLMOptions? { + guard let optionsObject = call.getObject("options") else { + return nil + } + + return LLMOptions( + temperature: optionsObject["temperature"] as? Double, + maximumOutputTokens: optionsObject["maximumOutputTokens"] as? Int, + ) + } } diff --git a/package-lock.json b/package-lock.json index d126960..9ebc92d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@ionic/eslint-config": "^0.4.0", "@ionic/prettier-config": "^4.0.0", "@ionic/swiftlint-config": "^2.0.0", + "@typescript-eslint/eslint-plugin": "^8.59.1", "eslint": "^8.57.1", + "eslint-plugin-import": "^2.32.0", "prettier": "^3.8.1", "prettier-plugin-java": "^2.8.1", "rimraf": "^6.1.3", @@ -369,60 +371,6 @@ } } }, - "node_modules/@ionic/eslint-config/node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/@ionic/eslint-config/node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@ionic/eslint-config/node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@ionic/eslint-config/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -1031,6 +979,391 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", @@ -1049,6 +1382,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/type-utils": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", @@ -1170,6 +1520,160 @@ } } }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/visitor-keys": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", @@ -1220,9 +1724,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2099,6 +2603,60 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -2365,9 +2923,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3488,9 +4046,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4546,6 +5104,55 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4569,6 +5176,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/package.json b/package.json index 8097d70..45ea48a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "Package.swift", "CapacitorLocalLlm.podspec" ], - "author": "Joseph Pender", + "author": "Ionic ", "license": "MIT", "repository": { "type": "git", @@ -53,7 +53,9 @@ "@ionic/eslint-config": "^0.4.0", "@ionic/prettier-config": "^4.0.0", "@ionic/swiftlint-config": "^2.0.0", + "@typescript-eslint/eslint-plugin": "^8.59.1", "eslint": "^8.57.1", + "eslint-plugin-import": "^2.32.0", "prettier": "^3.8.1", "prettier-plugin-java": "^2.8.1", "rimraf": "^6.1.3", diff --git a/src/definitions.ts b/src/definitions.ts index a2813db..ec88a00 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,3 +1,5 @@ +import type { PluginListenerHandle } from '@capacitor/core'; + /** * The main plugin interface for interacting with on-device LLMs. * @@ -111,8 +113,44 @@ export interface LocalLLMPlugin { * @returns A promise that resolves when warmup is complete */ warmup(options: WarmupOptions): Promise; + + /** + * Registers a listener that is called whenever the on-device LLM availability status changes. + * + * The listener is invoked with the new availability status each time it changes. Polling + * begins when the first listener is added and stops when all listeners are removed via + * `removeAllListeners()`. + * + * @since 1.0.0 + * @example + * ```typescript + * const handle = await LocalLLM.addListener('systemAvailabilityChange', (status) => { + * console.log('LLM availability changed:', status); + * }); + * + * // Later, to stop listening: + * await handle.remove(); + * ``` + * @param eventName - The event name to listen for + * @param listenerFunc - The callback invoked with the new availability status on each change + * @returns A handle that can be used to remove this specific listener + */ + addListener( + eventName: 'systemAvailabilityChange', + listenerFunc: SystemAvailabilityChangeListener, + ): Promise; + + removeAllListeners(): Promise; } +/** + * Callback invoked when the on-device LLM availability status changes. + * + * @since 1.0.0 + * @param availability - The new availability status of the LLM + */ +export type SystemAvailabilityChangeListener = (availability: LLMAvailability) => void; + /** * Configuration options for LLM inference behavior. * diff --git a/src/web.ts b/src/web.ts index 5a23324..32a0a80 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,15 +1,6 @@ import { WebPlugin } from '@capacitor/core'; -import type { - EndSessionOptions, - GenerateImageOptions, - GenerateImageResponse, - PromptOptions, - PromptResponse, - LocalLLMPlugin, - SystemAvailabilityResponse, - WarmupOptions, -} from './definitions'; +import type { GenerateImageResponse, PromptResponse, LocalLLMPlugin, SystemAvailabilityResponse } from './definitions'; export class LocalLLMWeb extends WebPlugin implements LocalLLMPlugin { systemAvailability(): Promise { @@ -18,16 +9,16 @@ export class LocalLLMWeb extends WebPlugin implements LocalLLMPlugin { download(): Promise { throw new Error('not available on the web.'); } - prompt(_options: PromptOptions): Promise { + prompt(): Promise { throw new Error('not available on the web.'); } - endSession(_options: EndSessionOptions): Promise { + endSession(): Promise { throw new Error('not available on the web.'); } - generateImage(_options: GenerateImageOptions): Promise { + generateImage(): Promise { throw new Error('not available on the web.'); } - warmup(_options: WarmupOptions): Promise { + warmup(): Promise { throw new Error('not available on the web.'); } }