Merge pull request #6507 from menloresearch/dev

Sync dev with dev web (google auth)
This commit is contained in:
Dinh Long Nguyen 2025-09-18 11:47:56 +07:00 committed by GitHub
commit 664f304631
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
351 changed files with 1834 additions and 62980 deletions

View File

@ -64,7 +64,7 @@ jobs:
- name: Install Tauri dependencies
run: |
sudo apt update
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 libayatana-appindicator3-dev
- name: Update app version
run: |

View File

@ -101,7 +101,7 @@ jobs:
- name: Install Tauri dependencies
run: |
sudo apt update
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2
sudo apt install -y libglib2.0-dev libatk1.0-dev libpango1.0-dev libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev librsvg2-dev libfuse2 libayatana-appindicator3-dev
- name: Update app version base public_provider
run: |

View File

@ -682,4 +682,18 @@ docs/guides/fine-tuning/what-models-can-be-fine-tuned/ /docs 302
/docs/server-installation/onprem /docs/desktop 302
/docs/server-installation/aws /docs/desktop 302
/docs/server-installation/gcp /docs/desktop 302
/docs/server-installation/azure /docs/desktop 302
/docs/server-installation/azure /docs/desktop 302
/about /docs 302
/api-server /docs/api-server 302
/cdn-cgi/l/email-protection 302
/docs/built-in/tensorrt-llm 302
/docs/desktop/beta /docs 302
/docs/docs/data-folder /docs/data-folder 302
/docs/docs/desktop/linux /docs/desktop/linux 302
/docs/docs/troubleshooting /docs/troubleshooting 302
/docs/local-engines/llama-cpp 302
/docs/models/model-parameters 302
/mcp /docs/mcp 302
/quickstart /docs/quickstart 302
/server-examples/continue-dev /docs/server-examples/continue-dev 302

View File

@ -9,6 +9,15 @@ export { default as ConversationalExtensionWeb } from './conversational-web'
export { default as JanProviderWeb } from './jan-provider-web'
export { default as MCPExtensionWeb } from './mcp-web'
// Re-export auth functionality
export {
JanAuthService,
getSharedAuthService,
AUTH_STORAGE_KEYS,
AUTH_EVENTS,
AUTH_BROADCAST_CHANNEL,
} from './shared/auth'
// Re-export types
export type {
WebExtensionRegistry,
@ -17,12 +26,21 @@ export type {
WebExtensionLoader,
ConversationalWebModule,
JanProviderWebModule,
MCPWebModule
MCPWebModule,
} from './types'
// Re-export auth types
export type {
User,
AuthTokens,
AuthProvider,
AuthProviderRegistry,
ProviderType,
} from './shared/auth'
// Extension registry for dynamic loading
export const WEB_EXTENSIONS: WebExtensionRegistry = {
'conversational-web': () => import('./conversational-web'),
'jan-provider-web': () => import('./jan-provider-web'),
'mcp-web': () => import('./mcp-web'),
}
}

View File

@ -1,219 +0,0 @@
/**
* Shared Authentication Service
* Handles guest login and token refresh for Jan API
*/
// JAN_API_BASE is defined in vite.config.ts
declare const JAN_API_BASE: string
export interface AuthTokens {
access_token: string
expires_in: number
}
export interface AuthResponse {
access_token: string
expires_in: number
}
const AUTH_STORAGE_KEY = 'jan_auth_tokens'
const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry
export class JanAuthService {
private tokens: AuthTokens | null = null
private tokenExpiryTime: number = 0
constructor() {
this.loadTokensFromStorage()
}
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<AuthTokens> {
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<AuthTokens> {
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<string> {
// 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 getAuthHeader(): Promise<{ Authorization: string }> {
const token = await this.getValidAccessToken()
return {
Authorization: `Bearer ${token}`
}
}
async makeAuthenticatedRequest<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
try {
const authHeader = await this.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
}
}
logout(): void {
this.clearTokens()
}
}
declare global {
interface Window {
janAuthService?: JanAuthService
}
}
/**
* Gets or creates the shared JanAuthService instance on the window object
* This ensures all extensions use the same auth service instance
*/
export function getSharedAuthService(): JanAuthService {
if (!window.janAuthService) {
window.janAuthService = new JanAuthService()
}
return window.janAuthService
}

View File

@ -0,0 +1,68 @@
/**
* Generic Authentication API Layer
* Generic API calls for authentication (not provider-specific)
*/
import { AuthTokens } from './types'
import { AUTH_ENDPOINTS } from './const'
declare const JAN_API_BASE: string
/**
* Logout user on server
*/
export async function logoutUser(): Promise<void> {
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.LOGOUT}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
console.warn(`Logout failed with status: ${response.status}`)
}
}
/**
* Guest login
*/
export async function guestLogin(): Promise<AuthTokens> {
const response = await fetch(`${JAN_API_BASE}${AUTH_ENDPOINTS.GUEST_LOGIN}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(
`Guest login failed: ${response.status} ${response.statusText}`
)
}
return response.json() as Promise<AuthTokens>
}
/**
* Refresh token (works for both guest and authenticated users)
*/
export async function refreshToken(): Promise<AuthTokens> {
const response = await fetch(
`${JAN_API_BASE}${AUTH_ENDPOINTS.REFRESH_TOKEN}`,
{
method: 'GET',
credentials: 'include',
}
)
if (!response.ok) {
throw new Error(
`Token refresh failed: ${response.status} ${response.statusText}`
)
}
return response.json() as Promise<AuthTokens>
}

View File

@ -0,0 +1,92 @@
/**
* Authentication Broadcast Channel Handler
* Manages cross-tab communication for auth state changes
*/
import { AUTH_BROADCAST_CHANNEL, AUTH_EVENTS } from './const'
import type { AuthBroadcastMessage } from './types'
export class AuthBroadcast {
private broadcastChannel: BroadcastChannel | null = null
constructor() {
this.setupBroadcastChannel()
}
/**
* Setup broadcast channel for cross-tab sync
*/
private setupBroadcastChannel(): void {
if (typeof BroadcastChannel !== 'undefined') {
try {
this.broadcastChannel = new BroadcastChannel(AUTH_BROADCAST_CHANNEL)
} catch (error) {
console.warn('BroadcastChannel not available:', error)
}
}
}
/**
* Broadcast auth event to other tabs
*/
broadcastEvent(type: AuthBroadcastMessage): void {
if (this.broadcastChannel) {
try {
const message = { type }
this.broadcastChannel.postMessage(message)
} catch (error) {
console.warn('Failed to broadcast auth event:', error)
}
}
}
/**
* Broadcast login event
*/
broadcastLogin(): void {
this.broadcastEvent(AUTH_EVENTS.LOGIN)
}
/**
* Broadcast logout event
*/
broadcastLogout(): void {
this.broadcastEvent(AUTH_EVENTS.LOGOUT)
}
/**
* Subscribe to auth events
*/
onAuthEvent(
listener: (event: MessageEvent<{ type: AuthBroadcastMessage }>) => void
): () => void {
if (this.broadcastChannel) {
this.broadcastChannel.addEventListener('message', listener)
// Return cleanup function
return () => {
this.broadcastChannel?.removeEventListener('message', listener)
}
}
// Return no-op cleanup if no broadcast channel
return () => {}
}
/**
* Get the broadcast channel for external listeners
*/
getBroadcastChannel(): BroadcastChannel | null {
return this.broadcastChannel
}
/**
* Cleanup broadcast channel
*/
destroy(): void {
if (this.broadcastChannel) {
this.broadcastChannel.close()
this.broadcastChannel = null
}
}
}

View File

@ -0,0 +1,29 @@
/**
* Authentication Constants and Configuration
* Generic constants used across all auth providers
*/
// Storage keys
export const AUTH_STORAGE_KEYS = {
AUTH_PROVIDER: 'jan_auth_provider',
} as const
// Generic API endpoints (provider-agnostic)
export const AUTH_ENDPOINTS = {
ME: '/auth/me',
LOGOUT: '/auth/logout',
GUEST_LOGIN: '/auth/guest-login',
REFRESH_TOKEN: '/auth/refresh-token',
} as const
// Token expiry buffer
export const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before expiry
// Broadcast channel for cross-tab communication
export const AUTH_BROADCAST_CHANNEL = 'jan_auth_channel'
// Auth events
export const AUTH_EVENTS = {
LOGIN: 'auth:login',
LOGOUT: 'auth:logout',
} as const

View File

@ -0,0 +1,10 @@
// Main exports
export { JanAuthService, getSharedAuthService } from './service'
export { AuthProviderRegistry } from './registry'
// Type exports
export type { AuthTokens, AuthType, User } from './types'
export type { AuthProvider, ProviderType } from './providers'
// Constant exports
export { AUTH_STORAGE_KEYS, AUTH_EVENTS, AUTH_BROADCAST_CHANNEL } from './const'

View File

@ -0,0 +1,49 @@
/**
* Provider-specific API Layer
* API calls specific to authentication providers
*/
import { AuthTokens, LoginUrlResponse } from './types'
declare const JAN_API_BASE: string
export async function getLoginUrl(endpoint: string): Promise<LoginUrlResponse> {
const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(
`Failed to get login URL: ${response.status} ${response.statusText}`
)
}
return response.json() as Promise<LoginUrlResponse>
}
export async function handleOAuthCallback(
endpoint: string,
code: string,
state?: string
): Promise<AuthTokens> {
const response: Response = await fetch(`${JAN_API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ code, state }),
})
if (!response.ok) {
throw new Error(
`OAuth callback failed: ${response.status} ${response.statusText}`
)
}
return response.json() as Promise<AuthTokens>
}

