web: update model capabilites (#6814)
* update model capabilites * refactor + remove projects
This commit is contained in:
parent
147cab94a8
commit
e46200868e
@ -4,8 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSharedAuthService, JanAuthService } from '../shared'
|
import { getSharedAuthService, JanAuthService } from '../shared'
|
||||||
import { JanModel, janProviderStore } from './store'
|
|
||||||
import { ApiError } from '../shared/types/errors'
|
import { ApiError } from '../shared/types/errors'
|
||||||
|
import { JAN_API_ROUTES } from './const'
|
||||||
|
import { JanModel, janProviderStore } from './store'
|
||||||
|
|
||||||
// JAN_API_BASE is defined in vite.config.ts
|
// JAN_API_BASE is defined in vite.config.ts
|
||||||
|
|
||||||
@ -19,12 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat'
|
|||||||
*/
|
*/
|
||||||
function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) {
|
function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) {
|
||||||
const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID
|
const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID
|
||||||
|
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}`
|
||||||
// For temporary chats, use the stateless /chat/completions endpoint
|
|
||||||
// For regular conversations, use the stateful /conv/chat/completions endpoint
|
|
||||||
const endpoint = isTemporaryChat
|
|
||||||
? `${JAN_API_BASE}/chat/completions`
|
|
||||||
: `${JAN_API_BASE}/conv/chat/completions`
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...request,
|
...request,
|
||||||
@ -44,9 +40,30 @@ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: bool
|
|||||||
return { endpoint, payload, isTemporaryChat }
|
return { endpoint, payload, isTemporaryChat }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JanModelsResponse {
|
interface JanModelSummary {
|
||||||
|
id: string
|
||||||
object: string
|
object: string
|
||||||
data: JanModel[]
|
owned_by: string
|
||||||
|
created?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JanModelsResponse {
|
||||||
|
object: string
|
||||||
|
data: JanModelSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JanModelCatalogResponse {
|
||||||
|
id: string
|
||||||
|
supported_parameters?: {
|
||||||
|
names?: string[]
|
||||||
|
default?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
extras?: {
|
||||||
|
supported_parameters?: string[]
|
||||||
|
default_parameters?: Record<string, unknown>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JanChatMessage {
|
export interface JanChatMessage {
|
||||||
@ -112,6 +129,8 @@ export interface JanChatCompletionChunk {
|
|||||||
export class JanApiClient {
|
export class JanApiClient {
|
||||||
private static instance: JanApiClient
|
private static instance: JanApiClient
|
||||||
private authService: JanAuthService
|
private authService: JanAuthService
|
||||||
|
private modelsCache: JanModel[] | null = null
|
||||||
|
private modelsFetchPromise: Promise<JanModel[]> | null = null
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.authService = getSharedAuthService()
|
this.authService = getSharedAuthService()
|
||||||
@ -124,25 +143,64 @@ export class JanApiClient {
|
|||||||
return JanApiClient.instance
|
return JanApiClient.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
async getModels(): Promise<JanModel[]> {
|
async getModels(options?: { forceRefresh?: boolean }): Promise<JanModel[]> {
|
||||||
try {
|
try {
|
||||||
|
const forceRefresh = options?.forceRefresh ?? false
|
||||||
|
|
||||||
|
if (forceRefresh) {
|
||||||
|
this.modelsCache = null
|
||||||
|
} else if (this.modelsCache) {
|
||||||
|
return this.modelsCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.modelsFetchPromise) {
|
||||||
|
return this.modelsFetchPromise
|
||||||
|
}
|
||||||
|
|
||||||
janProviderStore.setLoadingModels(true)
|
janProviderStore.setLoadingModels(true)
|
||||||
janProviderStore.clearError()
|
janProviderStore.clearError()
|
||||||
|
|
||||||
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
|
this.modelsFetchPromise = (async () => {
|
||||||
`${JAN_API_BASE}/conv/models`
|
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
|
||||||
)
|
`${JAN_API_BASE}${JAN_API_ROUTES.MODELS}`
|
||||||
|
)
|
||||||
|
|
||||||
const models = response.data || []
|
const summaries = response.data || []
|
||||||
janProviderStore.setModels(models)
|
|
||||||
|
const models: JanModel[] = await Promise.all(
|
||||||
return models
|
summaries.map(async (summary) => {
|
||||||
|
const supportedParameters = await this.fetchSupportedParameters(summary.id)
|
||||||
|
const capabilities = this.deriveCapabilitiesFromParameters(supportedParameters)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: summary.id,
|
||||||
|
object: summary.object,
|
||||||
|
owned_by: summary.owned_by,
|
||||||
|
created: summary.created,
|
||||||
|
capabilities,
|
||||||
|
supportedParameters,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
this.modelsCache = models
|
||||||
|
janProviderStore.setModels(models)
|
||||||
|
|
||||||
|
return models
|
||||||
|
})()
|
||||||
|
|
||||||
|
return await this.modelsFetchPromise
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.modelsCache = null
|
||||||
|
this.modelsFetchPromise = null
|
||||||
|
|
||||||
const errorMessage = error instanceof ApiError ? error.message :
|
const errorMessage = error instanceof ApiError ? error.message :
|
||||||
error instanceof Error ? error.message : 'Failed to fetch models'
|
error instanceof Error ? error.message : 'Failed to fetch models'
|
||||||
janProviderStore.setError(errorMessage)
|
janProviderStore.setError(errorMessage)
|
||||||
janProviderStore.setLoadingModels(false)
|
janProviderStore.setLoadingModels(false)
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.modelsFetchPromise = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,7 +312,7 @@ export class JanApiClient {
|
|||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
janProviderStore.setAuthenticated(true)
|
janProviderStore.setAuthenticated(true)
|
||||||
// Fetch initial models
|
// Fetch initial models (cached for subsequent calls)
|
||||||
await this.getModels()
|
await this.getModels()
|
||||||
console.log('Jan API client initialized successfully')
|
console.log('Jan API client initialized successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -266,6 +324,52 @@ export class JanApiClient {
|
|||||||
janProviderStore.setInitializing(false)
|
janProviderStore.setInitializing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchSupportedParameters(modelId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.MODEL_CATALOGS}/${this.encodeModelIdForCatalog(modelId)}`
|
||||||
|
const catalog = await this.authService.makeAuthenticatedRequest<JanModelCatalogResponse>(endpoint)
|
||||||
|
return this.extractSupportedParameters(catalog)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch catalog metadata for model "${modelId}":`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeModelIdForCatalog(modelId: string): string {
|
||||||
|
return modelId
|
||||||
|
.split('/')
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSupportedParameters(catalog: JanModelCatalogResponse | null | undefined): string[] {
|
||||||
|
if (!catalog) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryNames = catalog.supported_parameters?.names
|
||||||
|
if (Array.isArray(primaryNames) && primaryNames.length > 0) {
|
||||||
|
return [...new Set(primaryNames)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraNames = catalog.extras?.supported_parameters
|
||||||
|
if (Array.isArray(extraNames) && extraNames.length > 0) {
|
||||||
|
return [...new Set(extraNames)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveCapabilitiesFromParameters(parameters: string[]): string[] {
|
||||||
|
const capabilities = new Set<string>()
|
||||||
|
|
||||||
|
if (parameters.includes('tools')) {
|
||||||
|
capabilities.add('tools')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(capabilities)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const janApiClient = JanApiClient.getInstance()
|
export const janApiClient = JanApiClient.getInstance()
|
||||||
|
|||||||
7
extensions-web/src/jan-provider-web/const.ts
Normal file
7
extensions-web/src/jan-provider-web/const.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const JAN_API_ROUTES = {
|
||||||
|
MODELS: '/models',
|
||||||
|
CHAT_COMPLETIONS: '/chat/completions',
|
||||||
|
MODEL_CATALOGS: '/models/catalogs',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const MODEL_PROVIDER_STORAGE_KEY = 'model-provider'
|
||||||
122
extensions-web/src/jan-provider-web/helpers.ts
Normal file
122
extensions-web/src/jan-provider-web/helpers.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import type { JanModel } from './store'
|
||||||
|
import { MODEL_PROVIDER_STORAGE_KEY } from './const'
|
||||||
|
|
||||||
|
type StoredModel = {
|
||||||
|
id?: string
|
||||||
|
capabilities?: unknown
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredProvider = {
|
||||||
|
provider?: string
|
||||||
|
models?: StoredModel[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoredState = {
|
||||||
|
state?: {
|
||||||
|
providers?: StoredProvider[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
version?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeCapabilities = (capabilities: unknown): string[] => {
|
||||||
|
if (!Array.isArray(capabilities)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(capabilities.filter((item): item is string => typeof item === 'string'))].sort(
|
||||||
|
(a, b) => a.localeCompare(b)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize Jan models stored in localStorage with the latest server state.
|
||||||
|
* Returns true if the stored data was modified (including being cleared).
|
||||||
|
*/
|
||||||
|
export function syncJanModelsLocalStorage(
|
||||||
|
remoteModels: JanModel[],
|
||||||
|
storageKey: string = MODEL_PROVIDER_STORAGE_KEY
|
||||||
|
): boolean {
|
||||||
|
const rawStorage = localStorage.getItem(storageKey)
|
||||||
|
if (!rawStorage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let storedState: StoredState
|
||||||
|
try {
|
||||||
|
storedState = JSON.parse(rawStorage) as StoredState
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse Jan model storage; clearing entry.', error)
|
||||||
|
localStorage.removeItem(storageKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = storedState?.state?.providers
|
||||||
|
if (!Array.isArray(providers)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteModelMap = new Map(remoteModels.map((model) => [model.id, model]))
|
||||||
|
let storageUpdated = false
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
if (provider.provider !== 'jan' || !Array.isArray(provider.models)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedModels: StoredModel[] = []
|
||||||
|
|
||||||
|
for (const model of provider.models) {
|
||||||
|
const modelId = typeof model.id === 'string' ? model.id : null
|
||||||
|
if (!modelId) {
|
||||||
|
storageUpdated = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteModel = remoteModelMap.get(modelId)
|
||||||
|
if (!remoteModel) {
|
||||||
|
console.log(`Removing unknown Jan model from localStorage: ${modelId}`)
|
||||||
|
storageUpdated = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedCapabilities = normalizeCapabilities(model.capabilities)
|
||||||
|
const remoteCapabilities = normalizeCapabilities(remoteModel.capabilities)
|
||||||
|
|
||||||
|
const capabilitiesMatch =
|
||||||
|
storedCapabilities.length === remoteCapabilities.length &&
|
||||||
|
storedCapabilities.every((cap, index) => cap === remoteCapabilities[index])
|
||||||
|
|
||||||
|
if (!capabilitiesMatch) {
|
||||||
|
console.log(
|
||||||
|
`Updating capabilities for Jan model ${modelId}:`,
|
||||||
|
storedCapabilities,
|
||||||
|
'=>',
|
||||||
|
remoteCapabilities
|
||||||
|
)
|
||||||
|
updatedModels.push({
|
||||||
|
...model,
|
||||||
|
capabilities: remoteModel.capabilities,
|
||||||
|
})
|
||||||
|
storageUpdated = true
|
||||||
|
} else {
|
||||||
|
updatedModels.push(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedModels.length !== provider.models.length) {
|
||||||
|
storageUpdated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.models = updatedModels
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageUpdated) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(storedState))
|
||||||
|
}
|
||||||
|
|
||||||
|
return storageUpdated
|
||||||
|
}
|
||||||
@ -14,12 +14,10 @@ import {
|
|||||||
ImportOptions,
|
ImportOptions,
|
||||||
} from '@janhq/core' // cspell: disable-line
|
} from '@janhq/core' // cspell: disable-line
|
||||||
import { janApiClient, JanChatMessage } from './api'
|
import { janApiClient, JanChatMessage } from './api'
|
||||||
|
import { syncJanModelsLocalStorage } from './helpers'
|
||||||
import { janProviderStore } from './store'
|
import { janProviderStore } from './store'
|
||||||
import { ApiError } from '../shared/types/errors'
|
import { ApiError } from '../shared/types/errors'
|
||||||
|
|
||||||
// Jan models support tools via MCP
|
|
||||||
const JAN_MODEL_CAPABILITIES = ['tools'] as const
|
|
||||||
|
|
||||||
export default class JanProviderWeb extends AIEngine {
|
export default class JanProviderWeb extends AIEngine {
|
||||||
readonly provider = 'jan'
|
readonly provider = 'jan'
|
||||||
private activeSessions: Map<string, SessionInfo> = new Map()
|
private activeSessions: Map<string, SessionInfo> = new Map()
|
||||||
@ -28,11 +26,11 @@ export default class JanProviderWeb extends AIEngine {
|
|||||||
console.log('Loading Jan Provider Extension...')
|
console.log('Loading Jan Provider Extension...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check and clear invalid Jan models (capabilities mismatch)
|
// Initialize authentication
|
||||||
this.validateJanModelsLocalStorage()
|
|
||||||
|
|
||||||
// Initialize authentication and fetch models
|
|
||||||
await janApiClient.initialize()
|
await janApiClient.initialize()
|
||||||
|
// Check and sync stored Jan models against latest catalog data
|
||||||
|
await this.validateJanModelsLocalStorage()
|
||||||
|
|
||||||
console.log('Jan Provider Extension loaded successfully')
|
console.log('Jan Provider Extension loaded successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load Jan Provider Extension:', error)
|
console.error('Failed to load Jan Provider Extension:', error)
|
||||||
@ -43,59 +41,17 @@ export default class JanProviderWeb extends AIEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify Jan models capabilities in localStorage
|
// Verify Jan models capabilities in localStorage
|
||||||
private validateJanModelsLocalStorage() {
|
private async validateJanModelsLocalStorage(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('Validating Jan models in localStorage...')
|
console.log('Validating Jan models in localStorage...')
|
||||||
const storageKey = 'model-provider'
|
|
||||||
const data = localStorage.getItem(storageKey)
|
|
||||||
if (!data) return
|
|
||||||
|
|
||||||
const parsed = JSON.parse(data)
|
const remoteModels = await janApiClient.getModels()
|
||||||
if (!parsed?.state?.providers) return
|
const storageUpdated = syncJanModelsLocalStorage(remoteModels)
|
||||||
|
|
||||||
// Check if any Jan model has incorrect capabilities
|
if (storageUpdated) {
|
||||||
let hasInvalidModel = false
|
|
||||||
|
|
||||||
for (const provider of parsed.state.providers) {
|
|
||||||
if (provider.provider === 'jan' && provider.models) {
|
|
||||||
for (const model of provider.models) {
|
|
||||||
console.log(`Checking Jan model: ${model.id}`, model.capabilities)
|
|
||||||
if (
|
|
||||||
JSON.stringify(model.capabilities) !==
|
|
||||||
JSON.stringify(JAN_MODEL_CAPABILITIES)
|
|
||||||
) {
|
|
||||||
hasInvalidModel = true
|
|
||||||
console.log(
|
|
||||||
`Found invalid Jan model: ${model.id}, clearing localStorage`
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasInvalidModel) break
|
|
||||||
}
|
|
||||||
|
|
||||||
// If any invalid model found, just clear the storage
|
|
||||||
if (hasInvalidModel) {
|
|
||||||
// Force clear the storage
|
|
||||||
localStorage.removeItem(storageKey)
|
|
||||||
// Verify it's actually removed
|
|
||||||
const afterRemoval = localStorage.getItem(storageKey)
|
|
||||||
// If still present, try setting to empty state
|
|
||||||
if (afterRemoval) {
|
|
||||||
// Try alternative clearing method
|
|
||||||
localStorage.setItem(
|
|
||||||
storageKey,
|
|
||||||
JSON.stringify({
|
|
||||||
state: { providers: [] },
|
|
||||||
version: parsed.version || 3,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
console.log(
|
console.log(
|
||||||
'Cleared model-provider from localStorage due to invalid Jan capabilities'
|
'Synchronized Jan models in localStorage with server capabilities; reloading...'
|
||||||
)
|
)
|
||||||
// Force a page reload to ensure clean state
|
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -132,7 +88,7 @@ export default class JanProviderWeb extends AIEngine {
|
|||||||
path: undefined, // Remote model, no local path
|
path: undefined, // Remote model, no local path
|
||||||
owned_by: model.owned_by,
|
owned_by: model.owned_by,
|
||||||
object: model.object,
|
object: model.object,
|
||||||
capabilities: [...JAN_MODEL_CAPABILITIES],
|
capabilities: [...model.capabilities],
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
@ -153,7 +109,7 @@ export default class JanProviderWeb extends AIEngine {
|
|||||||
path: undefined, // Remote model, no local path
|
path: undefined, // Remote model, no local path
|
||||||
owned_by: model.owned_by,
|
owned_by: model.owned_by,
|
||||||
object: model.object,
|
object: model.object,
|
||||||
capabilities: [...JAN_MODEL_CAPABILITIES],
|
capabilities: [...model.capabilities],
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to list Jan models:', error)
|
console.error('Failed to list Jan models:', error)
|
||||||
|
|||||||
@ -9,6 +9,9 @@ export interface JanModel {
|
|||||||
id: string
|
id: string
|
||||||
object: string
|
object: string
|
||||||
owned_by: string
|
owned_by: string
|
||||||
|
created?: number
|
||||||
|
capabilities: string[]
|
||||||
|
supportedParameters?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JanProviderState {
|
export interface JanProviderState {
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
declare const JAN_API_BASE: string
|
declare const JAN_API_BASE: string
|
||||||
|
|
||||||
import { User, AuthState, AuthBroadcastMessage } from './types'
|
import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types'
|
||||||
import {
|
import {
|
||||||
AUTH_STORAGE_KEYS,
|
AUTH_STORAGE_KEYS,
|
||||||
AUTH_ENDPOINTS,
|
AUTH_ENDPOINTS,
|
||||||
@ -115,7 +115,7 @@ export class JanAuthService {
|
|||||||
|
|
||||||
// Store tokens and set authenticated state
|
// Store tokens and set authenticated state
|
||||||
this.accessToken = tokens.access_token
|
this.accessToken = tokens.access_token
|
||||||
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
|
||||||
this.setAuthProvider(providerId)
|
this.setAuthProvider(providerId)
|
||||||
|
|
||||||
this.authBroadcast.broadcastLogin()
|
this.authBroadcast.broadcastLogin()
|
||||||
@ -158,7 +158,7 @@ export class JanAuthService {
|
|||||||
const tokens = await refreshToken()
|
const tokens = await refreshToken()
|
||||||
|
|
||||||
this.accessToken = tokens.access_token
|
this.accessToken = tokens.access_token
|
||||||
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh access token:', error)
|
console.error('Failed to refresh access token:', error)
|
||||||
if (error instanceof ApiError && error.isStatus(401)) {
|
if (error instanceof ApiError && error.isStatus(401)) {
|
||||||
@ -343,6 +343,23 @@ export class JanAuthService {
|
|||||||
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
|
localStorage.removeItem(AUTH_STORAGE_KEYS.AUTH_PROVIDER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private computeTokenExpiry(tokens: AuthTokens): number {
|
||||||
|
if (tokens.expires_at) {
|
||||||
|
const expiresAt = new Date(tokens.expires_at).getTime()
|
||||||
|
if (!Number.isNaN(expiresAt)) {
|
||||||
|
return expiresAt
|
||||||
|
}
|
||||||
|
console.warn('Invalid expires_at format in auth tokens:', tokens.expires_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tokens.expires_in === 'number') {
|
||||||
|
return Date.now() + tokens.expires_in * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Auth tokens missing expiry information; defaulting to immediate expiry')
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure guest access is available
|
* Ensure guest access is available
|
||||||
*/
|
*/
|
||||||
@ -352,7 +369,7 @@ export class JanAuthService {
|
|||||||
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
|
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
|
||||||
const tokens = await guestLogin()
|
const tokens = await guestLogin()
|
||||||
this.accessToken = tokens.access_token
|
this.accessToken = tokens.access_token
|
||||||
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
|
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to ensure guest access:', error)
|
console.error('Failed to ensure guest access:', error)
|
||||||
@ -387,7 +404,6 @@ export class JanAuthService {
|
|||||||
case AUTH_EVENTS.LOGOUT:
|
case AUTH_EVENTS.LOGOUT:
|
||||||
// Another tab logged out, clear our state
|
// Another tab logged out, clear our state
|
||||||
this.clearAuthState()
|
this.clearAuthState()
|
||||||
this.ensureGuestAccess().catch(console.error)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -16,7 +16,8 @@ export type AuthType = ProviderType | 'guest'
|
|||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
access_token: string
|
access_token: string
|
||||||
expires_in: number
|
expires_in?: number
|
||||||
|
expires_at?: string
|
||||||
object: string
|
object: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,6 @@ import { McpExtensionToolLoader } from './McpExtensionToolLoader'
|
|||||||
import { ExtensionTypeEnum, MCPExtension, fs, RAGExtension } from '@janhq/core'
|
import { ExtensionTypeEnum, MCPExtension, fs, RAGExtension } from '@janhq/core'
|
||||||
import { ExtensionManager } from '@/lib/extension'
|
import { ExtensionManager } from '@/lib/extension'
|
||||||
import { useAttachments } from '@/hooks/useAttachments'
|
import { useAttachments } from '@/hooks/useAttachments'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PlatformFeatures } from '@/lib/platform/const'
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
import { PlatformFeature } from '@/lib/platform/types'
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
@ -333,7 +332,7 @@ const ChatInput = ({
|
|||||||
toast.info('Attachments are disabled in Settings')
|
toast.info('Attachments are disabled in Settings')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const selection = await open({
|
const selection = await serviceHub.dialog().open({
|
||||||
multiple: true,
|
multiple: true,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -56,7 +56,8 @@ const mainMenus = [
|
|||||||
title: 'common:projects.title',
|
title: 'common:projects.title',
|
||||||
icon: IconFolderPlus,
|
icon: IconFolderPlus,
|
||||||
route: route.project,
|
route: route.project,
|
||||||
isEnabled: !(IS_IOS || IS_ANDROID),
|
isEnabled:
|
||||||
|
PlatformFeatures[PlatformFeature.PROJECTS] && !(IS_IOS || IS_ANDROID),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -88,6 +89,7 @@ const LeftPanel = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const { isAuthenticated } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
|
const projectsEnabled = PlatformFeatures[PlatformFeature.PROJECTS]
|
||||||
|
|
||||||
const isSmallScreen = useSmallScreen()
|
const isSmallScreen = useSmallScreen()
|
||||||
const prevScreenSizeRef = useRef<boolean | null>(null)
|
const prevScreenSizeRef = useRef<boolean | null>(null)
|
||||||
@ -402,7 +404,9 @@ const LeftPanel = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredProjects.length > 0 && !(IS_IOS || IS_ANDROID) && (
|
{projectsEnabled &&
|
||||||
|
filteredProjects.length > 0 &&
|
||||||
|
!(IS_IOS || IS_ANDROID) && (
|
||||||
<div className="space-y-1 py-1">
|
<div className="space-y-1 py-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
|
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
|
||||||
@ -670,23 +674,29 @@ const LeftPanel = () => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Project Dialogs */}
|
{/* Project Dialogs */}
|
||||||
<AddProjectDialog
|
{projectsEnabled && (
|
||||||
open={projectDialogOpen}
|
<>
|
||||||
onOpenChange={setProjectDialogOpen}
|
<AddProjectDialog
|
||||||
editingKey={editingProjectKey}
|
open={projectDialogOpen}
|
||||||
initialData={
|
onOpenChange={setProjectDialogOpen}
|
||||||
editingProjectKey ? getFolderById(editingProjectKey) : undefined
|
editingKey={editingProjectKey}
|
||||||
}
|
initialData={
|
||||||
onSave={handleProjectSave}
|
editingProjectKey ? getFolderById(editingProjectKey) : undefined
|
||||||
/>
|
}
|
||||||
<DeleteProjectDialog
|
onSave={handleProjectSave}
|
||||||
open={deleteProjectConfirmOpen}
|
/>
|
||||||
onOpenChange={handleProjectDeleteClose}
|
<DeleteProjectDialog
|
||||||
projectId={deletingProjectId ?? undefined}
|
open={deleteProjectConfirmOpen}
|
||||||
projectName={
|
onOpenChange={handleProjectDeleteClose}
|
||||||
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
|
projectId={deletingProjectId ?? undefined}
|
||||||
}
|
projectName={
|
||||||
/>
|
deletingProjectId
|
||||||
|
? getFolderById(deletingProjectId)?.name
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import { useLeftPanel } from '@/hooks/useLeftPanel'
|
|||||||
import { useMessages } from '@/hooks/useMessages'
|
import { useMessages } from '@/hooks/useMessages'
|
||||||
import { cn, extractThinkingContent } from '@/lib/utils'
|
import { cn, extractThinkingContent } from '@/lib/utils'
|
||||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -88,6 +90,7 @@ const SortableItem = memo(
|
|||||||
'threadId' in match.params &&
|
'threadId' in match.params &&
|
||||||
match.params.threadId === thread.id
|
match.params.threadId === thread.id
|
||||||
)
|
)
|
||||||
|
const projectsEnabled = PlatformFeatures[PlatformFeature.PROJECTS]
|
||||||
|
|
||||||
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
|
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
if (openDropdown) {
|
if (openDropdown) {
|
||||||
@ -111,6 +114,9 @@ const SortableItem = memo(
|
|||||||
}, [thread.title])
|
}, [thread.title])
|
||||||
|
|
||||||
const availableProjects = useMemo(() => {
|
const availableProjects = useMemo(() => {
|
||||||
|
if (!projectsEnabled) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
return folders
|
return folders
|
||||||
.filter((f) => {
|
.filter((f) => {
|
||||||
// Exclude the current project page we're on
|
// Exclude the current project page we're on
|
||||||
@ -120,9 +126,18 @@ const SortableItem = memo(
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.updated_at - a.updated_at)
|
.sort((a, b) => b.updated_at - a.updated_at)
|
||||||
}, [folders, currentProjectId, thread.metadata?.project?.id])
|
}, [
|
||||||
|
projectsEnabled,
|
||||||
|
folders,
|
||||||
|
currentProjectId,
|
||||||
|
thread.metadata?.project?.id,
|
||||||
|
])
|
||||||
|
|
||||||
const assignThreadToProject = (threadId: string, projectId: string) => {
|
const assignThreadToProject = (threadId: string, projectId: string) => {
|
||||||
|
if (!projectsEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const project = getFolderById(projectId)
|
const project = getFolderById(projectId)
|
||||||
if (project && updateThread) {
|
if (project && updateThread) {
|
||||||
const projectMetadata = {
|
const projectMetadata = {
|
||||||
@ -234,37 +249,39 @@ const SortableItem = memo(
|
|||||||
onDropdownClose={() => setOpenDropdown(false)}
|
onDropdownClose={() => setOpenDropdown(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DropdownMenuSub>
|
{projectsEnabled && (
|
||||||
<DropdownMenuSubTrigger className="gap-2">
|
<DropdownMenuSub>
|
||||||
<IconFolder size={16} />
|
<DropdownMenuSubTrigger className="gap-2">
|
||||||
<span>{t('common:projects.addToProject')}</span>
|
<IconFolder size={16} />
|
||||||
</DropdownMenuSubTrigger>
|
<span>{t('common:projects.addToProject')}</span>
|
||||||
<DropdownMenuSubContent className="max-h-60 min-w-44 overflow-y-auto">
|
</DropdownMenuSubTrigger>
|
||||||
{availableProjects.length === 0 ? (
|
<DropdownMenuSubContent className="max-h-60 min-w-44 overflow-y-auto">
|
||||||
<DropdownMenuItem disabled>
|
{availableProjects.length === 0 ? (
|
||||||
<span className="text-left-panel-fg/50">
|
<DropdownMenuItem disabled>
|
||||||
{t('common:projects.noProjectsAvailable')}
|
<span className="text-left-panel-fg/50">
|
||||||
</span>
|
{t('common:projects.noProjectsAvailable')}
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
availableProjects.map((folder) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={folder.id}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
assignThreadToProject(thread.id, folder.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconFolder size={16} />
|
|
||||||
<span className="truncate max-w-[200px]">
|
|
||||||
{folder.name}
|
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))
|
) : (
|
||||||
)}
|
availableProjects.map((folder) => (
|
||||||
</DropdownMenuSubContent>
|
<DropdownMenuItem
|
||||||
</DropdownMenuSub>
|
key={folder.id}
|
||||||
{thread.metadata?.project && (
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
assignThreadToProject(thread.id, folder.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconFolder size={16} />
|
||||||
|
<span className="truncate max-w-[200px]">
|
||||||
|
{folder.name}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
|
{projectsEnabled && thread.metadata?.project && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@ -201,6 +201,9 @@ vi.mock('@tabler/icons-react', () => ({
|
|||||||
IconAtom: () => <svg data-testid="atom-icon">Atom</svg>,
|
IconAtom: () => <svg data-testid="atom-icon">Atom</svg>,
|
||||||
IconTool: () => <svg data-testid="tool-icon">Tool</svg>,
|
IconTool: () => <svg data-testid="tool-icon">Tool</svg>,
|
||||||
IconCodeCircle2: () => <svg data-testid="code-icon">Code</svg>,
|
IconCodeCircle2: () => <svg data-testid="code-icon">Code</svg>,
|
||||||
|
IconPaperclip: () => <svg data-testid="paperclip-icon">Paperclip</svg>,
|
||||||
|
IconLoader2: () => <svg data-testid="loader-icon">Loader</svg>,
|
||||||
|
IconCheck: () => <svg data-testid="check-icon">Check</svg>,
|
||||||
IconPlayerStopFilled: () => <svg className="tabler-icon-player-stop-filled" data-testid="stop-icon">Stop</svg>,
|
IconPlayerStopFilled: () => <svg className="tabler-icon-player-stop-filled" data-testid="stop-icon">Stop</svg>,
|
||||||
IconX: () => <svg data-testid="x-icon">X</svg>,
|
IconX: () => <svg data-testid="x-icon">X</svg>,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -73,6 +73,7 @@ vi.mock('@/lib/platform/const', () => ({
|
|||||||
PlatformFeatures: {
|
PlatformFeatures: {
|
||||||
WEB_AUTO_MODEL_SELECTION: false,
|
WEB_AUTO_MODEL_SELECTION: false,
|
||||||
MODEL_PROVIDER_SETTINGS: true,
|
MODEL_PROVIDER_SETTINGS: true,
|
||||||
|
projects: true,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -122,6 +122,7 @@ vi.mock('@/lib/platform/const', () => ({
|
|||||||
ASSISTANTS: true,
|
ASSISTANTS: true,
|
||||||
MODEL_HUB: true,
|
MODEL_HUB: true,
|
||||||
AUTHENTICATION: false,
|
AUTHENTICATION: false,
|
||||||
|
projects: true,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ vi.mock('@/lib/platform/const', () => ({
|
|||||||
alternateShortcutBindings: false,
|
alternateShortcutBindings: false,
|
||||||
firstMessagePersistedThread: false,
|
firstMessagePersistedThread: false,
|
||||||
temporaryChat: false,
|
temporaryChat: false,
|
||||||
|
projects: true,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,10 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
|||||||
// Default model providers (OpenAI, Anthropic, etc.) - disabled for web-only Jan builds
|
// Default model providers (OpenAI, Anthropic, etc.) - disabled for web-only Jan builds
|
||||||
[PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(),
|
[PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Projects management
|
||||||
|
[PlatformFeature.PROJECTS]:
|
||||||
|
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
|
||||||
|
|
||||||
// Analytics and telemetry - disabled for web
|
// Analytics and telemetry - disabled for web
|
||||||
[PlatformFeature.ANALYTICS]:
|
[PlatformFeature.ANALYTICS]:
|
||||||
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
|
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
|
||||||
|
|||||||
@ -35,6 +35,8 @@ export enum PlatformFeature {
|
|||||||
|
|
||||||
// Default model providers (OpenAI, Anthropic, etc.)
|
// Default model providers (OpenAI, Anthropic, etc.)
|
||||||
DEFAULT_PROVIDERS = 'defaultProviders',
|
DEFAULT_PROVIDERS = 'defaultProviders',
|
||||||
|
// Projects management
|
||||||
|
PROJECTS = 'projects',
|
||||||
|
|
||||||
// Analytics and telemetry
|
// Analytics and telemetry
|
||||||
ANALYTICS = 'analytics',
|
ANALYTICS = 'analytics',
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import ThreadList from '@/containers/ThreadList'
|
|||||||
import DropdownAssistant from '@/containers/DropdownAssistant'
|
import DropdownAssistant from '@/containers/DropdownAssistant'
|
||||||
|
|
||||||
import { PlatformFeatures } from '@/lib/platform/const'
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
|
||||||
import { PlatformFeature } from '@/lib/platform/types'
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
import { IconMessage } from '@tabler/icons-react'
|
import { IconMessage } from '@tabler/icons-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -22,6 +23,14 @@ export const Route = createFileRoute('/project/$projectId')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function ProjectPage() {
|
function ProjectPage() {
|
||||||
|
return (
|
||||||
|
<PlatformGuard feature={PlatformFeature.PROJECTS}>
|
||||||
|
<ProjectPageContent />
|
||||||
|
</PlatformGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectPageContent() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { projectId } = useParams({ from: '/project/$projectId' })
|
const { projectId } = useParams({ from: '/project/$projectId' })
|
||||||
const { getFolderById } = useThreadManagement()
|
const { getFolderById } = useThreadManagement()
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useState, useMemo } from 'react'
|
|||||||
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||||
import { useThreads } from '@/hooks/useThreads'
|
import { useThreads } from '@/hooks/useThreads'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
import HeaderPage from '@/containers/HeaderPage'
|
import HeaderPage from '@/containers/HeaderPage'
|
||||||
import ThreadList from '@/containers/ThreadList'
|
import ThreadList from '@/containers/ThreadList'
|
||||||
@ -28,7 +30,11 @@ export const Route = createFileRoute('/project/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function Project() {
|
function Project() {
|
||||||
return <ProjectContent />
|
return (
|
||||||
|
<PlatformGuard feature={PlatformFeature.PROJECTS}>
|
||||||
|
<ProjectContent />
|
||||||
|
</PlatformGuard>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectContent() {
|
function ProjectContent() {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ vi.mock('@/lib/platform/const', () => ({
|
|||||||
systemIntegrations: true,
|
systemIntegrations: true,
|
||||||
httpsProxy: true,
|
httpsProxy: true,
|
||||||
defaultProviders: true,
|
defaultProviders: true,
|
||||||
|
projects: true,
|
||||||
analytics: true,
|
analytics: true,
|
||||||
webAutoModelSelection: false,
|
webAutoModelSelection: false,
|
||||||
modelProviderSettings: true,
|
modelProviderSettings: true,
|
||||||
@ -25,6 +26,7 @@ vi.mock('@/lib/platform/const', () => ({
|
|||||||
extensionsSettings: true,
|
extensionsSettings: true,
|
||||||
assistants: true,
|
assistants: true,
|
||||||
authentication: false,
|
authentication: false,
|
||||||
|
attachments: true,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user