feat: add model load error handling to improve UX (#5802)

* feat: model load error handling

* chore: clean up

* test: add tests

* fix: provider name
This commit is contained in:
Louis 2025-07-18 09:55:54 +07:00 committed by GitHub
parent bcb60378c0
commit 8d84c3b884
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 300 additions and 54 deletions

View File

@ -82,7 +82,7 @@ fn validate_proxy_config(config: &ProxyConfig) -> Result<(), String> {
} }
// SSL verification settings are all optional booleans, no validation needed // SSL verification settings are all optional booleans, no validation needed
Ok(()) Ok(())
} }
@ -155,7 +155,7 @@ fn _get_client_for_item(
// Note: reqwest doesn't have fine-grained SSL verification controls // Note: reqwest doesn't have fine-grained SSL verification controls
// for verify_proxy_ssl, verify_proxy_host_ssl, verify_peer_ssl, verify_host_ssl // for verify_proxy_ssl, verify_proxy_host_ssl, verify_peer_ssl, verify_host_ssl
// These settings are handled by the underlying TLS implementation // These settings are handled by the underlying TLS implementation
// Check if this URL should bypass proxy // Check if this URL should bypass proxy
let no_proxy = proxy_config.no_proxy.as_deref().unwrap_or(&[]); let no_proxy = proxy_config.no_proxy.as_deref().unwrap_or(&[]);
if !should_bypass_proxy(&item.url, no_proxy) { if !should_bypass_proxy(&item.url, no_proxy) {
@ -617,17 +617,17 @@ mod tests {
config.verify_proxy_host_ssl = Some(false); config.verify_proxy_host_ssl = Some(false);
config.verify_peer_ssl = Some(true); config.verify_peer_ssl = Some(true);
config.verify_host_ssl = Some(true); config.verify_host_ssl = Some(true);
// Should validate successfully // Should validate successfully
assert!(validate_proxy_config(&config).is_ok()); assert!(validate_proxy_config(&config).is_ok());
// Test with all SSL settings as false // Test with all SSL settings as false
config.ignore_ssl = Some(false); config.ignore_ssl = Some(false);
config.verify_proxy_ssl = Some(false); config.verify_proxy_ssl = Some(false);
config.verify_proxy_host_ssl = Some(false); config.verify_proxy_host_ssl = Some(false);
config.verify_peer_ssl = Some(false); config.verify_peer_ssl = Some(false);
config.verify_host_ssl = Some(false); config.verify_host_ssl = Some(false);
// Should still validate successfully // Should still validate successfully
assert!(validate_proxy_config(&config).is_ok()); assert!(validate_proxy_config(&config).is_ok());
} }
@ -641,7 +641,7 @@ mod tests {
config.verify_proxy_host_ssl = Some(true); config.verify_proxy_host_ssl = Some(true);
config.verify_peer_ssl = Some(false); config.verify_peer_ssl = Some(false);
config.verify_host_ssl = Some(true); config.verify_host_ssl = Some(true);
assert!(validate_proxy_config(&config).is_ok()); assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_config(&config).is_ok()); assert!(create_proxy_from_config(&config).is_ok());
} }
@ -650,61 +650,30 @@ mod tests {
fn test_proxy_config_ssl_defaults() { fn test_proxy_config_ssl_defaults() {
// Test with no SSL settings (should use None defaults) // Test with no SSL settings (should use None defaults)
let config = create_test_proxy_config("https://proxy.example.com:8080"); let config = create_test_proxy_config("https://proxy.example.com:8080");
assert_eq!(config.ignore_ssl, None); assert_eq!(config.ignore_ssl, None);
assert_eq!(config.verify_proxy_ssl, None); assert_eq!(config.verify_proxy_ssl, None);
assert_eq!(config.verify_proxy_host_ssl, None); assert_eq!(config.verify_proxy_host_ssl, None);
assert_eq!(config.verify_peer_ssl, None); assert_eq!(config.verify_peer_ssl, None);
assert_eq!(config.verify_host_ssl, None); assert_eq!(config.verify_host_ssl, None);
assert!(validate_proxy_config(&config).is_ok()); assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_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] #[test]
fn test_download_item_with_ssl_proxy() { fn test_download_item_with_ssl_proxy() {
// Test that DownloadItem can be created with SSL proxy configuration // Test that DownloadItem can be created with SSL proxy configuration
let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080"); let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080");
proxy_config.ignore_ssl = Some(true); proxy_config.ignore_ssl = Some(true);
proxy_config.verify_proxy_ssl = Some(false); proxy_config.verify_proxy_ssl = Some(false);
let download_item = DownloadItem { let download_item = DownloadItem {
url: "https://example.com/file.zip".to_string(), url: "https://example.com/file.zip".to_string(),
save_path: "downloads/file.zip".to_string(), save_path: "downloads/file.zip".to_string(),
proxy: Some(proxy_config), proxy: Some(proxy_config),
}; };
assert!(download_item.proxy.is_some()); assert!(download_item.proxy.is_some());
let proxy = download_item.proxy.unwrap(); let proxy = download_item.proxy.unwrap();
assert_eq!(proxy.ignore_ssl, Some(true)); assert_eq!(proxy.ignore_ssl, Some(true));
@ -716,16 +685,16 @@ mod tests {
// Test client creation with SSL settings // Test client creation with SSL settings
let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080"); let mut proxy_config = create_test_proxy_config("https://proxy.example.com:8080");
proxy_config.ignore_ssl = Some(true); proxy_config.ignore_ssl = Some(true);
let download_item = DownloadItem { let download_item = DownloadItem {
url: "https://example.com/file.zip".to_string(), url: "https://example.com/file.zip".to_string(),
save_path: "downloads/file.zip".to_string(), save_path: "downloads/file.zip".to_string(),
proxy: Some(proxy_config), proxy: Some(proxy_config),
}; };
let header_map = HeaderMap::new(); let header_map = HeaderMap::new();
let result = _get_client_for_item(&download_item, &header_map); let result = _get_client_for_item(&download_item, &header_map);
// Should create client successfully even with SSL settings // Should create client successfully even with SSL settings
assert!(result.is_ok()); assert!(result.is_ok());
} }
@ -736,7 +705,7 @@ mod tests {
let mut config = create_test_proxy_config("http://proxy.example.com:8080"); let mut config = create_test_proxy_config("http://proxy.example.com:8080");
config.ignore_ssl = Some(true); config.ignore_ssl = Some(true);
config.verify_proxy_ssl = Some(false); config.verify_proxy_ssl = Some(false);
assert!(validate_proxy_config(&config).is_ok()); assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_config(&config).is_ok()); assert!(create_proxy_from_config(&config).is_ok());
} }
@ -748,8 +717,98 @@ mod tests {
config.ignore_ssl = Some(false); config.ignore_ssl = Some(false);
config.verify_peer_ssl = Some(true); config.verify_peer_ssl = Some(true);
config.verify_host_ssl = Some(true); config.verify_host_ssl = Some(true);
assert!(validate_proxy_config(&config).is_ok()); assert!(validate_proxy_config(&config).is_ok());
assert!(create_proxy_from_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");
}
} }

