web: update model capabilites (#6814)

* update model capabilites

* refactor + remove projects
This commit is contained in:
Dinh Long Nguyen 2025-10-24 01:31:21 +07:00 committed by GitHub
parent 147cab94a8
commit e46200868e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 396 additions and 132 deletions

View File

@ -4,8 +4,9 @@
*/
import { getSharedAuthService, JanAuthService } from '../shared'
import { JanModel, janProviderStore } from './store'
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
@ -19,12 +20,7 @@ const TEMPORARY_CHAT_ID = 'temporary-chat'
*/
function getChatCompletionConfig(request: JanChatCompletionRequest, stream: boolean = false) {
const isTemporaryChat = request.conversation_id === TEMPORARY_CHAT_ID
// 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 endpoint = `${JAN_API_BASE}${JAN_API_ROUTES.CHAT_COMPLETIONS}`
const payload = {
...request,
@ -44,9 +40,30 @@ function getChatCompletionConfig(request: JanChatCompletionRequest, stream: bool
return { endpoint, payload, isTemporaryChat }
}
export interface JanModelsResponse {
interface JanModelSummary {
id: 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 {
@ -112,6 +129,8 @@ export interface JanChatCompletionChunk {
export class JanApiClient {
private static instance: JanApiClient
private authService: JanAuthService
private modelsCache: JanModel[] | null = null
private modelsFetchPromise: Promise<JanModel[]> | null = null
private constructor() {
this.authService = getSharedAuthService()
@ -124,25 +143,64 @@ export class JanApiClient {
return JanApiClient.instance
}
async getModels(): Promise<JanModel[]> {
async getModels(options?: { forceRefresh?: boolean }): Promise<JanModel[]> {
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.clearError()
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${JAN_API_BASE}/conv/models`
)
this.modelsFetchPromise = (async () => {
const response = await this.authService.makeAuthenticatedRequest<JanModelsResponse>(
`${JAN_API_BASE}${JAN_API_ROUTES.MODELS}`
)
const models = response.data || []
janProviderStore.setModels(models)
const summaries = response.data || []
return models
const models: JanModel[] = await Promise.all(
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) {
this.modelsCache = null
this.modelsFetchPromise = null
const errorMessage = error instanceof ApiError ? error.message :
error instanceof Error ? error.message : 'Failed to fetch models'
janProviderStore.setError(errorMessage)
janProviderStore.setLoadingModels(false)
throw error
} finally {
this.modelsFetchPromise = null
}
}
@ -254,7 +312,7 @@ export class JanApiClient {
async initialize(): Promise<void> {
try {
janProviderStore.setAuthenticated(true)
// Fetch initial models
// Fetch initial models (cached for subsequent calls)
await this.getModels()
console.log('Jan API client initialized successfully')
} catch (error) {
@ -266,6 +324,52 @@ export class JanApiClient {
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()

View 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'

View 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
}

View File

@ -14,12 +14,10 @@ import {
ImportOptions,
} from '@janhq/core' // cspell: disable-line
import { janApiClient, JanChatMessage } from './api'
import { syncJanModelsLocalStorage } from './helpers'
import { janProviderStore } from './store'
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 {
readonly provider = 'jan'
private activeSessions: Map<string, SessionInfo> = new Map()
@ -28,11 +26,11 @@ export default class JanProviderWeb extends AIEngine {
console.log('Loading Jan Provider Extension...')
try {
// Check and clear invalid Jan models (capabilities mismatch)
this.validateJanModelsLocalStorage()
// Initialize authentication and fetch models
// Initialize authentication
await janApiClient.initialize()
// Check and sync stored Jan models against latest catalog data
await this.validateJanModelsLocalStorage()
console.log('Jan Provider Extension loaded successfully')
} catch (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
private validateJanModelsLocalStorage() {
private async validateJanModelsLocalStorage(): Promise<void> {
try {
console.log('Validating Jan models in localStorage...')
const storageKey = 'model-provider'
const data = localStorage.getItem(storageKey)
if (!data) return
const parsed = JSON.parse(data)
if (!parsed?.state?.providers) return
const remoteModels = await janApiClient.getModels()
const storageUpdated = syncJanModelsLocalStorage(remoteModels)
// Check if any Jan model has incorrect capabilities
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,
})
)
}
if (storageUpdated) {
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()
}
} catch (error) {
@ -132,7 +88,7 @@ export default class JanProviderWeb extends AIEngine {
path: undefined, // Remote model, no local path
owned_by: model.owned_by,
object: model.object,
capabilities: [...JAN_MODEL_CAPABILITIES],
capabilities: [...model.capabilities],
}
: undefined
)
@ -153,7 +109,7 @@ export default class JanProviderWeb extends AIEngine {
path: undefined, // Remote model, no local path
owned_by: model.owned_by,
object: model.object,
capabilities: [...JAN_MODEL_CAPABILITIES],
capabilities: [...model.capabilities],
}))
} catch (error) {
console.error('Failed to list Jan models:', error)

View File

@ -9,6 +9,9 @@ export interface JanModel {
id: string
object: string
owned_by: string
created?: number
capabilities: string[]
supportedParameters?: string[]
}
export interface JanProviderState {

View File

@ -5,7 +5,7 @@
declare const JAN_API_BASE: string
import { User, AuthState, AuthBroadcastMessage } from './types'
import { User, AuthState, AuthBroadcastMessage, AuthTokens } from './types'
import {
AUTH_STORAGE_KEYS,
AUTH_ENDPOINTS,
@ -115,7 +115,7 @@ export class JanAuthService {
// Store tokens and set authenticated state
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
this.setAuthProvider(providerId)
this.authBroadcast.broadcastLogin()
@ -158,7 +158,7 @@ export class JanAuthService {
const tokens = await refreshToken()
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
} catch (error) {
console.error('Failed to refresh access token:', error)
if (error instanceof ApiError && error.isStatus(401)) {
@ -343,6 +343,23 @@ export class JanAuthService {
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
*/
@ -352,7 +369,7 @@ export class JanAuthService {
if (!this.accessToken || Date.now() > this.tokenExpiryTime) {
const tokens = await guestLogin()
this.accessToken = tokens.access_token
this.tokenExpiryTime = Date.now() + tokens.expires_in * 1000
this.tokenExpiryTime = this.computeTokenExpiry(tokens)
}
} catch (error) {
console.error('Failed to ensure guest access:', error)
@ -387,7 +404,6 @@ export class JanAuthService {
case AUTH_EVENTS.LOGOUT:
// Another tab logged out, clear our state
this.clearAuthState()
this.ensureGuestAccess().catch(console.error)
break
}
})

View File

@ -16,7 +16,8 @@ export type AuthType = ProviderType | 'guest'
export interface AuthTokens {
access_token: string
expires_in: number
expires_in?: number
expires_at?: string
object: string
}

View File

@ -44,7 +44,6 @@ import { McpExtensionToolLoader } from './McpExtensionToolLoader'
import { ExtensionTypeEnum, MCPExtension, fs, RAGExtension } from '@janhq/core'
import { ExtensionManager } from '@/lib/extension'
import { useAttachments } from '@/hooks/useAttachments'
import { open } from '@tauri-apps/plugin-dialog'
import { toast } from 'sonner'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
@ -333,7 +332,7 @@ const ChatInput = ({
toast.info('Attachments are disabled in Settings')
return
}
const selection = await open({
const selection = await serviceHub.dialog().open({
multiple: true,
filters: [
{

View File

@ -56,7 +56,8 @@ const mainMenus = [
title: 'common:projects.title',
icon: IconFolderPlus,
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 [searchTerm, setSearchTerm] = useState('')
const { isAuthenticated } = useAuth()
const projectsEnabled = PlatformFeatures[PlatformFeature.PROJECTS]
const isSmallScreen = useSmallScreen()
const prevScreenSizeRef = useRef<boolean | null>(null)
@ -402,7 +404,9 @@ const LeftPanel = () => {
})}
</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="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
@ -670,23 +674,29 @@ const LeftPanel = () => {
</aside>
{/* Project Dialogs */}
<AddProjectDialog
open={projectDialogOpen}
onOpenChange={setProjectDialogOpen}
editingKey={editingProjectKey}
initialData={
editingProjectKey ? getFolderById(editingProjectKey) : undefined
}
onSave={handleProjectSave}
/>
<DeleteProjectDialog
open={deleteProjectConfirmOpen}
onOpenChange={handleProjectDeleteClose}
projectId={deletingProjectId ?? undefined}
projectName={
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
}
/>
{projectsEnabled && (
<>
<AddProjectDialog
open={projectDialogOpen}
onOpenChange={setProjectDialogOpen}
editingKey={editingProjectKey}
initialData={
editingProjectKey ? getFolderById(editingProjectKey) : undefined
}
onSave={handleProjectSave}
/>
<DeleteProjectDialog
open={deleteProjectConfirmOpen}
onOpenChange={handleProjectDeleteClose}
projectId={deletingProjectId ?? undefined}
projectName={
deletingProjectId
? getFolderById(deletingProjectId)?.name
: undefined
}
/>
</>
)}
</>
)
}

View File

@ -25,6 +25,8 @@ import { useLeftPanel } from '@/hooks/useLeftPanel'
import { useMessages } from '@/hooks/useMessages'
import { cn, extractThinkingContent } from '@/lib/utils'
import { useSmallScreen } from '@/hooks/useMediaQuery'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import {
DropdownMenu,
@ -88,6 +90,7 @@ const SortableItem = memo(
'threadId' in match.params &&
match.params.threadId === thread.id
)
const projectsEnabled = PlatformFeatures[PlatformFeature.PROJECTS]
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
if (openDropdown) {
@ -111,6 +114,9 @@ const SortableItem = memo(
}, [thread.title])
const availableProjects = useMemo(() => {
if (!projectsEnabled) {
return []
}
return folders
.filter((f) => {
// Exclude the current project page we're on
@ -120,9 +126,18 @@ const SortableItem = memo(
return true
})
.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) => {
if (!projectsEnabled) {
return
}
const project = getFolderById(projectId)
if (project && updateThread) {
const projectMetadata = {
@ -234,37 +249,39 @@ const SortableItem = memo(
onDropdownClose={() => setOpenDropdown(false)}
/>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<IconFolder size={16} />
<span>{t('common:projects.addToProject')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-60 min-w-44 overflow-y-auto">
{availableProjects.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-left-panel-fg/50">
{t('common:projects.noProjectsAvailable')}
</span>
</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}
{projectsEnabled && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<IconFolder size={16} />
<span>{t('common:projects.addToProject')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-60 min-w-44 overflow-y-auto">
{availableProjects.length === 0 ? (
<DropdownMenuItem disabled>
<span className="text-left-panel-fg/50">
{t('common:projects.noProjectsAvailable')}
</span>
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
{thread.metadata?.project && (
) : (
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>
</DropdownMenuItem>
))
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{projectsEnabled && thread.metadata?.project && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem

View File

@ -201,6 +201,9 @@ vi.mock('@tabler/icons-react', () => ({
IconAtom: () => <svg data-testid="atom-icon">Atom</svg>,
IconTool: () => <svg data-testid="tool-icon">Tool</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>,
IconX: () => <svg data-testid="x-icon">X</svg>,
}))

View File

@ -73,6 +73,7 @@ vi.mock('@/lib/platform/const', () => ({
PlatformFeatures: {
WEB_AUTO_MODEL_SELECTION: false,
MODEL_PROVIDER_SETTINGS: true,
projects: true,
},
}))

View File

@ -122,6 +122,7 @@ vi.mock('@/lib/platform/const', () => ({
ASSISTANTS: true,
MODEL_HUB: true,
AUTHENTICATION: false,
projects: true,
},
}))

View File

@ -34,6 +34,7 @@ vi.mock('@/lib/platform/const', () => ({
alternateShortcutBindings: false,
firstMessagePersistedThread: false,
temporaryChat: false,
projects: true,
},
}))

View File

@ -38,6 +38,10 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
// Default model providers (OpenAI, Anthropic, etc.) - disabled for web-only Jan builds
[PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(),
// Projects management
[PlatformFeature.PROJECTS]:
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
// Analytics and telemetry - disabled for web
[PlatformFeature.ANALYTICS]:
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),

View File

@ -35,6 +35,8 @@ export enum PlatformFeature {
// Default model providers (OpenAI, Anthropic, etc.)
DEFAULT_PROVIDERS = 'defaultProviders',
// Projects management
PROJECTS = 'projects',
// Analytics and telemetry
ANALYTICS = 'analytics',

View File

@ -11,6 +11,7 @@ import ThreadList from '@/containers/ThreadList'
import DropdownAssistant from '@/containers/DropdownAssistant'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
import { PlatformFeature } from '@/lib/platform/types'
import { IconMessage } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
@ -22,6 +23,14 @@ export const Route = createFileRoute('/project/$projectId')({
})
function ProjectPage() {
return (
<PlatformGuard feature={PlatformFeature.PROJECTS}>
<ProjectPageContent />
</PlatformGuard>
)
}
function ProjectPageContent() {
const { t } = useTranslation()
const { projectId } = useParams({ from: '/project/$projectId' })
const { getFolderById } = useThreadManagement()

View File

@ -4,6 +4,8 @@ import { useState, useMemo } from 'react'
import { useThreadManagement } from '@/hooks/useThreadManagement'
import { useThreads } from '@/hooks/useThreads'
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 ThreadList from '@/containers/ThreadList'
@ -28,7 +30,11 @@ export const Route = createFileRoute('/project/')({
})
function Project() {
return <ProjectContent />
return (
<PlatformGuard feature={PlatformFeature.PROJECTS}>
<ProjectContent />
</PlatformGuard>
)
}
function ProjectContent() {

View File

@ -17,6 +17,7 @@ vi.mock('@/lib/platform/const', () => ({
systemIntegrations: true,
httpsProxy: true,
defaultProviders: true,
projects: true,
analytics: true,
webAutoModelSelection: false,
modelProviderSettings: true,
@ -25,6 +26,7 @@ vi.mock('@/lib/platform/const', () => ({
extensionsSettings: true,
assistants: true,
authentication: false,
attachments: true,
}
}))