From 38b020236573113fa897fccca73e7b79e84efe67 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 15 Aug 2025 19:00:13 +0200 Subject: [PATCH 01/43] fix: resolve issue #6025 - default color selection in appearance page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix default color selection logic in all color picker components - Use existing helper functions (isDefaultColor, isDefaultColorPrimary, etc.) - Ensure default colors are properly highlighted when active - Apply fix to all color pickers: primary, accent, destructive, main view, and background 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- web-app/src/containers/ColorPickerAppAccentColor.tsx | 7 ++++--- web-app/src/containers/ColorPickerAppBgColor.tsx | 7 ++++--- web-app/src/containers/ColorPickerAppDestructiveColor.tsx | 7 ++++--- web-app/src/containers/ColorPickerAppMainView.tsx | 7 ++++--- web-app/src/containers/ColorPickerAppPrimaryColor.tsx | 7 ++++--- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/web-app/src/containers/ColorPickerAppAccentColor.tsx b/web-app/src/containers/ColorPickerAppAccentColor.tsx index 751543b7d..85178b7a0 100644 --- a/web-app/src/containers/ColorPickerAppAccentColor.tsx +++ b/web-app/src/containers/ColorPickerAppAccentColor.tsx @@ -1,4 +1,4 @@ -import { useAppearance } from '@/hooks/useAppearance' +import { useAppearance, isDefaultColorAccent } from '@/hooks/useAppearance' import { cn } from '@/lib/utils' import { RgbaColor, RgbaColorPicker } from 'react-colorful' import { IconColorPicker } from '@tabler/icons-react' @@ -37,10 +37,11 @@ export function ColorPickerAppAccentColor() {
{predefineAppAccentBgColor.map((item, i) => { const isSelected = - item.r === appAccentBgColor.r && + (item.r === appAccentBgColor.r && item.g === appAccentBgColor.g && item.b === appAccentBgColor.b && - item.a === appAccentBgColor.a + item.a === appAccentBgColor.a) || + (isDefaultColorAccent(appAccentBgColor) && isDefaultColorAccent(item)) return (
{predefineAppBgColor.map((item, i) => { const isSelected = - item.r === appBgColor.r && + (item.r === appBgColor.r && item.g === appBgColor.g && item.b === appBgColor.b && - item.a === appBgColor.a + item.a === appBgColor.a) || + (isDefaultColor(appBgColor) && isDefaultColor(item)) return (
{predefineAppDestructiveBgColor.map((item, i) => { const isSelected = - item.r === appDestructiveBgColor.r && + (item.r === appDestructiveBgColor.r && item.g === appDestructiveBgColor.g && item.b === appDestructiveBgColor.b && - item.a === appDestructiveBgColor.a + item.a === appDestructiveBgColor.a) || + (isDefaultColorDestructive(appDestructiveBgColor) && isDefaultColorDestructive(item)) return (
{predefineAppMainViewBgColor.map((item, i) => { const isSelected = - item.r === appMainViewBgColor.r && + (item.r === appMainViewBgColor.r && item.g === appMainViewBgColor.g && item.b === appMainViewBgColor.b && - item.a === appMainViewBgColor.a + item.a === appMainViewBgColor.a) || + (isDefaultColorMainView(appMainViewBgColor) && isDefaultColorMainView(item)) return (
{predefineappPrimaryBgColor.map((item, i) => { const isSelected = - item.r === appPrimaryBgColor.r && + (item.r === appPrimaryBgColor.r && item.g === appPrimaryBgColor.g && item.b === appPrimaryBgColor.b && - item.a === appPrimaryBgColor.a + item.a === appPrimaryBgColor.a) || + (isDefaultColorPrimary(appPrimaryBgColor) && isDefaultColorPrimary(item)) return (
Date: Mon, 11 Aug 2025 22:19:35 +0700 Subject: [PATCH 02/43] feat: enable attachment UI --- src-tauri/tauri.conf.json | 3 +- web-app/src/containers/ChatInput.tsx | 69 ++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c2e37e483..c5dcb9c1b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -35,7 +35,8 @@ "effects": ["fullScreenUI", "mica", "tabbed", "blur", "acrylic"], "state": "active", "radius": 8 - } + }, + "dragDropEnabled": false } ], "security": { diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 59cdaa3cd..dc3935ff6 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -70,6 +70,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { }> >([]) const [connectedServers, setConnectedServers] = useState([]) + const [isDragOver, setIsDragOver] = useState(false) // Check for connected MCP servers useEffect(() => { @@ -281,6 +282,54 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { } } + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + // Only set dragOver to false if we're leaving the drop zone entirely + // In Tauri, relatedTarget can be null, so we need to handle that case + const relatedTarget = e.relatedTarget as Node | null + if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) { + setIsDragOver(false) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + // Ensure drag state is maintained during drag over + setIsDragOver(true) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(false) + + // Check if dataTransfer exists (it might not in some Tauri scenarios) + if (!e.dataTransfer) { + console.warn('No dataTransfer available in drop event') + return + } + + const files = e.dataTransfer.files + if (files && files.length > 0) { + // Create a synthetic event to reuse existing file handling logic + const syntheticEvent = { + target: { + files: files, + }, + } as React.ChangeEvent + + handleFileChange(syntheticEvent) + } + } + return (
@@ -305,8 +354,13 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
{uploadedFiles.length > 0 && (
@@ -372,8 +426,14 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { }} onKeyDown={(e) => { // e.keyCode 229 is for IME input with Safari - const isComposing = e.nativeEvent.isComposing || e.keyCode === 229; - if (e.key === 'Enter' && !e.shiftKey && prompt.trim() && !isComposing) { + const isComposing = + e.nativeEvent.isComposing || e.keyCode === 229 + if ( + e.key === 'Enter' && + !e.shiftKey && + prompt.trim() && + !isComposing + ) { e.preventDefault() // Submit the message when Enter is pressed without Shift handleSendMesage(prompt) @@ -414,7 +474,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { )} {/* File attachment - always available */}
@@ -422,6 +482,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { type="file" ref={fileInputRef} className="hidden" + multiple onChange={handleFileChange} />
From e9bd0f0bec4180be6dcbd76cb1fc973cf1b7a278 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Mon, 11 Aug 2025 22:22:18 +0700 Subject: [PATCH 03/43] chore: update alignment --- web-app/src/containers/ChatInput.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index dc3935ff6..0cd9c523e 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -460,7 +460,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
@@ -474,7 +474,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { )} {/* File attachment - always available */}
@@ -645,6 +645,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
+ {message && (
From cef3e122ff284e25edb7d5b339bc7456113a4dbc Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 14 Aug 2025 13:39:38 +0700 Subject: [PATCH 04/43] chore: send attachment file when send message --- web-app/src/containers/ChatInput.tsx | 17 ++- web-app/src/containers/ThreadContent.tsx | 152 ++++++++++++++++++++--- web-app/src/hooks/useChat.ts | 16 ++- web-app/src/lib/completion.ts | 68 +++++++--- web-app/src/lib/messages.ts | 117 ++++++++++++++--- 5 files changed, 316 insertions(+), 54 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 0cd9c523e..e711804a8 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -100,11 +100,16 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { setMessage('Please select a model to start chatting.') return } - if (!prompt.trim()) { + if (!prompt.trim() && uploadedFiles.length === 0) { return } setMessage('') - sendMessage(prompt) + sendMessage( + prompt, + true, + uploadedFiles.length > 0 ? uploadedFiles : undefined + ) + setUploadedFiles([]) } useEffect(() => { @@ -629,9 +634,13 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { ) : ( From 9e3237da38a398c3ac651f539ef0e6de002a3b7b Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 21:55:57 +0700 Subject: [PATCH 17/43] fix: downloaded model hub screen should from variant level instead of the model level --- web-app/src/routes/hub/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web-app/src/routes/hub/index.tsx b/web-app/src/routes/hub/index.tsx index c58fa3169..ffacd2f04 100644 --- a/web-app/src/routes/hub/index.tsx +++ b/web-app/src/routes/hub/index.tsx @@ -146,13 +146,14 @@ function Hub() { } // Apply downloaded filter if (showOnlyDownloaded) { - filtered = filtered?.filter((model) => - model.quants.some((variant) => + filtered = filtered?.map((model) => ({ + ...model, + quants: model.quants.filter((variant) => llamaProvider?.models.some( (m: { id: string }) => m.id === variant.model_id ) ) - ) + })).filter((model) => model.quants.length > 0) } // Add HuggingFace repo at the beginning if available if (huggingFaceRepo) { From 6c612d8eba06c9f4fcc619a44e005d1b21020609 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 22:01:14 +0700 Subject: [PATCH 18/43] chore: seperate function handle import model --- .../settings/providers/$providerName.tsx | 122 +++++++++--------- 1 file changed, 64 insertions(+), 58 deletions(-) diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 21a88191a..787ef9162 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -96,6 +96,69 @@ function ProviderDetail() { !setting.controller_props.value) ) + const handleImportModel = async () => { + if (!provider) { + return + } + + setImportingModel(true) + const selectedFile = await open({ + multiple: false, + directory: false, + filters: [ + { + name: 'GGUF', + extensions: ['gguf'], + }, + ], + }) + // If the dialog returns a file path, extract just the file name + const fileName = + typeof selectedFile === 'string' + ? selectedFile + .split(/[\\/]/) + .pop() + ?.replace(/\s/g, '-') + : undefined + + if (selectedFile && fileName) { + // Check if model already exists + const modelExists = provider.models.some( + (model) => model.name === fileName + ) + + if (modelExists) { + toast.error('Model already exists', { + description: `${fileName} already imported`, + }) + setImportingModel(false) + return + } + + try { + await pullModel(fileName, selectedFile) + // Refresh the provider to update the models list + await getProviders().then(setProviders) + toast.success(t('providers:import'), { + id: `import-model-${provider.provider}`, + description: t( + 'providers:importModelSuccess', + { provider: fileName } + ), + }) + } catch (error) { + console.error( + t('providers:importModelError'), + error + ) + } finally { + setImportingModel(false) + } + } else { + setImportingModel(false) + } + } + useEffect(() => { // Initial data fetch getActiveModels().then((models) => setActiveModels(models || [])) @@ -484,64 +547,7 @@ function ProviderDetail() { size="sm" className="hover:no-underline" disabled={importingModel} - onClick={async () => { - setImportingModel(true) - const selectedFile = await open({ - multiple: false, - directory: false, - filters: [ - { - name: 'GGUF', - extensions: ['gguf'], - }, - ], - }) - // If the dialog returns a file path, extract just the file name - const fileName = - typeof selectedFile === 'string' - ? selectedFile - .split(/[\\/]/) - .pop() - ?.replace(/\s/g, '-') - : undefined - - if (selectedFile && fileName) { - // Check if model already exists - const modelExists = provider.models.some( - (model) => model.name === fileName - ) - - if (modelExists) { - toast.error('Model already exists', { - description: `${fileName} already imported`, - }) - setImportingModel(false) - return - } - - try { - await pullModel(fileName, selectedFile) - } catch (error) { - console.error( - t('providers:importModelError'), - error - ) - } finally { - // Refresh the provider to update the models list - await getProviders().then(setProviders) - toast.success(t('providers:import'), { - id: `import-model-${provider.provider}`, - description: t( - 'providers:importModelSuccess', - { provider: fileName } - ), - }) - setImportingModel(false) - } - } else { - setImportingModel(false) - } - }} + onClick={handleImportModel} >
{importingModel ? ( From a210e2f13a0df08ff9e780045f1332b99771bced Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 19 Aug 2025 22:06:26 +0700 Subject: [PATCH 19/43] fix: timeout for completion request --- extensions/llamacpp-extension/package.json | 1 + extensions/llamacpp-extension/src/index.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/llamacpp-extension/package.json b/extensions/llamacpp-extension/package.json index b5db33c5e..585365130 100644 --- a/extensions/llamacpp-extension/package.json +++ b/extensions/llamacpp-extension/package.json @@ -31,6 +31,7 @@ "@janhq/tauri-plugin-hardware-api": "link:../../src-tauri/plugins/tauri-plugin-hardware", "@janhq/tauri-plugin-llamacpp-api": "link:../../src-tauri/plugins/tauri-plugin-llamacpp", "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-http": "^2.5.1", "@tauri-apps/plugin-log": "^2.6.0", "fetch-retry": "^5.0.6", "ulidx": "^2.3.0" diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index b45dfb00e..e2569485c 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -23,6 +23,7 @@ import { } from '@janhq/core' import { error, info, warn } from '@tauri-apps/plugin-log' +import { fetch } from "@tauri-apps/plugin-http" import { listSupportedBackends, @@ -1389,7 +1390,8 @@ export default class llamacpp_extension extends AIEngine { method: 'POST', headers, body, - signal: AbortSignal.any([AbortSignal.timeout(120000), abortController?.signal]), + connectTimeout: 600000, // 10 minutes + signal: AbortSignal.any([AbortSignal.timeout(600000), abortController?.signal]), }) if (!response.ok) { const errorData = await response.json().catch(() => null) From b828d3f84fdb8d79d4f9494272987d8bc9486686 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 22:07:30 +0700 Subject: [PATCH 20/43] chore: handle toaster failed import model --- src-tauri/Cargo.lock | 55 +++++++++++++++---- .../settings/providers/$providerName.tsx | 3 + 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fc6bfd301..32638bc56 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -854,8 +854,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08440b3dd222c3d0433e63e097463969485f112baff337dfdaca043a0d760570" +dependencies = [ + "darling_core 0.21.2", + "darling_macro 0.21.2", ] [[package]] @@ -872,13 +882,38 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "darling_core" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25b7912bc28a04ab1b7715a68ea03aaa15662b43a1a4b2c480531fd19f8bf7e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.104", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "darling_macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce154b9bea7fb0c8e8326e62d00354000c36e79770ff21b8c84e3aa267d9d531" +dependencies = [ + "darling_core 0.21.2", "quote", "syn 2.0.104", ] @@ -3984,8 +4019,8 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.2.1" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3196c95f1dfafbffbdcdd6d365c94969ac975e6a#3196c95f1dfafbffbdcdd6d365c94969ac975e6a" +version = "0.5.0" +source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=209dbac50f51737ad953c3a2c8e28f3619b6c277#209dbac50f51737ad953c3a2c8e28f3619b6c277" dependencies = [ "base64 0.22.1", "chrono", @@ -4010,10 +4045,10 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.2.1" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=3196c95f1dfafbffbdcdd6d365c94969ac975e6a#3196c95f1dfafbffbdcdd6d365c94969ac975e6a" +version = "0.5.0" +source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=209dbac50f51737ad953c3a2c8e28f3619b6c277#209dbac50f51737ad953c3a2c8e28f3619b6c277" dependencies = [ - "darling", + "darling 0.21.2", "proc-macro2", "quote", "serde_json", @@ -4408,7 +4443,7 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.104", @@ -6868,7 +6903,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.104", diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 787ef9162..fa57ca497 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -151,6 +151,9 @@ function ProviderDetail() { t('providers:importModelError'), error ) + toast.error(t('providers:importModelError'), { + description: error instanceof Error ? error.message : 'Unknown error occurred', + }) } finally { setImportingModel(false) } From df41bad465f31342451d7b6edb8ea82ba5f59df8 Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 19 Aug 2025 22:16:24 +0700 Subject: [PATCH 21/43] fix: import --- extensions/llamacpp-extension/rolldown.config.mjs | 3 +++ extensions/llamacpp-extension/src/index.ts | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/llamacpp-extension/rolldown.config.mjs b/extensions/llamacpp-extension/rolldown.config.mjs index 86b6798d7..64f92f29a 100644 --- a/extensions/llamacpp-extension/rolldown.config.mjs +++ b/extensions/llamacpp-extension/rolldown.config.mjs @@ -17,4 +17,7 @@ export default defineConfig({ IS_MAC: JSON.stringify(process.platform === 'darwin'), IS_LINUX: JSON.stringify(process.platform === 'linux'), }, + inject: { + fetch: ['@tauri-apps/plugin-http', 'fetch'], + }, }) diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index e2569485c..c908a53dd 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -23,7 +23,6 @@ import { } from '@janhq/core' import { error, info, warn } from '@tauri-apps/plugin-log' -import { fetch } from "@tauri-apps/plugin-http" import { listSupportedBackends, From e3eb8e909bd53e81c18b0677852b2ff76c350a46 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 22:29:56 +0700 Subject: [PATCH 22/43] chore: attachment icon conditional --- web-app/src/containers/ChatInput.tsx | 30 +++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index a0ac7db5f..4943b71fb 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -488,20 +488,22 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { useLastUsedModel={initialMessage} /> )} - {/* File attachment - always available */} -
- - -
+ {/* File attachment - show only for models with mmproj */} + {selectedModel?.settings?.offload_mmproj && ( +
+ + +
+ )} {/* Microphone - always available - Temp Hide */} {/*
From 80dc491f9d571dd51039b21887c7c527e5dc957e Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 22:36:20 +0700 Subject: [PATCH 23/43] chore: conditianal attachment and drag file to chat input --- web-app/src/containers/ChatInput.tsx | 54 +++++++++++++++---- .../src/containers/DropdownModelProvider.tsx | 8 +-- web-app/src/services/models.ts | 24 ++++++++- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 4943b71fb..3b8126e0b 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -34,6 +34,7 @@ import DropdownModelProvider from '@/containers/DropdownModelProvider' import { ModelLoader } from '@/containers/loaders/ModelLoader' import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable' import { getConnectedServers } from '@/services/mcp' +import { checkMmprojExists } from '@/services/models' type ChatInputProps = { className?: string @@ -71,6 +72,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { >([]) const [connectedServers, setConnectedServers] = useState([]) const [isDragOver, setIsDragOver] = useState(false) + const [hasMmproj, setHasMmproj] = useState(false) // Check for connected MCP servers useEffect(() => { @@ -92,6 +94,25 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { return () => clearInterval(intervalId) }, []) + // Check for mmproj existence when model changes + useEffect(() => { + const checkMmprojSupport = async () => { + if (selectedModel?.id) { + try { + const exists = await checkMmprojExists(selectedModel.id) + setHasMmproj(exists) + } catch (error) { + console.error('Error checking mmproj:', error) + setHasMmproj(false) + } + } else { + setHasMmproj(false) + } + } + + checkMmprojSupport() + }, [selectedModel?.id]) + // Check if there are active MCP servers const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 @@ -283,7 +304,10 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setIsDragOver(true) + // Only allow drag if model supports mmproj + if (hasMmproj) { + setIsDragOver(true) + } } const handleDragLeave = (e: React.DragEvent) => { @@ -301,7 +325,9 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { e.preventDefault() e.stopPropagation() // Ensure drag state is maintained during drag over - setIsDragOver(true) + if (hasMmproj) { + setIsDragOver(true) + } } const handleDrop = (e: React.DragEvent) => { @@ -309,6 +335,11 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { e.stopPropagation() setIsDragOver(false) + // Only allow drop if model supports mmproj + if (!hasMmproj) { + return + } + // Check if dataTransfer exists (it might not in some Tauri scenarios) if (!e.dataTransfer) { console.warn('No dataTransfer available in drop event') @@ -332,6 +363,11 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const clipboardItems = e.clipboardData?.items if (!clipboardItems) return + // Only allow paste if model supports mmproj + if (!hasMmproj) { + return + } + const imageItems = Array.from(clipboardItems).filter((item) => item.type.startsWith('image/') ) @@ -390,11 +426,11 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { isFocused && 'ring-1 ring-main-view-fg/10', isDragOver && 'ring-2 ring-accent border-accent' )} - data-drop-zone="true" - onDragEnter={handleDragEnter} - onDragLeave={handleDragLeave} - onDragOver={handleDragOver} - onDrop={handleDrop} + data-drop-zone={hasMmproj ? "true" : undefined} + onDragEnter={hasMmproj ? handleDragEnter : undefined} + onDragLeave={hasMmproj ? handleDragLeave : undefined} + onDragOver={hasMmproj ? handleDragOver : undefined} + onDrop={hasMmproj ? handleDrop : undefined} > {uploadedFiles.length > 0 && (
@@ -455,7 +491,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { // When Shift+Enter is pressed, a new line is added (default behavior) } }} - onPaste={handlePaste} + onPaste={hasMmproj ? handlePaste : undefined} placeholder={t('common:placeholder.chatInput')} autoFocus spellCheck={spellCheckChatInput} @@ -489,7 +525,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { /> )} {/* File attachment - show only for models with mmproj */} - {selectedModel?.settings?.offload_mmproj && ( + {hasMmproj && (
=> { * @param getProviderByName - Function to get provider by name * @returns Promise<{exists: boolean, settingsUpdated: boolean}> - exists: true if mmproj.gguf exists, settingsUpdated: true if settings were modified */ -export const checkMmprojExists = async ( +export const checkMmprojExistsAndUpdateOffloadMMprojSetting = async ( modelId: string, updateProvider?: (providerName: string, data: Partial) => void, getProviderByName?: (providerName: string) => ModelProvider | undefined @@ -465,3 +465,25 @@ export const checkMmprojExists = async ( } return { exists: false, settingsUpdated } } + +/** + * Checks if mmproj.gguf file exists for a given model ID in the llamacpp provider. + * If mmproj.gguf exists, adds offload_mmproj setting with value true. + * @param modelId - The model ID to check for mmproj.gguf + * @param updateProvider - Function to update the provider state + * @param getProviderByName - Function to get provider by name + * @returns Promise<{exists: boolean, settingsUpdated: boolean}> - exists: true if mmproj.gguf exists, settingsUpdated: true if settings were modified + */ +export const checkMmprojExists = async (modelId: string): Promise => { + try { + const engine = getEngine('llamacpp') as AIEngine & { + checkMmprojExists?: (id: string) => Promise + } + if (engine && typeof engine.checkMmprojExists === 'function') { + return await engine.checkMmprojExists(modelId) + } + } catch (error) { + console.error(`Error checking mmproj for model ${modelId}:`, error) + } + return false +} From 07b1101736179cc6fa922ffe008f7a5286fdafd0 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 22:42:33 +0700 Subject: [PATCH 24/43] chore: enable attachment icon for remote provider --- web-app/src/containers/ChatInput.tsx | 18 +++++++++++------- web-app/src/services/models.ts | 2 -- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 3b8126e0b..51023e792 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -94,24 +94,28 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { return () => clearInterval(intervalId) }, []) - // Check for mmproj existence when model changes + // Check for mmproj existence or vision capability when model changes useEffect(() => { const checkMmprojSupport = async () => { if (selectedModel?.id) { try { - const exists = await checkMmprojExists(selectedModel.id) - setHasMmproj(exists) + // Only check mmproj for llamacpp provider + if (model?.provider === 'llamacpp') { + const hasLocalMmproj = await checkMmprojExists(selectedModel.id) + setHasMmproj(hasLocalMmproj) + } else { + // For non-llamacpp providers, only check vision capability + setHasMmproj(true) + } } catch (error) { console.error('Error checking mmproj:', error) setHasMmproj(false) } - } else { - setHasMmproj(false) } } checkMmprojSupport() - }, [selectedModel?.id]) + }, [selectedModel?.id, selectedModel?.capabilities, model?.provider]) // Check if there are active MCP servers const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 @@ -426,7 +430,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { isFocused && 'ring-1 ring-main-view-fg/10', isDragOver && 'ring-2 ring-accent border-accent' )} - data-drop-zone={hasMmproj ? "true" : undefined} + data-drop-zone={hasMmproj ? 'true' : undefined} onDragEnter={hasMmproj ? handleDragEnter : undefined} onDragLeave={hasMmproj ? handleDragLeave : undefined} onDragOver={hasMmproj ? handleDragOver : undefined} diff --git a/web-app/src/services/models.ts b/web-app/src/services/models.ts index 729f10e3e..790620f22 100644 --- a/web-app/src/services/models.ts +++ b/web-app/src/services/models.ts @@ -470,8 +470,6 @@ export const checkMmprojExistsAndUpdateOffloadMMprojSetting = async ( * Checks if mmproj.gguf file exists for a given model ID in the llamacpp provider. * If mmproj.gguf exists, adds offload_mmproj setting with value true. * @param modelId - The model ID to check for mmproj.gguf - * @param updateProvider - Function to update the provider state - * @param getProviderByName - Function to get provider by name * @returns Promise<{exists: boolean, settingsUpdated: boolean}> - exists: true if mmproj.gguf exists, settingsUpdated: true if settings were modified */ export const checkMmprojExists = async (modelId: string): Promise => { From 5155f19c9b0d04f42d75677708a64fab0ce80251 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 19 Aug 2025 22:56:16 +0700 Subject: [PATCH 25/43] chore: update data test id chat input --- web-app/src/containers/ChatInput.tsx | 2 +- web-app/src/containers/__tests__/ChatInput.test.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 51023e792..8f788446d 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -472,7 +472,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { rows={1} maxRows={10} value={prompt} - data-test-id={'chat-input'} + data-testid={'chat-input'} onChange={(e) => { setPrompt(e.target.value) // Count the number of newlines to estimate rows diff --git a/web-app/src/containers/__tests__/ChatInput.test.tsx b/web-app/src/containers/__tests__/ChatInput.test.tsx index 2ed26fc56..4b7e34d26 100644 --- a/web-app/src/containers/__tests__/ChatInput.test.tsx +++ b/web-app/src/containers/__tests__/ChatInput.test.tsx @@ -75,6 +75,10 @@ vi.mock('@/services/models', () => ({ stopAllModels: vi.fn(), })) +vi.mock('../MovingBorder', () => ({ + MovingBorder: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + describe('ChatInput', () => { const mockSendMessage = vi.fn() const mockSetPrompt = vi.fn() @@ -361,7 +365,7 @@ describe('ChatInput', () => { renderWithRouter() }) - const textarea = screen.getByRole('textbox') + const textarea = screen.getByTestId('chat-input') expect(textarea).toBeDisabled() }) From 91f05b8f321c8799f5ee6f5609f4373654f2737a Mon Sep 17 00:00:00 2001 From: Louis Date: Tue, 19 Aug 2025 23:27:12 +0700 Subject: [PATCH 26/43] feat: add tool call cancellation --- src-tauri/src/core/mcp/commands.rs | 82 +++++++++++++++++++++++++--- src-tauri/src/core/state.rs | 3 +- src-tauri/src/lib.rs | 2 + web-app/src/containers/ChatInput.tsx | 12 +++- web-app/src/hooks/useAppState.ts | 8 +++ web-app/src/lib/completion.ts | 19 ++++--- web-app/src/lib/service.ts | 1 + web-app/src/services/mcp.ts | 41 ++++++++++++++ 8 files changed, 148 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/core/mcp/commands.rs b/src-tauri/src/core/mcp/commands.rs index 02caca827..48c7f88a1 100644 --- a/src-tauri/src/core/mcp/commands.rs +++ b/src-tauri/src/core/mcp/commands.rs @@ -2,6 +2,7 @@ use rmcp::model::{CallToolRequestParam, CallToolResult}; use serde_json::{Map, Value}; use tauri::{AppHandle, Emitter, Runtime, State}; use tokio::time::timeout; +use tokio::sync::oneshot; use super::{ constants::{DEFAULT_MCP_CONFIG, MCP_TOOL_CALL_TIMEOUT}, @@ -179,6 +180,7 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result /// * `state` - Application state containing MCP server connections /// * `tool_name` - Name of the tool to call /// * `arguments` - Optional map of argument names to values +/// * `cancellation_token` - Optional token to allow cancellation from JS side /// /// # Returns /// * `Result` - Result of the tool call if successful, or error message if failed @@ -187,13 +189,23 @@ pub async fn get_tools(state: State<'_, AppState>) -> Result /// 1. Locks the MCP servers mutex to access server connections /// 2. Searches through all servers for one containing the named tool /// 3. When found, calls the tool on that server with the provided arguments -/// 4. Returns error if no server has the requested tool +/// 4. Supports cancellation via cancellation_token +/// 5. Returns error if no server has the requested tool #[tauri::command] pub async fn call_tool( state: State<'_, AppState>, tool_name: String, arguments: Option>, + cancellation_token: Option, ) -> Result { + // Set up cancellation if token is provided + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + + if let Some(token) = &cancellation_token { + let mut cancellations = state.tool_call_cancellations.lock().await; + cancellations.insert(token.clone(), cancel_tx); + } + let servers = state.mcp_servers.lock().await; // Iterate through servers and find the first one that contains the tool @@ -209,25 +221,77 @@ pub async fn call_tool( println!("Found tool {} in server", tool_name); - // Call the tool with timeout + // Call the tool with timeout and cancellation support let tool_call = service.call_tool(CallToolRequestParam { name: tool_name.clone().into(), arguments, }); - return match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await { - Ok(result) => result.map_err(|e| e.to_string()), - Err(_) => Err(format!( - "Tool call '{}' timed out after {} seconds", - tool_name, - MCP_TOOL_CALL_TIMEOUT.as_secs() - )), + // Race between timeout, tool call, and cancellation + let result = if cancellation_token.is_some() { + tokio::select! { + result = timeout(MCP_TOOL_CALL_TIMEOUT, tool_call) => { + match result { + Ok(call_result) => call_result.map_err(|e| e.to_string()), + Err(_) => Err(format!( + "Tool call '{}' timed out after {} seconds", + tool_name, + MCP_TOOL_CALL_TIMEOUT.as_secs() + )), + } + } + _ = cancel_rx => { + Err(format!("Tool call '{}' was cancelled", tool_name)) + } + } + } else { + match timeout(MCP_TOOL_CALL_TIMEOUT, tool_call).await { + Ok(call_result) => call_result.map_err(|e| e.to_string()), + Err(_) => Err(format!( + "Tool call '{}' timed out after {} seconds", + tool_name, + MCP_TOOL_CALL_TIMEOUT.as_secs() + )), + } }; + + // Clean up cancellation token + if let Some(token) = &cancellation_token { + let mut cancellations = state.tool_call_cancellations.lock().await; + cancellations.remove(token); + } + + return result; } Err(format!("Tool {} not found", tool_name)) } +/// Cancels a running tool call by its cancellation token +/// +/// # Arguments +/// * `state` - Application state containing cancellation tokens +/// * `cancellation_token` - Token identifying the tool call to cancel +/// +/// # Returns +/// * `Result<(), String>` - Success if token found and cancelled, error otherwise +#[tauri::command] +pub async fn cancel_tool_call( + state: State<'_, AppState>, + cancellation_token: String, +) -> Result<(), String> { + let mut cancellations = state.tool_call_cancellations.lock().await; + + if let Some(cancel_tx) = cancellations.remove(&cancellation_token) { + // Send cancellation signal - ignore if receiver is already dropped + let _ = cancel_tx.send(()); + println!("Tool call with token {} cancelled", cancellation_token); + Ok(()) + } else { + Err(format!("Cancellation token {} not found", cancellation_token)) + } +} + #[tauri::command] pub async fn get_mcp_configs(app: AppHandle) -> Result { let mut path = get_jan_data_folder_path(app); diff --git a/src-tauri/src/core/state.rs b/src-tauri/src/core/state.rs index 3408052d4..ddbbcf7bd 100644 --- a/src-tauri/src/core/state.rs +++ b/src-tauri/src/core/state.rs @@ -6,7 +6,7 @@ use rmcp::{ service::RunningService, RoleClient, ServiceError, }; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, oneshot}; use tokio::task::JoinHandle; /// Server handle type for managing the proxy server lifecycle @@ -27,6 +27,7 @@ pub struct AppState { pub mcp_active_servers: Arc>>, pub mcp_successfully_connected: Arc>>, pub server_handle: Arc>>, + pub tool_call_cancellations: Arc>>>, } impl RunningServiceEnum { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63d60a571..10a9d7556 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,6 +74,7 @@ pub fn run() { // MCP commands core::mcp::commands::get_tools, core::mcp::commands::call_tool, + core::mcp::commands::cancel_tool_call, core::mcp::commands::restart_mcp_servers, core::mcp::commands::get_connected_servers, core::mcp::commands::save_mcp_configs, @@ -105,6 +106,7 @@ pub fn run() { mcp_active_servers: Arc::new(Mutex::new(HashMap::new())), mcp_successfully_connected: Arc::new(Mutex::new(HashMap::new())), server_handle: Arc::new(Mutex::new(None)), + tool_call_cancellations: Arc::new(Mutex::new(HashMap::new())), }) .setup(|app| { app.handle().plugin( diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 59cdaa3cd..c6360253e 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -46,8 +46,13 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const textareaRef = useRef(null) const [isFocused, setIsFocused] = useState(false) const [rows, setRows] = useState(1) - const { streamingContent, abortControllers, loadingModel, tools } = - useAppState() + const { + streamingContent, + abortControllers, + loadingModel, + tools, + cancelToolCall, + } = useAppState() const { prompt, setPrompt } = usePrompt() const { currentThreadId } = useThreads() const { t } = useTranslation() @@ -161,8 +166,9 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const stopStreaming = useCallback( (threadId: string) => { abortControllers[threadId]?.abort() + cancelToolCall?.() }, - [abortControllers] + [abortControllers, cancelToolCall] ) const fileInputRef = useRef(null) diff --git a/web-app/src/hooks/useAppState.ts b/web-app/src/hooks/useAppState.ts index 5876daefb..7b3841f5c 100644 --- a/web-app/src/hooks/useAppState.ts +++ b/web-app/src/hooks/useAppState.ts @@ -13,6 +13,7 @@ type AppState = { tokenSpeed?: TokenSpeed currentToolCall?: ChatCompletionMessageToolCall showOutOfContextDialog?: boolean + cancelToolCall?: () => void setServerStatus: (value: 'running' | 'stopped' | 'pending') => void updateStreamingContent: (content: ThreadMessage | undefined) => void updateCurrentToolCall: ( @@ -24,6 +25,7 @@ type AppState = { updateTokenSpeed: (message: ThreadMessage, increment?: number) => void resetTokenSpeed: () => void setOutOfContextDialog: (show: boolean) => void + setCancelToolCall: (cancel: (() => void) | undefined) => void } export const useAppState = create()((set) => ({ @@ -34,6 +36,7 @@ export const useAppState = create()((set) => ({ abortControllers: {}, tokenSpeed: undefined, currentToolCall: undefined, + cancelToolCall: undefined, updateStreamingContent: (content: ThreadMessage | undefined) => { const assistants = useAssistant.getState().assistants const currentAssistant = useAssistant.getState().currentAssistant @@ -112,4 +115,9 @@ export const useAppState = create()((set) => ({ showOutOfContextDialog: show, })) }, + setCancelToolCall: (cancel) => { + set(() => ({ + cancelToolCall: cancel, + })) + }, })) diff --git a/web-app/src/lib/completion.ts b/web-app/src/lib/completion.ts index 22ac724e9..c92a0b096 100644 --- a/web-app/src/lib/completion.ts +++ b/web-app/src/lib/completion.ts @@ -31,8 +31,9 @@ import { ulid } from 'ulidx' import { MCPTool } from '@/types/completion' import { CompletionMessagesBuilder } from './messages' import { ChatCompletionMessageToolCall } from 'openai/resources' -import { callTool } from '@/services/mcp' +import { callToolWithCancellation } from '@/services/mcp' import { ExtensionManager } from './extension' +import { useAppState } from '@/hooks/useAppState' export type ChatCompletionResponse = | chatCompletion @@ -381,13 +382,17 @@ export const postMessageProcessing = async ( ) : true) + const { promise, cancel } = callToolWithCancellation({ + toolName: toolCall.function.name, + arguments: toolCall.function.arguments.length + ? JSON.parse(toolCall.function.arguments) + : {}, + }) + + useAppState.getState().setCancelToolCall(cancel) + let result = approved - ? await callTool({ - toolName: toolCall.function.name, - arguments: toolCall.function.arguments.length - ? JSON.parse(toolCall.function.arguments) - : {}, - }).catch((e) => { + ? await promise.catch((e) => { console.error('Tool call failed:', e) return { content: [ diff --git a/web-app/src/lib/service.ts b/web-app/src/lib/service.ts index 0898cc4dc..809090b9d 100644 --- a/web-app/src/lib/service.ts +++ b/web-app/src/lib/service.ts @@ -5,6 +5,7 @@ export const AppRoutes = [ 'installExtensions', 'getTools', 'callTool', + 'cancelToolCall', 'listThreads', 'createThread', 'modifyThread', diff --git a/web-app/src/services/mcp.ts b/web-app/src/services/mcp.ts index 8159a5048..c266c6a13 100644 --- a/web-app/src/services/mcp.ts +++ b/web-app/src/services/mcp.ts @@ -56,3 +56,44 @@ export const callTool = (args: { }): Promise<{ error: string; content: { text: string }[] }> => { return window.core?.api?.callTool(args) } + +/** + * @description Enhanced function to invoke an MCP tool with cancellation support + * @param args - Tool call arguments + * @param cancellationToken - Optional cancellation token + * @returns Promise with tool result and cancellation function + */ +export const callToolWithCancellation = (args: { + toolName: string + arguments: object + cancellationToken?: string +}): { + promise: Promise<{ error: string; content: { text: string }[] }> + cancel: () => Promise + token: string +} => { + // Generate a unique cancellation token if not provided + const token = args.cancellationToken ?? `tool_call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + // Create the tool call promise with cancellation token + const promise = window.core?.api?.callTool({ + ...args, + cancellationToken: token + }) + + // Create cancel function + const cancel = async () => { + await window.core?.api?.cancelToolCall({ cancellationToken: token }) + } + + return { promise, cancel, token } +} + +/** + * @description This function cancels a running tool call + * @param cancellationToken - The token identifying the tool call to cancel + * @returns + */ +export const cancelToolCall = (cancellationToken: string): Promise => { + return window.core?.api?.cancelToolCall({ cancellationToken }) +} From 87af59b65dc3f34fb2e95d52973565c67a26df50 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 20 Aug 2025 09:46:57 +0700 Subject: [PATCH 27/43] chore: update icon image instead paperclip --- web-app/src/containers/ChatInput.tsx | 10 +++--- .../containers/__tests__/ChatInput.test.tsx | 34 +++++++++++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 8f788446d..8b2c0b05e 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/tooltip' import { ArrowRight } from 'lucide-react' import { - IconPaperclip, + IconPhoto, IconWorld, IconAtom, IconEye, @@ -56,7 +56,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { const maxRows = 10 - const { selectedModel } = useModelProvider() + const { selectedModel, selectedProvider } = useModelProvider() const { sendMessage } = useChat() const [message, setMessage] = useState('') const [dropdownToolsAvailable, setDropdownToolsAvailable] = useState(false) @@ -100,7 +100,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { if (selectedModel?.id) { try { // Only check mmproj for llamacpp provider - if (model?.provider === 'llamacpp') { + if (selectedProvider === 'llamacpp') { const hasLocalMmproj = await checkMmprojExists(selectedModel.id) setHasMmproj(hasLocalMmproj) } else { @@ -115,7 +115,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { } checkMmprojSupport() - }, [selectedModel?.id, selectedModel?.capabilities, model?.provider]) + }, [selectedModel?.id, selectedProvider]) // Check if there are active MCP servers const hasActiveMCPServers = connectedServers.length > 0 || tools.length > 0 @@ -534,7 +534,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => { className="h-6 p-1 ml-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1" onClick={handleAttachmentClick} > - + ({ vi.mock('@/services/models', () => ({ stopAllModels: vi.fn(), + checkMmprojExists: vi.fn(() => Promise.resolve(true)), })) vi.mock('../MovingBorder', () => ({ @@ -347,9 +348,12 @@ describe('ChatInput', () => { const user = userEvent.setup() renderWithRouter() - // File upload is rendered as hidden input element - const fileInput = document.querySelector('input[type="file"]') - expect(fileInput).toBeInTheDocument() + // Wait for async effects to complete (mmproj check) + await waitFor(() => { + // File upload is rendered as hidden input element + const fileInput = document.querySelector('input[type="file"]') + expect(fileInput).toBeInTheDocument() + }) }) it('disables input when streaming', () => { @@ -382,4 +386,28 @@ describe('ChatInput', () => { expect(toolsIcon).toBeInTheDocument() }) }) + + it('uses selectedProvider for provider checks', () => { + // Test that the component correctly uses selectedProvider instead of selectedModel.provider + vi.mocked(useModelProvider).mockReturnValue({ + selectedModel: { + id: 'test-model', + capabilities: ['vision'], + }, + providers: [], + getModelBy: vi.fn(), + selectModelProvider: vi.fn(), + selectedProvider: 'llamacpp', + setProviders: vi.fn(), + getProviderByName: vi.fn(), + updateProvider: vi.fn(), + addProvider: vi.fn(), + deleteProvider: vi.fn(), + deleteModel: vi.fn(), + deletedModels: [], + }) + + // This test ensures the component renders without errors when using selectedProvider + expect(() => renderWithRouter()).not.toThrow() + }) }) \ No newline at end of file From b0eec07a01d6af4115c75a0a7f17e432c49a5cfc Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Wed, 20 Aug 2025 10:18:35 +0700 Subject: [PATCH 28/43] Add contributing section for jan (#6231) (#6232) * Add contributing section for jan * Update CONTRIBUTING.md Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 262 ++++++++++++++++++++++++++++-- core/CONTRIBUTING.md | 71 ++++++++ extensions/CONTRIBUTING.md | 137 ++++++++++++++++ src-tauri/CONTRIBUTING.md | 111 +++++++++++++ src-tauri/plugins/CONTRIBUTING.md | 119 ++++++++++++++ web-app/CONTRIBUTING.md | 128 +++++++++++++++ 6 files changed, 814 insertions(+), 14 deletions(-) create mode 100644 core/CONTRIBUTING.md create mode 100644 extensions/CONTRIBUTING.md create mode 100644 src-tauri/CONTRIBUTING.md create mode 100644 src-tauri/plugins/CONTRIBUTING.md create mode 100644 web-app/CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a254fb49..16379e575 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,32 +1,266 @@ -# Contributing to jan +# Contributing to Jan -First off, thank you for considering contributing to jan. It's people like you that make jan such an amazing project. +First off, thank you for considering contributing to Jan. It's people like you that make Jan such an amazing project. + +Jan is an AI assistant that can run 100% offline on your device. Think ChatGPT, but private, local, and under your complete control. If you're thinking about contributing, you're already awesome - let's make AI accessible to everyone, one commit at a time. + +## Quick Links to Component Guides + +- **[Web App](./web-app/CONTRIBUTING.md)** - React UI and logic +- **[Core SDK](./core/CONTRIBUTING.md)** - TypeScript SDK and extension system +- **[Extensions](./extensions/CONTRIBUTING.md)** - Supportive modules for the frontend +- **[Tauri Backend](./src-tauri/CONTRIBUTING.md)** - Rust native integration +- **[Tauri Plugins](./src-tauri/plugins/CONTRIBUTING.md)** - Hardware and system plugins + +## How Jan Actually Works + +Jan is a desktop app that runs local AI models. Here's how the components actually connect: + +``` +┌──────────────────────────────────────────────────────────┐ +│ Web App (Frontend) │ +│ (web-app/) │ +│ • React UI │ +│ • Chat Interface │ +│ • Settings Pages │ +│ • Model Hub │ +└────────────┬─────────────────────────────┬───────────────┘ + │ │ + │ imports │ imports + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ Core SDK │ │ Extensions │ + │ (core/) │ │ (extensions/) │ + │ │ │ │ + │ • TypeScript APIs │◄─────│ • Assistant Mgmt │ + │ • Extension System │ uses │ • Conversations │ + │ • Event Bus │ │ • Downloads │ + │ • Type Definitions │ │ • LlamaCPP │ + └──────────┬───────────┘ └───────────┬──────────┘ + │ │ + │ ┌──────────────────────┐ │ + │ │ Web App │ │ + │ └──────────┬───────────┘ │ + │ │ │ + └──────────────┼───────────────┘ + │ + ▼ + Tauri IPC + (invoke commands) + │ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Tauri Backend (Rust) │ +│ (src-tauri/) │ +│ │ +│ • Window Management • File System Access │ +│ • Process Control • System Integration │ +│ • IPC Command Handler • Security & Permissions │ +└───────────────────────────┬───────────────────────────────┘ + │ + │ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ Tauri Plugins (Rust) │ +│ (src-tauri/plugins/) │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Hardware Plugin │ │ LlamaCPP Plugin │ │ +│ │ │ │ │ │ +│ │ • CPU/GPU Info │ │ • Process Mgmt │ │ +│ │ • Memory Stats │ │ • Model Loading │ │ +│ │ • System Info │ │ • Inference │ │ +│ └──────────────────┘ └──────────────────┘ │ +└───────────────────────────────────────────────────────────┘ +``` + +### The Communication Flow + +1. **JavaScript Layer Relationships**: + - Web App imports Core SDK and Extensions as JavaScript modules + - Extensions use Core SDK for shared functionality + - All run in the browser/webview context + +2. **All Three → Backend**: Through Tauri IPC + - **Web App** → Backend: `await invoke('app_command', data)` + - **Core SDK** → Backend: `await invoke('core_command', data)` + - **Extensions** → Backend: `await invoke('ext_command', data)` + - Each component can independently call backend commands + +3. **Backend → Plugins**: Native Rust integration + - Backend loads plugins as Rust libraries + - Direct function calls, no IPC overhead + +4. **Response Flow**: + - Plugin → Backend → IPC → Requester (Web App/Core/Extension) → UI updates + +### Real-World Example: Loading a Model + +Here's what actually happens when you click "Download Llama 3": + +1. **Web App** (`web-app/`) - User clicks download button +2. **Extension** (`extensions/download-extension`) - Handles the download logic +3. **Tauri Backend** (`src-tauri/`) - Actually downloads the file to disk +4. **Extension** (`extensions/llamacpp-extension`) - Prepares model for loading +5. **Tauri Plugin** (`src-tauri/plugins/llamacpp`) - Starts llama.cpp process +6. **Hardware Plugin** (`src-tauri/plugins/hardware`) - Detects GPU, optimizes settings +7. **Model ready!** - User can start chatting + +## Project Structure + +``` +jan/ +├── web-app/ # React frontend (what users see) +├── src-tauri/ # Rust backend (system integration) +│ ├── src/core/ # Core Tauri commands +│ └── plugins/ # Tauri plugins (hardware, llamacpp) +├── core/ # TypeScript SDK (API layer) +├── extensions/ # JavaScript extensions +│ ├── assistant-extension/ +│ ├── conversational-extension/ +│ ├── download-extension/ +│ └── llamacpp-extension/ +├── docs/ # Documentation website +├── website/ # Marketing website +├── autoqa/ # Automated testing +├── scripts/ # Build utilities +│ +├── package.json # Root workspace configuration +├── Makefile # Build automation commands +├── mise.toml # Mise tool configuration +├── LICENSE # Apache 2.0 license +└── README.md # Project overview +``` + +## Development Setup + +### The Scenic Route (Build from Source) + +**Prerequisites:** +- Node.js ≥ 20.0.0 +- Yarn ≥ 1.22.0 +- Rust (for Tauri) +- Make ≥ 3.81 + +**Option 1: The Easy Way (Make)** +```bash +git clone https://github.com/menloresearch/jan +cd jan +make dev +``` + +**Option 2: The Easier Way (Mise)** +```bash +git clone https://github.com/menloresearch/jan +cd jan + +# Install mise +curl https://mise.run | sh + +# Let mise handle everything +mise install # installs Node.js, Rust, and other tools +mise dev # runs the full development setup +``` ## How Can I Contribute? ### Reporting Bugs -- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/menloresearch/jan/issues). -- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/menloresearch/jan/issues/new). +- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/menloresearch/jan/issues) +- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/menloresearch/jan/issues/new) +- Include your system specs and error logs - it helps a ton ### Suggesting Enhancements -- Open a new issue with a clear title and description. +- Open a new issue with a clear title and description +- Explain why this enhancement would be useful +- Include mockups or examples if you can ### Your First Code Contribution -- Fork the repo. -- Create a new branch (`git checkout -b feature-name`). -- Commit your changes (`git commit -am 'Add some feature'`). -- Push to the branch (`git push origin feature-name`). -- Open a new Pull Request. +**Choose Your Adventure:** +- **Frontend UI and logic** → `web-app/` +- **Shared API declarations** → `core/` +- **Backend system integration** → `src-tauri/` +- **Business logic features** → `extensions/` +- **Dedicated backend handler** → `src-tauri/plugins/` -## Styleguides +**The Process:** +1. Fork the repo +2. Create a new branch (`git checkout -b feature-name`) +3. Make your changes (and write tests!) +4. Commit your changes (`git commit -am 'Add some feature'`) +5. Push to the branch (`git push origin feature-name`) +6. Open a new Pull Request against `dev` branch -### Git Commit Messages +## Testing -- Use the present tense ("Add feature" not "Added feature"). +```bash +yarn test # All tests +cd src-tauri && cargo test # Rust tests +cd autoqa && python main.py # End-to-end tests +``` + +## Code Standards + +### TypeScript/JavaScript +- TypeScript required (we're not animals) +- ESLint + Prettier +- Functional React components +- Proper typing (no `any` - seriously!) + +### Rust +- `cargo fmt` + `cargo clippy` +- `Result` for error handling +- Document public APIs + +## Git Conventions + +### Branches +- `main` - stable releases +- `dev` - development (target this for PRs) +- `feature/*` - new features +- `fix/*` - bug fixes + +### Commit Messages +- Use the present tense ("Add feature" not "Added feature") +- Be descriptive but concise +- Reference issues when applicable + +Examples: +``` +feat: add support for Qwen models +fix: resolve memory leak in model loading +docs: update installation instructions +``` + +## Troubleshooting + +If things go sideways: + +1. **Check our [troubleshooting docs](https://jan.ai/docs/troubleshooting)** +2. **Clear everything and start fresh:** `make clean` then `make dev` +3. **Copy your error logs and system specs** +4. **Ask for help in our [Discord](https://discord.gg/FTk2MvZwJH)** `#🆘|jan-help` channel + +Common issues: +- **Build failures**: Check Node.js and Rust versions +- **Extension not loading**: Verify it's properly registered +- **Model not working**: Check hardware requirements and GPU drivers + +## Getting Help + +- [Documentation](https://jan.ai/docs) - The manual you should read +- [Discord Community](https://discord.gg/jan) - Where the community lives +- [GitHub Issues](https://github.com/janhq/jan/issues) - Report bugs here +- [GitHub Discussions](https://github.com/janhq/jan/discussions) - Ask questions + +## License + +Apache 2.0 - Because sharing is caring. See [LICENSE](./LICENSE) for the legal stuff. ## Additional Notes -Thank you for contributing to jan! +We're building something pretty cool here - an AI assistant that respects your privacy and runs entirely on your machine. Every contribution, no matter how small, helps make AI more accessible to everyone. + +Thanks for being part of the journey. Let's build the future of local AI together! 🚀 diff --git a/core/CONTRIBUTING.md b/core/CONTRIBUTING.md new file mode 100644 index 000000000..4d0a16989 --- /dev/null +++ b/core/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to Jan Core + +[← Back to Main Contributing Guide](../CONTRIBUTING.md) + +TypeScript SDK providing extension system, APIs, and type definitions for all Jan components. + +## Key Directories + +- **`/src/browser`** - Core APIs (events, extensions, file system) +- **`/src/browser/extensions`** - Built-in extensions (assistant, inference, conversational) +- **`/src/types`** - TypeScript type definitions +- **`/src/test`** - Testing utilities + +## Development + +### Key Principles + +1. **Platform Agnostic** - Works everywhere (browser, Node.js) +2. **Extension-Based** - New features = new extensions +3. **Type Everything** - TypeScript required +4. **Event-Driven** - Components communicate via events + +### Building & Testing + +```bash +# Build the SDK +yarn build + +# Run tests +yarn test + +# Watch mode +yarn test:watch +``` + +### Event System + +```typescript +// Emit events +events.emit('model:loaded', { modelId: 'llama-3' }) + +// Listen for events +events.on('model:loaded', (data) => { + console.log('Model loaded:', data.modelId) +}) +``` + +## Testing + +```typescript +describe('MyFeature', () => { + it('should do something', () => { + const result = doSomething() + expect(result).toBe('expected') + }) +}) +``` + +## Best Practices + +- Keep it simple +- Use TypeScript fully (no `any`) +- Write tests for critical features +- Follow existing patterns +- Export new modules in index files + +## Dependencies + +- **TypeScript** - Type safety +- **Rolldown** - Bundling +- **Vitest** - Testing diff --git a/extensions/CONTRIBUTING.md b/extensions/CONTRIBUTING.md new file mode 100644 index 000000000..ee5c5aa9f --- /dev/null +++ b/extensions/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Contributing to Jan Extensions + +[← Back to Main Contributing Guide](../CONTRIBUTING.md) + +Extensions add specific features to Jan as self-contained modules. + +## Current Extensions + +### `/assistant-extension` +- Assistant CRUD operations +- `src/index.ts` - Main implementation + +### `/conversational-extension` +- Message handling, conversation state +- `src/index.ts` - Chat logic + +### `/download-extension` +- Model downloads with progress tracking +- `src/index.ts` - Download logic +- `settings.json` - Download settings + +### `/llamacpp-extension` +- Local model inference via llama.cpp +- `src/index.ts` - Entry point +- `src/backend.ts` - llama.cpp integration +- `settings.json` - Model settings + +## Creating Extensions + +### Setup + +```bash +mkdir my-extension +cd my-extension +yarn init +``` + +### Structure + +``` +my-extension/ +├── package.json +├── rolldown.config.mjs +├── src/index.ts +└── settings.json (optional) +``` + +### Basic Extension + +```typescript +import { Extension } from '@janhq/core' + +export default class MyExtension extends Extension { + async onLoad() { + // Extension initialization + } + + async onUnload() { + // Cleanup + } +} +``` + +## Building & Testing + +```bash +# Build extension +yarn build + +# Run tests +yarn test +``` + +## Common Patterns + +### Service Registration +```typescript +async onLoad() { + this.registerService('myService', { + doSomething: async () => 'result' + }) +} +``` + +### Event Handling +```typescript +async onLoad() { + this.on('model:loaded', (model) => { + console.log('Model loaded:', model.id) + }) +} +``` + +## Extension Lifecycle + +1. **Jan starts** → Discovers extensions +2. **Loading** → Calls `onLoad()` method +3. **Active** → Extension responds to events +4. **Unloading** → Calls `onUnload()` on shutdown + +## Debugging Extensions + +```bash +# Check if extension loaded +console.log(window.core.extensions) + +# Debug extension events +this.on('*', console.log) + +# Check extension services +console.log(window.core.api) +``` + +## Common Issues + +**Extension not loading?** +- Check package.json format: `@janhq/extension-name` +- Ensure `onLoad()` doesn't throw errors +- Verify exports in index.ts + +**Events not working?** +- Check event name spelling +- Ensure listeners are set up in `onLoad()` + +## Best Practices + +- Keep extensions focused on one feature +- Use async/await for all operations +- Clean up resources in onUnload() +- Handle errors gracefully +- Don't depend on other extensions + +## Dependencies + +- **@janhq/core** - Core SDK and extension system +- **TypeScript** - Type safety +- **Rolldown** - Bundling \ No newline at end of file diff --git a/src-tauri/CONTRIBUTING.md b/src-tauri/CONTRIBUTING.md new file mode 100644 index 000000000..e9d68cfaf --- /dev/null +++ b/src-tauri/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# Contributing to Tauri Backend + +[← Back to Main Contributing Guide](../CONTRIBUTING.md) + +Rust backend that handles native system integration, file operations, and process management. + +## Key Modules + +- **`/src/core/app`** - App state and commands +- **`/src/core/downloads`** - Model download management +- **`/src/core/filesystem`** - File system operations +- **`/src/core/mcp`** - Model Context Protocol +- **`/src/core/server`** - Local API server +- **`/src/core/system`** - System information and utilities +- **`/src/core/threads`** - Conversation management +- **`/utils`** - Shared utility crate (CLI, crypto, HTTP, path utils). Used by plugins and the main backend. +- **`/plugins`** - Native Tauri plugins ([see plugins guide](./plugins/CONTRIBUTING.md)) + +## Development + +### Adding Tauri Commands + +```rust +#[tauri::command] +async fn my_command(param: String) -> Result { + Ok(format!("Processed: {}", param)) +} + +// Register in lib.rs +tauri::Builder::default() + .invoke_handler(tauri::generate_handler![my_command]) +``` + +## Building & Testing + +```bash +# Development +yarn tauri dev + +# Build +yarn tauri build + +# Run tests +cargo test +``` + +### State Management + +```rust +#[tauri::command] +async fn get_data(state: State<'_, AppState>) -> Result { + state.get_data().await +} +``` + +### Error Handling + +```rust +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} +``` + +## Debugging + +```rust +// Enable debug logging +env::set_var("RUST_LOG", "debug"); + +// Debug print in commands +#[tauri::command] +async fn my_command() -> Result { + println!("Command called"); // Shows in terminal + dbg!("Debug info"); + Ok("result".to_string()) +} +``` + +## Platform-Specific Notes + +**Windows**: Requires Visual Studio Build Tools +**macOS**: Needs Xcode command line tools +**Linux**: May need additional system packages + +```rust +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +``` + +## Common Issues + +**Build failures**: Check Rust toolchain version +**IPC errors**: Ensure command names match frontend calls +**Permission errors**: Update capabilities configuration + +## Best Practices + +- Always use `Result` for fallible operations +- Validate all input from frontend +- Use async for I/O operations +- Follow Rust naming conventions +- Document public APIs + +## Dependencies + +- **Tauri** - Desktop app framework +- **Tokio** - Async runtime +- **Serde** - JSON serialization +- **thiserror** - Error handling \ No newline at end of file diff --git a/src-tauri/plugins/CONTRIBUTING.md b/src-tauri/plugins/CONTRIBUTING.md new file mode 100644 index 000000000..2f999d617 --- /dev/null +++ b/src-tauri/plugins/CONTRIBUTING.md @@ -0,0 +1,119 @@ +# Contributing to Tauri Plugins + +[← Back to Main Contributing Guide](../../CONTRIBUTING.md) | [← Back to Tauri Guide](../CONTRIBUTING.md) + +Native Rust plugins for hardware access, process management, and system integration. + +## Current Plugins + +### `/tauri-plugin-hardware` +- Hardware detection (CPU, GPU, memory) + +### `/tauri-plugin-llamacpp` +- llama.cpp process management and model inference + +## Plugin Structure + +``` +tauri-plugin-name/ +├── Cargo.toml +├── src/lib.rs # Plugin entry point +├── src/commands.rs # Tauri commands +├── guest-js/index.ts # JavaScript API +└── permissions/default.toml +``` + +## Development + +### Creating Plugins + +Assuming that your new plugin name is `my-plugin` + +```bash +# with npx +npx @tauri-apps/cli plugin new my-plugin + +# with cargo +cargo tauri plugin new my-plugin + +cd tauri-plugin-my-plugin +``` + +### Plugin Registration + +```rust +use tauri::{plugin::{Builder, TauriPlugin}, Runtime}; + +pub fn init() -> TauriPlugin { + Builder::new("my-plugin") + .invoke_handler(tauri::generate_handler![commands::my_command]) + .build() +} +``` + +### Commands & JavaScript API + +```rust +#[tauri::command] +pub async fn my_command(param: String) -> Result { + Ok(format!("Result: {}", param)) +} +``` + +```typescript +import { invoke } from '@tauri-apps/api/core' + +export async function myCommand(param: string): Promise { + return await invoke('plugin:my-plugin|my_command', { param }) +} +``` + +### Building & Testing + +```bash +cargo build # Build plugin +yarn build # Build JavaScript +cargo test # Run tests +``` + +## Security Considerations + +```toml +# permissions/default.toml - Be specific +[[permission]] +identifier = "allow-hardware-info" +description = "Read system hardware information" + +# Never use wildcards in production +# ❌ identifier = "allow-*" +# ✅ identifier = "allow-specific-action" +``` + +## Testing Plugins + +```bash +# Test plugin in isolation +cd tauri-plugin-my-plugin +cargo test + +# Test with main app +cd ../../ +yarn tauri dev + +# Test JavaScript API +yarn build && node -e "const plugin = require('./dist-js'); console.log(plugin)" +``` + +## Best Practices + +- Use secure permission configurations +- Validate all command inputs +- Handle platform differences properly +- Clean up resources in Drop implementations +- Test on all target platforms + +## Dependencies + +- **Tauri** - Plugin framework +- **Serde** - JSON serialization +- **Tokio** - Async runtime (if needed) \ No newline at end of file diff --git a/web-app/CONTRIBUTING.md b/web-app/CONTRIBUTING.md new file mode 100644 index 000000000..32d7779bd --- /dev/null +++ b/web-app/CONTRIBUTING.md @@ -0,0 +1,128 @@ +# Contributing to Jan Web App + +[← Back to Main Contributing Guide](../CONTRIBUTING.md) + +React frontend using TypeScript, TanStack Router, Radix UI, and Tailwind CSS. State is managed by React State and Zustand. + +## Key Directories + +- **`/src/components/ui`** - UI components (buttons, dialogs, inputs) +- **`/src/containers`** - Complex feature components (ChatInput, ThreadContent) +- **`/src/hooks`** - Custom React hooks (useChat, useThreads, useAppState) +- **`/src/routes`** - TanStack Router pages +- **`/src/services`** - API layer for backend communication +- **`/src/types`** - TypeScript definitions + +## Development + +### Component Example + +```tsx +interface Props { + title: string + onAction?: () => void +} + +export const MyComponent: React.FC = ({ title, onAction }) => { + return ( +
+

{title}

+ +
+ ) +} +``` + +### Routing + +```tsx +export const Route = createFileRoute('/settings/general')({ + component: GeneralSettings +}) +``` + +### Building & Testing + +```bash +# Development +yarn dev +yarn build +yarn test +``` + +### State Management + +```tsx +// Local state +const [value, setValue] = useState('') + +// Global state (Zustand) +export const useAppState = create((set) => ({ + data: null, + setData: (data) => set({ data }) +})) +``` + +### Tauri Integration + +```tsx +import { invoke } from '@tauri-apps/api/tauri' + +const result = await invoke('command_name', { param: 'value' }) +``` + +## Performance Tips + +```tsx +// Use React.memo for expensive components +const ExpensiveComponent = React.memo(({ data }) => { + return
{processData(data)}
+}) + +// Debounce frequent updates +const debouncedValue = useDebounce(searchTerm, 300) + +// Virtual scrolling for large lists +import { VariableSizeList } from 'react-window' +``` + +## Debugging + +```bash +# React DevTools +# Install browser extension, then: +# - Inspect component tree +# - Debug hooks and state +# - Profile performance + +# Debug Tauri commands +console.log(await window.__TAURI__.invoke('command_name')) + +# Check for console errors +# Press F12 → Console tab +``` + +## Accessibility Guidelines + +- Use semantic HTML (`
+
+
+ +
+
+
+
+ {t('assistants:maxToolSteps')} + { + const newSteps = e.target.value + const stepNumber = Number(newSteps) + setToolSteps(isNaN(stepNumber) ? 20 : stepNumber) + }} + placeholder="20" + className="w-full sm:w-24" + /> +
+
+
+
+
From 8f0999cc37c0112d7d659bbf4cebb92f5734ac08 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 20 Aug 2025 13:00:59 +0700 Subject: [PATCH 34/43] chore: update layout setting assistant --- .../containers/dialogs/AddEditAssistant.tsx | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/web-app/src/containers/dialogs/AddEditAssistant.tsx b/web-app/src/containers/dialogs/AddEditAssistant.tsx index 21a651a78..abc852810 100644 --- a/web-app/src/containers/dialogs/AddEditAssistant.tsx +++ b/web-app/src/containers/dialogs/AddEditAssistant.tsx @@ -327,28 +327,26 @@ export default function AddEditAssistant({ />
-
+
-
-
-
- {t('assistants:maxToolSteps')} - { - const newSteps = e.target.value - const stepNumber = Number(newSteps) - setToolSteps(isNaN(stepNumber) ? 20 : stepNumber) - }} - placeholder="20" - className="w-full sm:w-24" - /> -
+
+
+

{t('assistants:maxToolSteps')}

+ { + const newSteps = e.target.value + const stepNumber = Number(newSteps) + setToolSteps(isNaN(stepNumber) ? 20 : stepNumber) + }} + placeholder="20" + className="w-18 text-right" + />
From df4491e568c330ed6e56f3af44034f7a476aca87 Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 20 Aug 2025 13:57:52 +0700 Subject: [PATCH 35/43] =?UTF-8?q?ch=E1=BB=8Fe:=20update=20new=20domain=20f?= =?UTF-8?q?or=20jan=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/jan-astro-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/jan-astro-docs.yml b/.github/workflows/jan-astro-docs.yml index b551d847d..4e28f8180 100644 --- a/.github/workflows/jan-astro-docs.yml +++ b/.github/workflows/jan-astro-docs.yml @@ -19,7 +19,7 @@ jobs: deploy: name: Deploy to CloudFlare Pages env: - CLOUDFLARE_PROJECT_NAME: astro-docs + CLOUDFLARE_PROJECT_NAME: astro-docs # docs.jan.ai runs-on: ubuntu-latest permissions: contents: write From 65193272440357fb73d2d0bbe0fef105babe0da2 Mon Sep 17 00:00:00 2001 From: Dinh Long Nguyen Date: Wed, 20 Aug 2025 14:13:27 +0700 Subject: [PATCH 36/43] fix: local api server log (#6244) --- web-app/src/routes/local-api-server/logs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/src/routes/local-api-server/logs.tsx b/web-app/src/routes/local-api-server/logs.tsx index 813e0b066..ee3b41ab5 100644 --- a/web-app/src/routes/local-api-server/logs.tsx +++ b/web-app/src/routes/local-api-server/logs.tsx @@ -11,7 +11,7 @@ export const Route = createFileRoute(route.localApiServerlogs as any)({ component: LogsViewer, }) -const SERVER_LOG_TARGET = 'app_lib::core::server' +const SERVER_LOG_TARGET = 'app_lib::core::server::proxy' const LOG_EVENT_NAME = 'log://log' function LogsViewer() { From 1ad29077e34c87e227c2f384dbd1c6f6ffcec9a8 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 20 Aug 2025 14:16:45 +0700 Subject: [PATCH 37/43] fix: hiden sort filter when searching model --- web-app/src/routes/hub/index.tsx | 91 +++++++++++++++++--------------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/web-app/src/routes/hub/index.tsx b/web-app/src/routes/hub/index.tsx index ffacd2f04..e873baa77 100644 --- a/web-app/src/routes/hub/index.tsx +++ b/web-app/src/routes/hub/index.tsx @@ -146,14 +146,16 @@ function Hub() { } // Apply downloaded filter if (showOnlyDownloaded) { - filtered = filtered?.map((model) => ({ - ...model, - quants: model.quants.filter((variant) => - llamaProvider?.models.some( - (m: { id: string }) => m.id === variant.model_id - ) - ) - })).filter((model) => model.quants.length > 0) + filtered = filtered + ?.map((model) => ({ + ...model, + quants: model.quants.filter((variant) => + llamaProvider?.models.some( + (m: { id: string }) => m.id === variant.model_id + ) + ), + })) + .filter((model) => model.quants.length > 0) } // Add HuggingFace repo at the beginning if available if (huggingFaceRepo) { @@ -428,43 +430,44 @@ function Hub() { const isLastStep = currentStepIndex === steps.length - 1 const renderFilter = () => { - return ( - <> - - - - { - sortOptions.find((option) => option.value === sortSelected) - ?.name - } + if (searchValue.length === 0) + return ( + <> + + + + { + sortOptions.find((option) => option.value === sortSelected) + ?.name + } + + + + {sortOptions.map((option) => ( + setSortSelected(option.value)} + > + {option.name} + + ))} + + +
+ + + {t('hub:downloaded')} - - - {sortOptions.map((option) => ( - setSortSelected(option.value)} - > - {option.name} - - ))} - - -
- - - {t('hub:downloaded')} - -
- - ) +
+ + ) } return ( From 906b87022d6d456256dffd816b3b011ac7bfe165 Mon Sep 17 00:00:00 2001 From: Akarshan Biswas Date: Wed, 20 Aug 2025 13:06:21 +0530 Subject: [PATCH 38/43] chore: re enable reasoning_content in backend (#6228) * chore: re enable reasoning_content in backend * chore: handle reasoning_content * chore: refactor get reasoning content * chore: update PR review --------- Co-authored-by: Faisal Amir --- .../browser/extensions/engines/AIEngine.ts | 3 +- extensions/llamacpp-extension/src/index.ts | 1 - web-app/src/utils/__tests__/reasoning.test.ts | 381 ++++++++++++++++++ web-app/src/utils/reasoning.ts | 9 +- 4 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 web-app/src/utils/__tests__/reasoning.test.ts diff --git a/core/src/browser/extensions/engines/AIEngine.ts b/core/src/browser/extensions/engines/AIEngine.ts index 01854852a..b203092ce 100644 --- a/core/src/browser/extensions/engines/AIEngine.ts +++ b/core/src/browser/extensions/engines/AIEngine.ts @@ -7,6 +7,7 @@ export interface chatCompletionRequestMessage { role: 'system' | 'user' | 'assistant' | 'tool' content: string | null | Content[] // Content can be a string OR an array of content parts reasoning?: string | null // Some models return reasoning in completed responses + reasoning_content?: string | null // Some models return reasoning in completed responses name?: string tool_calls?: any[] // Simplified tool_call_id?: string } @@ -274,7 +275,7 @@ export abstract class AIEngine extends BaseExtension { /** * Check if a tool is supported by the model - * @param modelId + * @param modelId */ abstract isToolSupported(modelId: string): Promise } diff --git a/extensions/llamacpp-extension/src/index.ts b/extensions/llamacpp-extension/src/index.ts index a4116b78d..bf03024a0 100644 --- a/extensions/llamacpp-extension/src/index.ts +++ b/extensions/llamacpp-extension/src/index.ts @@ -1228,7 +1228,6 @@ export default class llamacpp_extension extends AIEngine { modelConfig.model_path, ]) args.push('--jinja') - args.push('--reasoning-format', 'none') args.push('-m', modelPath) // For overriding tensor buffer type, useful where // massive MOE models can be made faster by keeping attention on the GPU diff --git a/web-app/src/utils/__tests__/reasoning.test.ts b/web-app/src/utils/__tests__/reasoning.test.ts new file mode 100644 index 000000000..f9717b753 --- /dev/null +++ b/web-app/src/utils/__tests__/reasoning.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + ReasoningProcessor, + extractReasoningFromMessage, +} from '../reasoning' +import { CompletionResponseChunk } from 'token.js' +import { chatCompletionChunk, chatCompletionRequestMessage } from '@janhq/core' + +describe('extractReasoningFromMessage', () => { + it('should extract reasoning from message with reasoning_content property', () => { + const message = { + role: 'assistant' as const, + content: 'Hello', + reasoning_content: 'This is my reasoning content', + } + + const result = extractReasoningFromMessage(message) + expect(result).toBe('This is my reasoning content') + }) + + it('should extract reasoning from message with legacy reasoning property', () => { + const message = { + role: 'assistant' as const, + content: 'Hello', + reasoning: 'This is my reasoning', + } + + const result = extractReasoningFromMessage(message) + expect(result).toBe('This is my reasoning') + }) + + it('should prefer reasoning_content over reasoning property', () => { + const message = { + role: 'assistant' as const, + content: 'Hello', + reasoning_content: 'New reasoning content', + reasoning: 'Old reasoning', + } + + const result = extractReasoningFromMessage(message) + expect(result).toBe('New reasoning content') + }) + + it('should return null for message without reasoning', () => { + const message = { + role: 'assistant' as const, + content: 'Hello', + } + + const result = extractReasoningFromMessage(message) + expect(result).toBeNull() + }) + + it('should return null for null/undefined message', () => { + expect(extractReasoningFromMessage(null as any)).toBeNull() + expect(extractReasoningFromMessage(undefined as any)).toBeNull() + }) +}) + +describe('ReasoningProcessor', () => { + let processor: ReasoningProcessor + + beforeEach(() => { + processor = new ReasoningProcessor() + }) + + describe('processReasoningChunk', () => { + it('should start reasoning with opening think tag using reasoning_content', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: 'Let me think about this...', + }, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('Let me think about this...') + expect(processor.isReasoningInProgress()).toBe(true) + }) + + it('should start reasoning with opening think tag using legacy reasoning', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning: 'Let me think about this...', + }, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('Let me think about this...') + expect(processor.isReasoningInProgress()).toBe(true) + }) + + it('should continue reasoning without opening tag', () => { + // Start reasoning + const chunk1: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: 'First part', + }, + }], + } + processor.processReasoningChunk(chunk1) + + // Continue reasoning + const chunk2: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: ' second part', + }, + }], + } + + const result = processor.processReasoningChunk(chunk2) + expect(result).toBe(' second part') + expect(processor.isReasoningInProgress()).toBe(true) + }) + + it('should end reasoning when content starts', () => { + // Start reasoning + const chunk1: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: 'Thinking...', + }, + }], + } + processor.processReasoningChunk(chunk1) + + // End reasoning with content + const chunk2: chatCompletionChunk = { + choices: [{ + delta: { + content: 'Now I respond', + }, + }], + } + + const result = processor.processReasoningChunk(chunk2) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle empty reasoning chunks', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: '', + }, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle whitespace-only reasoning', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: ' \n ', + }, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle non-string reasoning', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: null as any, + }, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle chunk without choices', () => { + const chunk: chatCompletionChunk = { + choices: undefined as any, + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle chunk without delta', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: undefined as any, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle content without active reasoning', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + content: 'Regular content', + }, + }], + } + + const result = processor.processReasoningChunk(chunk) + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + }) + + describe('finalize', () => { + it('should close reasoning if still active', () => { + // Start reasoning + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: 'Unfinished thinking', + }, + }], + } + processor.processReasoningChunk(chunk) + + const result = processor.finalize() + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should return empty string if no active reasoning', () => { + const result = processor.finalize() + expect(result).toBe('') + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle multiple finalize calls', () => { + // Start reasoning + const chunk: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: 'Thinking', + }, + }], + } + processor.processReasoningChunk(chunk) + + // First finalize + const result1 = processor.finalize() + expect(result1).toBe('') + + // Second finalize should return empty + const result2 = processor.finalize() + expect(result2).toBe('') + }) + }) + + describe('isReasoningInProgress', () => { + it('should track reasoning state correctly', () => { + expect(processor.isReasoningInProgress()).toBe(false) + + // Start reasoning + const chunk1: chatCompletionChunk = { + choices: [{ + delta: { + reasoning_content: 'Start thinking', + }, + }], + } + processor.processReasoningChunk(chunk1) + expect(processor.isReasoningInProgress()).toBe(true) + + // End with content + const chunk2: chatCompletionChunk = { + choices: [{ + delta: { + content: 'Response', + }, + }], + } + processor.processReasoningChunk(chunk2) + expect(processor.isReasoningInProgress()).toBe(false) + }) + }) + + describe('integration scenarios', () => { + it('should handle complete reasoning flow', () => { + const chunks: chatCompletionChunk[] = [ + { + choices: [{ + delta: { reasoning_content: 'Let me think' }, + }], + }, + { + choices: [{ + delta: { reasoning_content: ' about this problem' }, + }], + }, + { + choices: [{ + delta: { reasoning_content: ' step by step.' }, + }], + }, + { + choices: [{ + delta: { content: 'Based on my analysis,' }, + }], + }, + { + choices: [{ + delta: { content: ' the answer is 42.' }, + }], + }, + ] + + const results = chunks.map(chunk => processor.processReasoningChunk(chunk)) + + expect(results[0]).toBe('Let me think') + expect(results[1]).toBe(' about this problem') + expect(results[2]).toBe(' step by step.') + expect(results[3]).toBe('') + expect(results[4]).toBe('') + + expect(processor.isReasoningInProgress()).toBe(false) + }) + + it('should handle reasoning without content', () => { + const chunk: chatCompletionChunk = { + choices: [{ + delta: { reasoning_content: 'Only reasoning, no content' }, + }], + } + + const result1 = processor.processReasoningChunk(chunk) + expect(result1).toBe('Only reasoning, no content') + + const result2 = processor.finalize() + expect(result2).toBe('') + }) + + it('should handle mixed reasoning and content chunks', () => { + // Reasoning then content then reasoning again (edge case) + const chunk1: chatCompletionChunk = { + choices: [{ + delta: { reasoning_content: 'First thought' }, + }], + } + + const chunk2: chatCompletionChunk = { + choices: [{ + delta: { content: 'Some content' }, + }], + } + + const chunk3: chatCompletionChunk = { + choices: [{ + delta: { reasoning_content: 'Second thought' }, + }], + } + + const result1 = processor.processReasoningChunk(chunk1) + const result2 = processor.processReasoningChunk(chunk2) + const result3 = processor.processReasoningChunk(chunk3) + + expect(result1).toBe('First thought') + expect(result2).toBe('') + expect(result3).toBe('Second thought') + }) + }) +}) \ No newline at end of file diff --git a/web-app/src/utils/reasoning.ts b/web-app/src/utils/reasoning.ts index f102b8a76..a189639f0 100644 --- a/web-app/src/utils/reasoning.ts +++ b/web-app/src/utils/reasoning.ts @@ -5,6 +5,11 @@ import { chatCompletionRequestMessage, } from '@janhq/core' +// Helper function to get reasoning content from an object +function getReasoning(obj: { reasoning_content?: string | null; reasoning?: string | null } | null | undefined): string | null { + return obj?.reasoning_content ?? obj?.reasoning ?? null +} + // Extract reasoning from a message (for completed responses) export function extractReasoningFromMessage( message: chatCompletionRequestMessage | ChatCompletionMessage @@ -12,7 +17,7 @@ export function extractReasoningFromMessage( if (!message) return null const extendedMessage = message as chatCompletionRequestMessage - return extendedMessage.reasoning || null + return getReasoning(extendedMessage) } // Extract reasoning from a chunk (for streaming responses) @@ -22,7 +27,7 @@ function extractReasoningFromChunk( if (!chunk.choices?.[0]?.delta) return null const delta = chunk.choices[0].delta as chatCompletionRequestMessage - const reasoning = delta.reasoning + const reasoning = getReasoning(delta) // Return null for falsy values, non-strings, or whitespace-only strings if (!reasoning || typeof reasoning !== 'string' || !reasoning.trim()) From 4059d7a7ece3d3e5524ed4d90c5f8b676b375d5a Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 20 Aug 2025 18:03:27 +0700 Subject: [PATCH 39/43] ci: add autoqa reliability workflow for windows --- .github/workflows/autoqa-reliability.yml | 156 +++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .github/workflows/autoqa-reliability.yml diff --git a/.github/workflows/autoqa-reliability.yml b/.github/workflows/autoqa-reliability.yml new file mode 100644 index 000000000..d7a4c006b --- /dev/null +++ b/.github/workflows/autoqa-reliability.yml @@ -0,0 +1,156 @@ +name: AutoQA Reliability (Manual) + +on: + workflow_dispatch: + inputs: + source_type: + description: 'App source type (url or local)' + required: true + type: choice + options: [url, local] + default: url + jan_app_windows_source: + description: 'Windows installer URL or local path (used when source_type=url or to select artifact)' + required: true + type: string + default: 'https://catalog.jan.ai/windows/Jan_0.6.8_x64-setup.exe' + jan_app_ubuntu_source: + description: 'Ubuntu .deb URL or local path' + required: true + type: string + default: 'https://delta.jan.ai/nightly/Jan-nightly_0.6.4-728_amd64.deb' + jan_app_macos_source: + description: 'macOS .dmg URL or local path' + required: true + type: string + default: 'https://delta.jan.ai/nightly/Jan-nightly_0.6.4-728_universal.dmg' + is_nightly: + description: 'Is the app a nightly build?' + required: true + type: boolean + default: true + reliability_phase: + description: 'Reliability phase' + required: true + type: choice + options: [development, deployment] + default: development + reliability_runs: + description: 'Custom runs (0 uses phase default)' + required: true + type: number + default: 0 + reliability_test_path: + description: 'Test file path (relative to autoqa working directory)' + required: true + type: string + default: 'tests/base/settings/app-data.txt' + artifact_name_windows: + description: 'Windows artifact name (only for source_type=local)' + required: false + type: string + default: '' + artifact_name_ubuntu: + description: 'Ubuntu artifact name (only for source_type=local)' + required: false + type: string + default: '' + artifact_name_macos: + description: 'macOS artifact name (only for source_type=local)' + required: false + type: string + default: '' + +jobs: + reliability-windows: + runs-on: windows-11-nvidia-gpu + timeout-minutes: 60 + env: + DEFAULT_JAN_APP_URL: 'https://catalog.jan.ai/windows/Jan_0.6.8_x64-setup.exe' + DEFAULT_IS_NIGHTLY: 'false' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python 3.13 + uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Download artifact (if source_type is local) + if: inputs.source_type == 'local' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact_name_windows }} + path: ${{ runner.temp }}/windows-artifact + + - name: Clean existing Jan installations + shell: powershell + run: | + .\autoqa\scripts\windows_cleanup.ps1 -IsNightly "${{ inputs.is_nightly }}" + + - name: Download/Prepare Jan app + shell: powershell + run: | + if ("${{ inputs.source_type }}" -eq "local") { + $exeFile = Get-ChildItem -Path "${{ runner.temp }}/windows-artifact" -Recurse -Filter "*.exe" | Select-Object -First 1 + if ($exeFile) { + Write-Host "[SUCCESS] Found local installer: $($exeFile.FullName)" + Copy-Item -Path $exeFile.FullName -Destination "$env:TEMP\jan-installer.exe" -Force + Write-Host "[SUCCESS] Installer copied to: $env:TEMP\jan-installer.exe" + echo "IS_NIGHTLY=${{ inputs.is_nightly }}" >> $env:GITHUB_ENV + } else { + Write-Error "[FAILED] No .exe file found in artifact" + exit 1 + } + } else { + .\autoqa\scripts\windows_download.ps1 ` + -WorkflowInputUrl "${{ inputs.jan_app_windows_source }}" ` + -WorkflowInputIsNightly "${{ inputs.is_nightly }}" ` + -RepoVariableUrl "${{ vars.JAN_APP_URL }}" ` + -RepoVariableIsNightly "${{ vars.IS_NIGHTLY }}" ` + -DefaultUrl "$env:DEFAULT_JAN_APP_URL" ` + -DefaultIsNightly "$env:DEFAULT_IS_NIGHTLY" + } + + - name: Install Jan app + shell: powershell + run: | + .\autoqa\scripts\windows_install.ps1 -IsNightly "$env:IS_NIGHTLY" + + - name: Install Python dependencies + working-directory: autoqa + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run reliability tests + working-directory: autoqa + shell: powershell + run: | + $runs = "${{ inputs.reliability_runs }}" + $runsArg = "" + if ([int]$runs -gt 0) { $runsArg = "--reliability-runs $runs" } + python main.py --enable-reliability-test --reliability-phase "${{ inputs.reliability_phase }}" --reliability-test-path "${{ inputs.reliability_test_path }}" $runsArg + + - name: Upload screen recordings + if: always() + uses: actions/upload-artifact@v4 + continue-on-error: true + with: + name: reliability-recordings-${{ github.run_number }}-${{ runner.os }} + path: autoqa/recordings/ + + - name: Upload trajectories + if: always() + uses: actions/upload-artifact@v4 + continue-on-error: true + with: + name: reliability-trajectories-${{ github.run_number }}-${{ runner.os }} + path: autoqa/trajectories/ + + - name: Cleanup after tests + if: always() + shell: powershell + run: | + .\autoqa\scripts\windows_post_cleanup.ps1 -IsNightly "${{ inputs.is_nightly }}" From 76dfe027abc521308326509053899506b1cf23fd Mon Sep 17 00:00:00 2001 From: Minh141120 Date: Wed, 20 Aug 2025 18:08:38 +0700 Subject: [PATCH 40/43] chore: remove local artifact inputs --- .github/workflows/autoqa-reliability.yml | 59 +++++------------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/.github/workflows/autoqa-reliability.yml b/.github/workflows/autoqa-reliability.yml index d7a4c006b..759e93717 100644 --- a/.github/workflows/autoqa-reliability.yml +++ b/.github/workflows/autoqa-reliability.yml @@ -4,23 +4,23 @@ on: workflow_dispatch: inputs: source_type: - description: 'App source type (url or local)' + description: 'App source type (url)' required: true type: choice - options: [url, local] + options: [url] default: url jan_app_windows_source: - description: 'Windows installer URL or local path (used when source_type=url or to select artifact)' + description: 'Windows installer URL path (used when source_type=url or to select artifact)' required: true type: string default: 'https://catalog.jan.ai/windows/Jan_0.6.8_x64-setup.exe' jan_app_ubuntu_source: - description: 'Ubuntu .deb URL or local path' + description: 'Ubuntu .deb URL path' required: true type: string default: 'https://delta.jan.ai/nightly/Jan-nightly_0.6.4-728_amd64.deb' jan_app_macos_source: - description: 'macOS .dmg URL or local path' + description: 'macOS .dmg URL path' required: true type: string default: 'https://delta.jan.ai/nightly/Jan-nightly_0.6.4-728_universal.dmg' @@ -45,21 +45,6 @@ on: required: true type: string default: 'tests/base/settings/app-data.txt' - artifact_name_windows: - description: 'Windows artifact name (only for source_type=local)' - required: false - type: string - default: '' - artifact_name_ubuntu: - description: 'Ubuntu artifact name (only for source_type=local)' - required: false - type: string - default: '' - artifact_name_macos: - description: 'macOS artifact name (only for source_type=local)' - required: false - type: string - default: '' jobs: reliability-windows: @@ -77,13 +62,6 @@ jobs: with: python-version: '3.13' - - name: Download artifact (if source_type is local) - if: inputs.source_type == 'local' - uses: actions/download-artifact@v4 - with: - name: ${{ inputs.artifact_name_windows }} - path: ${{ runner.temp }}/windows-artifact - - name: Clean existing Jan installations shell: powershell run: | @@ -92,26 +70,13 @@ jobs: - name: Download/Prepare Jan app shell: powershell run: | - if ("${{ inputs.source_type }}" -eq "local") { - $exeFile = Get-ChildItem -Path "${{ runner.temp }}/windows-artifact" -Recurse -Filter "*.exe" | Select-Object -First 1 - if ($exeFile) { - Write-Host "[SUCCESS] Found local installer: $($exeFile.FullName)" - Copy-Item -Path $exeFile.FullName -Destination "$env:TEMP\jan-installer.exe" -Force - Write-Host "[SUCCESS] Installer copied to: $env:TEMP\jan-installer.exe" - echo "IS_NIGHTLY=${{ inputs.is_nightly }}" >> $env:GITHUB_ENV - } else { - Write-Error "[FAILED] No .exe file found in artifact" - exit 1 - } - } else { - .\autoqa\scripts\windows_download.ps1 ` - -WorkflowInputUrl "${{ inputs.jan_app_windows_source }}" ` - -WorkflowInputIsNightly "${{ inputs.is_nightly }}" ` - -RepoVariableUrl "${{ vars.JAN_APP_URL }}" ` - -RepoVariableIsNightly "${{ vars.IS_NIGHTLY }}" ` - -DefaultUrl "$env:DEFAULT_JAN_APP_URL" ` - -DefaultIsNightly "$env:DEFAULT_IS_NIGHTLY" - } + .\autoqa\scripts\windows_download.ps1 ` + -WorkflowInputUrl "${{ inputs.jan_app_windows_source }}" ` + -WorkflowInputIsNightly "${{ inputs.is_nightly }}" ` + -RepoVariableUrl "${{ vars.JAN_APP_URL }}" ` + -RepoVariableIsNightly "${{ vars.IS_NIGHTLY }}" ` + -DefaultUrl "$env:DEFAULT_JAN_APP_URL" ` + -DefaultIsNightly "$env:DEFAULT_IS_NIGHTLY" - name: Install Jan app shell: powershell From c2cdb48ff387fd4e40327ee36e1d060d78362d1a Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 20 Aug 2025 21:01:38 +0700 Subject: [PATCH 41/43] enhancement: vision icon on model hub --- web-app/src/routes/hub/index.tsx | 47 ++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/web-app/src/routes/hub/index.tsx b/web-app/src/routes/hub/index.tsx index e873baa77..164e3344f 100644 --- a/web-app/src/routes/hub/index.tsx +++ b/web-app/src/routes/hub/index.tsx @@ -20,10 +20,17 @@ import { extractModelName, extractDescription } from '@/lib/models' import { IconDownload, IconFileCode, + IconPhoto, IconSearch, IconTool, } from '@tabler/icons-react' import { Switch } from '@/components/ui/switch' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import Joyride, { CallBackProps, STATUS } from 'react-joyride' import { CustomTooltipJoyRide } from '@/containers/CustomeTooltipJoyRide' import { @@ -661,11 +668,41 @@ function Hub() {
{filteredModels[virtualItem.index].tools && (
- + + + +
+ +
+
+ +

{t('tools')}

+
+
+
+
+ )} + {filteredModels[virtualItem.index].num_mmproj > + 0 && ( +
+ + + +
+ +
+
+ +

{t('vision')}

+
+
+
)} {filteredModels[virtualItem.index].quants.length > From 68f8c2d3f81e645841aaf0f262f5aa7cfcfe351d Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Wed, 20 Aug 2025 22:21:42 +0700 Subject: [PATCH 42/43] enhancement: change icon vision --- web-app/src/routes/hub/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-app/src/routes/hub/index.tsx b/web-app/src/routes/hub/index.tsx index 164e3344f..2e5db8ba2 100644 --- a/web-app/src/routes/hub/index.tsx +++ b/web-app/src/routes/hub/index.tsx @@ -20,7 +20,7 @@ import { extractModelName, extractDescription } from '@/lib/models' import { IconDownload, IconFileCode, - IconPhoto, + IconEye, IconSearch, IconTool, } from '@tabler/icons-react' @@ -692,7 +692,7 @@ function Hub() {
- From 6850dda10855d9828b533b53a363c0b51dc5a2c5 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 20 Aug 2025 23:42:12 +0700 Subject: [PATCH 43/43] feat: MCP server error handling --- Makefile | 2 + src-tauri/Cargo.toml | 2 +- src-tauri/src/core/mcp/helpers.rs | 66 +++++----- .../src/containers/dialogs/ErrorDialog.tsx | 123 ++++++++++++++++++ web-app/src/hooks/useAppState.ts | 13 ++ web-app/src/routes/__root.tsx | 2 + web-app/src/routes/settings/mcp-servers.tsx | 8 +- 7 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 web-app/src/containers/dialogs/ErrorDialog.tsx diff --git a/Makefile b/Makefile index 4bd823437..2515f8bf4 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,8 @@ test: lint yarn copy:assets:tauri yarn build:icon cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1 + cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml + cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml # Builds and publishes the app build-and-publish: install-and-build diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 58a342a26..efd69e9bf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ jan-utils = { path = "./utils" } libloading = "0.8.7" log = "0.4" reqwest = { version = "0.11", features = ["json", "blocking", "stream"] } -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "209dbac50f51737ad953c3a2c8e28f3619b6c277", features = [ +rmcp = { version = "0.6.0", features = [ "client", "transport-sse-client", "transport-streamable-http-client", diff --git a/src-tauri/src/core/mcp/helpers.rs b/src-tauri/src/core/mcp/helpers.rs index 75a1bba3a..80a8b5f86 100644 --- a/src-tauri/src/core/mcp/helpers.rs +++ b/src-tauri/src/core/mcp/helpers.rs @@ -7,10 +7,11 @@ use rmcp::{ ServiceExt, }; use serde_json::Value; -use std::{collections::HashMap, env, sync::Arc, time::Duration}; +use std::{collections::HashMap, env, process::Stdio, sync::Arc, time::Duration}; use tauri::{AppHandle, Emitter, Manager, Runtime, State}; use tauri_plugin_http::reqwest; use tokio::{ + io::AsyncReadExt, process::Command, sync::Mutex, time::{sleep, timeout}, @@ -647,23 +648,8 @@ async fn schedule_mcp_start_task( { cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows } - let app_path_str = app_path.to_str().unwrap().to_string(); - let log_file_path = format!("{}/logs/app.log", app_path_str); - match std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_file_path) - { - Ok(file) => { - cmd.stderr(std::process::Stdio::from(file)); - } - Err(err) => { - log::error!("Failed to open log file: {}", err); - } - }; cmd.kill_on_drop(true); - log::trace!("Command: {cmd:#?}"); config_params .args @@ -678,26 +664,42 @@ async fn schedule_mcp_start_task( } }); - let process = TokioChildProcess::new(cmd).map_err(|e| { - log::error!("Failed to run command {name}: {e}"); - format!("Failed to run command {name}: {e}") - })?; + let (process, stderr) = TokioChildProcess::builder(cmd) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + log::error!("Failed to run command {name}: {e}"); + format!("Failed to run command {name}: {e}") + })?; let service = () .serve(process) .await - .map_err(|e| format!("Failed to start MCP server {name}: {e}"))?; + .map_err(|e| format!("Failed to start MCP server {name}: {e}")); - // Get peer info and clone the needed values before moving the service - let server_info = service.peer_info(); - log::trace!("Connected to server: {server_info:#?}"); - - // Now move the service into the HashMap - servers - .lock() - .await - .insert(name.clone(), RunningServiceEnum::NoInit(service)); - log::info!("Server {name} started successfully."); + match service { + Ok(server) => { + log::trace!("Connected to server: {:#?}", server.peer_info()); + servers + .lock() + .await + .insert(name.clone(), RunningServiceEnum::NoInit(server)); + log::info!("Server {name} started successfully."); + } + Err(_) => { + let mut buffer = String::new(); + let error = match stderr + .expect("stderr must be piped") + .read_to_string(&mut buffer) + .await + { + Ok(_) => format!("Failed to start MCP server {name}: {buffer}"), + Err(_) => format!("Failed to read MCP server {name} stderr"), + }; + log::error!("{error}"); + return Err(error); + } + } // Wait a short time to verify the server is stable before marking as connected // This prevents race conditions where the server quits immediately @@ -754,7 +756,7 @@ pub fn extract_command_args(config: &Value) -> Option { command, args, envs, - headers + headers, }) } diff --git a/web-app/src/containers/dialogs/ErrorDialog.tsx b/web-app/src/containers/dialogs/ErrorDialog.tsx new file mode 100644 index 000000000..cd6ca879a --- /dev/null +++ b/web-app/src/containers/dialogs/ErrorDialog.tsx @@ -0,0 +1,123 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react' +import { IconCopy, IconCopyCheck } from '@tabler/icons-react' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { toast } from 'sonner' +import { useState } from 'react' +import { useAppState } from '@/hooks/useAppState' + +export default function ErrorDialog() { + const { t } = useTranslation() + const { errorMessage, setErrorMessage } = useAppState() + const [isCopying, setIsCopying] = useState(false) + const [isDetailExpanded, setIsDetailExpanded] = useState(true) + + const handleCopy = async () => { + setIsCopying(true) + try { + await navigator.clipboard.writeText(errorMessage?.message ?? '') + toast.success('Copy successful', { + id: 'copy-model', + description: 'Model load error information copied to clipboard', + }) + } catch { + toast.error('Failed to copy', { + id: 'copy-model-error', + description: 'Failed to copy error information to clipboard', + }) + } finally { + setTimeout(() => setIsCopying(false), 2000) + } + } + + const handleDialogOpen = (open: boolean) => { + setErrorMessage(open ? errorMessage : undefined) + } + + return ( + + + +
+
+ +
+
+ {t('common:error')} + + {errorMessage?.title ?? 'Something went wrong'} + +
+
+
+ +
+
+ + + {isDetailExpanded && ( +
{ + if (el) { + el.scrollTop = el.scrollHeight + } + }} + > + {errorMessage?.message} +
+ )} +
+ {errorMessage?.subtitle} +
+ + + + + +
+
+ ) +} diff --git a/web-app/src/hooks/useAppState.ts b/web-app/src/hooks/useAppState.ts index 7b3841f5c..fe885e043 100644 --- a/web-app/src/hooks/useAppState.ts +++ b/web-app/src/hooks/useAppState.ts @@ -4,6 +4,12 @@ import { MCPTool } from '@/types/completion' import { useAssistant } from './useAssistant' import { ChatCompletionMessageToolCall } from 'openai/resources' +type AppErrorMessage = { + message?: string + title?: string + subtitle: string +} + type AppState = { streamingContent?: ThreadMessage loadingModel?: boolean @@ -13,6 +19,7 @@ type AppState = { tokenSpeed?: TokenSpeed currentToolCall?: ChatCompletionMessageToolCall showOutOfContextDialog?: boolean + errorMessage?: AppErrorMessage cancelToolCall?: () => void setServerStatus: (value: 'running' | 'stopped' | 'pending') => void updateStreamingContent: (content: ThreadMessage | undefined) => void @@ -26,6 +33,7 @@ type AppState = { resetTokenSpeed: () => void setOutOfContextDialog: (show: boolean) => void setCancelToolCall: (cancel: (() => void) | undefined) => void + setErrorMessage: (error: AppErrorMessage | undefined) => void } export const useAppState = create()((set) => ({ @@ -120,4 +128,9 @@ export const useAppState = create()((set) => ({ cancelToolCall: cancel, })) }, + setErrorMessage: (error) => { + set(() => ({ + errorMessage: error, + })) + }, })) diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 77d9f9d2b..a8dc9fb03 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -29,6 +29,7 @@ import { import { useCallback, useEffect } from 'react' import GlobalError from '@/containers/GlobalError' import { GlobalEventHandler } from '@/providers/GlobalEventHandler' +import ErrorDialog from '@/containers/dialogs/ErrorDialog' export const Route = createRootRoute({ component: RootLayout, @@ -203,6 +204,7 @@ function RootLayout() { {/* */} + diff --git a/web-app/src/routes/settings/mcp-servers.tsx b/web-app/src/routes/settings/mcp-servers.tsx index 9c65a6f88..c95c47a2d 100644 --- a/web-app/src/routes/settings/mcp-servers.tsx +++ b/web-app/src/routes/settings/mcp-servers.tsx @@ -21,6 +21,7 @@ import { useToolApproval } from '@/hooks/useToolApproval' import { toast } from 'sonner' import { invoke } from '@tauri-apps/api/core' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useAppState } from '@/hooks/useAppState' // Function to mask sensitive values const maskSensitiveValue = (value: string) => { @@ -120,6 +121,7 @@ function MCPServers() { const [loadingServers, setLoadingServers] = useState<{ [key: string]: boolean }>({}) + const { setErrorMessage } = useAppState() const handleOpenDialog = (serverKey?: string) => { if (serverKey) { @@ -247,13 +249,13 @@ function MCPServers() { getConnectedServers().then(setConnectedServers) }) .catch((error) => { - console.log(error, 'error.mcp') editServer(serverKey, { ...(config ?? (mcpServers[serverKey] as MCPServerConfig)), active: false, }) - toast.error(error, { - description: t('mcp-servers:checkParams'), + setErrorMessage({ + message: error, + subtitle: t('mcp-servers:checkParams'), }) }) .finally(() => {