View File

@ -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<HTMLDivElement>(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 (
<p
data-testid="passthrough-error-message"
className="first-letter:uppercase"
>
<>
{message?.content[0]?.text?.value && (
<span>{message?.content[0]?.text?.value}</span>
)}
{!message?.content[0]?.text?.value && (
<span>Something went wrong. Please try again.</span>
)}
</>
</p>
)
}
return (
<div className="mx-auto my-6 max-w-[700px] px-4">
<div
className="mx-auto max-w-[400px] rounded-lg border border-[hsla(var(--app-border))]"
key={message?.id}
>
<div className="flex justify-between border-b border-inherit px-4 py-2">
<h6 className="flex items-center gap-x-1 font-semibold text-[hsla(var(--destructive-bg))]">
<span className="h-2 w-2 rounded-full bg-[hsla(var(--destructive-bg))]" />
<span>Error</span>
</h6>
<div className="flex items-center gap-x-4 text-xs">
<div className="font-semibold">
<span
className="flex cursor-pointer items-center gap-x-1 text-[hsla(var(--app-link))]"
// onClick={() => setModalTroubleShooting(true)}
>
<SearchCodeIcon size={14} className="text-inherit" />
Troubleshooting
</span>
{/* <ModalTroubleShooting /> */}
</div>
<div
className="flex cursor-pointer items-center gap-x-1 font-semibold text-[hsla(var(--text-secondary))]"
onClick={handleCopy}
>
{copied ? (
<>
<CheckIcon
size={14}
className="text-[hsla(var(--success-bg))]"
/>
Copied
</>
) : (
<>
<ClipboardIcon size={14} className="text-inherit" />
Copy
</>
)}
</div>
</div>
</div>
<div className="max-h-[80px] w-full overflow-x-auto p-4 py-2">
<div
className="font-serif text-xs leading-relaxed text-[hsla(var(--text-secondary))]"
ref={errorDivRef}
>
{getErrorTitle()}
</div>
</div>
</div>
</div>
)
}
export default ErrorMessage

View File

