mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-04 05:07:30 +00:00
Improve Assistant failed-turn recovery and tool output
This commit is contained in:
@@ -117,6 +117,11 @@ runtime cost control, and shared AI transport surfaces.
|
||||
structured mentions, finding id, approval override, handoff resources,
|
||||
handoff actions, and handoff metadata, but must not reconstruct scoped
|
||||
context from prompt history or saved transcript prose.
|
||||
Failed-turn recovery must also expose model-route switching through the
|
||||
existing drawer model selector so operators can move from a blocked direct
|
||||
provider route to a configured gateway or alternate model without losing the
|
||||
draft or creating a parallel picker. Retry remains available, but it must not
|
||||
be the only visible action when a failed Assistant turn is shown.
|
||||
Restored Assistant sessions must hydrate saved assistant content and
|
||||
persisted tool calls into the same transcript event shape used by live
|
||||
streams so switching sessions does not hide prior tool evidence or collapse
|
||||
@@ -143,8 +148,11 @@ runtime cost control, and shared AI transport surfaces.
|
||||
approval, or question blocks; if a provider emits `pulse_*` / `patrol_*`
|
||||
calls, DSML, XML/function-call envelopes, or JSON tool-call shapes as text
|
||||
content, the chat runtime must strip them before streaming, persistence, and
|
||||
frontend rendering. Token accounting and other provider metadata remain
|
||||
runtime/accounting data, not normal transcript prose.
|
||||
frontend rendering. Completed tool rows in the drawer may show compact tool
|
||||
name, action summary, status, and an explicit details affordance, but raw
|
||||
tool input/output JSON must not render in the default transcript. Token
|
||||
accounting and other provider metadata remain runtime/accounting data, not
|
||||
normal transcript prose.
|
||||
4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts`
|
||||
Provider preflight diagnostics returned from `internal/api/ai_handlers.go`
|
||||
must reuse the Patrol runtime failure classifier in `internal/ai/` and
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ChatMessagesProps {
|
||||
) => void;
|
||||
onSkipQuestion: (messageId: string, questionId: string) => void;
|
||||
onRetry?: (messageId: string) => void;
|
||||
onChangeModel?: () => void;
|
||||
// Dashboard props
|
||||
recentSessions?: ChatSession[];
|
||||
onLoadSession?: (sessionId: string) => void;
|
||||
@@ -146,6 +147,7 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
}
|
||||
onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)}
|
||||
onRetry={props.onRetry}
|
||||
onChangeModel={props.onChangeModel}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -3,6 +3,7 @@ import CheckIcon from 'lucide-solid/icons/check';
|
||||
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
|
||||
import ClockIcon from 'lucide-solid/icons/clock';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import CpuIcon from 'lucide-solid/icons/cpu';
|
||||
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
|
||||
import SparklesIcon from 'lucide-solid/icons/sparkles';
|
||||
import { renderMarkdown } from '../aiChatUtils';
|
||||
@@ -28,6 +29,7 @@ interface MessageItemProps {
|
||||
) => void;
|
||||
onSkipQuestion: (questionId: string) => void;
|
||||
onRetry?: (messageId: string) => void;
|
||||
onChangeModel?: () => void;
|
||||
}
|
||||
|
||||
const markdownClass =
|
||||
@@ -277,16 +279,28 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
<CircleAlertIcon class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{props.message.error}</p>
|
||||
<Show when={props.onRetry}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRetry?.(props.message.id)}
|
||||
class="mt-2 inline-flex items-center gap-1.5 rounded-md border border-red-300 dark:border-red-800 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 transition-colors hover:bg-red-100 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RotateCcwIcon class="h-3.5 w-3.5" />
|
||||
Try again
|
||||
</button>
|
||||
</Show>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<Show when={props.onChangeModel}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onChangeModel?.()}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<CpuIcon class="h-3.5 w-3.5" />
|
||||
Change model
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onRetry}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRetry?.(props.message.id)}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RotateCcwIcon class="h-3.5 w-3.5" />
|
||||
Try again
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Component, For, Show, createSignal, createMemo, onMount, onCleanup } from 'solid-js';
|
||||
import {
|
||||
Component,
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createSignal,
|
||||
createMemo,
|
||||
onMount,
|
||||
onCleanup,
|
||||
} from 'solid-js';
|
||||
import { getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils';
|
||||
import { SearchField } from '@/components/shared/SearchField';
|
||||
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
|
||||
import {
|
||||
formatAIModelRouteLabel,
|
||||
getAIProviderDisplayName,
|
||||
} from '@/utils/aiProviderPresentation';
|
||||
import { AI_CHAT_MODEL_SELECTOR_EMPTY_STATE } from '@/utils/aiChatPresentation';
|
||||
import type { ModelInfo } from './types';
|
||||
|
||||
@@ -13,6 +25,7 @@ export interface ModelSelectorProps {
|
||||
chatOverrideLabel?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
openRequest?: number;
|
||||
onModelSelect: (modelId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
@@ -27,6 +40,8 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [dropdownPosition, setDropdownPosition] = createSignal({ top: 0, right: 0 });
|
||||
let buttonRef: HTMLButtonElement | undefined;
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
let lastOpenRequest = props.openRequest || 0;
|
||||
|
||||
// Filter models by notable status (show only recent/notable models by default)
|
||||
const notableFilteredModels = createMemo(() => {
|
||||
@@ -81,10 +96,13 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isOpen()) {
|
||||
if (isOpen()) {
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
updateDropdownPosition();
|
||||
setIsOpen(true);
|
||||
queueMicrotask(() => searchInputRef?.focus());
|
||||
}
|
||||
setIsOpen(!isOpen());
|
||||
};
|
||||
|
||||
const handleSelect = (modelId: string) => {
|
||||
@@ -93,6 +111,18 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const request = props.openRequest || 0;
|
||||
if (request <= 0 || request === lastOpenRequest) return;
|
||||
lastOpenRequest = request;
|
||||
queueMicrotask(() => {
|
||||
updateDropdownPosition();
|
||||
setSearchQuery('');
|
||||
setIsOpen(true);
|
||||
queueMicrotask(() => searchInputRef?.focus());
|
||||
});
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
@@ -110,7 +140,7 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
return props.defaultModelLabel || 'Default';
|
||||
}
|
||||
const match = props.models.find((model) => model.id === selected);
|
||||
if (match) return match.name || match.id.split(':').pop() || match.id;
|
||||
if (match) return formatAIModelRouteLabel(match);
|
||||
return selected;
|
||||
});
|
||||
|
||||
@@ -203,6 +233,9 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
placeholder="Search or enter model ID"
|
||||
class="flex-1"
|
||||
inputClass="py-1.5 text-xs focus:ring-blue-400"
|
||||
inputRef={(el) => {
|
||||
searchInputRef = el;
|
||||
}}
|
||||
/>
|
||||
<Show when={props.onRefresh}>
|
||||
<button
|
||||
@@ -297,7 +330,7 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-base-content">
|
||||
{model.name || model.id.split(':').pop() || model.id}
|
||||
{formatAIModelRouteLabel(model)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={model.description}>
|
||||
|
||||
@@ -7,123 +7,111 @@ interface ToolExecutionBlockProps {
|
||||
tool: ToolExecution;
|
||||
}
|
||||
|
||||
const getToolLabel = (name: string) => {
|
||||
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
|
||||
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
|
||||
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state')
|
||||
return 'infra';
|
||||
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
|
||||
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
|
||||
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
|
||||
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
|
||||
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
|
||||
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
|
||||
if (name === 'pulse_get_storage_config') return 'storage cfg';
|
||||
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
|
||||
if (name.includes('finding')) return 'finding';
|
||||
return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 });
|
||||
};
|
||||
|
||||
const parseToolInputSummary = (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (typeof record.action === 'string' && record.action.trim()) {
|
||||
return formatIdentifierLabel(record.action, { maxLength: 28 });
|
||||
}
|
||||
if (typeof record.command === 'string' && record.command.trim()) {
|
||||
return formatIdentifierLabel(record.command, { maxLength: 28 });
|
||||
}
|
||||
return 'request';
|
||||
}
|
||||
} catch {
|
||||
return formatIdentifierLabel(trimmed, { maxLength: 28 });
|
||||
}
|
||||
|
||||
return 'request';
|
||||
};
|
||||
|
||||
const hasReadableToolOutput = (output: string) => {
|
||||
const trimmed = output.trim();
|
||||
return trimmed.length > 0 && !trimmed.toLowerCase().includes('not available');
|
||||
};
|
||||
|
||||
/**
|
||||
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
|
||||
*/
|
||||
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
|
||||
const [showOutput, setShowOutput] = createSignal(false); // Collapsed by default like Claude Code
|
||||
const [showDetails, setShowDetails] = createSignal(false);
|
||||
|
||||
// Get display name for tool
|
||||
const toolLabel = createMemo(() => {
|
||||
const name = props.tool.name;
|
||||
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
|
||||
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
|
||||
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state')
|
||||
return 'infra';
|
||||
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
|
||||
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
|
||||
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
|
||||
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
|
||||
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
|
||||
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
|
||||
if (name === 'pulse_get_storage_config') return 'storage cfg';
|
||||
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
|
||||
if (name.includes('finding')) return 'finding';
|
||||
return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 });
|
||||
});
|
||||
|
||||
// Check if output is non-empty and interesting
|
||||
const hasOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
return output.trim().length > 0 && !output.includes('not available');
|
||||
});
|
||||
|
||||
// Show only last few lines by default, full output when expanded
|
||||
const displayOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
if (showOutput()) {
|
||||
// Show full output when expanded
|
||||
return output;
|
||||
}
|
||||
// Show last 3 lines by default
|
||||
const lines = output.split('\n').filter((line) => line.trim());
|
||||
if (lines.length <= 3) {
|
||||
return output.trim();
|
||||
}
|
||||
const lastLines = lines.slice(-3).join('\n');
|
||||
return '...\n' + lastLines;
|
||||
});
|
||||
|
||||
const hasMoreOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
const lines = output.split('\n').filter((line) => line.trim());
|
||||
return lines.length > 3;
|
||||
});
|
||||
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
|
||||
const inputSummary = createMemo(() => parseToolInputSummary(props.tool.input || ''));
|
||||
const hasInput = createMemo(() => (props.tool.input || '').trim().length > 0);
|
||||
const hasOutput = createMemo(() => hasReadableToolOutput(props.tool.output || ''));
|
||||
const hasDetails = createMemo(() => hasInput() || hasOutput());
|
||||
|
||||
const statusIcon = () => (props.tool.success ? '✓' : '✗');
|
||||
const statusLabel = () => (props.tool.success ? 'completed' : 'failed');
|
||||
|
||||
return (
|
||||
<div class="my-1 font-mono text-[11px]">
|
||||
{/* Compact single-line header */}
|
||||
<div
|
||||
class={`flex items-center gap-1.5 px-2 py-1 rounded ${
|
||||
hasMoreOutput() ? 'cursor-pointer hover:bg-surface-hover' : ''
|
||||
} ${showOutput() ? 'bg-surface-alt' : ''}`}
|
||||
onClick={() => hasMoreOutput() && setShowOutput(!showOutput())}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div class="flex items-center gap-1.5 rounded px-2 py-1">
|
||||
<span class={`${getToolCallResultTextClass(props.tool.success)} font-bold`}>
|
||||
{statusIcon()}
|
||||
</span>
|
||||
|
||||
{/* Tool label */}
|
||||
<span class="text-muted uppercase text-[9px] font-medium tracking-wider min-w-[50px]">
|
||||
{toolLabel()}
|
||||
</span>
|
||||
|
||||
{/* Command/input - truncated */}
|
||||
<code class="text-base-content truncate flex-1">
|
||||
{(props.tool.input || '').length > 60
|
||||
? (props.tool.input || '').substring(0, 60) + '...'
|
||||
: props.tool.input || '{}'}
|
||||
</code>
|
||||
<span class="min-w-0 flex-1 truncate text-base-content">{inputSummary()}</span>
|
||||
<span class="text-[10px] text-muted">{statusLabel()}</span>
|
||||
|
||||
{/* Expand indicator if has more output */}
|
||||
<Show when={hasMoreOutput()}>
|
||||
<svg
|
||||
class={`w-3 h-3 text-slate-400 transition-transform ${showOutput() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<Show when={hasDetails()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setShowDetails(!showDetails());
|
||||
}}
|
||||
class="rounded px-1.5 py-0.5 text-[9px] font-medium text-muted hover:bg-surface-hover hover:text-base-content"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
{showDetails() ? 'Hide details' : 'Details'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Output - always show last few lines, expandable for full output */}
|
||||
<Show when={hasOutput()}>
|
||||
<Show when={showDetails() && hasDetails()}>
|
||||
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-border overflow-hidden">
|
||||
<pre
|
||||
class={`text-[10px] text-muted whitespace-pre-wrap break-all leading-relaxed overflow-y-auto overflow-x-hidden bg-surface-alt rounded p-2 ${showOutput() ? 'max-h-64' : 'max-h-20'}`}
|
||||
>
|
||||
{displayOutput()}
|
||||
</pre>
|
||||
<Show when={hasMoreOutput()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowOutput(!showOutput());
|
||||
}}
|
||||
class="mt-1 text-[9px] text-muted hover:text-base-content hover:underline"
|
||||
>
|
||||
{showOutput() ? 'Show less' : 'Show full output'}
|
||||
</button>
|
||||
<Show when={hasInput()}>
|
||||
<div class="mb-1 text-[9px] font-semibold uppercase tracking-wide text-muted">
|
||||
Input
|
||||
</div>
|
||||
<pre class="mb-2 max-h-32 overflow-y-auto overflow-x-hidden rounded bg-surface-alt p-2 text-[10px] leading-relaxed text-muted whitespace-pre-wrap break-all">
|
||||
{(props.tool.input || '').trim()}
|
||||
</pre>
|
||||
</Show>
|
||||
<Show when={hasOutput()}>
|
||||
<div class="mb-1 text-[9px] font-semibold uppercase tracking-wide text-muted">
|
||||
Output
|
||||
</div>
|
||||
<pre class="max-h-64 overflow-y-auto overflow-x-hidden rounded bg-surface-alt p-2 text-[10px] leading-relaxed text-muted whitespace-pre-wrap break-all">
|
||||
{(props.tool.output || '').trim()}
|
||||
</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -139,22 +127,8 @@ interface PendingToolBlockProps {
|
||||
}
|
||||
|
||||
export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
|
||||
const toolLabel = createMemo(() => {
|
||||
const name = props.tool.name;
|
||||
if (name === 'run_command' || name === 'pulse_run_command') return 'cmd';
|
||||
if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch';
|
||||
if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state')
|
||||
return 'infra';
|
||||
if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts';
|
||||
if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics';
|
||||
if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines';
|
||||
if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns';
|
||||
if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks';
|
||||
if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage';
|
||||
if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource';
|
||||
if (name.includes('finding')) return 'finding';
|
||||
return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 });
|
||||
});
|
||||
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
|
||||
const inputSummary = createMemo(() => parseToolInputSummary(props.tool.input || ''));
|
||||
|
||||
return (
|
||||
<div class="my-0.5 font-mono text-[11px] flex items-center gap-1.5 px-2 py-1 rounded bg-surface-alt border border-border">
|
||||
@@ -177,12 +151,7 @@ export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
|
||||
{toolLabel()}
|
||||
</span>
|
||||
|
||||
{/* Command - truncated */}
|
||||
<code class="text-base-content truncate flex-1">
|
||||
{props.tool.input.length > 50
|
||||
? props.tool.input.substring(0, 50) + '...'
|
||||
: props.tool.input}
|
||||
</code>
|
||||
<span class="text-base-content truncate flex-1">{inputSummary()}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,11 @@ const {
|
||||
mockByType,
|
||||
mockResources,
|
||||
mockWebSocketState,
|
||||
mockChatMessagesProps,
|
||||
} = vi.hoisted(() => {
|
||||
const mockChatMessagesProps: Array<{
|
||||
onChangeModel?: () => void;
|
||||
}> = [];
|
||||
const mockChat = {
|
||||
messages: vi.fn((): ChatMessage[] => []),
|
||||
isLoading: vi.fn(() => false),
|
||||
@@ -169,6 +173,7 @@ const {
|
||||
mockByType,
|
||||
mockResources,
|
||||
mockWebSocketState,
|
||||
mockChatMessagesProps,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -182,24 +187,36 @@ vi.mock('../ChatMessages', () => ({
|
||||
ChatMessages: (props: {
|
||||
messages: ChatMessage[];
|
||||
emptyState?: { title: string; subtitle?: string };
|
||||
}) => (
|
||||
<div data-testid="chat-messages" data-msg-count={props.messages.length}>
|
||||
{props.emptyState?.title && (
|
||||
<span data-testid="empty-state-title">{props.emptyState.title}</span>
|
||||
)}
|
||||
{props.emptyState?.subtitle && (
|
||||
<span data-testid="empty-state-subtitle">{props.emptyState.subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
onChangeModel?: () => void;
|
||||
}) => {
|
||||
mockChatMessagesProps.push(props);
|
||||
return (
|
||||
<div data-testid="chat-messages" data-msg-count={props.messages.length}>
|
||||
{props.emptyState?.title && (
|
||||
<span data-testid="empty-state-title">{props.emptyState.title}</span>
|
||||
)}
|
||||
{props.emptyState?.subtitle && (
|
||||
<span data-testid="empty-state-subtitle">{props.emptyState.subtitle}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-change-model"
|
||||
onClick={() => props.onChangeModel?.()}
|
||||
>
|
||||
Change model
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: (props: { selectedModel: string; models: ModelInfo[] }) => (
|
||||
ModelSelector: (props: { selectedModel: string; models: ModelInfo[]; openRequest?: number }) => (
|
||||
<div
|
||||
data-testid="model-selector"
|
||||
data-selected={props.selectedModel}
|
||||
data-count={props.models.length}
|
||||
data-open-request={String(props.openRequest || 0)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -305,6 +322,7 @@ async function waitForProviderCheckSettled() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockChatMessagesProps.length = 0;
|
||||
setViewportWidth(1440);
|
||||
resetAIRuntimeState();
|
||||
mockAiChatStore.isOpenSignal.mockReturnValue(true);
|
||||
@@ -382,6 +400,18 @@ describe('AIChat', () => {
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the model selector from failed-turn recovery', async () => {
|
||||
renderChat();
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '0');
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-change-model'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '1');
|
||||
});
|
||||
});
|
||||
|
||||
it('checks the selected provider and shows a readiness issue before the first send', async () => {
|
||||
mockAIAPI.getSettings.mockResolvedValue({
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
|
||||
@@ -13,6 +13,7 @@ let capturedMessageItemProps: Array<{
|
||||
answers: Array<{ id: string; value: string }>,
|
||||
) => void;
|
||||
onSkipQuestion: (questionId: string) => void;
|
||||
onChangeModel?: () => void;
|
||||
}> = [];
|
||||
|
||||
vi.mock('../MessageItem', () => ({
|
||||
@@ -25,6 +26,7 @@ vi.mock('../MessageItem', () => ({
|
||||
answers: Array<{ id: string; value: string }>,
|
||||
) => void;
|
||||
onSkipQuestion: (questionId: string) => void;
|
||||
onChangeModel?: () => void;
|
||||
}) => {
|
||||
capturedMessageItemProps.push(props);
|
||||
return (
|
||||
@@ -316,6 +318,20 @@ describe('ChatMessages', () => {
|
||||
expect(handlers.onSkipQuestion).toHaveBeenCalledWith('msg-5', 'q-77');
|
||||
});
|
||||
|
||||
it('forwards onChangeModel unchanged for failed-turn recovery', () => {
|
||||
const onChangeModel = vi.fn();
|
||||
render(() => (
|
||||
<ChatMessages
|
||||
messages={[makeMessage({ id: 'msg-model', role: 'assistant', error: 'failed' })]}
|
||||
{...makeHandlers()}
|
||||
onChangeModel={onChangeModel}
|
||||
/>
|
||||
));
|
||||
|
||||
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-model');
|
||||
expect(propsForMsg?.onChangeModel).toBe(onChangeModel);
|
||||
});
|
||||
|
||||
it('forwards correct message.id when multiple messages exist', () => {
|
||||
const handlers = makeHandlers();
|
||||
render(() => (
|
||||
|
||||
@@ -146,6 +146,7 @@ describe('MessageItem', () => {
|
||||
describe('error block', () => {
|
||||
it('renders a distinct error block with the message and a retry button', () => {
|
||||
const onRetry = vi.fn();
|
||||
const onChangeModel = vi.fn();
|
||||
render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({
|
||||
@@ -155,6 +156,7 @@ describe('MessageItem', () => {
|
||||
})}
|
||||
{...makeHandlers()}
|
||||
onRetry={onRetry}
|
||||
onChangeModel={onChangeModel}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -162,6 +164,10 @@ describe('MessageItem', () => {
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(alert.textContent).toContain('billing or quota reasons');
|
||||
|
||||
const changeModel = screen.getByRole('button', { name: /change model/i });
|
||||
fireEvent.click(changeModel);
|
||||
expect(onChangeModel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const retry = screen.getByRole('button', { name: /try again/i });
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledWith('msg-1');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { ModelSelector } from '../ModelSelector';
|
||||
import type { ModelInfo } from '../types';
|
||||
|
||||
@@ -53,6 +54,26 @@ describe('ModelSelector', () => {
|
||||
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps gateway-routed selected model labels distinct', () => {
|
||||
render(() => (
|
||||
<ModelSelector
|
||||
models={[
|
||||
...SAMPLE_MODELS,
|
||||
makeModel({
|
||||
id: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
name: 'DeepSeek: DeepSeek V4 Pro',
|
||||
provider: 'openrouter',
|
||||
notable: true,
|
||||
}),
|
||||
]}
|
||||
selectedModel="openrouter:deepseek/deepseek-v4-pro"
|
||||
onModelSelect={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Default" when no model is selected', () => {
|
||||
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
|
||||
|
||||
@@ -114,6 +135,32 @@ describe('ModelSelector', () => {
|
||||
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dropdown and focuses search when an external recovery action requests it', async () => {
|
||||
const [openRequest, setOpenRequest] = createSignal(0);
|
||||
render(() => (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpenRequest((value) => value + 1)}>
|
||||
Recovery action
|
||||
</button>
|
||||
<ModelSelector
|
||||
models={SAMPLE_MODELS}
|
||||
selectedModel=""
|
||||
openRequest={openRequest()}
|
||||
onModelSelect={vi.fn()}
|
||||
/>
|
||||
</>
|
||||
));
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Recovery action' }));
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText('Search or enter model ID');
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dropdown when toggled again', () => {
|
||||
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -109,22 +109,34 @@ describe('ToolExecutionBlock', () => {
|
||||
expect(screen.getByText('ls -la')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates input longer than 60 chars', () => {
|
||||
it('truncates input summaries longer than 28 chars', () => {
|
||||
const longInput = 'A'.repeat(70);
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: longInput })} />);
|
||||
expect(screen.getByText('A'.repeat(60) + '...')).toBeInTheDocument();
|
||||
expect(screen.getByText('A'.repeat(28))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "{}" when input is empty', () => {
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: '' })} />);
|
||||
expect(screen.getByText('{}')).toBeInTheDocument();
|
||||
it('summarizes JSON action input without showing raw JSON by default', () => {
|
||||
render(() => (
|
||||
<ToolExecutionBlock
|
||||
tool={makeTool({
|
||||
name: 'query',
|
||||
input: '{"action":"topology","include":"all","summary_only":true}',
|
||||
output: '{"summary":{"total_nodes":3}}',
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('topology')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/summary_only/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/total_nodes/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Output display ---
|
||||
|
||||
it('shows output when non-empty', () => {
|
||||
it('does not show output by default when non-empty', () => {
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output: 'hello world' })} />);
|
||||
expect(screen.getByText('hello world')).toBeInTheDocument();
|
||||
expect(screen.queryByText('hello world')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides output that is only whitespace', () => {
|
||||
@@ -142,90 +154,42 @@ describe('ToolExecutionBlock', () => {
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows last 3 lines by default when output has more lines', () => {
|
||||
const output = 'line1\nline2\nline3\nline4\nline5';
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
const pre = screen.getByText(/line5/);
|
||||
expect(pre.textContent).toContain('...');
|
||||
expect(pre.textContent).toContain('line3');
|
||||
expect(pre.textContent).toContain('line4');
|
||||
expect(pre.textContent).toContain('line5');
|
||||
});
|
||||
|
||||
it('shows all lines when 3 or fewer non-empty lines', () => {
|
||||
const output = 'line1\nline2\nline3';
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
const pre = screen.getByText(/line1/);
|
||||
expect(pre.textContent).not.toContain('...');
|
||||
});
|
||||
|
||||
// --- Expand/collapse behavior ---
|
||||
|
||||
it('shows "Show full output" button when output has more than 3 lines', () => {
|
||||
it('shows a details button when raw input or output is available', () => {
|
||||
const output = 'l1\nl2\nl3\nl4\nl5';
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
expect(screen.getByText('Show full output')).toBeInTheDocument();
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show expand button when output has 3 or fewer lines', () => {
|
||||
const output = 'l1\nl2\nl3';
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
expect(screen.queryByText('Show full output')).toBeNull();
|
||||
it('does not show a details button when there are no details', () => {
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: '', output: '' })} />);
|
||||
expect(screen.queryByText('Details')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows full output when "Show full output" button is clicked', async () => {
|
||||
it('shows raw input and output when details are opened', async () => {
|
||||
const output = 'line1\nline2\nline3\nline4\nline5';
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
const btn = screen.getByText('Show full output');
|
||||
render(() => (
|
||||
<ToolExecutionBlock tool={makeTool({ input: '{"action":"list"}', output })} />
|
||||
));
|
||||
const btn = screen.getByText('Details');
|
||||
fireEvent.click(btn);
|
||||
// After expanding, the button should say "Show less"
|
||||
expect(screen.getByText('Show less')).toBeInTheDocument();
|
||||
// And full output should be visible
|
||||
const pre = screen.getByText(/line1/);
|
||||
expect(pre.textContent).toContain('line1');
|
||||
expect(pre.textContent).toContain('line5');
|
||||
|
||||
expect(screen.getByText('Hide details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Input')).toBeInTheDocument();
|
||||
expect(screen.getByText('Output')).toBeInTheDocument();
|
||||
expect(screen.getByText('{"action":"list"}')).toBeInTheDocument();
|
||||
expect(screen.getByText(/line1/).textContent).toContain('line5');
|
||||
});
|
||||
|
||||
it('collapses back when "Show less" is clicked', async () => {
|
||||
it('hides raw details when toggled closed', async () => {
|
||||
const output = 'line1\nline2\nline3\nline4\nline5';
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
fireEvent.click(screen.getByText('Show full output'));
|
||||
expect(screen.getByText('Show less')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Show less'));
|
||||
expect(screen.getByText('Show full output')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking header row toggles output when has more lines', async () => {
|
||||
const output = 'l1\nl2\nl3\nl4\nl5';
|
||||
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
// The header row has cursor-pointer class when collapsible
|
||||
const header = container.querySelector('.cursor-pointer');
|
||||
expect(header).not.toBeNull();
|
||||
fireEvent.click(header!);
|
||||
expect(screen.getByText('Show less')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('header row is not clickable when output has 3 or fewer lines', () => {
|
||||
const output = 'l1\nl2';
|
||||
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
expect(container.querySelector('.cursor-pointer')).toBeNull();
|
||||
});
|
||||
|
||||
// --- Expand chevron icon ---
|
||||
|
||||
it('renders expand chevron SVG when output is collapsible', () => {
|
||||
const output = 'l1\nl2\nl3\nl4';
|
||||
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rotates chevron when expanded', async () => {
|
||||
const output = 'l1\nl2\nl3\nl4';
|
||||
const { container } = render(() => <ToolExecutionBlock tool={makeTool({ output })} />);
|
||||
fireEvent.click(screen.getByText('Show full output'));
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg?.classList.contains('rotate-180')).toBe(true);
|
||||
fireEvent.click(screen.getByText('Details'));
|
||||
expect(screen.getByText('Hide details')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Hide details'));
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/line1/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Edge cases ---
|
||||
@@ -248,20 +212,19 @@ describe('ToolExecutionBlock', () => {
|
||||
// hasOutput returns false when output contains "not available"
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
// The expand button should also not appear since output section is hidden
|
||||
expect(screen.queryByText('Show full output')).toBeNull();
|
||||
expect(screen.getByText('Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not truncate input at exactly 60 chars', () => {
|
||||
const input60 = 'X'.repeat(60);
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: input60 })} />);
|
||||
// Exactly 60 chars should NOT be truncated (condition is > 60)
|
||||
expect(screen.getByText(input60)).toBeInTheDocument();
|
||||
it('does not truncate input summaries at exactly 28 chars', () => {
|
||||
const input28 = 'X'.repeat(28);
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: input28 })} />);
|
||||
expect(screen.getByText(input28)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates input at 61 chars', () => {
|
||||
const input61 = 'Y'.repeat(61);
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: input61 })} />);
|
||||
expect(screen.getByText('Y'.repeat(60) + '...')).toBeInTheDocument();
|
||||
it('truncates input summaries at 29 chars', () => {
|
||||
const input29 = 'Y'.repeat(29);
|
||||
render(() => <ToolExecutionBlock tool={makeTool({ input: input29 })} />);
|
||||
expect(screen.getByText('Y'.repeat(28))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,16 +258,9 @@ describe('PendingToolBlock', () => {
|
||||
expect(screen.getByText('custom op')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls through to fallback for pulse_get_storage_config (not mapped in pending)', () => {
|
||||
// PendingToolBlock does NOT have pulse_get_storage_config mapped unlike ToolExecutionBlock
|
||||
// Fallback: strip pulse_, replace underscores, truncate to 12 → "get storage "
|
||||
const { container } = render(() => (
|
||||
<PendingToolBlock tool={makePending({ name: 'pulse_get_storage_config' })} />
|
||||
));
|
||||
// Use container query since getByText trims trailing whitespace
|
||||
const label = container.querySelector('span.uppercase');
|
||||
expect(label).not.toBeNull();
|
||||
expect(label!.textContent).toBe('get storage ');
|
||||
it('maps pulse_get_storage_config consistently while pending', () => {
|
||||
render(() => <PendingToolBlock tool={makePending({ name: 'pulse_get_storage_config' })} />);
|
||||
expect(screen.getByText('storage cfg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Input display ---
|
||||
@@ -314,22 +270,33 @@ describe('PendingToolBlock', () => {
|
||||
expect(screen.getByText('df -h')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates input longer than 50 chars', () => {
|
||||
it('truncates input summaries longer than 28 chars', () => {
|
||||
const longInput = 'B'.repeat(55);
|
||||
render(() => <PendingToolBlock tool={makePending({ input: longInput })} />);
|
||||
expect(screen.getByText('B'.repeat(50) + '...')).toBeInTheDocument();
|
||||
expect(screen.getByText('B'.repeat(28))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not truncate input at exactly 50 chars', () => {
|
||||
const input50 = 'C'.repeat(50);
|
||||
render(() => <PendingToolBlock tool={makePending({ input: input50 })} />);
|
||||
expect(screen.getByText(input50)).toBeInTheDocument();
|
||||
it('does not truncate input summaries at exactly 28 chars', () => {
|
||||
const input28 = 'C'.repeat(28);
|
||||
render(() => <PendingToolBlock tool={makePending({ input: input28 })} />);
|
||||
expect(screen.getByText(input28)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates input at 51 chars', () => {
|
||||
const input51 = 'D'.repeat(51);
|
||||
render(() => <PendingToolBlock tool={makePending({ input: input51 })} />);
|
||||
expect(screen.getByText('D'.repeat(50) + '...')).toBeInTheDocument();
|
||||
it('truncates input summaries at 29 chars', () => {
|
||||
const input29 = 'D'.repeat(29);
|
||||
render(() => <PendingToolBlock tool={makePending({ input: input29 })} />);
|
||||
expect(screen.getByText('D'.repeat(28))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('summarizes JSON action input without showing raw JSON', () => {
|
||||
render(() => (
|
||||
<PendingToolBlock
|
||||
tool={makePending({ name: 'query', input: '{"action":"topology","include":"all"}' })}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('topology')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/include/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Spinner ---
|
||||
@@ -355,7 +322,7 @@ describe('PendingToolsList', () => {
|
||||
render(() => <PendingToolsList tools={tools} />);
|
||||
expect(screen.getByText('cmd1')).toBeInTheDocument();
|
||||
expect(screen.getByText('url1')).toBeInTheDocument();
|
||||
expect(screen.getByText('{}')).toBeInTheDocument();
|
||||
expect(screen.getByText('request')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses when more than 3 tools, showing first 2', () => {
|
||||
|
||||
@@ -451,6 +451,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const [showSessions, setShowSessions] = createSignal(false);
|
||||
const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 });
|
||||
let sessionButtonRef: HTMLButtonElement | undefined;
|
||||
const [modelSelectorOpenRequest, setModelSelectorOpenRequest] = createSignal(0);
|
||||
const [defaultModel, setDefaultModel] = createSignal('');
|
||||
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
|
||||
const [providerReadiness, setProviderReadiness] = createSignal<ChatProviderReadinessState>({
|
||||
@@ -707,14 +708,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const fallback = defaultModel().trim();
|
||||
if (!fallback) return '';
|
||||
const match = aiRuntimeModels().find((model) => model.id === fallback);
|
||||
return match ? match.name || match.id.split(':').pop() || match.id : fallback;
|
||||
return match ? formatAIModelRouteLabel(match) : fallback;
|
||||
});
|
||||
|
||||
const chatOverrideLabel = createMemo(() => {
|
||||
const override = chatOverrideModel().trim();
|
||||
if (!override) return '';
|
||||
const match = aiRuntimeModels().find((model) => model.id === override);
|
||||
return match ? match.name || match.id.split(':').pop() || match.id : override;
|
||||
return match ? formatAIModelRouteLabel(match) : override;
|
||||
});
|
||||
|
||||
const selectedChatModel = createMemo(() => {
|
||||
@@ -879,6 +880,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
updateStoredModel(chat.sessionId(), modelId);
|
||||
};
|
||||
|
||||
const openModelSelectorFromError = () => {
|
||||
setModelSelectorOpenRequest((value) => value + 1);
|
||||
};
|
||||
|
||||
const switchToProviderReadinessAlternative = () => {
|
||||
const alternative = providerReadinessAlternative();
|
||||
if (!alternative) return;
|
||||
@@ -1764,6 +1769,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
chatOverrideLabel={chatOverrideLabel()}
|
||||
isLoading={aiRuntimeModelsLoading()}
|
||||
error={aiRuntimeModelsError()}
|
||||
openRequest={modelSelectorOpenRequest()}
|
||||
onModelSelect={selectModel}
|
||||
onRefresh={() => loadModels(true)}
|
||||
/>
|
||||
@@ -2139,6 +2145,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
onAnswerQuestion={handleAnswerQuestion}
|
||||
onSkipQuestion={handleSkipQuestion}
|
||||
onRetry={(messageId) => chat.retryMessage(messageId)}
|
||||
onChangeModel={openModelSelectorFromError}
|
||||
recentSessions={sessions()
|
||||
.filter((s) => s.id !== chat.sessionId() && s.message_count > 0)
|
||||
.slice(0, 3)}
|
||||
|
||||
Reference in New Issue
Block a user