View File

@ -0,0 +1,39 @@
/**
* Base Auth Provider
* Abstract base class that all providers should extend
*/
import { AuthProvider, AuthTokens } from './types'
import { getLoginUrl, handleOAuthCallback } from './api'
export abstract class BaseAuthProvider implements AuthProvider {
abstract readonly id: string
abstract readonly name: string
abstract readonly icon: string
abstract getLoginEndpoint(): string
abstract getCallbackEndpoint(): string
async initiateLogin(): Promise<void> {
try {
// Fetch login URL from API
const data = await getLoginUrl(this.getLoginEndpoint())
// Redirect to the OAuth URL provided by the API
window.location.href = data.url
} catch (error) {
console.error(`Failed to initiate ${this.id} login:`, error)
throw error
}
}
async handleCallback(code: string, state?: string): Promise<AuthTokens> {
try {
// Handle OAuth callback and return token data
return await handleOAuthCallback(this.getCallbackEndpoint(), code, state)
} catch (error) {
console.error(`${this.name} callback handling failed:`, error)
throw error
}
}
}

View File

@ -0,0 +1,20 @@
/**
* Google Auth Provider
* Specific implementation for Google OAuth
*/
import { BaseAuthProvider } from './base'
export class GoogleAuthProvider extends BaseAuthProvider {
readonly id = 'google'
readonly name = 'Google'
readonly icon = 'IconBrandGoogleFilled'
getLoginEndpoint(): string {
return '/auth/google/login'
}
getCallbackEndpoint(): string {
return '/auth/google/callback'
}
}

View File

@ -0,0 +1,19 @@
/**
* Auth Providers Export
* Central place to register and export all available providers
*/
export { BaseAuthProvider } from './base'
export { GoogleAuthProvider } from './google'
// Registry of all available providers
import { GoogleAuthProvider } from './google'
// Instantiate providers
export const PROVIDERS = [new GoogleAuthProvider()] as const
// Generate proper types from providers
export type ProviderType = (typeof PROVIDERS)[number]['id']
// Export types
export type { AuthProvider } from './types'

View File

@ -0,0 +1,28 @@
/**
* Provider Type Definitions
* Interfaces and types for authentication providers
*/
import { AuthTokens } from '../types'
export { AuthTokens } from '../types'
// Login URL response from API
export interface LoginUrlResponse {
object: string
url: string
}
// Provider interface - all providers must implement this
export interface AuthProvider {
readonly id: string
readonly name: string
readonly icon: string
// Provider-specific configuration
getLoginEndpoint(): string
getCallbackEndpoint(): string
// OAuth flow methods
initiateLogin(): Promise<void>
handleCallback(code: string, state?: string): Promise<AuthTokens>
}

View File

@ -0,0 +1,25 @@
/**
* Dynamic Auth Provider Registry
* Provider-agnostic registry that can be extended at runtime
*/
import { PROVIDERS, type AuthProvider, type ProviderType } from './providers'
export class AuthProviderRegistry {
private providers = new Map<ProviderType, AuthProvider>()
constructor() {
// Register all available providers on initialization
for (const provider of PROVIDERS) {
this.providers.set(provider.id, provider)
}
}
getProvider(providerId: ProviderType): AuthProvider | undefined {
return this.providers.get(providerId)
}
getAllProviders(): AuthProvider[] {
return Array.from(this.providers.values())
}
}

View File

