♻️ Sync changes from version 0.6.1 into dev branch (#5384)

* chore: enable shortcut zoom (#5261)

* chore: enable shortcut zoom

* chore: update shortcut setting

* fix: thinking block (#5263)

* Merge pull request #5262 from menloresearch/chore/sync-new-hub-data

chore: sync new hub data

* enhancement: model run improvement (#5268)

* fix: mcp tool error handling

* fix: error message

* fix: trigger download from recommend model

* fix: can't scroll hub

* fix: show progress

* enhancement: prompt users to increase context size

* enhancement: rearrange action buttons for a better UX

* 🔧chore: clean up logics

---------

Co-authored-by: Faisal Amir <urmauur@gmail.com>

* fix: glitch download from onboarding (#5269)

* enhancement: Model sources should not be hard coded from frontend (#5270)

* 🐛fix: default onboarding model should use recommended quantizations (#5273)

* 🐛fix: default onboarding model should use recommended quantizations

* enhancement: show context shift option in provider settings

* 🔧chore: wording

* 🔧 config: add to gitignore

* 🐛fix: Jan-nano repo name changed (#5274)

* 🚧 wip: disable showSpeedToken in ChatInput

* 🐛 fix: commented out the wrong import

* fix: masking value MCP env field (#5276)

*  feat: add token speed to each message that persist

* ♻️ refactor: to follow prettier convention

* 🐛 fix: exclude deleted field

* 🧹 clean: all the missed console.log

* enhancement: out of context troubleshooting (#5275)

* enhancement: out of context troubleshooting

* 🔧refactor: clean up

* enhancement: add setting chat width container (#5289)

* enhancement: add setting conversation width

* enahncement: cleanup log and change improve accesibility

* enahcement: move const beta version

* 🐛fix: optional additional_information gpu (#5291)

* 🐛fix: showing release notes for beta and prod (#5292)

* 🐛fix: showing release notes for beta and prod

* ♻️refactor: make an utils env

* ♻️refactor: hide MCP for production

* ♻️refactor: simplify the boolean expression fetch release note

* 🐛fix: typo in build type check (#5297)

* 🐛fix: remove onboarding local model and hide the edit capabilities model (#5301)

* 🐛fix: remove onboarding local model and hide the edit capabilities model

* ♻️refactor: conditional search params setup screen

* 🐛fix: hide token speed when assistant params stream false (#5302)

* 🐛fix: glitch padding speed token (#5307)

* 🐛fix: immediately show download progress (#5308)

* 🐛fix:safely convert values to numbers and handle NaN cases (#5309)

* chore: correct binary name for stable version (#5303) (#5311)

Co-authored-by: hiento09 <136591877+hiento09@users.noreply.github.com>

* 🐛fix: llama.cpp default NGL setting does not offload all layers to GPU (#5310)

* 🐛fix: llama.cpp default NGL setting does not offload all layers to GPU

* chore: cover more cases

* chore: clean up

* fix: should not show GPU section on Mac

* 🐛fix: update default extension settings (#5315)

* fix: update default extension settings

* chore: hide language setting on Prod

* 🐛fix: allow script posthog (#5316)

* Sync 0.5.18 to 0.6.0 (#5320)

* chore: correct binary name for stable version (#5303)

* ci: enable devtool on prod build (#5317)

* ci: enable devtool on prod build

---------

Co-authored-by: hiento09 <136591877+hiento09@users.noreply.github.com>
Co-authored-by: Nguyen Ngoc Minh <91668012+Minh141120@users.noreply.github.com>

* fix: glitch model download issue (#5322)

* 🐛 fix(updater): terminate sidecar processes before update to avoid file access errors (#5325)

* 🐛 fix: disable sorting for threads in SortableItem and clean up thread order handling (#5326)

* improved wording in UI elements (#5323)

* fix: sorted-thread-not-stable (#5336)

* 🐛fix: update wording desc vulkan (#5338)

* 🐛fix: update wording desc vulkan

* enhancement: update copy

* 🐛fix: handle NaN value tokenspeed (#5339)

* 🐛 fix: window path problem

* feat(server): filter /models endpoint to show only downloaded models (#5343)

- Add filtering logic to proxy server for GET /models requests
- Keep only models with status "downloaded" in response
- Remove Content-Length header to prevent mismatch after filtering
- Support both ListModelsResponseDto and direct array formats
- Add comprehensive tests for filtering functionality
- Fix Content-Length header conflict causing empty responses

Fixes issue where all models were returned regardless of download status.

* 🐛fix: render streaming token speed based on thread ID & assistant metadata (#5346)

* fix(server): add gzip decompression support for /models endpoint filtering (#5349)

- Add gzip detection using magic number check (0x1f 0x8b)
- Implement gzip decompression before JSON parsing
- Add gzip re-compression for filtered responses
- Fix "invalid utf-8 sequence" error when upstream returns gzipped content
- Maintain Content-Encoding consistency for compressed responses
- Add comprehensive gzip handling with flate2 library

Resolves issue where filtering failed on gzip-compressed model responses.

* fix(proxy): implement true HTTP streaming for chat completions API (#5350)

* fix: glitch toggle gpus (#5353)

* fix: glitch toogle gpu

* fix: Using the GPU's array index as a key for gpuLoading

* enhancement: added try-finally

* fix: built in models capabilities (#5354)

* 🐛fix: setting provider hide model capabilities (#5355)

* 🐛fix: setting provider hide model capabilities

* 🐛fix: hide tools icon on dropdown model providers

* fix: stop server on app close or reload

* enhancement: reset heading class

---------

Co-authored-by: Louis <louis@jan.ai>

* fix: stop api server on page unload (#5356)

* fix: stop api server on page unload

* fix: check api server status on reload

* refactor: api server state

* fix: should not pop the guard

* 🐛fix: avoid render html title thread (#5375)

* 🐛fix: avoid render html title thread

* chore: minor bump - tokenjs for manual adding models

---------

Co-authored-by: Louis <louis@jan.ai>

---------

Co-authored-by: Faisal Amir <urmauur@gmail.com>
Co-authored-by: LazyYuuki <huy2840@gmail.com>
Co-authored-by: Bui Quang Huy <34532913+LazyYuuki@users.noreply.github.com>
Co-authored-by: hiento09 <136591877+hiento09@users.noreply.github.com>
Co-authored-by: Nguyen Ngoc Minh <91668012+Minh141120@users.noreply.github.com>
Co-authored-by: Sam Hoang Van <samhv.ict@gmail.com>
Co-authored-by: Ramon Perez <ramonpzg@protonmail.com>
This commit is contained in:
Louis 2025-06-20 09:46:43 +07:00 committed by GitHub
commit a1cef450de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 662 additions and 275 deletions

View File

@ -122,6 +122,10 @@ jobs:
jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json jq --arg version "${{ inputs.new_version }}" '.version = $version' web-app/package.json > /tmp/package.json
mv /tmp/package.json web-app/package.json mv /tmp/package.json web-app/package.json
# Temporarily enable devtool on prod build
ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools"
cat ./src-tauri/Cargo.toml
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
cat ./src-tauri/Cargo.toml cat ./src-tauri/Cargo.toml

View File

@ -126,6 +126,10 @@ jobs:
ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}" ctoml ./src-tauri/Cargo.toml package.version "${{ inputs.new_version }}"
cat ./src-tauri/Cargo.toml cat ./src-tauri/Cargo.toml
# Temporarily enable devtool on prod build
ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools"
cat ./src-tauri/Cargo.toml
# Change app name for beta and nightly builds # Change app name for beta and nightly builds
if [ "${{ inputs.channel }}" != "stable" ]; then if [ "${{ inputs.channel }}" != "stable" ]; then
jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json

View File

@ -137,6 +137,10 @@ jobs:
sed -i "s/jan_version/$new_base_version/g" ./src-tauri/tauri.bundle.windows.nsis.template sed -i "s/jan_version/$new_base_version/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_build/$new_build_version/g" ./src-tauri/tauri.bundle.windows.nsis.template sed -i "s/jan_build/$new_build_version/g" ./src-tauri/tauri.bundle.windows.nsis.template
# Temporarily enable devtool on prod build
ctoml ./src-tauri/Cargo.toml dependencies.tauri.features[] "devtools"
cat ./src-tauri/Cargo.toml
# Change app name for beta and nightly builds # Change app name for beta and nightly builds
if [ "${{ inputs.channel }}" != "stable" ]; then if [ "${{ inputs.channel }}" != "stable" ]; then
jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json jq '.plugins.updater.endpoints = ["https://delta.jan.ai/${{ inputs.channel }}/latest.json"]' ./src-tauri/tauri.conf.json > /tmp/tauri.conf.json
@ -160,6 +164,9 @@ jobs:
sed -i "s/jan_productname/Jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template sed -i "s/jan_productname/Jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_mainbinaryname/jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template sed -i "s/jan_mainbinaryname/jan-${{ inputs.channel }}/g" ./src-tauri/tauri.bundle.windows.nsis.template
else
sed -i "s/jan_productname/Jan/g" ./src-tauri/tauri.bundle.windows.nsis.template
sed -i "s/jan_mainbinaryname/jan/g" ./src-tauri/tauri.bundle.windows.nsis.template
fi fi
echo "---------nsis.template---------" echo "---------nsis.template---------"
cat ./src-tauri/tauri.bundle.windows.nsis.template cat ./src-tauri/tauri.bundle.windows.nsis.template

View File

@ -17,7 +17,7 @@ export const validationRules: { [key: string]: (value: any) => boolean } = {
presence_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1, presence_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1,
ctx_len: (value: any) => Number.isInteger(value) && value >= 0, ctx_len: (value: any) => Number.isInteger(value) && value >= 0,
ngl: (value: any) => Number.isInteger(value) && value >= 0, ngl: (value: any) => Number.isInteger(value),
embedding: (value: any) => typeof value === 'boolean', embedding: (value: any) => typeof value === 'boolean',
n_parallel: (value: any) => Number.isInteger(value) && value >= 0, n_parallel: (value: any) => Number.isInteger(value) && value >= 0,
cpu_threads: (value: any) => Number.isInteger(value) && value >= 0, cpu_threads: (value: any) => Number.isInteger(value) && value >= 0,

View File

@ -14,7 +14,7 @@
"description": "Automatically shifts the context window when the model is unable to process the entire prompt, ensuring that the most relevant information is always included.", "description": "Automatically shifts the context window when the model is unable to process the entire prompt, ensuring that the most relevant information is always included.",
"controllerType": "checkbox", "controllerType": "checkbox",
"controllerProps": { "controllerProps": {
"value": true "value": false
} }
}, },
{ {

View File

@ -64,7 +64,7 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
cpu_threads?: number cpu_threads?: number
auto_unload_models: boolean = true auto_unload_models: boolean = true
reasoning_budget = -1 // Default reasoning budget in seconds reasoning_budget = -1 // Default reasoning budget in seconds
context_shift = true context_shift = false
/** /**
* The URL for making inference requests. * The URL for making inference requests.
*/ */
@ -132,7 +132,7 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
this.flash_attn = await this.getSetting<boolean>(Settings.flash_attn, true) this.flash_attn = await this.getSetting<boolean>(Settings.flash_attn, true)
this.context_shift = await this.getSetting<boolean>( this.context_shift = await this.getSetting<boolean>(
Settings.context_shift, Settings.context_shift,
true false
) )
this.use_mmap = await this.getSetting<boolean>(Settings.use_mmap, true) this.use_mmap = await this.getSetting<boolean>(Settings.use_mmap, true)
if (this.caching_enabled) if (this.caching_enabled)
@ -253,11 +253,12 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
} }
} }
} }
const modelSettings = extractModelLoadParams(model.settings)
return await this.apiInstance().then((api) => return await this.apiInstance().then((api) =>
api api
.post('v1/models/start', { .post('v1/models/start', {
json: { json: {
...extractModelLoadParams(model.settings), ...modelSettings,
model: model.id, model: model.id,
engine: engine:
model.engine === 'nitro' // Legacy model cache model.engine === 'nitro' // Legacy model cache
@ -282,6 +283,9 @@ export default class JanInferenceCortexExtension extends LocalOAIEngine {
...(this.context_shift === false ...(this.context_shift === false
? { 'no-context-shift': true } ? { 'no-context-shift': true }
: {}), : {}),
...(modelSettings.ngl === -1 || modelSettings.ngl === undefined
? { ngl: 100 }
: {}),
}, },
timeout: false, timeout: false,
signal, signal,

View File

@ -125,59 +125,59 @@
}, },
"models": [ "models": [
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-iQ4_XS.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-iQ4_XS.gguf",
"size": 2270750400 "size": 2270750400
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q3_K_L.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q3_K_L.gguf",
"size": 2239784384 "size": 2239784384
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q3_K_M.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q3_K_M.gguf",
"size": 2075616704 "size": 2075616704
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q3_K_S.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q3_K_S.gguf",
"size": 1886995904 "size": 1886995904
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q4_0.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q4_0.gguf",
"size": 2369545664 "size": 2369545664
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q4_1.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q4_1.gguf",
"size": 2596627904 "size": 2596627904
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q4_K_M.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q4_K_M.gguf",
"size": 2497279424 "size": 2497279424
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q4_K_S.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q4_K_S.gguf",
"size": 2383308224 "size": 2383308224
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q5_0.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q5_0.gguf",
"size": 2823710144 "size": 2823710144
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q5_1.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q5_1.gguf",
"size": 3050792384 "size": 3050792384
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q5_K_M.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q5_K_M.gguf",
"size": 2889512384 "size": 2889512384
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q5_K_S.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q5_K_S.gguf",
"size": 2823710144 "size": 2823710144
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q6_K.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q6_K.gguf",
"size": 3306259904 "size": 3306259904
}, },
{ {
"id": "Menlo:Jan-nano:jan-nano-4b-Q8_0.gguf", "id": "Menlo:Jan-nano-gguf:jan-nano-4b-Q8_0.gguf",
"size": 4280403904 "size": 4280403904
} }
] ]

View File

@ -348,23 +348,41 @@ pub async fn start_server(
api_key: String, api_key: String,
trusted_hosts: Vec<String>, trusted_hosts: Vec<String>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let auth_token = app let state = app.state::<AppState>();
.state::<AppState>() let auth_token = state.app_token.clone().unwrap_or_default();
.app_token let server_handle = state.server_handle.clone();
.clone()
.unwrap_or_default(); server::start_server(
server::start_server(host, port, prefix, auth_token, api_key, trusted_hosts) server_handle,
.await host,
.map_err(|e| e.to_string())?; port,
prefix,
auth_token,
api_key,
trusted_hosts,
)
.await
.map_err(|e| e.to_string())?;
Ok(true) Ok(true)
} }
#[tauri::command] #[tauri::command]
pub async fn stop_server() -> Result<(), String> { pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> {
server::stop_server().await.map_err(|e| e.to_string())?; let server_handle = state.server_handle.clone();
server::stop_server(server_handle)
.await
.map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn get_server_status(state: State<'_, AppState>) -> Result<bool, String> {
let server_handle = state.server_handle.clone();
Ok(server::is_server_running(server_handle).await)
}
#[tauri::command] #[tauri::command]
pub async fn read_logs(app: AppHandle) -> Result<String, String> { pub async fn read_logs(app: AppHandle) -> Result<String, String> {
let log_path = get_jan_data_folder_path(app).join("logs").join("app.log"); let log_path = get_jan_data_folder_path(app).join("logs").join("app.log");

View File

@ -1,17 +1,16 @@
use flate2::read::GzDecoder;
use futures_util::StreamExt;
use hyper::service::{make_service_fn, service_fn}; use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server, StatusCode}; use hyper::{Body, Request, Response, Server, StatusCode};
use reqwest::Client; use reqwest::Client;
use serde_json::Value;
use std::convert::Infallible; use std::convert::Infallible;
use std::io::Read;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::LazyLock; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle;
/// Server handle type for managing the proxy server lifecycle use crate::core::state::ServerHandle;
type ServerHandle = JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>;
/// Global singleton for the current server instance
static SERVER_HANDLE: LazyLock<Mutex<Option<ServerHandle>>> = LazyLock::new(|| Mutex::new(None));
/// Configuration for the proxy server /// Configuration for the proxy server
#[derive(Clone)] #[derive(Clone)]
@ -263,11 +262,12 @@ async fn proxy_request(
let original_path = req.uri().path(); let original_path = req.uri().path();
let path = get_destination_path(original_path, &config.prefix); let path = get_destination_path(original_path, &config.prefix);
let method = req.method().clone();
// Verify Host header (check target), but bypass for whitelisted paths // Verify Host header (check target), but bypass for whitelisted paths
let whitelisted_paths = ["/", "/openapi.json", "/favicon.ico"]; let whitelisted_paths = ["/", "/openapi.json", "/favicon.ico"];
let is_whitelisted_path = whitelisted_paths.contains(&path.as_str()); let is_whitelisted_path = whitelisted_paths.contains(&path.as_str());
if !is_whitelisted_path { if !is_whitelisted_path {
if !host_header.is_empty() { if !host_header.is_empty() {
if !is_valid_host(&host_header, &config.trusted_hosts) { if !is_valid_host(&host_header, &config.trusted_hosts) {
@ -328,7 +328,10 @@ async fn proxy_request(
.unwrap()); .unwrap());
} }
} else if is_whitelisted_path { } else if is_whitelisted_path {
log::debug!("Bypassing authorization check for whitelisted path: {}", path); log::debug!(
"Bypassing authorization check for whitelisted path: {}",
path
);
} }
// Block access to /configs endpoint // Block access to /configs endpoint
@ -368,10 +371,11 @@ async fn proxy_request(
let mut builder = Response::builder().status(status); let mut builder = Response::builder().status(status);
// Copy response headers, excluding CORS headers to avoid conflicts // Copy response headers, excluding CORS headers and Content-Length to avoid conflicts
for (name, value) in response.headers() { for (name, value) in response.headers() {
// Skip CORS headers from upstream to avoid duplicates // Skip CORS headers from upstream to avoid duplicates
if !is_cors_header(name.as_str()) { // Skip Content-Length header when filtering models response to avoid mismatch
if !is_cors_header(name.as_str()) && name != hyper::header::CONTENT_LENGTH {
builder = builder.header(name, value); builder = builder.header(name, value);
} }
} }
@ -384,23 +388,59 @@ async fn proxy_request(
&config.trusted_hosts, &config.trusted_hosts,
); );
// Read response body // Handle streaming vs non-streaming responses
match response.bytes().await { if path.contains("/models") && method == hyper::Method::GET {
Ok(bytes) => Ok(builder.body(Body::from(bytes)).unwrap()), // For /models endpoint, we need to buffer and filter the response
Err(e) => { match response.bytes().await {
log::error!("Failed to read response body: {}", e); Ok(bytes) => match filter_models_response(&bytes) {
let mut error_response = Ok(filtered_bytes) => Ok(builder.body(Body::from(filtered_bytes)).unwrap()),
Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR); Err(e) => {
error_response = add_cors_headers_with_host_and_origin( log::warn!(
error_response, "Failed to filter models response: {}, returning original",
&host_header, e
&origin_header, );
&config.trusted_hosts, Ok(builder.body(Body::from(bytes)).unwrap())
); }
Ok(error_response },
.body(Body::from("Error reading upstream response")) Err(e) => {
.unwrap()) log::error!("Failed to read response body: {}", e);
let mut error_response =
Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR);
error_response = add_cors_headers_with_host_and_origin(
error_response,
&host_header,
&origin_header,
&config.trusted_hosts,
);
Ok(error_response
.body(Body::from("Error reading upstream response"))
.unwrap())
}
} }
} else {
// For streaming endpoints (like chat completions), we need to collect and forward the stream
let mut stream = response.bytes_stream();
let (mut sender, body) = hyper::Body::channel();
// Spawn a task to forward the stream
tokio::spawn(async move {
while let Some(chunk_result) = stream.next().await {
match chunk_result {
Ok(chunk) => {
if sender.send_data(chunk).await.is_err() {
log::debug!("Client disconnected during streaming");
break;
}
}
Err(e) => {
log::error!("Stream error: {}", e);
break;
}
}
}
});
Ok(builder.body(body).unwrap())
} }
} }
Err(e) => { Err(e) => {
@ -419,6 +459,98 @@ async fn proxy_request(
} }
} }
/// Checks if the byte array starts with gzip magic number
fn is_gzip_encoded(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b
}
/// Decompresses gzip-encoded bytes
fn decompress_gzip(bytes: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let mut decoder = GzDecoder::new(bytes);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
/// Compresses bytes using gzip
fn compress_gzip(bytes: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(bytes)?;
let compressed = encoder.finish()?;
Ok(compressed)
}
/// Filters models response to keep only models with status "downloaded"
fn filter_models_response(
bytes: &[u8],
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
// Try to decompress if it's gzip-encoded
let decompressed_bytes = if is_gzip_encoded(bytes) {
log::debug!("Response is gzip-encoded, decompressing...");
decompress_gzip(bytes)?
} else {
bytes.to_vec()
};
let response_text = std::str::from_utf8(&decompressed_bytes)?;
let mut response_json: Value = serde_json::from_str(response_text)?;
// Check if this is a ListModelsResponseDto format with data array
if let Some(data_array) = response_json.get_mut("data") {
if let Some(models) = data_array.as_array_mut() {
// Keep only models where status == "downloaded"
models.retain(|model| {
if let Some(status) = model.get("status") {
if let Some(status_str) = status.as_str() {
status_str == "downloaded"
} else {
false // Remove models without string status
}
} else {
false // Remove models without status field
}
});
log::debug!(
"Filtered models response: {} downloaded models remaining",
models.len()
);
}
} else if response_json.is_array() {
// Handle direct array format
if let Some(models) = response_json.as_array_mut() {
models.retain(|model| {
if let Some(status) = model.get("status") {
if let Some(status_str) = status.as_str() {
status_str == "downloaded"
} else {
false // Remove models without string status
}
} else {
false // Remove models without status field
}
});
log::debug!(
"Filtered models response: {} downloaded models remaining",
models.len()
);
}
}
let filtered_json = serde_json::to_vec(&response_json)?;
// If original was gzip-encoded, re-compress the filtered response
if is_gzip_encoded(bytes) {
log::debug!("Re-compressing filtered response with gzip");
compress_gzip(&filtered_json)
} else {
Ok(filtered_json)
}
}
/// Checks if a header is a CORS-related header that should be filtered out from upstream responses /// Checks if a header is a CORS-related header that should be filtered out from upstream responses
fn is_cors_header(header_name: &str) -> bool { fn is_cors_header(header_name: &str) -> bool {
let header_lower = header_name.to_lowercase(); let header_lower = header_name.to_lowercase();
@ -509,8 +641,19 @@ fn is_valid_host(host: &str, trusted_hosts: &[String]) -> bool {
}) })
} }
pub async fn is_server_running(server_handle: Arc<Mutex<Option<ServerHandle>>>) -> bool {
let handle_guard = server_handle.lock().await;
if handle_guard.is_some() {
true
} else {
false
}
}
/// Starts the proxy server /// Starts the proxy server
pub async fn start_server( pub async fn start_server(
server_handle: Arc<Mutex<Option<ServerHandle>>>,
host: String, host: String,
port: u16, port: u16,
prefix: String, prefix: String,
@ -519,7 +662,7 @@ pub async fn start_server(
trusted_hosts: Vec<String>, trusted_hosts: Vec<String>,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Check if server is already running // Check if server is already running
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() {
return Err("Server is already running".into()); return Err("Server is already running".into());
} }
@ -538,9 +681,11 @@ pub async fn start_server(
trusted_hosts, trusted_hosts,
}; };
// Create HTTP client // Create HTTP client with longer timeout for streaming
let client = Client::builder() let client = Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(300)) // 5 minutes for streaming
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(30))
.build()?; .build()?;
// Create service handler // Create service handler
@ -560,7 +705,7 @@ pub async fn start_server(
log::info!("Proxy server started on http://{}", addr); log::info!("Proxy server started on http://{}", addr);
// Spawn server task // Spawn server task
let server_handle = tokio::spawn(async move { let server_task = tokio::spawn(async move {
if let Err(e) = server.await { if let Err(e) = server.await {
log::error!("Server error: {}", e); log::error!("Server error: {}", e);
return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>); return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>);
@ -568,16 +713,20 @@ pub async fn start_server(
Ok(()) Ok(())
}); });
*handle_guard = Some(server_handle); *handle_guard = Some(server_task);
Ok(true) Ok(true)
} }
/// Stops the currently running proxy server /// Stops the currently running proxy server
pub async fn stop_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { pub async fn stop_server(
let mut handle_guard = SERVER_HANDLE.lock().await; server_handle: Arc<Mutex<Option<ServerHandle>>>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut handle_guard = server_handle.lock().await;
if let Some(handle) = handle_guard.take() { if let Some(handle) = handle_guard.take() {
handle.abort(); handle.abort();
// remove the handle to prevent future use
*handle_guard = None;
log::info!("Proxy server stopped"); log::info!("Proxy server stopped");
} else { } else {
log::debug!("No server was running"); log::debug!("No server was running");
@ -585,3 +734,139 @@ pub async fn stop_server() -> Result<(), Box<dyn std::error::Error + Send + Sync
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_filter_models_response_with_downloaded_status() {
let test_response = json!({
"object": "list",
"data": [
{
"id": "model1",
"name": "Model 1",
"status": "downloaded"
},
{
"id": "model2",
"name": "Model 2",
"status": "available"
},
{
"id": "model3",
"name": "Model 3"
}
]
});
let response_bytes = serde_json::to_vec(&test_response).unwrap();
let filtered_bytes = filter_models_response(&response_bytes).unwrap();
let filtered_response: serde_json::Value = serde_json::from_slice(&filtered_bytes).unwrap();
let data = filtered_response["data"].as_array().unwrap();
assert_eq!(data.len(), 1); // Should have 1 model (only model1 with "downloaded" status)
// Verify only model1 (with "downloaded" status) is kept
assert!(data.iter().any(|model| model["id"] == "model1"));
// Verify model2 and model3 are filtered out
assert!(!data.iter().any(|model| model["id"] == "model2"));
assert!(!data.iter().any(|model| model["id"] == "model3"));
}
#[test]
fn test_filter_models_response_direct_array() {
let test_response = json!([
{
"id": "model1",
"name": "Model 1",
"status": "downloaded"
},
{
"id": "model2",
"name": "Model 2",
"status": "available"
}
]);
let response_bytes = serde_json::to_vec(&test_response).unwrap();
let filtered_bytes = filter_models_response(&response_bytes).unwrap();
let filtered_response: serde_json::Value = serde_json::from_slice(&filtered_bytes).unwrap();
let data = filtered_response.as_array().unwrap();
assert_eq!(data.len(), 1); // Should have 1 model (only model1 with "downloaded" status)
assert!(data.iter().any(|model| model["id"] == "model1"));
assert!(!data.iter().any(|model| model["id"] == "model2"));
}
#[test]
fn test_filter_models_response_no_status_field() {
let test_response = json!({
"object": "list",
"data": [
{
"id": "model1",
"name": "Model 1"
},
{
"id": "model2",
"name": "Model 2"
}
]
});
let response_bytes = serde_json::to_vec(&test_response).unwrap();
let filtered_bytes = filter_models_response(&response_bytes).unwrap();
let filtered_response: serde_json::Value = serde_json::from_slice(&filtered_bytes).unwrap();
let data = filtered_response["data"].as_array().unwrap();
assert_eq!(data.len(), 0); // Should remove all models when no status field (no "downloaded" status)
}
#[test]
fn test_filter_models_response_multiple_downloaded() {
let test_response = json!({
"object": "list",
"data": [
{
"id": "model1",
"name": "Model 1",
"status": "downloaded"
},
{
"id": "model2",
"name": "Model 2",
"status": "available"
},
{
"id": "model3",
"name": "Model 3",
"status": "downloaded"
},
{
"id": "model4",
"name": "Model 4",
"status": "installing"
}
]
});
let response_bytes = serde_json::to_vec(&test_response).unwrap();
let filtered_bytes = filter_models_response(&response_bytes).unwrap();
let filtered_response: serde_json::Value = serde_json::from_slice(&filtered_bytes).unwrap();
let data = filtered_response["data"].as_array().unwrap();
assert_eq!(data.len(), 2); // Should have 2 models (model1 and model3 with "downloaded" status)
// Verify only models with "downloaded" status are kept
assert!(data.iter().any(|model| model["id"] == "model1"));
assert!(data.iter().any(|model| model["id"] == "model3"));
// Verify other models are filtered out
assert!(!data.iter().any(|model| model["id"] == "model2"));
assert!(!data.iter().any(|model| model["id"] == "model4"));
}
}

View File

@ -247,7 +247,10 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> {
]); ]);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
cmd = cmd.current_dir(app_handle_for_spawn.path().resource_dir().unwrap()); let resource_dir = app_handle_for_spawn.path().resource_dir().unwrap();
let normalized_path = resource_dir.to_string_lossy().replace(r"\\?\", "");
let normalized_pathbuf = PathBuf::from(normalized_path);
cmd = cmd.current_dir(normalized_pathbuf);
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
@ -291,6 +294,7 @@ pub fn setup_sidecar(app: &App) -> Result<(), String> {
} else { } else {
log::warn!("Kill event received, but no active sidecar process found to kill."); log::warn!("Kill event received, but no active sidecar process found to kill.");
} }
clean_up()
}); });
}); });

View File

@ -4,6 +4,10 @@ use crate::core::utils::download::DownloadManagerState;
use rand::{distributions::Alphanumeric, Rng}; use rand::{distributions::Alphanumeric, Rng};
use rmcp::{service::RunningService, RoleClient}; use rmcp::{service::RunningService, RoleClient};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle;
/// Server handle type for managing the proxy server lifecycle
pub type ServerHandle = JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>;
#[derive(Default)] #[derive(Default)]
pub struct AppState { pub struct AppState {
@ -12,6 +16,7 @@ pub struct AppState {
pub download_manager: Arc<Mutex<DownloadManagerState>>, pub download_manager: Arc<Mutex<DownloadManagerState>>,
pub cortex_restart_count: Arc<Mutex<u32>>, pub cortex_restart_count: Arc<Mutex<u32>>,
pub cortex_killed_intentionally: Arc<Mutex<bool>>, pub cortex_killed_intentionally: Arc<Mutex<bool>>,
pub server_handle: Arc<Mutex<Option<ServerHandle>>>,
} }
pub fn generate_app_token() -> String { pub fn generate_app_token() -> String {
rand::thread_rng() rand::thread_rng()

View File

@ -55,6 +55,7 @@ pub fn run() {
core::cmd::app_token, core::cmd::app_token,
core::cmd::start_server, core::cmd::start_server,
core::cmd::stop_server, core::cmd::stop_server,
core::cmd::get_server_status,
core::cmd::read_logs, core::cmd::read_logs,
core::cmd::change_app_data_folder, core::cmd::change_app_data_folder,
core::cmd::reset_cortex_restart_count, core::cmd::reset_cortex_restart_count,
@ -92,6 +93,7 @@ pub fn run() {
download_manager: Arc::new(Mutex::new(DownloadManagerState::default())), download_manager: Arc::new(Mutex::new(DownloadManagerState::default())),
cortex_restart_count: Arc::new(Mutex::new(0)), cortex_restart_count: Arc::new(Mutex::new(0)),
cortex_killed_intentionally: Arc::new(Mutex::new(false)), cortex_killed_intentionally: Arc::new(Mutex::new(false)),
server_handle: Arc::new(Mutex::new(None)),
}) })
.setup(|app| { .setup(|app| {
app.handle().plugin( app.handle().plugin(

View File

@ -45,7 +45,7 @@
], ],
"img-src": "'self' asset: http://asset.localhost blob: data: https://cdn.jsdelivr.net", "img-src": "'self' asset: http://asset.localhost blob: data: https://cdn.jsdelivr.net",
"style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com", "style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com",
"script-src": "'self' asset: $APPDATA/**.* http://asset.localhost" "script-src": "'self' asset: $APPDATA/**.* http://asset.localhost https://eu-assets.i.posthog.com https://posthog.com"
}, },
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,

View File

@ -65,7 +65,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"token.js": "npm:token.js-fork@0.7.5", "token.js": "npm:token.js-fork@0.7.9",
"tw-animate-css": "^1.2.7", "tw-animate-css": "^1.2.7",
"ulidx": "^2.4.1", "ulidx": "^2.4.1",
"unified": "^11.0.5", "unified": "^11.0.5",

View File

@ -19,7 +19,13 @@ export function DownloadManagement() {
const { setProviders } = useModelProvider() const { setProviders } = useModelProvider()
const { open: isLeftPanelOpen } = useLeftPanel() const { open: isLeftPanelOpen } = useLeftPanel()
const [isPopoverOpen, setIsPopoverOpen] = useState(false) const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const { downloads, updateProgress, removeDownload } = useDownloadStore() const {
downloads,
updateProgress,
localDownloadingModels,
removeDownload,
removeLocalDownloadingModel,
} = useDownloadStore()
const { updateState } = useAppUpdater() const { updateState } = useAppUpdater()
const [appUpdateState, setAppUpdateState] = useState({ const [appUpdateState, setAppUpdateState] = useState({
@ -76,23 +82,36 @@ export function DownloadManagement() {
}) })
}, []) }, [])
const downloadProcesses = useMemo(() => {
// Get downloads with progress data
const downloadsWithProgress = Object.values(downloads).map((download) => ({
id: download.name,
name: download.name,
progress: download.progress,
current: download.current,
total: download.total,
}))
// Add local downloading models that don't have progress data yet
const localDownloadsWithoutProgress = Array.from(localDownloadingModels)
.filter((modelId) => !downloads[modelId]) // Only include models not in downloads
.map((modelId) => ({
id: modelId,
name: modelId,
progress: 0,
current: 0,
total: 0,
}))
return [...downloadsWithProgress, ...localDownloadsWithoutProgress]
}, [downloads, localDownloadingModels])
const downloadCount = useMemo(() => { const downloadCount = useMemo(() => {
const modelDownloads = Object.keys(downloads).length const modelDownloads = downloadProcesses.length
const appUpdateDownload = appUpdateState.isDownloading ? 1 : 0 const appUpdateDownload = appUpdateState.isDownloading ? 1 : 0
const total = modelDownloads + appUpdateDownload const total = modelDownloads + appUpdateDownload
return total return total
}, [downloads, appUpdateState.isDownloading]) }, [downloadProcesses, appUpdateState.isDownloading])
const downloadProcesses = useMemo(
() =>
Object.values(downloads).map((download) => ({
id: download.name,
name: download.name,
progress: download.progress,
current: download.current,
total: download.total,
})),
[downloads]
)
const overallProgress = useMemo(() => { const overallProgress = useMemo(() => {
const modelTotal = downloadProcesses.reduce((acc, download) => { const modelTotal = downloadProcesses.reduce((acc, download) => {
@ -139,29 +158,32 @@ export function DownloadManagement() {
(state: DownloadState) => { (state: DownloadState) => {
console.debug('onFileDownloadError', state) console.debug('onFileDownloadError', state)
removeDownload(state.modelId) removeDownload(state.modelId)
removeLocalDownloadingModel(state.modelId)
}, },
[removeDownload] [removeDownload, removeLocalDownloadingModel]
) )
const onFileDownloadStopped = useCallback( const onFileDownloadStopped = useCallback(
(state: DownloadState) => { (state: DownloadState) => {
console.debug('onFileDownloadError', state) console.debug('onFileDownloadError', state)
removeDownload(state.modelId) removeDownload(state.modelId)
removeLocalDownloadingModel(state.modelId)
}, },
[removeDownload] [removeDownload, removeLocalDownloadingModel]
) )
const onFileDownloadSuccess = useCallback( const onFileDownloadSuccess = useCallback(
async (state: DownloadState) => { async (state: DownloadState) => {
console.debug('onFileDownloadSuccess', state) console.debug('onFileDownloadSuccess', state)
removeDownload(state.modelId) removeDownload(state.modelId)
removeLocalDownloadingModel(state.modelId)
getProviders().then(setProviders) getProviders().then(setProviders)
toast.success('Download Complete', { toast.success('Download Complete', {
id: 'download-complete', id: 'download-complete',
description: `The model ${state.modelId} has been downloaded`, description: `The model ${state.modelId} has been downloaded`,
}) })
}, },
[removeDownload, setProviders] [removeDownload, removeLocalDownloadingModel, setProviders]
) )
useEffect(() => { useEffect(() => {
@ -264,12 +286,16 @@ export function DownloadManagement() {
/> />
<p className="text-main-view-fg/60 text-xs"> <p className="text-main-view-fg/60 text-xs">
{`${renderGB(appUpdateState.downloadedBytes)} / ${renderGB(appUpdateState.totalBytes)}`}{' '} {`${renderGB(appUpdateState.downloadedBytes)} / ${renderGB(appUpdateState.totalBytes)}`}{' '}
GB ({Math.round(appUpdateState.downloadProgress * 100)}%) GB ({Math.round(appUpdateState.downloadProgress * 100)}
%)
</p> </p>
</div> </div>
)} )}
{downloadProcesses.map((download) => ( {downloadProcesses.map((download) => (
<div className="bg-main-view-fg/4 rounded-md p-2"> <div
key={download.id}
className="bg-main-view-fg/4 rounded-md p-2"
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="truncate text-main-view-fg/80"> <p className="truncate text-main-view-fg/80">
{download.name} {download.name}
@ -299,8 +325,9 @@ export function DownloadManagement() {
className="my-2" className="my-2"
/> />
<p className="text-main-view-fg/60 text-xs"> <p className="text-main-view-fg/60 text-xs">
{`${renderGB(download.current)} / ${renderGB(download.total)}`}{' '} {download.total > 0
GB ({Math.round(download.progress * 100)}%) ? `${renderGB(download.current)} / ${renderGB(download.total)} GB (${Math.round(download.progress * 100)}%)`
: 'Initializing download...'}
</p> </p>
</div> </div>
))} ))}

View File

@ -16,6 +16,7 @@ import { ModelSetting } from '@/containers/ModelSetting'
import ProvidersAvatar from '@/containers/ProvidersAvatar' import ProvidersAvatar from '@/containers/ProvidersAvatar'
import { Fzf } from 'fzf' import { Fzf } from 'fzf'
import { localStorageKey } from '@/constants/localStorage' import { localStorageKey } from '@/constants/localStorage'
import { isProd } from '@/lib/version'
type DropdownModelProviderProps = { type DropdownModelProviderProps = {
model?: ThreadModel model?: ThreadModel
@ -390,17 +391,12 @@ const DropdownModelProvider = ({
)} )}
> >
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<span <span className="truncate text-main-view-fg/80 text-sm">
className="truncate text-main-view-fg/80 text-sm" {searchableModel.model.id}
dangerouslySetInnerHTML={{ </span>
__html:
searchableModel.highlightedId ||
searchableModel.model.id,
}}
/>
<div className="flex-1"></div> <div className="flex-1"></div>
{capabilities.length > 0 && ( {!isProd && capabilities.length > 0 && (
<div className="flex-shrink-0 -mr-1.5"> <div className="flex-shrink-0 -mr-1.5">
<Capabilities capabilities={capabilities} /> <Capabilities capabilities={capabilities} />
</div> </div>

View File

@ -3,6 +3,7 @@ import { useModelProvider } from '@/hooks/useModelProvider'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
import HeaderPage from './HeaderPage' import HeaderPage from './HeaderPage'
import { isProd } from '@/lib/version'
function SetupScreen() { function SetupScreen() {
const { providers } = useModelProvider() const { providers } = useModelProvider()
@ -19,7 +20,7 @@ function SetupScreen() {
Welcome to Jan Welcome to Jan
</h1> </h1>
<p className="text-main-view-fg/70 text-lg mt-2"> <p className="text-main-view-fg/70 text-lg mt-2">
To get started, youll need to either download a local AI model or To get started, you'll need to either download a local AI model or
connect to a cloud model using an API key connect to a cloud model using an API key
</p> </p>
</div> </div>
@ -29,7 +30,7 @@ function SetupScreen() {
<Link <Link
to={route.hub} to={route.hub}
search={{ search={{
step: 'setup_local_provider', ...(!isProd ? { step: 'setup_local_provider' } : {}),
}} }}
> >
<div> <div>

View File

@ -358,12 +358,15 @@ export const ThreadContent = memo(
) : null} ) : null}
{!isToolCalls && ( {!isToolCalls && (
<div className="flex items-center gap-2 mt-2 text-main-view-fg/60 text-xs"> <div className="flex items-center gap-2 text-main-view-fg/60 text-xs">
<div className={cn('flex items-center gap-2')}> <div className={cn('flex items-center gap-2')}>
<div <div
className={cn( className={cn(
'flex items-center gap-2', 'flex items-center gap-2',
item.isLastMessage && streamingContent && 'hidden' item.isLastMessage &&
streamingContent &&
streamingContent.thread_id === item.thread_id &&
'hidden'
)} )}
> >
<CopyButton text={item.content?.[0]?.text.value || ''} /> <CopyButton text={item.content?.[0]?.text.value || ''} />
@ -417,17 +420,6 @@ export const ThreadContent = memo(
/> />
</div> </div>
</div> </div>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader> </DialogHeader>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -450,7 +442,11 @@ export const ThreadContent = memo(
</div> </div>
<TokenSpeedIndicator <TokenSpeedIndicator
streaming={Boolean(item.isLastMessage && streamingContent)} streaming={Boolean(
item.isLastMessage &&
streamingContent &&
streamingContent.thread_id === item.thread_id
)}
metadata={item.metadata} metadata={item.metadata}
/> />
</div> </div>
@ -458,6 +454,7 @@ export const ThreadContent = memo(
)} )}
</> </>
)} )}
{item.type === 'image_url' && image && ( {item.type === 'image_url' && image && (
<div> <div>
<img <img

View File

@ -9,7 +9,6 @@ import {
import { import {
SortableContext, SortableContext,
verticalListSortingStrategy, verticalListSortingStrategy,
arrayMove,
useSortable, useSortable,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
@ -54,7 +53,7 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
transform, transform,
transition, transition,
isDragging, isDragging,
} = useSortable({ id: thread.id }) } = useSortable({ id: thread.id, disabled: true })
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@ -102,9 +101,7 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
)} )}
> >
<div className="py-1 pr-2 truncate"> <div className="py-1 pr-2 truncate">
<span <span>{thread.title || 'New Thread'}</span>
dangerouslySetInnerHTML={{ __html: thread.title || 'New Thread' }}
/>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu <DropdownMenu
@ -263,18 +260,8 @@ type ThreadListProps = {
} }
function ThreadList({ threads }: ThreadListProps) { function ThreadList({ threads }: ThreadListProps) {
const { setThreads } = useThreads()
const sortedThreads = useMemo(() => { const sortedThreads = useMemo(() => {
return threads.sort((a, b) => { return threads.sort((a, b) => {
// If both have order, sort by order (ascending, so lower order comes first)
if (a.order != null && b.order != null) {
return a.order - b.order
}
// If only one has order, prioritize the one with order (order comes first)
if (a.order != null) return -1
if (b.order != null) return 1
// If neither has order, sort by updated time (newer threads first)
return (b.updated || 0) - (a.updated || 0) return (b.updated || 0) - (a.updated || 0)
}) })
}, [threads]) }, [threads])
@ -290,36 +277,7 @@ function ThreadList({ threads }: ThreadListProps) {
) )
return ( return (
<DndContext <DndContext sensors={sensors} collisionDetection={closestCenter}>
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
const { active, over } = event
if (active.id !== over?.id && over) {
// Access Global State
const allThreadsMap = useThreads.getState().threads
const allThreadsArray = Object.values(allThreadsMap)
// Calculate Global Indices
const oldIndexInGlobal = allThreadsArray.findIndex(
(t) => t.id === active.id
)
const newIndexInGlobal = allThreadsArray.findIndex(
(t) => t.id === over.id
)
// Reorder Globally and Update State
if (oldIndexInGlobal !== -1 && newIndexInGlobal !== -1) {
const reorderedGlobalThreads = arrayMove(
allThreadsArray,
oldIndexInGlobal,
newIndexInGlobal
)
setThreads(reorderedGlobalThreads)
}
}
}}
>
<SortableContext <SortableContext
items={sortedThreads.map((t) => t.id)} items={sortedThreads.map((t) => t.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}

View File

@ -1,4 +1,5 @@
import { useAppState } from '@/hooks/useAppState' import { useAppState } from '@/hooks/useAppState'
import { toNumber } from '@/utils/number'
import { Gauge } from 'lucide-react' import { Gauge } from 'lucide-react'
interface TokenSpeedIndicatorProps { interface TokenSpeedIndicatorProps {
@ -11,16 +12,27 @@ export const TokenSpeedIndicator = ({
streaming, streaming,
}: TokenSpeedIndicatorProps) => { }: TokenSpeedIndicatorProps) => {
const { tokenSpeed } = useAppState() const { tokenSpeed } = useAppState()
const persistedTokenSpeed = (metadata?.tokenSpeed as { tokenSpeed: number }) const persistedTokenSpeed =
?.tokenSpeed (metadata?.tokenSpeed as { tokenSpeed: number })?.tokenSpeed || 0
const nonStreamingAssistantParam =
typeof metadata?.assistant === 'object' &&
metadata?.assistant !== null &&
'parameters' in metadata.assistant
? (metadata.assistant as { parameters?: { stream?: boolean } }).parameters
?.stream === false
: undefined
if (nonStreamingAssistantParam) return
return ( return (
<div className="flex items-center gap-1 text-main-view-fg/60 text-xs"> <div className="flex items-center gap-1 text-main-view-fg/60 text-xs">
<Gauge size={16} /> <Gauge size={16} />
<span> <span>
{Math.round( {Math.round(
streaming ? Number(tokenSpeed?.tokenSpeed) : persistedTokenSpeed streaming
? toNumber(tokenSpeed?.tokenSpeed)
: toNumber(persistedTokenSpeed)
)} )}
&nbsp;tokens/sec &nbsp;tokens/sec
</span> </span>

View File

@ -180,10 +180,6 @@ export const useChat = () => {
if (updatedProvider) if (updatedProvider)
await restartModel(updatedProvider, model.id, controller) await restartModel(updatedProvider, model.id, controller)
console.log(
updatedProvider?.models.find((e) => e.id === model.id)?.settings
?.ctx_len?.controller_props.value
)
return updatedProvider return updatedProvider
}, },
[getProviderByName, restartModel, updateProvider] [getProviderByName, restartModel, updateProvider]

View File

@ -11,6 +11,7 @@ export interface DownloadProgressProps {
// Zustand store for thinking block state // Zustand store for thinking block state
export type DownloadState = { export type DownloadState = {
downloads: { [id: string]: DownloadProgressProps } downloads: { [id: string]: DownloadProgressProps }
localDownloadingModels: Set<string>
removeDownload: (id: string) => void removeDownload: (id: string) => void
updateProgress: ( updateProgress: (
id: string, id: string,
@ -19,6 +20,8 @@ export type DownloadState = {
current?: number, current?: number,
total?: number total?: number
) => void ) => void
addLocalDownloadingModel: (modelId: string) => void
removeLocalDownloadingModel: (modelId: string) => void
} }
/** /**
@ -26,6 +29,7 @@ export type DownloadState = {
*/ */
export const useDownloadStore = create<DownloadState>((set) => ({ export const useDownloadStore = create<DownloadState>((set) => ({
downloads: {}, downloads: {},
localDownloadingModels: new Set(),
removeDownload: (id: string) => removeDownload: (id: string) =>
set((state) => { set((state) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -46,4 +50,18 @@ export const useDownloadStore = create<DownloadState>((set) => ({
}, },
}, },
})), })),
addLocalDownloadingModel: (modelId: string) =>
set((state) => ({
localDownloadingModels: new Set(state.localDownloadingModels).add(
modelId
),
})),
removeLocalDownloadingModel: (modelId: string) =>
set((state) => {
const newSet = new Set(state.localDownloadingModels)
newSet.delete(modelId)
return { localDownloadingModels: newSet }
}),
})) }))

View File

@ -87,8 +87,17 @@ interface HardwareStore {
// Update RAM available // Update RAM available
updateRAMAvailable: (available: number) => void updateRAMAvailable: (available: number) => void
// Toggle GPU activation // Toggle GPU activation (async, with loading)
toggleGPUActivation: (index: number) => void toggleGPUActivation: (index: number) => Promise<void>
// GPU loading state
gpuLoading: { [index: number]: boolean }
setGpuLoading: (index: number, loading: boolean) => void
// Polling control
pollingPaused: boolean
pausePolling: () => void
resumePolling: () => void
// Reorder GPUs // Reorder GPUs
reorderGPUs: (oldIndex: number, newIndex: number) => void reorderGPUs: (oldIndex: number, newIndex: number) => void
@ -96,8 +105,16 @@ interface HardwareStore {
export const useHardware = create<HardwareStore>()( export const useHardware = create<HardwareStore>()(
persist( persist(
(set) => ({ (set, get) => ({
hardwareData: defaultHardwareData, hardwareData: defaultHardwareData,
gpuLoading: {},
pollingPaused: false,
setGpuLoading: (index, loading) =>
set((state) => ({
gpuLoading: { ...state.gpuLoading, [state.hardwareData.gpus[index].uuid]: loading },
})),
pausePolling: () => set({ pollingPaused: true }),
resumePolling: () => set({ pollingPaused: false }),
setCPU: (cpu) => setCPU: (cpu) =>
set((state) => ({ set((state) => ({
@ -172,25 +189,34 @@ export const useHardware = create<HardwareStore>()(
}, },
})), })),
toggleGPUActivation: (index) => { toggleGPUActivation: async (index) => {
set((state) => { const { pausePolling, setGpuLoading, resumePolling } = get();
const newGPUs = [...state.hardwareData.gpus] pausePolling();
if (index >= 0 && index < newGPUs.length) { setGpuLoading(index, true);
newGPUs[index] = { try {
...newGPUs[index], await new Promise((resolve) => setTimeout(resolve, 200)); // Simulate async, replace with real API if needed
activated: !newGPUs[index].activated, set((state) => {
const newGPUs = [...state.hardwareData.gpus];
if (index >= 0 && index < newGPUs.length) {
newGPUs[index] = {
...newGPUs[index],
activated: !newGPUs[index].activated,
};
} }
} setActiveGpus({
setActiveGpus({ gpus: newGPUs.filter((e) => e.activated).map((e) => parseInt(e.id)),
gpus: newGPUs.filter((e) => e.activated).map((e) => parseInt(e.id)), });
}) return {
return { hardwareData: {
hardwareData: { ...state.hardwareData,
...state.hardwareData, gpus: newGPUs,
gpus: newGPUs, },
}, };
} });
}) } finally {
setGpuLoading(index, false);
setTimeout(resumePolling, 1000); // Resume polling after 1s
}
}, },
reorderGPUs: (oldIndex, newIndex) => reorderGPUs: (oldIndex, newIndex) =>

View File

@ -34,7 +34,13 @@ export const useMessages = create<MessageState>()((set, get) => ({
created_at: message.created_at || Date.now(), created_at: message.created_at || Date.now(),
metadata: { metadata: {
...message.metadata, ...message.metadata,
assistant: currentAssistant, assistant: {
id: currentAssistant?.id || '',
name: currentAssistant?.name || '',
avatar: currentAssistant?.avatar || '',
instructions: currentAssistant?.instructions || '',
parameters: currentAssistant?.parameters || '',
},
}, },
} }
createMessage(newMessage).then((createdMessage) => { createMessage(newMessage).then((createdMessage) => {

View File

@ -2,7 +2,7 @@ import { create } from 'zustand'
import { ulid } from 'ulidx' import { ulid } from 'ulidx'
import { createThread, deleteThread, updateThread } from '@/services/threads' import { createThread, deleteThread, updateThread } from '@/services/threads'
import { Fzf } from 'fzf' import { Fzf } from 'fzf'
import { highlightFzfMatch } from '../utils/highlight'
type ThreadState = { type ThreadState = {
threads: Record<string, Thread> threads: Record<string, Thread>
currentThreadId?: string currentThreadId?: string
@ -32,13 +32,6 @@ export const useThreads = create<ThreadState>()((set, get) => ({
threads: {}, threads: {},
searchIndex: null, searchIndex: null,
setThreads: (threads) => { setThreads: (threads) => {
threads.forEach((thread, index) => {
thread.order = index + 1
updateThread({
...thread,
order: index + 1,
})
})
const threadMap = threads.reduce( const threadMap = threads.reduce(
(acc: Record<string, Thread>, thread) => { (acc: Record<string, Thread>, thread) => {
acc[thread.id] = thread acc[thread.id] = thread
@ -75,12 +68,10 @@ export const useThreads = create<ThreadState>()((set, get) => ({
return fzfResults.map( return fzfResults.map(
(result: { item: Thread; positions: Set<number> }) => { (result: { item: Thread; positions: Set<number> }) => {
const thread = result.item // Fzf stores the original item here const thread = result.item // Fzf stores the original item here
// Ensure result.positions is an array, default to empty if undefined
const positions = Array.from(result.positions) || []
const highlightedTitle = highlightFzfMatch(thread.title, positions)
return { return {
...thread, ...thread,
title: highlightedTitle, // Override title with highlighted version title: thread.title, // Override title with highlighted version
} }
} }
) )
@ -159,7 +150,6 @@ export const useThreads = create<ThreadState>()((set, get) => ({
id: ulid(), id: ulid(),
title: title ?? 'New Thread', title: title ?? 'New Thread',
model, model,
// order: 1, // Will be set properly by setThreads
updated: Date.now() / 1000, updated: Date.now() / 1000,
assistants: assistant ? [assistant] : [], assistants: assistant ? [assistant] : [],
} }
@ -244,44 +234,14 @@ export const useThreads = create<ThreadState>()((set, get) => ({
const thread = state.threads[threadId] const thread = state.threads[threadId]
if (!thread) return state if (!thread) return state
// If the thread is already at order 1, just update the timestamp
if (thread.order === 1) {
const updatedThread = {
...thread,
updated: Date.now() / 1000,
}
updateThread(updatedThread)
return {
threads: {
...state.threads,
[threadId]: updatedThread,
},
}
}
// Update the thread with new timestamp and set it to order 1 (top) // Update the thread with new timestamp and set it to order 1 (top)
const updatedThread = { const updatedThread = {
...thread, ...thread,
updated: Date.now() / 1000, updated: Date.now() / 1000,
order: 1,
} }
// Update all other threads to increment their order by 1 // Update all other threads to increment their order by 1
const updatedThreads = { ...state.threads } const updatedThreads = { ...state.threads }
Object.keys(updatedThreads).forEach((id) => {
if (id !== threadId) {
const otherThread = updatedThreads[id]
updatedThreads[id] = {
...otherThread,
order: (otherThread.order || 1) + 1,
}
// Update the backend for other threads
updateThread(updatedThreads[id])
}
})
// Set the updated thread
updatedThreads[threadId] = updatedThread updatedThreads[threadId] = updatedThread
// Update the backend for the main thread // Update the backend for the main thread

View File

@ -79,6 +79,15 @@
::-ms-reveal { ::-ms-reveal {
display: none; display: none;
} }
.reset-heading {
:is(h1, h2, h3, h4, h5, h6) {
font-weight: 600;
font-size: 14px !important;
margin-top: 0 !important;
margin-bottom: 0.5em;
}
}
} }
@layer utilities { @layer utilities {

View File

@ -2,4 +2,4 @@ import { isDev } from './utils'
export const isNightly = VERSION.includes('-') export const isNightly = VERSION.includes('-')
export const isBeta = VERSION.includes('beta') export const isBeta = VERSION.includes('beta')
export const isProd = !isNightly && !isBeta && !isDev export const isProd = !isNightly && !isBeta && !isDev()

View File

@ -182,7 +182,8 @@ function Hub() {
} }
} }
const { downloads } = useDownloadStore() const { downloads, localDownloadingModels, addLocalDownloadingModel } =
useDownloadStore()
const downloadProcesses = useMemo( const downloadProcesses = useMemo(
() => () =>
@ -225,7 +226,9 @@ function Hub() {
model.models.find((e) => model.models.find((e) =>
defaultModelQuantizations.some((m) => e.id.toLowerCase().includes(m)) defaultModelQuantizations.some((m) => e.id.toLowerCase().includes(m))
)?.id ?? model.models[0]?.id )?.id ?? model.models[0]?.id
const isDownloading = downloadProcesses.some((e) => e.id === modelId) const isDownloading =
localDownloadingModels.has(modelId) ||
downloadProcesses.some((e) => e.id === modelId)
const downloadProgress = const downloadProgress =
downloadProcesses.find((e) => e.id === modelId)?.progress || 0 downloadProcesses.find((e) => e.id === modelId)?.progress || 0
const isDownloaded = llamaProvider?.models.some( const isDownloaded = llamaProvider?.models.some(
@ -233,6 +236,12 @@ function Hub() {
) )
const isRecommended = isRecommendedModel(model.metadata?.id) const isRecommended = isRecommendedModel(model.metadata?.id)
const handleDownload = () => {
// Immediately set local downloading state
addLocalDownloadingModel(modelId)
downloadModel(modelId)
}
return ( return (
<div <div
className={cn( className={cn(
@ -255,7 +264,7 @@ function Hub() {
) : ( ) : (
<Button <Button
size="sm" size="sm"
onClick={() => downloadModel(modelId)} onClick={handleDownload}
className={cn(isDownloading && 'hidden')} className={cn(isDownloading && 'hidden')}
ref={isRecommended ? downloadButtonRef : undefined} ref={isRecommended ? downloadButtonRef : undefined}
> >
@ -271,6 +280,8 @@ function Hub() {
handleUseModel, handleUseModel,
isRecommendedModel, isRecommendedModel,
downloadButtonRef, downloadButtonRef,
localDownloadingModels,
addLocalDownloadingModel,
]) ])
const { step } = useSearch({ from: Route.id }) const { step } = useSearch({ from: Route.id })
@ -320,7 +331,8 @@ function Hub() {
} }
// Check if any model is currently downloading // Check if any model is currently downloading
const isDownloading = downloadProcesses.length > 0 const isDownloading =
localDownloadingModels.size > 0 || downloadProcesses.length > 0
const steps = [ const steps = [
{ {
@ -483,7 +495,7 @@ function Hub() {
<div className="line-clamp-2 mt-3 text-main-view-fg/60"> <div className="line-clamp-2 mt-3 text-main-view-fg/60">
<RenderMarkdown <RenderMarkdown
enableRawHtml={true} enableRawHtml={true}
className="select-none" className="select-none reset-heading"
components={{ components={{
a: ({ ...props }) => ( a: ({ ...props }) => (
<a <a
@ -553,6 +565,9 @@ function Hub() {
</p> </p>
{(() => { {(() => {
const isDownloading = const isDownloading =
localDownloadingModels.has(
variant.id
) ||
downloadProcesses.some( downloadProcesses.some(
(e) => e.id === variant.id (e) => e.id === variant.id
) )
@ -607,9 +622,12 @@ function Hub() {
<div <div
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out" className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
title="Download model" title="Download model"
onClick={() => onClick={() => {
addLocalDownloadingModel(
variant.id
)
downloadModel(variant.id) downloadModel(variant.id)
} }}
> >
<IconDownload <IconDownload
size={16} size={16}

View File

@ -45,6 +45,7 @@ import { isDev } from '@/lib/utils'
import { emit } from '@tauri-apps/api/event' import { emit } from '@tauri-apps/api/event'
import { stopAllModels } from '@/services/models' import { stopAllModels } from '@/services/models'
import { SystemEvent } from '@/types/events' import { SystemEvent } from '@/types/events'
import { isProd } from '@/lib/version'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.general as any)({ export const Route = createFileRoute(route.settings.general as any)({
@ -234,10 +235,12 @@ function General() {
</Button> </Button>
} }
/> />
<CardItem {!isProd && (
title={t('common.language')} <CardItem
actions={<LanguageSwitcher />} title={t('common.language')}
/> actions={<LanguageSwitcher />}
/>
)}
</Card> </Card>
{/* Data folder */} {/* Data folder */}

View File

@ -49,7 +49,7 @@ function SortableGPUItem({ gpu, index }: { gpu: GPU; index: number }) {
isDragging, isDragging,
} = useSortable({ id: gpu.id || index }) } = useSortable({ id: gpu.id || index })
const { toggleGPUActivation } = useHardware() const { toggleGPUActivation, gpuLoading } = useHardware()
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@ -78,6 +78,7 @@ function SortableGPUItem({ gpu, index }: { gpu: GPU; index: number }) {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Switch <Switch
checked={gpu.activated} checked={gpu.activated}
disabled={!!gpuLoading[index]}
onCheckedChange={() => toggleGPUActivation(index)} onCheckedChange={() => toggleGPUActivation(index)}
/> />
</div> </div>
@ -122,6 +123,7 @@ function Hardware() {
updateCPUUsage, updateCPUUsage,
updateRAMAvailable, updateRAMAvailable,
reorderGPUs, reorderGPUs,
pollingPaused,
} = useHardware() } = useHardware()
const { vulkanEnabled, setVulkanEnabled } = useVulkan() const { vulkanEnabled, setVulkanEnabled } = useVulkan()
@ -155,16 +157,16 @@ function Hardware() {
} }
useEffect(() => { useEffect(() => {
if (pollingPaused) return;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
getHardwareInfo().then((data) => { getHardwareInfo().then((data) => {
setHardwareData(data as unknown as HardwareData)
updateCPUUsage(data.cpu.usage) updateCPUUsage(data.cpu.usage)
updateRAMAvailable(data.ram.available) updateRAMAvailable(data.ram.available)
}) })
}, 5000) }, 5000)
return () => clearInterval(intervalId) return () => clearInterval(intervalId)
}, [setHardwareData, updateCPUUsage, updateRAMAvailable]) }, [setHardwareData, updateCPUUsage, updateRAMAvailable, pollingPaused])
const handleClickSystemMonitor = async () => { const handleClickSystemMonitor = async () => {
try { try {
@ -352,7 +354,7 @@ function Hardware() {
<Card title="Vulkan"> <Card title="Vulkan">
<CardItem <CardItem
title="Enable Vulkan" title="Enable Vulkan"
description="Use Vulkan API for GPU acceleration." description="Use Vulkan API for GPU acceleration. Do not enable Vulkan if you have an NVIDIA GPU as it may cause compatibility issues."
actions={ actions={
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Switch <Switch
@ -371,30 +373,34 @@ function Hardware() {
)} )}
{/* GPU Information */} {/* GPU Information */}
<Card title="GPUs"> {!IS_MACOS ? (
{hardwareData.gpus.length > 0 ? ( <Card title="GPUs">
<DndContext {hardwareData.gpus.length > 0 ? (
sensors={sensors} <DndContext
collisionDetection={closestCenter} sensors={sensors}
onDragEnd={handleDragEnd} collisionDetection={closestCenter}
> onDragEnd={handleDragEnd}
<SortableContext
items={hardwareData.gpus.map((gpu) => gpu.id)}
strategy={verticalListSortingStrategy}
> >
{hardwareData.gpus.map((gpu, index) => ( <SortableContext
<SortableGPUItem items={hardwareData.gpus.map((gpu) => gpu.id)}
key={gpu.id || index} strategy={verticalListSortingStrategy}
gpu={gpu} >
index={index} {hardwareData.gpus.map((gpu, index) => (
/> <SortableGPUItem
))} key={gpu.id || index}
</SortableContext> gpu={gpu}
</DndContext> index={index}
) : ( />
<CardItem title="No GPUs detected" actions={<></>} /> ))}
)} </SortableContext>
</Card> </DndContext>
) : (
<CardItem title="No GPUs detected" actions={<></>} />
)}
</Card>
) : (
<></>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,7 +17,8 @@ import { windowKey } from '@/constants/windows'
import { IconLogs } from '@tabler/icons-react' import { IconLogs } from '@tabler/icons-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ApiKeyInput } from '@/containers/ApiKeyInput' import { ApiKeyInput } from '@/containers/ApiKeyInput'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.local_api_server as any)({ export const Route = createFileRoute(route.settings.local_api_server as any)({
@ -44,6 +45,17 @@ function LocalAPIServer() {
!apiKey || apiKey.toString().trim().length === 0 !apiKey || apiKey.toString().trim().length === 0
) )
useEffect(() => {
const checkServerStatus = async () => {
invoke('get_server_status').then((running) => {
if (running) {
setServerStatus('running')
}
})
}
checkServerStatus()
}, [setServerStatus])
const handleApiKeyValidation = (isValid: boolean) => { const handleApiKeyValidation = (isValid: boolean) => {
setIsApiKeyEmpty(!isValid) setIsApiKeyEmpty(!isValid)
} }

View File

@ -39,6 +39,7 @@ import { toast } from 'sonner'
import { ActiveModel } from '@/types/models' import { ActiveModel } from '@/types/models'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { predefinedProviders } from '@/mock/data' import { predefinedProviders } from '@/mock/data'
import { isProd } from '@/lib/version'
// as route.threadsDetail // as route.threadsDetail
export const Route = createFileRoute('/settings/providers/$providerName')({ export const Route = createFileRoute('/settings/providers/$providerName')({
@ -454,15 +455,19 @@ function ProviderDetail() {
title={ title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="font-medium">{model.id}</h1> <h1 className="font-medium">{model.id}</h1>
<Capabilities capabilities={capabilities} /> {!isProd && (
<Capabilities capabilities={capabilities} />
)}
</div> </div>
} }
actions={ actions={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<DialogEditModel {!isProd && (
provider={provider} <DialogEditModel
modelId={model.id} provider={provider}
/> modelId={model.id}
/>
)}
{model.settings && ( {model.settings && (
<ModelSetting <ModelSetting
provider={provider} provider={provider}

View File

@ -0,0 +1,4 @@
export const toNumber = (value: unknown): number => {
const num = Number(value)
return isNaN(num) ? 0 : num
}