feat: Web use jan model (#6374)
* call jan api * fix lint * ci: add jan server web * chore: add Dockerfile * clean up ui ux and support for reasoning fields, make app spa * add logo * chore: update tag for preview image * chore: update k8s service name * chore: update image tag and image name * fixed test --------- Co-authored-by: Minh141120 <minh.itptit@gmail.com> Co-authored-by: Nguyen Ngoc Minh <91668012+Minh141120@users.noreply.github.com>
This commit is contained in:
parent
a30eb7f968
commit
d490174544
117
.github/workflows/jan-server-web-ci.yml
vendored
Normal file
117
.github/workflows/jan-server-web-ci.yml
vendored
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
name: Jan Web Server build image and push to Harbor Registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-preview:
|
||||||
|
runs-on: [ubuntu-24-04-docker]
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout source repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Harbor Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.menlo.ai
|
||||||
|
username: ${{ secrets.HARBOR_USERNAME }}
|
||||||
|
password: ${{ secrets.HARBOR_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
|
||||||
|
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
|
||||||
|
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||||
|
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
||||||
|
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& sudo mkdir -p -m 755 /etc/apt/sources.list.d \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||||
|
&& sudo apt update \
|
||||||
|
&& sudo apt install gh -y
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y jq gettext
|
||||||
|
|
||||||
|
- name: Set image tag and service name
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
SERVICE_NAME=jan-server-web
|
||||||
|
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||||
|
IMAGE_TAG="web:preview-${{ github.sha }}"
|
||||||
|
else
|
||||||
|
IMAGE_TAG="web:dev-${{ github.sha }}"
|
||||||
|
fi
|
||||||
|
echo "SERVICE_NAME=${SERVICE_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT
|
||||||
|
echo "FULL_IMAGE=registry.menlo.ai/jan-server/${IMAGE_TAG}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build docker image
|
||||||
|
run: |
|
||||||
|
docker build -t ${{ steps.vars.outputs.FULL_IMAGE }} .
|
||||||
|
|
||||||
|
- name: Push docker image
|
||||||
|
run: |
|
||||||
|
docker push ${{ steps.vars.outputs.FULL_IMAGE }}
|
||||||
|
|
||||||
|
- name: Checkout preview URL repo
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: menloresearch/infra-domains
|
||||||
|
token: ${{ secrets.PAT_SERVICE_ACCOUNT }}
|
||||||
|
path: preview-repo
|
||||||
|
|
||||||
|
- name: Generate preview manifest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
cd preview-repo/kubernetes
|
||||||
|
bash template/generate.sh \
|
||||||
|
template/preview-url-template.yaml \
|
||||||
|
preview-url/pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.yaml \
|
||||||
|
${{ github.sha }} \
|
||||||
|
${{ steps.vars.outputs.SERVICE_NAME }} \
|
||||||
|
${{ steps.vars.outputs.FULL_IMAGE }} \
|
||||||
|
80
|
||||||
|
|
||||||
|
- name: Commit and push preview manifest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
cd preview-repo
|
||||||
|
git config user.name "preview-bot"
|
||||||
|
git config user.email "preview-bot@users.noreply.github.com"
|
||||||
|
git add kubernetes/preview-url/pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.yaml
|
||||||
|
git commit -m "feat(preview): add pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.yaml"
|
||||||
|
git push origin main
|
||||||
|
sleep 180
|
||||||
|
|
||||||
|
- name: Comment preview URL on PR
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
DOMAIN="https://pr-${{ github.sha }}-${{ steps.vars.outputs.SERVICE_NAME }}.menlo.ai"
|
||||||
|
COMMENT_BODY="🌐 Preview available: [${DOMAIN}](${DOMAIN})"
|
||||||
|
|
||||||
|
echo "🔍 Looking for existing preview comment..."
|
||||||
|
|
||||||
|
COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
|
||||||
|
--jq '.[] | select(.user.login=="github-actions[bot]") | select(.body | contains("<!-- preview-url -->")) | .id')
|
||||||
|
|
||||||
|
if [[ -n "$COMMENT_ID" ]]; then
|
||||||
|
echo "✏️ Updating existing comment ID $COMMENT_ID"
|
||||||
|
gh api repos/${{ github.repository }}/issues/comments/${COMMENT_ID} \
|
||||||
|
--method PATCH \
|
||||||
|
--field "body=${COMMENT_BODY}"
|
||||||
|
else
|
||||||
|
echo "💬 Creating new comment"
|
||||||
|
gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
|
||||||
|
--method POST \
|
||||||
|
--field "body=${COMMENT_BODY}"
|
||||||
|
fi
|
||||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Stage 1: Build stage with Node.js and Yarn v4
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
make \
|
||||||
|
g++ \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
|
git
|
||||||
|
|
||||||
|
# Enable corepack and install Yarn 4
|
||||||
|
RUN corepack enable && corepack prepare yarn@4.5.3 --activate
|
||||||
|
|
||||||
|
# Verify Yarn version
|
||||||
|
RUN yarn --version
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY ./extensions ./extensions
|
||||||
|
COPY ./extensions-web ./extensions-web
|
||||||
|
COPY ./web-app ./web-app
|
||||||
|
COPY ./Makefile ./Makefile
|
||||||
|
COPY ./.* /
|
||||||
|
COPY ./package.json ./package.json
|
||||||
|
COPY ./yarn.lock ./yarn.lock
|
||||||
|
COPY ./pre-install ./pre-install
|
||||||
|
COPY ./core ./core
|
||||||
|
|
||||||
|
# Build web application
|
||||||
|
RUN yarn install && yarn build:core && make build-web-app
|
||||||
|
|
||||||
|
# Stage 2: Production stage with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy static files from build stage
|
||||||
|
COPY --from=builder /app/web-app/dist-web /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration for SPA (if custom config is needed)
|
||||||
|
# COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@ -20,15 +20,15 @@
|
|||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"@janhq/core": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@janhq/core": "workspace:*",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@janhq/core": "*"
|
"@janhq/core": "*",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { WebExtensionRegistry } from './types'
|
|||||||
|
|
||||||
export { default as AssistantExtensionWeb } from './assistant-web'
|
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'
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type {
|
export type {
|
||||||
@ -15,11 +16,13 @@ export type {
|
|||||||
WebExtensionName,
|
WebExtensionName,
|
||||||
WebExtensionLoader,
|
WebExtensionLoader,
|
||||||
AssistantWebModule,
|
AssistantWebModule,
|
||||||
ConversationalWebModule
|
ConversationalWebModule,
|
||||||
|
JanProviderWebModule
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
// 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'),
|
'assistant-web': () => import('./assistant-web'),
|
||||||
'conversational-web': () => import('./conversational-web'),
|
'conversational-web': () => import('./conversational-web'),
|
||||||
|
'jan-provider-web': () => import('./jan-provider-web'),
|
||||||
}
|
}
|
||||||
260
extensions-web/src/jan-provider-web/api.ts
Normal file
260
extensions-web/src/jan-provider-web/api.ts
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Jan Provider API Client
|
||||||
|
* Handles API requests to Jan backend for models and chat completions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JanAuthService } from './auth'
|
||||||
|
import { JanModel, janProviderStore } from './store'
|
||||||
|
|
||||||
|
// JAN_API_BASE is defined in vite.config.ts
|
||||||
|
|
||||||
|
export interface JanModelsResponse {
|
||||||
|
object: string
|
||||||
|
data: JanModel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanChatMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
reasoning?: string
|
||||||
|
reasoning_content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanChatCompletionRequest {
|
||||||
|
model: string
|
||||||
|
messages: JanChatMessage[]
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
top_p?: number
|
||||||
|
frequency_penalty?: number
|
||||||
|
presence_penalty?: number
|
||||||
|
stream?: boolean
|
||||||
|
stop?: string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanChatCompletionChoice {
|
||||||
|
index: number
|
||||||
|
message: JanChatMessage
|
||||||
|
finish_reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanChatCompletionResponse {
|
||||||
|
id: string
|
||||||
|
object: string
|
||||||
|
created: number
|
||||||
|
model: string
|
||||||
|
choices: JanChatCompletionChoice[]
|
||||||
|
usage?: {
|
||||||
|
prompt_tokens: number
|
||||||
|
completion_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanChatCompletionChunk {
|
||||||
|
id: string
|
||||||
|
object: string
|
||||||
|
created: number
|
||||||
|
model: string
|
||||||
|
choices: Array<{
|
||||||
|
index: number
|
||||||
|
delta: {
|
||||||
|
role?: string
|
||||||
|
content?: string
|
||||||
|
reasoning?: string
|
||||||
|
reasoning_content?: string
|
||||||
|
}
|
||||||
|
finish_reason: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JanApiClient {
|
||||||
|
private static instance: JanApiClient
|
||||||
|
private authService: JanAuthService
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.authService = JanAuthService.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): JanApiClient {
|
||||||
|
if (!JanApiClient.instance) {
|
||||||
|
JanApiClient.instance = new JanApiClient()
|
||||||
|
}
|
||||||
|
return JanApiClient.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
private async makeAuthenticatedRequest<T>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const authHeader = await this.authService.getAuthHeader()
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeader,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API request failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModels(): Promise<JanModel[]> {
|
||||||
|
try {
|
||||||
|
janProviderStore.setLoadingModels(true)
|
||||||
|
janProviderStore.clearError()
|
||||||
|
|
||||||
|
const response = await this.makeAuthenticatedRequest<JanModelsResponse>(
|
||||||
|
`${JAN_API_BASE}/models`
|
||||||
|
)
|
||||||
|
|
||||||
|
const models = response.data || []
|
||||||
|
janProviderStore.setModels(models)
|
||||||
|
|
||||||
|
return models
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch models'
|
||||||
|
janProviderStore.setError(errorMessage)
|
||||||
|
janProviderStore.setLoadingModels(false)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChatCompletion(
|
||||||
|
request: JanChatCompletionRequest
|
||||||
|
): Promise<JanChatCompletionResponse> {
|
||||||
|
try {
|
||||||
|
janProviderStore.clearError()
|
||||||
|
|
||||||
|
return await this.makeAuthenticatedRequest<JanChatCompletionResponse>(
|
||||||
|
`${JAN_API_BASE}/chat/completions`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...request,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create chat completion'
|
||||||
|
janProviderStore.setError(errorMessage)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStreamingChatCompletion(
|
||||||
|
request: JanChatCompletionRequest,
|
||||||
|
onChunk: (chunk: JanChatCompletionChunk) => void,
|
||||||
|
onComplete?: () => void,
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
janProviderStore.clearError()
|
||||||
|
|
||||||
|
const authHeader = await this.authService.getAuthHeader()
|
||||||
|
|
||||||
|
const response = await fetch(`${JAN_API_BASE}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...authHeader,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...request,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
|
||||||
|
// Keep the last incomplete line in buffer
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
if (trimmedLine.startsWith('data: ')) {
|
||||||
|
const data = trimmedLine.slice(6).trim()
|
||||||
|
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
onComplete?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedChunk: JanChatCompletionChunk = JSON.parse(data)
|
||||||
|
onChunk(parsedChunk)
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse SSE chunk:', parseError, 'Data:', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete?.()
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error('Unknown error occurred')
|
||||||
|
janProviderStore.setError(err.message)
|
||||||
|
onError?.(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.authService.initialize()
|
||||||
|
janProviderStore.setAuthenticated(true)
|
||||||
|
|
||||||
|
// Fetch initial models
|
||||||
|
await this.getModels()
|
||||||
|
|
||||||
|
console.log('Jan API client initialized successfully')
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize API client'
|
||||||
|
janProviderStore.setError(errorMessage)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
janProviderStore.setInitializing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const janApiClient = JanApiClient.getInstance()
|
||||||
190
extensions-web/src/jan-provider-web/auth.ts
Normal file
190
extensions-web/src/jan-provider-web/auth.ts
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Jan Provider Authentication Service
|
||||||
|
* Handles guest login and token refresh for Jan API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// JAN_API_BASE is defined in vite.config.ts
|
||||||
|
const AUTH_STORAGE_KEY = 'jan_auth_tokens'
|
||||||
|
const TOKEN_EXPIRY_BUFFER = 60 * 1000 // 1 minute buffer before actual expiry
|
||||||
|
|
||||||
|
export class JanAuthService {
|
||||||
|
private static instance: JanAuthService
|
||||||
|
private tokens: AuthTokens | null = null
|
||||||
|
private tokenExpiryTime: number = 0
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.loadTokensFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): JanAuthService {
|
||||||
|
if (!JanAuthService.instance) {
|
||||||
|
JanAuthService.instance = new JanAuthService()
|
||||||
|
}
|
||||||
|
return JanAuthService.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTokensFromStorage(): void {
|
||||||
|
try {
|
||||||
|
const storedTokens = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||||
|
if (storedTokens) {
|
||||||
|
const parsed = JSON.parse(storedTokens)
|
||||||
|
this.tokens = parsed.tokens
|
||||||
|
this.tokenExpiryTime = parsed.expiryTime || 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load tokens from storage:', error)
|
||||||
|
this.clearTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTokensToStorage(): void {
|
||||||
|
if (this.tokens) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({
|
||||||
|
tokens: this.tokens,
|
||||||
|
expiryTime: this.tokenExpiryTime
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save tokens to storage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTokens(): void {
|
||||||
|
this.tokens = null
|
||||||
|
this.tokenExpiryTime = 0
|
||||||
|
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTokenExpired(): boolean {
|
||||||
|
return Date.now() > (this.tokenExpiryTime - TOKEN_EXPIRY_BUFFER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateExpiryTime(expiresIn: number): number {
|
||||||
|
return Date.now() + (expiresIn * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async guestLogin(): Promise<AuthTokens> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${JAN_API_BASE}/auth/guest-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include', // Include cookies for session management
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Guest login failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// API response is wrapped in result object
|
||||||
|
const authResponse = data.result || data
|
||||||
|
|
||||||
|
// Guest login returns only access_token and expires_in
|
||||||
|
const tokens: AuthTokens = {
|
||||||
|
access_token: authResponse.access_token,
|
||||||
|
expires_in: authResponse.expires_in
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tokens = tokens
|
||||||
|
this.tokenExpiryTime = this.calculateExpiryTime(tokens.expires_in)
|
||||||
|
this.saveTokensToStorage()
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Guest login failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshToken(): Promise<AuthTokens> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${JAN_API_BASE}/auth/refresh-token`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include', // Cookies will include the refresh token
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Refresh token is invalid, clear tokens and do guest login
|
||||||
|
this.clearTokens()
|
||||||
|
return this.guestLogin()
|
||||||
|
}
|
||||||
|
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// API response is wrapped in result object
|
||||||
|
const authResponse = data.result || data
|
||||||
|
|
||||||
|
// Refresh endpoint returns only access_token and expires_in
|
||||||
|
const tokens: AuthTokens = {
|
||||||
|
access_token: authResponse.access_token,
|
||||||
|
expires_in: authResponse.expires_in
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tokens = tokens
|
||||||
|
this.tokenExpiryTime = this.calculateExpiryTime(tokens.expires_in)
|
||||||
|
this.saveTokensToStorage()
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error)
|
||||||
|
// If refresh fails, fall back to guest login
|
||||||
|
this.clearTokens()
|
||||||
|
return this.guestLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getValidAccessToken(): Promise<string> {
|
||||||
|
// If no tokens exist, do guest login
|
||||||
|
if (!this.tokens) {
|
||||||
|
const tokens = await this.guestLogin()
|
||||||
|
return tokens.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token is expired or about to expire, refresh it
|
||||||
|
if (this.isTokenExpired()) {
|
||||||
|
const tokens = await this.refreshToken()
|
||||||
|
return tokens.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return existing valid token
|
||||||
|
return this.tokens.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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 }> {
|
||||||
|
const token = await this.getValidAccessToken()
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.clearTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
1
extensions-web/src/jan-provider-web/index.ts
Normal file
1
extensions-web/src/jan-provider-web/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './provider'
|
||||||
307
extensions-web/src/jan-provider-web/provider.ts
Normal file
307
extensions-web/src/jan-provider-web/provider.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Jan Provider Extension for Web
|
||||||
|
* Provides remote model inference through Jan API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AIEngine,
|
||||||
|
modelInfo,
|
||||||
|
SessionInfo,
|
||||||
|
UnloadResult,
|
||||||
|
chatCompletionRequest,
|
||||||
|
chatCompletion,
|
||||||
|
chatCompletionChunk,
|
||||||
|
ImportOptions,
|
||||||
|
} from '@janhq/core' // cspell: disable-line
|
||||||
|
import { janApiClient, JanChatMessage } from './api'
|
||||||
|
import { janProviderStore } from './store'
|
||||||
|
|
||||||
|
export default class JanProviderWeb extends AIEngine {
|
||||||
|
readonly provider = 'jan'
|
||||||
|
private activeSessions: Map<string, SessionInfo> = new Map()
|
||||||
|
|
||||||
|
override async onLoad() {
|
||||||
|
console.log('Loading Jan Provider Extension...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize authentication and fetch models
|
||||||
|
await janApiClient.initialize()
|
||||||
|
console.log('Jan Provider Extension loaded successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Jan Provider Extension:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
override async onUnload() {
|
||||||
|
console.log('Unloading Jan Provider Extension...')
|
||||||
|
|
||||||
|
// Clear all sessions
|
||||||
|
for (const sessionId of this.activeSessions.keys()) {
|
||||||
|
await this.unload(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
janProviderStore.reset()
|
||||||
|
console.log('Jan Provider Extension unloaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<modelInfo[]> {
|
||||||
|
try {
|
||||||
|
const janModels = await janApiClient.getModels()
|
||||||
|
|
||||||
|
return janModels.map((model) => ({
|
||||||
|
id: model.id,
|
||||||
|
name: model.id, // Use ID as name for now
|
||||||
|
quant_type: undefined,
|
||||||
|
providerId: this.provider,
|
||||||
|
port: 443, // HTTPS port for API
|
||||||
|
sizeBytes: 0, // Size not provided by Jan API
|
||||||
|
tags: [],
|
||||||
|
path: undefined, // Remote model, no local path
|
||||||
|
owned_by: model.owned_by,
|
||||||
|
object: model.object,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list Jan models:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(modelId: string, _settings?: any): Promise<SessionInfo> {
|
||||||
|
try {
|
||||||
|
// For Jan API, we don't actually "load" models in the traditional sense
|
||||||
|
// We just create a session reference for tracking
|
||||||
|
const sessionId = `jan-${modelId}-${Date.now()}`
|
||||||
|
|
||||||
|
const sessionInfo: SessionInfo = {
|
||||||
|
pid: Date.now(), // Use timestamp as pseudo-PID
|
||||||
|
port: 443, // HTTPS port
|
||||||
|
model_id: modelId,
|
||||||
|
model_path: `remote:${modelId}`, // Indicate this is a remote model
|
||||||
|
api_key: '', // API key handled by auth service
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeSessions.set(sessionId, sessionInfo)
|
||||||
|
|
||||||
|
console.log(`Jan model session created: ${sessionId} for model ${modelId}`)
|
||||||
|
return sessionInfo
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load Jan model ${modelId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unload(sessionId: string): Promise<UnloadResult> {
|
||||||
|
try {
|
||||||
|
const session = this.activeSessions.get(sessionId)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Session ${sessionId} not found`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeSessions.delete(sessionId)
|
||||||
|
console.log(`Jan model session unloaded: ${sessionId}`)
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to unload Jan session ${sessionId}:`, error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(
|
||||||
|
opts: chatCompletionRequest,
|
||||||
|
abortController?: AbortController
|
||||||
|
): Promise<chatCompletion | AsyncIterable<chatCompletionChunk>> {
|
||||||
|
try {
|
||||||
|
// Check if request was aborted before starting
|
||||||
|
if (abortController?.signal?.aborted) {
|
||||||
|
throw new Error('Request was aborted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Jan API, we need to determine which model to use
|
||||||
|
// The model should be specified in opts.model
|
||||||
|
const modelId = opts.model
|
||||||
|
if (!modelId) {
|
||||||
|
throw new Error('Model ID is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert core chat completion request to Jan API format
|
||||||
|
const janMessages: JanChatMessage[] = opts.messages.map(msg => ({
|
||||||
|
role: msg.role as 'system' | 'user' | 'assistant',
|
||||||
|
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const janRequest = {
|
||||||
|
model: modelId,
|
||||||
|
messages: janMessages,
|
||||||
|
temperature: opts.temperature ?? undefined,
|
||||||
|
max_tokens: opts.n_predict ?? undefined,
|
||||||
|
top_p: opts.top_p ?? undefined,
|
||||||
|
frequency_penalty: opts.frequency_penalty ?? undefined,
|
||||||
|
presence_penalty: opts.presence_penalty ?? undefined,
|
||||||
|
stream: opts.stream ?? false,
|
||||||
|
stop: opts.stop ?? undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.stream) {
|
||||||
|
// Return async generator for streaming
|
||||||
|
return this.createStreamingGenerator(janRequest, abortController)
|
||||||
|
} else {
|
||||||
|
// Return single response
|
||||||
|
const response = await janApiClient.createChatCompletion(janRequest)
|
||||||
|
|
||||||
|
// Check if aborted after completion
|
||||||
|
if (abortController?.signal?.aborted) {
|
||||||
|
throw new Error('Request was aborted')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: response.id,
|
||||||
|
object: 'chat.completion' as const,
|
||||||
|
created: response.created,
|
||||||
|
model: response.model,
|
||||||
|
choices: response.choices.map(choice => ({
|
||||||
|
index: choice.index,
|
||||||
|
message: {
|
||||||
|
role: choice.message.role,
|
||||||
|
content: choice.message.content,
|
||||||
|
reasoning: choice.message.reasoning,
|
||||||
|
reasoning_content: choice.message.reasoning_content,
|
||||||
|
},
|
||||||
|
finish_reason: (choice.finish_reason || 'stop') as 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'function_call',
|
||||||
|
})),
|
||||||
|
usage: response.usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Jan chat completion failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *createStreamingGenerator(janRequest: any, abortController?: AbortController) {
|
||||||
|
let resolve: () => void
|
||||||
|
let reject: (error: Error) => void
|
||||||
|
const chunks: any[] = []
|
||||||
|
let isComplete = false
|
||||||
|
let error: Error | null = null
|
||||||
|
|
||||||
|
const promise = new Promise<void>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle abort signal
|
||||||
|
const abortListener = () => {
|
||||||
|
error = new Error('Request was aborted')
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortController?.signal) {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
throw new Error('Request was aborted')
|
||||||
|
}
|
||||||
|
abortController.signal.addEventListener('abort', abortListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start the streaming request
|
||||||
|
janApiClient.createStreamingChatCompletion(
|
||||||
|
janRequest,
|
||||||
|
(chunk) => {
|
||||||
|
if (abortController?.signal?.aborted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const streamChunk = {
|
||||||
|
id: chunk.id,
|
||||||
|
object: chunk.object,
|
||||||
|
created: chunk.created,
|
||||||
|
model: chunk.model,
|
||||||
|
choices: chunk.choices.map(choice => ({
|
||||||
|
index: choice.index,
|
||||||
|
delta: {
|
||||||
|
role: choice.delta.role,
|
||||||
|
content: choice.delta.content,
|
||||||
|
reasoning: choice.delta.reasoning,
|
||||||
|
reasoning_content: choice.delta.reasoning_content,
|
||||||
|
},
|
||||||
|
finish_reason: choice.finish_reason,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
chunks.push(streamChunk)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
isComplete = true
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
error = err
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Yield chunks as they arrive
|
||||||
|
let yieldedIndex = 0
|
||||||
|
while (!isComplete && !error) {
|
||||||
|
if (abortController?.signal?.aborted) {
|
||||||
|
throw new Error('Request was aborted')
|
||||||
|
}
|
||||||
|
|
||||||
|
while (yieldedIndex < chunks.length) {
|
||||||
|
yield chunks[yieldedIndex]
|
||||||
|
yieldedIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit before checking again
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield any remaining chunks
|
||||||
|
while (yieldedIndex < chunks.length) {
|
||||||
|
yield chunks[yieldedIndex]
|
||||||
|
yieldedIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await promise
|
||||||
|
} finally {
|
||||||
|
// Clean up abort listener
|
||||||
|
if (abortController?.signal) {
|
||||||
|
abortController.signal.removeEventListener('abort', abortListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(modelId: string): Promise<void> {
|
||||||
|
throw new Error(`Delete operation not supported for remote Jan API model: ${modelId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async import(modelId: string, _opts: ImportOptions): Promise<void> {
|
||||||
|
throw new Error(`Import operation not supported for remote Jan API model: ${modelId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async abortImport(modelId: string): Promise<void> {
|
||||||
|
throw new Error(`Abort import operation not supported for remote Jan API model: ${modelId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLoadedModels(): Promise<string[]> {
|
||||||
|
return Array.from(this.activeSessions.values()).map(session => session.model_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async isToolSupported(): Promise<boolean> {
|
||||||
|
// Tools are not yet supported
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
95
extensions-web/src/jan-provider-web/store.ts
Normal file
95
extensions-web/src/jan-provider-web/store.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Jan Provider Store
|
||||||
|
* Zustand-based state management for Jan provider authentication and models
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface JanModel {
|
||||||
|
id: string
|
||||||
|
object: string
|
||||||
|
owned_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanProviderState {
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isInitializing: boolean
|
||||||
|
models: JanModel[]
|
||||||
|
isLoadingModels: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JanProviderActions {
|
||||||
|
setAuthenticated: (isAuthenticated: boolean) => void
|
||||||
|
setInitializing: (isInitializing: boolean) => void
|
||||||
|
setModels: (models: JanModel[]) => void
|
||||||
|
setLoadingModels: (isLoadingModels: boolean) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
clearError: () => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JanProviderStore = JanProviderState & JanProviderActions
|
||||||
|
|
||||||
|
const initialState: JanProviderState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: true,
|
||||||
|
models: [],
|
||||||
|
isLoadingModels: false,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useJanProviderStore = create<JanProviderStore>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setAuthenticated: (isAuthenticated: boolean) =>
|
||||||
|
set({ isAuthenticated, error: null }),
|
||||||
|
|
||||||
|
setInitializing: (isInitializing: boolean) =>
|
||||||
|
set({ isInitializing }),
|
||||||
|
|
||||||
|
setModels: (models: JanModel[]) =>
|
||||||
|
set({ models, isLoadingModels: false }),
|
||||||
|
|
||||||
|
setLoadingModels: (isLoadingModels: boolean) =>
|
||||||
|
set({ isLoadingModels }),
|
||||||
|
|
||||||
|
setError: (error: string | null) =>
|
||||||
|
set({ error }),
|
||||||
|
|
||||||
|
clearError: () =>
|
||||||
|
set({ error: null }),
|
||||||
|
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitializing: false,
|
||||||
|
models: [],
|
||||||
|
isLoadingModels: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Export a store instance for non-React usage
|
||||||
|
export const janProviderStore = {
|
||||||
|
// Store access methods
|
||||||
|
getState: useJanProviderStore.getState,
|
||||||
|
setState: useJanProviderStore.setState,
|
||||||
|
subscribe: useJanProviderStore.subscribe,
|
||||||
|
|
||||||
|
// Direct action methods
|
||||||
|
setAuthenticated: (isAuthenticated: boolean) =>
|
||||||
|
useJanProviderStore.getState().setAuthenticated(isAuthenticated),
|
||||||
|
setInitializing: (isInitializing: boolean) =>
|
||||||
|
useJanProviderStore.getState().setInitializing(isInitializing),
|
||||||
|
setModels: (models: JanModel[]) =>
|
||||||
|
useJanProviderStore.getState().setModels(models),
|
||||||
|
setLoadingModels: (isLoadingModels: boolean) =>
|
||||||
|
useJanProviderStore.getState().setLoadingModels(isLoadingModels),
|
||||||
|
setError: (error: string | null) =>
|
||||||
|
useJanProviderStore.getState().setError(error),
|
||||||
|
clearError: () =>
|
||||||
|
useJanProviderStore.getState().clearError(),
|
||||||
|
reset: () =>
|
||||||
|
useJanProviderStore.getState().reset(),
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
* Web Extension Types
|
* Web Extension Types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AssistantExtension, ConversationalExtension, BaseExtension } from '@janhq/core'
|
import type { AssistantExtension, ConversationalExtension, BaseExtension, AIEngine } from '@janhq/core'
|
||||||
|
|
||||||
type ExtensionConstructorParams = ConstructorParameters<typeof BaseExtension>
|
type ExtensionConstructorParams = ConstructorParameters<typeof BaseExtension>
|
||||||
|
|
||||||
@ -14,11 +14,16 @@ export interface ConversationalWebModule {
|
|||||||
default: new (...args: ExtensionConstructorParams) => ConversationalExtension
|
default: new (...args: ExtensionConstructorParams) => ConversationalExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebExtensionModule = AssistantWebModule | ConversationalWebModule
|
export interface JanProviderWebModule {
|
||||||
|
default: new (...args: ExtensionConstructorParams) => AIEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebExtensionModule = AssistantWebModule | ConversationalWebModule | JanProviderWebModule
|
||||||
|
|
||||||
export interface WebExtensionRegistry {
|
export interface WebExtensionRegistry {
|
||||||
'assistant-web': () => Promise<AssistantWebModule>
|
'assistant-web': () => Promise<AssistantWebModule>
|
||||||
'conversational-web': () => Promise<ConversationalWebModule>
|
'conversational-web': () => Promise<ConversationalWebModule>
|
||||||
|
'jan-provider-web': () => Promise<JanProviderWebModule>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebExtensionName = keyof WebExtensionRegistry
|
export type WebExtensionName = keyof WebExtensionRegistry
|
||||||
|
|||||||
5
extensions-web/src/types/global.d.ts
vendored
Normal file
5
extensions-web/src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
declare const JAN_API_BASE: string
|
||||||
|
}
|
||||||
1
extensions-web/src/vite-env.d.ts
vendored
Normal file
1
extensions-web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@ -9,13 +9,11 @@ export default defineConfig({
|
|||||||
fileName: 'index'
|
fileName: 'index'
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['@janhq/core'],
|
external: ['@janhq/core', 'zustand']
|
||||||
output: {
|
|
||||||
globals: {
|
|
||||||
'@janhq/core': 'JanCore'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
emptyOutDir: false // Don't clean the output directory
|
emptyOutDir: false // Don't clean the output directory
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/jan/v1'),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -2,7 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/images/jan-logo.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/images/jan-logo.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/images/jan-logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Jan</title>
|
<title>Jan</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -13,7 +13,8 @@
|
|||||||
"dev:web": "vite --config vite.config.web.ts",
|
"dev:web": "vite --config vite.config.web.ts",
|
||||||
"build:web": "yarn tsc -b tsconfig.web.json && vite build --config vite.config.web.ts",
|
"build:web": "yarn tsc -b tsconfig.web.json && vite build --config vite.config.web.ts",
|
||||||
"preview:web": "vite preview --config vite.config.web.ts --outDir dist-web",
|
"preview:web": "vite preview --config vite.config.web.ts --outDir dist-web",
|
||||||
"serve:web": "npx serve dist-web -p 3001",
|
"serve:web": "npx serve dist-web -p 3001 -s",
|
||||||
|
"serve:web:alt": "npx http-server dist-web -p 3001 --proxy http://localhost:3001? -o",
|
||||||
"build:serve:web": "yarn build:web && yarn serve:web"
|
"build:serve:web": "yarn build:web && yarn serve:web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
BIN
web-app/public/images/jan-logo.png
Normal file
BIN
web-app/public/images/jan-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
web-app/public/images/model-provider/jan.png
Normal file
BIN
web-app/public/images/model-provider/jan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@ -21,6 +21,8 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
|
|||||||
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
import { useFavoriteModel } from '@/hooks/useFavoriteModel'
|
||||||
import { predefinedProviders } from '@/consts/providers'
|
import { predefinedProviders } from '@/consts/providers'
|
||||||
import { useServiceHub } from '@/hooks/useServiceHub'
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
type DropdownModelProviderProps = {
|
type DropdownModelProviderProps = {
|
||||||
model?: ThreadModel
|
model?: ThreadModel
|
||||||
@ -171,8 +173,28 @@ const DropdownModelProvider = ({
|
|||||||
await checkAndUpdateModelVisionCapability(lastUsed.model)
|
await checkAndUpdateModelVisionCapability(lastUsed.model)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// For web-only builds, auto-select the first model from jan provider
|
||||||
|
if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION]) {
|
||||||
|
const janProvider = providers.find(
|
||||||
|
(p) => p.provider === 'jan' && p.active && p.models.length > 0
|
||||||
|
)
|
||||||
|
if (janProvider && janProvider.models.length > 0) {
|
||||||
|
const firstModel = janProvider.models[0]
|
||||||
|
selectModelProvider(janProvider.provider, firstModel.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
selectModelProvider('', '')
|
selectModelProvider('', '')
|
||||||
}
|
}
|
||||||
|
} else if (PlatformFeatures[PlatformFeature.WEB_AUTO_MODEL_SELECTION] && !selectedModel) {
|
||||||
|
// For web-only builds, always auto-select the first model from jan provider if none is selected
|
||||||
|
const janProvider = providers.find(
|
||||||
|
(p) => p.provider === 'jan' && p.active && p.models.length > 0
|
||||||
|
)
|
||||||
|
if (janProvider && janProvider.models.length > 0) {
|
||||||
|
const firstModel = janProvider.models[0]
|
||||||
|
selectModelProvider(janProvider.provider, firstModel.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,6 +210,7 @@ const DropdownModelProvider = ({
|
|||||||
getProviderByName,
|
getProviderByName,
|
||||||
checkAndUpdateModelVisionCapability,
|
checkAndUpdateModelVisionCapability,
|
||||||
serviceHub,
|
serviceHub,
|
||||||
|
selectedModel,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Update display model when selection changes
|
// Update display model when selection changes
|
||||||
@ -549,22 +572,24 @@ const DropdownModelProvider = ({
|
|||||||
{getProviderTitle(providerInfo.provider)}
|
{getProviderTitle(providerInfo.provider)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
{PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS] && (
|
||||||
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
<div
|
||||||
onClick={(e) => {
|
className="size-6 cursor-pointer flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
navigate({
|
e.stopPropagation()
|
||||||
to: route.settings.providers,
|
navigate({
|
||||||
params: { providerName: providerInfo.provider },
|
to: route.settings.providers,
|
||||||
})
|
params: { providerName: providerInfo.provider },
|
||||||
setOpen(false)
|
})
|
||||||
}}
|
setOpen(false)
|
||||||
>
|
}}
|
||||||
<IconSettings
|
>
|
||||||
size={16}
|
<IconSettings
|
||||||
className="text-main-view-fg/50"
|
size={16}
|
||||||
/>
|
className="text-main-view-fg/50"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Models for this provider */}
|
{/* Models for this provider */}
|
||||||
|
|||||||
@ -480,7 +480,7 @@ const LeftPanel = () => {
|
|||||||
<div className="space-y-1 shrink-0 py-1 mt-2">
|
<div className="space-y-1 shrink-0 py-1 mt-2">
|
||||||
{mainMenus.map((menu) => {
|
{mainMenus.map((menu) => {
|
||||||
if (!menu.isEnabled) {
|
if (!menu.isEnabled) {
|
||||||
return <></>
|
return null
|
||||||
}
|
}
|
||||||
const isActive =
|
const isActive =
|
||||||
currentPath.includes(route.settings.index) &&
|
currentPath.includes(route.settings.index) &&
|
||||||
|
|||||||
@ -80,13 +80,13 @@ const SettingsMenu = () => {
|
|||||||
title: 'common:privacy',
|
title: 'common:privacy',
|
||||||
route: route.settings.privacy,
|
route: route.settings.privacy,
|
||||||
hasSubMenu: false,
|
hasSubMenu: false,
|
||||||
isEnabled: true,
|
isEnabled: PlatformFeatures[PlatformFeature.ANALYTICS],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'common:modelProviders',
|
title: 'common:modelProviders',
|
||||||
route: route.settings.model_providers,
|
route: route.settings.model_providers,
|
||||||
hasSubMenu: activeProviders.length > 0,
|
hasSubMenu: activeProviders.length > 0,
|
||||||
isEnabled: true,
|
isEnabled: PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'common:keyboardShortcuts',
|
title: 'common:keyboardShortcuts',
|
||||||
@ -159,7 +159,7 @@ const SettingsMenu = () => {
|
|||||||
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
|
<div className="flex flex-col gap-1 w-full text-main-view-fg/90 font-medium">
|
||||||
{menuSettings.map((menu) => {
|
{menuSettings.map((menu) => {
|
||||||
if (!menu.isEnabled) {
|
if (!menu.isEnabled) {
|
||||||
return <></>
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={menu.title}>
|
<div key={menu.title}>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ function SetupScreen() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { providers } = useModelProvider()
|
const { providers } = useModelProvider()
|
||||||
const firstItemRemoteProvider =
|
const firstItemRemoteProvider =
|
||||||
providers.length > 0 ? providers[1].provider : 'openai'
|
providers.length > 0 ? providers[1]?.provider : 'openai'
|
||||||
|
|
||||||
// Check if setup tour has been completed
|
// Check if setup tour has been completed
|
||||||
const isSetupCompleted =
|
const isSetupCompleted =
|
||||||
|
|||||||
@ -34,4 +34,16 @@ export const PlatformFeatures: Record<PlatformFeature, boolean> = {
|
|||||||
|
|
||||||
// HTTPS proxy
|
// HTTPS proxy
|
||||||
[PlatformFeature.HTTPS_PROXY]: isPlatformTauri(),
|
[PlatformFeature.HTTPS_PROXY]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Default model providers (OpenAI, Anthropic, etc.) - disabled for web-only Jan builds
|
||||||
|
[PlatformFeature.DEFAULT_PROVIDERS]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Analytics and telemetry - disabled for web
|
||||||
|
[PlatformFeature.ANALYTICS]: isPlatformTauri(),
|
||||||
|
|
||||||
|
// Web-specific automatic model selection from jan provider - enabled for web only
|
||||||
|
[PlatformFeature.WEB_AUTO_MODEL_SELECTION]: !isPlatformTauri(),
|
||||||
|
|
||||||
|
// Model provider settings page management - disabled for web only
|
||||||
|
[PlatformFeature.MODEL_PROVIDER_SETTINGS]: isPlatformTauri(),
|
||||||
}
|
}
|
||||||
@ -36,4 +36,16 @@ export enum PlatformFeature {
|
|||||||
|
|
||||||
// HTTPS proxy
|
// HTTPS proxy
|
||||||
HTTPS_PROXY = 'httpsProxy',
|
HTTPS_PROXY = 'httpsProxy',
|
||||||
|
|
||||||
|
// Default model providers (OpenAI, Anthropic, etc.)
|
||||||
|
DEFAULT_PROVIDERS = 'defaultProviders',
|
||||||
|
|
||||||
|
// Analytics and telemetry
|
||||||
|
ANALYTICS = 'analytics',
|
||||||
|
|
||||||
|
// Web-specific automatic model selection from jan provider
|
||||||
|
WEB_AUTO_MODEL_SELECTION = 'webAutoModelSelection',
|
||||||
|
|
||||||
|
// Model provider settings page management
|
||||||
|
MODEL_PROVIDER_SETTINGS = 'modelProviderSettings',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
|
|
||||||
export function getProviderLogo(provider: string) {
|
export function getProviderLogo(provider: string) {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
|
case 'jan':
|
||||||
|
return '/images/model-provider/jan.png'
|
||||||
case 'llamacpp':
|
case 'llamacpp':
|
||||||
return '/images/model-provider/llamacpp.svg'
|
return '/images/model-provider/llamacpp.svg'
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
@ -33,6 +35,8 @@ export function getProviderLogo(provider: string) {
|
|||||||
|
|
||||||
export const getProviderTitle = (provider: string) => {
|
export const getProviderTitle = (provider: string) => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
|
case 'jan':
|
||||||
|
return 'Jan'
|
||||||
case 'llamacpp':
|
case 'llamacpp':
|
||||||
return 'Llama.cpp'
|
return 'Llama.cpp'
|
||||||
case 'openai':
|
case 'openai':
|
||||||
|
|||||||
@ -3,12 +3,18 @@ import { useEffect } from 'react'
|
|||||||
|
|
||||||
import { useServiceHub } from '@/hooks/useServiceHub'
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
||||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
export function AnalyticProvider() {
|
export function AnalyticProvider() {
|
||||||
const { productAnalytic } = useAnalytic()
|
const { productAnalytic } = useAnalytic()
|
||||||
const serviceHub = useServiceHub()
|
const serviceHub = useServiceHub()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Early exit if analytics are disabled for this platform
|
||||||
|
if (!PlatformFeatures[PlatformFeature.ANALYTICS]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!POSTHOG_KEY || !POSTHOG_HOST) {
|
if (!POSTHOG_KEY || !POSTHOG_HOST) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'PostHog not initialized: Missing POSTHOG_KEY or POSTHOG_HOST environment variables'
|
'PostHog not initialized: Missing POSTHOG_KEY or POSTHOG_HOST environment variables'
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import GlobalError from '@/containers/GlobalError'
|
|||||||
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
|
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
|
||||||
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
|
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
|
||||||
import { ServiceHubProvider } from '@/providers/ServiceHubProvider'
|
import { ServiceHubProvider } from '@/providers/ServiceHubProvider'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@ -162,7 +164,7 @@ const AppLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
{productAnalyticPrompt && <PromptAnalytic />}
|
{PlatformFeatures[PlatformFeature.ANALYTICS] && productAnalyticPrompt && <PromptAnalytic />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,11 +35,12 @@ function Index() {
|
|||||||
useTools()
|
useTools()
|
||||||
|
|
||||||
// Conditional to check if there are any valid providers
|
// Conditional to check if there are any valid providers
|
||||||
// required min 1 api_key or 1 model in llama.cpp
|
// required min 1 api_key or 1 model in llama.cpp or jan provider
|
||||||
const hasValidProviders = providers.some(
|
const hasValidProviders = providers.some(
|
||||||
(provider) =>
|
(provider) =>
|
||||||
provider.api_key?.length ||
|
provider.api_key?.length ||
|
||||||
(provider.provider === 'llamacpp' && provider.models.length)
|
(provider.provider === 'llamacpp' && provider.models.length) ||
|
||||||
|
(provider.provider === 'jan' && provider.models.length)
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -181,14 +181,16 @@ function General() {
|
|||||||
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
|
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
|
||||||
{/* General */}
|
{/* General */}
|
||||||
<Card title={t('common:general')}>
|
<Card title={t('common:general')}>
|
||||||
<CardItem
|
{PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
||||||
title={t('settings:general.appVersion')}
|
<CardItem
|
||||||
actions={
|
title={t('settings:general.appVersion')}
|
||||||
<span className="text-main-view-fg/80 font-medium">
|
actions={
|
||||||
v{VERSION}
|
<span className="text-main-view-fg/80 font-medium">
|
||||||
</span>
|
v{VERSION}
|
||||||
}
|
</span>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!AUTO_UPDATER_DISABLED && PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
{!AUTO_UPDATER_DISABLED && PlatformFeatures[PlatformFeature.SYSTEM_INTEGRATIONS] && (
|
||||||
<CardItem
|
<CardItem
|
||||||
title={t('settings:general.checkForUpdates')}
|
title={t('settings:general.checkForUpdates')}
|
||||||
@ -427,23 +429,25 @@ function General() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardItem
|
{PlatformFeatures[PlatformFeature.MODEL_HUB] && (
|
||||||
title={t('settings:general.huggingfaceToken', {
|
<CardItem
|
||||||
ns: 'settings',
|
title={t('settings:general.huggingfaceToken', {
|
||||||
})}
|
ns: 'settings',
|
||||||
description={t('settings:general.huggingfaceTokenDesc', {
|
})}
|
||||||
ns: 'settings',
|
description={t('settings:general.huggingfaceTokenDesc', {
|
||||||
})}
|
ns: 'settings',
|
||||||
actions={
|
})}
|
||||||
<Input
|
actions={
|
||||||
id="hf-token"
|
<Input
|
||||||
value={huggingfaceToken || ''}
|
id="hf-token"
|
||||||
onChange={(e) => setHuggingfaceToken(e.target.value)}
|
value={huggingfaceToken || ''}
|
||||||
placeholder={'hf_xxx'}
|
onChange={(e) => setHuggingfaceToken(e.target.value)}
|
||||||
required
|
placeholder={'hf_xxx'}
|
||||||
/>
|
required
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Resources */}
|
{/* Resources */}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { Card, CardItem } from '@/containers/Card'
|
|||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
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.settings.privacy as any)({
|
export const Route = createFileRoute(route.settings.privacy as any)({
|
||||||
@ -26,7 +28,8 @@ function Privacy() {
|
|||||||
<SettingsMenu />
|
<SettingsMenu />
|
||||||
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto">
|
<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">
|
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
|
||||||
<Card
|
{PlatformFeatures[PlatformFeature.ANALYTICS] && (
|
||||||
|
<Card
|
||||||
header={
|
header={
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-main-view-fg font-medium text-base">
|
<h1 className="text-main-view-fg font-medium text-base">
|
||||||
@ -82,6 +85,7 @@ function Privacy() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,6 +31,8 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { predefinedProviders } from '@/consts/providers'
|
import { predefinedProviders } from '@/consts/providers'
|
||||||
import { useModelLoad } from '@/hooks/useModelLoad'
|
import { useModelLoad } from '@/hooks/useModelLoad'
|
||||||
import { useLlamacppDevices } from '@/hooks/useLlamacppDevices'
|
import { useLlamacppDevices } from '@/hooks/useLlamacppDevices'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
// as route.threadsDetail
|
// as route.threadsDetail
|
||||||
export const Route = createFileRoute('/settings/providers/$providerName')({
|
export const Route = createFileRoute('/settings/providers/$providerName')({
|
||||||
@ -284,6 +286,30 @@ function ProviderDetail() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if model provider settings are enabled for this platform
|
||||||
|
if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) {
|
||||||
|
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 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-medium text-main-view-fg/80 mb-2">
|
||||||
|
{t('common:notAvailable')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-main-view-fg/60">
|
||||||
|
Provider settings are not available on the web platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Joyride
|
<Joyride
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import { openAIProviderSettings } from '@/consts/providers'
|
|||||||
import cloneDeep from 'lodash/cloneDeep'
|
import cloneDeep from 'lodash/cloneDeep'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useServiceHub } from '@/hooks/useServiceHub'
|
import { useServiceHub } from '@/hooks/useServiceHub'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
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.settings.model_providers as any)({
|
export const Route = createFileRoute(route.settings.model_providers as any)({
|
||||||
@ -65,6 +67,30 @@ function ModelProviders() {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}, [providers, name, addProvider, t, navigate])
|
}, [providers, name, addProvider, t, navigate])
|
||||||
|
|
||||||
|
// Check if model provider settings are enabled for this platform
|
||||||
|
if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) {
|
||||||
|
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 flex-col sm:flex-row">
|
||||||
|
<SettingsMenu />
|
||||||
|
<div className="p-4 w-full h-[calc(100%-32px)] overflow-y-auto flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-medium text-main-view-fg/80 mb-2">
|
||||||
|
{t('common:notAvailable')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-main-view-fg/60">
|
||||||
|
Model provider settings are not available on the web platform.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<HeaderPage>
|
<HeaderPage>
|
||||||
|
|||||||
@ -9,43 +9,11 @@ import { ModelCapabilities } from '@/types/models'
|
|||||||
import { modelSettings } from '@/lib/predefined'
|
import { modelSettings } from '@/lib/predefined'
|
||||||
import { ExtensionManager } from '@/lib/extension'
|
import { ExtensionManager } from '@/lib/extension'
|
||||||
import type { ProvidersService } from './types'
|
import type { ProvidersService } from './types'
|
||||||
|
import { PlatformFeatures } from '@/lib/platform/const'
|
||||||
|
import { PlatformFeature } from '@/lib/platform/types'
|
||||||
|
|
||||||
export class WebProvidersService implements ProvidersService {
|
export class WebProvidersService implements ProvidersService {
|
||||||
async getProviders(): Promise<ModelProvider[]> {
|
async getProviders(): Promise<ModelProvider[]> {
|
||||||
const builtinProviders = predefinedProviders.map((provider) => {
|
|
||||||
let models = provider.models as Model[]
|
|
||||||
if (Object.keys(providerModels).includes(provider.provider)) {
|
|
||||||
const builtInModels = providerModels[
|
|
||||||
provider.provider as unknown as keyof typeof providerModels
|
|
||||||
].models as unknown as string[]
|
|
||||||
|
|
||||||
if (Array.isArray(builtInModels))
|
|
||||||
models = builtInModels.map((model) => {
|
|
||||||
const modelManifest = models.find((e) => e.id === model)
|
|
||||||
// TODO: Check chat_template for tool call support
|
|
||||||
const capabilities = [
|
|
||||||
ModelCapabilities.COMPLETION,
|
|
||||||
(
|
|
||||||
providerModels[
|
|
||||||
provider.provider as unknown as keyof typeof providerModels
|
|
||||||
].supportsToolCalls as unknown as string[]
|
|
||||||
).includes(model)
|
|
||||||
? ModelCapabilities.TOOLS
|
|
||||||
: undefined,
|
|
||||||
].filter(Boolean) as string[]
|
|
||||||
return {
|
|
||||||
...(modelManifest ?? { id: model, name: model }),
|
|
||||||
capabilities,
|
|
||||||
} as Model
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...provider,
|
|
||||||
models,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const runtimeProviders: ModelProvider[] = []
|
const runtimeProviders: ModelProvider[] = []
|
||||||
for (const [providerName, value] of EngineManager.instance().engines) {
|
for (const [providerName, value] of EngineManager.instance().engines) {
|
||||||
const models = (await value.list()) ?? []
|
const models = (await value.list()) ?? []
|
||||||
@ -105,6 +73,45 @@ export class WebProvidersService implements ProvidersService {
|
|||||||
runtimeProviders.push(provider)
|
runtimeProviders.push(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!PlatformFeatures[PlatformFeature.DEFAULT_PROVIDERS]) {
|
||||||
|
return runtimeProviders
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinProviders = predefinedProviders.map((provider) => {
|
||||||
|
let models = provider.models as Model[]
|
||||||
|
if (Object.keys(providerModels).includes(provider.provider)) {
|
||||||
|
const builtInModels = providerModels[
|
||||||
|
provider.provider as unknown as keyof typeof providerModels
|
||||||
|
].models as unknown as string[]
|
||||||
|
|
||||||
|
if (Array.isArray(builtInModels)) {
|
||||||
|
models = builtInModels.map((model) => {
|
||||||
|
const modelManifest = models.find((e) => e.id === model)
|
||||||
|
// TODO: Check chat_template for tool call support
|
||||||
|
const capabilities = [
|
||||||
|
ModelCapabilities.COMPLETION,
|
||||||
|
(
|
||||||
|
providerModels[
|
||||||
|
provider.provider as unknown as keyof typeof providerModels
|
||||||
|
].supportsToolCalls as unknown as string[]
|
||||||
|
).includes(model)
|
||||||
|
? ModelCapabilities.TOOLS
|
||||||
|
: undefined,
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
return {
|
||||||
|
...(modelManifest ?? { id: model, name: model }),
|
||||||
|
capabilities,
|
||||||
|
} as Model
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...provider,
|
||||||
|
models,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return runtimeProviders.concat(builtinProviders as ModelProvider[])
|
return runtimeProviders.concat(builtinProviders as ModelProvider[])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +139,7 @@ export class WebProvidersService implements ProvidersService {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to fetch models: ${response.status} ${response.statusText}`
|
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,14 +170,14 @@ 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
|
// Provide helpful error message for any connection errors
|
||||||
if (error instanceof Error && error.message.includes('fetch')) {
|
if (error instanceof Error && error.message.includes('Cannot connect')) {
|
||||||
throw new Error(
|
throw error
|
||||||
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw new Error(
|
||||||
|
`Cannot connect to ${provider.provider} at ${provider.base_url}. Please check that the service is running and accessible.`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ vi.mock('@/lib/platform/const', () => ({
|
|||||||
modelHub: true,
|
modelHub: true,
|
||||||
systemIntegrations: true,
|
systemIntegrations: true,
|
||||||
httpsProxy: true,
|
httpsProxy: true,
|
||||||
|
defaultProviders: true,
|
||||||
|
analytics: true,
|
||||||
|
webAutoModelSelection: true,
|
||||||
|
modelProviderSettings: true,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -28,11 +32,12 @@ const mockServiceHub = {
|
|||||||
setTheme: vi.fn(),
|
setTheme: vi.fn(),
|
||||||
toggleTheme: vi.fn(),
|
toggleTheme: vi.fn(),
|
||||||
}),
|
}),
|
||||||
window: () => ({
|
window: vi.fn().mockReturnValue({
|
||||||
minimize: vi.fn(),
|
minimize: vi.fn(),
|
||||||
maximize: vi.fn(),
|
maximize: vi.fn(),
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
isMaximized: vi.fn().mockResolvedValue(false),
|
isMaximized: vi.fn().mockResolvedValue(false),
|
||||||
|
openLogsWindow: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
events: () => ({
|
events: () => ({
|
||||||
emit: vi.fn().mockResolvedValue(undefined),
|
emit: vi.fn().mockResolvedValue(undefined),
|
||||||
@ -41,7 +46,7 @@ const mockServiceHub = {
|
|||||||
hardware: () => ({
|
hardware: () => ({
|
||||||
getHardwareInfo: vi.fn().mockResolvedValue(null),
|
getHardwareInfo: vi.fn().mockResolvedValue(null),
|
||||||
getSystemUsage: vi.fn().mockResolvedValue(null),
|
getSystemUsage: vi.fn().mockResolvedValue(null),
|
||||||
getLlamacppDevices: vi.fn().mockResolvedValue([]),
|
getLlamacppDevices: vi.fn().mockResolvedValue([]), // cspell: disable-line
|
||||||
setActiveGpus: vi.fn().mockResolvedValue(undefined),
|
setActiveGpus: vi.fn().mockResolvedValue(undefined),
|
||||||
// Legacy methods for backward compatibility
|
// Legacy methods for backward compatibility
|
||||||
getGpuInfo: vi.fn().mockResolvedValue([]),
|
getGpuInfo: vi.fn().mockResolvedValue([]),
|
||||||
@ -52,6 +57,8 @@ const mockServiceHub = {
|
|||||||
getAppSettings: vi.fn().mockResolvedValue({}),
|
getAppSettings: vi.fn().mockResolvedValue({}),
|
||||||
updateAppSettings: vi.fn().mockResolvedValue(undefined),
|
updateAppSettings: vi.fn().mockResolvedValue(undefined),
|
||||||
getSystemInfo: vi.fn().mockResolvedValue({}),
|
getSystemInfo: vi.fn().mockResolvedValue({}),
|
||||||
|
relocateJanDataFolder: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getJanDataFolder: vi.fn().mockResolvedValue('/mock/jan/data'),
|
||||||
}),
|
}),
|
||||||
analytic: () => ({
|
analytic: () => ({
|
||||||
track: vi.fn(),
|
track: vi.fn(),
|
||||||
@ -104,6 +111,9 @@ const mockServiceHub = {
|
|||||||
deleteModel: vi.fn().mockResolvedValue(undefined),
|
deleteModel: vi.fn().mockResolvedValue(undefined),
|
||||||
updateModel: vi.fn().mockResolvedValue(undefined),
|
updateModel: vi.fn().mockResolvedValue(undefined),
|
||||||
startModel: vi.fn().mockResolvedValue(undefined),
|
startModel: vi.fn().mockResolvedValue(undefined),
|
||||||
|
isModelSupported: vi.fn().mockResolvedValue('GREEN'),
|
||||||
|
checkMmprojExists: vi.fn().mockResolvedValue(true), // cspell: disable-line
|
||||||
|
stopAllModels: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
assistants: () => ({
|
assistants: () => ({
|
||||||
getAssistants: vi.fn().mockResolvedValue([]),
|
getAssistants: vi.fn().mockResolvedValue([]),
|
||||||
@ -117,15 +127,16 @@ const mockServiceHub = {
|
|||||||
save: vi.fn().mockResolvedValue('/path/to/file'),
|
save: vi.fn().mockResolvedValue('/path/to/file'),
|
||||||
message: vi.fn().mockResolvedValue(undefined),
|
message: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
opener: () => ({
|
opener: vi.fn().mockReturnValue({
|
||||||
open: vi.fn().mockResolvedValue(undefined),
|
open: vi.fn().mockResolvedValue(undefined),
|
||||||
|
revealItemInDir: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
updater: () => ({
|
updater: () => ({
|
||||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||||
installUpdate: vi.fn().mockResolvedValue(undefined),
|
installUpdate: vi.fn().mockResolvedValue(undefined),
|
||||||
downloadAndInstallWithProgress: vi.fn().mockResolvedValue(undefined),
|
downloadAndInstallWithProgress: vi.fn().mockResolvedValue(undefined),
|
||||||
}),
|
}),
|
||||||
path: () => ({
|
path: vi.fn().mockReturnValue({
|
||||||
sep: () => '/',
|
sep: () => '/',
|
||||||
join: vi.fn((...args) => args.join('/')),
|
join: vi.fn((...args) => args.join('/')),
|
||||||
resolve: vi.fn((path) => path),
|
resolve: vi.fn((path) => path),
|
||||||
@ -137,7 +148,7 @@ const mockServiceHub = {
|
|||||||
stopCore: vi.fn().mockResolvedValue(undefined),
|
stopCore: vi.fn().mockResolvedValue(undefined),
|
||||||
getCoreStatus: vi.fn().mockResolvedValue('stopped'),
|
getCoreStatus: vi.fn().mockResolvedValue('stopped'),
|
||||||
}),
|
}),
|
||||||
deeplink: () => ({
|
deeplink: () => ({ // cspell: disable-line
|
||||||
register: vi.fn().mockResolvedValue(undefined),
|
register: vi.fn().mockResolvedValue(undefined),
|
||||||
handle: vi.fn().mockResolvedValue(undefined),
|
handle: vi.fn().mockResolvedValue(undefined),
|
||||||
getCurrent: vi.fn().mockResolvedValue(null),
|
getCurrent: vi.fn().mockResolvedValue(null),
|
||||||
@ -168,6 +179,27 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mock globalThis.core.api for @janhq/core functions // cspell: disable-line
|
||||||
|
;(globalThis as Record<string, unknown>).core = {
|
||||||
|
api: {
|
||||||
|
getJanDataFolderPath: vi.fn().mockResolvedValue('/mock/jan/data'),
|
||||||
|
openFileExplorer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
joinPath: vi.fn((...paths: string[]) => paths.join('/')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock globalThis.fs for @janhq/core fs functions // cspell: disable-line
|
||||||
|
;(globalThis as Record<string, unknown>).fs = {
|
||||||
|
existsSync: vi.fn().mockResolvedValue(false),
|
||||||
|
readFile: vi.fn().mockResolvedValue(''),
|
||||||
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
readdir: vi.fn().mockResolvedValue([]),
|
||||||
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
|
unlink: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rmdir: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// runs a cleanup after each test case (e.g. clearing jsdom)
|
// runs a cleanup after each test case (e.g. clearing jsdom)
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|||||||
@ -64,4 +64,6 @@ export default defineConfig({
|
|||||||
port: 3001,
|
port: 3001,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
// Enable SPA mode - fallback to index.html for all routes
|
||||||
|
appType: 'spa',
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user