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:
parent
bcb60378c0
commit
8d84c3b884
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
96
web-app/src/containers/ErrorMessage.tsx
Normal file
96
web-app/src/containers/ErrorMessage.tsx
Normal 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
|
||||
80
web-app/src/containers/dialogs/LoadModelErrorDialog.tsx
Normal file
80
web-app/src/containers/dialogs/LoadModelErrorDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
11
web-app/src/hooks/useModelLoad.ts
Normal file
11
web-app/src/hooks/useModelLoad.ts
Normal 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 }),
|
||||
}))
|
||||
@ -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() {
|
||||
{/* <TanStackRouterDevtools position="bottom-right" /> */}
|
||||
<CortexFailureDialog />
|
||||
<ToolApproval />
|
||||
<LoadModelErrorDialog />
|
||||
<OutOfContextPromiseModal />
|
||||
</TranslationProvider>
|
||||
</Fragment>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user