diff --git a/src-tauri/src/core/utils/download.rs b/src-tauri/src/core/utils/download.rs index a1a655e88..512731751 100644 --- a/src-tauri/src/core/utils/download.rs +++ b/src-tauri/src/core/utils/download.rs @@ -82,7 +82,7 @@ fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> { } // SSL verification settings are all optional booleans, no validation needed - + Ok(()) } @@ -155,7 +155,7 @@ fn _get_client_for_item( // Note: reqwest doesn't have fine-grained SSL verification controls // for verify_proxy_ssl, verify_proxy_host_ssl, verify_peer_ssl, verify_host_ssl // These settings are handled by the underlying TLS implementation - + // Check if this URL should bypass proxy let no_proxy = proxy_config.no_proxy.as_deref().unwrap_or(&[]); if !should_bypass_proxy(&item.url, no_proxy) { @@ -617,17 +617,17 @@ mod tests { config.verify_proxy_host_ssl = Some(false); config.verify_peer_ssl = Some(true); config.verify_host_ssl = Some(true); - + // Should validate successfully assert!(validate_proxy_config(&config).is_ok()); - + // Test with all SSL settings as false config.ignore_ssl = Some(false); config.verify_proxy_ssl = Some(false); config.verify_proxy_host_ssl = Some(false); config.verify_peer_ssl = Some(false); config.verify_host_ssl = Some(false); - + // Should still validate successfully assert!(validate_proxy_config(&config).is_ok()); } @@ -641,7 +641,7 @@ mod tests { config.verify_proxy_host_ssl = Some(true); config.verify_peer_ssl = Some(false); config.verify_host_ssl = Some(true); - + assert!(validate_proxy_config(&config).is_ok()); assert!(create_proxy_from_config(&config).is_ok()); } @@ -650,61 +650,30 @@ mod tests { fn test_proxy_config_ssl_defaults() { // Test with no SSL settings (should use None defaults) let config = create_test_proxy_config("https://proxy.example.com:8080"); - + assert_eq!(config.ignore_ssl, None); assert_eq!(config.verify_proxy_ssl, None); assert_eq!(config.verify_proxy_host_ssl, None); assert_eq!(config.verify_peer_ssl, None); assert_eq!(config.verify_host_ssl, None); - + assert!(validate_proxy_config(&config).is_ok()); assert!(create_proxy_from_config(&config).is_ok()); } - #[test] - fn test_proxy_config_serialization() { - // Test that proxy config with SSL settings can be serialized/deserialized - let mut config = create_test_proxy_config("https://proxy.example.com:8080"); - config.username = Some("user".to_string()); - config.password = Some("pass".to_string()); - config.ignore_ssl = Some(true); - config.verify_proxy_ssl = Some(false); - config.verify_proxy_host_ssl = Some(false); - config.verify_peer_ssl = Some(true); - config.verify_host_ssl = Some(true); - config.no_proxy = Some(vec!["localhost".to_string(), "*.example.com".to_string()]); - - // Serialize to JSON - let json = serde_json::to_string(&config).unwrap(); - - // Deserialize from JSON - let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap(); - - // Verify all fields are preserved - assert_eq!(deserialized.url, config.url); - assert_eq!(deserialized.username, config.username); - assert_eq!(deserialized.password, config.password); - assert_eq!(deserialized.ignore_ssl, config.ignore_ssl); - assert_eq!(deserialized.verify_proxy_ssl, config.verify_proxy_ssl); - assert_eq!(deserialized.verify_proxy_host_ssl, config.verify_proxy_host_ssl); - assert_eq!(deserialized.verify_peer_ssl, config.verify_peer_ssl); - assert_eq!(deserialized.verify_host_ssl, config.verify_host_ssl); - assert_eq!(deserialized.no_proxy, config.no_proxy); - } - #[test] fn test_download_item_with_ssl_proxy() { // Test that DownloadItem can be created with SSL proxy configuration let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080"); proxy_config.ignore_ssl = Some(true); proxy_config.verify_proxy_ssl = Some(false); - + let download_item = DownloadItem { url: "https://example.com/file.zip".to_string(), save_path: "downloads/file.zip".to_string(), proxy: Some(proxy_config), }; - + assert!(download_item.proxy.is_some()); let proxy = download_item.proxy.unwrap(); assert_eq!(proxy.ignore_ssl, Some(true)); @@ -716,16 +685,16 @@ mod tests { // Test client creation with SSL settings let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080"); proxy_config.ignore_ssl = Some(true); - + let download_item = DownloadItem { url: "https://example.com/file.zip".to_string(), save_path: "downloads/file.zip".to_string(), proxy: Some(proxy_config), }; - + let header_map = HeaderMap::new(); let result = _get_client_for_item(&download_item, &header_map); - + // Should create client successfully even with SSL settings assert!(result.is_ok()); } @@ -736,7 +705,7 @@ mod tests { let mut config = create_test_proxy_config("http://proxy.example.com:8080"); config.ignore_ssl = Some(true); config.verify_proxy_ssl = Some(false); - + assert!(validate_proxy_config(&config).is_ok()); assert!(create_proxy_from_config(&config).is_ok()); } @@ -748,8 +717,98 @@ mod tests { config.ignore_ssl = Some(false); config.verify_peer_ssl = Some(true); config.verify_host_ssl = Some(true); - + assert!(validate_proxy_config(&config).is_ok()); assert!(create_proxy_from_config(&config).is_ok()); } + + #[test] + fn test_download_item_creation() { + let item = DownloadItem { + url: "https://example.com/file.tar.gz".to_string(), + save_path: "models/test.tar.gz".to_string(), + proxy: None, + }; + + assert_eq!(item.url, "https://example.com/file.tar.gz"); + assert_eq!(item.save_path, "models/test.tar.gz"); + } + + #[test] + fn test_download_event_creation() { + let event = DownloadEvent { + transferred: 1024, + total: 2048, + }; + + assert_eq!(event.transferred, 1024); + assert_eq!(event.total, 2048); + } + + #[test] + fn test_err_to_string() { + let error = "Test error"; + let result = err_to_string(error); + assert_eq!(result, "Error: Test error"); + } + + #[test] + fn test_convert_headers_valid() { + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + headers.insert("Authorization".to_string(), "Bearer token123".to_string()); + + let result = _convert_headers(&headers); + assert!(result.is_ok()); + + let header_map = result.unwrap(); + assert_eq!(header_map.len(), 2); + assert_eq!(header_map.get("Content-Type").unwrap(), "application/json"); + assert_eq!(header_map.get("Authorization").unwrap(), "Bearer token123"); + } + + #[test] + fn test_convert_headers_invalid_header_name() { + let mut headers = HashMap::new(); + headers.insert("Invalid\nHeader".to_string(), "value".to_string()); + + let result = _convert_headers(&headers); + assert!(result.is_err()); + } + + #[test] + fn test_convert_headers_invalid_header_value() { + let mut headers = HashMap::new(); + headers.insert("Content-Type".to_string(), "invalid\nvalue".to_string()); + + let result = _convert_headers(&headers); + assert!(result.is_err()); + } + + #[test] + fn test_download_manager_state_default() { + let state = DownloadManagerState::default(); + assert!(state.cancel_tokens.is_empty()); + } + + #[test] + fn test_download_event_serialization() { + let event = DownloadEvent { + transferred: 512, + total: 1024, + }; + + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("\"transferred\":512")); + assert!(json.contains("\"total\":1024")); + } + + #[test] + fn test_download_item_deserialization() { + let json = r#"{"url":"https://example.com/file.zip","save_path":"downloads/file.zip"}"#; + let item: DownloadItem = serde_json::from_str(json).unwrap(); + + assert_eq!(item.url, "https://example.com/file.zip"); + assert_eq!(item.save_path, "downloads/file.zip"); + } } diff --git a/web-app/src/containers/ErrorMessage.tsx b/web-app/src/containers/ErrorMessage.tsx new file mode 100644 index 000000000..02f906088 --- /dev/null +++ b/web-app/src/containers/ErrorMessage.tsx @@ -0,0 +1,96 @@ +import { useRef, useState } from 'react' + +import { ThreadMessage } from '@janhq/core' + +import { CheckIcon, ClipboardIcon, SearchCodeIcon } from 'lucide-react' + +const ErrorMessage = ({ message }: { message?: ThreadMessage }) => { + // const setModalTroubleShooting = useSetAtom(modalTroubleShootingAtom) + const errorDivRef = useRef(null) + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + if (errorDivRef.current) { + const errorText = errorDivRef.current.innerText + if (errorText) { + navigator.clipboard.writeText(errorText) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + } + + const getErrorTitle = () => { + return ( +

