♻️ 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:
commit
a1cef450de
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
prefix,
|
||||||
|
auth_token,
|
||||||
|
api_key,
|
||||||
|
trusted_hosts,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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");
|
||||||
|
|||||||
@ -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,6 +262,7 @@ 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"];
|
||||||
@ -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,9 +388,20 @@ async fn proxy_request(
|
|||||||
&config.trusted_hosts,
|
&config.trusted_hosts,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Read response body
|
// Handle streaming vs non-streaming responses
|
||||||
|
if path.contains("/models") && method == hyper::Method::GET {
|
||||||
|
// For /models endpoint, we need to buffer and filter the response
|
||||||
match response.bytes().await {
|
match response.bytes().await {
|
||||||
Ok(bytes) => Ok(builder.body(Body::from(bytes)).unwrap()),
|
Ok(bytes) => match filter_models_response(&bytes) {
|
||||||
|
Ok(filtered_bytes) => Ok(builder.body(Body::from(filtered_bytes)).unwrap()),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to filter models response: {}, returning original",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Ok(builder.body(Body::from(bytes)).unwrap())
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to read response body: {}", e);
|
log::error!("Failed to read response body: {}", e);
|
||||||
let mut error_response =
|
let mut error_response =
|
||||||
@ -402,6 +417,31 @@ async fn proxy_request(
|
|||||||
.unwrap())
|
.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) => {
|
||||||
log::error!("Proxy request failed: {}", e);
|
log::error!("Proxy request failed: {}", 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 downloadCount = useMemo(() => {
|
const downloadProcesses = useMemo(() => {
|
||||||
const modelDownloads = Object.keys(downloads).length
|
// Get downloads with progress data
|
||||||
const appUpdateDownload = appUpdateState.isDownloading ? 1 : 0
|
const downloadsWithProgress = Object.values(downloads).map((download) => ({
|
||||||
const total = modelDownloads + appUpdateDownload
|
|
||||||
return total
|
|
||||||
}, [downloads, appUpdateState.isDownloading])
|
|
||||||
const downloadProcesses = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(downloads).map((download) => ({
|
|
||||||
id: download.name,
|
id: download.name,
|
||||||
name: download.name,
|
name: download.name,
|
||||||
progress: download.progress,
|
progress: download.progress,
|
||||||
current: download.current,
|
current: download.current,
|
||||||
total: download.total,
|
total: download.total,
|
||||||
})),
|
}))
|
||||||
[downloads]
|
|
||||||
)
|
// 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 modelDownloads = downloadProcesses.length
|
||||||
|
const appUpdateDownload = appUpdateState.isDownloading ? 1 : 0
|
||||||
|
const total = modelDownloads + appUpdateDownload
|
||||||
|
return total
|
||||||
|
}, [downloadProcesses, appUpdateState.isDownloading])
|
||||||
|
|
||||||
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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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, you’ll 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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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)
|
||||||
)}
|
)}
|
||||||
tokens/sec
|
tokens/sec
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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 }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
const { pausePolling, setGpuLoading, resumePolling } = get();
|
||||||
|
pausePolling();
|
||||||
|
setGpuLoading(index, true);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200)); // Simulate async, replace with real API if needed
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newGPUs = [...state.hardwareData.gpus]
|
const newGPUs = [...state.hardwareData.gpus];
|
||||||
if (index >= 0 && index < newGPUs.length) {
|
if (index >= 0 && index < newGPUs.length) {
|
||||||
newGPUs[index] = {
|
newGPUs[index] = {
|
||||||
...newGPUs[index],
|
...newGPUs[index],
|
||||||
activated: !newGPUs[index].activated,
|
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) =>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!isProd && (
|
||||||
<CardItem
|
<CardItem
|
||||||
title={t('common.language')}
|
title={t('common.language')}
|
||||||
actions={<LanguageSwitcher />}
|
actions={<LanguageSwitcher />}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Data folder */}
|
{/* Data folder */}
|
||||||
|
|||||||
@ -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,6 +373,7 @@ function Hardware() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GPU Information */}
|
{/* GPU Information */}
|
||||||
|
{!IS_MACOS ? (
|
||||||
<Card title="GPUs">
|
<Card title="GPUs">
|
||||||
{hardwareData.gpus.length > 0 ? (
|
{hardwareData.gpus.length > 0 ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
@ -395,6 +398,9 @@ function Hardware() {
|
|||||||
<CardItem title="No GPUs detected" actions={<></>} />
|
<CardItem title="No GPUs detected" actions={<></>} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
{!isProd && (
|
||||||
<Capabilities capabilities={capabilities} />
|
<Capabilities capabilities={capabilities} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{!isProd && (
|
||||||
<DialogEditModel
|
<DialogEditModel
|
||||||
provider={provider}
|
provider={provider}
|
||||||
modelId={model.id}
|
modelId={model.id}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{model.settings && (
|
{model.settings && (
|
||||||
<ModelSetting
|
<ModelSetting
|
||||||
provider={provider}
|
provider={provider}
|
||||||
|
|||||||
4
web-app/src/utils/number.ts
Normal file
4
web-app/src/utils/number.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const toNumber = (value: unknown): number => {
|
||||||
|
const num = Number(value)
|
||||||
|
return isNaN(num) ? 0 : num
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user