@ -0,0 +1,424 @@
/**
* Generic Authentication Service
* Handles authentication flows for any OAuth provider
*/
declare const JAN_API_BASE: string
import { User, AuthState, AuthBroadcastMessage } from './types'
import {
AUTH_STORAGE_KEYS,
AUTH_ENDPOINTS,
TOKEN_EXPIRY_BUFFER,
AUTH_EVENTS,
} from './const'
import { logoutUser, refreshToken, guestLogin } from './api'
import { AuthProviderRegistry } from './registry'
import { AuthBroadcast } from './broadcast'
import type { ProviderType } from './providers'
const authProviderRegistry = new AuthProviderRegistry()
export class JanAuthService {
private accessToken: string | null = null
private tokenExpiryTime: number = 0
private refreshPromise: Promise<void> | null = null
private authBroadcast: AuthBroadcast
private currentUser: User | null = null
private initPromise: Promise<void> | null = null
constructor() {
this.authBroadcast = new AuthBroadcast()
this.setupBroadcastHandlers()
this.initPromise = this.initialize().catch(console.error)
}
/**
* Ensure initialization is complete before proceeding
*/
private async ensureInitialized(): Promise<void> {
if (this.initPromise) {
await this.initPromise
this.initPromise = null
}
}
/**
* Initialize the auth service
* Called on app load to check existing session
*/
async initialize(): Promise<void> {
try {
if (!this.isAuthenticated()) {
// Not authenticated - ensure guest access
await this.ensureGuestAccess()
return
}
// Authenticated - ensure we have a valid token
await this.refreshAccessToken()
} catch (error) {
console.error('Failed to initialize auth:', error)
}
}
/**
* Start OAuth login flow with specified provider
*/
async loginWithProvider(providerId: ProviderType): Promise<void> {
await this.ensureInitialized()
const provider = authProviderRegistry.getProvider(providerId)
if (!provider) {
throw new Error(`Provider ${providerId} is not available`)
}
try {
await provider.initiateLogin()
} catch (error) {
console.error(`Failed to initiate ${providerId} login:`, error)
throw error
}
}
/**
* Handle OAuth callback for any provider
*/
async handleProviderCallback(
providerId: ProviderType,
code: string,
state?: string
): Promise<void> {
await this.ensureInitialized()
const provider = authProviderRegistry.getProvider(providerId)
if (!provider) {
throw new Error(`Provider ${providerId} is not supported`)
}
try {
// Use provider to handle the callback - this returns tokens
const tokens = await provider.handleCallback(code, state)
// Store tokens and set authenticated state
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.setAuthProvider(providerId)
this.authBroadcast.broadcastLogin()
} catch (error) {
console.error(`Failed to handle ${providerId} callback:`, error)
throw error
}
}
/**
* Get a valid access token
* Handles both authenticated and guest tokens
*/
async getValidAccessToken(): Promise<string> {
await this.ensureInitialized()
if (
this.accessToken &&
Date.now() < this.tokenExpiryTime - TOKEN_EXPIRY_BUFFER
) {
return this.accessToken
}
if (!this.refreshPromise) {
this.refreshPromise = this.refreshAccessToken().finally(() => {
this.refreshPromise = null
})
}
await this.refreshPromise
if (!this.accessToken) {
throw new Error('Failed to obtain access token')
}
return this.accessToken
}
async refreshAccessToken(): Promise<void> {
try {
const tokens = await refreshToken()
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
} catch (error) {
console.error('Failed to refresh access token:', error)
if (error instanceof Error && error.message.includes('401')) {
await this.handleSessionExpired()
}
throw error
}
}
/**
* Get current authenticated user
*/
async getCurrentUser(): Promise<User | null> {
await this.ensureInitialized()
const authType = this.getAuthState()
if (authType !== AuthState.AUTHENTICATED) {
return null
}
if (this.currentUser) {
return this.currentUser
}
const userProfile = await this.fetchUserProfile()
if (userProfile) {
const user: User = {
id: userProfile.id,
email: userProfile.email,
name: userProfile.name,
picture: userProfile.picture,
object: userProfile.object || 'user',
}
this.currentUser = user
}
return this.currentUser
}
/**
* Logout the current user
*/
async logout(): Promise<void> {
await this.ensureInitialized()
try {
const authType = this.getAuthState()
if (authType === AuthState.AUTHENTICATED) {
await logoutUser()
}
this.clearAuthState()
this.authBroadcast.broadcastLogout()
if (window.location.pathname !== '/') {
window.location.href = '/'
}
} catch (error) {
console.error('Logout failed:', error)
this.clearAuthState()
}
}
/**
* Get enabled authentication providers
*/
getAllProviders(): Array<{ id: string; name: string; icon: string }> {
return authProviderRegistry.getAllProviders().map((provider) => ({
id: provider.id,
name: provider.name,
icon: provider.icon,
}))
}
/**
* Check if user is authenticated with any provider
*/
isAuthenticated(): boolean {
return this.getAuthState() === AuthState.AUTHENTICATED
}
/**
* Check if user is authenticated with specific provider
*/
isAuthenticatedWithProvider(providerId: ProviderType): boolean {
const authType = this.getAuthState()
if (authType !== AuthState.AUTHENTICATED) {
return false
}
return this.getAuthProvider() === providerId
}
/**
* Get current auth type derived from provider
*/
getAuthState(): AuthState {
const provider = this.getAuthProvider()
if (!provider) return AuthState.UNAUTHENTICATED
if (provider === 'guest') return AuthState.GUEST
return AuthState.AUTHENTICATED
}
/**
* Get auth headers for API requests
*/
async getAuthHeader(): Promise<{ Authorization: string }> {
await this.ensureInitialized()
const token = await this.getValidAccessToken()
return {
Authorization: `Bearer ${token}`,
}
}
/**
* Make authenticated API request
*/
async makeAuthenticatedRequest<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
await this.ensureInitialized()
try {
const authHeader = await this.getAuthHeader()
const response = await fetch(url, {
...options,
credentials: 'include',
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
}
}
/**
* Get the broadcast channel for external listeners
*/
getBroadcastChannel(): BroadcastChannel | null {
return this.authBroadcast.getBroadcastChannel()
}
/**
* Subscribe to auth events
*/
onAuthEvent(
callback: (event: MessageEvent<{ type: AuthBroadcastMessage }>) => void
): () => void {
return this.authBroadcast.onAuthEvent(callback)
}
/**
* Clear all auth state
*/
private clearAuthState(): void {
this.accessToken = null
this.tokenExpiryTime = 0
this.currentUser = null
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
}
/**
* Ensure guest access is available
*/
private async ensureGuestAccess(): Promise<void> {
try {
this.setAuthProvider('guest')
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
const tokens = await guestLogin()
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
}
} catch (error) {
console.error('Failed to ensure guest access:', error)
// Remove provider (unauthenticated state)
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
}
}
/**
* Handle session expired
*/
private async handleSessionExpired(): Promise<void> {
this.logout().catch(console.error)
this.ensureGuestAccess().catch(console.error)
}
/**
* Setup broadcast event handlers
*/
private setupBroadcastHandlers(): void {
this.authBroadcast.onAuthEvent((event) => {
switch (event.data.type) {
case AUTH_EVENTS.LOGIN:
// Another tab logged in, refresh our state
this.initialize().catch(console.error)
break
case AUTH_EVENTS.LOGOUT:
// Another tab logged out, clear our state
this.clearAuthState()
this.ensureGuestAccess().catch(console.error)
break
}
})
}
/**
* Get current auth provider
*/
getAuthProvider(): string | null {
return localStorage.getItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
}
/**
* Set auth provider
*/
private setAuthProvider(provider: string): void {
localStorage.setItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER, provider)
}
/**
* Fetch user profile from server
*/
private async fetchUserProfile(): Promise<User | null> {
try {
return await this.makeAuthenticatedRequest<User>(
`${JAN_API_BASE}${AUTH_ENDPOINTS.ME}`
)
} catch (error) {
console.error('Failed to fetch user profile:', error)
if (error instanceof Error && error.message.includes('401')) {
// Authentication failed - handle session expiry
await this.handleSessionExpired()
return null
}
return null
}
}
}
// Singleton instance management
declare global {
interface Window {
janAuthService?: JanAuthService
}
}
/**
* Get or create the shared JanAuthService instance
*/
export function getSharedAuthService(): JanAuthService {
if (!window.janAuthService) {
window.janAuthService = new JanAuthService()
}
return window.janAuthService
}

View File

@ -0,0 +1,31 @@
/**
* Generic Authentication Types
* Provider-agnostic type definitions
*/
import type { ProviderType } from './providers'
import { AUTH_EVENTS } from './const'
export enum AuthState {
GUEST = 'guest',
AUTHENTICATED = 'authenticated',
UNAUTHENTICATED = 'unauthenticated',
}
export type AuthType = ProviderType | 'guest'
export interface AuthTokens {
access_token: string
expires_in: number
object: string
}
export interface User {
id: string
email: string
name: string
object: string
picture?: string
}
export type AuthBroadcastMessage = typeof AUTH_EVENTS[keyof typeof AUTH_EVENTS]

View File

@ -1,3 +1,3 @@
export { getSharedDB } from './db'
export { JanAuthService, getSharedAuthService } from './auth'
export type { AuthTokens, AuthResponse } from './auth'
export * from './auth'

View File

@ -517,41 +517,41 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
languageName: node
linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
languageName: node
linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
languageName: node
linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=c5357d&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=fcb200&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
checksum: 10c0/95e2ec1f1213d604730f5c9c381c80840402b00a9649039d1a9754ee3efa13e224e4ca39ea094aab5751f3f2ace1860c7640769e66b191b8c56998fd5f2ba5b9
checksum: 10c0/603e79794614f861a9cf5693a4bbc480a62242309a6cb94a97c81e31518032c7462b2edad93a4380a18110f817ab15d85dc91a7924fd6e103a3462f6915ee368
languageName: node
linkType: hard

View File

@ -1,4 +1,5 @@
[env]
# workaround needed to prevent `STATUS_ENTRYPOINT_NOT_FOUND` error in tests
# see https://github.com/tauri-apps/tauri/pull/4383#issuecomment-1212221864
__TAURI_WORKSPACE__ = "true"
__TAURI_WORKSPACE__ = "true"
ENABLE_SYSTEM_TRAY_ICON = "false"

View File