+ <> + {message?.content[0]?.text?.value && ( + {message?.content[0]?.text?.value} + )} + {!message?.content[0]?.text?.value && ( + Something went wrong. Please try again. + )} + +

+ ) + } + + return ( +
+
+
+
+ + Error +
+
+
+ setModalTroubleShooting(true)} + > + + Troubleshooting + + {/* */} +
+
+ {copied ? ( + <> + + Copied + + ) : ( + <> + + Copy + + )} +
+
+
+
+
+ {getErrorTitle()} +
+
+
+
+ ) +} +export default ErrorMessage diff --git a/web-app/src/containers/dialogs/LoadModelErrorDialog.tsx b/web-app/src/containers/dialogs/LoadModelErrorDialog.tsx new file mode 100644 index 000000000..7d1c98699 --- /dev/null +++ b/web-app/src/containers/dialogs/LoadModelErrorDialog.tsx @@ -0,0 +1,80 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { AlertTriangle } from 'lucide-react' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { useModelLoad } from '@/hooks/useModelLoad' +import { toast } from 'sonner' + +export default function LoadModelErrorDialog() { + const { t } = useTranslation() + const { modelLoadError, setModelLoadError } = useModelLoad() + + const handleCopy = () => { + navigator.clipboard.writeText(modelLoadError ?? '') + toast.success('Copy successful', { + id: 'copy-model', + description: 'Model load error information copied to clipboard', + }) + } + + const handleDialogOpen = (open: boolean) => { + setModelLoadError(open ? modelLoadError : undefined) + } + + return ( + + + +
+
+ +
+
+ {t('common:error')} + + Failed to load model + +
+
+
+ +
+

{ + if (el) { + el.scrollTop = el.scrollHeight + } + }} + > + {modelLoadError} +

+
+ + + + + +
+
+ ) +} diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index 1f2eb5a48..e9e190a5c 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -19,7 +19,6 @@ import { import { CompletionMessagesBuilder } from '@/lib/messages' import { ChatCompletionMessageToolCall } from 'openai/resources' import { useAssistant } from './useAssistant' -import { toast } from 'sonner' import { getTools } from '@/services/mcp' import { MCPTool } from '@/types/completion' import { listen } from '@tauri-apps/api/event' @@ -31,6 +30,7 @@ import { useToolAvailable } from '@/hooks/useToolAvailable' import { OUT_OF_CONTEXT_SIZE } from '@/utils/error' import { updateSettings } from '@/services/providers' import { useContextSizeApproval } from './useModelContextApproval' +import { useModelLoad } from './useModelLoad' export const useChat = () => { const { prompt, setPrompt } = usePrompt() @@ -61,6 +61,7 @@ export const useChat = () => { updateThreadTimestamp, } = useThreads() const { getMessages, addMessage } = useMessages() + const { setModelLoadError } = useModelLoad() const router = useRouter() const provider = useMemo(() => { @@ -232,9 +233,7 @@ export const useChat = () => { try { if (selectedModel?.id) { updateLoadingModel(true) - await startModel(activeProvider, selectedModel.id).catch( - console.error - ) + await startModel(activeProvider, selectedModel.id) updateLoadingModel(false) } @@ -368,7 +367,7 @@ export const useChat = () => { activeThread.model?.id && provider?.provider === 'llamacpp' ) { - await stopModel(activeThread.model.id, 'cortex') + await stopModel(activeThread.model.id, 'llamacpp') throw new Error('No response received from the model') } @@ -404,9 +403,7 @@ export const useChat = () => { error && typeof error === 'object' && 'message' in error ? error.message : error - - toast.error(`Error sending message: ${errorMessage}`) - console.error('Error sending message:', error) + setModelLoadError(`${errorMessage}`) } finally { updateLoadingModel(false) updateStreamingContent(undefined) @@ -436,6 +433,7 @@ export const useChat = () => { showIncreaseContextSizeModal, increaseModelContextSize, toggleOnContextShifting, + setModelLoadError, ] ) diff --git a/web-app/src/hooks/useModelLoad.ts b/web-app/src/hooks/useModelLoad.ts new file mode 100644 index 000000000..c4b57a3a8 --- /dev/null +++ b/web-app/src/hooks/useModelLoad.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand' + +type ModelLoadState = { + modelLoadError?: string + setModelLoadError: (error: string | undefined) => void +} + +export const useModelLoad = create()((set) => ({ + modelLoadError: undefined, + setModelLoadError: (error) => set({ modelLoadError: error }), +})) diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index d6383f394..7713cddda 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -20,6 +20,7 @@ import { cn } from '@/lib/utils' import ToolApproval from '@/containers/dialogs/ToolApproval' import { TranslationProvider } from '@/i18n/TranslationContext' import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog' +import LoadModelErrorDialog from '@/containers/dialogs/LoadModelErrorDialog' export const Route = createRootRoute({ component: RootLayout, @@ -97,6 +98,7 @@ function RootLayout() { {/* */} +