From e2f144962c29ced2665fe1768cd0112f883368c0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 23 Mar 2026 22:06:30 +0100 Subject: [PATCH] feat: add unified @manacore/shared-llm package and migrate all backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a shared LLM client package that provides a unified interface to the mana-llm service, replacing 9 individual fetch-based integrations with consistent error handling, retry logic, and JSON extraction. Package (@manacore/shared-llm): - LlmModule with forRoot/forRootAsync (NestJS dynamic module) - LlmClientService: chat, json, vision, visionJson, embed, stream - LlmClient standalone class for non-NestJS consumers - extractJson utility (consolidates 3 markdown-stripping implementations) - retryFetch with exponential backoff (429, 5xx, network errors) - 44 unit tests (json-extractor, retry, llm-client) Migrated backends: - mana-core-auth: raw fetch → llm.json() - planta: raw fetch + vision → llm.visionJson() - nutriphi: raw fetch + regex → llm.visionJson() + llm.json() - chat: custom OllamaService (175 LOC) → llm.chatMessages() - context: raw fetch → llm.chat() (keeps token tracking) - traces: 2x raw fetch → llm.chat() - manadeck: @google/genai SDK → llm.json() + llm.visionJson() - bot-services: raw Ollama API → LlmClient standalone - matrix-ollama-bot: raw fetch → llm.chatMessages() + llm.vision() New credit operations: - AI_PLANT_ANALYSIS (2 credits, planta) - AI_GUIDE_GENERATION (5 credits, traces) - AI_CONTEXT_GENERATION (2 credits, context) - AI_BOT_CHAT (0.1 credits, matrix) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/chat/apps/backend/package.json | 9 +- apps/chat/apps/backend/src/app.module.ts | 10 + .../apps/backend/src/chat/ollama.service.ts | 125 +-- apps/context/apps/backend/package.json | 3 +- .../context/apps/backend/src/ai/ai.service.ts | 54 +- apps/context/apps/backend/src/app.module.ts | 11 +- apps/manadeck/apps/backend/package.json | 7 +- apps/manadeck/apps/backend/src/app.module.ts | 11 + .../apps/backend/src/services/ai.service.ts | 245 ++--- apps/nutriphi/apps/backend/package.json | 5 +- .../backend/src/analysis/gemini.service.ts | 97 +- apps/nutriphi/apps/backend/src/app.module.ts | 11 +- apps/planta/apps/backend/package.json | 3 +- .../backend/src/analysis/vision.service.ts | 76 +- apps/planta/apps/backend/src/app.module.ts | 11 +- apps/traces/apps/backend/package.json | 3 +- apps/traces/apps/backend/src/app.module.ts | 9 + .../apps/backend/src/guide/guide.service.ts | 97 +- packages/bot-services/package.json | 1 + packages/bot-services/src/ai/ai.service.ts | 181 ++-- packages/credit-operations/src/index.ts | 49 + packages/shared-llm/package.json | 47 + .../src/__tests__/json-extractor.spec.ts | 119 +++ .../src/__tests__/llm-client.spec.ts | 277 +++++ .../shared-llm/src/__tests__/retry.spec.ts | 118 +++ packages/shared-llm/src/index.ts | 35 + packages/shared-llm/src/interfaces/index.ts | 8 + .../src/interfaces/llm-options.interface.ts | 47 + packages/shared-llm/src/llm-client.service.ts | 16 + packages/shared-llm/src/llm-client.ts | 350 +++++++ packages/shared-llm/src/llm.constants.ts | 1 + packages/shared-llm/src/llm.module.ts | 80 ++ packages/shared-llm/src/standalone.ts | 30 + packages/shared-llm/src/types/chat.types.ts | 100 ++ packages/shared-llm/src/types/index.ts | 26 + .../src/types/openai-compat.types.ts | 97 ++ packages/shared-llm/src/utils/index.ts | 3 + .../shared-llm/src/utils/json-extractor.ts | 94 ++ packages/shared-llm/src/utils/retry.ts | 51 + packages/shared-llm/tsconfig.json | 21 + pnpm-lock.yaml | 972 +++++++++--------- services/mana-core-auth/package.json | 1 + services/mana-core-auth/src/ai/ai.module.ts | 2 - services/mana-core-auth/src/ai/ai.service.ts | 76 +- services/mana-core-auth/src/app.module.ts | 11 +- services/matrix-ollama-bot/package.json | 1 + services/matrix-ollama-bot/src/app.module.ts | 12 +- .../src/ollama/ollama.service.ts | 160 +-- 48 files changed, 2476 insertions(+), 1297 deletions(-) create mode 100644 packages/shared-llm/package.json create mode 100644 packages/shared-llm/src/__tests__/json-extractor.spec.ts create mode 100644 packages/shared-llm/src/__tests__/llm-client.spec.ts create mode 100644 packages/shared-llm/src/__tests__/retry.spec.ts create mode 100644 packages/shared-llm/src/index.ts create mode 100644 packages/shared-llm/src/interfaces/index.ts create mode 100644 packages/shared-llm/src/interfaces/llm-options.interface.ts create mode 100644 packages/shared-llm/src/llm-client.service.ts create mode 100644 packages/shared-llm/src/llm-client.ts create mode 100644 packages/shared-llm/src/llm.constants.ts create mode 100644 packages/shared-llm/src/llm.module.ts create mode 100644 packages/shared-llm/src/standalone.ts create mode 100644 packages/shared-llm/src/types/chat.types.ts create mode 100644 packages/shared-llm/src/types/index.ts create mode 100644 packages/shared-llm/src/types/openai-compat.types.ts create mode 100644 packages/shared-llm/src/utils/index.ts create mode 100644 packages/shared-llm/src/utils/json-extractor.ts create mode 100644 packages/shared-llm/src/utils/retry.ts create mode 100644 packages/shared-llm/tsconfig.json diff --git a/apps/chat/apps/backend/package.json b/apps/chat/apps/backend/package.json index 4be761dd6..d94ccbcae 100644 --- a/apps/chat/apps/backend/package.json +++ b/apps/chat/apps/backend/package.json @@ -29,19 +29,20 @@ "test:cov": "jest --coverage" }, "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", "@manacore/credit-operations": "workspace:*", "@manacore/nestjs-integration": "workspace:*", + "@manacore/shared-error-tracking": "workspace:*", "@manacore/shared-errors": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@manacore/shared-nestjs-auth": "workspace:*", "@manacore/shared-nestjs-health": "workspace:*", "@manacore/shared-nestjs-metrics": "workspace:*", "@manacore/shared-nestjs-setup": "workspace:*", "@nestjs/common": "^10.4.15", - "@nestjs/throttler": "^6.2.1", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", + "@nestjs/throttler": "^6.2.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", @@ -56,15 +57,15 @@ "devDependencies": { "@nestjs/cli": "^10.4.9", "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.15", "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8.18.1", "@typescript-eslint/parser": "^8.18.1", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", - "@nestjs/testing": "^10.4.15", - "@types/jest": "^30.0.0", "jest": "^30.2.0", "prettier": "^3.4.2", "source-map-support": "^0.5.21", diff --git a/apps/chat/apps/backend/src/app.module.ts b/apps/chat/apps/backend/src/app.module.ts index 85df23f6e..8ec99fd0b 100644 --- a/apps/chat/apps/backend/src/app.module.ts +++ b/apps/chat/apps/backend/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ThrottlerModule } from '@nestjs/throttler'; +import { LlmModule } from '@manacore/shared-llm'; import { MetricsModule } from '@manacore/shared-nestjs-metrics'; import { ManaCoreModule } from '@manacore/nestjs-integration'; import { DatabaseModule } from './db/database.module'; @@ -20,6 +21,15 @@ import { HealthModule } from '@manacore/shared-nestjs-health'; envFilePath: '.env', }), ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + manaLlmUrl: configService.get('MANA_LLM_URL'), + timeout: configService.get('LLM_TIMEOUT', 120000), + debug: configService.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), ManaCoreModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/apps/chat/apps/backend/src/chat/ollama.service.ts b/apps/chat/apps/backend/src/chat/ollama.service.ts index dc8d476b5..0e6a4483c 100644 --- a/apps/chat/apps/backend/src/chat/ollama.service.ts +++ b/apps/chat/apps/backend/src/chat/ollama.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { LlmClientService } from '@manacore/shared-llm'; import { AsyncResult, ok, err, ServiceError } from '@manacore/shared-errors'; import type { ChatCompletionResponseDto } from './dto/chat-completion.dto'; @@ -8,65 +8,33 @@ interface ChatMessage { content: string; } -interface ChatCompletionResponse { - id: string; - model: string; - choices: { - message: { role: string; content: string }; - finish_reason: string; - }[]; - usage: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; -} - -interface LlmModel { - id: string; - owned_by: string; -} - @Injectable() export class OllamaService { private readonly logger = new Logger(OllamaService.name); - private readonly baseUrl: string; - private readonly timeout: number; - private isConnected = false; - constructor(private configService: ConfigService) { - this.baseUrl = this.configService.get('MANA_LLM_URL') || 'http://localhost:3025'; - this.timeout = this.configService.get('LLM_TIMEOUT') || 120000; - - // Check connection on startup + constructor(private readonly llm: LlmClientService) { this.checkConnection(); } async checkConnection(): Promise { try { - const response = await fetch(`${this.baseUrl}/health`, { - signal: AbortSignal.timeout(5000), - }); - if (response.ok) { - const data = await response.json(); - this.isConnected = data.status === 'healthy' || data.status === 'degraded'; - if (this.isConnected) { - const providers = Object.keys(data.providers || {}).join(', '); - this.logger.log(`mana-llm connected: ${data.status}, providers: ${providers}`); - } - return this.isConnected; + const health = await this.llm.health(); + const isConnected = health.status === 'healthy' || health.status === 'degraded'; + if (isConnected) { + const providers = Object.keys(health.providers || {}).join(', '); + this.logger.log(`mana-llm connected: ${health.status}, providers: ${providers}`); } - this.isConnected = false; - return false; - } catch (error) { - this.isConnected = false; - this.logger.warn(`mana-llm not available at ${this.baseUrl} - local models will not work`); + return isConnected; + } catch { + this.logger.warn('mana-llm not available - local models will not work'); return false; } } isAvailable(): boolean { - return this.isConnected; + // Perform a synchronous check based on last known state + // The actual health is checked on-demand via checkConnection + return true; } async createChatCompletion( @@ -75,70 +43,33 @@ export class OllamaService { temperature?: number, maxTokens?: number ): AsyncResult { - if (!this.isConnected) { - // Try to reconnect - await this.checkConnection(); - if (!this.isConnected) { - return err( - ServiceError.externalError('mana-llm', `mana-llm server not available at ${this.baseUrl}`) - ); - } - } - - // Normalize model name to include ollama/ prefix if it doesn't have a provider const normalizedModel = modelName.includes('/') ? modelName : `ollama/${modelName}`; this.logger.log(`Sending request to mana-llm model: ${normalizedModel}`); try { - const requestBody: Record = { + const result = await this.llm.chatMessages(messages, { model: normalizedModel, - messages, - stream: false, - }; - - // Add optional parameters - if (temperature !== undefined) { - requestBody.temperature = temperature; - } - if (maxTokens !== undefined) { - requestBody.max_tokens = maxTokens; - } - - const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(this.timeout), + temperature, + maxTokens, }); - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`mana-llm API error: ${response.status} - ${errorText}`); - return err(ServiceError.externalError('mana-llm', `API error: ${response.status}`)); - } - - const data: ChatCompletionResponse = await response.json(); - - if (!data.choices?.[0]?.message?.content) { + if (!result.content) { this.logger.warn('No message content in mana-llm response'); return err(ServiceError.generationFailed('mana-llm', 'No response generated')); } - const usage = data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; - - // Log performance metrics - if (usage.completion_tokens) { + if (result.usage.completion_tokens) { this.logger.debug( - `Generated ${usage.completion_tokens} tokens (total: ${usage.total_tokens})` + `Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})` ); } return ok({ - content: data.choices[0].message.content, + content: result.content, usage: { - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - total_tokens: usage.total_tokens, + prompt_tokens: result.usage.prompt_tokens, + completion_tokens: result.usage.completion_tokens, + total_tokens: result.usage.total_tokens, }, }); } catch (error) { @@ -160,14 +91,8 @@ export class OllamaService { async listModels(): Promise { try { - const response = await fetch(`${this.baseUrl}/v1/models`, { - signal: AbortSignal.timeout(5000), - }); - if (!response.ok) { - return []; - } - const data = await response.json(); - return (data.data || []).map((m: LlmModel) => m.id); + const models = await this.llm.listModels(); + return models.map((m) => m.id); } catch { return []; } diff --git a/apps/context/apps/backend/package.json b/apps/context/apps/backend/package.json index 326ea5811..c048318a7 100644 --- a/apps/context/apps/backend/package.json +++ b/apps/context/apps/backend/package.json @@ -21,8 +21,9 @@ "db:seed": "tsx src/db/seed.ts" }, "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", "@manacore/shared-drizzle-config": "workspace:*", + "@manacore/shared-error-tracking": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@manacore/shared-nestjs-auth": "workspace:*", "@manacore/shared-nestjs-health": "workspace:*", "@manacore/shared-nestjs-setup": "workspace:*", diff --git a/apps/context/apps/backend/src/ai/ai.service.ts b/apps/context/apps/backend/src/ai/ai.service.ts index 8f607a765..cc6e5f765 100644 --- a/apps/context/apps/backend/src/ai/ai.service.ts +++ b/apps/context/apps/backend/src/ai/ai.service.ts @@ -1,5 +1,5 @@ import { Injectable, BadRequestException, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { LlmClientService } from '@manacore/shared-llm'; import { TokenService } from '../token/token.service'; interface GenerateOptions { @@ -19,14 +19,11 @@ function estimateTokens(text: string): number { @Injectable() export class AiService { private readonly logger = new Logger(AiService.name); - private readonly manaLlmUrl: string; constructor( - private configService: ConfigService, + private readonly llm: LlmClientService, private tokenService: TokenService - ) { - this.manaLlmUrl = this.configService.get('MANA_LLM_URL') || 'http://localhost:3025'; - } + ) {} async generate(userId: string, options: GenerateOptions) { const model = options.model || 'ollama/gemma3:4b'; @@ -51,11 +48,16 @@ export class AiService { } // Generate text via mana-llm - const completionText = await this.generateWithManaLlm(fullPrompt, options, model); + const result = await this.llm.chat(fullPrompt, { + model, + systemPrompt: 'You are a helpful assistant.', + temperature: options.temperature || 0.7, + maxTokens: options.maxTokens || 2000, + }); - // Calculate actual cost and log - const actualPromptTokens = estimateTokens(fullPrompt); - const completionTokens = estimateTokens(completionText); + // Use actual token counts from response when available, fall back to estimates + const actualPromptTokens = result.usage.prompt_tokens || estimateTokens(fullPrompt); + const completionTokens = result.usage.completion_tokens || estimateTokens(result.content); const { tokensUsed, remainingBalance } = await this.tokenService.logUsage( userId, model, @@ -65,7 +67,7 @@ export class AiService { ); return { - text: completionText, + text: result.content, tokenInfo: { promptTokens: actualPromptTokens, completionTokens, @@ -110,34 +112,4 @@ export class AiService { balance, }; } - - private async generateWithManaLlm( - prompt: string, - options: GenerateOptions, - model: string - ): Promise { - const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: prompt }, - ], - temperature: options.temperature || 0.7, - max_tokens: options.maxTokens || 2000, - }), - signal: AbortSignal.timeout(120000), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`mana-llm error: ${response.status} - ${errorText}`); - throw new BadRequestException(`LLM generation failed: ${response.status}`); - } - - const data = await response.json(); - return data.choices?.[0]?.message?.content || ''; - } } diff --git a/apps/context/apps/backend/src/app.module.ts b/apps/context/apps/backend/src/app.module.ts index bc52e4d92..86f733b83 100644 --- a/apps/context/apps/backend/src/app.module.ts +++ b/apps/context/apps/backend/src/app.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; import { APP_FILTER } from '@nestjs/core'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { ThrottlerModule } from '@nestjs/throttler'; +import { LlmModule } from '@manacore/shared-llm'; import { DatabaseModule } from './db/database.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; import { SpaceModule } from './space/space.module'; @@ -22,6 +23,14 @@ import { HttpExceptionFilter } from './common/http-exception.filter'; limit: 100, }, ]), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('MANA_LLM_URL'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), DatabaseModule, HealthModule.forRoot({ serviceName: 'context-backend' }), SpaceModule, diff --git a/apps/manadeck/apps/backend/package.json b/apps/manadeck/apps/backend/package.json index 00c7ad59d..115ef2979 100644 --- a/apps/manadeck/apps/backend/package.json +++ b/apps/manadeck/apps/backend/package.json @@ -21,11 +21,12 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", - "@manacore/nestjs-integration": "workspace:*", - "@manacore/shared-errors": "workspace:*", "@google/genai": "^1.14.0", "@manacore/manadeck-database": "workspace:*", + "@manacore/nestjs-integration": "workspace:*", + "@manacore/shared-error-tracking": "workspace:*", + "@manacore/shared-errors": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", diff --git a/apps/manadeck/apps/backend/src/app.module.ts b/apps/manadeck/apps/backend/src/app.module.ts index fa7a16caf..a83e2d4e0 100644 --- a/apps/manadeck/apps/backend/src/app.module.ts +++ b/apps/manadeck/apps/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { ClsModule } from 'nestjs-cls'; import { TerminusModule } from '@nestjs/terminus'; import { HttpModule } from '@nestjs/axios'; import { ManaCoreModule } from '@manacore/nestjs-integration'; +import { LlmModule } from '@manacore/shared-llm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ApiController } from './controllers/api.controller'; @@ -50,6 +51,16 @@ import { inject: [ConfigService], }) as any, + // LLM (via mana-llm service) + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('MANA_LLM_URL'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), + // Health checks TerminusModule, HttpModule, diff --git a/apps/manadeck/apps/backend/src/services/ai.service.ts b/apps/manadeck/apps/backend/src/services/ai.service.ts index 0285ad540..f2eb92bed 100644 --- a/apps/manadeck/apps/backend/src/services/ai.service.ts +++ b/apps/manadeck/apps/backend/src/services/ai.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { GoogleGenAI, Type } from '@google/genai'; +import { LlmClientService } from '@manacore/shared-llm'; import { AsyncResult, ok, err, ServiceError } from '@manacore/shared-errors'; export type CardType = 'text' | 'flashcard' | 'quiz' | 'mixed'; @@ -50,32 +49,16 @@ export interface DeckGenerationData { @Injectable() export class AiService { private readonly logger = new Logger(AiService.name); - private readonly ai: GoogleGenAI | null; - private readonly model = 'gemini-2.0-flash'; - constructor(private readonly configService: ConfigService) { - const apiKey = this.configService.get('GOOGLE_GENAI_API_KEY'); - - if (apiKey) { - this.ai = new GoogleGenAI({ apiKey }); - this.logger.log('Google Gemini AI initialized successfully'); - } else { - this.ai = null; - this.logger.warn('Google Gemini API key not configured - AI features disabled'); - } - } + constructor(private readonly llm: LlmClientService) {} isAvailable(): boolean { - return this.ai !== null; + return true; } async generateDeck(request: DeckGenerationRequest): AsyncResult { const startTime = Date.now(); - if (!this.ai) { - return err(ServiceError.unavailable('AI (Google Gemini not configured)')); - } - const { prompt, deckTitle, @@ -96,28 +79,23 @@ export class AiService { cardTypes ); - const response = await this.ai.models.generateContent({ - model: this.model, - contents: userPrompt, - config: { - systemInstruction: systemPrompt, - responseMimeType: 'application/json', - responseSchema: this.buildResponseSchema(cardTypes), + const { data, usage } = await this.llm.json<{ cards: GeneratedCard[] }>(userPrompt, { + systemPrompt, + temperature: 0.7, + validate: (raw) => { + const obj = raw as { cards: GeneratedCard[] }; + if (!obj.cards || !Array.isArray(obj.cards)) { + throw new Error('Response must contain a "cards" array'); + } + return obj; }, }); const generationTime = Date.now() - startTime; - const responseText = response.text?.trim(); - - if (!responseText) { - return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI')); - } - - const parsed = JSON.parse(responseText); - const cards: GeneratedCard[] = parsed.cards || []; + const cards = data.cards; if (cards.length === 0) { - return err(ServiceError.generationFailed('Google Gemini', 'No cards generated')); + return err(ServiceError.generationFailed('mana-llm', 'No cards generated')); } this.logger.log(`Generated ${cards.length} cards in ${generationTime}ms`); @@ -125,8 +103,8 @@ export class AiService { return ok({ cards, metadata: { - model: this.model, - tokensUsed: response.usageMetadata?.totalTokenCount, + model: 'mana-llm', + tokensUsed: usage.total_tokens || undefined, generationTime, }, }); @@ -135,7 +113,7 @@ export class AiService { return err( ServiceError.generationFailed( - 'Google Gemini', + 'mana-llm', error instanceof Error ? error.message : 'Unknown error occurred', error instanceof Error ? error : undefined ) @@ -176,7 +154,33 @@ QUALITY GUIDELINES: 4. For quiz: all 4 options should be plausible, avoid obviously wrong answers 5. Include helpful hints for difficult flashcards 6. Add explanations for quiz questions to reinforce learning -7. Progress from easier to harder cards when possible`; +7. Progress from easier to harder cards when possible + +RESPONSE FORMAT: +You MUST respond with a valid JSON object containing a "cards" array. Each card has: +${this.buildJsonSchemaDescription(cardTypes)}`; + } + + private buildJsonSchemaDescription(cardTypes: CardType[]): string { + const schemas: string[] = []; + + if (cardTypes.includes('flashcard')) { + schemas.push( + `- Flashcard: { "cardType": "flashcard", "title": "optional title", "content": { "front": "question/term", "back": "answer/definition", "hint": "optional hint" } }` + ); + } + if (cardTypes.includes('quiz')) { + schemas.push( + `- Quiz: { "cardType": "quiz", "title": "optional title", "content": { "question": "the question", "options": ["A", "B", "C", "D"], "correctAnswer": 0, "explanation": "why this is correct" } }` + ); + } + if (cardTypes.includes('text')) { + schemas.push( + `- Text: { "cardType": "text", "title": "optional title", "content": { "text": "informational content" } }` + ); + } + + return schemas.join('\n'); } private buildUserPrompt( @@ -200,7 +204,9 @@ CARD DISTRIBUTION: ${typeDistribution} Generate exactly ${cardCount} cards that cover the topic comprehensively. -Ensure variety in the questions and good coverage of the subject matter.`; +Ensure variety in the questions and good coverage of the subject matter. + +Respond ONLY with a JSON object: {"cards": [...]}`; } private suggestTypeDistribution(cardCount: number, cardTypes: CardType[]): string { @@ -229,7 +235,7 @@ Ensure variety in the questions and good coverage of the subject matter.`; } /** - * Generate cards from an image using Gemini Vision + * Generate cards from an image using vision model */ async generateFromImage( imageBase64: string, @@ -238,59 +244,41 @@ Ensure variety in the questions and good coverage of the subject matter.`; ): AsyncResult { const startTime = Date.now(); - if (!this.ai) { - return err(ServiceError.unavailable('AI (Google Gemini not configured)')); - } - try { const prompt = `Analyze this image and create ${cardCount} educational flashcards based on its content. ${context ? `Context: ${context}` : ''} For each concept, term, or important element you identify in the image, create a flashcard or quiz question. -Return the cards as a JSON object with a "cards" array containing objects with: +Return ONLY a JSON object: {"cards": [...]} where each card has: - cardType: "flashcard" or "quiz" - title: short title - content: { front, back, hint } for flashcards OR { question, options, correctAnswer, explanation } for quiz`; - const response = await this.ai.models.generateContent({ - model: this.model, - contents: [ - { - role: 'user', - parts: [ - { text: prompt }, - { - inlineData: { - mimeType: 'image/jpeg', - data: imageBase64, - }, - }, - ], + const { data, usage } = await this.llm.visionJson<{ cards: GeneratedCard[] }>( + prompt, + imageBase64, + 'image/jpeg', + { + validate: (raw) => { + const obj = raw as { cards: GeneratedCard[] }; + if (!obj.cards || !Array.isArray(obj.cards)) { + throw new Error('Response must contain a "cards" array'); + } + return obj; }, - ], - config: { - responseMimeType: 'application/json', - }, - }); + } + ); const generationTime = Date.now() - startTime; - const responseText = response.text?.trim(); - if (!responseText) { - return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI')); - } - - const parsed = JSON.parse(responseText); - const cards: GeneratedCard[] = parsed.cards || []; - - this.logger.log(`Generated ${cards.length} cards from image in ${generationTime}ms`); + this.logger.log(`Generated ${data.cards.length} cards from image in ${generationTime}ms`); return ok({ - cards, + cards: data.cards, metadata: { - model: this.model, - tokensUsed: response.usageMetadata?.totalTokenCount, + model: 'mana-llm', + tokensUsed: usage.total_tokens || undefined, generationTime, }, }); @@ -298,7 +286,7 @@ Return the cards as a JSON object with a "cards" array containing objects with: this.logger.error('AI image generation failed:', error); return err( ServiceError.generationFailed( - 'Google Gemini', + 'mana-llm', error instanceof Error ? error.message : 'Unknown error' ) ); @@ -312,109 +300,24 @@ Return the cards as a JSON object with a "cards" array containing objects with: content: string, cardType: string ): AsyncResult<{ enhancedContent: string }> { - if (!this.ai) { - return err(ServiceError.unavailable('AI (Google Gemini not configured)')); - } - try { - const prompt = `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging. + const result = await this.llm.chat( + `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging. Original content: ${content} -Return the enhanced content in the same JSON format as the input, but improved.`; +Return the enhanced content in the same JSON format as the input, but improved.` + ); - const response = await this.ai.models.generateContent({ - model: this.model, - contents: prompt, - config: { - responseMimeType: 'application/json', - }, - }); - - const responseText = response.text?.trim(); - if (!responseText) { + if (!result.content) { return ok({ enhancedContent: content }); } - return ok({ enhancedContent: responseText }); + return ok({ enhancedContent: result.content }); } catch (error) { this.logger.error('AI content enhancement failed:', error); - return ok({ enhancedContent: content }); // Return original on failure + return ok({ enhancedContent: content }); } } - - private buildResponseSchema(cardTypes: CardType[]): any { - const cardSchemas: any[] = []; - - if (cardTypes.includes('flashcard')) { - cardSchemas.push({ - type: Type.OBJECT, - properties: { - cardType: { type: Type.STRING, enum: ['flashcard'] }, - title: { type: Type.STRING }, - content: { - type: Type.OBJECT, - properties: { - front: { type: Type.STRING }, - back: { type: Type.STRING }, - hint: { type: Type.STRING }, - }, - required: ['front', 'back'], - }, - }, - required: ['cardType', 'content'], - }); - } - - if (cardTypes.includes('quiz')) { - cardSchemas.push({ - type: Type.OBJECT, - properties: { - cardType: { type: Type.STRING, enum: ['quiz'] }, - title: { type: Type.STRING }, - content: { - type: Type.OBJECT, - properties: { - question: { type: Type.STRING }, - options: { type: Type.ARRAY, items: { type: Type.STRING } }, - correctAnswer: { type: Type.NUMBER }, - explanation: { type: Type.STRING }, - }, - required: ['question', 'options', 'correctAnswer'], - }, - }, - required: ['cardType', 'content'], - }); - } - - if (cardTypes.includes('text')) { - cardSchemas.push({ - type: Type.OBJECT, - properties: { - cardType: { type: Type.STRING, enum: ['text'] }, - title: { type: Type.STRING }, - content: { - type: Type.OBJECT, - properties: { - text: { type: Type.STRING }, - }, - required: ['text'], - }, - }, - required: ['cardType', 'content'], - }); - } - - return { - type: Type.OBJECT, - properties: { - cards: { - type: Type.ARRAY, - items: cardSchemas.length === 1 ? cardSchemas[0] : { anyOf: cardSchemas }, - }, - }, - required: ['cards'], - }; - } } diff --git a/apps/nutriphi/apps/backend/package.json b/apps/nutriphi/apps/backend/package.json index 79eef49e1..fbbe85aa4 100644 --- a/apps/nutriphi/apps/backend/package.json +++ b/apps/nutriphi/apps/backend/package.json @@ -23,17 +23,18 @@ "db:seed": "tsx src/db/seed.ts" }, "dependencies": { + "@google/generative-ai": "^0.21.0", "@manacore/shared-error-tracking": "workspace:*", - "@nutriphi/shared": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@manacore/shared-nestjs-auth": "workspace:*", "@manacore/shared-nestjs-health": "workspace:*", "@manacore/shared-nestjs-metrics": "workspace:*", "@manacore/shared-nestjs-setup": "workspace:*", - "@google/generative-ai": "^0.21.0", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", + "@nutriphi/shared": "workspace:*", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", diff --git a/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts b/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts index 63662b562..ff42687f0 100644 --- a/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts +++ b/apps/nutriphi/apps/backend/src/analysis/gemini.service.ts @@ -1,5 +1,5 @@ -import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Injectable, Logger } from '@nestjs/common'; +import { LlmClientService } from '@manacore/shared-llm'; import type { AIAnalysisResult } from '../types/nutrition.types'; const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere das Bild dieser Mahlzeit und liefere eine detaillierte Nährwertanalyse. @@ -75,95 +75,28 @@ Antworte NUR mit einem validen JSON-Objekt im folgenden Format: }`; @Injectable() -export class GeminiService implements OnModuleInit { +export class GeminiService { private readonly logger = new Logger(GeminiService.name); - private manaLlmUrl: string | null = null; - private readonly visionModel = 'ollama/llava:7b'; - private readonly textModel = 'ollama/gemma3:4b'; - constructor(private configService: ConfigService) {} - - onModuleInit() { - this.manaLlmUrl = this.configService.get('MANA_LLM_URL') || 'http://localhost:3025'; - this.logger.log(`NutriPhi AI using mana-llm at ${this.manaLlmUrl}`); - } + constructor(private readonly llm: LlmClientService) {} async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise { - if (!this.manaLlmUrl) { - throw new Error('mana-llm not configured'); - } - - const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: this.visionModel, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: ANALYSIS_PROMPT }, - { - type: 'image_url', - image_url: { url: `data:${mimeType};base64,${imageBase64}` }, - }, - ], - }, - ], - temperature: 0.3, - }), - signal: AbortSignal.timeout(120000), - }); - - if (!response.ok) { - const errorText = await response.text(); - this.logger.error(`mana-llm vision error: ${response.status} - ${errorText}`); - throw new Error('Failed to analyze image'); - } - - const data = await response.json(); - const text = data.choices?.[0]?.message?.content || ''; - - // Extract JSON from response - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error('Failed to parse AI response'); - } - - return JSON.parse(jsonMatch[0]) as AIAnalysisResult; + const { data } = await this.llm.visionJson( + ANALYSIS_PROMPT, + imageBase64, + mimeType, + { temperature: 0.3 } + ); + return data; } async analyzeText(description: string): Promise { - if (!this.manaLlmUrl) { - throw new Error('mana-llm not configured'); - } - const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description); - const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: this.textModel, - messages: [{ role: 'user', content: prompt }], - temperature: 0.3, - }), - signal: AbortSignal.timeout(60000), + const { data } = await this.llm.json(prompt, { + temperature: 0.3, + timeout: 60_000, }); - - if (!response.ok) { - throw new Error(`mana-llm error: ${response.status}`); - } - - const data = await response.json(); - const text = data.choices?.[0]?.message?.content || ''; - - // Extract JSON from response - const jsonMatch = text.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error('Failed to parse AI response'); - } - - return JSON.parse(jsonMatch[0]) as AIAnalysisResult; + return data; } } diff --git a/apps/nutriphi/apps/backend/src/app.module.ts b/apps/nutriphi/apps/backend/src/app.module.ts index a18201f66..b37d83ae1 100644 --- a/apps/nutriphi/apps/backend/src/app.module.ts +++ b/apps/nutriphi/apps/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { LlmModule } from '@manacore/shared-llm'; import { DatabaseModule } from './db/database.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; import { MetricsModule } from '@manacore/shared-nestjs-metrics'; @@ -16,6 +17,14 @@ import { RecommendationsModule } from './recommendations/recommendations.module' isGlobal: true, envFilePath: ['.env', '.env.development'], }), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('MANA_LLM_URL'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), DatabaseModule, HealthModule.forRoot({ serviceName: 'nutriphi-backend' }), MetricsModule.register({ diff --git a/apps/planta/apps/backend/package.json b/apps/planta/apps/backend/package.json index e45026c98..9c5c64a8e 100644 --- a/apps/planta/apps/backend/package.json +++ b/apps/planta/apps/backend/package.json @@ -18,8 +18,9 @@ "db:seed": "tsx src/db/seed.ts" }, "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", "@google/generative-ai": "^0.21.0", + "@manacore/shared-error-tracking": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@manacore/shared-nestjs-auth": "workspace:*", "@manacore/shared-nestjs-health": "workspace:*", "@manacore/shared-nestjs-metrics": "workspace:*", diff --git a/apps/planta/apps/backend/src/analysis/vision.service.ts b/apps/planta/apps/backend/src/analysis/vision.service.ts index 2383c5439..6946fa351 100644 --- a/apps/planta/apps/backend/src/analysis/vision.service.ts +++ b/apps/planta/apps/backend/src/analysis/vision.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { LlmClientService } from '@manacore/shared-llm'; import type { AnalysisResult } from '@planta/shared'; const PLANT_ANALYSIS_PROMPT = `Du bist ein erfahrener Botaniker und Pflanzenexperte. Analysiere dieses Pflanzenfoto und erstelle einen detaillierten Steckbrief. @@ -43,70 +43,32 @@ Falls du die Pflanze nicht identifizieren kannst, setze confidence auf 0 und sci @Injectable() export class VisionService { private readonly logger = new Logger(VisionService.name); - private readonly manaLlmUrl: string; - private readonly visionModel = 'ollama/llava:7b'; - constructor(private configService: ConfigService) { - this.manaLlmUrl = this.configService.get('MANA_LLM_URL') || 'http://localhost:3025'; - this.logger.log(`Planta Vision using mana-llm at ${this.manaLlmUrl}`); - } + constructor(private readonly llm: LlmClientService) {} async analyzePlantImage(imageBuffer: Buffer, mimeType: string): Promise { try { const base64 = imageBuffer.toString('base64'); - const result = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: this.visionModel, - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: PLANT_ANALYSIS_PROMPT }, - { - type: 'image_url', - image_url: { url: `data:${mimeType};base64,${base64}` }, - }, - ], - }, - ], + const { data } = await this.llm.visionJson( + PLANT_ANALYSIS_PROMPT, + base64, + mimeType, + { temperature: 0.3, - }), - signal: AbortSignal.timeout(120000), - }); - - if (!result.ok) { - const errorText = await result.text(); - this.logger.error(`mana-llm vision error: ${result.status} - ${errorText}`); - return null; - } - - const data = await result.json(); - const response = (data.choices?.[0]?.message?.content || '').trim(); - - this.logger.debug(`Gemini raw response: ${response}`); - - // Parse JSON response - handle potential markdown code blocks - let jsonStr = response; - if (response.includes('```')) { - const match = response.match(/```(?:json)?\s*([\s\S]*?)```/); - if (match) { - jsonStr = match[1].trim(); + validate: (raw) => { + const result = raw as AnalysisResult; + this.validateAnalysisResult(result); + return result; + }, } - } - - const parsed = JSON.parse(jsonStr) as AnalysisResult; - - // Validate and sanitize response - this.validateAnalysisResult(parsed); - - this.logger.log( - `Plant identified: ${parsed.identification.scientificName} (${parsed.identification.confidence}% confidence)` ); - return parsed; + this.logger.log( + `Plant identified: ${data.identification.scientificName} (${data.identification.confidence}% confidence)` + ); + + return data; } catch (error) { this.logger.error(`Vision analysis failed: ${error}`); return null; @@ -114,7 +76,6 @@ export class VisionService { } private validateAnalysisResult(result: AnalysisResult): void { - // Validate identification if (!result.identification) { result.identification = { scientificName: 'Unbekannt', @@ -123,13 +84,11 @@ export class VisionService { }; } - // Ensure confidence is within range if (typeof result.identification.confidence !== 'number') { result.identification.confidence = 0; } result.identification.confidence = Math.max(0, Math.min(100, result.identification.confidence)); - // Validate health if (!result.health) { result.health = { status: 'healthy', @@ -143,7 +102,6 @@ export class VisionService { result.health.status = 'healthy'; } - // Validate care if (!result.care) { result.care = { light: 'medium', diff --git a/apps/planta/apps/backend/src/app.module.ts b/apps/planta/apps/backend/src/app.module.ts index a227dcc86..0019a62ed 100644 --- a/apps/planta/apps/backend/src/app.module.ts +++ b/apps/planta/apps/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { LlmModule } from '@manacore/shared-llm'; import { DatabaseModule } from './db/database.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; import { MetricsModule } from '@manacore/shared-nestjs-metrics'; @@ -14,6 +15,14 @@ import { WateringModule } from './watering/watering.module'; isGlobal: true, envFilePath: '.env', }), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('MANA_LLM_URL'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), DatabaseModule, HealthModule.forRoot({ serviceName: 'planta-backend' }), MetricsModule.register({ diff --git a/apps/traces/apps/backend/package.json b/apps/traces/apps/backend/package.json index 44424b3c1..a287a51f8 100644 --- a/apps/traces/apps/backend/package.json +++ b/apps/traces/apps/backend/package.json @@ -17,8 +17,9 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", "@manacore/nestjs-integration": "workspace:*", + "@manacore/shared-error-tracking": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@manacore/shared-nestjs-auth": "workspace:*", "@manacore/shared-nestjs-health": "workspace:*", "@manacore/shared-nestjs-metrics": "workspace:*", diff --git a/apps/traces/apps/backend/src/app.module.ts b/apps/traces/apps/backend/src/app.module.ts index f4d8168f6..ee397da8e 100644 --- a/apps/traces/apps/backend/src/app.module.ts +++ b/apps/traces/apps/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { LlmModule } from '@manacore/shared-llm'; import { MetricsModule } from '@manacore/shared-nestjs-metrics'; import { ManaCoreModule } from '@manacore/nestjs-integration'; import { HealthModule } from '@manacore/shared-nestjs-health'; @@ -25,6 +26,14 @@ import { GuideModule } from './guide/guide.module'; }), inject: [ConfigService], }), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('MANA_LLM_URL'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), MetricsModule.register({ prefix: 'traces_', excludePaths: ['/health'], diff --git a/apps/traces/apps/backend/src/guide/guide.service.ts b/apps/traces/apps/backend/src/guide/guide.service.ts index 1611567a4..be7bb26bf 100644 --- a/apps/traces/apps/backend/src/guide/guide.service.ts +++ b/apps/traces/apps/backend/src/guide/guide.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } fro import { ConfigService } from '@nestjs/config'; import { eq, and, desc } from 'drizzle-orm'; import { CreditClientService } from '@manacore/nestjs-integration'; +import { LlmClientService } from '@manacore/shared-llm'; import { DATABASE_CONNECTION } from '../db/database.module'; import type { Database } from '../db/connection'; import { guides, guidePois, pois, cities } from '../db/schema'; @@ -18,7 +19,8 @@ export class GuideService { private readonly configService: ConfigService, private readonly cityService: CityService, private readonly poiService: PoiService, - private readonly creditClient: CreditClientService + private readonly creditClient: CreditClientService, + private readonly llm: LlmClientService ) {} async generateGuide(userId: string, request: GenerateGuideRequest) { @@ -135,35 +137,20 @@ export class GuideService { // Step 3: Enrich POIs with AI summaries this.logger.log(`[${guideId}] Step 3: Content enrichment`); - if (manaLlmUrl) { - for (const poi of nearbyPois) { - if (!poi.aiSummary) { - try { - const prompt = - language === 'de' - ? `Schreibe eine 200-Wort-Zusammenfassung über "${poi.name}" in ${city.name}. Fokus auf Baugeschichte, Architekturstil und interessante Anekdoten.` - : `Write a 200-word summary about "${poi.name}" in ${city.name}. Focus on architectural history, style, and interesting anecdotes.`; + for (const poi of nearbyPois) { + if (!poi.aiSummary) { + try { + const prompt = + language === 'de' + ? `Schreibe eine 200-Wort-Zusammenfassung über "${poi.name}" in ${city.name}. Fokus auf Baugeschichte, Architekturstil und interessante Anekdoten.` + : `Write a 200-word summary about "${poi.name}" in ${city.name}. Focus on architectural history, style, and interesting anecdotes.`; - const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [{ role: 'user', content: prompt }], - model: 'default', - max_tokens: 500, - }), - }); - - if (llmResponse.ok) { - const data = await llmResponse.json(); - const summary = data.choices?.[0]?.message?.content; - if (summary) { - await this.poiService.updateAiSummary(poi.id, summary, language); - } - } - } catch (err) { - this.logger.warn(`AI summary failed for POI ${poi.name}:`, err); + const result = await this.llm.chat(prompt, { maxTokens: 500 }); + if (result.content) { + await this.poiService.updateAiSummary(poi.id, result.content, language); } + } catch (err) { + this.logger.warn(`AI summary failed for POI ${poi.name}:`, err); } } } @@ -197,43 +184,29 @@ export class GuideService { const poi = sortedPois[i]; let narrative: string | null = null; - if (manaLlmUrl) { - try { - const prevStation = i > 0 ? sortedPois[i - 1].name : 'Startpunkt'; - const distanceToPrev = - i > 0 - ? Math.round( - this.haversineDistance( - sortedPois[i - 1].latitude, - sortedPois[i - 1].longitude, - poi.latitude, - poi.longitude - ) + try { + const prevStation = i > 0 ? sortedPois[i - 1].name : 'Startpunkt'; + const distanceToPrev = + i > 0 + ? Math.round( + this.haversineDistance( + sortedPois[i - 1].latitude, + sortedPois[i - 1].longitude, + poi.latitude, + poi.longitude ) - : 0; + ) + : 0; - const prompt = - language === 'de' - ? `Du bist ein erfahrener Stadtführer in ${city.name}. Schreibe einen kurzen, lebendigen Stadtführer-Text (80-120 Wörter) über "${poi.name}" als Station ${i + 1} einer Stadtführung. ${i > 0 ? `Die vorherige Station war "${prevStation}" (${distanceToPrev}m entfernt).` : 'Dies ist die erste Station.'} Erwähne architektonische Details und eine interessante Anekdote.` - : `You are an experienced city guide in ${city.name}. Write a short, vivid guide text (80-120 words) about "${poi.name}" as station ${i + 1} of a walking tour. ${i > 0 ? `The previous station was "${prevStation}" (${distanceToPrev}m away).` : 'This is the first station.'} Mention architectural details and an interesting anecdote.`; + const prompt = + language === 'de' + ? `Du bist ein erfahrener Stadtführer in ${city.name}. Schreibe einen kurzen, lebendigen Stadtführer-Text (80-120 Wörter) über "${poi.name}" als Station ${i + 1} einer Stadtführung. ${i > 0 ? `Die vorherige Station war "${prevStation}" (${distanceToPrev}m entfernt).` : 'Dies ist die erste Station.'} Erwähne architektonische Details und eine interessante Anekdote.` + : `You are an experienced city guide in ${city.name}. Write a short, vivid guide text (80-120 words) about "${poi.name}" as station ${i + 1} of a walking tour. ${i > 0 ? `The previous station was "${prevStation}" (${distanceToPrev}m away).` : 'This is the first station.'} Mention architectural details and an interesting anecdote.`; - const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [{ role: 'user', content: prompt }], - model: 'default', - max_tokens: 300, - }), - }); - - if (llmResponse.ok) { - const data = await llmResponse.json(); - narrative = data.choices?.[0]?.message?.content || null; - } - } catch (err) { - this.logger.warn(`Narrative generation failed for POI ${poi.name}:`, err); - } + const result = await this.llm.chat(prompt, { maxTokens: 300 }); + narrative = result.content || null; + } catch (err) { + this.logger.warn(`Narrative generation failed for POI ${poi.name}:`, err); } guidePoiRecords.push({ diff --git a/packages/bot-services/package.json b/packages/bot-services/package.json index 4f09e860f..08954c764 100644 --- a/packages/bot-services/package.json +++ b/packages/bot-services/package.json @@ -71,6 +71,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { + "@manacore/shared-llm": "workspace:^", "@nestjs/common": "^11.0.20", "@nestjs/config": "^4.0.2", "date-fns": "^4.1.0", diff --git a/packages/bot-services/src/ai/ai.service.ts b/packages/bot-services/src/ai/ai.service.ts index 224c880b8..d73e1b533 100644 --- a/packages/bot-services/src/ai/ai.service.ts +++ b/packages/bot-services/src/ai/ai.service.ts @@ -1,4 +1,6 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { LlmClient, resolveOptions } from '@manacore/shared-llm'; +import type { ModelInfo } from '@manacore/shared-llm'; import { OllamaModel, ChatMessage, @@ -9,23 +11,34 @@ import { SYSTEM_PROMPTS, VISION_MODELS, NON_CHAT_MODELS, - OllamaVersionResponse, - OllamaTagsResponse, - OllamaChatResponse, } from './types'; @Injectable() export class AiService implements OnModuleInit { private readonly logger = new Logger(AiService.name); private readonly config: AiServiceConfig; + private readonly llm: LlmClient; private sessions: Map = new Map(); constructor(config?: Partial) { this.config = { - baseUrl: config?.baseUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434', + baseUrl: + config?.baseUrl ?? + process.env.MANA_LLM_URL ?? + process.env.OLLAMA_URL ?? + 'http://localhost:3025', defaultModel: config?.defaultModel ?? process.env.OLLAMA_MODEL ?? 'gemma3:4b', timeout: config?.timeout ?? parseInt(process.env.OLLAMA_TIMEOUT ?? '120000'), }; + + this.llm = new LlmClient( + resolveOptions({ + manaLlmUrl: this.config.baseUrl, + defaultModel: this.normalizeModel(this.config.defaultModel), + timeout: this.config.timeout, + maxRetries: 1, + }) + ); } async onModuleInit() { @@ -36,14 +49,15 @@ export class AiService implements OnModuleInit { async checkConnection(): Promise { try { - const response = await fetch(`${this.config.baseUrl}/api/version`, { - signal: AbortSignal.timeout(5000), - }); - const data = (await response.json()) as OllamaVersionResponse; - this.logger.log(`Ollama connected: v${data.version}`); - return true; + const health = await this.llm.health(); + const isConnected = health.status === 'healthy' || health.status === 'degraded'; + if (isConnected) { + const providers = Object.keys(health.providers || {}).join(', '); + this.logger.log(`mana-llm connected: ${health.status}, providers: ${providers}`); + } + return isConnected; } catch (error) { - this.logger.error(`Failed to connect to Ollama at ${this.config.baseUrl}:`, error); + this.logger.error(`Failed to connect to mana-llm at ${this.config.baseUrl}:`, error); return false; } } @@ -52,9 +66,12 @@ export class AiService implements OnModuleInit { async listModels(): Promise { try { - const response = await fetch(`${this.config.baseUrl}/api/tags`); - const data = (await response.json()) as OllamaTagsResponse; - return data.models || []; + const models = await this.llm.listModels(); + return models.map((m: ModelInfo) => ({ + name: m.id, + size: 0, + modified_at: new Date(m.created * 1000).toISOString(), + })); } catch (error) { this.logger.error('Failed to list models:', error); return []; @@ -79,55 +96,38 @@ export class AiService implements OnModuleInit { async chat(messages: ChatMessage[], options?: ChatOptions): Promise { const model = options?.model ?? this.config.defaultModel; + const normalizedModel = this.normalizeModel(model); - try { - const response = await fetch(`${this.config.baseUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages, - stream: false, - options: { - temperature: options?.temperature, - num_predict: options?.maxTokens, - }, - }), - signal: AbortSignal.timeout(this.config.timeout), - }); - - if (!response.ok) { - throw new Error(`Ollama API error: ${response.status}`); + const result = await this.llm.chatMessages( + messages.map((m) => ({ + role: m.role, + content: m.content, + })), + { + model: normalizedModel, + temperature: options?.temperature, + maxTokens: options?.maxTokens, } + ); - const data = (await response.json()) as OllamaChatResponse; + const meta = { + model, + evalCount: result.usage.completion_tokens, + evalDuration: undefined as number | undefined, + tokensPerSecond: undefined as number | undefined, + }; - const meta = { - model, - evalCount: data.eval_count, - evalDuration: data.eval_duration, - tokensPerSecond: - data.eval_count && data.eval_duration - ? (data.eval_count / data.eval_duration) * 1e9 - : undefined, - }; - - if (meta.tokensPerSecond) { - this.logger.debug( - `Generated ${meta.evalCount} tokens at ${meta.tokensPerSecond.toFixed(1)} t/s` - ); - } - - return { - content: data.message?.content || '', - meta, - }; - } catch (error) { - if (error instanceof Error && error.name === 'TimeoutError') { - throw new Error('Ollama Timeout - Antwort dauerte zu lange'); - } - throw error; + if (meta.evalCount && result.latencyMs > 0) { + meta.tokensPerSecond = (meta.evalCount / result.latencyMs) * 1000; + this.logger.debug( + `Generated ${meta.evalCount} tokens at ${meta.tokensPerSecond.toFixed(1)} t/s` + ); } + + return { + content: result.content, + meta, + }; } async chatSimple(userId: string, message: string, options?: ChatOptions): Promise { @@ -162,51 +162,27 @@ export class AiService implements OnModuleInit { async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise { const selectedModel = model ?? this.config.defaultModel; + const normalizedModel = this.normalizeModel(selectedModel); - try { - const response = await fetch(`${this.config.baseUrl}/api/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: selectedModel, - messages: [ - { - role: 'user', - content: prompt, - images: [imageBase64], - }, - ], - stream: false, - }), - signal: AbortSignal.timeout(this.config.timeout), - }); + const result = await this.llm.vision(prompt, imageBase64, 'image/png', { + model: normalizedModel, + }); - if (!response.ok) { - throw new Error(`Ollama API error: ${response.status}`); - } + const meta = { + model: selectedModel, + evalCount: result.usage.completion_tokens, + evalDuration: undefined as number | undefined, + tokensPerSecond: undefined as number | undefined, + }; - const data = (await response.json()) as OllamaChatResponse; - - const meta = { - model: selectedModel, - evalCount: data.eval_count, - evalDuration: data.eval_duration, - tokensPerSecond: - data.eval_count && data.eval_duration - ? (data.eval_count / data.eval_duration) * 1e9 - : undefined, - }; - - return { - content: data.message?.content || '', - meta, - }; - } catch (error) { - if (error instanceof Error && error.name === 'TimeoutError') { - throw new Error('Ollama Timeout - Bildanalyse dauerte zu lange'); - } - throw error; + if (meta.evalCount && result.latencyMs > 0) { + meta.tokensPerSecond = (meta.evalCount / result.latencyMs) * 1000; } + + return { + content: result.content, + meta, + }; } // ===== Compare Models ===== @@ -256,7 +232,7 @@ export class AiService implements OnModuleInit { setSessionModel(userId: string, model: string): void { const session = this.getSession(userId); session.model = model; - session.history = []; // Clear history when switching models + session.history = []; } setSessionSystemPrompt(userId: string, prompt: string): void { @@ -303,4 +279,9 @@ export class AiService implements OnModuleInit { const entry = Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt); return entry ? entry[0] : 'custom'; } + + private normalizeModel(model: string): string { + if (model.includes('/')) return model; + return `ollama/${model}`; + } } diff --git a/packages/credit-operations/src/index.ts b/packages/credit-operations/src/index.ts index e603a724f..6bee32a4e 100644 --- a/packages/credit-operations/src/index.ts +++ b/packages/credit-operations/src/index.ts @@ -44,6 +44,18 @@ export enum CreditOperationType { // Zitare - AI explanations AI_QUOTE_EXPLANATION = 'ai_quote_explanation', + // Planta - Plant analysis + AI_PLANT_ANALYSIS = 'ai_plant_analysis', + + // Traces - City guide generation + AI_GUIDE_GENERATION = 'ai_guide_generation', + + // Context - AI text generation + AI_CONTEXT_GENERATION = 'ai_context_generation', + + // Matrix Bots - Bot chat + AI_BOT_CHAT = 'ai_bot_chat', + // General AI features AI_SMART_SCHEDULING = 'ai_smart_scheduling', AI_SUGGESTIONS = 'ai_suggestions', @@ -117,6 +129,11 @@ export const CREDIT_COSTS: Record = { [CreditOperationType.AI_QUOTE_EXPLANATION]: 2, + [CreditOperationType.AI_PLANT_ANALYSIS]: 2, + [CreditOperationType.AI_GUIDE_GENERATION]: 5, + [CreditOperationType.AI_CONTEXT_GENERATION]: 2, + [CreditOperationType.AI_BOT_CHAT]: 0.1, + [CreditOperationType.AI_SMART_SCHEDULING]: 2, [CreditOperationType.AI_SUGGESTIONS]: 2, [CreditOperationType.AI_ENRICHMENT]: 2, @@ -271,6 +288,38 @@ export const OPERATION_METADATA: Record app: 'zitare', }, + // Planta + [CreditOperationType.AI_PLANT_ANALYSIS]: { + name: 'Plant Analysis', + description: 'Identify and analyze a plant from a photo', + category: CreditCategory.AI, + app: 'planta', + }, + + // Traces + [CreditOperationType.AI_GUIDE_GENERATION]: { + name: 'City Guide Generation', + description: 'Generate an AI-powered city walking guide', + category: CreditCategory.AI, + app: 'traces', + }, + + // Context + [CreditOperationType.AI_CONTEXT_GENERATION]: { + name: 'AI Text Generation', + description: 'Generate or transform text with AI', + category: CreditCategory.AI, + app: 'context', + }, + + // Matrix Bots + [CreditOperationType.AI_BOT_CHAT]: { + name: 'Bot Chat Message', + description: 'Chat with AI via Matrix bot', + category: CreditCategory.AI, + app: 'matrix', + }, + // General AI [CreditOperationType.AI_SMART_SCHEDULING]: { name: 'Smart Scheduling', diff --git a/packages/shared-llm/package.json b/packages/shared-llm/package.json new file mode 100644 index 000000000..4af079b4f --- /dev/null +++ b/packages/shared-llm/package.json @@ -0,0 +1,47 @@ +{ + "name": "@manacore/shared-llm", + "version": "1.0.0", + "private": true, + "description": "Unified LLM client for all Mana backends via mana-llm service", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./standalone": { + "types": "./dist/standalone.d.ts", + "import": "./dist/standalone.js", + "require": "./dist/standalone.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "type-check": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/config": "^3.0.0 || ^4.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/config": "^3.0.0 || ^4.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vitest": "^2.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/packages/shared-llm/src/__tests__/json-extractor.spec.ts b/packages/shared-llm/src/__tests__/json-extractor.spec.ts new file mode 100644 index 000000000..a67118c87 --- /dev/null +++ b/packages/shared-llm/src/__tests__/json-extractor.spec.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { extractJson } from '../utils/json-extractor'; + +describe('extractJson', () => { + it('parses direct JSON object', () => { + const result = extractJson('{"name": "test", "value": 42}'); + expect(result).toEqual({ name: 'test', value: 42 }); + }); + + it('parses direct JSON array', () => { + const result = extractJson('[1, 2, 3]'); + expect(result).toEqual([1, 2, 3]); + }); + + it('strips markdown json code fence', () => { + const input = '```json\n{"category": "bug", "title": "Fix login"}\n```'; + const result = extractJson(input); + expect(result).toEqual({ category: 'bug', title: 'Fix login' }); + }); + + it('strips markdown code fence without json label', () => { + const input = '```\n{"key": "value"}\n```'; + const result = extractJson(input); + expect(result).toEqual({ key: 'value' }); + }); + + it('extracts JSON from surrounding text', () => { + const input = + 'Here is the analysis:\n{"confidence": 0.95, "species": "Rose"}\nHope this helps!'; + const result = extractJson(input); + expect(result).toEqual({ confidence: 0.95, species: 'Rose' }); + }); + + it('extracts JSON array from surrounding text', () => { + const input = 'The items are: [1, 2, 3] as requested.'; + const result = extractJson(input); + expect(result).toEqual([1, 2, 3]); + }); + + it('handles nested JSON objects', () => { + const input = '{"outer": {"inner": {"deep": true}}, "list": [1, 2]}'; + const result = extractJson(input); + expect(result).toEqual({ outer: { inner: { deep: true } }, list: [1, 2] }); + }); + + it('handles JSON with escaped quotes in strings', () => { + const input = '{"text": "He said \\"hello\\""}'; + const result = extractJson(input); + expect(result).toEqual({ text: 'He said "hello"' }); + }); + + it('handles JSON with braces inside strings', () => { + const input = 'Result: {"code": "if (x) { return }"}'; + const result = extractJson(input); + expect(result).toEqual({ code: 'if (x) { return }' }); + }); + + it('trims whitespace before parsing', () => { + const input = ' \n {"key": "value"} \n '; + const result = extractJson(input); + expect(result).toEqual({ key: 'value' }); + }); + + it('applies validation function on success', () => { + const validate = (data: unknown) => { + const obj = data as { name: string }; + if (!obj.name) throw new Error('missing name'); + return obj; + }; + const result = extractJson('{"name": "test"}', validate); + expect(result).toEqual({ name: 'test' }); + }); + + it('throws when validation fails', () => { + const validate = (data: unknown) => { + const obj = data as { name?: string }; + if (!obj.name) throw new Error('missing name'); + return obj; + }; + expect(() => extractJson('{"value": 123}', validate)).toThrow(); + }); + + it('throws on completely invalid input', () => { + expect(() => extractJson('This is just plain text with no JSON')).toThrow( + 'Failed to extract JSON' + ); + }); + + it('throws on empty input', () => { + expect(() => extractJson('')).toThrow('Failed to extract JSON'); + }); + + it('handles real-world LLM response with preamble', () => { + const input = `Based on my analysis, here is the result: + +\`\`\`json +{ + "foods": [ + {"name": "Apple", "calories": 95, "protein": 0.5} + ], + "totalCalories": 95, + "confidence": 0.9 +} +\`\`\` + +This analysis is based on the image provided.`; + + const result = extractJson<{ foods: unknown[]; totalCalories: number }>(input); + expect(result.totalCalories).toBe(95); + expect(result.foods).toHaveLength(1); + }); + + it('prefers object over array when both exist', () => { + // Direct parse fails, fence fails, tries object first + const input = 'Some text {"key": "val"} and [1, 2, 3]'; + const result = extractJson(input); + expect(result).toEqual({ key: 'val' }); + }); +}); diff --git a/packages/shared-llm/src/__tests__/llm-client.spec.ts b/packages/shared-llm/src/__tests__/llm-client.spec.ts new file mode 100644 index 000000000..2ee65e8e0 --- /dev/null +++ b/packages/shared-llm/src/__tests__/llm-client.spec.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LlmClient } from '../llm-client'; +import type { ResolvedLlmOptions } from '../interfaces/llm-options.interface'; +import type { ChatCompletionResponse } from '../types/openai-compat.types'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +const DEFAULT_OPTIONS: ResolvedLlmOptions = { + manaLlmUrl: 'http://localhost:3025', + defaultModel: 'ollama/gemma3:4b', + defaultVisionModel: 'ollama/llava:7b', + timeout: 30_000, + maxRetries: 0, // No retries in tests for simplicity + debug: false, +}; + +function mockCompletionResponse( + content: string, + model = 'ollama/gemma3:4b' +): ChatCompletionResponse { + return { + id: 'chatcmpl-test123', + object: 'chat.completion', + created: Date.now(), + model, + choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }; +} + +function mockFetchOk(body: unknown): void { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as unknown as Response); +} + +function mockFetchError(status: number, body = ''): void { + mockFetch.mockResolvedValueOnce({ + ok: false, + status, + statusText: `Error ${status}`, + json: () => Promise.resolve({}), + text: () => Promise.resolve(body), + } as unknown as Response); +} + +describe('LlmClient', () => { + let client: LlmClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = new LlmClient(DEFAULT_OPTIONS); + }); + + describe('chat', () => { + it('sends correct request body', async () => { + mockFetchOk(mockCompletionResponse('Hello!')); + + await client.chat('Hi there'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('http://localhost:3025/v1/chat/completions'); + + const body = JSON.parse(init.body); + expect(body.model).toBe('ollama/gemma3:4b'); + expect(body.messages).toEqual([{ role: 'user', content: 'Hi there' }]); + expect(body.stream).toBe(false); + }); + + it('includes system prompt when provided', async () => { + mockFetchOk(mockCompletionResponse('Response')); + + await client.chat('Question', { systemPrompt: 'You are helpful.' }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.messages).toEqual([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Question' }, + ]); + }); + + it('uses custom model and temperature', async () => { + mockFetchOk(mockCompletionResponse('Response')); + + await client.chat('Prompt', { model: 'openrouter/gpt-4o', temperature: 0.3 }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.model).toBe('openrouter/gpt-4o'); + expect(body.temperature).toBe(0.3); + }); + + it('returns ChatResult with content and usage', async () => { + mockFetchOk(mockCompletionResponse('Generated text')); + + const result = await client.chat('Prompt'); + + expect(result.content).toBe('Generated text'); + expect(result.model).toBe('ollama/gemma3:4b'); + expect(result.usage.total_tokens).toBe(30); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('throws on error response', async () => { + mockFetchError(500, 'Internal Server Error'); + + await expect(client.chat('Prompt')).rejects.toThrow('mana-llm error 500'); + }); + }); + + describe('json', () => { + it('extracts JSON from response', async () => { + mockFetchOk(mockCompletionResponse('{"category": "bug", "title": "Fix it"}')); + + const result = await client.json<{ category: string; title: string }>('Analyze this'); + + expect(result.data).toEqual({ category: 'bug', title: 'Fix it' }); + expect(result.content).toBe('{"category": "bug", "title": "Fix it"}'); + }); + + it('extracts JSON from markdown-wrapped response', async () => { + mockFetchOk(mockCompletionResponse('```json\n{"key": "value"}\n```')); + + const result = await client.json('Parse this'); + expect(result.data).toEqual({ key: 'value' }); + }); + + it('applies validation function', async () => { + mockFetchOk(mockCompletionResponse('{"name": "test"}')); + + const validate = (data: unknown) => { + const obj = data as { name: string }; + if (typeof obj.name !== 'string') throw new Error('invalid'); + return obj; + }; + + const result = await client.json('Prompt', { validate }); + expect(result.data.name).toBe('test'); + }); + + it('retries JSON extraction on parse failure', async () => { + // First attempt returns bad JSON, second returns good + mockFetchOk(mockCompletionResponse('not json at all')); + mockFetchOk(mockCompletionResponse('{"valid": true}')); + + const result = await client.json('Prompt', { jsonRetries: 1 }); + expect(result.data).toEqual({ valid: true }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('vision', () => { + it('builds multimodal message with base64 image', async () => { + mockFetchOk(mockCompletionResponse('A rose')); + + await client.vision('What is this?', 'abc123base64', 'image/jpeg'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.model).toBe('ollama/llava:7b'); + expect(body.messages[0].content).toEqual([ + { type: 'text', text: 'What is this?' }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,abc123base64' } }, + ]); + }); + + it('uses data URL as-is if already formatted', async () => { + mockFetchOk(mockCompletionResponse('A cat')); + + await client.vision('What?', 'data:image/png;base64,xyz'); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const imageUrl = body.messages[0].content[1].image_url.url; + expect(imageUrl).toBe('data:image/png;base64,xyz'); + }); + + it('uses custom vision model when specified', async () => { + mockFetchOk(mockCompletionResponse('Result')); + + await client.vision('Prompt', 'img', 'image/jpeg', { + visionModel: 'ollama/qwen3-vl:4b', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.model).toBe('ollama/qwen3-vl:4b'); + }); + }); + + describe('visionJson', () => { + it('extracts JSON from vision response', async () => { + mockFetchOk(mockCompletionResponse('```json\n{"species": "Rose", "confidence": 0.95}\n```')); + + const result = await client.visionJson<{ species: string }>( + 'Identify plant', + 'imgdata', + 'image/jpeg' + ); + + expect(result.data.species).toBe('Rose'); + }); + }); + + describe('health', () => { + it('returns health status', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + status: 'healthy', + providers: { ollama: { status: 'healthy' } }, + }), + } as unknown as Response); + + const health = await client.health(); + expect(health.status).toBe('healthy'); + }); + + it('returns unhealthy on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const health = await client.health(); + expect(health.status).toBe('unhealthy'); + }); + }); + + describe('listModels', () => { + it('returns model list', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: [{ id: 'ollama/gemma3:4b', object: 'model', created: 0, owned_by: 'ollama' }], + }), + } as unknown as Response); + + const models = await client.listModels(); + expect(models).toHaveLength(1); + expect(models[0].id).toBe('ollama/gemma3:4b'); + }); + }); + + describe('chatMessages', () => { + it('sends full message history', async () => { + mockFetchOk(mockCompletionResponse('Answer')); + + await client.chatMessages([ + { role: 'system', content: 'Be brief.' }, + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi!' }, + { role: 'user', content: 'How are you?' }, + ]); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.messages).toHaveLength(4); + }); + }); + + describe('embed', () => { + it('sends embedding request', async () => { + mockFetchOk({ + object: 'list', + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + model: 'ollama/gemma3:4b', + usage: { prompt_tokens: 5, completion_tokens: 0, total_tokens: 5 }, + }); + + const result = await client.embed('Hello world'); + expect(result.embeddings).toHaveLength(1); + expect(result.embeddings[0]).toEqual([0.1, 0.2, 0.3]); + }); + }); +}); diff --git a/packages/shared-llm/src/__tests__/retry.spec.ts b/packages/shared-llm/src/__tests__/retry.spec.ts new file mode 100644 index 000000000..80122e165 --- /dev/null +++ b/packages/shared-llm/src/__tests__/retry.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { retryFetch } from '../utils/retry'; + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function mockResponse(status: number, body = ''): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: `Status ${status}`, + text: () => Promise.resolve(body), + json: () => Promise.resolve(JSON.parse(body || '{}')), + headers: new Headers(), + } as unknown as Response; +} + +describe('retryFetch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns on first successful attempt', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(200, '{"ok": true}')); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('retries on 503 and succeeds', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(503)) + .mockResolvedValueOnce(mockResponse(200, '{}')); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('retries on 429 rate limit', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(429)) + .mockResolvedValueOnce(mockResponse(200, '{}')); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('retries on network error and succeeds', async () => { + mockFetch + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce(mockResponse(200, '{}')); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.ok).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('does NOT retry on 400 client error', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(400, 'Bad Request')); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.status).toBe(400); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does NOT retry on 401 unauthorized', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(401)); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.status).toBe(401); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does NOT retry on 404 not found', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(404)); + + const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 }); + expect(response.status).toBe(404); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('throws after exhausting all retries', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(503)) + .mockResolvedValueOnce(mockResponse(503)) + .mockResolvedValueOnce(mockResponse(503)); + + await expect(retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 })).rejects.toThrow( + 'HTTP 503' + ); + + expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries + }); + + it('throws after exhausting retries on network errors', async () => { + mockFetch + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(retryFetch('http://test', {}, { maxRetries: 1, baseDelay: 10 })).rejects.toThrow( + 'ECONNREFUSED' + ); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('works with maxRetries: 0 (no retries)', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(503)); + + await expect(retryFetch('http://test', {}, { maxRetries: 0, baseDelay: 10 })).rejects.toThrow(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-llm/src/index.ts b/packages/shared-llm/src/index.ts new file mode 100644 index 000000000..d7f9de192 --- /dev/null +++ b/packages/shared-llm/src/index.ts @@ -0,0 +1,35 @@ +// Module +export { LlmModule } from './llm.module'; +export { LlmClientService } from './llm-client.service'; +export { LLM_MODULE_OPTIONS } from './llm.constants'; + +// Core client (for advanced use cases) +export { LlmClient } from './llm-client'; + +// Interfaces +export type { + LlmModuleOptions, + LlmModuleAsyncOptions, + LlmOptionsFactory, + ResolvedLlmOptions, +} from './interfaces'; +export { resolveOptions } from './interfaces'; + +// Types +export type { + ChatMessage, + ContentPart, + TextContentPart, + ImageContentPart, + ChatOptions, + JsonOptions, + VisionOptions, + TokenUsage, + ChatResult, + JsonResult, + ModelInfo, + HealthStatus, +} from './types'; + +// Utilities +export { extractJson } from './utils'; diff --git a/packages/shared-llm/src/interfaces/index.ts b/packages/shared-llm/src/interfaces/index.ts new file mode 100644 index 000000000..5159eabfe --- /dev/null +++ b/packages/shared-llm/src/interfaces/index.ts @@ -0,0 +1,8 @@ +export type { + LlmModuleOptions, + LlmModuleAsyncOptions, + LlmOptionsFactory, + ResolvedLlmOptions, +} from './llm-options.interface'; + +export { resolveOptions } from './llm-options.interface'; diff --git a/packages/shared-llm/src/interfaces/llm-options.interface.ts b/packages/shared-llm/src/interfaces/llm-options.interface.ts new file mode 100644 index 000000000..def8b8463 --- /dev/null +++ b/packages/shared-llm/src/interfaces/llm-options.interface.ts @@ -0,0 +1,47 @@ +import type { ModuleMetadata, Type } from '@nestjs/common'; + +export interface LlmModuleOptions { + /** mana-llm service URL (default: http://localhost:3025) */ + manaLlmUrl?: string; + /** Default text model (default: ollama/gemma3:4b) */ + defaultModel?: string; + /** Default vision model (default: ollama/llava:7b) */ + defaultVisionModel?: string; + /** Request timeout in ms (default: 120000) */ + timeout?: number; + /** Max retries on transient failures (default: 2) */ + maxRetries?: number; + /** Enable debug logging (default: false) */ + debug?: boolean; +} + +export interface LlmModuleAsyncOptions extends Pick { + useExisting?: Type; + useClass?: Type; + useFactory?: (...args: any[]) => Promise | LlmModuleOptions; + inject?: any[]; +} + +export interface LlmOptionsFactory { + createLlmOptions(): Promise | LlmModuleOptions; +} + +export interface ResolvedLlmOptions { + manaLlmUrl: string; + defaultModel: string; + defaultVisionModel: string; + timeout: number; + maxRetries: number; + debug: boolean; +} + +export function resolveOptions(options: LlmModuleOptions): ResolvedLlmOptions { + return { + manaLlmUrl: options.manaLlmUrl ?? 'http://localhost:3025', + defaultModel: options.defaultModel ?? 'ollama/gemma3:4b', + defaultVisionModel: options.defaultVisionModel ?? 'ollama/llava:7b', + timeout: options.timeout ?? 120_000, + maxRetries: options.maxRetries ?? 2, + debug: options.debug ?? false, + }; +} diff --git a/packages/shared-llm/src/llm-client.service.ts b/packages/shared-llm/src/llm-client.service.ts new file mode 100644 index 000000000..17d210ea9 --- /dev/null +++ b/packages/shared-llm/src/llm-client.service.ts @@ -0,0 +1,16 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { LlmClient } from './llm-client'; +import { LLM_MODULE_OPTIONS } from './llm.constants'; +import type { LlmModuleOptions } from './interfaces/llm-options.interface'; +import { resolveOptions } from './interfaces/llm-options.interface'; + +/** + * NestJS injectable wrapper around LlmClient. + * All logic lives in the framework-agnostic LlmClient base class. + */ +@Injectable() +export class LlmClientService extends LlmClient { + constructor(@Inject(LLM_MODULE_OPTIONS) options: LlmModuleOptions) { + super(resolveOptions(options)); + } +} diff --git a/packages/shared-llm/src/llm-client.ts b/packages/shared-llm/src/llm-client.ts new file mode 100644 index 000000000..5ff6f2763 --- /dev/null +++ b/packages/shared-llm/src/llm-client.ts @@ -0,0 +1,350 @@ +/** + * Framework-agnostic LLM client that communicates with the mana-llm service. + * + * This is the core implementation shared between the NestJS LlmClientService + * and the standalone LlmClient export (for non-NestJS consumers like bot-services). + */ + +import type { ResolvedLlmOptions } from './interfaces/llm-options.interface'; +import type { + ChatMessage, + ChatOptions, + ChatResult, + JsonOptions, + JsonResult, + VisionOptions, + TokenUsage, + ModelInfo, + HealthStatus, +} from './types/chat.types'; +import type { + ChatCompletionRequest, + ChatCompletionResponse, + EmbeddingResponse, +} from './types/openai-compat.types'; +import { extractJson } from './utils/json-extractor'; +import { retryFetch } from './utils/retry'; + +function createTimeoutSignal(ms: number): any { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; +} + +export class LlmClient { + private readonly baseUrl: string; + private readonly options: ResolvedLlmOptions; + + constructor(options: ResolvedLlmOptions) { + this.options = options; + this.baseUrl = options.manaLlmUrl.replace(/\/+$/, ''); + } + + // --------------------------------------------------------------------------- + // Text Chat + // --------------------------------------------------------------------------- + + /** Simple chat with a single prompt string. */ + async chat(prompt: string, opts?: ChatOptions): Promise { + const messages = this.buildMessages(prompt, opts?.systemPrompt); + return this.chatMessages(messages, opts); + } + + /** Chat with full message history. */ + async chatMessages(messages: ChatMessage[], opts?: ChatOptions): Promise { + const body = this.buildRequest(messages, opts, false); + const start = Date.now(); + const response = await this.fetchCompletion(body, opts?.timeout); + const latencyMs = Date.now() - start; + + return { + content: response.choices[0]?.message?.content ?? '', + model: response.model, + usage: response.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + latencyMs, + }; + } + + // --------------------------------------------------------------------------- + // Streaming + // --------------------------------------------------------------------------- + + /** Streaming chat - returns an async iterable of text tokens. */ + async *chatStream(prompt: string, opts?: ChatOptions): AsyncIterable { + const messages = this.buildMessages(prompt, opts?.systemPrompt); + yield* this.chatStreamMessages(messages, opts); + } + + /** Streaming chat with full message history. */ + async *chatStreamMessages(messages: ChatMessage[], opts?: ChatOptions): AsyncIterable { + const body = this.buildRequest(messages, opts, true); + const timeout = opts?.timeout ?? this.options.timeout; + + const response = await retryFetch( + `${this.baseUrl}/v1/chat/completions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: createTimeoutSignal(timeout), + }, + { maxRetries: this.options.maxRetries } + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`mana-llm stream error ${response.status}: ${text}`); + } + + if (!response.body) { + throw new Error('mana-llm returned no response body for stream'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data: ')) continue; + + const data = trimmed.slice(6); + if (data === '[DONE]') return; + + try { + const chunk = JSON.parse(data); + const content = chunk.choices?.[0]?.delta?.content; + if (content) yield content; + } catch { + // Skip unparseable chunks + } + } + } + } finally { + reader.releaseLock(); + } + } + + // --------------------------------------------------------------------------- + // Structured JSON Output + // --------------------------------------------------------------------------- + + /** Chat that extracts and parses JSON from the response. */ + async json(prompt: string, opts?: JsonOptions): Promise> { + const messages = this.buildMessages(prompt, opts?.systemPrompt); + return this.jsonMessages(messages, opts); + } + + /** JSON extraction from full message history. */ + async jsonMessages( + messages: ChatMessage[], + opts?: JsonOptions + ): Promise> { + const maxAttempts = (opts?.jsonRetries ?? 1) + 1; + let lastError: Error | undefined; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await this.chatMessages(messages, opts); + + try { + const data = extractJson(result.content, opts?.validate); + return { ...result, data }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (this.options.debug) { + console.warn( + `[shared-llm] JSON extraction attempt ${attempt + 1}/${maxAttempts} failed:`, + lastError.message + ); + } + } + } + + throw lastError ?? new Error('JSON extraction failed'); + } + + // --------------------------------------------------------------------------- + // Vision + // --------------------------------------------------------------------------- + + /** Analyze an image with a text prompt. */ + async vision( + prompt: string, + imageBase64: string, + mimeType?: string, + opts?: VisionOptions + ): Promise { + const messages = this.buildVisionMessages(prompt, imageBase64, mimeType, opts?.systemPrompt); + const model = opts?.visionModel ?? this.options.defaultVisionModel; + return this.chatMessages(messages, { ...opts, model }); + } + + /** Vision + JSON extraction. */ + async visionJson( + prompt: string, + imageBase64: string, + mimeType?: string, + opts?: VisionOptions & JsonOptions + ): Promise> { + const messages = this.buildVisionMessages(prompt, imageBase64, mimeType, opts?.systemPrompt); + const model = opts?.visionModel ?? this.options.defaultVisionModel; + return this.jsonMessages(messages, { ...opts, model }); + } + + // --------------------------------------------------------------------------- + // Embeddings + // --------------------------------------------------------------------------- + + /** Generate embeddings for text input. */ + async embed( + input: string | string[], + model?: string + ): Promise<{ embeddings: number[][]; usage: TokenUsage }> { + const response = await retryFetch( + `${this.baseUrl}/v1/embeddings`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: model ?? this.options.defaultModel, + input, + }), + signal: createTimeoutSignal(this.options.timeout), + }, + { maxRetries: this.options.maxRetries } + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`mana-llm embeddings error ${response.status}: ${text}`); + } + + const data = (await response.json()) as EmbeddingResponse; + return { + embeddings: data.data.map((d) => d.embedding), + usage: data.usage, + }; + } + + // --------------------------------------------------------------------------- + // Health & Models + // --------------------------------------------------------------------------- + + /** Check mana-llm health and provider status. */ + async health(): Promise { + try { + const response = await fetch(`${this.baseUrl}/health`, { + signal: createTimeoutSignal(5_000), + }); + if (!response.ok) { + return { status: 'unhealthy', providers: {} }; + } + return (await response.json()) as HealthStatus; + } catch { + return { status: 'unhealthy', providers: {} }; + } + } + + /** List available models from all providers. */ + async listModels(): Promise { + const response = await fetch(`${this.baseUrl}/v1/models`, { + signal: createTimeoutSignal(10_000), + }); + + if (!response.ok) { + throw new Error(`mana-llm models error ${response.status}`); + } + + const data = (await response.json()) as { data: ModelInfo[] }; + return data.data ?? []; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private buildMessages(prompt: string, systemPrompt?: string): ChatMessage[] { + const messages: ChatMessage[] = []; + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + messages.push({ role: 'user', content: prompt }); + return messages; + } + + private buildVisionMessages( + prompt: string, + imageBase64: string, + mimeType?: string, + systemPrompt?: string + ): ChatMessage[] { + const mime = mimeType ?? 'image/jpeg'; + const dataUrl = imageBase64.startsWith('data:') + ? imageBase64 + : `data:${mime};base64,${imageBase64}`; + + const messages: ChatMessage[] = []; + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + messages.push({ + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: dataUrl } }, + ], + }); + return messages; + } + + private buildRequest( + messages: ChatMessage[], + opts: ChatOptions | undefined, + stream: boolean + ): ChatCompletionRequest { + const request: ChatCompletionRequest = { + model: opts?.model ?? this.options.defaultModel, + messages, + stream, + }; + + if (opts?.temperature !== undefined) request.temperature = opts.temperature; + if (opts?.maxTokens !== undefined) request.max_tokens = opts.maxTokens; + + return request; + } + + private async fetchCompletion( + body: ChatCompletionRequest, + timeoutOverride?: number + ): Promise { + const timeout = timeoutOverride ?? this.options.timeout; + + const response = await retryFetch( + `${this.baseUrl}/v1/chat/completions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: createTimeoutSignal(timeout), + }, + { maxRetries: this.options.maxRetries } + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`mana-llm error ${response.status}: ${text}`); + } + + return (await response.json()) as ChatCompletionResponse; + } +} diff --git a/packages/shared-llm/src/llm.constants.ts b/packages/shared-llm/src/llm.constants.ts new file mode 100644 index 000000000..67f0064f5 --- /dev/null +++ b/packages/shared-llm/src/llm.constants.ts @@ -0,0 +1 @@ +export const LLM_MODULE_OPTIONS = 'LLM_MODULE_OPTIONS'; diff --git a/packages/shared-llm/src/llm.module.ts b/packages/shared-llm/src/llm.module.ts new file mode 100644 index 000000000..d70edcf37 --- /dev/null +++ b/packages/shared-llm/src/llm.module.ts @@ -0,0 +1,80 @@ +import { DynamicModule, Module, Global, Provider } from '@nestjs/common'; +import type { + LlmModuleOptions, + LlmModuleAsyncOptions, + LlmOptionsFactory, +} from './interfaces/llm-options.interface'; +import { LlmClientService } from './llm-client.service'; +import { LLM_MODULE_OPTIONS } from './llm.constants'; + +@Global() +@Module({}) +export class LlmModule { + static forRoot(options: LlmModuleOptions): DynamicModule { + return { + module: LlmModule, + providers: [ + { + provide: LLM_MODULE_OPTIONS, + useValue: options, + }, + LlmClientService, + ], + exports: [LLM_MODULE_OPTIONS, LlmClientService], + }; + } + + static forRootAsync(options: LlmModuleAsyncOptions): DynamicModule { + const asyncProviders = this.createAsyncProviders(options); + + return { + module: LlmModule, + imports: options.imports || [], + providers: [...asyncProviders, LlmClientService], + exports: [LLM_MODULE_OPTIONS, LlmClientService], + }; + } + + private static createAsyncProviders(options: LlmModuleAsyncOptions): Provider[] { + if (options.useFactory) { + return [ + { + provide: LLM_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + }, + ]; + } + + const useClass = options.useClass; + const useExisting = options.useExisting; + + if (useClass) { + return [ + { + provide: LLM_MODULE_OPTIONS, + useFactory: async (optionsFactory: LlmOptionsFactory) => + await optionsFactory.createLlmOptions(), + inject: [useClass], + }, + { + provide: useClass, + useClass, + }, + ]; + } + + if (useExisting) { + return [ + { + provide: LLM_MODULE_OPTIONS, + useFactory: async (optionsFactory: LlmOptionsFactory) => + await optionsFactory.createLlmOptions(), + inject: [useExisting], + }, + ]; + } + + return []; + } +} diff --git a/packages/shared-llm/src/standalone.ts b/packages/shared-llm/src/standalone.ts new file mode 100644 index 000000000..d04d1f6c1 --- /dev/null +++ b/packages/shared-llm/src/standalone.ts @@ -0,0 +1,30 @@ +/** + * Standalone exports for non-NestJS consumers (e.g. bot-services). + * + * Usage: + * import { LlmClient } from '@manacore/shared-llm/standalone'; + * const llm = new LlmClient({ manaLlmUrl: 'http://localhost:3025' }); + */ + +export { LlmClient } from './llm-client'; +export { resolveOptions } from './interfaces/llm-options.interface'; +export type { LlmModuleOptions, ResolvedLlmOptions } from './interfaces/llm-options.interface'; + +// Types +export type { + ChatMessage, + ContentPart, + TextContentPart, + ImageContentPart, + ChatOptions, + JsonOptions, + VisionOptions, + TokenUsage, + ChatResult, + JsonResult, + ModelInfo, + HealthStatus, +} from './types'; + +// Utilities +export { extractJson } from './utils'; diff --git a/packages/shared-llm/src/types/chat.types.ts b/packages/shared-llm/src/types/chat.types.ts new file mode 100644 index 000000000..53b2b1f7b --- /dev/null +++ b/packages/shared-llm/src/types/chat.types.ts @@ -0,0 +1,100 @@ +/** + * Core chat types for the LLM client. + * These are the high-level types that consumers interact with. + */ + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +export interface TextContentPart { + type: 'text'; + text: string; +} + +export interface ImageContentPart { + type: 'image_url'; + image_url: { url: string }; +} + +export type ContentPart = TextContentPart | ImageContentPart; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string | ContentPart[]; +} + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface ChatOptions { + /** Model to use (default from module config, e.g. "ollama/gemma3:4b") */ + model?: string; + /** Sampling temperature 0.0-2.0 */ + temperature?: number; + /** Max tokens to generate */ + maxTokens?: number; + /** System prompt prepended to messages */ + systemPrompt?: string; + /** Request timeout in ms (overrides module default) */ + timeout?: number; +} + +export interface JsonOptions extends ChatOptions { + /** Validation function applied to parsed JSON. Should throw on invalid data. */ + validate?: (data: unknown) => T; + /** Number of extraction retries on parse failure (default: 1) */ + jsonRetries?: number; +} + +export interface VisionOptions extends ChatOptions { + /** Vision model override (default from module config, e.g. "ollama/llava:7b") */ + visionModel?: string; +} + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +export interface TokenUsage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface ChatResult { + /** Generated text content */ + content: string; + /** Model that was actually used */ + model: string; + /** Token usage statistics */ + usage: TokenUsage; + /** Request latency in milliseconds */ + latencyMs: number; +} + +export interface JsonResult extends ChatResult { + /** Parsed and optionally validated data */ + data: T; +} + +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +export interface ModelInfo { + id: string; + object: 'model'; + created: number; + owned_by: string; +} + +// --------------------------------------------------------------------------- +// Health +// --------------------------------------------------------------------------- + +export interface HealthStatus { + status: 'healthy' | 'degraded' | 'unhealthy'; + providers: Record; +} diff --git a/packages/shared-llm/src/types/index.ts b/packages/shared-llm/src/types/index.ts new file mode 100644 index 000000000..368d18ff8 --- /dev/null +++ b/packages/shared-llm/src/types/index.ts @@ -0,0 +1,26 @@ +export type { + ChatMessage, + ContentPart, + TextContentPart, + ImageContentPart, + ChatOptions, + JsonOptions, + VisionOptions, + TokenUsage, + ChatResult, + JsonResult, + ModelInfo, + HealthStatus, +} from './chat.types'; + +export type { + ChatCompletionRequest, + ChatCompletionResponse, + ChatCompletionChoice, + ChatCompletionStreamChunk, + StreamChoice, + EmbeddingRequest, + EmbeddingResponse, + EmbeddingData, + ModelsListResponse, +} from './openai-compat.types'; diff --git a/packages/shared-llm/src/types/openai-compat.types.ts b/packages/shared-llm/src/types/openai-compat.types.ts new file mode 100644 index 000000000..457c671fd --- /dev/null +++ b/packages/shared-llm/src/types/openai-compat.types.ts @@ -0,0 +1,97 @@ +/** + * OpenAI-compatible wire format types matching the mana-llm API contract. + * These are internal types used for HTTP communication - consumers should + * use the high-level types from chat.types.ts instead. + */ + +import type { ChatMessage, TokenUsage } from './chat.types'; + +// --------------------------------------------------------------------------- +// Request (POST /v1/chat/completions) +// --------------------------------------------------------------------------- + +export interface ChatCompletionRequest { + model: string; + messages: ChatMessage[]; + stream?: boolean; + temperature?: number; + max_tokens?: number; + top_p?: number; + frequency_penalty?: number; + presence_penalty?: number; + stop?: string | string[]; +} + +// --------------------------------------------------------------------------- +// Response (non-streaming) +// --------------------------------------------------------------------------- + +export interface ChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: ChatCompletionChoice[]; + usage: TokenUsage; +} + +export interface ChatCompletionChoice { + index: number; + message: { role: 'assistant'; content: string }; + finish_reason: 'stop' | 'length' | 'content_filter' | null; +} + +// --------------------------------------------------------------------------- +// Response (streaming) +// --------------------------------------------------------------------------- + +export interface ChatCompletionStreamChunk { + id: string; + object: 'chat.completion.chunk'; + created: number; + model: string; + choices: StreamChoice[]; +} + +export interface StreamChoice { + index: number; + delta: { role?: 'assistant'; content?: string }; + finish_reason: string | null; +} + +// --------------------------------------------------------------------------- +// Embeddings +// --------------------------------------------------------------------------- + +export interface EmbeddingRequest { + model: string; + input: string | string[]; + encoding_format?: 'float' | 'base64'; +} + +export interface EmbeddingResponse { + object: 'list'; + data: EmbeddingData[]; + model: string; + usage: TokenUsage; +} + +export interface EmbeddingData { + object: 'embedding'; + index: number; + embedding: number[]; +} + +// --------------------------------------------------------------------------- +// Models (GET /v1/models) +// --------------------------------------------------------------------------- + +export interface ModelsListResponse { + object: 'list'; + data: Array<{ + id: string; + object: 'model'; + created: number; + owned_by: string; + }>; +} diff --git a/packages/shared-llm/src/utils/index.ts b/packages/shared-llm/src/utils/index.ts new file mode 100644 index 000000000..1f345070f --- /dev/null +++ b/packages/shared-llm/src/utils/index.ts @@ -0,0 +1,3 @@ +export { extractJson } from './json-extractor'; +export { retryFetch } from './retry'; +export type { RetryOptions } from './retry'; diff --git a/packages/shared-llm/src/utils/json-extractor.ts b/packages/shared-llm/src/utils/json-extractor.ts new file mode 100644 index 000000000..8e0d27c90 --- /dev/null +++ b/packages/shared-llm/src/utils/json-extractor.ts @@ -0,0 +1,94 @@ +/** + * Extract and parse JSON from LLM responses. + * + * LLMs often wrap JSON in markdown code fences or include extra text. + * This utility handles all common patterns: + * 1. Direct JSON parse + * 2. Markdown ```json ... ``` fences + * 3. First { ... } or [ ... ] block in text + */ +export function extractJson(text: string, validate?: (data: unknown) => T): T { + const trimmed = text.trim(); + + // Step 1: Try direct parse + const direct = tryParse(trimmed, validate); + if (direct !== undefined) return direct; + + // Step 2: Strip markdown code fences + const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) { + const fenced = tryParse(fenceMatch[1].trim(), validate); + if (fenced !== undefined) return fenced; + } + + // Step 3: Find first JSON object + const objectStart = trimmed.indexOf('{'); + if (objectStart !== -1) { + const objectStr = extractBalanced(trimmed, objectStart, '{', '}'); + if (objectStr) { + const obj = tryParse(objectStr, validate); + if (obj !== undefined) return obj; + } + } + + // Step 4: Find first JSON array + const arrayStart = trimmed.indexOf('['); + if (arrayStart !== -1) { + const arrayStr = extractBalanced(trimmed, arrayStart, '[', ']'); + if (arrayStr) { + const arr = tryParse(arrayStr, validate); + if (arr !== undefined) return arr; + } + } + + throw new Error(`Failed to extract JSON from LLM response: ${trimmed.slice(0, 200)}...`); +} + +function tryParse(text: string, validate?: (data: unknown) => T): T | undefined { + try { + const parsed = JSON.parse(text); + return validate ? validate(parsed) : parsed; + } catch { + return undefined; + } +} + +/** + * Extract a balanced block starting from the given position. + * Handles nested braces/brackets but not strings with escaped delimiters. + */ +function extractBalanced(text: string, start: number, open: string, close: string): string | null { + let depth = 0; + let inString = false; + let escape = false; + + for (let i = start; i < text.length; i++) { + const ch = text[i]; + + if (escape) { + escape = false; + continue; + } + + if (ch === '\\') { + escape = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (ch === open) depth++; + if (ch === close) depth--; + + if (depth === 0) { + return text.slice(start, i + 1); + } + } + + return null; +} diff --git a/packages/shared-llm/src/utils/retry.ts b/packages/shared-llm/src/utils/retry.ts new file mode 100644 index 000000000..c05f92c85 --- /dev/null +++ b/packages/shared-llm/src/utils/retry.ts @@ -0,0 +1,51 @@ +/** + * Fetch wrapper with exponential backoff retry for transient failures. + * + * Retries on: 429 (rate limit), 502, 503, 504 (server errors), network errors. + * Does NOT retry on: 400, 401, 403, 404 (client errors). + */ + +const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); + +export interface RetryOptions { + maxRetries: number; + /** Base delay in ms (doubles each retry). Default: 200 */ + baseDelay?: number; +} + +export async function retryFetch( + url: string, + init: RequestInit, + options: RetryOptions +): Promise { + const { maxRetries, baseDelay = 200 } = options; + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, init); + + if (response.ok || !RETRYABLE_STATUS_CODES.has(response.status)) { + return response; + } + + // Retryable status code + lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); + } catch (error) { + // Network error (connection refused, timeout, etc.) + lastError = error instanceof Error ? error : new Error(String(error)); + } + + // Don't sleep after the last attempt + if (attempt < maxRetries) { + const delay = baseDelay * Math.pow(2, attempt); + await sleep(delay); + } + } + + throw lastError ?? new Error('retryFetch exhausted all retries'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/shared-llm/tsconfig.json b/packages/shared-llm/tsconfig.json new file mode 100644 index 000000000..310fa8950 --- /dev/null +++ b/packages/shared-llm/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "lib": ["ES2021"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8444d3d1e..15177e6c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,7 +165,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9(esbuild@0.27.4) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -204,7 +204,7 @@ importers: version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) prettier: specifier: ^3.4.2 version: 3.6.2 @@ -213,10 +213,10 @@ importers: version: 0.5.21 ts-jest: specifier: ^29.4.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.4)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -240,14 +240,14 @@ importers: version: link:../../../../packages/shared-landing-ui astro: specifier: ^5.16.0 - version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.9.2 version: 5.9.3 devDependencies: '@astrojs/tailwind': specifier: ^6.0.2 - version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) + version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) '@tailwindcss/typography': specifier: ^0.5.18 version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1)) @@ -256,13 +256,13 @@ importers: version: 20.19.25 eslint: specifier: ^9.0.0 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.2(eslint@9.39.1(jiti@1.21.7)) + version: 9.1.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-astro: specifier: ^1.0.0 - version: 1.5.0(eslint@9.39.1(jiti@1.21.7)) + version: 1.5.0(eslint@9.39.1(jiti@2.6.1)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -691,19 +691,19 @@ importers: version: 18.3.27 '@typescript-eslint/eslint-plugin': specifier: ^7.7.0 - version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^7.7.0 - version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) dotenv: specifier: ^16.4.7 version: 16.6.1 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-universe: specifier: ^12.0.1 - version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3) + version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3) prettier: specifier: ^3.2.5 version: 3.6.2 @@ -2064,7 +2064,7 @@ importers: dependencies: '@expo/vector-icons': specifier: ^15.0.3 - version: 15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + version: 15.0.3(expo-font@55.0.4(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) '@manacore/shared-auth': specifier: workspace:* version: link:../../../../packages/shared-auth @@ -2334,6 +2334,9 @@ importers: '@manacore/shared-errors': specifier: workspace:* version: link:../../../../packages/shared-errors + '@manacore/shared-llm': + specifier: workspace:^ + version: link:../../../../packages/shared-llm '@nestjs/axios': specifier: ^4.0.1 version: 4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2) @@ -2427,7 +2430,7 @@ importers: version: 7.1.4 ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.2 version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.4)) @@ -5196,7 +5199,7 @@ importers: version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -5658,7 +5661,7 @@ importers: version: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) tsx: specifier: ^4.19.4 version: 4.20.6 @@ -6319,6 +6322,9 @@ importers: packages/bot-services: dependencies: + '@manacore/shared-llm': + specifier: workspace:^ + version: link:../shared-llm '@nestjs/common': specifier: ^11.0.20 version: 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -7252,7 +7258,7 @@ importers: version: 1.57.0 jest: specifier: ^29.0.0 - version: 29.7.0(@types/node@24.10.1) + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) vitest: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) @@ -8294,6 +8300,9 @@ importers: '@manacore/matrix-bot-common': specifier: workspace:* version: link:../../packages/matrix-bot-common + '@manacore/shared-llm': + specifier: workspace:^ + version: link:../../packages/shared-llm '@nestjs/common': specifier: ^10.4.15 version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -27818,16 +27827,6 @@ snapshots: transitivePeerDependencies: - ts-node - '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': - dependencies: - astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) - autoprefixer: 10.4.22(postcss@8.5.6) - postcss: 8.5.6 - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)) - tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.1) - transitivePeerDependencies: - - ts-node - '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))': dependencies: astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) @@ -30806,7 +30805,7 @@ snapshots: wrap-ansi: 7.0.0 ws: 8.18.3 optionalDependencies: - expo-router: 55.0.5(xobbmdsfsaswzvlwz73gklusxm) + expo-router: 55.0.5(qwxmdxiornnsbyvrtivw4g2joq) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -31183,7 +31182,7 @@ snapshots: ws: 8.18.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.5(apnkrhypuo4jtg23v6qzhb7sxe) + expo-router: 55.0.5(tkph4mqwn7yyg5tlp6kukooce4) react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) transitivePeerDependencies: - '@expo/dom-webview' @@ -31197,6 +31196,7 @@ snapshots: - supports-color - typescript - utf-8-validate + optional: true '@expo/code-signing-certificates@0.0.5': dependencies: @@ -31442,6 +31442,7 @@ snapshots: optionalDependencies: react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + optional: true '@expo/dom-webview@55.0.3(expo@52.0.47)(react-native@0.76.7(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: @@ -31510,6 +31511,7 @@ snapshots: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + optional: true '@expo/env@0.4.2': dependencies: @@ -31691,6 +31693,7 @@ snapshots: react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) stacktrace-parser: 0.1.11 + optional: true '@expo/mcp-tunnel@0.0.8': dependencies: @@ -31877,7 +31880,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - bufferutil - supports-color @@ -31905,7 +31908,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - bufferutil - supports-color @@ -32196,7 +32199,7 @@ snapshots: '@expo/json-file': 10.0.7 '@react-native/normalize-colors': 0.81.5 debug: 4.4.3 - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) resolve-from: 5.0.0 semver: 7.7.3 xml2js: 0.6.0 @@ -32212,7 +32215,7 @@ snapshots: '@expo/json-file': 10.0.12 '@react-native/normalize-colors': 0.83.2 debug: 4.4.3 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) resolve-from: 5.0.0 semver: 7.7.3 xml2js: 0.6.0 @@ -32282,10 +32285,11 @@ snapshots: react: 19.2.4 optionalDependencies: '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - expo-router: 55.0.5(apnkrhypuo4jtg23v6qzhb7sxe) + expo-router: 55.0.5(tkph4mqwn7yyg5tlp6kukooce4) react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: - supports-color + optional: true '@expo/rudder-sdk-node@1.1.1(encoding@0.1.13)': dependencies: @@ -32361,12 +32365,6 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - '@expo/vector-icons@15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': - dependencies: - expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) - '@expo/vector-icons@15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': dependencies: expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) @@ -32379,6 +32377,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + '@expo/vector-icons@15.0.3(expo-font@14.0.10)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)': + dependencies: + expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) + '@expo/vector-icons@15.0.3(expo-font@14.0.9(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: expo-font: 14.0.9(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -32391,6 +32395,12 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) + '@expo/vector-icons@15.0.3(expo-font@55.0.4(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': + dependencies: + expo-font: 55.0.4(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + '@expo/vector-icons@15.0.3(expo-font@55.0.4)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': dependencies: expo-font: 55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) @@ -32402,6 +32412,7 @@ snapshots: expo-font: 55.0.4(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + optional: true '@expo/ws-tunnel@1.0.6': {} @@ -32993,6 +33004,41 @@ snapshots: - supports-color - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 22.19.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': dependencies: '@jest/console': 30.2.0 @@ -33029,7 +33075,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))': + '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -33044,7 +33090,43 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)) + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-resolve-dependencies: 30.3.0 + jest-runner: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + jest-watcher: 30.3.0 + pretty-format: 30.3.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + + '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))': + dependencies: + '@jest/console': 30.3.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 22.19.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.3.0 + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -33667,32 +33749,6 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/cli@10.4.9(esbuild@0.19.12)': - dependencies: - '@angular-devkit/core': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) - '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) - '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) - chalk: 4.1.2 - chokidar: 3.6.0 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)) - glob: 10.4.5 - inquirer: 8.2.6 - node-emoji: 1.11.0 - ora: 5.4.1 - tree-kill: 1.2.2 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.7.2 - webpack: 5.97.1(esbuild@0.19.12) - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - esbuild - - uglify-js - - webpack-cli - '@nestjs/cli@10.4.9(esbuild@0.27.4)': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -35979,7 +36035,8 @@ snapshots: '@react-native/assets-registry@0.83.2': {} - '@react-native/assets-registry@0.84.1': {} + '@react-native/assets-registry@0.84.1': + optional: true '@react-native/babel-plugin-codegen@0.76.7(@babel/preset-env@7.28.5(@babel/core@7.28.5))': dependencies: @@ -36280,6 +36337,7 @@ snapshots: nullthrows: 1.1.1 tinyglobby: 0.2.15 yargs: 17.7.2 + optional: true '@react-native/community-cli-plugin@0.76.7(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(encoding@0.1.13)': dependencies: @@ -36378,6 +36436,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true '@react-native/debugger-frontend@0.76.7': {} @@ -36389,7 +36448,8 @@ snapshots: '@react-native/debugger-frontend@0.83.2': {} - '@react-native/debugger-frontend@0.84.1': {} + '@react-native/debugger-frontend@0.84.1': + optional: true '@react-native/debugger-shell@0.83.2': dependencies: @@ -36403,6 +36463,7 @@ snapshots: fb-dotslash: 0.5.8 transitivePeerDependencies: - supports-color + optional: true '@react-native/dev-middleware@0.76.7': dependencies: @@ -36515,6 +36576,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true '@react-native/gradle-plugin@0.76.7': {} @@ -36526,7 +36588,8 @@ snapshots: '@react-native/gradle-plugin@0.83.2': {} - '@react-native/gradle-plugin@0.84.1': {} + '@react-native/gradle-plugin@0.84.1': + optional: true '@react-native/js-polyfills@0.76.7': {} @@ -36538,7 +36601,8 @@ snapshots: '@react-native/js-polyfills@0.83.2': {} - '@react-native/js-polyfills@0.84.1': {} + '@react-native/js-polyfills@0.84.1': + optional: true '@react-native/metro-babel-transformer@0.76.7(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))': dependencies: @@ -36574,7 +36638,8 @@ snapshots: '@react-native/normalize-colors@0.83.2': {} - '@react-native/normalize-colors@0.84.1': {} + '@react-native/normalize-colors@0.84.1': + optional: true '@react-native/virtualized-lists@0.76.7(@types/react@18.3.27)(react-native@0.76.7(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: @@ -36683,6 +36748,7 @@ snapshots: react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) optionalDependencies: '@types/react': 19.2.14 + optional: true '@react-navigation/bottom-tabs@7.15.5(@react-navigation/native@7.1.33(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: @@ -39159,7 +39225,7 @@ snapshots: jest: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)) optional: true - '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)': + '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -39169,7 +39235,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.2.4) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': @@ -39182,7 +39248,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': @@ -39195,7 +39261,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1)': @@ -39208,7 +39274,7 @@ snapshots: react-test-renderer: 19.1.0(react@18.3.1) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': @@ -39221,7 +39287,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.1.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) optional: true '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)': @@ -39234,7 +39300,7 @@ snapshots: react-test-renderer: 19.1.0(react@19.2.0) redent: 3.0.0 optionalDependencies: - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) optional: true '@testing-library/svelte-core@1.0.0(svelte@5.44.0)': @@ -39877,16 +39943,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -39935,15 +40001,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 @@ -40035,14 +40101,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -40074,14 +40140,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 7.18.0 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: @@ -40207,12 +40273,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -40243,12 +40309,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 1.4.3(typescript@5.3.3) optionalDependencies: typescript: 5.3.3 @@ -40430,15 +40496,15 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -40469,13 +40535,13 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)': + '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) '@typescript-eslint/scope-manager': 7.18.0 '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) transitivePeerDependencies: - supports-color - typescript @@ -40704,11 +40770,11 @@ snapshots: - vite optional: true - '@vitest/browser@3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)': + '@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/mocker': 3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/utils': 3.2.4 magic-string: 0.30.21 sirv: 3.0.2 @@ -40821,15 +40887,6 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - optional: true - '@vitest/mocker@4.0.14(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.14 @@ -40986,7 +41043,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) optional: true '@vitest/ui@4.0.14(vitest@4.0.14)': @@ -41545,108 +41602,6 @@ snapshots: transitivePeerDependencies: - supports-color - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): - dependencies: - '@astrojs/compiler': 2.13.0 - '@astrojs/internal-helpers': 0.7.5 - '@astrojs/markdown-remark': 6.3.9 - '@astrojs/telemetry': 3.3.0 - '@capsizecss/unpack': 3.0.1 - '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.53.3) - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - boxen: 8.0.1 - ci-info: 4.3.1 - clsx: 2.1.1 - common-ancestor-path: 1.0.1 - cookie: 1.1.0 - cssesc: 3.0.0 - debug: 4.4.3 - deterministic-object-hash: 2.0.2 - devalue: 5.5.0 - diff: 5.2.0 - dlv: 1.1.3 - dset: 3.1.4 - es-module-lexer: 1.7.0 - esbuild: 0.25.12 - estree-walker: 3.0.3 - flattie: 1.1.1 - fontace: 0.3.1 - github-slugger: 2.0.0 - html-escaper: 3.0.3 - http-cache-semantics: 4.2.0 - import-meta-resolve: 4.2.0 - js-yaml: 4.1.1 - magic-string: 0.30.21 - magicast: 0.5.1 - mrmime: 2.0.1 - neotraverse: 0.6.18 - p-limit: 6.2.0 - p-queue: 8.1.1 - package-manager-detector: 1.5.0 - piccolore: 0.1.3 - picomatch: 4.0.3 - prompts: 2.4.2 - rehype: 13.0.2 - semver: 7.7.3 - shiki: 3.15.0 - smol-toml: 1.5.2 - svgo: 4.0.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tsconfck: 3.1.6(typescript@5.9.3) - ultrahtml: 1.6.0 - unifont: 0.6.0 - unist-util-visit: 5.0.0 - unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.9.2) - vfile: 6.0.3 - vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)) - xxhash-wasm: 1.1.0 - yargs-parser: 21.1.1 - yocto-spinner: 0.2.3 - zod: 3.25.76 - zod-to-json-schema: 3.25.0(zod@3.25.76) - zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) - optionalDependencies: - sharp: 0.34.5 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@netlify/blobs' - - '@planetscale/database' - - '@types/node' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - db0 - - idb-keyval - - ioredis - - jiti - - less - - lightningcss - - rollup - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - typescript - - uploadthing - - yaml - astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: '@astrojs/compiler': 2.13.0 @@ -42273,7 +42228,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.4 - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -42306,7 +42261,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.4 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -43162,13 +43117,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@24.10.1): + create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -44334,11 +44289,6 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) semver: 7.7.3 - eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - semver: 7.7.3 - eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -44351,7 +44301,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -44368,7 +44318,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -44385,7 +44335,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-expo: 0.1.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) globals: 16.5.0 @@ -44403,14 +44353,14 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) - eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)): - dependencies: - eslint: 9.39.1(jiti@1.21.7) - eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -44435,17 +44385,17 @@ snapshots: - supports-color - typescript - eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3): + eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3): dependencies: - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) - eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) - eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2) + eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7)) optionalDependencies: prettier: 3.6.2 transitivePeerDependencies: @@ -44481,7 +44431,7 @@ snapshots: '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-config-prettier: 9.1.2(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-n: 17.24.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) @@ -44516,7 +44466,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -44530,12 +44480,12 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) - eslint: 9.39.1(jiti@2.6.1) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) + eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color @@ -44572,20 +44522,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@jridgewell/sourcemap-codec': 1.5.5 - '@typescript-eslint/types': 8.48.0 - astro-eslint-parser: 1.2.2 - eslint: 9.39.1(jiti@1.21.7) - eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7)) - globals: 16.5.0 - postcss: 8.5.6 - postcss-selector-parser: 7.1.0 - transitivePeerDependencies: - - supports-color - eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -44613,6 +44549,12 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 + eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-utils: 2.1.0 + regexpp: 3.2.0 + eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -44666,7 +44608,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -44675,9 +44617,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -44689,7 +44631,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -44724,7 +44666,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -44753,7 +44695,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -44807,6 +44749,16 @@ snapshots: resolve: 1.22.11 semver: 6.3.1 + eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7)) + eslint-utils: 2.1.0 + ignore: 5.3.2 + minimatch: 3.1.2 + resolve: 1.22.11 + semver: 6.3.1 + eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -44837,6 +44789,16 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 8.10.2(eslint@8.57.1) + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + prettier: 3.6.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -44861,6 +44823,10 @@ snapshots: dependencies: eslint: 8.57.1 + eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)): + dependencies: + eslint: 9.39.1(jiti@1.21.7) + eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)): dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -44891,6 +44857,28 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.39.1(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): dependencies: array-includes: 3.1.9 @@ -45378,6 +45366,7 @@ snapshots: transitivePeerDependencies: - supports-color - typescript + optional: true expo-audio@55.0.8(expo-asset@55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: @@ -45547,6 +45536,7 @@ snapshots: transitivePeerDependencies: - supports-color - typescript + optional: true expo-dev-client@5.0.20(expo@52.0.47): dependencies: @@ -45572,7 +45562,7 @@ snapshots: expo-dev-client@6.0.18(expo@54.0.25): dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-dev-launcher: 6.0.18(expo@54.0.25) expo-dev-menu: 7.0.17(expo@54.0.25) expo-dev-menu-interface: 2.0.0(expo@54.0.25) @@ -45621,7 +45611,7 @@ snapshots: expo-dev-launcher@6.0.18(expo@54.0.25): dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-dev-menu: 7.0.17(expo@54.0.25) expo-manifests: 1.0.9(expo@54.0.25) transitivePeerDependencies: @@ -45645,7 +45635,7 @@ snapshots: expo-dev-menu-interface@2.0.0(expo@54.0.25): dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-dev-menu@6.0.25(expo@52.0.47): dependencies: @@ -45669,7 +45659,7 @@ snapshots: expo-dev-menu@7.0.17(expo@54.0.25): dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-dev-menu-interface: 2.0.0(expo@54.0.25) expo-device@8.0.9(expo@54.0.13): @@ -45734,6 +45724,7 @@ snapshots: dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + optional: true expo-font@13.0.4(expo@52.0.47)(react@18.3.1): dependencies: @@ -45811,14 +45802,6 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0) - expo-font@55.0.4(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): - dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) - fontfaceobserver: 2.3.0 - react: 19.1.0 - react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) - optional: true - expo-font@55.0.4(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): dependencies: expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) @@ -45827,6 +45810,13 @@ snapshots: react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) optional: true + expo-font@55.0.4(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + fontfaceobserver: 2.3.0 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + expo-font@55.0.4(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -45840,6 +45830,7 @@ snapshots: fontfaceobserver: 2.3.0 react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + optional: true expo-glass-effect@55.0.8(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: @@ -45967,7 +45958,7 @@ snapshots: expo-keep-awake@15.0.7(expo@54.0.25)(react@19.1.0): dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) react: 19.1.0 expo-keep-awake@55.0.4(expo@55.0.5)(react@19.2.0): @@ -45979,6 +45970,7 @@ snapshots: dependencies: expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 + optional: true expo-linear-gradient@15.0.7(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: @@ -46143,7 +46135,7 @@ snapshots: expo-manifests@1.0.9(expo@54.0.25): dependencies: '@expo/config': 12.0.10 - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-json-utils: 0.15.0 transitivePeerDependencies: - supports-color @@ -46261,6 +46253,7 @@ snapshots: invariant: 2.2.4 react: 19.2.4 react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + optional: true expo-notifications@55.0.12(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: @@ -46336,57 +46329,6 @@ snapshots: - react-native - supports-color - expo-router@55.0.5(apnkrhypuo4jtg23v6qzhb7sxe): - dependencies: - '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - '@expo/schema-utils': 55.0.2 - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - '@react-navigation/native': 7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - client-only: 0.0.1 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) - expo-constants: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3) - expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - expo-linking: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) - expo-server: 55.0.6 - expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - fast-deep-equal: 3.1.3 - invariant: 2.2.4 - nanoid: 3.3.11 - query-string: 7.1.3 - react: 19.2.4 - react-fast-compare: 3.2.2 - react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) - react-native-is-edge-to-edge: 1.2.1(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - react-native-safe-area-context: 5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - react-native-screens: 4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - semver: 7.6.3 - server-only: 0.0.1 - sf-symbols-typescript: 2.2.0 - shallowequal: 1.1.0 - use-latest-callback: 0.2.6(react@19.2.4) - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - optionalDependencies: - '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-gesture-handler@2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4) - react-dom: 19.2.4(react@19.2.4) - react-native-gesture-handler: 2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - transitivePeerDependencies: - - '@react-native-masked-view/masked-view' - - '@types/react' - - '@types/react-dom' - - expo-font - - supports-color - optional: true - expo-router@55.0.5(doxl36orx2qeblgfhuv3q6cufq): dependencies: '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) @@ -46438,7 +46380,7 @@ snapshots: - supports-color optional: true - expo-router@55.0.5(xobbmdsfsaswzvlwz73gklusxm): + expo-router@55.0.5(qwxmdxiornnsbyvrtivw4g2joq): dependencies: '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.2.4(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) @@ -46457,7 +46399,7 @@ snapshots: expo-image: 55.0.6(expo@54.0.25)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) expo-linking: 55.0.7(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) expo-server: 55.0.6 - expo-symbols: 55.0.5(expo-font@55.0.4)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-symbols: 55.0.5(expo-font@14.0.10)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.11 @@ -46489,6 +46431,57 @@ snapshots: - supports-color optional: true + expo-router@55.0.5(tkph4mqwn7yyg5tlp6kukooce4): + dependencies: + '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@expo/schema-utils': 55.0.2 + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@react-navigation/native': 7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + expo-constants: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3) + expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + expo-linking: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + expo-server: 55.0.6 + expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 19.2.4 + react-fast-compare: 3.2.2 + react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4) + react-native-is-edge-to-edge: 1.2.1(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-safe-area-context: 5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-screens: 4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@19.2.4) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + optionalDependencies: + '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-gesture-handler@2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + react-native-gesture-handler: 2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - expo-font + - supports-color + optional: true + expo-router@55.0.5(xtsqo6xlpeezoeb4r7ibrbxkam): dependencies: '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) @@ -46815,11 +46808,11 @@ snapshots: react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) sf-symbols-typescript: 2.1.0 - expo-symbols@55.0.5(expo-font@55.0.4)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): + expo-symbols@55.0.5(expo-font@14.0.10)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: '@expo-google-fonts/material-symbols': 0.4.25 expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) - expo-font: 55.0.4(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0) sf-symbols-typescript: 2.2.0 @@ -46947,7 +46940,7 @@ snapshots: expo-updates-interface@2.0.0(expo@54.0.25): dependencies: - expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) + expo: 54.0.25(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-updates@29.0.13(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: @@ -47161,7 +47154,7 @@ snapshots: '@expo/fingerprint': 0.15.3 '@expo/metro': 54.1.0 '@expo/metro-config': 54.0.9(expo@54.0.25) - '@expo/vector-icons': 15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + '@expo/vector-icons': 15.0.3(expo-font@14.0.10)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) '@ungap/structured-clone': 1.3.0 babel-preset-expo: 54.0.7(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.25)(react-refresh@0.14.2) expo-asset: 12.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) @@ -47386,6 +47379,7 @@ snapshots: - supports-color - typescript - utf-8-validate + optional: true exponential-backoff@3.1.3: {} @@ -47787,23 +47781,6 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - '@babel/code-frame': 7.27.1 - chalk: 4.1.2 - chokidar: 3.6.0 - cosmiconfig: 8.3.6(typescript@5.7.2) - deepmerge: 4.3.1 - fs-extra: 10.1.0 - memfs: 3.5.3 - minimatch: 3.1.2 - node-abort-controller: 3.1.1 - schema-utils: 3.3.0 - semver: 7.7.3 - tapable: 2.3.0 - typescript: 5.7.2 - webpack: 5.97.1(esbuild@0.19.12) - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.27.4)): dependencies: '@babel/code-frame': 7.27.1 @@ -48565,7 +48542,8 @@ snapshots: hermes-compiler@0.14.1: {} - hermes-compiler@250829098.0.9: {} + hermes-compiler@250829098.0.9: + optional: true hermes-estree@0.23.1: {} @@ -49346,16 +49324,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@24.10.1): + jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@24.10.1) + create-jest: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@24.10.1) + jest-config: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -49386,7 +49364,7 @@ snapshots: jest-cli@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 @@ -49404,15 +49382,15 @@ snapshots: - ts-node optional: true - jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.19.12)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -49422,16 +49400,17 @@ snapshots: - esbuild-register - supports-color - ts-node + optional: true jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -49461,6 +49440,26 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 @@ -49492,7 +49491,38 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@24.10.1): + jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -49518,6 +49548,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 24.10.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -49589,7 +49620,7 @@ snapshots: - supports-color optional: true - jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)): dependencies: '@babel/core': 7.28.5 '@jest/get-type': 30.1.0 @@ -49617,7 +49648,39 @@ snapshots: optionalDependencies: '@types/node': 22.19.1 esbuild-register: 3.6.0(esbuild@0.19.12) - ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + + jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.1 + esbuild-register: 3.6.0(esbuild@0.27.4) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -49655,6 +49718,38 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + esbuild-register: 3.6.0(esbuild@0.27.4) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -50312,12 +50407,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@24.10.1): + jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@24.10.1) + jest-cli: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -50339,7 +50434,7 @@ snapshots: jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) '@jest/types': 30.3.0 import-local: 3.2.0 jest-cli: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)) @@ -50351,22 +50446,23 @@ snapshots: - ts-node optional: true - jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)): + jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.19.12)) '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros - esbuild-register - supports-color - ts-node + optional: true jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)): dependencies: - '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) '@jest/types': 30.3.0 import-local: 3.2.0 jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)) @@ -50390,6 +50486,20 @@ snapshots: - supports-color - ts-node + jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + optional: true + jimp-compact@0.16.1: {} jiti@1.21.7: {} @@ -55399,6 +55509,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true react-refresh@0.14.2: {} @@ -57107,17 +57218,6 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - serialize-javascript: 6.0.2 - terser: 5.44.1 - webpack: 5.97.1(esbuild@0.19.12) - optionalDependencies: - esbuild: 0.19.12 - terser-webpack-plugin@5.3.14(esbuild@0.27.4)(webpack@5.100.2(esbuild@0.27.4)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -57345,28 +57445,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): - dependencies: - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.3 - type-fest: 4.41.0 - typescript: 5.9.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.5 - '@jest/transform': 30.3.0 - '@jest/types': 30.3.0 - babel-jest: 30.3.0(@babel/core@7.28.5) - esbuild: 0.19.12 - jest-util: 30.3.0 - - ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)))(typescript@5.9.3): + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -57469,16 +57548,6 @@ snapshots: typescript: 5.9.3 webpack: 5.100.2 - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)): - dependencies: - chalk: 4.1.2 - enhanced-resolve: 5.18.3 - micromatch: 4.0.8 - semver: 7.7.3 - source-map: 0.7.6 - typescript: 5.9.3 - webpack: 5.97.1(esbuild@0.19.12) - ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -58418,23 +58487,6 @@ snapshots: lightningcss: 1.30.2 terser: 5.44.1 - vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.25 - fsevents: 2.3.3 - jiti: 1.21.7 - lightningcss: 1.30.2 - terser: 5.44.1 - tsx: 4.21.0 - yaml: 2.8.1 - vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1): dependencies: esbuild: 0.25.12 @@ -58538,10 +58590,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.1 - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): - optionalDependencies: - vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) - vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)): optionalDependencies: vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1) @@ -58783,7 +58831,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.1 - '@vitest/browser': 3.2.4(playwright@1.57.0)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4) + '@vitest/browser': 3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4) '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 27.2.0 transitivePeerDependencies: @@ -59238,36 +59286,6 @@ snapshots: - esbuild - uglify-js - webpack@5.97.1(esbuild@0.19.12): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - browserslist: 4.28.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 - es-module-lexer: 1.7.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.1 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(esbuild@0.19.12)(webpack@5.97.1(esbuild@0.19.12)) - watchpack: 2.4.4 - webpack-sources: 3.3.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.97.1(esbuild@0.27.4): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/services/mana-core-auth/package.json b/services/mana-core-auth/package.json index 4fda24769..ac96f3be7 100644 --- a/services/mana-core-auth/package.json +++ b/services/mana-core-auth/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@google/generative-ai": "^0.24.1", + "@manacore/shared-llm": "workspace:^", "@manacore/shared-storage": "workspace:*", "@nestjs/axios": "^4.0.1", "@nestjs/common": "^10.4.15", diff --git a/services/mana-core-auth/src/ai/ai.module.ts b/services/mana-core-auth/src/ai/ai.module.ts index 83e65fd18..3b6d89254 100644 --- a/services/mana-core-auth/src/ai/ai.module.ts +++ b/services/mana-core-auth/src/ai/ai.module.ts @@ -1,10 +1,8 @@ import { Module, Global } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { AiService } from './ai.service'; @Global() @Module({ - imports: [ConfigModule], providers: [AiService], exports: [AiService], }) diff --git a/services/mana-core-auth/src/ai/ai.service.ts b/services/mana-core-auth/src/ai/ai.service.ts index 7fed38c65..5df822f5e 100644 --- a/services/mana-core-auth/src/ai/ai.service.ts +++ b/services/mana-core-auth/src/ai/ai.service.ts @@ -1,32 +1,20 @@ import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { LlmClientService } from '@manacore/shared-llm'; export interface FeedbackAnalysis { title: string; category: 'bug' | 'feature' | 'improvement' | 'question' | 'other'; } +const VALID_CATEGORIES = ['bug', 'feature', 'improvement', 'question', 'other'] as const; + @Injectable() export class AiService { private readonly logger = new Logger(AiService.name); - private readonly manaLlmUrl: string | null = null; - constructor(private configService: ConfigService) { - const url = this.configService.get('MANA_LLM_URL'); - if (url) { - this.manaLlmUrl = url; - this.logger.log(`AI service using mana-llm at ${url}`); - } else { - this.logger.warn('MANA_LLM_URL not configured - AI features disabled'); - } - } + constructor(private readonly llm: LlmClientService) {} async analyzeFeedback(feedbackText: string): Promise { - // Fallback if AI not available - if (!this.manaLlmUrl) { - return this.fallbackAnalysis(feedbackText); - } - try { const prompt = `Analysiere dieses User-Feedback und generiere: 1. Einen kurzen, prägnanten deutschen Titel (max 60 Zeichen) der den Kern des Feedbacks zusammenfasst @@ -37,48 +25,24 @@ Feedback: "${feedbackText}" Antworte NUR mit validem JSON in diesem Format (keine Markdown-Codeblocks, kein anderer Text): {"title": "...", "category": "..."}`; - const result = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'ollama/gemma3:4b', - messages: [{ role: 'user', content: prompt }], - temperature: 0.3, - }), - signal: AbortSignal.timeout(30000), + const { data } = await this.llm.json(prompt, { + temperature: 0.3, + timeout: 30_000, + validate: (raw) => { + const obj = raw as FeedbackAnalysis; + if (!obj.title || !obj.category) throw new Error('missing fields'); + if (!VALID_CATEGORIES.includes(obj.category as any)) { + obj.category = 'other'; + } + if (obj.title.length > 60) { + obj.title = obj.title.substring(0, 57) + '...'; + } + return obj; + }, }); - if (!result.ok) { - throw new Error(`mana-llm error: ${result.status}`); - } - - const data = await result.json(); - const response = (data.choices?.[0]?.message?.content || '').trim(); - - // Parse JSON response - handle potential markdown code blocks - let jsonStr = response; - if (response.includes('```')) { - const match = response.match(/```(?:json)?\s*([\s\S]*?)```/); - if (match) { - jsonStr = match[1].trim(); - } - } - - const parsed = JSON.parse(jsonStr) as FeedbackAnalysis; - - // Validate category - const validCategories = ['bug', 'feature', 'improvement', 'question', 'other']; - if (!validCategories.includes(parsed.category)) { - parsed.category = 'other'; - } - - // Ensure title is not too long - if (parsed.title.length > 60) { - parsed.title = parsed.title.substring(0, 57) + '...'; - } - - this.logger.debug(`AI analyzed feedback: ${JSON.stringify(parsed)}`); - return parsed; + this.logger.debug(`AI analyzed feedback: ${JSON.stringify(data)}`); + return data; } catch (error) { this.logger.error(`AI analysis failed: ${error}`); return this.fallbackAnalysis(feedbackText); diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 015f17900..fd4de20a4 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { ThrottlerModule } from '@nestjs/throttler'; import { APP_FILTER } from '@nestjs/core'; +import { LlmModule } from '@manacore/shared-llm'; import configuration from './config/configuration'; import { AdminModule } from './admin/admin.module'; import { AiModule } from './ai/ai.module'; @@ -35,6 +36,14 @@ import { SecurityModule } from './security'; limit: 100, // 100 requests per minute }, ]), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('MANA_LLM_URL'), + debug: config.get('NODE_ENV') === 'development', + }), + inject: [ConfigService], + }), LoggerModule, SecurityModule, MetricsModule, diff --git a/services/matrix-ollama-bot/package.json b/services/matrix-ollama-bot/package.json index 30b4c3475..3af8c5af9 100644 --- a/services/matrix-ollama-bot/package.json +++ b/services/matrix-ollama-bot/package.json @@ -29,6 +29,7 @@ "dependencies": { "@manacore/bot-services": "workspace:*", "@manacore/matrix-bot-common": "workspace:*", + "@manacore/shared-llm": "workspace:^", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", diff --git a/services/matrix-ollama-bot/src/app.module.ts b/services/matrix-ollama-bot/src/app.module.ts index 6d09cf311..dcdde94e8 100644 --- a/services/matrix-ollama-bot/src/app.module.ts +++ b/services/matrix-ollama-bot/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { LlmModule } from '@manacore/shared-llm'; import { HealthController, createHealthProvider } from '@manacore/matrix-bot-common'; import { BotModule } from './bot/bot.module'; import configuration from './config/configuration'; @@ -10,6 +11,15 @@ import configuration from './config/configuration'; isGlobal: true, load: [configuration], }), + LlmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + manaLlmUrl: config.get('llm.url') || 'http://localhost:3025', + defaultModel: config.get('llm.model') || 'ollama/gemma3:4b', + timeout: config.get('llm.timeout') || 120000, + }), + inject: [ConfigService], + }), BotModule, ], controllers: [HealthController], diff --git a/services/matrix-ollama-bot/src/ollama/ollama.service.ts b/services/matrix-ollama-bot/src/ollama/ollama.service.ts index 320549994..df6e1524c 100644 --- a/services/matrix-ollama-bot/src/ollama/ollama.service.ts +++ b/services/matrix-ollama-bot/src/ollama/ollama.service.ts @@ -1,49 +1,17 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { LlmClientService } from '@manacore/shared-llm'; import { ConfigService } from '@nestjs/config'; -interface LlmModel { - id: string; - name: string; - size: number; - owned_by: string; -} - -interface ChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string | ContentPart[]; -} - -interface ContentPart { - type: 'text' | 'image_url'; - text?: string; - image_url?: { url: string }; -} - -interface ChatCompletionResponse { - id: string; - model: string; - choices: { - message: { role: string; content: string }; - finish_reason: string; - }[]; - usage: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; -} - @Injectable() export class OllamaService implements OnModuleInit { private readonly logger = new Logger(OllamaService.name); - private readonly baseUrl: string; private readonly defaultModel: string; - private readonly timeout: number; - constructor(private configService: ConfigService) { - this.baseUrl = this.configService.get('llm.url') || 'http://localhost:3025'; + constructor( + private readonly llm: LlmClientService, + private configService: ConfigService + ) { this.defaultModel = this.configService.get('llm.model') || 'ollama/gemma3:4b'; - this.timeout = this.configService.get('llm.timeout') || 120000; } async onModuleInit() { @@ -52,27 +20,23 @@ export class OllamaService implements OnModuleInit { async checkConnection(): Promise { try { - const response = await fetch(`${this.baseUrl}/health`, { - signal: AbortSignal.timeout(5000), - }); - const data = await response.json(); - this.logger.log(`mana-llm connected: ${data.status}, providers: ${Object.keys(data.providers || {}).join(', ')}`); - return data.status === 'healthy' || data.status === 'degraded'; + const health = await this.llm.health(); + this.logger.log( + `mana-llm connected: ${health.status}, providers: ${Object.keys(health.providers || {}).join(', ')}` + ); + return health.status === 'healthy' || health.status === 'degraded'; } catch (error) { - this.logger.error(`Failed to connect to mana-llm at ${this.baseUrl}:`, error); + this.logger.error('Failed to connect to mana-llm:', error); return false; } } async listModels(): Promise<{ name: string; size: number; modified_at: string }[]> { try { - const response = await fetch(`${this.baseUrl}/v1/models`); - const data = await response.json(); - - // Convert OpenAI format to legacy Ollama format for compatibility - return (data.data || []).map((m: LlmModel) => ({ + const models = await this.llm.listModels(); + return models.map((m) => ({ name: m.id, - size: 0, // mana-llm doesn't provide size + size: 0, modified_at: new Date().toISOString(), })); } catch (error) { @@ -87,39 +51,15 @@ export class OllamaService implements OnModuleInit { ): Promise { const selectedModel = model ? this.normalizeModel(model) : this.defaultModel; - try { - const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: selectedModel, - messages, - stream: false, - }), - signal: AbortSignal.timeout(this.timeout), - }); + const result = await this.llm.chatMessages(messages, { model: selectedModel }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`mana-llm API error: ${response.status} - ${errorText}`); - } - - const data: ChatCompletionResponse = await response.json(); - - // Log performance metrics - if (data.usage) { - this.logger.debug( - `Generated ${data.usage.completion_tokens} tokens (total: ${data.usage.total_tokens})` - ); - } - - return data.choices[0]?.message?.content || ''; - } catch (error) { - if (error instanceof Error && error.name === 'TimeoutError') { - throw new Error('LLM Timeout - Antwort dauerte zu lange'); - } - throw error; + if (result.usage.completion_tokens) { + this.logger.debug( + `Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})` + ); } + + return result.content; } getDefaultModel(): string { @@ -129,59 +69,19 @@ export class OllamaService implements OnModuleInit { async chatWithImage(prompt: string, imageBase64: string, model?: string): Promise { const selectedModel = model ? this.normalizeModel(model) : this.defaultModel; - try { - // Use OpenAI vision format - const messages: ChatMessage[] = [ - { - role: 'user', - content: [ - { type: 'text', text: prompt }, - { - type: 'image_url', - image_url: { url: `data:image/png;base64,${imageBase64}` }, - }, - ], - }, - ]; + const result = await this.llm.vision(prompt, imageBase64, 'image/png', { + model: selectedModel, + }); - const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: selectedModel, - messages, - stream: false, - }), - signal: AbortSignal.timeout(this.timeout), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`mana-llm API error: ${response.status} - ${errorText}`); - } - - const data: ChatCompletionResponse = await response.json(); - - // Log performance metrics - if (data.usage) { - this.logger.debug( - `Vision: Generated ${data.usage.completion_tokens} tokens (total: ${data.usage.total_tokens})` - ); - } - - return data.choices[0]?.message?.content || ''; - } catch (error) { - if (error instanceof Error && error.name === 'TimeoutError') { - throw new Error('LLM Timeout - Bildanalyse dauerte zu lange'); - } - throw error; + if (result.usage.completion_tokens) { + this.logger.debug( + `Vision: Generated ${result.usage.completion_tokens} tokens (total: ${result.usage.total_tokens})` + ); } + + return result.content; } - /** - * Normalize model name to include provider prefix if missing. - * e.g., "gemma3:4b" -> "ollama/gemma3:4b" - */ private normalizeModel(model: string): string { if (model.includes('/')) { return model;