@ -20,6 +20,7 @@ default = [
"tauri/x11",
"tauri/protocol-asset",
"tauri/macos-private-api",
"tauri/tray-icon",
"tauri/test",
]
test-tauri = [
@ -27,6 +28,7 @@ test-tauri = [
"tauri/x11",
"tauri/protocol-asset",
"tauri/macos-private-api",
"tauri/tray-icon",
"tauri/test",
]

View File

@ -296,7 +296,6 @@ pub async fn cancel_tool_call(
pub async fn get_mcp_configs(app: AppHandle) -> Result<String, String> {
let mut path = get_jan_data_folder_path(app);
path.push("mcp_config.json");
log::info!("read mcp configs, path: {:?}", path);
// Create default empty config if file doesn't exist
if !path.exists() {

View File

@ -8,6 +8,12 @@ pub const MCP_BACKOFF_MULTIPLIER: f64 = 2.0; // Double the delay each time
pub const DEFAULT_MCP_CONFIG: &str = r#"{
"mcpServers": {
"exa": {
"command": "npx",
"args": ["-y", "exa-mcp-server"],
"env": { "EXA_API_KEY": "YOUR_EXA_API_KEY_HERE" },
"active": false
},
"browsermcp": {
"command": "npx",
"args": ["@browsermcp/mcp"],

View File

@ -5,7 +5,11 @@ use std::{
path::PathBuf,
};
use tar::Archive;
use tauri::{App, Emitter, Manager};
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
App, Emitter, Manager,
};
use tauri_plugin_store::StoreExt;
// use tokio::sync::Mutex;
// use tokio::time::{sleep, Duration}; // Using tokio::sync::Mutex
@ -82,7 +86,6 @@ pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), Stri
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "tgz") {
log::info!("Installing extension from {:?}", path);
let tar_gz = File::open(&path).map_err(|e| e.to_string())?;
let gz_decoder = GzDecoder::new(tar_gz);
let mut archive = Archive::new(gz_decoder);
@ -207,3 +210,46 @@ pub fn setup_mcp(app: &App) {
.unwrap();
});
}
pub fn setup_tray(app: &App) -> tauri::Result<TrayIcon> {
let show_i = MenuItem::with_id(app.handle(), "open", "Open Jan", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app.handle(), "quit", "Quit", true, None::<&str>)?;
let separator_i = PredefinedMenuItem::separator(app.handle())?;
let menu = Menu::with_items(app.handle(), &[&show_i, &separator_i, &quit_i])?;
TrayIconBuilder::with_id("tray")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
// let's show and focus the main window when the tray is clicked
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {
log::debug!("unhandled event {event:?}");
}
})
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
let window = app.get_webview_window("main").unwrap();
window.show().unwrap();
window.set_focus().unwrap();
}
"quit" => {
app.exit(0);
}
other => {
println!("menu item {} not handled", other);
}
})
.build(app)
}

View File

