Merge pull request #6470 from menloresearch/dev
This commit is contained in:
commit
e88b8baf19
38
.github/ISSUE_TEMPLATE/4-goal.md
vendored
38
.github/ISSUE_TEMPLATE/4-goal.md
vendored
@ -5,34 +5,20 @@ title: 'goal: '
|
|||||||
type: Goal
|
type: Goal
|
||||||
---
|
---
|
||||||
|
|
||||||
## Goal
|
## 🎯 Goal
|
||||||
|
<!-- Short description of our goal -->
|
||||||
|
|
||||||
> Why are we doing this? 1 liner value proposition
|
## 📖 Context
|
||||||
|
<!-- Give a description of our current context -->
|
||||||
|
|
||||||
_e.g. Make onboarding to Jan 3x easier_
|
## ✅ Scope
|
||||||
|
<!-- High lever description of what we are going to deliver -->
|
||||||
|
|
||||||
## Success Criteria
|
## ❌ Out of Scope
|
||||||
|
<!-- What we are not targeting / delivering / discussing in this goal -->
|
||||||
|
|
||||||
> When do we consider this done? Limit to 3.
|
## 🛠 Deliverables
|
||||||
|
<!-- What we are the tangible deliverables for this goal -->
|
||||||
|
|
||||||
1. _e.g. Redesign onboarding flow to remove redundant steps._
|
## ❓Open questions
|
||||||
2. _e.g. Add a “getting started” guide_
|
<!-- What are we not sure about and need to discuss more -->
|
||||||
3. _e.g. Make local model setup more “normie” friendly_
|
|
||||||
|
|
||||||
## Non Goals
|
|
||||||
|
|
||||||
> What is out of scope?
|
|
||||||
|
|
||||||
- _e.g. Take advanced users through customizing settings_
|
|
||||||
|
|
||||||
## User research (if any)
|
|
||||||
|
|
||||||
> Links to user messages and interviews
|
|
||||||
|
|
||||||
## Design inspo
|
|
||||||
|
|
||||||
> Links
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
> What are we not sure about?
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,6 +13,7 @@ core/lib/**
|
|||||||
.yarnrc
|
.yarnrc
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
test_results.html
|
test_results.html
|
||||||
|
pre-install
|
||||||
|
|
||||||
# docs
|
# docs
|
||||||
docs/yarn.lock
|
docs/yarn.lock
|
||||||
|
|||||||
@ -9,13 +9,7 @@
|
|||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"title": "Docs",
|
"title": "Docs"
|
||||||
"display": "hidden"
|
|
||||||
},
|
|
||||||
"Documentation": {
|
|
||||||
"type": "page",
|
|
||||||
"title": "Documentation",
|
|
||||||
"href": "https://docs.jan.ai"
|
|
||||||
},
|
},
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"type": "page",
|
"type": "page",
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { Settings } from 'lucide-react'
|
|||||||
`llama.cpp` is the core **inference engine** Jan uses to run AI models locally on your computer. This section covers the settings for the engine itself, which control *how* a model processes information on your hardware.
|
`llama.cpp` is the core **inference engine** Jan uses to run AI models locally on your computer. This section covers the settings for the engine itself, which control *how* a model processes information on your hardware.
|
||||||
|
|
||||||
<Callout>
|
<Callout>
|
||||||
Looking for API server settings (like port, host, CORS)? They have been moved to the dedicated [**Local API Server**](/docs/local-server/api-server) page.
|
Looking for API server settings (like port, host, CORS)? They have been moved to the dedicated [**Local API Server**](/docs/api-server) page.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
## Accessing Engine Settings
|
## Accessing Engine Settings
|
||||||
|
|||||||
@ -174,7 +174,7 @@ This includes configuration for:
|
|||||||
- CORS (Cross-Origin Resource Sharing)
|
- CORS (Cross-Origin Resource Sharing)
|
||||||
- Verbose Logging
|
- Verbose Logging
|
||||||
|
|
||||||
[**Go to Local API Server Settings →**](/docs/local-server/api-server)
|
[**Go to Local API Server Settings →**](/docs/api-server)
|
||||||
|
|
||||||
## Emergency Options
|
## Emergency Options
|
||||||
|
|
||||||
|
|||||||
@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* Web Assistant Extension
|
|
||||||
* Implements assistant management using IndexedDB
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Assistant, AssistantExtension } from '@janhq/core'
|
|
||||||
import { getSharedDB } from '../shared/db'
|
|
||||||
|
|
||||||
export default class AssistantExtensionWeb extends AssistantExtension {
|
|
||||||
private db: IDBDatabase | null = null
|
|
||||||
|
|
||||||
private defaultAssistant: Assistant = {
|
|
||||||
avatar: '👋',
|
|
||||||
thread_location: undefined,
|
|
||||||
id: 'jan',
|
|
||||||
object: 'assistant',
|
|
||||||
created_at: Date.now() / 1000,
|
|
||||||
name: 'Jan',
|
|
||||||
description:
|
|
||||||
'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the user\'s behalf.',
|
|
||||||
model: '*',
|
|
||||||
instructions:
|
|
||||||
'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\n' +
|
|
||||||
'When responding:\n' +
|
|
||||||
'- Answer directly from your knowledge when you can\n' +
|
|
||||||
'- Be concise, clear, and helpful\n' +
|
|
||||||
'- Admit when you\'re unsure rather than making things up\n\n' +
|
|
||||||
'If tools are available to you:\n' +
|
|
||||||
'- Only use tools when they add real value to your response\n' +
|
|
||||||
'- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n' +
|
|
||||||
'- Use tools for information you don\'t know or that needs verification\n' +
|
|
||||||
'- Never use tools just because they\'re available\n\n' +
|
|
||||||
'When using tools:\n' +
|
|
||||||
'- Use one tool at a time and wait for results\n' +
|
|
||||||
'- Use actual values as arguments, not variable names\n' +
|
|
||||||
'- Learn from each result before deciding next steps\n' +
|
|
||||||
'- Avoid repeating the same tool call with identical parameters\n\n' +
|
|
||||||
'Remember: Most questions can be answered without tools. Think first whether you need them.\n\n' +
|
|
||||||
'Current date: {{current_date}}',
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
type: 'retrieval',
|
|
||||||
enabled: false,
|
|
||||||
useTimeWeightedRetriever: false,
|
|
||||||
settings: {
|
|
||||||
top_k: 2,
|
|
||||||
chunk_size: 1024,
|
|
||||||
chunk_overlap: 64,
|
|
||||||
retrieval_template: `Use the following pieces of context to answer the question at the end.
|
|
||||||
{context}
|
|
||||||
Question: {question}
|
|
||||||
Helpful Answer:`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
file_ids: [],
|
|
||||||
metadata: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
async onLoad() {
|
|
||||||
console.log('Loading Web Assistant Extension')
|
|
||||||
this.db = await getSharedDB()
|
|
||||||
|
|
||||||
// Create default assistant if none exist
|
|
||||||
const assistants = await this.getAssistants()
|
|
||||||
if (assistants.length === 0) {
|
|
||||||
await this.createAssistant(this.defaultAssistant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
// Don't close shared DB, other extensions might be using it
|
|
||||||
this.db = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureDB(): void {
|
|
||||||
if (!this.db) {
|
|
||||||
throw new Error('Database not initialized. Call onLoad() first.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAssistants(): Promise<Assistant[]> {
|
|
||||||
this.ensureDB()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = this.db!.transaction(['assistants'], 'readonly')
|
|
||||||
const store = transaction.objectStore('assistants')
|
|
||||||
const request = store.getAll()
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
resolve(request.result || [])
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
reject(request.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async createAssistant(assistant: Assistant): Promise<void> {
|
|
||||||
this.ensureDB()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = this.db!.transaction(['assistants'], 'readwrite')
|
|
||||||
const store = transaction.objectStore('assistants')
|
|
||||||
|
|
||||||
const assistantToStore = {
|
|
||||||
...assistant,
|
|
||||||
created_at: assistant.created_at || Date.now() / 1000,
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = store.add(assistantToStore)
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
console.log('Assistant created:', assistant.id)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
console.error('Failed to create assistant:', request.error)
|
|
||||||
reject(request.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateAssistant(id: string, assistant: Partial<Assistant>): Promise<void> {
|
|
||||||
this.ensureDB()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = this.db!.transaction(['assistants'], 'readwrite')
|
|
||||||
const store = transaction.objectStore('assistants')
|
|
||||||
|
|
||||||
// First get the existing assistant
|
|
||||||
const getRequest = store.get(id)
|
|
||||||
|
|
||||||
getRequest.onsuccess = () => {
|
|
||||||
const existingAssistant = getRequest.result
|
|
||||||
if (!existingAssistant) {
|
|
||||||
reject(new Error(`Assistant with id ${id} not found`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedAssistant = {
|
|
||||||
...existingAssistant,
|
|
||||||
...assistant,
|
|
||||||
id, // Ensure ID doesn't change
|
|
||||||
}
|
|
||||||
|
|
||||||
const putRequest = store.put(updatedAssistant)
|
|
||||||
|
|
||||||
putRequest.onsuccess = () => resolve()
|
|
||||||
putRequest.onerror = () => reject(putRequest.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
getRequest.onerror = () => {
|
|
||||||
reject(getRequest.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAssistant(assistant: Assistant): Promise<void> {
|
|
||||||
this.ensureDB()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = this.db!.transaction(['assistants'], 'readwrite')
|
|
||||||
const store = transaction.objectStore('assistants')
|
|
||||||
const request = store.delete(assistant.id)
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
console.log('Assistant deleted:', assistant.id)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
console.error('Failed to delete assistant:', request.error)
|
|
||||||
reject(request.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAssistant(id: string): Promise<Assistant | null> {
|
|
||||||
this.ensureDB()
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = this.db!.transaction(['assistants'], 'readonly')
|
|
||||||
const store = transaction.objectStore('assistants')
|
|
||||||
const request = store.get(id)
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
resolve(request.result || null)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
reject(request.error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import type { WebExtensionRegistry } from './types'
|
import type { WebExtensionRegistry } from './types'
|
||||||
|
|
||||||
export { default as AssistantExtensionWeb } from './assistant-web'
|
|
||||||
export { default as ConversationalExtensionWeb } from './conversational-web'
|
export { default as ConversationalExtensionWeb } from './conversational-web'
|
||||||
export { default as JanProviderWeb } from './jan-provider-web'
|
export { default as JanProviderWeb } from './jan-provider-web'
|
||||||
export { default as MCPExtensionWeb } from './mcp-web'
|
export { default as MCPExtensionWeb } from './mcp-web'
|
||||||
@ -16,7 +15,6 @@ export type {
|
|||||||
WebExtensionModule,
|
WebExtensionModule,
|
||||||
WebExtensionName,
|
WebExtensionName,
|
||||||
WebExtensionLoader,
|
WebExtensionLoader,
|
||||||
AssistantWebModule,
|
|
||||||
ConversationalWebModule,
|
ConversationalWebModule,
|
||||||
JanProviderWebModule,
|
JanProviderWebModule,
|
||||||
MCPWebModule
|
MCPWebModule
|
||||||
@ -24,7 +22,6 @@ export type {
|
|||||||
|
|
||||||
// Extension registry for dynamic loading
|
// Extension registry for dynamic loading
|
||||||
export const WEB_EXTENSIONS: WebExtensionRegistry = {
|
export const WEB_EXTENSIONS: WebExtensionRegistry = {
|
||||||
'assistant-web': () => import('./assistant-web'),
|
|
||||||
'conversational-web': () => import('./conversational-web'),
|
'conversational-web': () => import('./conversational-web'),
|
||||||
'jan-provider-web': () => import('./jan-provider-web'),
|
'jan-provider-web': () => import('./jan-provider-web'),
|
||||||
'mcp-web': () => import('./mcp-web'),
|
'mcp-web': () => import('./mcp-web'),
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* Handles API requests to Jan backend for models and chat completions
|
* Handles API requests to Jan backend for models and chat completions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { JanAuthService } from '../shared/auth'
|
import { getSharedAuthService, JanAuthService } from '../shared'
|
||||||
import { JanModel, janProviderStore } from './store'
|
import { JanModel, janProviderStore } from './store'
|
||||||
|
|
||||||
// JAN_API_BASE is defined in vite.config.ts
|
// JAN_API_BASE is defined in vite.config.ts
|
||||||
@ -77,7 +77,7 @@ export class JanApiClient {
|
|||||||
private authService: JanAuthService
|
private authService: JanAuthService
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
this.authService = JanAuthService.getInstance()
|
this.authService = getSharedAuthService()
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): JanApiClient {
|
static getInstance(): JanApiClient {
|
||||||
@ -216,12 +216,9 @@ export class JanApiClient {
|
|||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.authService.initialize()
|
|
||||||
janProviderStore.setAuthenticated(true)
|
janProviderStore.setAuthenticated(true)
|
||||||
|
|
||||||
// Fetch initial models
|
// Fetch initial models
|
||||||
await this.getModels()
|
await this.getModels()
|
||||||
|
|
||||||
console.log('Jan API client initialized successfully')
|
console.log('Jan API client initialized successfully')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize API client'
|
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize API client'
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MCPExtension, MCPTool, MCPToolCallResult } from '@janhq/core'
|
import { MCPExtension, MCPTool, MCPToolCallResult } from '@janhq/core'
|
||||||
import { JanAuthService } from '../shared/auth'
|
import { getSharedAuthService, JanAuthService } from '../shared'
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||||
import { JanMCPOAuthProvider } from './oauth-provider'
|
import { JanMCPOAuthProvider } from './oauth-provider'
|
||||||
@ -30,14 +30,12 @@ export default class MCPExtensionWeb extends MCPExtension {
|
|||||||
version?: string
|
version?: string
|
||||||
) {
|
) {
|
||||||
super(url, name, productName, active, description, version)
|
super(url, name, productName, active, description, version)
|
||||||
this.authService = JanAuthService.getInstance()
|
this.authService = getSharedAuthService()
|
||||||
this.oauthProvider = new JanMCPOAuthProvider(this.authService)
|
this.oauthProvider = new JanMCPOAuthProvider(this.authService)
|
||||||
}
|
}
|
||||||
|
|
||||||
async onLoad(): Promise<void> {
|
async onLoad(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Initialize authentication first
|
|
||||||
await this.authService.initialize()
|
|
||||||
// Initialize MCP client with OAuth
|
// Initialize MCP client with OAuth
|
||||||
await this.initializeMCPClient()
|
await this.initializeMCPClient()
|
||||||
// Then fetch tools
|
// Then fetch tools
|
||||||
|
|||||||
@ -20,21 +20,13 @@ const AUTH_STORAGE_KEY = 'jan_auth_tokens'
|
|||||||
const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry
|
const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry
|
||||||
|
|
||||||
export class JanAuthService {
|
export class JanAuthService {
|
||||||
private static instance: JanAuthService
|
|
||||||
private tokens: AuthTokens | null = null
|
private tokens: AuthTokens | null = null
|
||||||
private tokenExpiryTime: number = 0
|
private tokenExpiryTime: number = 0
|
||||||
|
|
||||||
private constructor() {
|
constructor() {
|
||||||
this.loadTokensFromStorage()
|
this.loadTokensFromStorage()
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): JanAuthService {
|
|
||||||
if (!JanAuthService.instance) {
|
|
||||||
JanAuthService.instance = new JanAuthService()
|
|
||||||
}
|
|
||||||
return JanAuthService.instance
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadTokensFromStorage(): void {
|
private loadTokensFromStorage(): void {
|
||||||
try {
|
try {
|
||||||
const storedTokens = localStorage.getItem(AUTH_STORAGE_KEY)
|
const storedTokens = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||||
@ -169,16 +161,6 @@ export class JanAuthService {
|
|||||||
return this.tokens.access_token
|
return this.tokens.access_token
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.getValidAccessToken()
|
|
||||||
console.log('Jan auth service initialized successfully')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize Jan auth service:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAuthHeader(): Promise<{ Authorization: string }> {
|
async getAuthHeader(): Promise<{ Authorization: string }> {
|
||||||
const token = await this.getValidAccessToken()
|
const token = await this.getValidAccessToken()
|
||||||
return {
|
return {
|
||||||
@ -218,3 +200,20 @@ export class JanAuthService {
|
|||||||
this.clearTokens()
|
this.clearTokens()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
janAuthService?: JanAuthService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or creates the shared JanAuthService instance on the window object
|
||||||
|
* This ensures all extensions use the same auth service instance
|
||||||
|
*/
|
||||||
|
export function getSharedAuthService(): JanAuthService {
|
||||||
|
if (!window.janAuthService) {
|
||||||
|
window.janAuthService = new JanAuthService()
|
||||||
|
}
|
||||||
|
return window.janAuthService
|
||||||
|
}
|
||||||
@ -1,3 +1,3 @@
|
|||||||
export { getSharedDB } from './db'
|
export { getSharedDB } from './db'
|
||||||
export { JanAuthService } from './auth'
|
export { JanAuthService, getSharedAuthService } from './auth'
|
||||||
export type { AuthTokens, AuthResponse } from './auth'
|
export type { AuthTokens, AuthResponse } from './auth'
|
||||||
@ -2,14 +2,10 @@
|
|||||||
* Web Extension Types
|
* Web Extension Types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AssistantExtension, ConversationalExtension, BaseExtension, AIEngine, MCPExtension } from '@janhq/core'
|
import type { ConversationalExtension, BaseExtension, AIEngine, MCPExtension } from '@janhq/core'
|
||||||
|
|
||||||
type ExtensionConstructorParams = ConstructorParameters<typeof BaseExtension>
|
type ExtensionConstructorParams = ConstructorParameters<typeof BaseExtension>
|
||||||
|
|
||||||
export interface AssistantWebModule {
|
|
||||||
default: new (...args: ExtensionConstructorParams) => AssistantExtension
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationalWebModule {
|
export interface ConversationalWebModule {
|
||||||
default: new (...args: ExtensionConstructorParams) => ConversationalExtension
|
default: new (...args: ExtensionConstructorParams) => ConversationalExtension
|
||||||
}
|
}
|
||||||
@ -22,10 +18,9 @@ export interface MCPWebModule {
|
|||||||
default: new (...args: ExtensionConstructorParams) => MCPExtension
|
default: new (...args: ExtensionConstructorParams) => MCPExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebExtensionModule = AssistantWebModule | ConversationalWebModule | JanProviderWebModule | MCPWebModule
|
export type WebExtensionModule = ConversationalWebModule | JanProviderWebModule | MCPWebModule
|
||||||
|
|
||||||
export interface WebExtensionRegistry {
|
export interface WebExtensionRegistry {
|
||||||
'assistant-web': () => Promise<AssistantWebModule>
|
|
||||||
'conversational-web': () => Promise<ConversationalWebModule>
|
'conversational-web': () => Promise<ConversationalWebModule>
|
||||||
'jan-provider-web': () => Promise<JanProviderWebModule>
|
'jan-provider-web': () => Promise<JanProviderWebModule>
|
||||||
'mcp-web': () => Promise<MCPWebModule>
|
'mcp-web': () => Promise<MCPWebModule>
|
||||||
|
|||||||
@ -231,11 +231,6 @@ async function main() {
|
|||||||
console.log('Downloads completed.')
|
console.log('Downloads completed.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the downloads directory exists
|
|
||||||
if (!fs.existsSync('downloads')) {
|
|
||||||
fs.mkdirSync('downloads')
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error('Error:', err)
|
console.error('Error:', err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@ -162,7 +162,7 @@ pub async fn load_llama_model<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for server to be ready or timeout
|
// Wait for server to be ready or timeout
|
||||||
let timeout_duration = Duration::from_secs(180); // 3 minutes timeout
|
let timeout_duration = Duration::from_secs(300); // 5 minutes timeout
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
log::info!("Waiting for model session to be ready...");
|
log::info!("Waiting for model session to be ready...");
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ pub async fn start_server<R: Runtime>(
|
|||||||
prefix: String,
|
prefix: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
trusted_hosts: Vec<String>,
|
trusted_hosts: Vec<String>,
|
||||||
|
proxy_timeout: u64,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let server_handle = state.server_handle.clone();
|
let server_handle = state.server_handle.clone();
|
||||||
let plugin_state: State<LlamacppState> = app_handle.state();
|
let plugin_state: State<LlamacppState> = app_handle.state();
|
||||||
@ -26,6 +27,7 @@ pub async fn start_server<R: Runtime>(
|
|||||||
prefix,
|
prefix,
|
||||||
api_key,
|
api_key,
|
||||||
vec![trusted_hosts],
|
vec![trusted_hosts],
|
||||||
|
proxy_timeout,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@ -631,6 +631,7 @@ pub async fn start_server(
|
|||||||
prefix: String,
|
prefix: String,
|
||||||
proxy_api_key: String,
|
proxy_api_key: String,
|
||||||
trusted_hosts: Vec<Vec<String>>,
|
trusted_hosts: Vec<Vec<String>>,
|
||||||
|
proxy_timeout: u64,
|
||||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut handle_guard = server_handle.lock().await;
|
let mut handle_guard = server_handle.lock().await;
|
||||||
if handle_guard.is_some() {
|
if handle_guard.is_some() {
|
||||||
@ -648,7 +649,7 @@ pub async fn start_server(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(300))
|
.timeout(std::time::Duration::from_secs(proxy_timeout))
|
||||||
.pool_max_idle_per_host(10)
|
.pool_max_idle_per_host(10)
|
||||||
.pool_idle_timeout(std::time::Duration::from_secs(30))
|
.pool_idle_timeout(std::time::Duration::from_secs(30))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const DropdownAssistant = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const selectedAssistant =
|
const selectedAssistant =
|
||||||
assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
|
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ const mainMenus = [
|
|||||||
title: 'common:assistants',
|
title: 'common:assistants',
|
||||||
icon: IconClipboardSmileFilled,
|
icon: IconClipboardSmileFilled,
|
||||||
route: route.assistant,
|
route: route.assistant,
|
||||||
isEnabled: true,
|
isEnabled: PlatformFeatures[PlatformFeature.ASSISTANTS],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'common:hub',
|
title: 'common:hub',
|
||||||
|
|||||||
430
web-app/src/containers/ModelCombobox.tsx
Normal file
430
web-app/src/containers/ModelCombobox.tsx
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { IconChevronDown, IconLoader2, IconRefresh } from '@tabler/icons-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
|
||||||
|
// Hook for the dropdown position
|
||||||
|
function useDropdownPosition(open: boolean, containerRef: React.RefObject<HTMLDivElement | null>) {
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 })
|
||||||
|
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 4,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
width: rect.width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
// Update the position when the dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
updateDropdownPosition()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open, updateDropdownPosition])
|
||||||
|
|
||||||
|
// Update the position when the window is resized
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
updateDropdownPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
window.addEventListener('scroll', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('scroll', handleResize)
|
||||||
|
}
|
||||||
|
}, [open, updateDropdownPosition])
|
||||||
|
|
||||||
|
return { dropdownPosition, updateDropdownPosition }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Components for the different sections of the dropdown
|
||||||
|
const ErrorSection = ({ error, t }: { error: string; t: (key: string) => string }) => (
|
||||||
|
<div className="px-3 py-2 text-sm text-destructive">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-destructive font-medium">{t('common:failedToLoadModels')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-main-view-fg/50 mt-0">{error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LoadingSection = ({ t }: { t: (key: string) => string }) => (
|
||||||
|
<div className="flex items-center justify-center px-3 py-3 text-sm text-main-view-fg/50">
|
||||||
|
<IconLoader2 className="h-4 w-4 animate-spin mr-2 text-main-view-fg/50" />
|
||||||
|
<span className="text-sm text-main-view-fg/50">{t('common:loading')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const EmptySection = ({ inputValue, t }: { inputValue: string; t: (key: string, options?: Record<string, string>) => string }) => (
|
||||||
|
<div className="px-3 py-3 text-sm text-main-view-fg/50 text-center">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{inputValue.trim() ? (
|
||||||
|
<span className="text-main-view-fg/50">{t('common:noModelsFoundFor', { searchValue: inputValue })}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-main-view-fg/50">{t('common:noModels')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ModelsList = ({
|
||||||
|
filteredModels,
|
||||||
|
value,
|
||||||
|
highlightedIndex,
|
||||||
|
onModelSelect,
|
||||||
|
onHighlight
|
||||||
|
}: {
|
||||||
|
filteredModels: string[]
|
||||||
|
value: string
|
||||||
|
highlightedIndex: number
|
||||||
|
onModelSelect: (model: string) => void
|
||||||
|
onHighlight: (index: number) => void
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{filteredModels.map((model, index) => (
|
||||||
|
<div
|
||||||
|
key={model}
|
||||||
|
data-model={model}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onModelSelect(model)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onHighlight(index)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer px-3 py-2 hover:bg-main-view-fg/15 hover:shadow-sm transition-all duration-200 text-main-view-fg',
|
||||||
|
value === model && 'bg-main-view-fg/12 shadow-sm',
|
||||||
|
highlightedIndex === index && 'bg-main-view-fg/20 shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm truncate text-main-view-fg">{model}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Custom hook for keyboard navigation
|
||||||
|
function useKeyboardNavigation(
|
||||||
|
open: boolean,
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
models: string[],
|
||||||
|
filteredModels: string[],
|
||||||
|
highlightedIndex: number,
|
||||||
|
setHighlightedIndex: React.Dispatch<React.SetStateAction<number>>,
|
||||||
|
onModelSelect: (model: string) => void,
|
||||||
|
dropdownRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Scroll to the highlighted element
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightedIndex >= 0 && dropdownRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const modelElements = dropdownRef.current?.querySelectorAll('[data-model]')
|
||||||
|
const highlightedElement = modelElements?.[highlightedIndex] as HTMLElement
|
||||||
|
if (highlightedElement) {
|
||||||
|
highlightedElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
behavior: 'auto'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, dropdownRef])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
// Open the dropdown with the arrows if closed
|
||||||
|
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||||
|
if (models.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(true)
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev < filteredModels.length - 1 ? prev + 1 : 0))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedIndex((prev: number) => filteredModels.length === 0 ? 0 : (prev > 0 ? prev - 1 : filteredModels.length - 1))
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (highlightedIndex >= 0 && highlightedIndex < filteredModels.length) {
|
||||||
|
onModelSelect(filteredModels[highlightedIndex])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
break
|
||||||
|
case 'PageUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedIndex(0)
|
||||||
|
break
|
||||||
|
case 'PageDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlightedIndex(filteredModels.length - 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, [open, setOpen, models.length, filteredModels, highlightedIndex, setHighlightedIndex, onModelSelect])
|
||||||
|
|
||||||
|
return { handleKeyDown }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelComboboxProps = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
models: string[]
|
||||||
|
loading?: boolean
|
||||||
|
error?: string | null
|
||||||
|
onRefresh?: () => void
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelCombobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
models,
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
onRefresh,
|
||||||
|
placeholder = 'Type or select a model...',
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
onOpenChange,
|
||||||
|
}: ModelComboboxProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(value)
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// Sync input value with prop value
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
// Notify parent when open state changes
|
||||||
|
useEffect(() => {
|
||||||
|
onOpenChange?.(open)
|
||||||
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
|
// Hook for the dropdown position
|
||||||
|
const { dropdownPosition } = useDropdownPosition(open, containerRef)
|
||||||
|
|
||||||
|
// Optimized model filtering
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
if (!inputValue.trim()) return models
|
||||||
|
const searchValue = inputValue.toLowerCase()
|
||||||
|
return models.filter((model) => model.toLowerCase().includes(searchValue))
|
||||||
|
}, [models, inputValue])
|
||||||
|
|
||||||
|
// Reset highlighted index when filtered models change
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
}, [filteredModels])
|
||||||
|
|
||||||
|
// Close the dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const handleClickOutside = (event: Event) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
const isInsideContainer = containerRef.current?.contains(target)
|
||||||
|
const isInsideDropdown = dropdownRef.current?.contains(target)
|
||||||
|
|
||||||
|
if (!isInsideContainer && !isInsideDropdown) {
|
||||||
|
setOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = ['mousedown', 'touchstart']
|
||||||
|
events.forEach(eventType => {
|
||||||
|
document.addEventListener(eventType, handleClickOutside, { capture: true, passive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
events.forEach(eventType => {
|
||||||
|
document.removeEventListener(eventType, handleClickOutside, { capture: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Cleanup: close the dropdown when the component is unmounted
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handler for the input change
|
||||||
|
const handleInputChange = useCallback((newValue: string) => {
|
||||||
|
setInputValue(newValue)
|
||||||
|
onChange(newValue)
|
||||||
|
|
||||||
|
// Open the dropdown if the user types and there are models
|
||||||
|
if (newValue.trim() && models.length > 0) {
|
||||||
|
setOpen(true)
|
||||||
|
} else {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}, [onChange, models.length])
|
||||||
|
|
||||||
|
// Handler for the model selection
|
||||||
|
const handleModelSelect = useCallback((model: string) => {
|
||||||
|
setInputValue(model)
|
||||||
|
onChange(model)
|
||||||
|
setOpen(false)
|
||||||
|
setHighlightedIndex(-1)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
// Hook for the keyboard navigation
|
||||||
|
const { handleKeyDown } = useKeyboardNavigation(
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
models,
|
||||||
|
filteredModels,
|
||||||
|
highlightedIndex,
|
||||||
|
setHighlightedIndex,
|
||||||
|
handleModelSelect,
|
||||||
|
dropdownRef
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler for the dropdown opening
|
||||||
|
const handleDropdownToggle = useCallback(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
setOpen(!open)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Handler for the input click
|
||||||
|
const handleInputClick = useCallback(() => {
|
||||||
|
if (models.length > 0) {
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}, [models.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)} ref={containerRef}>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => handleInputChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={handleInputClick}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className="pr-16"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Input action buttons */}
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex gap-1">
|
||||||
|
{onRefresh && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRefresh()
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10"
|
||||||
|
aria-label="Refresh models"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<IconLoader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<IconRefresh className="h-3 w-3 opacity-70" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
className="h-6 w-6 p-0 no-underline hover:bg-main-view-fg/10"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<IconLoader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<IconChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom dropdown rendered as portal */}
|
||||||
|
{open && dropdownPosition.width > 0 && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[9999] bg-main-view border border-main-view-fg/10 rounded-md shadow-lg max-h-[300px] overflow-y-auto text-main-view-fg animate-in fade-in-0 zoom-in-95 duration-200"
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
width: dropdownPosition.width,
|
||||||
|
minWidth: dropdownPosition.width,
|
||||||
|
maxWidth: dropdownPosition.width,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
data-dropdown="model-combobox"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Error state */}
|
||||||
|
{error && <ErrorSection error={error} t={t} />}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{loading && <LoadingSection t={t} />}
|
||||||
|
|
||||||
|
{/* Models list */}
|
||||||
|
{!loading && !error && (
|
||||||
|
filteredModels.length === 0 ? (
|
||||||
|
<EmptySection inputValue={inputValue} t={t} />
|
||||||
|
) : (
|
||||||
|
<ModelsList
|
||||||
|
filteredModels={filteredModels}
|
||||||
|
value={value}
|
||||||
|
highlightedIndex={highlightedIndex}
|
||||||
|
onModelSelect={handleModelSelect}
|
||||||
|
onHighlight={setHighlightedIndex}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
web-app/src/containers/ProxyTimeoutInput.tsx
Normal file
39
web-app/src/containers/ProxyTimeoutInput.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function ProxyTimeoutInput({ isServerRunning }: { isServerRunning?: boolean }) {
|
||||||
|
const { proxyTimeout, setProxyTimeout } = useLocalApiServer()
|
||||||
|
const [inputValue, setInputValue] = useState(proxyTimeout.toString())
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setInputValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
const timeout = parseInt(inputValue)
|
||||||
|
if (!isNaN(timeout) && timeout >= 0 && timeout <= 86400) {
|
||||||
|
setProxyTimeout(timeout)
|
||||||
|
} else {
|
||||||
|
// Reset to current value if invalid
|
||||||
|
setInputValue(proxyTimeout.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={86400}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={cn(
|
||||||
|
'w-24 h-8 text-sm',
|
||||||
|
isServerRunning && 'opacity-50 pointer-events-none'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -104,7 +104,7 @@ const SettingsMenu = () => {
|
|||||||
title: 'common:mcp-servers',
|
title: 'common:mcp-servers',
|
||||||
route: route.settings.mcp_servers,
|
route: route.settings.mcp_servers,
|
||||||
hasSubMenu: false,
|
hasSubMenu: false,
|
||||||
isEnabled: PlatformFeatures[PlatformFeature.MCP_SERVERS],
|
isEnabled: PlatformFeatures[PlatformFeature.MCP_SERVERS_SETTINGS],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'common:local_api_server',
|
title: 'common:local_api_server',
|
||||||
@ -122,7 +122,7 @@ const SettingsMenu = () => {
|
|||||||
title: 'common:extensions',
|
title: 'common:extensions',
|
||||||
route: route.settings.extensions,
|
route: route.settings.extensions,
|
||||||
hasSubMenu: false,
|
hasSubMenu: false,
|
||||||
isEnabled: PlatformFeatures[PlatformFeature.EXTENSION_MANAGEMENT],
|
isEnabled: PlatformFeatures[PlatformFeature.EXTENSIONS_SETTINGS],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
490
web-app/src/containers/__tests__/ModelCombobox.test.tsx
Normal file
490
web-app/src/containers/__tests__/ModelCombobox.test.tsx
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import React from 'react'
|
||||||
|
import { ModelCombobox } from '../ModelCombobox'
|
||||||
|
|
||||||
|
// Mock translation hook
|
||||||
|
vi.mock('@/i18n/react-i18next-compat', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, string>) => {
|
||||||
|
if (key === 'common:failedToLoadModels') return 'Failed to load models'
|
||||||
|
if (key === 'common:loading') return 'Loading'
|
||||||
|
if (key === 'common:noModelsFoundFor') return `No models found for "${options?.searchValue}"`
|
||||||
|
if (key === 'common:noModels') return 'No models available'
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ModelCombobox', () => {
|
||||||
|
const mockOnChange = vi.fn()
|
||||||
|
const mockOnRefresh = vi.fn()
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
value: '',
|
||||||
|
onChange: mockOnChange,
|
||||||
|
models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-haiku'],
|
||||||
|
}
|
||||||
|
|
||||||
|
let bcrSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
let scrollSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const mockRect = {
|
||||||
|
width: 300,
|
||||||
|
height: 40,
|
||||||
|
top: 100,
|
||||||
|
left: 50,
|
||||||
|
bottom: 140,
|
||||||
|
right: 350,
|
||||||
|
x: 50,
|
||||||
|
y: 100,
|
||||||
|
toJSON: () => {},
|
||||||
|
} as unknown as DOMRect
|
||||||
|
|
||||||
|
bcrSpy = vi
|
||||||
|
.spyOn(Element.prototype as any, 'getBoundingClientRect')
|
||||||
|
.mockReturnValue(mockRect)
|
||||||
|
|
||||||
|
Element.prototype.scrollIntoView = () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
bcrSpy?.mockRestore()
|
||||||
|
scrollSpy?.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders input field with default placeholder', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom placeholder', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} placeholder="Choose a model" />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Choose a model')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dropdown trigger button', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
expect(button).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays current value in input', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} value="gpt-4" />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('gpt-4')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ModelCombobox {...defaultProps} className="custom-class" />
|
||||||
|
)
|
||||||
|
|
||||||
|
const wrapper = container.firstChild as HTMLElement
|
||||||
|
expect(wrapper).toHaveClass('custom-class')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables input when disabled prop is true', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} disabled />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
|
||||||
|
expect(input).toBeDisabled()
|
||||||
|
expect(button).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading spinner in trigger button', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} loading />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
const spinner = button.querySelector('.animate-spin')
|
||||||
|
expect(spinner).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading section when dropdown is opened during loading', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} loading />)
|
||||||
|
|
||||||
|
// Click input to trigger dropdown opening
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
// Wait for dropdown to appear and check loading section
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onChange when typing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'g')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('g')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates input value when typing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'test')
|
||||||
|
|
||||||
|
expect(input).toHaveValue('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles input focus', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with empty models array', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} models={[]} />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with models array', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles mount and unmount without errors', () => {
|
||||||
|
const { unmount } = render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles props changes', () => {
|
||||||
|
const { rerender } = render(<ModelCombobox {...defaultProps} value="" />)
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<ModelCombobox {...defaultProps} value="gpt-4" />)
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('gpt-4')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles models array changes', () => {
|
||||||
|
const { rerender } = render(<ModelCombobox {...defaultProps} models={[]} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<ModelCombobox {...defaultProps} models={['model1', 'model2']} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not open dropdown when clicking input with no models', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} models={[]} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
// Should focus but not open dropdown
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts error prop without crashing', () => {
|
||||||
|
act(() => {
|
||||||
|
render(<ModelCombobox {...defaultProps} error="Test error message" />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Type or select a model...')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with all props', () => {
|
||||||
|
act(() => {
|
||||||
|
render(
|
||||||
|
<ModelCombobox
|
||||||
|
{...defaultProps}
|
||||||
|
loading
|
||||||
|
error="Error message"
|
||||||
|
onRefresh={mockOnRefresh}
|
||||||
|
placeholder="Custom placeholder"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown when clicking trigger button', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
await user.click(button)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown when clicking input', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters models based on input value', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'gpt-4')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows filtered models in dropdown when typing', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Type 'gpt' to trigger dropdown opening
|
||||||
|
await user.type(input, 'gpt')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Should show GPT models
|
||||||
|
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
|
// Should not show Claude
|
||||||
|
expect(screen.queryByText('claude-3-haiku')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles case insensitive filtering', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'GPT')
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('GPT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no models match filter', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Type something that doesn't match any model to trigger dropdown + empty state
|
||||||
|
await user.type(input, 'nonexistent')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
// Should show empty state message
|
||||||
|
expect(screen.getByText('No models found for "nonexistent"')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects model from dropdown when clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const modelOption = screen.getByText('gpt-4')
|
||||||
|
expect(modelOption).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelOption = screen.getByText('gpt-4')
|
||||||
|
await user.click(modelOption)
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt-4')
|
||||||
|
expect(input).toHaveValue('gpt-4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits input value with Enter key', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'gpt')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays error message in dropdown', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} error="Network connection failed" />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Click input to open dropdown
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
// Error messages should be displayed
|
||||||
|
expect(screen.getByText('Failed to load models')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Network connection failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onRefresh when refresh button is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnRefresh = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} error="Network error" onRefresh={localMockOnRefresh} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Click input to open dropdown
|
||||||
|
await user.click(input)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open with error section
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
const refreshButton = document.querySelector('[aria-label="Refresh models"]')
|
||||||
|
expect(refreshButton).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshButton = document.querySelector('[aria-label="Refresh models"]')
|
||||||
|
if (refreshButton) {
|
||||||
|
await user.click(refreshButton)
|
||||||
|
expect(localMockOnRefresh).toHaveBeenCalledTimes(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens dropdown when pressing ArrowDown', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
expect(input).toHaveFocus()
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates through models with arrow keys', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ModelCombobox {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
|
||||||
|
// ArrowDown should open dropdown
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to second item
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const secondModel = screen.getByText('gpt-4')
|
||||||
|
const modelElement = secondModel.closest('[data-model]')
|
||||||
|
expect(modelElement).toHaveClass('bg-main-view-fg/20')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles Enter key to select highlighted model', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const localMockOnChange = vi.fn()
|
||||||
|
render(<ModelCombobox {...defaultProps} onChange={localMockOnChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
// Type 'gpt' to open dropdown and filter models
|
||||||
|
await user.type(input, 'gpt')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Dropdown should be open with filtered models
|
||||||
|
const dropdown = document.querySelector('[data-dropdown="model-combobox"]')
|
||||||
|
expect(dropdown).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to highlight first model and select it
|
||||||
|
await user.keyboard('{ArrowDown}')
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
|
||||||
|
expect(localMockOnChange).toHaveBeenCalledWith('gpt-3.5-turbo')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,8 +8,9 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||||
|
import { useProviderModels } from '@/hooks/useProviderModels'
|
||||||
|
import { ModelCombobox } from '@/containers/ModelCombobox'
|
||||||
import { IconPlus } from '@tabler/icons-react'
|
import { IconPlus } from '@tabler/icons-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { getProviderTitle } from '@/lib/utils'
|
import { getProviderTitle } from '@/lib/utils'
|
||||||
@ -25,6 +26,12 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
|
|||||||
const { updateProvider } = useModelProvider()
|
const { updateProvider } = useModelProvider()
|
||||||
const [modelId, setModelId] = useState<string>('')
|
const [modelId, setModelId] = useState<string>('')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [isComboboxOpen, setIsComboboxOpen] = useState(false)
|
||||||
|
|
||||||
|
// Fetch models from provider API (API key is optional)
|
||||||
|
const { models, loading, error, refetch } = useProviderModels(
|
||||||
|
provider.base_url ? provider : undefined
|
||||||
|
)
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
@ -62,7 +69,13 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent
|
||||||
|
onEscapeKeyDown={(e: KeyboardEvent) => {
|
||||||
|
if (isComboboxOpen) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('providers:addModel.title')}</DialogTitle>
|
<DialogTitle>{t('providers:addModel.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@ -72,7 +85,7 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Model ID field - required */}
|
{/* Model selection field - required */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="model-id"
|
htmlFor="model-id"
|
||||||
@ -81,12 +94,16 @@ export const DialogAddModel = ({ provider, trigger }: DialogAddModelProps) => {
|
|||||||
{t('providers:addModel.modelId')}{' '}
|
{t('providers:addModel.modelId')}{' '}
|
||||||
<span className="text-destructive">*</span>
|
<span className="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<ModelCombobox
|
||||||
id="model-id"
|
key={`${provider.provider}-${provider.base_url || ''}`}
|
||||||
value={modelId}
|
value={modelId}
|
||||||
onChange={(e) => setModelId(e.target.value)}
|
onChange={setModelId}
|
||||||
|
models={models}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onRefresh={refetch}
|
||||||
placeholder={t('providers:addModel.enterModelId')}
|
placeholder={t('providers:addModel.enterModelId')}
|
||||||
required
|
onOpenChange={setIsComboboxOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ describe('useLocalApiServer', () => {
|
|||||||
store.setVerboseLogs(true)
|
store.setVerboseLogs(true)
|
||||||
store.setTrustedHosts([])
|
store.setTrustedHosts([])
|
||||||
store.setApiKey('')
|
store.setApiKey('')
|
||||||
|
store.setProxyTimeout(600)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize with default values', () => {
|
it('should initialize with default values', () => {
|
||||||
@ -45,6 +46,7 @@ describe('useLocalApiServer', () => {
|
|||||||
expect(result.current.verboseLogs).toBe(true)
|
expect(result.current.verboseLogs).toBe(true)
|
||||||
expect(result.current.trustedHosts).toEqual([])
|
expect(result.current.trustedHosts).toEqual([])
|
||||||
expect(result.current.apiKey).toBe('')
|
expect(result.current.apiKey).toBe('')
|
||||||
|
expect(result.current.proxyTimeout).toBe(600)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('enableOnStartup', () => {
|
describe('enableOnStartup', () => {
|
||||||
@ -317,6 +319,32 @@ describe('useLocalApiServer', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('proxyTimeout', () => {
|
||||||
|
it('should set proxy timeout', () => {
|
||||||
|
const { result } = renderHook(() => useLocalApiServer())
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setProxyTimeout(1800)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.proxyTimeout).toBe(1800)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different proxy timeouts', () => {
|
||||||
|
const { result } = renderHook(() => useLocalApiServer())
|
||||||
|
|
||||||
|
const testTimeouts = [100, 300, 600, 3600]
|
||||||
|
|
||||||
|
testTimeouts.forEach((timeout) => {
|
||||||
|
act(() => {
|
||||||
|
result.current.setProxyTimeout(timeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.proxyTimeout).toBe(timeout)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('state persistence', () => {
|
describe('state persistence', () => {
|
||||||
it('should maintain state across multiple hook instances', () => {
|
it('should maintain state across multiple hook instances', () => {
|
||||||
const { result: result1 } = renderHook(() => useLocalApiServer())
|
const { result: result1 } = renderHook(() => useLocalApiServer())
|
||||||
@ -331,6 +359,7 @@ describe('useLocalApiServer', () => {
|
|||||||
result1.current.setVerboseLogs(false)
|
result1.current.setVerboseLogs(false)
|
||||||
result1.current.setApiKey('test-key')
|
result1.current.setApiKey('test-key')
|
||||||
result1.current.addTrustedHost('example.com')
|
result1.current.addTrustedHost('example.com')
|
||||||
|
result1.current.setProxyTimeout(1800)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result2.current.enableOnStartup).toBe(false)
|
expect(result2.current.enableOnStartup).toBe(false)
|
||||||
@ -341,6 +370,7 @@ describe('useLocalApiServer', () => {
|
|||||||
expect(result2.current.verboseLogs).toBe(false)
|
expect(result2.current.verboseLogs).toBe(false)
|
||||||
expect(result2.current.apiKey).toBe('test-key')
|
expect(result2.current.apiKey).toBe('test-key')
|
||||||
expect(result2.current.trustedHosts).toEqual(['example.com'])
|
expect(result2.current.trustedHosts).toEqual(['example.com'])
|
||||||
|
expect(result2.current.proxyTimeout).toBe(1800)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -356,6 +386,7 @@ describe('useLocalApiServer', () => {
|
|||||||
result.current.addTrustedHost('localhost')
|
result.current.addTrustedHost('localhost')
|
||||||
result.current.addTrustedHost('127.0.0.1')
|
result.current.addTrustedHost('127.0.0.1')
|
||||||
result.current.setApiKey('sk-test-key')
|
result.current.setApiKey('sk-test-key')
|
||||||
|
result.current.setProxyTimeout(800)
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.current.serverHost).toBe('0.0.0.0')
|
expect(result.current.serverHost).toBe('0.0.0.0')
|
||||||
@ -364,6 +395,7 @@ describe('useLocalApiServer', () => {
|
|||||||
expect(result.current.corsEnabled).toBe(false)
|
expect(result.current.corsEnabled).toBe(false)
|
||||||
expect(result.current.trustedHosts).toEqual(['localhost', '127.0.0.1'])
|
expect(result.current.trustedHosts).toEqual(['localhost', '127.0.0.1'])
|
||||||
expect(result.current.apiKey).toBe('sk-test-key')
|
expect(result.current.apiKey).toBe('sk-test-key')
|
||||||
|
expect(result.current.proxyTimeout).toBe(800)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve independent state changes', () => {
|
it('should preserve independent state changes', () => {
|
||||||
@ -376,6 +408,17 @@ describe('useLocalApiServer', () => {
|
|||||||
expect(result.current.serverPort).toBe(9000)
|
expect(result.current.serverPort).toBe(9000)
|
||||||
expect(result.current.serverHost).toBe('127.0.0.1') // Should remain default
|
expect(result.current.serverHost).toBe('127.0.0.1') // Should remain default
|
||||||
expect(result.current.apiPrefix).toBe('/v1') // Should remain default
|
expect(result.current.apiPrefix).toBe('/v1') // Should remain default
|
||||||
|
expect(result.current.proxyTimeout).toBe(600) // Should remain default
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setProxyTimeout(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.proxyTimeout).toBe(400)
|
||||||
|
expect(result.current.serverPort).toBe(9000) // Should remain default
|
||||||
|
expect(result.current.serverHost).toBe('127.0.0.1') // Should remain default
|
||||||
|
expect(result.current.apiPrefix).toBe('/v1') // Should remain default
|
||||||
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.addTrustedHost('example.com')
|
result.current.addTrustedHost('example.com')
|
||||||
|
|||||||
102
web-app/src/hooks/__tests__/useProviderModels.test.ts
Normal file
102
web-app/src/hooks/__tests__/useProviderModels.test.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { useProviderModels } from '../useProviderModels'
|
||||||
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
||||||
|
|
||||||
|
// Local minimal provider type for tests
|
||||||
|
type MockModelProvider = {
|
||||||
|
active: boolean
|
||||||
|
provider: string
|
||||||
|
base_url?: string
|
||||||
|
api_key?: string
|
||||||
|
settings: any[]
|
||||||
|
models: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useProviderModels', () => {
|
||||||
|
const mockProvider: MockModelProvider = {
|
||||||
|
active: true,
|
||||||
|
provider: 'openai',
|
||||||
|
base_url: 'https://api.openai.com/v1',
|
||||||
|
api_key: 'test-api-key',
|
||||||
|
settings: [],
|
||||||
|
models: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockModels = ['gpt-4', 'gpt-3.5-turbo', 'gpt-4-turbo']
|
||||||
|
|
||||||
|
let fetchModelsSpy: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const hub = (useServiceHub as unknown as () => any)()
|
||||||
|
const mockedFetch = vi.fn()
|
||||||
|
vi.spyOn(hub, 'providers').mockReturnValue({
|
||||||
|
fetchModelsFromProvider: mockedFetch,
|
||||||
|
} as any)
|
||||||
|
fetchModelsSpy = mockedFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with empty state', () => {
|
||||||
|
const { result } = renderHook(() => useProviderModels())
|
||||||
|
|
||||||
|
expect(result.current.models).toEqual([])
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
expect(result.current.error).toBe(null)
|
||||||
|
expect(typeof result.current.refetch).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not fetch models when provider is undefined', () => {
|
||||||
|
renderHook(() => useProviderModels(undefined))
|
||||||
|
expect(fetchModelsSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not fetch models when provider has no base_url', () => {
|
||||||
|
const providerWithoutUrl = { ...mockProvider, base_url: undefined }
|
||||||
|
renderHook(() => useProviderModels(providerWithoutUrl))
|
||||||
|
expect(fetchModelsSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fetch and sort models', async () => {
|
||||||
|
fetchModelsSpy.mockResolvedValueOnce(mockModels)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useProviderModels(mockProvider))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.error).toBe(null)
|
||||||
|
expect(fetchModelsSpy).toHaveBeenCalledWith(mockProvider)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear models when switching to invalid provider', async () => {
|
||||||
|
fetchModelsSpy.mockResolvedValueOnce(mockModels)
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ provider }) => useProviderModels(provider),
|
||||||
|
{ initialProps: { provider: mockProvider } }
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.models).toEqual(['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo'])
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
}, { timeout: 500 })
|
||||||
|
|
||||||
|
// Switch to invalid provider
|
||||||
|
rerender({ provider: { ...mockProvider, base_url: undefined } })
|
||||||
|
|
||||||
|
expect(result.current.models).toEqual([])
|
||||||
|
expect(result.current.error).toBe(null)
|
||||||
|
expect(result.current.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not refetch when provider is undefined', () => {
|
||||||
|
const { result } = renderHook(() => useProviderModels(undefined))
|
||||||
|
|
||||||
|
result.current.refetch()
|
||||||
|
|
||||||
|
expect(fetchModelsSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -50,7 +50,7 @@ export const useAppState = create<AppState>()((set) => ({
|
|||||||
const currentAssistant = useAssistant.getState().currentAssistant
|
const currentAssistant = useAssistant.getState().currentAssistant
|
||||||
|
|
||||||
const selectedAssistant =
|
const selectedAssistant =
|
||||||
assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
|
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0]
|
||||||
|
|
||||||
set(() => ({
|
set(() => ({
|
||||||
streamingContent: content
|
streamingContent: content
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import { getServiceHub } from '@/hooks/useServiceHub'
|
|||||||
import { Assistant as CoreAssistant } from '@janhq/core'
|
import { Assistant as CoreAssistant } from '@janhq/core'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { localStorageKey } from '@/constants/localStorage'
|
import { localStorageKey } from '@/constants/localStorage'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
interface AssistantState {
|
interface AssistantState {
|
||||||
assistants: Assistant[]
|
assistants: Assistant[]
|
||||||
currentAssistant: Assistant
|
currentAssistant: Assistant | null
|
||||||
addAssistant: (assistant: Assistant) => void
|
addAssistant: (assistant: Assistant) => void
|
||||||
updateAssistant: (assistant: Assistant) => void
|
updateAssistant: (assistant: Assistant) => void
|
||||||
deleteAssistant: (id: string) => void
|
deleteAssistant: (id: string) => void
|
||||||
@ -46,12 +48,29 @@ export const defaultAssistant: Assistant = {
|
|||||||
'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}',
|
'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAssistant = create<AssistantState>()((set, get) => ({
|
// Platform-aware initial state
|
||||||
|
const getInitialAssistantState = () => {
|
||||||
|
if (PlatformFeatures[PlatformFeature.ASSISTANTS]) {
|
||||||
|
return {
|
||||||
assistants: [defaultAssistant],
|
assistants: [defaultAssistant],
|
||||||
currentAssistant: defaultAssistant,
|
currentAssistant: defaultAssistant,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
assistants: [],
|
||||||
|
currentAssistant: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAssistant = create<AssistantState>((set, get) => ({
|
||||||
|
...getInitialAssistantState(),
|
||||||
addAssistant: (assistant) => {
|
addAssistant: (assistant) => {
|
||||||
set({ assistants: [...get().assistants, assistant] })
|
set({ assistants: [...get().assistants, assistant] })
|
||||||
getServiceHub().assistants().createAssistant(assistant as unknown as CoreAssistant).catch((error) => {
|
getServiceHub()
|
||||||
|
.assistants()
|
||||||
|
.createAssistant(assistant as unknown as CoreAssistant)
|
||||||
|
.catch((error) => {
|
||||||
console.error('Failed to create assistant:', error)
|
console.error('Failed to create assistant:', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -63,25 +82,31 @@ export const useAssistant = create<AssistantState>()((set, get) => ({
|
|||||||
),
|
),
|
||||||
// Update currentAssistant if it's the same assistant being updated
|
// Update currentAssistant if it's the same assistant being updated
|
||||||
currentAssistant:
|
currentAssistant:
|
||||||
state.currentAssistant.id === assistant.id
|
state.currentAssistant?.id === assistant.id
|
||||||
? assistant
|
? assistant
|
||||||
: state.currentAssistant,
|
: state.currentAssistant,
|
||||||
})
|
})
|
||||||
// Create assistant already cover update logic
|
// Create assistant already cover update logic
|
||||||
getServiceHub().assistants().createAssistant(assistant as unknown as CoreAssistant).catch((error) => {
|
getServiceHub()
|
||||||
|
.assistants()
|
||||||
|
.createAssistant(assistant as unknown as CoreAssistant)
|
||||||
|
.catch((error) => {
|
||||||
console.error('Failed to update assistant:', error)
|
console.error('Failed to update assistant:', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteAssistant: (id) => {
|
deleteAssistant: (id) => {
|
||||||
const state = get()
|
const state = get()
|
||||||
getServiceHub().assistants().deleteAssistant(
|
getServiceHub()
|
||||||
|
.assistants()
|
||||||
|
.deleteAssistant(
|
||||||
state.assistants.find((e) => e.id === id) as unknown as CoreAssistant
|
state.assistants.find((e) => e.id === id) as unknown as CoreAssistant
|
||||||
).catch((error) => {
|
)
|
||||||
|
.catch((error) => {
|
||||||
console.error('Failed to delete assistant:', error)
|
console.error('Failed to delete assistant:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if we're deleting the current assistant
|
// Check if we're deleting the current assistant
|
||||||
const wasCurrentAssistant = state.currentAssistant.id === id
|
const wasCurrentAssistant = state.currentAssistant?.id === id
|
||||||
|
|
||||||
set({ assistants: state.assistants.filter((a) => a.id !== id) })
|
set({ assistants: state.assistants.filter((a) => a.id !== id) })
|
||||||
|
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export const useChat = () => {
|
|||||||
}, [provider, selectedProvider])
|
}, [provider, selectedProvider])
|
||||||
|
|
||||||
const selectedAssistant =
|
const selectedAssistant =
|
||||||
assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
|
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0]
|
||||||
|
|
||||||
const getCurrentThread = useCallback(async () => {
|
const getCurrentThread = useCallback(async () => {
|
||||||
let currentThread = retrieveThread()
|
let currentThread = retrieveThread()
|
||||||
@ -237,7 +237,7 @@ export const useChat = () => {
|
|||||||
|
|
||||||
const builder = new CompletionMessagesBuilder(
|
const builder = new CompletionMessagesBuilder(
|
||||||
messages,
|
messages,
|
||||||
renderInstructions(currentAssistant?.instructions)
|
currentAssistant ? renderInstructions(currentAssistant.instructions) : undefined
|
||||||
)
|
)
|
||||||
if (troubleshooting) builder.addUserMessage(message, attachments)
|
if (troubleshooting) builder.addUserMessage(message, attachments)
|
||||||
|
|
||||||
@ -284,10 +284,10 @@ export const useChat = () => {
|
|||||||
builder.getMessages(),
|
builder.getMessages(),
|
||||||
abortController,
|
abortController,
|
||||||
availableTools,
|
availableTools,
|
||||||
currentAssistant.parameters?.stream === false ? false : true,
|
currentAssistant?.parameters?.stream === false ? false : true,
|
||||||
{
|
{
|
||||||
...modelSettings,
|
...modelSettings,
|
||||||
...currentAssistant.parameters,
|
...(currentAssistant?.parameters || {}),
|
||||||
} as unknown as Record<string, object>
|
} as unknown as Record<string, object>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,9 @@ type LocalApiServerState = {
|
|||||||
addTrustedHost: (host: string) => void
|
addTrustedHost: (host: string) => void
|
||||||
removeTrustedHost: (host: string) => void
|
removeTrustedHost: (host: string) => void
|
||||||
setTrustedHosts: (hosts: string[]) => void
|
setTrustedHosts: (hosts: string[]) => void
|
||||||
|
// Server request timeout (default 600 sec)
|
||||||
|
proxyTimeout: number
|
||||||
|
setProxyTimeout: (value: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLocalApiServer = create<LocalApiServerState>()(
|
export const useLocalApiServer = create<LocalApiServerState>()(
|
||||||
@ -55,6 +58,8 @@ export const useLocalApiServer = create<LocalApiServerState>()(
|
|||||||
trustedHosts: state.trustedHosts.filter((h) => h !== host),
|
trustedHosts: state.trustedHosts.filter((h) => h !== host),
|
||||||
})),
|
})),
|
||||||
setTrustedHosts: (hosts) => set({ trustedHosts: hosts }),
|
setTrustedHosts: (hosts) => set({ trustedHosts: hosts }),
|
||||||
|
proxyTimeout: 600,
|
||||||
|
setProxyTimeout: (value) => set({ proxyTimeout: value }),
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
setApiKey: (value) => set({ apiKey: value }),
|
setApiKey: (value) => set({ apiKey: value }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const useMessages = create<MessageState>()((set, get) => ({
|
|||||||
const currentAssistant = useAssistant.getState().currentAssistant
|
const currentAssistant = useAssistant.getState().currentAssistant
|
||||||
|
|
||||||
const selectedAssistant =
|
const selectedAssistant =
|
||||||
assistants.find((a) => a.id === currentAssistant.id) || assistants[0]
|
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0]
|
||||||
|
|
||||||
const newMessage = {
|
const newMessage = {
|
||||||
...message,
|
...message,
|
||||||
|
|||||||
93
web-app/src/hooks/useProviderModels.ts
Normal file
93
web-app/src/hooks/useProviderModels.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useServiceHub } from './useServiceHub'
|
||||||
|
|
||||||
|
type UseProviderModelsState = {
|
||||||
|
models: string[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
refetch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelsCache = new Map<string, { models: string[]; timestamp: number }>()
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
export const useProviderModels = (provider?: ModelProvider): UseProviderModelsState => {
|
||||||
|
const serviceHub = useServiceHub()
|
||||||
|
const [models, setModels] = useState<string[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const prevProviderKey = useRef<string>('')
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
if (!provider || !provider.base_url) {
|
||||||
|
// Clear models if provider is invalid (base_url is required, api_key is optional)
|
||||||
|
setModels([])
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any previous state when starting a new fetch for a different provider
|
||||||
|
const currentProviderKey = `${provider.provider}-${provider.base_url}`
|
||||||
|
if (currentProviderKey !== prevProviderKey.current) {
|
||||||
|
setModels([])
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
prevProviderKey.current = currentProviderKey
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${provider.provider}-${provider.base_url}`
|
||||||
|
const cached = modelsCache.get(cacheKey)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||||
|
setModels(cached.models)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequestId = ++requestIdRef.current
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchedModels = await serviceHub.providers().fetchModelsFromProvider(provider)
|
||||||
|
if (currentRequestId !== requestIdRef.current) return
|
||||||
|
const sortedModels = fetchedModels.sort((a, b) => a.localeCompare(b))
|
||||||
|
|
||||||
|
setModels(sortedModels)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
modelsCache.set(cacheKey, {
|
||||||
|
models: sortedModels,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (currentRequestId !== requestIdRef.current) return
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch models'
|
||||||
|
setError(errorMessage)
|
||||||
|
console.error(`Error fetching models from ${provider.provider}:`, err)
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === requestIdRef.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [provider, serviceHub])
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
if (provider) {
|
||||||
|
const cacheKey = `${provider.provider}-${provider.base_url}`
|
||||||
|
modelsCache.delete(cacheKey)
|
||||||
|
fetchModels()
|
||||||
|
}
|
||||||
|
}, [provider, fetchModels])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels()
|
||||||
|
}, [fetchModels])
|
||||||
|
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
import { localStorageKey } from '@/constants/localStorage'
|
import { localStorageKey } from '@/constants/localStorage'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
export type ToolApprovalModalProps = {
|
export type ToolApprovalModalProps = {
|
||||||
toolName: string
|
toolName: string
|
||||||
@ -32,7 +34,7 @@ export const useToolApproval = create<ToolApprovalState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
approvedTools: {},
|
approvedTools: {},
|
||||||
allowAllMCPPermissions: false,
|
allowAllMCPPermissions: PlatformFeatures[PlatformFeature.MCP_AUTO_APPROVE_TOOLS],
|
||||||
isModalOpen: false,
|
isModalOpen: false,
|
||||||
modalProps: null,
|
modalProps: null,
|
||||||
|
|
||||||
@ -55,6 +57,12 @@ export const useToolApproval = create<ToolApprovalState>()(
|
|||||||
|
|
||||||
showApprovalModal: (toolName: string, threadId: string, toolParameters?: object) => {
|
showApprovalModal: (toolName: string, threadId: string, toolParameters?: object) => {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
|
// Auto-approve MCP tools when feature is enabled
|
||||||
|
if (PlatformFeatures[PlatformFeature.MCP_AUTO_APPROVE_TOOLS]) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if tool is already approved for this thread
|
// Check if tool is already approved for this thread
|
||||||
const state = get()
|
const state = get()
|
||||||
if (state.isToolApproved(threadId, toolName)) {
|
if (state.isToolApproved(threadId, toolName)) {
|
||||||
|
|||||||
@ -193,6 +193,7 @@ export const sendCompletion = async (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
thread.model.id &&
|
thread.model.id &&
|
||||||
|
models[providerName]?.models !== true && // Skip if provider accepts any model (models: true)
|
||||||
!Object.values(models[providerName]).flat().includes(thread.model.id) &&
|
!Object.values(models[providerName]).flat().includes(thread.model.id) &&
|
||||||
!tokenJS.extendedModelExist(providerName as any, thread.model.id) &&
|
!tokenJS.extendedModelExist(providerName as any, thread.model.id) &&
|
||||||
provider.provider !== 'llamacpp'
|
provider.provider !== 'llamacpp'
|
||||||
@ -396,9 +397,12 @@ export const postMessageProcessing = async (
|
|||||||
let toolParameters = {}
|
let toolParameters = {}
|
||||||
if (toolCall.function.arguments.length) {
|
if (toolCall.function.arguments.length) {
|
||||||
try {
|
try {
|
||||||
|
console.log('Raw tool arguments:', toolCall.function.arguments)
|
||||||
toolParameters = JSON.parse(toolCall.function.arguments)
|
toolParameters = JSON.parse(toolCall.function.arguments)
|
||||||
|
console.log('Parsed tool parameters:', toolParameters)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse tool arguments:', error)
|
console.error('Failed to parse tool arguments:', error)
|
||||||
|
console.error('Raw arguments that failed:', toolCall.function.arguments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const approved =
|
const approved =
|
||||||
@ -414,9 +418,7 @@ export const postMessageProcessing = async (
|
|||||||
|
|
||||||
const { promise, cancel } = getServiceHub().mcp().callToolWithCancellation({
|
const { promise, cancel } = getServiceHub().mcp().callToolWithCancellation({
|
||||||
toolName: toolCall.function.name,
|
toolName: toolCall.function.name,
|
||||||
arguments: toolCall.function.arguments.length
|
arguments: toolCall.function.arguments.length ? toolParameters : {},
|
||||||
? JSON.parse(toolCall.function.arguments)
|
|
||||||
: {},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useAppState.getState().setCancelToolCall(cancel)
|
useAppState.getState().setCancelToolCall(cancel)
|
||||||
|
|||||||
@ -14,15 +14,9 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
|||||||
// Hardware monitoring and GPU usage
|
// Hardware monitoring and GPU usage
|
||||||
[PlatformFeature.HARDWARE_MONITORING]: isPlatformTauri(),
|
[PlatformFeature.HARDWARE_MONITORING]: isPlatformTauri(),
|
||||||
|
|
||||||
// Extension installation/management
|
|
||||||
[PlatformFeature.EXTENSION_MANAGEMENT]: true,
|
|
||||||
|
|
||||||
// Local model inference (llama.cpp)
|
// Local model inference (llama.cpp)
|
||||||
[PlatformFeature.LOCAL_INFERENCE]: isPlatformTauri(),
|
[PlatformFeature.LOCAL_INFERENCE]: isPlatformTauri(),
|
||||||
|
|
||||||
// MCP (Model Context Protocol) servers
|
|
||||||
[PlatformFeature.MCP_SERVERS]: true,
|
|
||||||
|
|
||||||
// Local API server
|
// Local API server
|
||||||
[PlatformFeature.LOCAL_API_SERVER]: isPlatformTauri(),
|
[PlatformFeature.LOCAL_API_SERVER]: isPlatformTauri(),
|
||||||
|
|
||||||
@ -46,4 +40,16 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
|||||||
|
|
||||||
// Model provider settings page management - disabled for web only
|
// Model provider settings page management - disabled for web only
|
||||||
[PlatformFeature.MODEL_PROVIDER_SETTINGS]: isPlatformTauri(),
|
[PlatformFeature.MODEL_PROVIDER_SETTINGS]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Auto-enable MCP tool permissions - enabled for web platform
|
||||||
|
[PlatformFeature.MCP_AUTO_APPROVE_TOOLS]: !isPlatformTauri(),
|
||||||
|
|
||||||
|
// MCP servers settings page - disabled for web
|
||||||
|
[PlatformFeature.MCP_SERVERS_SETTINGS]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Extensions settings page - disabled for web
|
||||||
|
[PlatformFeature.EXTENSIONS_SETTINGS]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Assistant functionality - disabled for web
|
||||||
|
[PlatformFeature.ASSISTANTS]: isPlatformTauri(),
|
||||||
}
|
}
|
||||||
@ -16,15 +16,9 @@ export enum PlatformFeature {
|
|||||||
// Hardware monitoring and GPU usage
|
// Hardware monitoring and GPU usage
|
||||||
HARDWARE_MONITORING = 'hardwareMonitoring',
|
HARDWARE_MONITORING = 'hardwareMonitoring',
|
||||||
|
|
||||||
// Extension installation/management
|
|
||||||
EXTENSION_MANAGEMENT = 'extensionManagement',
|
|
||||||
|
|
||||||
// Local model inference (llama.cpp)
|
// Local model inference (llama.cpp)
|
||||||
LOCAL_INFERENCE = 'localInference',
|
LOCAL_INFERENCE = 'localInference',
|
||||||
|
|
||||||
// MCP (Model Context Protocol) servers
|
|
||||||
MCP_SERVERS = 'mcpServers',
|
|
||||||
|
|
||||||
// Local API server
|
// Local API server
|
||||||
LOCAL_API_SERVER = 'localApiServer',
|
LOCAL_API_SERVER = 'localApiServer',
|
||||||
|
|
||||||
@ -48,4 +42,16 @@ export enum PlatformFeature {
|
|||||||
|
|
||||||
// Model provider settings page management
|
// Model provider settings page management
|
||||||
MODEL_PROVIDER_SETTINGS = 'modelProviderSettings',
|
MODEL_PROVIDER_SETTINGS = 'modelProviderSettings',
|
||||||
|
|
||||||
|
// Auto-enable MCP tool permissions without approval
|
||||||
|
MCP_AUTO_APPROVE_TOOLS = 'mcpAutoApproveTools',
|
||||||
|
|
||||||
|
// MCP servers settings page management
|
||||||
|
MCP_SERVERS_SETTINGS = 'mcpServersSettings',
|
||||||
|
|
||||||
|
// Extensions settings page management
|
||||||
|
EXTENSIONS_SETTINGS = 'extensionsSettings',
|
||||||
|
|
||||||
|
// Assistant functionality (creation, editing, management)
|
||||||
|
ASSISTANTS = 'assistants',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -180,7 +180,9 @@
|
|||||||
"cors": "Cross-Origin Resource Sharing (CORS)",
|
"cors": "Cross-Origin Resource Sharing (CORS)",
|
||||||
"corsDesc": "Erlaube Cross-Origin-Anfragen an den API-Server.",
|
"corsDesc": "Erlaube Cross-Origin-Anfragen an den API-Server.",
|
||||||
"verboseLogs": "Ausführliche Server Logs",
|
"verboseLogs": "Ausführliche Server Logs",
|
||||||
"verboseLogsDesc": "Aktiviere detaillierte Server Logs zum Debuggen"
|
"verboseLogsDesc": "Aktiviere detaillierte Server Logs zum Debuggen",
|
||||||
|
"proxyTimeout": "Zeitüberschreitung bei der Anfrage",
|
||||||
|
"proxyTimeoutDesc": "Wartezeit auf eine Antwort vom lokalen Modell in Sekunden."
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "Analytik",
|
"analytics": "Analytik",
|
||||||
|
|||||||
@ -75,6 +75,8 @@
|
|||||||
"selectAModel": "Select a model",
|
"selectAModel": "Select a model",
|
||||||
"noToolsAvailable": "No tools available",
|
"noToolsAvailable": "No tools available",
|
||||||
"noModelsFoundFor": "No models found for \"{{searchValue}}\"",
|
"noModelsFoundFor": "No models found for \"{{searchValue}}\"",
|
||||||
|
"failedToLoadModels": "Failed to load models",
|
||||||
|
"noModels": "No models found",
|
||||||
"customAvatar": "Custom avatar",
|
"customAvatar": "Custom avatar",
|
||||||
"editAssistant": "Edit Assistant",
|
"editAssistant": "Edit Assistant",
|
||||||
"jan": "Jan",
|
"jan": "Jan",
|
||||||
|
|||||||
@ -183,7 +183,9 @@
|
|||||||
"cors": "Cross-Origin Resource Sharing (CORS)",
|
"cors": "Cross-Origin Resource Sharing (CORS)",
|
||||||
"corsDesc": "Allow cross-origin requests to the API server.",
|
"corsDesc": "Allow cross-origin requests to the API server.",
|
||||||
"verboseLogs": "Verbose Server Logs",
|
"verboseLogs": "Verbose Server Logs",
|
||||||
"verboseLogsDesc": "Enable detailed server logs for debugging."
|
"verboseLogsDesc": "Enable detailed server logs for debugging.",
|
||||||
|
"proxyTimeout": "Request timeout",
|
||||||
|
"proxyTimeoutDesc": "Time to wait for a response from the local model, seconds."
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
|
|||||||
@ -180,7 +180,9 @@
|
|||||||
"cors": "Berbagi Sumber Daya Lintas Asal (CORS)",
|
"cors": "Berbagi Sumber Daya Lintas Asal (CORS)",
|
||||||
"corsDesc": "Izinkan permintaan lintas asal ke server API.",
|
"corsDesc": "Izinkan permintaan lintas asal ke server API.",
|
||||||
"verboseLogs": "Log Server Verbose",
|
"verboseLogs": "Log Server Verbose",
|
||||||
"verboseLogsDesc": "Aktifkan log server terperinci untuk debugging."
|
"verboseLogsDesc": "Aktifkan log server terperinci untuk debugging.",
|
||||||
|
"proxyTimeout": "Permintaan melebihi batas waktu",
|
||||||
|
"proxyTimeoutDesc": "Waktu tunggu untuk respons dari model lokal dalam detik."
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "Analitik",
|
"analytics": "Analitik",
|
||||||
|
|||||||
@ -183,7 +183,9 @@
|
|||||||
"cors": "Cross-Origin Resource Sharing (CORS)",
|
"cors": "Cross-Origin Resource Sharing (CORS)",
|
||||||
"corsDesc": "Pozwalaj na żądania cross-origin do serwera API.",
|
"corsDesc": "Pozwalaj na żądania cross-origin do serwera API.",
|
||||||
"verboseLogs": "Szczegółowe Wpisy Dzienników Serwera",
|
"verboseLogs": "Szczegółowe Wpisy Dzienników Serwera",
|
||||||
"verboseLogsDesc": "Włącz szczegółowe wpisy dzienników serwera na potrzeby rozwiązywania problemów."
|
"verboseLogsDesc": "Włącz szczegółowe wpisy dzienników serwera na potrzeby rozwiązywania problemów.",
|
||||||
|
"proxyTimeout": "Przekroczenie limitu czasu żądania",
|
||||||
|
"proxyTimeoutDesc": "Czas oczekiwania na odpowiedź od lokalnego modelu w sekundach."
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "Dane Analityczne",
|
"analytics": "Dane Analityczne",
|
||||||
|
|||||||
@ -180,7 +180,9 @@
|
|||||||
"cors": "Chia sẻ tài nguyên giữa các nguồn gốc (CORS)",
|
"cors": "Chia sẻ tài nguyên giữa các nguồn gốc (CORS)",
|
||||||
"corsDesc": "Cho phép các yêu cầu cross-origin đến máy chủ API.",
|
"corsDesc": "Cho phép các yêu cầu cross-origin đến máy chủ API.",
|
||||||
"verboseLogs": "Nhật ký máy chủ chi tiết",
|
"verboseLogs": "Nhật ký máy chủ chi tiết",
|
||||||
"verboseLogsDesc": "Bật nhật ký máy chủ chi tiết để gỡ lỗi."
|
"verboseLogsDesc": "Bật nhật ký máy chủ chi tiết để gỡ lỗi.",
|
||||||
|
"proxyTimeout": "Hết thời gian chờ yêu cầu",
|
||||||
|
"proxyTimeoutDesc": "Thời gian chờ phản hồi từ mô hình cục bộ (tính bằng giây)."
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "Phân tích",
|
"analytics": "Phân tích",
|
||||||
|
|||||||
@ -180,7 +180,9 @@
|
|||||||
"cors": "跨源资源共享 (CORS)",
|
"cors": "跨源资源共享 (CORS)",
|
||||||
"corsDesc": "允许跨源请求访问 API 服务器。",
|
"corsDesc": "允许跨源请求访问 API 服务器。",
|
||||||
"verboseLogs": "详细服务器日志",
|
"verboseLogs": "详细服务器日志",
|
||||||
"verboseLogsDesc": "启用详细服务器日志以进行调试。"
|
"verboseLogsDesc": "启用详细服务器日志以进行调试。",
|
||||||
|
"proxyTimeout": "请求超时",
|
||||||
|
"proxyTimeoutDesc": "等待本地模型响应的时间(单位:秒)。"
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "分析",
|
"analytics": "分析",
|
||||||
|
|||||||
@ -180,7 +180,9 @@
|
|||||||
"cors": "跨來源資源共用 (CORS)",
|
"cors": "跨來源資源共用 (CORS)",
|
||||||
"corsDesc": "允許跨來源請求存取 API 伺服器。",
|
"corsDesc": "允許跨來源請求存取 API 伺服器。",
|
||||||
"verboseLogs": "詳細伺服器日誌",
|
"verboseLogs": "詳細伺服器日誌",
|
||||||
"verboseLogsDesc": "啟用詳細伺服器日誌以進行偵錯。"
|
"verboseLogsDesc": "啟用詳細伺服器日誌以進行偵錯。",
|
||||||
|
"proxyTimeout": "請求逾時",
|
||||||
|
"proxyTimeoutDesc": "等待本地模型回應的時間(秒)。"
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"analytics": "分析",
|
"analytics": "分析",
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export function DataProvider() {
|
|||||||
trustedHosts,
|
trustedHosts,
|
||||||
corsEnabled,
|
corsEnabled,
|
||||||
verboseLogs,
|
verboseLogs,
|
||||||
|
proxyTimeout,
|
||||||
} = useLocalApiServer()
|
} = useLocalApiServer()
|
||||||
const { setServerStatus } = useAppState()
|
const { setServerStatus } = useAppState()
|
||||||
|
|
||||||
@ -169,6 +170,7 @@ export function DataProvider() {
|
|||||||
trustedHosts,
|
trustedHosts,
|
||||||
isCorsEnabled: corsEnabled,
|
isCorsEnabled: corsEnabled,
|
||||||
isVerboseEnabled: verboseLogs,
|
isVerboseEnabled: verboseLogs,
|
||||||
|
proxyTimeout: proxyTimeout,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@ -114,7 +114,7 @@ const AppLayout = () => {
|
|||||||
{/* Fake absolute panel top to enable window drag */}
|
{/* Fake absolute panel top to enable window drag */}
|
||||||
<div className="absolute w-full h-10 z-10" data-tauri-drag-region />
|
<div className="absolute w-full h-10 z-10" data-tauri-drag-region />
|
||||||
<DialogAppUpdater />
|
<DialogAppUpdater />
|
||||||
<BackendUpdater />
|
{PlatformFeatures[PlatformFeature.LOCAL_INFERENCE] && <BackendUpdater />}
|
||||||
|
|
||||||
{/* Use ResizablePanelGroup only on larger screens */}
|
{/* Use ResizablePanelGroup only on larger screens */}
|
||||||
{!isSmallScreen && isLeftPanelOpen ? (
|
{!isSmallScreen && isLeftPanelOpen ? (
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import AddEditAssistant from '@/containers/dialogs/AddEditAssistant'
|
|||||||
import { DeleteAssistantDialog } from '@/containers/dialogs'
|
import { DeleteAssistantDialog } from '@/containers/dialogs'
|
||||||
import { AvatarEmoji } from '@/containers/AvatarEmoji'
|
import { AvatarEmoji } from '@/containers/AvatarEmoji'
|
||||||
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'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const Route = createFileRoute(route.assistant as any)({
|
export const Route = createFileRoute(route.assistant as any)({
|
||||||
@ -17,6 +19,14 @@ export const Route = createFileRoute(route.assistant as any)({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function Assistant() {
|
function Assistant() {
|
||||||
|
return (
|
||||||
|
<PlatformGuard feature={PlatformFeature.ASSISTANTS}>
|
||||||
|
<AssistantContent />
|
||||||
|
</PlatformGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantContent() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { assistants, addAssistant, updateAssistant, deleteAssistant } =
|
const { assistants, addAssistant, updateAssistant, deleteAssistant } =
|
||||||
useAssistant()
|
useAssistant()
|
||||||
|
|||||||
@ -18,6 +18,8 @@ type SearchParams = {
|
|||||||
import DropdownAssistant from '@/containers/DropdownAssistant'
|
import DropdownAssistant from '@/containers/DropdownAssistant'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useThreads } from '@/hooks/useThreads'
|
import { useThreads } from '@/hooks/useThreads'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
export const Route = createFileRoute(route.home as any)({
|
export const Route = createFileRoute(route.home as any)({
|
||||||
component: Index,
|
component: Index,
|
||||||
@ -54,7 +56,7 @@ function Index() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col flex-justify-center">
|
<div className="flex h-full flex-col flex-justify-center">
|
||||||
<HeaderPage>
|
<HeaderPage>
|
||||||
<DropdownAssistant />
|
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
|
||||||
</HeaderPage>
|
</HeaderPage>
|
||||||
<div className="h-full px-4 md:px-8 overflow-y-auto flex flex-col gap-2 justify-center">
|
<div className="h-full px-4 md:px-8 overflow-y-auto flex flex-col gap-2 justify-center">
|
||||||
<div className="w-full md:w-4/6 mx-auto">
|
<div className="w-full md:w-4/6 mx-auto">
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const Route = createFileRoute(route.settings.extensions as any)({
|
|||||||
|
|
||||||
function Extensions() {
|
function Extensions() {
|
||||||
return (
|
return (
|
||||||
<PlatformGuard feature={PlatformFeature.EXTENSION_MANAGEMENT}>
|
<PlatformGuard feature={PlatformFeature.EXTENSIONS_SETTINGS}>
|
||||||
<ExtensionsContent />
|
<ExtensionsContent />
|
||||||
</PlatformGuard>
|
</PlatformGuard>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { ServerHostSwitcher } from '@/containers/ServerHostSwitcher'
|
import { ServerHostSwitcher } from '@/containers/ServerHostSwitcher'
|
||||||
import { PortInput } from '@/containers/PortInput'
|
import { PortInput } from '@/containers/PortInput'
|
||||||
|
import { ProxyTimeoutInput } from '@/containers/ProxyTimeoutInput'
|
||||||
import { ApiPrefixInput } from '@/containers/ApiPrefixInput'
|
import { ApiPrefixInput } from '@/containers/ApiPrefixInput'
|
||||||
import { TrustedHostsInput } from '@/containers/TrustedHostsInput'
|
import { TrustedHostsInput } from '@/containers/TrustedHostsInput'
|
||||||
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
|
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
|
||||||
@ -50,6 +51,7 @@ function LocalAPIServerContent() {
|
|||||||
apiPrefix,
|
apiPrefix,
|
||||||
apiKey,
|
apiKey,
|
||||||
trustedHosts,
|
trustedHosts,
|
||||||
|
proxyTimeout,
|
||||||
} = useLocalApiServer()
|
} = useLocalApiServer()
|
||||||
|
|
||||||
const { serverStatus, setServerStatus } = useAppState()
|
const { serverStatus, setServerStatus } = useAppState()
|
||||||
@ -157,6 +159,7 @@ function LocalAPIServerContent() {
|
|||||||
trustedHosts,
|
trustedHosts,
|
||||||
isCorsEnabled: corsEnabled,
|
isCorsEnabled: corsEnabled,
|
||||||
isVerboseEnabled: verboseLogs,
|
isVerboseEnabled: verboseLogs,
|
||||||
|
proxyTimeout: proxyTimeout,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -311,6 +314,11 @@ function LocalAPIServerContent() {
|
|||||||
<TrustedHostsInput isServerRunning={isServerRunning} />
|
<TrustedHostsInput isServerRunning={isServerRunning} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<CardItem
|
||||||
|
title={t('settings:localApiServer.proxyTimeout')}
|
||||||
|
description={t('settings:localApiServer.proxyTimeoutDesc')}
|
||||||
|
actions={<ProxyTimeoutInput isServerRunning={isServerRunning} />}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Advanced Settings */}
|
{/* Advanced Settings */}
|
||||||
|
|||||||
@ -23,8 +23,6 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
|
|||||||
import { useAppState } from '@/hooks/useAppState'
|
import { useAppState } from '@/hooks/useAppState'
|
||||||
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
|
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
|
||||||
import { PlatformFeature } from '@/lib/platform'
|
import { PlatformFeature } from '@/lib/platform'
|
||||||
import { isPlatformTauri } from '@/lib/platform/utils'
|
|
||||||
import { MCPTool } from '@janhq/core'
|
|
||||||
|
|
||||||
// Function to mask sensitive values
|
// Function to mask sensitive values
|
||||||
const maskSensitiveValue = (value: string) => {
|
const maskSensitiveValue = (value: string) => {
|
||||||
@ -92,118 +90,12 @@ export const Route = createFileRoute(route.settings.mcp_servers as any)({
|
|||||||
|
|
||||||
function MCPServers() {
|
function MCPServers() {
|
||||||
return (
|
return (
|
||||||
<PlatformGuard feature={PlatformFeature.MCP_SERVERS}>
|
<PlatformGuard feature={PlatformFeature.MCP_SERVERS_SETTINGS}>
|
||||||
{isPlatformTauri() ? <MCPServersDesktop /> : <MCPServersWeb />}
|
<MCPServersDesktop />
|
||||||
</PlatformGuard>
|
</PlatformGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web version of MCP servers - simpler UI without server management
|
|
||||||
function MCPServersWeb() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const serviceHub = useServiceHub()
|
|
||||||
const { allowAllMCPPermissions, setAllowAllMCPPermissions } = useToolApproval()
|
|
||||||
|
|
||||||
const [webMcpTools, setWebMcpTools] = useState<MCPTool[]>([])
|
|
||||||
const [webMcpServers, setWebMcpServers] = useState<string[]>([])
|
|
||||||
const [webMcpLoading, setWebMcpLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadWebMcpData() {
|
|
||||||
setWebMcpLoading(true)
|
|
||||||
try {
|
|
||||||
const [tools, servers] = await Promise.all([
|
|
||||||
serviceHub.mcp().getTools(),
|
|
||||||
serviceHub.mcp().getConnectedServers(),
|
|
||||||
])
|
|
||||||
setWebMcpTools(tools)
|
|
||||||
setWebMcpServers(servers)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load web MCP data:', error)
|
|
||||||
setWebMcpTools([])
|
|
||||||
setWebMcpServers([])
|
|
||||||
} finally {
|
|
||||||
setWebMcpLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadWebMcpData()
|
|
||||||
}, [serviceHub])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<HeaderPage>
|
|
||||||
<h1 className="font-medium">{t('common:settings')}</h1>
|
|
||||||
</HeaderPage>
|
|
||||||
<div className="flex h-full w-full">
|
|
||||||
<SettingsMenu />
|
|
||||||
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
|
|
||||||
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
|
|
||||||
<Card
|
|
||||||
header={
|
|
||||||
<div className="flex flex-col mb-4">
|
|
||||||
<h1 className="text-main-view-fg font-medium text-base">
|
|
||||||
{t('mcp-servers:title')}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-main-view-fg/70 mt-1">
|
|
||||||
MCP tools are automatically available in your chat sessions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CardItem
|
|
||||||
title={t('mcp-servers:allowPermissions')}
|
|
||||||
description={t('mcp-servers:allowPermissionsDesc')}
|
|
||||||
actions={
|
|
||||||
<div className="flex-shrink-0 ml-4">
|
|
||||||
<Switch
|
|
||||||
checked={allowAllMCPPermissions}
|
|
||||||
onCheckedChange={setAllowAllMCPPermissions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardItem
|
|
||||||
title="MCP Service Status"
|
|
||||||
description={
|
|
||||||
webMcpLoading
|
|
||||||
? "Loading MCP service status..."
|
|
||||||
: webMcpServers.length > 0
|
|
||||||
? `Connected to ${webMcpServers.join(', ')}. ${webMcpTools.length} tools available.`
|
|
||||||
: "MCP service not connected"
|
|
||||||
}
|
|
||||||
descriptionOutside={
|
|
||||||
webMcpTools.length > 0 && !webMcpLoading && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<h4 className="text-sm font-medium text-main-view-fg/80 mb-2">Available Tools:</h4>
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{webMcpTools.map((tool) => (
|
|
||||||
<div key={tool.name} className="flex items-start gap-2 p-2 bg-main-view-fg/5 rounded">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-sm">{tool.name}</div>
|
|
||||||
<div className="text-xs text-main-view-fg/70">{tool.description}</div>
|
|
||||||
{tool.server && (
|
|
||||||
<div className="text-xs text-main-view-fg/50 mt-1">Server: {tool.server}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop version of MCP servers - full server management capabilities
|
|
||||||
function MCPServersDesktop() {
|
function MCPServersDesktop() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const serviceHub = useServiceHub()
|
const serviceHub = useServiceHub()
|
||||||
@ -351,7 +243,9 @@ function MCPServersDesktop() {
|
|||||||
setLoadingServers((prev) => ({ ...prev, [serverKey]: true }))
|
setLoadingServers((prev) => ({ ...prev, [serverKey]: true }))
|
||||||
const config = getServerConfig(serverKey)
|
const config = getServerConfig(serverKey)
|
||||||
if (active && config) {
|
if (active && config) {
|
||||||
serviceHub.mcp().activateMCPServer(serverKey, {
|
serviceHub
|
||||||
|
.mcp()
|
||||||
|
.activateMCPServer(serverKey, {
|
||||||
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
|
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
|
||||||
active,
|
active,
|
||||||
})
|
})
|
||||||
@ -388,7 +282,10 @@ function MCPServersDesktop() {
|
|||||||
active,
|
active,
|
||||||
})
|
})
|
||||||
syncServers()
|
syncServers()
|
||||||
serviceHub.mcp().deactivateMCPServer(serverKey).finally(() => {
|
serviceHub
|
||||||
|
.mcp()
|
||||||
|
.deactivateMCPServer(serverKey)
|
||||||
|
.finally(() => {
|
||||||
serviceHub.mcp().getConnectedServers().then(setConnectedServers)
|
serviceHub.mcp().getConnectedServers().then(setConnectedServers)
|
||||||
setLoadingServers((prev) => ({ ...prev, [serverKey]: false }))
|
setLoadingServers((prev) => ({ ...prev, [serverKey]: false }))
|
||||||
})
|
})
|
||||||
|
|||||||
@ -24,6 +24,8 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
|
|||||||
import { useChat } from '@/hooks/useChat'
|
import { useChat } from '@/hooks/useChat'
|
||||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||||
import { useTools } from '@/hooks/useTools'
|
import { useTools } from '@/hooks/useTools'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
// as route.threadsDetail
|
// as route.threadsDetail
|
||||||
export const Route = createFileRoute('/threads/$threadId')({
|
export const Route = createFileRoute('/threads/$threadId')({
|
||||||
@ -300,7 +302,7 @@ function ThreadDetail() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<HeaderPage>
|
<HeaderPage>
|
||||||
<div className="flex items-center justify-between w-full pr-2">
|
<div className="flex items-center justify-between w-full pr-2">
|
||||||
<DropdownAssistant />
|
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
|
||||||
</div>
|
</div>
|
||||||
</HeaderPage>
|
</HeaderPage>
|
||||||
<div className="flex flex-col h-[calc(100%-40px)]">
|
<div className="flex flex-col h-[calc(100%-40px)]">
|
||||||
|
|||||||
@ -215,7 +215,7 @@ describe('WebProvidersService', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw error when API response is not ok', async () => {
|
it('should throw error when API response is not ok (404)', async () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
@ -229,7 +229,43 @@ describe('WebProvidersService', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await expect(providersService.fetchModelsFromProvider(provider)).rejects.toThrow(
|
await expect(providersService.fetchModelsFromProvider(provider)).rejects.toThrow(
|
||||||
'Cannot connect to custom at https://api.custom.com. Please check that the service is running and accessible.'
|
'Models endpoint not found for custom. Check the base URL configuration.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when API response is not ok (403)', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
}
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any)
|
||||||
|
|
||||||
|
const provider = {
|
||||||
|
provider: 'custom',
|
||||||
|
base_url: 'https://api.custom.com',
|
||||||
|
} as ModelProvider
|
||||||
|
|
||||||
|
await expect(providersService.fetchModelsFromProvider(provider)).rejects.toThrow(
|
||||||
|
'Access forbidden: Check your API key permissions for custom'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when API response is not ok (401)', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
}
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue(mockResponse as any)
|
||||||
|
|
||||||
|
const provider = {
|
||||||
|
provider: 'custom',
|
||||||
|
base_url: 'https://api.custom.com',
|
||||||
|
} as ModelProvider
|
||||||
|
|
||||||
|
await expect(providersService.fetchModelsFromProvider(provider)).rejects.toThrow(
|
||||||
|
'Authentication failed: API key is required or invalid for custom'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export class TauriProvidersService extends DefaultProvidersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runtimeProviders.concat(builtinProviders as ModelProvider[])
|
return runtimeProviders.concat(builtinProviders as ModelProvider[])
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('Error getting providers in Tauri:', error)
|
console.error('Error getting providers in Tauri:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@ -162,9 +162,24 @@ export class TauriProvidersService extends DefaultProvidersService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Provide more specific error messages based on status code (aligned with web implementation)
|
||||||
|
if (response.status === 401) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to fetch models: ${response.status} ${response.statusText}`
|
`Authentication failed: API key is required or invalid for ${provider.provider}`
|
||||||
)
|
)
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
throw new Error(
|
||||||
|
`Access forbidden: Check your API key permissions for ${provider.provider}`
|
||||||
|
)
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
throw new Error(
|
||||||
|
`Models endpoint not found for ${provider.provider}. Check the base URL configuration.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch models from ${provider.provider}: ${response.status} ${response.statusText}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@ -194,14 +209,30 @@ export class TauriProvidersService extends DefaultProvidersService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching models from provider:', error)
|
console.error('Error fetching models from provider:', error)
|
||||||
|
|
||||||
// Provide helpful error message
|
// Preserve structured error messages thrown above
|
||||||
|
const structuredErrorPrefixes = [
|
||||||
|
'Authentication failed',
|
||||||
|
'Access forbidden',
|
||||||
|
'Models endpoint not found',
|
||||||
|
'Failed to fetch models from'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (error instanceof Error &&
|
||||||
|
structuredErrorPrefixes.some(prefix => (error as Error).message.startsWith(prefix))) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide helpful error message for any connection errors
|
||||||
if (error instanceof Error && error.message.includes('fetch')) {
|
if (error instanceof Error && error.message.includes('fetch')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
// Generic fallback
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected error while fetching models from ${provider.provider}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -138,9 +138,24 @@ export class WebProvidersService implements ProvidersService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Provide more specific error messages based on status code
|
||||||
|
if (response.status === 401) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
`Authentication failed: API key is required or invalid for ${provider.provider}`
|
||||||
)
|
)
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
throw new Error(
|
||||||
|
`Access forbidden: Check your API key permissions for ${provider.provider}`
|
||||||
|
)
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
throw new Error(
|
||||||
|
`Models endpoint not found for ${provider.provider}. Check the base URL configuration.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch models from ${provider.provider}: ${response.status} ${response.statusText}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@ -170,15 +185,30 @@ export class WebProvidersService implements ProvidersService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching models from provider:', error)
|
console.error('Error fetching models from provider:', error)
|
||||||
|
|
||||||
// Provide helpful error message for any connection errors
|
const structuredErrorPrefixes = [
|
||||||
if (error instanceof Error && error.message.includes('Cannot connect')) {
|
'Authentication failed',
|
||||||
throw error
|
'Access forbidden',
|
||||||
|
'Models endpoint not found',
|
||||||
|
'Failed to fetch models from'
|
||||||
|
]
|
||||||
|
|
||||||
|
if (error instanceof Error &&
|
||||||
|
structuredErrorPrefixes.some(prefix => (error as Error).message.startsWith(prefix))) {
|
||||||
|
throw new Error(error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provide helpful error message for any connection errors
|
||||||
|
if (error instanceof Error && error.message.includes('fetch')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generic fallback
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected error while fetching models from ${provider.provider}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(providerName: string, settings: ProviderSetting[]): Promise<void> {
|
async updateSettings(providerName: string, settings: ProviderSetting[]): Promise<void> {
|
||||||
|
|||||||
@ -77,6 +77,10 @@
|
|||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul > li {
|
||||||
|
list-style-type: circle;
|
||||||
|
}
|
||||||
|
|
||||||
ol {
|
ol {
|
||||||
list-style-type: decimal;
|
list-style-type: decimal;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,17 +11,19 @@ expect.extend(matchers)
|
|||||||
vi.mock('@/lib/platform/const', () => ({
|
vi.mock('@/lib/platform/const', () => ({
|
||||||
PlatformFeatures: {
|
PlatformFeatures: {
|
||||||
hardwareMonitoring: true,
|
hardwareMonitoring: true,
|
||||||
extensionManagement: true,
|
|
||||||
localInference: true,
|
localInference: true,
|
||||||
mcpServers: true,
|
|
||||||
localApiServer: true,
|
localApiServer: true,
|
||||||
modelHub: true,
|
modelHub: true,
|
||||||
systemIntegrations: true,
|
systemIntegrations: true,
|
||||||
httpsProxy: true,
|
httpsProxy: true,
|
||||||
defaultProviders: true,
|
defaultProviders: true,
|
||||||
analytics: true,
|
analytics: true,
|
||||||
webAutoModelSelection: true,
|
webAutoModelSelection: false,
|
||||||
modelProviderSettings: true,
|
modelProviderSettings: true,
|
||||||
|
mcpAutoApproveTools: false,
|
||||||
|
mcpServersSettings: true,
|
||||||
|
extensionsSettings: true,
|
||||||
|
assistants: true,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -103,6 +105,7 @@ const mockServiceHub = {
|
|||||||
deleteProvider: vi.fn().mockResolvedValue(undefined),
|
deleteProvider: vi.fn().mockResolvedValue(undefined),
|
||||||
updateProvider: vi.fn().mockResolvedValue(undefined),
|
updateProvider: vi.fn().mockResolvedValue(undefined),
|
||||||
getProvider: vi.fn().mockResolvedValue(null),
|
getProvider: vi.fn().mockResolvedValue(null),
|
||||||
|
fetchModelsFromProvider: vi.fn().mockResolvedValue([]),
|
||||||
}),
|
}),
|
||||||
models: () => ({
|
models: () => ({
|
||||||
getModels: vi.fn().mockResolvedValue([]),
|
getModels: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@ -72,6 +72,10 @@ A mandatory secret key to authenticate requests.
|
|||||||
### Trusted Hosts
|
### Trusted Hosts
|
||||||
A comma-separated list of hostnames allowed to access the server. This provides an additional layer of security when the server is exposed on your network.
|
A comma-separated list of hostnames allowed to access the server. This provides an additional layer of security when the server is exposed on your network.
|
||||||
|
|
||||||
|
### Request timeout
|
||||||
|
Request timeout for local model response in seconds.
|
||||||
|
- **`600`** (Default): You can change this to any suitable value.
|
||||||
|
|
||||||
## Advanced Settings
|
## Advanced Settings
|
||||||
|
|
||||||
### Cross-Origin Resource Sharing (CORS)
|
### Cross-Origin Resource Sharing (CORS)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user