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 { 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)
|
||||
|
||||
return models
|
||||
const summaries = response.data || []
|
||||
|
||||
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()
|
||||
|
||||
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,
|
||||
} 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)
|
||||
|
||||
@ -9,6 +9,9 @@ export interface JanModel {
|
||||
id: string
|
||||
object: string
|
||||
owned_by: string
|
||||
created?: number
|
||||
capabilities: string[]
|
||||
supportedParameters?: string[]
|
||||
}
|
||||
|
||||
export interface JanProviderState {
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
}))
|
||||
|
||||
@ -73,6 +73,7 @@ vi.mock('@/lib/platform/const', () => ({
|
||||
PlatformFeatures: {
|
||||
WEB_AUTO_MODEL_SELECTION: false,
|
||||
MODEL_PROVIDER_SETTINGS: true,
|
||||
projects: true,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@ -122,6 +122,7 @@ vi.mock('@/lib/platform/const', () => ({
|
||||
ASSISTANTS: true,
|
||||
MODEL_HUB: true,
|
||||
AUTHENTICATION: false,
|
||||
projects: true,
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ vi.mock('@/lib/platform/const', () => ({
|
||||
alternateShortcutBindings: false,
|
||||
firstMessagePersistedThread: 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
|
||||
[PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(),
|
||||
|
||||
// Projects management
|
||||
[PlatformFeature.PROJECTS]:
|
||||
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
|
||||
|
||||
// Analytics and telemetry - disabled for web
|
||||
[PlatformFeature.ANALYTICS]:
|
||||
isPlatformTauri() && !isPlatformIOS() && !isPlatformAndroid(),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user