@ -8,10 +8,12 @@ use core::{
};
use jan_utils::generate_app_token;
use std::{collections::HashMap, sync::Arc};
use tauri::{Emitter, Manager, RunEvent};
use tauri::{Manager, RunEvent};
use tauri_plugin_llamacpp::cleanup_llama_processes;
use tokio::sync::Mutex;
use crate::core::setup::setup_tray;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default();
@ -108,6 +110,21 @@ pub fn run() {
server_handle: Arc::new(Mutex::new(None)),
tool_call_cancellations: Arc::new(Mutex::new(HashMap::new())),
})
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
if option_env!("ENABLE_SYSTEM_TRAY_ICON").unwrap_or("false") == "true" {
#[cfg(target_os = "macos")]
window
.app_handle()
.set_activation_policy(tauri::ActivationPolicy::Accessory)
.unwrap();
window.hide().unwrap();
api.prevent_close();
}
}
_ => {}
})
.setup(|app| {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@ -129,6 +146,11 @@ pub fn run() {
log::error!("Failed to install extensions: {}", e);
}
if option_env!("ENABLE_SYSTEM_TRAY_ICON").unwrap_or("false") == "true" {
log::info!("Enabling system tray icon");
let _ = setup_tray(app);
}
#[cfg(any(windows, target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
@ -146,14 +168,12 @@ pub fn run() {
// This is called when the app is actually exiting (e.g., macOS dock quit)
// We can't prevent this, so run cleanup quickly
let app_handle = app.clone();
// Hide window immediately
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
tokio::task::block_in_place(|| {
tauri::async_runtime::block_on(async {
// Hide window immediately
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
let _ = window.emit("kill-mcp-servers", ());
}
// Quick cleanup with shorter timeout
let state = app_handle.state::<AppState>();
let _ = clean_up_mcp_servers(state).await;

View File

@ -24,6 +24,7 @@
"@jan/extensions-web": "link:../extensions-web",
"@janhq/core": "link:../core",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",

View File

@ -0,0 +1,48 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
const Avatar = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ComponentRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,79 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -21,6 +21,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import { AuthLoginButton } from '@/containers/auth/AuthLoginButton'
import { UserProfileMenu } from '@/containers/auth/UserProfileMenu'
import { useAuth } from '@/hooks/useAuth'
import { useThreads } from '@/hooks/useThreads'
@ -31,8 +36,6 @@ import { DownloadManagement } from '@/containers/DownloadManegement'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import { useClickOutside } from '@/hooks/useClickOutside'
import { useDownloadStore } from '@/hooks/useDownloadStore'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
const mainMenus = [
@ -60,12 +63,19 @@ const mainMenus = [
route: route.settings.general,
isEnabled: true,
},
{
title: 'common:authentication',
icon: null,
route: null,
isEnabled: PlatformFeatures[PlatformFeature.AUTHENTICATION],
},
]
const LeftPanel = () => {
const { open, setLeftPanel } = useLeftPanel()
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState('')
const { isAuthenticated } = useAuth()
const isSmallScreen = useSmallScreen()
const prevScreenSizeRef = useRef<boolean | null>(null)
@ -413,8 +423,25 @@ const LeftPanel = () => {
<div className="space-y-1 shrink-0 py-1 mt-2">
{mainMenus.map((menu) => {
if (!menu.isEnabled) {
return null
return null
}
// Handle authentication menu specially
if (menu.title === 'common:authentication') {
return (
<div key={menu.title}>
{isAuthenticated ? (
<UserProfileMenu />
) : (
<AuthLoginButton />
)}
</div>
)
}
// Regular menu items must have route and icon
if (!menu.route || !menu.icon) return null
const isActive =
currentPath.includes(route.settings.index) &&
menu.route.includes(route.settings.index)

View File

@ -0,0 +1,84 @@
/**
* Auth Login Button with Dropdown Menu
* Shows available authentication providers in a dropdown menu
*/
import { useState } from 'react'
import { IconLogin, IconBrandGoogleFilled } from '@tabler/icons-react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useAuth } from '@/hooks/useAuth'
import { toast } from 'sonner'
import type { ProviderType } from '@jan/extensions-web'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export const AuthLoginButton = () => {
const { t } = useTranslation()
const { getAllProviders, loginWithProvider } = useAuth()
const [isLoading, setIsLoading] = useState(false)
const enabledProviders = getAllProviders()
const handleProviderLogin = async (providerId: ProviderType) => {
try {
setIsLoading(true)
await loginWithProvider(providerId)
} catch (error) {
console.error('Failed to login with provider:', error)
toast.error(t('common:loginFailed'))
} finally {
setIsLoading(false)
}
}
const getProviderIcon = (iconName: string) => {
switch (iconName) {
case 'IconBrandGoogleFilled':
return IconBrandGoogleFilled
default:
return IconLogin
}
}
if (enabledProviders.length === 0) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={isLoading}
className="flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded w-full"
>
<IconLogin size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">{t('common:login')}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48">
{enabledProviders.map((provider) => {
const IconComponent = getProviderIcon(provider.icon)
return (
<DropdownMenuItem
key={provider.id}
onClick={() => handleProviderLogin(provider.id as ProviderType)}
disabled={isLoading}
className="gap-2"
>
<IconComponent size={16} />
<span className="text-sm text-left-panel-fg/90">
{t('common:loginWith', {
provider: provider.name,
})}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,104 @@
/**
* User Profile Menu Container
* Dropdown menu with user profile and logout options
*/
import { useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { IconUser, IconLogout, IconChevronDown } from '@tabler/icons-react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useAuth } from '@/hooks/useAuth'
import { toast } from 'sonner'
export const UserProfileMenu = () => {
const { t } = useTranslation()
const { user, isLoading, logout } = useAuth()
const [isLoggingOut, setIsLoggingOut] = useState(false)
const handleLogout = async () => {
if (isLoggingOut) return
try {
setIsLoggingOut(true)
await logout()
toast.success(t('common:loggedOut'))
} catch (error) {
console.error('Failed to logout:', error)
toast.error(t('common:logoutFailed'))
} finally {
setIsLoggingOut(false)
}
}
if (isLoading || !user) {
return null
}
const getInitials = (name: string) => {
const parts = name.split(' ')
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="sm"
className="w-full justify-between gap-2 px-2"
>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
{user.picture && (
<AvatarImage src={user.picture} alt={user.name} />
)}
<AvatarFallback className="text-xs">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<span className="truncate text-sm">{user.name}</span>
</div>
<IconChevronDown size={14} className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2">
<IconUser size={16} />
<span>{t('common:profile')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
disabled={isLoggingOut}
className="gap-2 text-destructive focus:text-destructive"
>
<IconLogout size={16} />
<span>
{isLoggingOut ? t('common:loggingOut') : t('common:logout')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,226 @@
import { create } from 'zustand'
import {
type User,
type ProviderType,
JanAuthService,
} from '@jan/extensions-web'
import { PlatformFeature } from '@/lib/platform/types'
import { PlatformFeatures } from '@/lib/platform/const'
interface AuthState {
// Auth service
authService: JanAuthService | null
setAuthService: (authService: JanAuthService | null) => void
// Auth state
isAuthenticated: boolean
user: User | null
isLoading: boolean
// State setters
setUser: (user: User | null) => void
setIsLoading: (isLoading: boolean) => void
// Multi-provider auth actions
getAllProviders: () => Array<{ id: string; name: string; icon: string }>
loginWithProvider: (providerId: ProviderType) => Promise<void>
handleProviderCallback: (
providerId: ProviderType,
code: string,
state?: string
) => Promise<void>
isAuthenticatedWithProvider: (providerId: ProviderType) => boolean
// Auth actions
logout: () => Promise<void>
getCurrentUser: () => Promise<User | null>
loadAuthState: () => Promise<void>
subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => () => void
// Platform feature check
isAuthenticationEnabled: boolean
}
const useAuthStore = create<AuthState>()((set, get) => ({
// Auth service
authService: null,
setAuthService: (authService: JanAuthService | null) => set({ authService }),
// Auth state
isAuthenticated: false,
user: null,
isLoading: true,
// Platform feature check
isAuthenticationEnabled:
PlatformFeatures[PlatformFeature.AUTHENTICATION] || false,
// State setters
setUser: (user: User | null) =>
set(() => ({
user,
isAuthenticated: user !== null,
})),
setIsLoading: (isLoading: boolean) => set({ isLoading }),
// Multi-provider auth actions
getAllProviders: () => {
const { authService } = get()
if (!authService) {
return []
}
return authService.getAllProviders()
},
loginWithProvider: async (providerId: ProviderType) => {
const { authService, isAuthenticationEnabled } = get()
if (!isAuthenticationEnabled || !authService) {
throw new Error('Authentication not available on this platform')
}
await authService.loginWithProvider(providerId)
},
handleProviderCallback: async (
providerId: ProviderType,
code: string,
state?: string
) => {
const { authService, isAuthenticationEnabled, loadAuthState } = get()
if (!isAuthenticationEnabled || !authService) {
throw new Error('Authentication not available on this platform')
}
await authService.handleProviderCallback(providerId, code, state)
// Reload auth state after successful callback
await loadAuthState()
},
isAuthenticatedWithProvider: (providerId: ProviderType) => {
const { authService } = get()
if (!authService) {
return false
}
return authService.isAuthenticatedWithProvider(providerId)
},
logout: async () => {
const { authService, isAuthenticationEnabled } = get()
if (!isAuthenticationEnabled || !authService) {
throw new Error('Authentication not available on this platform')
}
await authService.logout()
// Update state after logout
set({
user: null,
isAuthenticated: false,
})
},
getCurrentUser: async (): Promise<User | null> => {
const { authService, isAuthenticationEnabled } = get()
if (!isAuthenticationEnabled || !authService) {
return null
}
try {
const profile = await authService.getCurrentUser()
set({
user: profile,
isAuthenticated: profile !== null,
})
return profile
} catch (error) {
console.error('Failed to get current user:', error)
return null
}
},
loadAuthState: async () => {
const { authService, isAuthenticationEnabled } = get()
if (!isAuthenticationEnabled || !authService) {
set({ isLoading: false })
return
}
try {
set({ isLoading: true })
// Check if user is authenticated with any provider
const isAuth = authService.isAuthenticated()
// Load user profile if authenticated
if (isAuth) {
const profile = await authService.getCurrentUser()
set({
user: profile,
isAuthenticated: profile !== null,
})
} else {
set({
user: null,
isAuthenticated: false,
})
}
} catch (error) {
console.error('Failed to load auth state:', error)
set({
user: null,
isAuthenticated: false,
})
} finally {
set({ isLoading: false })
}
},
subscribeToAuthEvents: (callback: (event: MessageEvent) => void) => {
const { authService } = get()
if (!authService || typeof authService.onAuthEvent !== 'function') {
return () => {} // Return no-op cleanup
}
try {
return authService.onAuthEvent(callback)
} catch (error) {
console.warn('Failed to subscribe to auth events:', error)
return () => {}
}
},
}))
/**
* Hook to get auth state and actions for React components
*/
export const useAuth = () => {
const authState = useAuthStore()
return authState
}
/**
* Global function to get auth store for non-React contexts
*/
export const getAuthStore = () => {
return useAuthStore.getState()
}
/**
* Initialize the auth service in the store
* This should only be called from the AuthProvider after service initialization
*/
export const initializeAuthStore = async (authService: JanAuthService) => {
const store = useAuthStore.getState()
store.setAuthService(authService)
// Load initial auth state
await store.loadAuthState()
}
/**
* Check if auth service is initialized
*/
export const isAuthServiceInitialized = (): boolean => {
return useAuthStore.getState().authService !== null
}

View File

@ -52,4 +52,7 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
// Assistant functionality - disabled for web
[PlatformFeature.ASSISTANTS]: isPlatformTauri(),
// Authentication (Google OAuth) - enabled for web only
[PlatformFeature.AUTHENTICATION]: !isPlatformTauri(),
}

View File

@ -54,4 +54,7 @@ export enum PlatformFeature {
// Assistant functionality (creation, editing, management)
ASSISTANTS = 'assistants',
// Authentication (Google OAuth, user profiles)
AUTHENTICATION = 'authentication',
}

View File

@ -27,6 +27,14 @@
"dataFolder": "Daten Ordner",
"others": "Andere",
"language": "Sprache",
"login": "Anmelden",
"loginWith": "Anmelden mit {{provider}}",
"loginFailed": "Anmeldung fehlgeschlagen",
"logout": "Abmelden",
"loggingOut": "Melde ab...",
"loggedOut": "Erfolgreich abgemeldet",
"logoutFailed": "Abmeldung fehlgeschlagen",
"profile": "Profil",
"reset": "Zurücksetzen",
"search": "Suchen",
"name": "Name",

View File

@ -27,6 +27,14 @@
"dataFolder": "Data Folder",
"others": "Other",
"language": "Language",
"login": "Log In",
"loginWith": "Log In With {{provider}}",
"loginFailed": "Failed To Log In",
"logout": "Log Out",
"loggingOut": "Logging Out...",
"loggedOut": "Successfully Logged Out",
"logoutFailed": "Failed To Log Out",
"profile": "Profile",
"reset": "Reset",
"search": "Search",
"name": "Name",

View File

@ -27,6 +27,14 @@
"dataFolder": "Folder Data",
"others": "Lainnya",
"language": "Bahasa",
"login": "Masuk",
"loginWith": "Masuk Dengan {{provider}}",
"loginFailed": "Gagal Masuk",
"logout": "Keluar",
"loggingOut": "Sedang Keluar...",
"loggedOut": "Berhasil Keluar",
"logoutFailed": "Gagal Keluar",
"profile": "Profil",
"reset": "Setel Ulang",
"search": "Cari",
"name": "Nama",

View File

@ -27,6 +27,14 @@
"dataFolder": "Katalog Danych",
"others": "Inne",
"language": "Język",
"login": "Zaloguj",
"loginWith": "Zaloguj Przez {{provider}}",
"loginFailed": "Logowanie Nie Powiodło Się",
"logout": "Wyloguj",
"loggingOut": "Wylogowywanie...",
"loggedOut": "Pomyślnie Wylogowano",
"logoutFailed": "Wylogowanie Nie Powiodło Się",
"profile": "Profil",
"reset": "Przywróć",
"search": "Szukaj",
"name": "Nazwa",

View File

@ -27,6 +27,14 @@
"dataFolder": "Thư mục Dữ liệu",
"others": "Khác",
"language": "Ngôn ngữ",
"login": "Đăng Nhập",
"loginWith": "Đăng Nhập Bằng {{provider}}",
"loginFailed": "Đăng Nhập Thất Bại",
"logout": "Đăng Xuất",
"loggingOut": "Đang Đăng Xuất...",
"loggedOut": "Đăng Xuất Thành Công",
"logoutFailed": "Đăng Xuất Thất Bại",
"profile": "Hồ Sơ",
"reset": "Đặt lại",
"search": "Tìm kiếm",
"name": "Tên",

View File

@ -27,6 +27,14 @@
"dataFolder": "数据文件夹",
"others": "其他",
"language": "语言",
"login": "登录",
"loginWith": "使用{{provider}}登录",
"loginFailed": "登录失败",
"logout": "退出登录",
"loggingOut": "正在退出...",
"loggedOut": "成功退出登录",
"logoutFailed": "退出登录失败",
"profile": "个人资料",
"reset": "重置",
"search": "搜索",
"name": "名称",

View File

@ -27,6 +27,14 @@
"dataFolder": "資料夾",
"others": "其他",
"language": "語言",
"login": "登入",
"loginWith": "使用{{provider}}登入",
"loginFailed": "登入失敗",
"logout": "登出",
"loggingOut": "正在登出...",
"loggedOut": "成功登出",
"logoutFailed": "登出失敗",
"profile": "個人資料",
"reset": "重設",
"search": "搜尋",
"name": "名稱",

View File

@ -0,0 +1,69 @@
/**
* Authentication Provider
* Initializes the auth service and sets up event listeners
*/
import { useEffect, useState, ReactNode } from 'react'
import { PlatformFeature } from '@/lib/platform/types'
import { PlatformFeatures } from '@/lib/platform/const'
import { initializeAuthStore, getAuthStore } from '@/hooks/useAuth'
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [isReady, setIsReady] = useState(false)
// Check if authentication is enabled for this platform
const isAuthenticationEnabled =
PlatformFeatures[PlatformFeature.AUTHENTICATION]
useEffect(() => {
if (!isAuthenticationEnabled) {
setIsReady(true)
return
}
const initializeAuth = async () => {
try {
console.log('Initializing auth service...')
const { getSharedAuthService } = await import('@jan/extensions-web')
const authService = getSharedAuthService()
await initializeAuthStore(authService)
console.log('Auth service initialized successfully')
setIsReady(true)
} catch (error) {
console.error('Failed to initialize auth service:', error)
setIsReady(true) // Still render to show error state
}
}
initializeAuth()
}, [isAuthenticationEnabled])
// Listen for auth state changes across tabs
useEffect(() => {
if (!isAuthenticationEnabled) return
const handleAuthEvent = (event: MessageEvent) => {
// Listen for all auth events, not just login/logout
if (event.data?.type?.startsWith('auth:')) {
const authStore = getAuthStore()
authStore.loadAuthState()
}
}
// Use the auth store's subscribeToAuthEvents method
const authStore = getAuthStore()
const cleanupAuthListener = authStore.subscribeToAuthEvents(handleAuthEvent)
return () => {
cleanupAuthListener()
}
}, [isAuthenticationEnabled])
return <>{isReady && children}</>
}

View File

@ -30,6 +30,7 @@ import { Route as LocalApiServerLogsImport } from './routes/local-api-server/log
import { Route as HubModelIdImport } from './routes/hub/$modelId'
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
import { Route as AuthGoogleCallbackImport } from './routes/auth.google.callback'
// Create/Update Routes
@ -148,6 +149,12 @@ const SettingsProvidersProviderNameRoute =
getParentRoute: () => rootRoute,
} as any)
const AuthGoogleCallbackRoute = AuthGoogleCallbackImport.update({
id: '/auth/google/callback',
path: '/auth/google/callback',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -271,6 +278,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HubIndexImport
parentRoute: typeof rootRoute
}
'/auth/google/callback': {
id: '/auth/google/callback'
path: '/auth/google/callback'
fullPath: '/auth/google/callback'
preLoaderRoute: typeof AuthGoogleCallbackImport
parentRoute: typeof rootRoute
}
'/settings/providers/$providerName': {
id: '/settings/providers/$providerName'
path: '/settings/providers/$providerName'
@ -308,6 +322,7 @@ export interface FileRoutesByFullPath {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub': typeof HubIndexRoute
'/auth/google/callback': typeof AuthGoogleCallbackRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
}
@ -330,6 +345,7 @@ export interface FileRoutesByTo {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub': typeof HubIndexRoute
'/auth/google/callback': typeof AuthGoogleCallbackRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers': typeof SettingsProvidersIndexRoute
}
@ -353,6 +369,7 @@ export interface FileRoutesById {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub/': typeof HubIndexRoute
'/auth/google/callback': typeof AuthGoogleCallbackRoute
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
'/settings/providers/': typeof SettingsProvidersIndexRoute
}
@ -377,6 +394,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub'
| '/auth/google/callback'
| '/settings/providers/$providerName'
| '/settings/providers'
fileRoutesByTo: FileRoutesByTo
@ -398,6 +416,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub'
| '/auth/google/callback'
| '/settings/providers/$providerName'
| '/settings/providers'
id:
@ -419,6 +438,7 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub/'
| '/auth/google/callback'
| '/settings/providers/$providerName'
| '/settings/providers/'
fileRoutesById: FileRoutesById
@ -442,6 +462,7 @@ export interface RootRouteChildren {
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
HubIndexRoute: typeof HubIndexRoute
AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
}
@ -464,6 +485,7 @@ const rootRouteChildren: RootRouteChildren = {
SettingsShortcutsRoute: SettingsShortcutsRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
HubIndexRoute: HubIndexRoute,
AuthGoogleCallbackRoute: AuthGoogleCallbackRoute,
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
}
@ -495,6 +517,7 @@ export const routeTree = rootRoute
"/settings/shortcuts",
"/threads/$threadId",
"/hub/",
"/auth/google/callback",
"/settings/providers/$providerName",
"/settings/providers/"
]
@ -550,6 +573,9 @@ export const routeTree = rootRoute
"/hub/": {
"filePath": "hub/index.tsx"
},
"/auth/google/callback": {
"filePath": "auth.google.callback.tsx"
},
"/settings/providers/$providerName": {
"filePath": "settings/providers/$providerName.tsx"
},

View File

@ -32,6 +32,7 @@ import GlobalError from '@/containers/GlobalError'
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
import { ServiceHubProvider } from '@/providers/ServiceHubProvider'
import { AuthProvider } from '@/providers/AuthProvider'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
@ -206,9 +207,11 @@ function RootLayout() {
<ToasterProvider />
<TranslationProvider>
<ExtensionProvider>
<DataProvider />
<GlobalEventHandler />
{isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />}
<AuthProvider>
<DataProvider />
<GlobalEventHandler />
{isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />}
</AuthProvider>
</ExtensionProvider>
{/* {isLocalAPIServerLogsRoute ? <LogsLayout /> : <AppLayout />} */}
{/* <TanStackRouterDevtools position="bottom-right" /> */}

View File

@ -0,0 +1,84 @@
/**
* Google OAuth Callback Route
* Handles the callback from Google OAuth flow
*/
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
import { PlatformFeature } from '@/lib/platform'
import { useAuth } from '@/hooks/useAuth'
import { toast } from 'sonner'
export const Route = createFileRoute('/auth/google/callback')({
component: () => (
<PlatformGuard feature={PlatformFeature.AUTHENTICATION}>
<GoogleCallbackRedirect />
</PlatformGuard>
),
})
function GoogleCallbackRedirect() {
const navigate = useNavigate()
const { isAuthenticationEnabled, handleProviderCallback } = useAuth()
useEffect(() => {
const handleCallback = async () => {
try {
if (!isAuthenticationEnabled) {
throw new Error('Authentication not available on this platform')
}
// Check for error parameters first
const urlParams = new URLSearchParams(window.location.search)
const error = urlParams.get('error')
const errorDescription = urlParams.get('error_description')
if (error) {
throw new Error(errorDescription || `OAuth error: ${error}`)
}
// Extract the authorization code and state from URL parameters
const code = urlParams.get('code')
const state = urlParams.get('state')
if (!code) {
throw new Error('No authorization code received from Google')
}
// State is optional, don't require it
// Handle successful callback with the code and optional state using generic method
await handleProviderCallback('google', code, state || undefined)
toast.success('Successfully signed in!')
// Redirect to home after authentication
navigate({ to: '/', replace: true })
} catch (error) {
console.error('Google OAuth callback failed:', error)
const message =
error instanceof Error ? error.message : 'Authentication failed'
toast.error(message)
// Redirect to home on error (no login page)
navigate({ to: '/', replace: true })
}
}
handleCallback()
}, [isAuthenticationEnabled, handleProviderCallback, navigate])
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<h2 className="text-xl font-semibold mb-2">Signing you in...</h2>
<p className="text-muted-foreground">
Please wait while we complete your Google authentication.
</p>
</div>
</div>
)
}

View File

@ -24,6 +24,7 @@ vi.mock('@/lib/platform/const', () => ({
mcpServersSettings: true,
extensionsSettings: true,
assistants: true,
authentication: false,
}
}))

21
website/.gitignore vendored
View File

@ -1,21 +0,0 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View File

@ -1,183 +0,0 @@
# API Specification Synchronization
This document explains how the Jan Server API specification is kept in sync with the documentation.
## Overview
The Jan documentation automatically synchronizes with the Jan Server API specification to ensure the API reference is always up to date. This is managed through GitHub Actions workflows that can be triggered in multiple ways.
## Synchronization Methods
### 1. Automatic Daily Sync
- **Schedule**: Runs daily at 2 AM UTC
- **Branch**: `dev`
- **Behavior**: Fetches the latest spec and commits changes if any
- **Workflow**: `.github/workflows/update-cloud-api-spec.yml`
### 2. Manual Trigger via GitHub UI
Navigate to Actions → "Update Cloud API Spec" → Run workflow
Options:
- **Commit changes**: Whether to commit changes directly (default: true)
- **Custom spec URL**: Override the default API spec URL
- **Create PR**: Create a pull request instead of direct commit (default: false)
### 3. Webhook Trigger (For Jan Server Team)
Send a repository dispatch event to trigger an update:
```bash
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token YOUR_GITHUB_TOKEN" \
https://api.github.com/repos/janhq/jan/dispatches \
-d '{
"event_type": "update-api-spec",
"client_payload": {
"spec_url": "https://api.jan.ai/api/swagger/doc.json"
}
}'
```
### 4. Local Development
For local development, the spec is updated conditionally:
```bash
# Force update the cloud spec
bun run generate:cloud-spec-force
# Normal update (checks if update is needed)
bun run generate:cloud-spec
# Update both local and cloud specs
bun run generate:specs
```
## Configuration
### Environment Variables
The following environment variables can be configured in GitHub Secrets:
| Variable | Description | Default |
|----------|-------------|---------|
| `JAN_SERVER_SPEC_URL` | URL to fetch the OpenAPI spec | `https://api.jan.ai/api/swagger/doc.json` |
| `JAN_SERVER_PROD_URL` | Production API base URL | `https://api.jan.ai/v1` |
| `JAN_SERVER_STAGING_URL` | Staging API base URL | `https://staging-api.jan.ai/v1` |
### Build Behavior
| Context | Behavior |
|---------|----------|
| Pull Request | Uses existing spec (no update) |
| Push to dev | Uses existing spec (no update) |
| Scheduled run | Updates spec and commits changes |
| Manual trigger | Updates based on input options |
| Webhook | Updates and creates PR |
| Local dev | Updates if spec is >24hrs old or missing |
## Workflow Integration
### For Jan Server Team
When deploying a new API version:
1. **Option A: Automatic PR**
- Deploy your API changes
- Trigger the webhook (see above)
- Review and merge the created PR
2. **Option B: Manual Update**
- Go to [Actions](https://github.com/janhq/jan/actions/workflows/update-cloud-api-spec.yml)
- Click "Run workflow"
- Select options:
- Set "Create PR" to `true` for review
- Or leave as `false` for direct commit
3. **Option C: Wait for Daily Sync**
- Changes will be picked up automatically at 2 AM UTC
### For Documentation Team
The API spec updates are handled automatically. However, you can:
1. **Force an update**: Run the "Update Cloud API Spec" workflow manually
2. **Test locally**: Use `bun run generate:cloud-spec-force`
3. **Review changes**: Check PRs labeled with `api` and `automated`
## Fallback Mechanism
If the Jan Server API is unavailable:
1. The workflow will use the last known good spec
2. Local builds will fall back to the local OpenAPI spec
3. The build will continue without failing
## Monitoring
### Check Update Status
1. Go to [Actions](https://github.com/janhq/jan/actions/workflows/update-cloud-api-spec.yml)
2. Check the latest run status
3. Review the workflow summary for details
### Notifications
To add Slack/Discord notifications:
1. Add webhook URL to GitHub Secrets
2. Uncomment notification section in workflow
3. Configure message format as needed
## Troubleshooting
### Spec Update Fails
1. Check if the API endpoint is accessible
2. Verify the spec URL is correct
3. Check GitHub Actions logs for errors
4. Ensure proper permissions for the workflow
### Changes Not Appearing
1. Verify the workflow completed successfully
2. Check if changes were committed to the correct branch
3. Ensure the build is using the updated spec
4. Clear CDN cache if using Cloudflare
### Manual Recovery
If automated updates fail:
```bash
# Clone the repository
git clone https://github.com/janhq/jan.git
cd jan/website
# Install dependencies
bun install
# Force update the spec
FORCE_UPDATE=true bun run generate:cloud-spec
# Commit and push
git add public/openapi/cloud-openapi.json
git commit -m "chore: manual update of API spec"
git push
```
## Best Practices
1. **Version Control**: Always review significant API changes before merging
2. **Testing**: Test the updated spec locally before deploying
3. **Communication**: Notify the docs team of breaking API changes
4. **Monitoring**: Set up alerts for failed spec updates
5. **Documentation**: Update this guide when changing the sync process
## Support
For issues or questions:
- Open an issue in the [Jan repository](https://github.com/janhq/jan/issues)
- Contact the documentation team on Discord
- Check the [workflow runs](https://github.com/janhq/jan/actions) for debugging

View File

@ -1,48 +0,0 @@
# Jan's Website
This website is [built with Starlight](https://starlight.astro.build)
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed
as a route based on its file name.
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
Static assets, like favicons, can be placed in the `public/` directory.
If you want to add new pages, these can go in the `src/pages/` directory. Because of the topics plugin
we are using ([starlight sidebar topics](https://starlight-sidebar-topics.netlify.app/docs/guides/excluded-pages/))
you will need to exclude them from the sidebar by adding them to the exclude list in `astro.config.mjs`, e.g., `exclude: ['/example'],`.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `bun install` | Installs dependencies |
| `bun dev` | Starts local dev server at `localhost:4321` |
| `bun build` | Build your production site to `./dist/` |
| `bun preview` | Preview your build locally, before deploying |
| `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
| `bun astro -- --help` | Get help using the Astro CLI |
## 📖 API Reference Commands
The website includes interactive API documentation. These commands help manage the OpenAPI specifications:
| Command | Action |
| :------------------------------- | :-------------------------------------------------------- |
| `bun run api:dev` | Start dev server with API reference at `/api` |
| `bun run api:local` | Start dev server with local API docs at `/api-reference/local` |
| `bun run api:cloud` | Start dev server with cloud API docs at `/api-reference/cloud` |
| `bun run generate:local-spec` | Generate/fix the local OpenAPI specification |
| `bun run generate:cloud-spec` | Generate the cloud OpenAPI specification from Jan Server |
| `bun run generate:cloud-spec-force` | Force update cloud spec (ignores cache/conditions) |
**API Reference Pages:**
- `/api` - Landing page with Local and Server API options
- `/api-reference/local` - Local API (llama.cpp) documentation
- `/api-reference/cloud` - Jan Server API (vLLM) documentation
The cloud specification is automatically synced via GitHub Actions on a daily schedule and can be manually triggered by the Jan Server team.

View File

@ -1,306 +0,0 @@
// @ts-check
import { defineConfig } from 'astro/config'
import starlight from '@astrojs/starlight'
import starlightThemeRapide from 'starlight-theme-rapide'
import starlightSidebarTopics from 'starlight-sidebar-topics'
import starlightUtils from '@lorenzo_lewis/starlight-utils'
import react from '@astrojs/react'
import mermaid from 'astro-mermaid'
import { fileURLToPath } from 'url'
import path, { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://astro.build/config
export default defineConfig({
// Deploy to the new v2 subdomain
site: 'https://docs.jan.ai',
integrations: [
react(),
mermaid({
theme: 'default',
autoTheme: true,
}),
starlight({
title: '👋 Jan',
favicon: 'favicon.ico',
customCss: ['./src/styles/global.css'],
head: [
{
tag: 'script',
attrs: { src: '/scripts/inject-navigation.js', defer: true },
},
{
tag: 'link',
attrs: { rel: 'stylesheet', href: '/styles/navigation.css' },
},
],
plugins: [
starlightThemeRapide(),
starlightSidebarTopics(
[
{
label: 'Jan',
link: '/',
icon: 'rocket',
items: [{ label: 'Ecosystem', slug: 'index' }],
},
{
label: 'Jan Desktop',
link: '/jan/quickstart',
icon: 'rocket',
items: [
{
label: '🚀 QUICK START',
items: [
{ label: 'Getting Started', slug: 'jan/quickstart' },
{
label: 'Install Jan',
collapsed: false,
autogenerate: { directory: 'jan/installation' },
},
{ label: 'AI Assistants', slug: 'jan/assistants' },
],
},
{
label: '🤖 MODELS',
items: [
{ label: 'Overview', slug: 'jan/manage-models' },
{
label: 'Jan Models',
collapsed: false,
items: [
{
label: 'Jan v1',
slug: 'jan/jan-models/jan-v1',
},
{
label: 'Research Models',
collapsed: true,
items: [
{
label: 'Jan Nano 32k',
slug: 'jan/jan-models/jan-nano-32',
},
{
label: 'Jan Nano 128k',
slug: 'jan/jan-models/jan-nano-128',
},
{
label: 'Lucy',
slug: 'jan/jan-models/lucy',
},
],
},
],
},
{
label: 'Cloud Providers',
collapsed: true,
items: [
{ label: 'OpenAI', slug: 'jan/remote-models/openai' },
{
label: 'Anthropic',
slug: 'jan/remote-models/anthropic',
},
{ label: 'Gemini', slug: 'jan/remote-models/google' },
{ label: 'Groq', slug: 'jan/remote-models/groq' },
{
label: 'Mistral',
slug: 'jan/remote-models/mistralai',
},
{ label: 'Cohere', slug: 'jan/remote-models/cohere' },
{
label: 'OpenRouter',
slug: 'jan/remote-models/openrouter',
},
{
label: 'HuggingFace 🤗',
slug: 'jan/remote-models/huggingface',
},
],
},
{
label: 'Custom Providers',
slug: 'jan/custom-provider',
},
{
label: 'Multi-Modal Models',
slug: 'jan/multi-modal',
},
],
},
{
label: '🔧 TOOLS & INTEGRATIONS',
items: [
{ label: 'What is MCP?', slug: 'jan/mcp' },
{
label: 'Examples & Tutorials',
collapsed: true,
items: [
{
label: 'Web & Search',
collapsed: true,
items: [
{
label: 'Browser Control',
slug: 'jan/mcp-examples/browser/browserbase',
},
{
label: 'Serper Search',
slug: 'jan/mcp-examples/search/serper',
},
{
label: 'Exa Search',
slug: 'jan/mcp-examples/search/exa',
},
],
},
{
label: 'Data & Analysis',
collapsed: true,
items: [
{
label: 'Jupyter Notebooks',
slug: 'jan/mcp-examples/data-analysis/jupyter',
},
{
label: 'Code Sandbox (E2B)',
slug: 'jan/mcp-examples/data-analysis/e2b',
},
{
label: 'Deep Financial Research',
slug: 'jan/mcp-examples/deepresearch/octagon',
},
],
},
{
label: 'Productivity',
collapsed: true,
items: [
{
label: 'Linear',
slug: 'jan/mcp-examples/productivity/linear',
},
{
label: 'Todoist',
slug: 'jan/mcp-examples/productivity/todoist',
},
],
},
{
label: 'Creative',
collapsed: true,
items: [
{
label: 'Design with Canva',
slug: 'jan/mcp-examples/design/canva',
},
],
},
],
},
],
},
{
label: '⚙️ DEVELOPER',
items: [
{
label: 'Local API Server',
collapsed: true,
items: [
{ label: 'Overview', slug: 'local-server' },
{
label: 'API Configuration',
slug: 'local-server/api-server',
},
{
label: 'Engine Settings',
slug: 'local-server/llama-cpp',
},
{
label: 'Server Settings',
slug: 'local-server/settings',
},
{
label: 'Integrations',
collapsed: true,
autogenerate: {
directory: 'local-server/integrations',
},
},
],
},
{
label: 'Technical Details',
collapsed: true,
items: [
{
label: 'Model Parameters',
slug: 'jan/explanation/model-parameters',
},
],
},
],
},
{
label: '📚 REFERENCE',
items: [
{ label: 'Settings', slug: 'jan/settings' },
{ label: 'Data Folder', slug: 'jan/data-folder' },
{ label: 'Troubleshooting', slug: 'jan/troubleshooting' },
{ label: 'Privacy Policy', slug: 'jan/privacy' },
],
},
],
},
{
label: 'Browser Extension',
link: '/browser/',
badge: { text: 'Alpha', variant: 'tip' },
icon: 'puzzle',
items: [{ label: 'Overview', slug: 'browser' }],
},
{
label: 'Jan Mobile',
link: '/mobile/',
badge: { text: 'Soon', variant: 'caution' },
icon: 'phone',
items: [{ label: 'Overview', slug: 'mobile' }],
},
{
label: 'Jan Server',
link: '/server/',
badge: { text: 'Soon', variant: 'caution' },
icon: 'forward-slash',
items: [{ label: 'Overview', slug: 'server' }],
},
],
{
exclude: ['/api-reference', '/api-reference/**/*'],
}
),
],
social: [
{
icon: 'github',
label: 'GitHub',
href: 'https://github.com/menloresearch/jan',
},
{
icon: 'x.com',
label: 'X',
href: 'https://twitter.com/jandotai',
},
{
icon: 'discord',
label: 'Discord',
href: 'https://discord.com/invite/FTk2MvZwJH',
},
],
}),
],
})

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,36 +0,0 @@
{
"name": "website",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"prebuild": "bun scripts/fix-local-spec-complete.js && bun scripts/conditional-cloud-spec.js",
"generate:local-spec": "bun scripts/fix-local-spec-complete.js",
"generate:cloud-spec": "bun scripts/generate-cloud-spec.js",
"generate:cloud-spec-force": "FORCE_UPDATE=true bun scripts/generate-cloud-spec.js",
"api:dev": "astro dev --open /api",
"api:local": "astro dev --open /api-reference/local",
"api:cloud": "astro dev --open /api-reference/cloud"
},
"dependencies": {
"@astrojs/react": "^4.3.0",
"@astrojs/starlight": "^0.35.1",
"@lorenzo_lewis/starlight-utils": "^0.3.2",
"@scalar/api-reference-react": "^0.7.42",
"@types/react": "^19.1.12",
"astro": "^5.6.1",
"astro-mermaid": "^1.0.4",
"mermaid": "^11.9.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sharp": "^0.34.3",
"starlight-openapi": "^0.19.1",
"starlight-sidebar-topics": "^0.6.0",
"starlight-theme-rapide": "^0.5.1",
"unist-util-visit": "^5.0.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 MiB

Some files were not shown because too many files have changed in this diff Show More