diff --git a/.github/workflows/jan-server-web-ci.yml b/.github/workflows/jan-server-web-ci.yml new file mode 100644 index 000000000..d65fa7b9a --- /dev/null +++ b/.github/workflows/jan-server-web-ci.yml @@ -0,0 +1,117 @@ +name: Jan Web Server build image and push to Harbor Registry + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + +jobs: + build-and-preview: + runs-on: [ubuntu-24-04-docker] + permissions: + pull-requests: write + contents: write + steps: + - name: Checkout source repo + uses: actions/checkout@v4 + + - name: Login to Harbor Registry + uses: docker/login-action@v3 + with: + registry: registry.menlo.ai + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Install dependencies + run: | + (type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \ + && sudo mkdir -p -m 755 /etc/apt/keyrings \ + && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && sudo mkdir -p -m 755 /etc/apt/sources.list.d \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt update \ + && sudo apt install gh -y + sudo apt-get update + sudo apt-get install -y jq gettext + + - name: Set image tag and service name + id: vars + run: | + SERVICE_NAME=jan-server-web + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + IMAGE_TAG="web:preview-${{ github.sha }}" + else + IMAGE_TAG="web:dev-${{ github.sha }}" + fi + echo "SERVICE_NAME=${SERVICE_NAME}" >> $GITHUB_OUTPUT + echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT + echo "FULL_IMAGE=registry.menlo.ai/jan-server/${IMAGE_TAG}" >> $GITHUB_OUTPUT + + - name: Build docker image + run: | + docker build -t ${{ steps.vars.outputs.FULL_IMAGE }} . + + - name: Push docker image + run: | + docker push ${{ steps.vars.outputs.FULL_IMAGE }} + + - name: Checkout preview URL repo + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + repository: menloresearch/infra-domains + token: ${{ secrets.PAT_SERVICE_ACCOUNT }} + path: preview-repo + + - name: Generate preview manifest + if: github.event_name == 'pull_request' + run: | + cd preview-repo/kubernetes + bash template/generate.sh \ + template/preview-url-template.yaml \ + preview-url/pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.yaml \ + ${{ github.sha }} \ + ${{ steps.vars.outputs.SERVICE_NAME }} \ + ${{ steps.vars.outputs.FULL_IMAGE }} \ + 80 + + - name: Commit and push preview manifest + if: github.event_name == 'pull_request' + run: | + cd preview-repo + git config user.name "preview-bot" + git config user.email "preview-bot@users.noreply.github.com" + git add kubernetes/preview-url/pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.yaml + git commit -m "feat(preview): add pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.yaml" + git push origin main + sleep 180 + + - name: Comment preview URL on PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DOMAIN="https://pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.menlo.ai" + COMMENT_BODY="🌐 Preview available: [${DOMAIN}](${DOMAIN})" + + echo "🔍 Looking for existing preview comment..." + + COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq '.[] | select(.user.login=="github-actions[bot]") | select(.body | contains("")) | .id') + + if [[ -n "$COMMENT_ID" ]]; then + echo "✏️ Updating existing comment ID $COMMENT_ID" + gh api repos/${{ github.repository }}/issues/comments/${COMMENT_ID} \ + --method PATCH \ + --field "body=${COMMENT_BODY}" + else + echo "💬 Creating new comment" + gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --method POST \ + --field "body=${COMMENT_BODY}" + fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..840ae887f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Stage 1: Build stage with Node.js and Yarn v4 +FROM node:20-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache \ + make \ + g++ \ + python3 \ + py3-pip \ + git + +# Enable corepack and install Yarn 4 +RUN corepack enable && corepack prepare yarn@4.5.3 --activate + +# Verify Yarn version +RUN yarn --version + +# Set working directory +WORKDIR /app + +# Copy source code +COPY ./extensions ./extensions +COPY ./extensions-web ./extensions-web +COPY ./web-app ./web-app +COPY ./Makefile ./Makefile +COPY ./.* / +COPY ./package.json ./package.json +COPY ./yarn.lock ./yarn.lock +COPY ./pre-install ./pre-install +COPY ./core ./core + +# Build web application +RUN yarn install && yarn build:core && make build-web-app + +# Stage 2: Production stage with Nginx +FROM nginx:alpine + +# Copy static files from build stage +COPY --from=builder /app/web-app/dist-web /usr/share/nginx/html + +# Copy nginx configuration for SPA (if custom config is needed) +# COPY nginx.conf /etc/nginx/nginx.conf + +# Expose port 80 +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/extensions-web/package.json b/extensions-web/package.json index 2e43b296d..8d54443fe 100644 --- a/extensions-web/package.json +++ b/extensions-web/package.json @@ -20,15 +20,15 @@ "test": "vitest", "typecheck": "tsc --noEmit" }, - "dependencies": { - "@janhq/core": "workspace:*" - }, "devDependencies": { + "@janhq/core": "workspace:*", "typescript": "^5.3.3", "vite": "^5.0.0", - "vitest": "^2.0.0" + "vitest": "^2.0.0", + "zustand": "^5.0.8" }, "peerDependencies": { - "@janhq/core": "*" + "@janhq/core": "*", + "zustand": "^5.0.0" } } diff --git a/extensions-web/src/index.ts b/extensions-web/src/index.ts index 72b507823..aa53e37e1 100644 --- a/extensions-web/src/index.ts +++ b/extensions-web/src/index.ts @@ -7,6 +7,7 @@ import type { WebExtensionRegistry } from './types' export { default as AssistantExtensionWeb } from './assistant-web' export { default as ConversationalExtensionWeb } from './conversational-web' +export { default as JanProviderWeb } from './jan-provider-web' // Re-export types export type { @@ -15,11 +16,13 @@ export type { WebExtensionName, WebExtensionLoader, AssistantWebModule, - ConversationalWebModule + ConversationalWebModule, + JanProviderWebModule } from './types' // Extension registry for dynamic loading export const WEB_EXTENSIONS: WebExtensionRegistry = { 'assistant-web': () => import('./assistant-web'), 'conversational-web': () => import('./conversational-web'), + 'jan-provider-web': () => import('./jan-provider-web'), } \ No newline at end of file diff --git a/extensions-web/src/jan-provider-web/api.ts b/extensions-web/src/jan-provider-web/api.ts new file mode 100644 index 000000000..68dd6cb77 --- /dev/null +++ b/extensions-web/src/jan-provider-web/api.ts @@ -0,0 +1,260 @@ +/** + * Jan Provider API Client + * Handles API requests to Jan backend for models and chat completions + */ + +import { JanAuthService } from './auth' +import { JanModel, janProviderStore } from './store' + +// JAN_API_BASE is defined in vite.config.ts + +export interface JanModelsResponse { + object: string + data: JanModel[] +} + +export interface JanChatMessage { + role: 'system' | 'user' | 'assistant' + content: string + reasoning?: string + reasoning_content?: string +} + +export interface JanChatCompletionRequest { + model: string + messages: JanChatMessage[] + temperature?: number + max_tokens?: number + top_p?: number + frequency_penalty?: number + presence_penalty?: number + stream?: boolean + stop?: string | string[] +} + +export interface JanChatCompletionChoice { + index: number + message: JanChatMessage + finish_reason: string | null +} + +export interface JanChatCompletionResponse { + id: string + object: string + created: number + model: string + choices: JanChatCompletionChoice[] + usage?: { + prompt_tokens: number + completion_tokens: number + total_tokens: number + } +} + +export interface JanChatCompletionChunk { + id: string + object: string + created: number + model: string + choices: Array<{ + index: number + delta: { + role?: string + content?: string + reasoning?: string + reasoning_content?: string + } + finish_reason: string | null + }> +} + +export class JanApiClient { + private static instance: JanApiClient + private authService: JanAuthService + + private constructor() { + this.authService = JanAuthService.getInstance() + } + + static getInstance(): JanApiClient { + if (!JanApiClient.instance) { + JanApiClient.instance = new JanApiClient() + } + return JanApiClient.instance + } + + private async makeAuthenticatedRequest( + url: string, + options: RequestInit = {} + ): Promise { + try { + const authHeader = await this.authService.getAuthHeader() + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authHeader, + ...options.headers, + }, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + return response.json() + } catch (error) { + console.error('API request failed:', error) + throw error + } + } + + async getModels(): Promise { + try { + janProviderStore.setLoadingModels(true) + janProviderStore.clearError() + + const response = await this.makeAuthenticatedRequest( + `${JAN_API_BASE}/models` + ) + + const models = response.data || [] + janProviderStore.setModels(models) + + return models + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch models' + janProviderStore.setError(errorMessage) + janProviderStore.setLoadingModels(false) + throw error + } + } + + async createChatCompletion( + request: JanChatCompletionRequest + ): Promise { + try { + janProviderStore.clearError() + + return await this.makeAuthenticatedRequest( + `${JAN_API_BASE}/chat/completions`, + { + method: 'POST', + body: JSON.stringify({ + ...request, + stream: false, + }), + } + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to create chat completion' + janProviderStore.setError(errorMessage) + throw error + } + } + + async createStreamingChatCompletion( + request: JanChatCompletionRequest, + onChunk: (chunk: JanChatCompletionChunk) => void, + onComplete?: () => void, + onError?: (error: Error) => void + ): Promise { + try { + janProviderStore.clearError() + + const authHeader = await this.authService.getAuthHeader() + + const response = await fetch(`${JAN_API_BASE}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeader, + }, + body: JSON.stringify({ + ...request, + stream: true, + }), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + if (!response.body) { + throw new Error('Response body is null') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + try { + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + + // Keep the last incomplete line in buffer + buffer = lines.pop() || '' + + for (const line of lines) { + const trimmedLine = line.trim() + if (trimmedLine.startsWith('data: ')) { + const data = trimmedLine.slice(6).trim() + + if (data === '[DONE]') { + onComplete?.() + return + } + + try { + const parsedChunk: JanChatCompletionChunk = JSON.parse(data) + onChunk(parsedChunk) + } catch (parseError) { + console.warn('Failed to parse SSE chunk:', parseError, 'Data:', data) + } + } + } + } + + onComplete?.() + } finally { + reader.releaseLock() + } + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error occurred') + janProviderStore.setError(err.message) + onError?.(err) + throw err + } + } + + async initialize(): Promise { + try { + await this.authService.initialize() + janProviderStore.setAuthenticated(true) + + // Fetch initial models + await this.getModels() + + console.log('Jan API client initialized successfully') + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to initialize API client' + janProviderStore.setError(errorMessage) + throw error + } finally { + janProviderStore.setInitializing(false) + } + } +} + +export const janApiClient = JanApiClient.getInstance() \ No newline at end of file diff --git a/extensions-web/src/jan-provider-web/auth.ts b/extensions-web/src/jan-provider-web/auth.ts new file mode 100644 index 000000000..be830c0d0 --- /dev/null +++ b/extensions-web/src/jan-provider-web/auth.ts @@ -0,0 +1,190 @@ +/** + * Jan Provider Authentication Service + * Handles guest login and token refresh for Jan API + */ + +export interface AuthTokens { + access_token: string + expires_in: number +} + +export interface AuthResponse { + access_token: string + expires_in: number +} + +// JAN_API_BASE is defined in vite.config.ts +const AUTH_STORAGE_KEY = 'jan_auth_tokens' +const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry + +export class JanAuthService { + private static instance: JanAuthService + private tokens: AuthTokens | null = null + private tokenExpiryTime: number = 0 + + private constructor() { + this.loadTokensFromStorage() + } + + static getInstance(): JanAuthService { + if (!JanAuthService.instance) { + JanAuthService.instance = new JanAuthService() + } + return JanAuthService.instance + } + + private loadTokensFromStorage(): void { + try { + const storedTokens = localStorage.getItem(AUTH_STORAGE_KEY) + if (storedTokens) { + const parsed = JSON.parse(storedTokens) + this.tokens = parsed.tokens + this.tokenExpiryTime = parsed.expiryTime || 0 + } + } catch (error) { + console.warn('Failed to load tokens from storage:', error) + this.clearTokens() + } + } + + private saveTokensToStorage(): void { + if (this.tokens) { + try { + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ + tokens: this.tokens, + expiryTime: this.tokenExpiryTime + })) + } catch (error) { + console.error('Failed to save tokens to storage:', error) + } + } + } + + private clearTokens(): void { + this.tokens = null + this.tokenExpiryTime = 0 + localStorage.removeItem(AUTH_STORAGE_KEY) + } + + private isTokenExpired(): boolean { + return Date.now() > (this.tokenExpiryTime - TOKEN_EXPIRY_BUFFER) + } + + private calculateExpiryTime(expiresIn: number): number { + return Date.now() + (expiresIn * 1000) + } + + private async guestLogin(): Promise { + try { + const response = await fetch(`${JAN_API_BASE}/auth/guest-login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Include cookies for session management + }) + + if (!response.ok) { + throw new Error(`Guest login failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + // API response is wrapped in result object + const authResponse = data.result || data + + // Guest login returns only access_token and expires_in + const tokens: AuthTokens = { + access_token: authResponse.access_token, + expires_in: authResponse.expires_in + } + + this.tokens = tokens + this.tokenExpiryTime = this.calculateExpiryTime(tokens.expires_in) + this.saveTokensToStorage() + + return tokens + } catch (error) { + console.error('Guest login failed:', error) + throw error + } + } + + private async refreshToken(): Promise { + try { + const response = await fetch(`${JAN_API_BASE}/auth/refresh-token`, { + method: 'GET', + credentials: 'include', // Cookies will include the refresh token + }) + + if (!response.ok) { + if (response.status === 401) { + // Refresh token is invalid, clear tokens and do guest login + this.clearTokens() + return this.guestLogin() + } + throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + // API response is wrapped in result object + const authResponse = data.result || data + + // Refresh endpoint returns only access_token and expires_in + const tokens: AuthTokens = { + access_token: authResponse.access_token, + expires_in: authResponse.expires_in + } + + this.tokens = tokens + this.tokenExpiryTime = this.calculateExpiryTime(tokens.expires_in) + this.saveTokensToStorage() + + return tokens + } catch (error) { + console.error('Token refresh failed:', error) + // If refresh fails, fall back to guest login + this.clearTokens() + return this.guestLogin() + } + } + + async getValidAccessToken(): Promise { + // If no tokens exist, do guest login + if (!this.tokens) { + const tokens = await this.guestLogin() + return tokens.access_token + } + + // If token is expired or about to expire, refresh it + if (this.isTokenExpired()) { + const tokens = await this.refreshToken() + return tokens.access_token + } + + // Return existing valid token + return this.tokens.access_token + } + + async initialize(): Promise { + try { + await this.getValidAccessToken() + console.log('Jan auth service initialized successfully') + } catch (error) { + console.error('Failed to initialize Jan auth service:', error) + throw error + } + } + + async getAuthHeader(): Promise<{ Authorization: string }> { + const token = await this.getValidAccessToken() + return { + Authorization: `Bearer ${token}` + } + } + + logout(): void { + this.clearTokens() + } +} \ No newline at end of file diff --git a/extensions-web/src/jan-provider-web/index.ts b/extensions-web/src/jan-provider-web/index.ts new file mode 100644 index 000000000..70cbf7770 --- /dev/null +++ b/extensions-web/src/jan-provider-web/index.ts @@ -0,0 +1 @@ +export { default } from './provider' \ No newline at end of file diff --git a/extensions-web/src/jan-provider-web/provider.ts b/extensions-web/src/jan-provider-web/provider.ts new file mode 100644 index 000000000..dbe39beba --- /dev/null +++ b/extensions-web/src/jan-provider-web/provider.ts @@ -0,0 +1,307 @@ +/** + * Jan Provider Extension for Web + * Provides remote model inference through Jan API + */ + +import { + AIEngine, + modelInfo, + SessionInfo, + UnloadResult, + chatCompletionRequest, + chatCompletion, + chatCompletionChunk, + ImportOptions, +} from '@janhq/core' // cspell: disable-line +import { janApiClient, JanChatMessage } from './api' +import { janProviderStore } from './store' + +export default class JanProviderWeb extends AIEngine { + readonly provider = 'jan' + private activeSessions: Map = new Map() + + override async onLoad() { + console.log('Loading Jan Provider Extension...') + + try { + // Initialize authentication and fetch models + await janApiClient.initialize() + console.log('Jan Provider Extension loaded successfully') + } catch (error) { + console.error('Failed to load Jan Provider Extension:', error) + throw error + } + + super.onLoad() + } + + override async onUnload() { + console.log('Unloading Jan Provider Extension...') + + // Clear all sessions + for (const sessionId of this.activeSessions.keys()) { + await this.unload(sessionId) + } + + janProviderStore.reset() + console.log('Jan Provider Extension unloaded') + } + + async list(): Promise { + try { + const janModels = await janApiClient.getModels() + + return janModels.map((model) => ({ + id: model.id, + name: model.id, // Use ID as name for now + quant_type: undefined, + providerId: this.provider, + port: 443, // HTTPS port for API + sizeBytes: 0, // Size not provided by Jan API + tags: [], + path: undefined, // Remote model, no local path + owned_by: model.owned_by, + object: model.object, + })) + } catch (error) { + console.error('Failed to list Jan models:', error) + throw error + } + } + + async load(modelId: string, _settings?: any): Promise { + try { + // For Jan API, we don't actually "load" models in the traditional sense + // We just create a session reference for tracking + const sessionId = `jan-${modelId}-${Date.now()}` + + const sessionInfo: SessionInfo = { + pid: Date.now(), // Use timestamp as pseudo-PID + port: 443, // HTTPS port + model_id: modelId, + model_path: `remote:${modelId}`, // Indicate this is a remote model + api_key: '', // API key handled by auth service + } + + this.activeSessions.set(sessionId, sessionInfo) + + console.log(`Jan model session created: ${sessionId} for model ${modelId}`) + return sessionInfo + } catch (error) { + console.error(`Failed to load Jan model ${modelId}:`, error) + throw error + } + } + + async unload(sessionId: string): Promise { + try { + const session = this.activeSessions.get(sessionId) + + if (!session) { + return { + success: false, + error: `Session ${sessionId} not found` + } + } + + this.activeSessions.delete(sessionId) + console.log(`Jan model session unloaded: ${sessionId}`) + + return { success: true } + } catch (error) { + console.error(`Failed to unload Jan session ${sessionId}:`, error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } + } + + async chat( + opts: chatCompletionRequest, + abortController?: AbortController + ): Promise> { + try { + // Check if request was aborted before starting + if (abortController?.signal?.aborted) { + throw new Error('Request was aborted') + } + + // For Jan API, we need to determine which model to use + // The model should be specified in opts.model + const modelId = opts.model + if (!modelId) { + throw new Error('Model ID is required') + } + + // Convert core chat completion request to Jan API format + const janMessages: JanChatMessage[] = opts.messages.map(msg => ({ + role: msg.role as 'system' | 'user' | 'assistant', + content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + })) + + const janRequest = { + model: modelId, + messages: janMessages, + temperature: opts.temperature ?? undefined, + max_tokens: opts.n_predict ?? undefined, + top_p: opts.top_p ?? undefined, + frequency_penalty: opts.frequency_penalty ?? undefined, + presence_penalty: opts.presence_penalty ?? undefined, + stream: opts.stream ?? false, + stop: opts.stop ?? undefined, + } + + if (opts.stream) { + // Return async generator for streaming + return this.createStreamingGenerator(janRequest, abortController) + } else { + // Return single response + const response = await janApiClient.createChatCompletion(janRequest) + + // Check if aborted after completion + if (abortController?.signal?.aborted) { + throw new Error('Request was aborted') + } + + return { + id: response.id, + object: 'chat.completion' as const, + created: response.created, + model: response.model, + choices: response.choices.map(choice => ({ + index: choice.index, + message: { + role: choice.message.role, + content: choice.message.content, + reasoning: choice.message.reasoning, + reasoning_content: choice.message.reasoning_content, + }, + finish_reason: (choice.finish_reason || 'stop') as 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call', + })), + usage: response.usage, + } + } + } catch (error) { + console.error('Jan chat completion failed:', error) + throw error + } + } + + private async *createStreamingGenerator(janRequest: any, abortController?: AbortController) { + let resolve: () => void + let reject: (error: Error) => void + const chunks: any[] = [] + let isComplete = false + let error: Error | null = null + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + // Handle abort signal + const abortListener = () => { + error = new Error('Request was aborted') + reject(error) + } + + if (abortController?.signal) { + if (abortController.signal.aborted) { + throw new Error('Request was aborted') + } + abortController.signal.addEventListener('abort', abortListener) + } + + try { + // Start the streaming request + janApiClient.createStreamingChatCompletion( + janRequest, + (chunk) => { + if (abortController?.signal?.aborted) { + return + } + const streamChunk = { + id: chunk.id, + object: chunk.object, + created: chunk.created, + model: chunk.model, + choices: chunk.choices.map(choice => ({ + index: choice.index, + delta: { + role: choice.delta.role, + content: choice.delta.content, + reasoning: choice.delta.reasoning, + reasoning_content: choice.delta.reasoning_content, + }, + finish_reason: choice.finish_reason, + })), + } + chunks.push(streamChunk) + }, + () => { + isComplete = true + resolve() + }, + (err) => { + error = err + reject(err) + } + ) + + // Yield chunks as they arrive + let yieldedIndex = 0 + while (!isComplete && !error) { + if (abortController?.signal?.aborted) { + throw new Error('Request was aborted') + } + + while (yieldedIndex < chunks.length) { + yield chunks[yieldedIndex] + yieldedIndex++ + } + + // Wait a bit before checking again + await new Promise(resolve => setTimeout(resolve, 10)) + } + + // Yield any remaining chunks + while (yieldedIndex < chunks.length) { + yield chunks[yieldedIndex] + yieldedIndex++ + } + + if (error) { + throw error + } + + await promise + } finally { + // Clean up abort listener + if (abortController?.signal) { + abortController.signal.removeEventListener('abort', abortListener) + } + } + } + + async delete(modelId: string): Promise { + throw new Error(`Delete operation not supported for remote Jan API model: ${modelId}`) + } + + async import(modelId: string, _opts: ImportOptions): Promise { + throw new Error(`Import operation not supported for remote Jan API model: ${modelId}`) + } + + async abortImport(modelId: string): Promise { + throw new Error(`Abort import operation not supported for remote Jan API model: ${modelId}`) + } + + async getLoadedModels(): Promise { + return Array.from(this.activeSessions.values()).map(session => session.model_id) + } + + async isToolSupported(): Promise { + // Tools are not yet supported + return false + } +} \ No newline at end of file diff --git a/extensions-web/src/jan-provider-web/store.ts b/extensions-web/src/jan-provider-web/store.ts new file mode 100644 index 000000000..02cc70686 --- /dev/null +++ b/extensions-web/src/jan-provider-web/store.ts @@ -0,0 +1,95 @@ +/** + * Jan Provider Store + * Zustand-based state management for Jan provider authentication and models + */ + +import { create } from 'zustand' + +export interface JanModel { + id: string + object: string + owned_by: string +} + +export interface JanProviderState { + isAuthenticated: boolean + isInitializing: boolean + models: JanModel[] + isLoadingModels: boolean + error: string | null +} + +export interface JanProviderActions { + setAuthenticated: (isAuthenticated: boolean) => void + setInitializing: (isInitializing: boolean) => void + setModels: (models: JanModel[]) => void + setLoadingModels: (isLoadingModels: boolean) => void + setError: (error: string | null) => void + clearError: () => void + reset: () => void +} + +export type JanProviderStore = JanProviderState & JanProviderActions + +const initialState: JanProviderState = { + isAuthenticated: false, + isInitializing: true, + models: [], + isLoadingModels: false, + error: null, +} + +export const useJanProviderStore = create((set) => ({ + ...initialState, + + setAuthenticated: (isAuthenticated: boolean) => + set({ isAuthenticated, error: null }), + + setInitializing: (isInitializing: boolean) => + set({ isInitializing }), + + setModels: (models: JanModel[]) => + set({ models, isLoadingModels: false }), + + setLoadingModels: (isLoadingModels: boolean) => + set({ isLoadingModels }), + + setError: (error: string | null) => + set({ error }), + + clearError: () => + set({ error: null }), + + reset: () => + set({ + isAuthenticated: false, + isInitializing: false, + models: [], + isLoadingModels: false, + error: null, + }), +})) + +// Export a store instance for non-React usage +export const janProviderStore = { + // Store access methods + getState: useJanProviderStore.getState, + setState: useJanProviderStore.setState, + subscribe: useJanProviderStore.subscribe, + + // Direct action methods + setAuthenticated: (isAuthenticated: boolean) => + useJanProviderStore.getState().setAuthenticated(isAuthenticated), + setInitializing: (isInitializing: boolean) => + useJanProviderStore.getState().setInitializing(isInitializing), + setModels: (models: JanModel[]) => + useJanProviderStore.getState().setModels(models), + setLoadingModels: (isLoadingModels: boolean) => + useJanProviderStore.getState().setLoadingModels(isLoadingModels), + setError: (error: string | null) => + useJanProviderStore.getState().setError(error), + clearError: () => + useJanProviderStore.getState().clearError(), + reset: () => + useJanProviderStore.getState().reset(), +} \ No newline at end of file diff --git a/extensions-web/src/types.ts b/extensions-web/src/types.ts index 5511c4479..4b2ba583e 100644 --- a/extensions-web/src/types.ts +++ b/extensions-web/src/types.ts @@ -2,7 +2,7 @@ * Web Extension Types */ -import type { AssistantExtension, ConversationalExtension, BaseExtension } from '@janhq/core' +import type { AssistantExtension, ConversationalExtension, BaseExtension, AIEngine } from '@janhq/core' type ExtensionConstructorParams = ConstructorParameters @@ -14,11 +14,16 @@ export interface ConversationalWebModule { default: new (...args: ExtensionConstructorParams) => ConversationalExtension } -export type WebExtensionModule = AssistantWebModule | ConversationalWebModule +export interface JanProviderWebModule { + default: new (...args: ExtensionConstructorParams) => AIEngine +} + +export type WebExtensionModule = AssistantWebModule | ConversationalWebModule | JanProviderWebModule export interface WebExtensionRegistry { 'assistant-web': () => Promise 'conversational-web': () => Promise + 'jan-provider-web': () => Promise } export type WebExtensionName = keyof WebExtensionRegistry diff --git a/extensions-web/src/types/global.d.ts b/extensions-web/src/types/global.d.ts new file mode 100644 index 000000000..a6e82d759 --- /dev/null +++ b/extensions-web/src/types/global.d.ts @@ -0,0 +1,5 @@ +export {} + +declare global { + declare const JAN_API_BASE: string +} \ No newline at end of file diff --git a/extensions-web/src/vite-env.d.ts b/extensions-web/src/vite-env.d.ts new file mode 100644 index 000000000..151aa6856 --- /dev/null +++ b/extensions-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/extensions-web/vite.config.ts b/extensions-web/vite.config.ts index f56e30df7..8d9147c79 100644 --- a/extensions-web/vite.config.ts +++ b/extensions-web/vite.config.ts @@ -9,13 +9,11 @@ export default defineConfig({ fileName: 'index' }, rollupOptions: { - external: ['@janhq/core'], - output: { - globals: { - '@janhq/core': 'JanCore' - } - } + external: ['@janhq/core', 'zustand'] }, emptyOutDir: false // Don't clean the output directory + }, + define: { + JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/jan/v1'), } }) \ No newline at end of file diff --git a/web-app/index.html b/web-app/index.html index a3117f37a..fc264d096 100644 --- a/web-app/index.html +++ b/web-app/index.html @@ -2,7 +2,9 @@ - + + + Jan diff --git a/web-app/package.json b/web-app/package.json index 53f1e46fc..2d080d0e6 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -13,7 +13,8 @@ "dev:web": "vite --config vite.config.web.ts", "build:web": "yarn tsc -b tsconfig.web.json && vite build --config vite.config.web.ts", "preview:web": "vite preview --config vite.config.web.ts --outDir dist-web", - "serve:web": "npx serve dist-web -p 3001", + "serve:web": "npx serve dist-web -p 3001 -s", + "serve:web:alt": "npx http-server dist-web -p 3001 --proxy http://localhost:3001? -o", "build:serve:web": "yarn build:web && yarn serve:web" }, "dependencies": { diff --git a/web-app/public/images/jan-logo.png b/web-app/public/images/jan-logo.png new file mode 100644 index 000000000..c16023e94 Binary files /dev/null and b/web-app/public/images/jan-logo.png differ diff --git a/web-app/public/images/model-provider/jan.png b/web-app/public/images/model-provider/jan.png new file mode 100644 index 000000000..c16023e94 Binary files /dev/null and b/web-app/public/images/model-provider/jan.png differ diff --git a/web-app/src/containers/DropdownModelProvider.tsx b/web-app/src/containers/DropdownModelProvider.tsx index da3bcd57b..303fe2a37 100644 --- a/web-app/src/containers/DropdownModelProvider.tsx +++ b/web-app/src/containers/DropdownModelProvider.tsx @@ -21,6 +21,8 @@ import { useTranslation } from '@/i18n/react-i18next-compat' import { useFavoriteModel } from '@/hooks/useFavoriteModel' import { predefinedProviders } from '@/consts/providers' import { useServiceHub } from '@/hooks/useServiceHub' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' type DropdownModelProviderProps = { model?: ThreadModel @@ -171,8 +173,28 @@ const DropdownModelProvider = ({ await checkAndUpdateModelVisionCapability(lastUsed.model) } } else { + // For web-only builds, auto-select the first model from jan provider + if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION]) { + const janProvider = providers.find( + (p) => p.provider === 'jan' && p.active && p.models.length > 0 + ) + if (janProvider && janProvider.models.length > 0) { + const firstModel = janProvider.models[0] + selectModelProvider(janProvider.provider, firstModel.id) + return + } + } selectModelProvider('', '') } + } else if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION] && !selectedModel) { + // For web-only builds, always auto-select the first model from jan provider if none is selected + const janProvider = providers.find( + (p) => p.provider === 'jan' && p.active && p.models.length > 0 + ) + if (janProvider && janProvider.models.length > 0) { + const firstModel = janProvider.models[0] + selectModelProvider(janProvider.provider, firstModel.id) + } } } @@ -188,6 +210,7 @@ const DropdownModelProvider = ({ getProviderByName, checkAndUpdateModelVisionCapability, serviceHub, + selectedModel, ]) // Update display model when selection changes @@ -549,22 +572,24 @@ const DropdownModelProvider = ({ {getProviderTitle(providerInfo.provider)} -
{ - e.stopPropagation() - navigate({ - to: route.settings.providers, - params: { providerName: providerInfo.provider }, - }) - setOpen(false) - }} - > - -
+ {PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS] && ( +
{ + e.stopPropagation() + navigate({ + to: route.settings.providers, + params: { providerName: providerInfo.provider }, + }) + setOpen(false) + }} + > + +
+ )} {/* Models for this provider */} diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 39e1b2ca2..b019d318e 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -480,7 +480,7 @@ const LeftPanel = () => {
{mainMenus.map((menu) => { if (!menu.isEnabled) { - return <> + return null } const isActive = currentPath.includes(route.settings.index) && diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index 52eebd741..7a543b03f 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -80,13 +80,13 @@ const SettingsMenu = () => { title: 'common:privacy', route: route.settings.privacy, hasSubMenu: false, - isEnabled: true, + isEnabled: PlatformFeatures[PlatformFeature.ANALYTICS], }, { title: 'common:modelProviders', route: route.settings.model_providers, hasSubMenu: activeProviders.length > 0, - isEnabled: true, + isEnabled: PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS], }, { title: 'common:keyboardShortcuts', @@ -159,7 +159,7 @@ const SettingsMenu = () => {
{menuSettings.map((menu) => { if (!menu.isEnabled) { - return <> + return null } return (
diff --git a/web-app/src/containers/SetupScreen.tsx b/web-app/src/containers/SetupScreen.tsx index e9867b38a..812ed6493 100644 --- a/web-app/src/containers/SetupScreen.tsx +++ b/web-app/src/containers/SetupScreen.tsx @@ -11,7 +11,7 @@ function SetupScreen() { const { t } = useTranslation() const { providers } = useModelProvider() const firstItemRemoteProvider = - providers.length > 0 ? providers[1].provider : 'openai' + providers.length > 0 ? providers[1]?.provider : 'openai' // Check if setup tour has been completed const isSetupCompleted = diff --git a/web-app/src/lib/platform/const.ts b/web-app/src/lib/platform/const.ts index da62860f4..e2aabb109 100644 --- a/web-app/src/lib/platform/const.ts +++ b/web-app/src/lib/platform/const.ts @@ -34,4 +34,16 @@ export const PlatformFeatures: Record = { // HTTPS proxy [PlatformFeature.HTTPS_PROXY]: isPlatformTauri(), + + // Default model providers (OpenAI, Anthropic, etc.) - disabled for web-only Jan builds + [PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(), + + // Analytics and telemetry - disabled for web + [PlatformFeature.ANALYTICS]: isPlatformTauri(), + + // Web-specific automatic model selection from jan provider - enabled for web only + [PlatformFeature.WEB_AUTO_MODEL_SELECTION]: !isPlatformTauri(), + + // Model provider settings page management - disabled for web only + [PlatformFeature.MODEL_PROVIDER_SETTINGS]: isPlatformTauri(), } \ No newline at end of file diff --git a/web-app/src/lib/platform/types.ts b/web-app/src/lib/platform/types.ts index c12c797a9..5aa0fe7f4 100644 --- a/web-app/src/lib/platform/types.ts +++ b/web-app/src/lib/platform/types.ts @@ -36,4 +36,16 @@ export enum PlatformFeature { // HTTPS proxy HTTPS_PROXY = 'httpsProxy', + + // Default model providers (OpenAI, Anthropic, etc.) + DEFAULT_PROVIDERS = 'defaultProviders', + + // Analytics and telemetry + ANALYTICS = 'analytics', + + // Web-specific automatic model selection from jan provider + WEB_AUTO_MODEL_SELECTION = 'webAutoModelSelection', + + // Model provider settings page management + MODEL_PROVIDER_SETTINGS = 'modelProviderSettings', } diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts index 3d896b883..0d3fa8f61 100644 --- a/web-app/src/lib/utils.ts +++ b/web-app/src/lib/utils.ts @@ -8,6 +8,8 @@ export function cn(...inputs: ClassValue[]) { export function getProviderLogo(provider: string) { switch (provider) { + case 'jan': + return '/images/model-provider/jan.png' case 'llamacpp': return '/images/model-provider/llamacpp.svg' case 'anthropic': @@ -33,6 +35,8 @@ export function getProviderLogo(provider: string) { export const getProviderTitle = (provider: string) => { switch (provider) { + case 'jan': + return 'Jan' case 'llamacpp': return 'Llama.cpp' case 'openai': diff --git a/web-app/src/providers/AnalyticProvider.tsx b/web-app/src/providers/AnalyticProvider.tsx index 29bd93074..f26e3f97e 100644 --- a/web-app/src/providers/AnalyticProvider.tsx +++ b/web-app/src/providers/AnalyticProvider.tsx @@ -3,12 +3,18 @@ import { useEffect } from 'react' import { useServiceHub } from '@/hooks/useServiceHub' import { useAnalytic } from '@/hooks/useAnalytic' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' export function AnalyticProvider() { const { productAnalytic } = useAnalytic() const serviceHub = useServiceHub() useEffect(() => { + // Early exit if analytics are disabled for this platform + if (!PlatformFeatures[PlatformFeature.ANALYTICS]) { + return + } if (!POSTHOG_KEY || !POSTHOG_HOST) { console.warn( 'PostHog not initialized: Missing POSTHOG_KEY or POSTHOG_HOST environment variables' diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 03435933d..13ed27813 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -31,6 +31,8 @@ import GlobalError from '@/containers/GlobalError' import { GlobalEventHandler } from '@/providers/GlobalEventHandler' import ErrorDialog from '@/containers/dialogs/ErrorDialog' import { ServiceHubProvider } from '@/providers/ServiceHubProvider' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' export const Route = createRootRoute({ component: RootLayout, @@ -162,7 +164,7 @@ const AppLayout = () => {
)} - {productAnalyticPrompt && } + {PlatformFeatures[PlatformFeature.ANALYTICS] && productAnalyticPrompt && } ) } diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index a8d9815ff..4ff643356 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -35,11 +35,12 @@ function Index() { useTools() // Conditional to check if there are any valid providers - // required min 1 api_key or 1 model in llama.cpp + // required min 1 api_key or 1 model in llama.cpp or jan provider const hasValidProviders = providers.some( (provider) => provider.api_key?.length || - (provider.provider === 'llamacpp' && provider.models.length) + (provider.provider === 'llamacpp' && provider.models.length) || + (provider.provider === 'jan' && provider.models.length) ) useEffect(() => { diff --git a/web-app/src/routes/settings/general.tsx b/web-app/src/routes/settings/general.tsx index 574d7ee6a..a07015c3f 100644 --- a/web-app/src/routes/settings/general.tsx +++ b/web-app/src/routes/settings/general.tsx @@ -181,14 +181,16 @@ function General() {
{/* General */} - - v{VERSION} - - } - /> + {PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && ( + + v{VERSION} + + } + /> + )} {!AUTO_UPDATER_DISABLED && PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && ( } /> - setHuggingfaceToken(e.target.value)} - placeholder={'hf_xxx'} - required - /> - } - /> + {PlatformFeatures[PlatformFeature.MODEL_HUB] && ( + setHuggingfaceToken(e.target.value)} + placeholder={'hf_xxx'} + required + /> + } + /> + )} {/* Resources */} diff --git a/web-app/src/routes/settings/privacy.tsx b/web-app/src/routes/settings/privacy.tsx index 425c4865a..3d0771db7 100644 --- a/web-app/src/routes/settings/privacy.tsx +++ b/web-app/src/routes/settings/privacy.tsx @@ -7,6 +7,8 @@ import { Card, CardItem } from '@/containers/Card' import { useTranslation } from '@/i18n/react-i18next-compat' import { useAnalytic } from '@/hooks/useAnalytic' import posthog from 'posthog-js' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' // eslint-disable-next-line @typescript-eslint/no-explicit-any export const Route = createFileRoute(route.settings.privacy as any)({ @@ -26,7 +28,8 @@ function Privacy() {
-

@@ -82,6 +85,7 @@ function Privacy() { } /> + )}

diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 8749bf915..873dc29b3 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -31,6 +31,8 @@ import { useCallback, useEffect, useState } from 'react' import { predefinedProviders } from '@/consts/providers' import { useModelLoad } from '@/hooks/useModelLoad' import { useLlamacppDevices } from '@/hooks/useLlamacppDevices' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' // as route.threadsDetail export const Route = createFileRoute('/settings/providers/$providerName')({ @@ -284,6 +286,30 @@ function ProviderDetail() { }) } + // Check if model provider settings are enabled for this platform + if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) { + return ( +
+ +

{t('common:settings')}

+
+
+ +
+
+

+ {t('common:notAvailable')} +

+

+ Provider settings are not available on the web platform. +

+
+
+
+
+ ) + } + return ( <> + +

{t('common:settings')}

+
+
+ +
+
+

+ {t('common:notAvailable')} +

+

+ Model provider settings are not available on the web platform. +

+
+
+
+
+ ) + } + return (
diff --git a/web-app/src/services/providers/web.ts b/web-app/src/services/providers/web.ts index c7b444388..30fe71366 100644 --- a/web-app/src/services/providers/web.ts +++ b/web-app/src/services/providers/web.ts @@ -9,43 +9,11 @@ import { ModelCapabilities } from '@/types/models' import { modelSettings } from '@/lib/predefined' import { ExtensionManager } from '@/lib/extension' import type { ProvidersService } from './types' +import { PlatformFeatures } from '@/lib/platform/const' +import { PlatformFeature } from '@/lib/platform/types' export class WebProvidersService implements ProvidersService { async getProviders(): Promise { - const builtinProviders = predefinedProviders.map((provider) => { - let models = provider.models as Model[] - if (Object.keys(providerModels).includes(provider.provider)) { - const builtInModels = providerModels[ - provider.provider as unknown as keyof typeof providerModels - ].models as unknown as string[] - - if (Array.isArray(builtInModels)) - models = builtInModels.map((model) => { - const modelManifest = models.find((e) => e.id === model) - // TODO: Check chat_template for tool call support - const capabilities = [ - ModelCapabilities.COMPLETION, - ( - providerModels[ - provider.provider as unknown as keyof typeof providerModels - ].supportsToolCalls as unknown as string[] - ).includes(model) - ? ModelCapabilities.TOOLS - : undefined, - ].filter(Boolean) as string[] - return { - ...(modelManifest ?? { id: model, name: model }), - capabilities, - } as Model - }) - } - - return { - ...provider, - models, - } - }) - const runtimeProviders: ModelProvider[] = [] for (const [providerName, value] of EngineManager.instance().engines) { const models = (await value.list()) ?? [] @@ -105,6 +73,45 @@ export class WebProvidersService implements ProvidersService { runtimeProviders.push(provider) } + if (!PlatformFeatures[PlatformFeature.DEFAULT_PROVIDERS]) { + return runtimeProviders + } + + const builtinProviders = predefinedProviders.map((provider) => { + let models = provider.models as Model[] + if (Object.keys(providerModels).includes(provider.provider)) { + const builtInModels = providerModels[ + provider.provider as unknown as keyof typeof providerModels + ].models as unknown as string[] + + if (Array.isArray(builtInModels)) { + models = builtInModels.map((model) => { + const modelManifest = models.find((e) => e.id === model) + // TODO: Check chat_template for tool call support + const capabilities = [ + ModelCapabilities.COMPLETION, + ( + providerModels[ + provider.provider as unknown as keyof typeof providerModels + ].supportsToolCalls as unknown as string[] + ).includes(model) + ? ModelCapabilities.TOOLS + : undefined, + ].filter(Boolean) as string[] + return { + ...(modelManifest ?? { id: model, name: model }), + capabilities, + } as Model + }) + } + } + + return { + ...provider, + models, + } + }) + return runtimeProviders.concat(builtinProviders as ModelProvider[]) } @@ -132,7 +139,7 @@ export class WebProvidersService implements ProvidersService { if (!response.ok) { throw new Error( - `Failed to fetch models: ${response.status} ${response.statusText}` + `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` ) } @@ -163,14 +170,14 @@ export class WebProvidersService implements ProvidersService { } catch (error) { console.error('Error fetching models from provider:', error) - // Provide helpful error message - if (error instanceof Error && error.message.includes('fetch')) { - throw new Error( - `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` - ) + // Provide helpful error message for any connection errors + if (error instanceof Error && error.message.includes('Cannot connect')) { + throw error } - - throw error + + throw new Error( + `Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.` + ) } } diff --git a/web-app/src/test/setup.ts b/web-app/src/test/setup.ts index 2d3b53c0e..a542ffec9 100644 --- a/web-app/src/test/setup.ts +++ b/web-app/src/test/setup.ts @@ -18,6 +18,10 @@ vi.mock('@/lib/platform/const', () => ({ modelHub: true, systemIntegrations: true, httpsProxy: true, + defaultProviders: true, + analytics: true, + webAutoModelSelection: true, + modelProviderSettings: true, } })) @@ -28,11 +32,12 @@ const mockServiceHub = { setTheme: vi.fn(), toggleTheme: vi.fn(), }), - window: () => ({ + window: vi.fn().mockReturnValue({ minimize: vi.fn(), maximize: vi.fn(), close: vi.fn(), isMaximized: vi.fn().mockResolvedValue(false), + openLogsWindow: vi.fn().mockResolvedValue(undefined), }), events: () => ({ emit: vi.fn().mockResolvedValue(undefined), @@ -41,7 +46,7 @@ const mockServiceHub = { hardware: () => ({ getHardwareInfo: vi.fn().mockResolvedValue(null), getSystemUsage: vi.fn().mockResolvedValue(null), - getLlamacppDevices: vi.fn().mockResolvedValue([]), + getLlamacppDevices: vi.fn().mockResolvedValue([]), // cspell: disable-line setActiveGpus: vi.fn().mockResolvedValue(undefined), // Legacy methods for backward compatibility getGpuInfo: vi.fn().mockResolvedValue([]), @@ -52,6 +57,8 @@ const mockServiceHub = { getAppSettings: vi.fn().mockResolvedValue({}), updateAppSettings: vi.fn().mockResolvedValue(undefined), getSystemInfo: vi.fn().mockResolvedValue({}), + relocateJanDataFolder: vi.fn().mockResolvedValue(undefined), + getJanDataFolder: vi.fn().mockResolvedValue('/mock/jan/data'), }), analytic: () => ({ track: vi.fn(), @@ -104,6 +111,9 @@ const mockServiceHub = { deleteModel: vi.fn().mockResolvedValue(undefined), updateModel: vi.fn().mockResolvedValue(undefined), startModel: vi.fn().mockResolvedValue(undefined), + isModelSupported: vi.fn().mockResolvedValue('GREEN'), + checkMmprojExists: vi.fn().mockResolvedValue(true), // cspell: disable-line + stopAllModels: vi.fn().mockResolvedValue(undefined), }), assistants: () => ({ getAssistants: vi.fn().mockResolvedValue([]), @@ -117,15 +127,16 @@ const mockServiceHub = { save: vi.fn().mockResolvedValue('/path/to/file'), message: vi.fn().mockResolvedValue(undefined), }), - opener: () => ({ + opener: vi.fn().mockReturnValue({ open: vi.fn().mockResolvedValue(undefined), + revealItemInDir: vi.fn().mockResolvedValue(undefined), }), updater: () => ({ checkForUpdates: vi.fn().mockResolvedValue(null), installUpdate: vi.fn().mockResolvedValue(undefined), downloadAndInstallWithProgress: vi.fn().mockResolvedValue(undefined), }), - path: () => ({ + path: vi.fn().mockReturnValue({ sep: () => '/', join: vi.fn((...args) => args.join('/')), resolve: vi.fn((path) => path), @@ -137,7 +148,7 @@ const mockServiceHub = { stopCore: vi.fn().mockResolvedValue(undefined), getCoreStatus: vi.fn().mockResolvedValue('stopped'), }), - deeplink: () => ({ + deeplink: () => ({ // cspell: disable-line register: vi.fn().mockResolvedValue(undefined), handle: vi.fn().mockResolvedValue(undefined), getCurrent: vi.fn().mockResolvedValue(null), @@ -168,6 +179,27 @@ Object.defineProperty(window, 'matchMedia', { })), }) +// Mock globalThis.core.api for @janhq/core functions // cspell: disable-line +;(globalThis as Record).core = { + api: { + getJanDataFolderPath: vi.fn().mockResolvedValue('/mock/jan/data'), + openFileExplorer: vi.fn().mockResolvedValue(undefined), + joinPath: vi.fn((...paths: string[]) => paths.join('/')), + } +} + +// Mock globalThis.fs for @janhq/core fs functions // cspell: disable-line +;(globalThis as Record).fs = { + existsSync: vi.fn().mockResolvedValue(false), + readFile: vi.fn().mockResolvedValue(''), + writeFile: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([]), + mkdir: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +} + + // runs a cleanup after each test case (e.g. clearing jsdom) afterEach(() => { cleanup() diff --git a/web-app/vite.config.web.ts b/web-app/vite.config.web.ts index 48495623d..6f4e271be 100644 --- a/web-app/vite.config.web.ts +++ b/web-app/vite.config.web.ts @@ -64,4 +64,6 @@ export default defineConfig({ port: 3001, strictPort: true, }, + // Enable SPA mode - fallback to index.html for all routes + appType: 'spa', })