@ -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 (
<Dialog open={!!modelLoadError} onOpenChange={handleDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<div className="flex items-start gap-3">
<div className="shrink-0">
<AlertTriangle className="size-4" />
</div>
<div>
<DialogTitle>{t('common:error')}</DialogTitle>
<DialogDescription className="mt-1 text-main-view-fg/70">
Failed to load model
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="bg-main-view-fg/8 p-2 border border-main-view-fg/5 rounded-lg">
<p
className="text-sm text-main-view-fg/70 leading-relaxed max-h-[200px] overflow-y-auto"
ref={(el) => {
if (el) {
el.scrollTop = el.scrollHeight
}
}}
>
{modelLoadError}
</p>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-right">
<Button
variant="link"
onClick={() => handleCopy()}
className="flex-1 text-right sm:flex-none"
>
{t('common:copy')}
</Button>
<Button
variant="link"
onClick={() => handleDialogOpen(false)}
className="flex-1 text-right sm:flex-none"
>
{t('common:cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -19,7 +19,6 @@ import {
import { CompletionMessagesBuilder } from '@/lib/messages' import { CompletionMessagesBuilder } from '@/lib/messages'
import { ChatCompletionMessageToolCall } from 'openai/resources' import { ChatCompletionMessageToolCall } from 'openai/resources'
import { useAssistant } from './useAssistant' import { useAssistant } from './useAssistant'
import { toast } from 'sonner'
import { getTools } from '@/services/mcp' import { getTools } from '@/services/mcp'
import { MCPTool } from '@/types/completion' import { MCPTool } from '@/types/completion'
import { listen } from '@tauri-apps/api/event' 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 { OUT_OF_CONTEXT_SIZE } from '@/utils/error'
import { updateSettings } from '@/services/providers' import { updateSettings } from '@/services/providers'
import { useContextSizeApproval } from './useModelContextApproval' import { useContextSizeApproval } from './useModelContextApproval'
import { useModelLoad } from './useModelLoad'
export const useChat = () => { export const useChat = () => {
const { prompt, setPrompt } = usePrompt() const { prompt, setPrompt } = usePrompt()
@ -61,6 +61,7 @@ export const useChat = () => {
updateThreadTimestamp, updateThreadTimestamp,
} = useThreads() } = useThreads()
const { getMessages, addMessage } = useMessages() const { getMessages, addMessage } = useMessages()
const { setModelLoadError } = useModelLoad()
const router = useRouter() const router = useRouter()
const provider = useMemo(() => { const provider = useMemo(() => {
@ -232,9 +233,7 @@ export const useChat = () => {
try { try {
if (selectedModel?.id) { if (selectedModel?.id) {
updateLoadingModel(true) updateLoadingModel(true)
await startModel(activeProvider, selectedModel.id).catch( await startModel(activeProvider, selectedModel.id)
console.error
)
updateLoadingModel(false) updateLoadingModel(false)
} }
@ -368,7 +367,7 @@ export const useChat = () => {
activeThread.model?.id && activeThread.model?.id &&
provider?.provider === 'llamacpp' provider?.provider === 'llamacpp'
) { ) {
await stopModel(activeThread.model.id, 'cortex') await stopModel(activeThread.model.id, 'llamacpp')
throw new Error('No response received from the model') throw new Error('No response received from the model')
} }
@ -404,9 +403,7 @@ export const useChat = () => {
error && typeof error === 'object' && 'message' in error error && typeof error === 'object' && 'message' in error
? error.message ? error.message
: error : error
setModelLoadError(`${errorMessage}`)
toast.error(`Error sending message: ${errorMessage}`)
console.error('Error sending message:', error)
} finally { } finally {
updateLoadingModel(false) updateLoadingModel(false)
updateStreamingContent(undefined) updateStreamingContent(undefined)
@ -436,6 +433,7 @@ export const useChat = () => {
showIncreaseContextSizeModal, showIncreaseContextSizeModal,
increaseModelContextSize, increaseModelContextSize,
toggleOnContextShifting, toggleOnContextShifting,
setModelLoadError,
] ]
) )

View File

@ -0,0 +1,11 @@
import { create } from 'zustand'
type ModelLoadState = {
modelLoadError?: string
setModelLoadError: (error: string | undefined) => void
}
export const useModelLoad = create<ModelLoadState>()((set) => ({
modelLoadError: undefined,
setModelLoadError: (error) => set({ modelLoadError: error }),
}))

View File

@ -20,6 +20,7 @@ import { cn } from '@/lib/utils'
import ToolApproval from '@/containers/dialogs/ToolApproval' import ToolApproval from '@/containers/dialogs/ToolApproval'
import { TranslationProvider } from '@/i18n/TranslationContext' import { TranslationProvider } from '@/i18n/TranslationContext'
import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog' import OutOfContextPromiseModal from '@/containers/dialogs/OutOfContextDialog'
import LoadModelErrorDialog from '@/containers/dialogs/LoadModelErrorDialog'
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
@ -97,6 +98,7 @@ function RootLayout() {
{/* <TanStackRouterDevtools position="bottom-right" /> */} {/* <TanStackRouterDevtools position="bottom-right" /> */}
<CortexFailureDialog /> <CortexFailureDialog />
<ToolApproval /> <ToolApproval />
<LoadModelErrorDialog />
<OutOfContextPromiseModal /> <OutOfContextPromiseModal />
</TranslationProvider> </TranslationProvider>
</Fragment> </Fragment>