mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Surface Assistant provider readiness
This commit is contained in:
parent
6ba21c7a2b
commit
c5bee4a785
13 changed files with 595 additions and 26 deletions
|
|
@ -70,6 +70,13 @@ runtime cost control, and shared AI transport surfaces.
|
|||
provider JSON bodies, request URLs, request methods, dashboard links, and
|
||||
key-management links must not be streamed or persisted as chat-visible
|
||||
assistant output.
|
||||
Assistant provider readiness is part of that same request flow: when the
|
||||
drawer opens or the selected chat model changes, the frontend must verify
|
||||
the selected provider/model through `/api/ai/test/{provider}` before the
|
||||
next user send. That check must use neutral provider diagnostic copy owned
|
||||
by `internal/ai/`, not Patrol runtime-finding wording, and the drawer may
|
||||
surface the result as actionable settings/retry status without converting
|
||||
it into assistant-authored output or disabling model-owned chat by default.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -444,12 +444,19 @@ payload shape change when the portal presents compact client rows.
|
|||
structured diagnostic envelope: `success`, `message`, optional `model`,
|
||||
`cause`, `summary`, `recommendation`, and `action`, plus `provider` on the
|
||||
provider-specific endpoint.
|
||||
The provider-specific endpoint may accept an optional JSON request body
|
||||
with `model` so Assistant can test the exact selected chat model; when that
|
||||
field carries an explicit provider prefix it must match the route provider
|
||||
instead of silently testing a different provider's model.
|
||||
Failure payloads must use the AI runtime's Patrol failure-cause vocabulary
|
||||
and safe remediation text instead of returning raw upstream provider errors,
|
||||
while still leaving those raw details available only to server logs or
|
||||
redacted governed internal Patrol evidence. The frontend API client and
|
||||
settings shell must treat this payload as the canonical provider health
|
||||
contract rather than parsing free-form provider error strings.
|
||||
and safe remediation text instead of returning raw upstream provider errors.
|
||||
General `/api/ai/test*` payloads use neutral provider diagnostic copy for
|
||||
Assistant and settings readiness; Patrol-specific wording is reserved for
|
||||
Patrol preflight, Patrol findings, and Patrol run records. Raw details stay
|
||||
available only to server logs or redacted governed internal Patrol
|
||||
evidence. The frontend API client, settings shell, and Assistant drawer
|
||||
must treat this payload as the canonical provider health contract rather
|
||||
than parsing free-form provider error strings.
|
||||
35. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
|
||||
36. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
That same shared boundary also owns reachable-host selection truth for canonical Proxmox registration: runtime callers may propose ordered `candidateHosts`, but the API contract must persist and echo the first candidate Pulse can actually reach instead of freezing the caller's rejected first preference into the stored node endpoint.
|
||||
|
|
|
|||
|
|
@ -212,6 +212,12 @@ describe('AIAPI', () => {
|
|||
method: 'POST',
|
||||
});
|
||||
|
||||
await AIAPI.testProvider('deepseek', 'deepseek:deepseek-v4-pro');
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/test/deepseek', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: 'deepseek:deepseek-v4-pro' }),
|
||||
});
|
||||
|
||||
await AIAPI.getRemediationPlan('plan/1?x=1');
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/ai/remediation/plan?plan_id=plan%2F1%3Fx%3D1',
|
||||
|
|
@ -228,17 +234,16 @@ describe('AIAPI', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns provider preflight diagnostics without narrowing the API payload', async () => {
|
||||
it('returns provider diagnostics without narrowing the API payload', async () => {
|
||||
const diagnostic = {
|
||||
success: false,
|
||||
message: 'Provider authentication issue',
|
||||
provider: 'openrouter',
|
||||
model: 'openrouter:deepseek/deepseek-r1',
|
||||
cause: 'provider_auth',
|
||||
summary:
|
||||
'Pulse Patrol cannot analyze your infrastructure because the provider rejected the configured credentials or account access.',
|
||||
summary: 'The provider rejected the configured credentials or account access.',
|
||||
recommendation:
|
||||
'Check the API key or provider authentication in Patrol provider settings, then rerun Patrol.',
|
||||
'Check the API key or provider authentication in Assistant and Patrol settings, then retry.',
|
||||
action: 'open_provider_settings',
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(diagnostic as any);
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@ export class AIAPI {
|
|||
}
|
||||
|
||||
// Test a specific provider connection
|
||||
static async testProvider(provider: string): Promise<AIProviderTestResult> {
|
||||
static async testProvider(provider: string, model?: string): Promise<AIProviderTestResult> {
|
||||
const body = model?.trim() ? JSON.stringify({ model: model.trim() }) : undefined;
|
||||
return apiFetchJSON(`${this.baseUrl}/ai/test/${encodeURIComponent(provider)}`, {
|
||||
method: 'POST',
|
||||
...(body ? { body } : {}),
|
||||
}) as Promise<AIProviderTestResult>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ const {
|
|||
autonomous_mode: false,
|
||||
discovery_enabled: true,
|
||||
}),
|
||||
testProvider: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
provider: 'openai',
|
||||
model: 'openai:gpt-4',
|
||||
}),
|
||||
updateSettings: vi.fn().mockResolvedValue({ control_level: 'controlled' }),
|
||||
};
|
||||
|
||||
|
|
@ -305,6 +311,12 @@ beforeEach(() => {
|
|||
autonomous_mode: false,
|
||||
discovery_enabled: true,
|
||||
});
|
||||
mockAIAPI.testProvider.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
provider: 'openai',
|
||||
model: 'openai:gpt-4',
|
||||
});
|
||||
mockAIChatAPI.getStatus.mockResolvedValue({ running: true });
|
||||
mockAIChatAPI.listSessions.mockResolvedValue([]);
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
|
@ -349,6 +361,106 @@ describe('AIChat', () => {
|
|||
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks the selected provider and shows a readiness issue before the first send', async () => {
|
||||
mockAIAPI.getSettings.mockResolvedValue({
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
chat_model: '',
|
||||
control_level: 'read_only',
|
||||
autonomous_mode: false,
|
||||
discovery_enabled: true,
|
||||
});
|
||||
mockAIAPI.testProvider.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: 'Provider connection issue',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
cause: 'provider_connection',
|
||||
summary: 'Pulse could not maintain a healthy connection to this provider.',
|
||||
recommendation: 'Check provider reachability, base URL, firewall or proxy rules.',
|
||||
action: 'open_provider_settings',
|
||||
});
|
||||
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAIAPI.testProvider).toHaveBeenCalledWith('deepseek', 'deepseek:deepseek-v4-pro');
|
||||
expect(screen.getByLabelText('Assistant provider status')).toHaveTextContent(
|
||||
'DeepSeek provider issue',
|
||||
);
|
||||
});
|
||||
expect(screen.getByLabelText('Assistant provider status')).toHaveTextContent(
|
||||
'Pulse could not maintain a healthy connection to this provider.',
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /Open settings/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/system-ai',
|
||||
);
|
||||
});
|
||||
|
||||
it('rechecks provider readiness from the drawer status banner', async () => {
|
||||
mockAIAPI.getSettings.mockResolvedValue({
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
chat_model: '',
|
||||
control_level: 'read_only',
|
||||
autonomous_mode: false,
|
||||
discovery_enabled: true,
|
||||
});
|
||||
mockAIAPI.testProvider
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: 'Provider connection issue',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
cause: 'provider_connection',
|
||||
summary: 'Pulse could not maintain a healthy connection to this provider.',
|
||||
recommendation: 'Check provider reachability.',
|
||||
action: 'open_provider_settings',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
});
|
||||
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DeepSeek provider issue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry provider check' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAIAPI.testProvider).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByLabelText('Assistant provider status')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('checks the explicitly selected chat model provider instead of the default provider', async () => {
|
||||
mockChat.model.mockReturnValue('openrouter:anthropic/claude-sonnet-4.5');
|
||||
mockAIAPI.getSettings.mockResolvedValue({
|
||||
model: 'deepseek:deepseek-v4-pro',
|
||||
chat_model: '',
|
||||
control_level: 'read_only',
|
||||
autonomous_mode: false,
|
||||
discovery_enabled: true,
|
||||
});
|
||||
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAIAPI.testProvider).toHaveBeenCalledWith(
|
||||
'openrouter',
|
||||
'openrouter:anthropic/claude-sonnet-4.5',
|
||||
);
|
||||
});
|
||||
expect(mockAIAPI.testProvider).not.toHaveBeenCalledWith(
|
||||
'deepseek',
|
||||
'deepseek:deepseek-v4-pro',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the compact composer send control', () => {
|
||||
renderChat();
|
||||
expect(screen.getByRole('button', { name: 'Send message' })).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {
|
|||
import { unwrap } from 'solid-js/store';
|
||||
import SendIcon from 'lucide-solid/icons/send';
|
||||
import SquareIcon from 'lucide-solid/icons/square';
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { AIChatAPI, type ChatSession, type ChatSessionHandoffSummary } from '@/api/aiChat';
|
||||
|
|
@ -35,15 +37,23 @@ import {
|
|||
AI_CHAT_NEW_SESSION_BUTTON_TITLE,
|
||||
AI_CHAT_NEW_SESSION_MENU_LABEL,
|
||||
AI_CHAT_NEW_SESSION_SHORT_LABEL,
|
||||
AI_CHAT_PROVIDER_READINESS_RETRY_LABEL,
|
||||
AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF,
|
||||
AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL,
|
||||
AI_CHAT_SESSION_MENU_TITLE,
|
||||
AI_CHAT_SESSION_EMPTY_STATE,
|
||||
getAIChatEmptyStatePresentation,
|
||||
getAIChatProviderReadinessPresentation,
|
||||
} from '@/utils/aiChatPresentation';
|
||||
import {
|
||||
getAIChatControlLevelPresentation,
|
||||
normalizeAIControlLevel,
|
||||
type AIControlLevel,
|
||||
} from '@/utils/aiControlLevelPresentation';
|
||||
import {
|
||||
getAIProviderDisplayName,
|
||||
getProviderFromModelId,
|
||||
} from '@/utils/aiProviderPresentation';
|
||||
import { getCachedUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { isAppContainerDiscoveryResourceType } from '@/utils/discoveryTarget';
|
||||
|
|
@ -71,6 +81,18 @@ const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
|
|||
const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']);
|
||||
const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']);
|
||||
|
||||
type ChatProviderReadinessStatus = 'idle' | 'checking' | 'ready' | 'error';
|
||||
|
||||
interface ChatProviderReadinessState {
|
||||
status: ChatProviderReadinessStatus;
|
||||
provider: string;
|
||||
model?: string;
|
||||
message?: string;
|
||||
summary?: string;
|
||||
recommendation?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
interface AIChatProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
|
@ -299,6 +321,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
let sessionButtonRef: HTMLButtonElement | undefined;
|
||||
const [defaultModel, setDefaultModel] = createSignal('');
|
||||
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
|
||||
const [providerReadiness, setProviderReadiness] = createSignal<ChatProviderReadinessState>({
|
||||
status: 'idle',
|
||||
provider: '',
|
||||
});
|
||||
const [providerReadinessRetryNonce, setProviderReadinessRetryNonce] = createSignal(0);
|
||||
const [controlLevel, setControlLevel] = createSignal<AIControlLevel>('read_only');
|
||||
const [showControlMenu, setShowControlMenu] = createSignal(false);
|
||||
const [controlSaving, setControlSaving] = createSignal(false);
|
||||
|
|
@ -404,6 +431,76 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
return match ? match.name || match.id.split(':').pop() || match.id : override;
|
||||
});
|
||||
|
||||
const selectedChatModel = createMemo(() => {
|
||||
const selected = chat.model().trim();
|
||||
return selected || defaultModel().trim();
|
||||
});
|
||||
|
||||
const selectedChatProvider = createMemo(() => {
|
||||
const model = selectedChatModel();
|
||||
if (!model) return '';
|
||||
const match = aiRuntimeModels().find((candidate) => candidate.id === model);
|
||||
return match?.provider?.trim() || getProviderFromModelId(model);
|
||||
});
|
||||
|
||||
const providerReadinessPresentation = createMemo(() => {
|
||||
const readiness = providerReadiness();
|
||||
if (!readiness.provider || readiness.status === 'idle' || readiness.status === 'ready') {
|
||||
return null;
|
||||
}
|
||||
return getAIChatProviderReadinessPresentation({
|
||||
status: readiness.status === 'checking' ? 'checking' : 'error',
|
||||
providerLabel: getAIProviderDisplayName(readiness.provider),
|
||||
message: readiness.message,
|
||||
summary: readiness.summary,
|
||||
recommendation: readiness.recommendation,
|
||||
});
|
||||
});
|
||||
|
||||
let providerReadinessRequestId = 0;
|
||||
let lastProviderReadinessKey = '';
|
||||
|
||||
const refreshSelectedProviderReadiness = async (provider: string, model: string) => {
|
||||
const requestId = ++providerReadinessRequestId;
|
||||
setProviderReadiness({
|
||||
status: 'checking',
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await AIAPI.testProvider(provider, model);
|
||||
if (requestId !== providerReadinessRequestId) return;
|
||||
setProviderReadiness({
|
||||
status: result.success ? 'ready' : 'error',
|
||||
provider: result.provider || provider,
|
||||
model: result.model,
|
||||
message: result.message,
|
||||
summary: result.summary,
|
||||
recommendation: result.recommendation,
|
||||
action: result.action,
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId !== providerReadinessRequestId) return;
|
||||
logger.error('[AIChat] Failed to check selected provider readiness:', error);
|
||||
setProviderReadiness({
|
||||
status: 'error',
|
||||
provider,
|
||||
model,
|
||||
message: 'Provider check failed',
|
||||
summary: 'Pulse could not verify the selected provider before this chat sends work.',
|
||||
recommendation:
|
||||
'Check provider settings and network reachability, then retry the provider check.',
|
||||
action: 'open_provider_settings',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const retrySelectedProviderReadiness = () => {
|
||||
setProviderReadinessRetryNonce((value) => value + 1);
|
||||
focusComposer();
|
||||
};
|
||||
|
||||
const isOverlayLayout = createMemo(() => width() < AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH);
|
||||
const rootClassName = createMemo(() => {
|
||||
if (isOverlayLayout()) {
|
||||
|
|
@ -636,6 +733,35 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
void initializeWhenOpen();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const open = isOpen();
|
||||
const model = selectedChatModel().trim();
|
||||
const provider = selectedChatProvider().trim();
|
||||
const retryNonce = providerReadinessRetryNonce();
|
||||
|
||||
if (!open) {
|
||||
providerReadinessRequestId += 1;
|
||||
lastProviderReadinessKey = '';
|
||||
setProviderReadiness({ status: 'idle', provider: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!provider || !model) {
|
||||
providerReadinessRequestId += 1;
|
||||
lastProviderReadinessKey = '';
|
||||
setProviderReadiness({ status: 'idle', provider: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${provider}:${model}:${retryNonce}`;
|
||||
if (key === lastProviderReadinessKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastProviderReadinessKey = key;
|
||||
void refreshSelectedProviderReadiness(provider, model);
|
||||
});
|
||||
|
||||
// Click outside handler to close all dropdowns
|
||||
onMount(() => {
|
||||
aiChatStore.registerInput?.(textareaRef ?? null);
|
||||
|
|
@ -1508,6 +1634,60 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={providerReadinessPresentation()}>
|
||||
{(presentation) => (
|
||||
<section
|
||||
class={`border-b px-4 py-2.5 text-[11px] ${
|
||||
presentation().tone === 'checking'
|
||||
? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100'
|
||||
}`}
|
||||
aria-label="Assistant provider status"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-2.5">
|
||||
<span
|
||||
class={`mt-1 h-2 w-2 flex-shrink-0 rounded-full ${
|
||||
presentation().tone === 'checking'
|
||||
? 'bg-blue-500 dark:bg-blue-300'
|
||||
: 'bg-amber-500 dark:bg-amber-300'
|
||||
}`}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-base-content">{presentation().title}</div>
|
||||
<div class="mt-0.5 leading-5">{presentation().body}</div>
|
||||
<Show when={presentation().recommendation}>
|
||||
{(recommendation) => (
|
||||
<div class="mt-0.5 leading-5">{recommendation()}</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={providerReadiness().status === 'error'}>
|
||||
<div class="flex flex-shrink-0 items-center gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={retrySelectedProviderReadiness}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
aria-label="Retry provider check"
|
||||
>
|
||||
<RefreshCwIcon class="h-3.5 w-3.5" />
|
||||
<span>{AI_CHAT_PROVIDER_READINESS_RETRY_LABEL}</span>
|
||||
</button>
|
||||
<a
|
||||
href={AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-current/20 bg-surface px-2 py-1 text-[10px] font-medium text-base-content hover:bg-surface-hover"
|
||||
>
|
||||
<SettingsIcon class="h-3.5 w-3.5" />
|
||||
<span>{AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL}</span>
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Discovery hint - show when discovery is disabled */}
|
||||
<Show when={discoveryEnabled() === false && !discoveryHintDismissed()}>
|
||||
<div class="px-4 py-2 border-b border-cyan-200 dark:border-cyan-800 bg-cyan-50 dark:bg-cyan-900 flex items-center justify-between gap-3 text-[11px] text-cyan-700 dark:text-cyan-200">
|
||||
|
|
|
|||
|
|
@ -759,7 +759,7 @@ describe('AISettings provider save failure context', () => {
|
|||
provider === 'openrouter' ? 'Provider authentication issue' : `${provider} reachable`,
|
||||
recommendation:
|
||||
provider === 'openrouter'
|
||||
? 'Check the API key or provider authentication in Patrol provider settings, then rerun Patrol.'
|
||||
? 'Check the API key or provider authentication in Assistant and Patrol settings, then retry.'
|
||||
: undefined,
|
||||
cause: provider === 'openrouter' ? 'provider_auth' : undefined,
|
||||
provider,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ import {
|
|||
AI_CHAT_MODEL_SELECTOR_EMPTY_STATE,
|
||||
AI_CHAT_NEW_SESSION_MENU_LABEL,
|
||||
AI_CHAT_NEW_SESSION_SHORT_LABEL,
|
||||
AI_CHAT_PROVIDER_READINESS_RETRY_LABEL,
|
||||
AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF,
|
||||
AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL,
|
||||
AI_CHAT_QUESTION_CARD_TITLE,
|
||||
AI_CHAT_SESSION_EMPTY_STATE,
|
||||
AI_CHAT_SESSION_MENU_TITLE,
|
||||
getAIChatProviderReadinessPresentation,
|
||||
getAIChatLauncherTitle,
|
||||
getAIChatEmptyStatePresentation,
|
||||
} from '@/utils/aiChatPresentation';
|
||||
|
|
@ -42,6 +46,9 @@ describe('aiChatPresentation', () => {
|
|||
expect(AI_CHAT_QUESTION_CARD_TITLE).toBe('Pulse Assistant needs your input');
|
||||
expect(AI_CHAT_ASSISTANT_MESSAGE_LABEL).toBe('Pulse Assistant');
|
||||
expect(AI_CHAT_CONTEXT_USED_LABEL).toBe('Context used');
|
||||
expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF).toBe('/settings/system-ai');
|
||||
expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL).toBe('Open settings');
|
||||
expect(AI_CHAT_PROVIDER_READINESS_RETRY_LABEL).toBe('Retry');
|
||||
});
|
||||
|
||||
it('builds canonical launcher titles without implying a keyboard shortcut', () => {
|
||||
|
|
@ -65,4 +72,27 @@ describe('aiChatPresentation', () => {
|
|||
subtitle: 'Pulse Patrol · Patrol assessment attached · Coverage incomplete',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds neutral provider readiness presentation copy for Assistant checks', () => {
|
||||
expect(
|
||||
getAIChatProviderReadinessPresentation({
|
||||
status: 'error',
|
||||
providerLabel: 'DeepSeek',
|
||||
summary: 'Pulse could not maintain a healthy connection to this provider.',
|
||||
recommendation: 'Check provider reachability.',
|
||||
}),
|
||||
).toEqual({
|
||||
tone: 'error',
|
||||
title: 'DeepSeek provider issue',
|
||||
body: 'Pulse could not maintain a healthy connection to this provider.',
|
||||
recommendation: 'Check provider reachability.',
|
||||
});
|
||||
|
||||
expect(
|
||||
getAIChatProviderReadinessPresentation({
|
||||
status: 'checking',
|
||||
providerLabel: 'OpenRouter',
|
||||
}).title,
|
||||
).toBe('Checking OpenRouter provider');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export const AI_CHAT_QUESTION_CARD_TITLE = 'Pulse Assistant needs your input';
|
|||
export const AI_CHAT_QUESTION_CARD_PLACEHOLDER = 'Type your answer...';
|
||||
export const AI_CHAT_ASSISTANT_MESSAGE_LABEL = 'Pulse Assistant';
|
||||
export const AI_CHAT_CONTEXT_USED_LABEL = 'Context used';
|
||||
export const AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF = '/settings/system-ai';
|
||||
export const AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL = 'Open settings';
|
||||
export const AI_CHAT_PROVIDER_READINESS_RETRY_LABEL = 'Retry';
|
||||
|
||||
export interface AIChatEmptyStateBriefingInput {
|
||||
sourceLabel?: string;
|
||||
|
|
@ -32,6 +35,15 @@ export interface AIChatEmptyStatePresentation {
|
|||
title: string;
|
||||
}
|
||||
|
||||
export type AIChatProviderReadinessStatus = 'checking' | 'error';
|
||||
|
||||
export interface AIChatProviderReadinessPresentation {
|
||||
body: string;
|
||||
recommendation?: string;
|
||||
title: string;
|
||||
tone: AIChatProviderReadinessStatus;
|
||||
}
|
||||
|
||||
export function getAIChatLauncherTitle(contextName?: unknown) {
|
||||
if (typeof contextName === 'string' && contextName.trim().length > 0) {
|
||||
return `Open Pulse Assistant for ${contextName}`;
|
||||
|
|
@ -61,3 +73,33 @@ export function getAIChatEmptyStatePresentation(args: {
|
|||
subtitle: AI_CHAT_EMPTY_STATE_SUBTITLE,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAIChatProviderReadinessPresentation(args: {
|
||||
message?: string;
|
||||
providerLabel?: string;
|
||||
recommendation?: string;
|
||||
status: AIChatProviderReadinessStatus;
|
||||
summary?: string;
|
||||
}): AIChatProviderReadinessPresentation {
|
||||
const providerLabel = args.providerLabel?.trim() || 'Selected';
|
||||
if (args.status === 'checking') {
|
||||
return {
|
||||
tone: 'checking',
|
||||
title: `Checking ${providerLabel} provider`,
|
||||
body: 'Pulse is verifying the selected provider before this chat sends work.',
|
||||
};
|
||||
}
|
||||
|
||||
const body =
|
||||
args.summary?.trim() ||
|
||||
args.message?.trim() ||
|
||||
'Pulse could not verify the selected provider before this chat sends work.';
|
||||
const recommendation = args.recommendation?.trim() || undefined;
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: `${providerLabel} provider issue`,
|
||||
body,
|
||||
recommendation,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,72 @@ func ClassifyPatrolRuntimeFailure(err error) PatrolRuntimeFailureDiagnostic {
|
|||
}
|
||||
}
|
||||
|
||||
func ClassifyProviderConnectionFailure(err error) PatrolRuntimeFailureDiagnostic {
|
||||
failure := patrolRuntimeFailureFromError(err)
|
||||
diagnostic := PatrolRuntimeFailureDiagnostic{
|
||||
Title: "Provider connection issue",
|
||||
Summary: "Provider connection issue",
|
||||
Cause: failure.Cause,
|
||||
Description: "Pulse could not maintain a healthy connection to this provider.",
|
||||
Recommendation: "Check provider reachability, base URL, firewall or proxy rules, and provider availability, then retry.",
|
||||
}
|
||||
|
||||
switch failure.Cause {
|
||||
case PatrolFailureCauseMalformedToolHistory:
|
||||
diagnostic.Title = "Provider conversation state issue"
|
||||
diagnostic.Summary = "Provider conversation state issue"
|
||||
diagnostic.Description = "The provider rejected the conversation structure used by Pulse."
|
||||
diagnostic.Recommendation = "Start a new assistant session and retry. If the issue persists, restart Pulse and report the selected provider and model."
|
||||
case PatrolFailureCauseToolChoiceRejected:
|
||||
diagnostic.Title = "Provider rejected tool-choice request"
|
||||
diagnostic.Summary = "Provider rejected tool-choice request"
|
||||
diagnostic.Description = "Pulse reached the provider, but the provider rejected a tool-choice transport setting."
|
||||
diagnostic.Recommendation = "Retry with automatic tool selection, or switch to a provider route with reliable tool-call support."
|
||||
case PatrolFailureCauseNoToolCapableEndpoint:
|
||||
diagnostic.Title = "No tool-capable provider endpoint available"
|
||||
diagnostic.Summary = "No tool-capable provider endpoint available"
|
||||
diagnostic.Description = "Pulse reached the provider, but the provider reports no available endpoint with tool support for the selected model."
|
||||
diagnostic.Recommendation = "Review provider routing and privacy filters, broaden allowed providers, or switch to a model with broader tool support."
|
||||
case PatrolFailureCauseModelUnsupportedTools:
|
||||
diagnostic.Title = "Selected model does not support tools"
|
||||
diagnostic.Summary = "Selected model does not support tools"
|
||||
diagnostic.Description = "Pulse reached the provider, but the selected model or routed endpoint rejected tool calling."
|
||||
diagnostic.Recommendation = "Choose a model or provider route that supports tool calling for governed Assistant and Patrol workflows."
|
||||
case PatrolFailureCauseModelUnavailable:
|
||||
diagnostic.Title = "Selected model unavailable"
|
||||
diagnostic.Summary = "Selected model unavailable"
|
||||
diagnostic.Description = "The selected model is not available from this provider path."
|
||||
diagnostic.Recommendation = "Choose one of the models currently returned by the provider, then retry."
|
||||
case PatrolFailureCauseContextWindowTooSmall:
|
||||
diagnostic.Title = "Selected model context window too small"
|
||||
diagnostic.Summary = "Selected model context window too small"
|
||||
diagnostic.Description = "The provider rejected the request because the selected model could not fit the current context."
|
||||
diagnostic.Recommendation = "Choose a model with a larger context window or retry with a narrower request."
|
||||
case PatrolFailureCauseProviderBilling:
|
||||
diagnostic.Title = "Provider billing or quota issue"
|
||||
diagnostic.Summary = "Provider billing or quota issue"
|
||||
diagnostic.Description = "The provider rejected the request for billing or quota reasons."
|
||||
diagnostic.Recommendation = "Resolve the billing or quota issue with your provider, or switch to a different provider or model."
|
||||
case PatrolFailureCauseProviderRateLimited:
|
||||
diagnostic.Title = "Provider rate limited"
|
||||
diagnostic.Summary = "Provider rate limited"
|
||||
diagnostic.Description = "The provider is rate limiting requests for this account or model."
|
||||
diagnostic.Recommendation = "Wait for the provider rate limit to reset, increase provider limits, or switch to another model."
|
||||
case PatrolFailureCauseProviderAuth:
|
||||
diagnostic.Title = "Provider authentication issue"
|
||||
diagnostic.Summary = "Provider authentication issue"
|
||||
diagnostic.Description = "The provider rejected the configured credentials or account access."
|
||||
diagnostic.Recommendation = "Check the API key or provider authentication in Assistant and Patrol settings, then retry."
|
||||
case PatrolFailureCauseProviderNotConfigured, PatrolFailureCauseModelNotSelected, PatrolFailureCauseModelProviderUnconfigured, PatrolFailureCauseAssistantDisabled, PatrolFailureCauseSettingsPersistence:
|
||||
diagnostic.Title = "Provider not ready"
|
||||
diagnostic.Summary = "Provider not ready"
|
||||
diagnostic.Description = "Pulse cannot test this provider because the provider runtime is not ready."
|
||||
diagnostic.Recommendation = "Open Assistant and Patrol provider settings, complete provider configuration, verify the selected model, and retry."
|
||||
}
|
||||
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
|
||||
raw := ""
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -194,6 +194,41 @@ func TestClassifyPatrolRuntimeFailureOmitsRawProviderEvidence(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClassifyProviderConnectionFailureUsesNeutralProviderCopy(t *testing.T) {
|
||||
diagnostic := ClassifyProviderConnectionFailure(errors.New(`Ollama returned status 500: raw upstream body`))
|
||||
|
||||
if diagnostic.Summary != "Provider connection issue" {
|
||||
t.Fatalf("unexpected summary %q", diagnostic.Summary)
|
||||
}
|
||||
if diagnostic.Cause != PatrolFailureCauseProviderConnection {
|
||||
t.Fatalf("unexpected cause %q", diagnostic.Cause)
|
||||
}
|
||||
for _, field := range []string{diagnostic.Title, diagnostic.Description, diagnostic.Recommendation} {
|
||||
if strings.Contains(field, "Patrol") {
|
||||
t.Fatalf("provider diagnostic leaked Patrol copy: %q", field)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diagnostic.Description, "this provider") {
|
||||
t.Fatalf("expected provider-specific description, got %q", diagnostic.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyProviderConnectionFailureKeepsSafeModelUnavailableCopy(t *testing.T) {
|
||||
diagnostic := ClassifyProviderConnectionFailure(errors.New(`connected to Ollama but model "qwen3.5:2b" is not available; found: qwen3.5:4b`))
|
||||
|
||||
if diagnostic.Summary != "Selected model unavailable" {
|
||||
t.Fatalf("unexpected summary %q", diagnostic.Summary)
|
||||
}
|
||||
if diagnostic.Cause != PatrolFailureCauseModelUnavailable {
|
||||
t.Fatalf("unexpected cause %q", diagnostic.Cause)
|
||||
}
|
||||
for _, field := range []string{diagnostic.Description, diagnostic.Recommendation} {
|
||||
if strings.Contains(field, "Patrol") || strings.Contains(field, "qwen3.5") {
|
||||
t.Fatalf("provider diagnostic leaked scoped or raw detail: %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolRuntimeFailureFromErrorRedactsSecretLikeDetail(t *testing.T) {
|
||||
failure := patrolRuntimeFailureFromError(errors.New(`request failed: Get "https://generativelanguage.googleapis.com/v1beta/models?key=AIzaSy-secret-token": Authorization: Bearer sk-live-secret {"api_key":"sk-json-secret"} https://user:pass@example.test/v1`))
|
||||
|
||||
|
|
|
|||
|
|
@ -3044,7 +3044,7 @@ func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *htt
|
|||
cfg := h.GetAIService(r.Context()).GetConfig()
|
||||
err := h.GetAIService(r.Context()).TestConnection(ctx)
|
||||
if err != nil {
|
||||
diagnostic := ai.ClassifyPatrolRuntimeFailure(err)
|
||||
diagnostic := ai.ClassifyProviderConnectionFailure(err)
|
||||
testResult.Success = false
|
||||
testResult.Message = diagnostic.Summary
|
||||
testResult.Cause = string(diagnostic.Cause)
|
||||
|
|
@ -3079,6 +3079,10 @@ type aiProviderTestResponse struct {
|
|||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
type aiProviderTestRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
func newAIProviderTestResponse(provider string) aiProviderTestResponse {
|
||||
return aiProviderTestResponse{Provider: provider}
|
||||
}
|
||||
|
|
@ -3089,14 +3093,14 @@ func newAIProviderTestNotConfiguredResponse(provider string) aiProviderTestRespo
|
|||
Message: "Provider not configured",
|
||||
Provider: provider,
|
||||
Cause: string(ai.PatrolFailureCauseProviderNotConfigured),
|
||||
Summary: "Pulse Patrol cannot test this provider because it is not configured for the current Assistant & Patrol settings.",
|
||||
Recommendation: "Open Assistant & Patrol provider settings, configure the provider credentials or base URL, choose a Patrol model, and run provider preflight again.",
|
||||
Summary: "Pulse cannot test this provider because it is not configured for the current Assistant and Patrol settings.",
|
||||
Recommendation: "Open Assistant and Patrol provider settings, configure the provider credentials or base URL, choose a model, and retry.",
|
||||
Action: "open_provider_settings",
|
||||
}
|
||||
}
|
||||
|
||||
func newAIProviderTestFailureResponse(provider, model string, err error) aiProviderTestResponse {
|
||||
diagnostic := ai.ClassifyPatrolRuntimeFailure(err)
|
||||
diagnostic := ai.ClassifyProviderConnectionFailure(err)
|
||||
return aiProviderTestResponse{
|
||||
Success: false,
|
||||
Message: diagnostic.Summary,
|
||||
|
|
@ -3139,6 +3143,26 @@ func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Re
|
|||
return
|
||||
}
|
||||
|
||||
var body aiProviderTestRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, `{"error":"Invalid JSON body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
requestedModel := config.NormalizeQuickstartModelString(body.Model)
|
||||
if len(requestedModel) > 256 {
|
||||
http.Error(w, `{"error":"Model id too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if requestedModel != "" && strings.Contains(requestedModel, ":") {
|
||||
modelProvider, _ := config.ParseModelString(requestedModel)
|
||||
if modelProvider != provider {
|
||||
http.Error(w, `{"error":"Model provider does not match test provider"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -3165,14 +3189,18 @@ func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Re
|
|||
}
|
||||
|
||||
// Create provider and test connection
|
||||
model, err := ai.ResolvePreferredModelForProvider(ctx, cfg, provider)
|
||||
if err != nil {
|
||||
testResult = newAIProviderTestFailureResponse(provider, "", err)
|
||||
log.Error().Err(err).Str("provider", provider).Msg("AI provider model resolution failed")
|
||||
if err := utils.WriteJSONResponse(w, testResult); err != nil {
|
||||
log.Error().Err(err).Msg("failed to write provider test response")
|
||||
model := requestedModel
|
||||
if model == "" {
|
||||
var err error
|
||||
model, err = ai.ResolvePreferredModelForProvider(ctx, cfg, provider)
|
||||
if err != nil {
|
||||
testResult = newAIProviderTestFailureResponse(provider, "", err)
|
||||
log.Error().Err(err).Str("provider", provider).Msg("AI provider model resolution failed")
|
||||
if err := utils.WriteJSONResponse(w, testResult); err != nil {
|
||||
log.Error().Err(err).Msg("failed to write provider test response")
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
testProvider, err := providers.NewForProvider(cfg, provider, model)
|
||||
|
|
|
|||
|
|
@ -1384,7 +1384,7 @@ func TestAISettingsHandler_TestConnection_Failure(t *testing.T) {
|
|||
assert.Equal(t, "Provider connection issue", resp.Message)
|
||||
assert.Equal(t, "ollama:llama3", resp.Model)
|
||||
assert.Equal(t, string(ai.PatrolFailureCauseProviderConnection), resp.Cause)
|
||||
assert.Contains(t, resp.Summary, "healthy connection")
|
||||
assert.Contains(t, resp.Summary, "healthy connection to this provider")
|
||||
assert.Contains(t, resp.Recommendation, "Check provider reachability")
|
||||
assert.Equal(t, "open_provider_settings", resp.Action)
|
||||
assert.NotContains(t, rec.Body.String(), "Ollama returned status 500")
|
||||
|
|
@ -1560,7 +1560,7 @@ func TestAISettingsHandler_TestProvider_ConnectionFailure(t *testing.T) {
|
|||
assert.Equal(t, "ollama", resp.Provider)
|
||||
assert.Equal(t, "ollama:llama3", resp.Model)
|
||||
assert.Equal(t, string(ai.PatrolFailureCauseProviderConnection), resp.Cause)
|
||||
assert.Contains(t, resp.Summary, "healthy connection")
|
||||
assert.Contains(t, resp.Summary, "healthy connection to this provider")
|
||||
assert.Contains(t, resp.Recommendation, "Check provider reachability")
|
||||
assert.Equal(t, "open_provider_settings", resp.Action)
|
||||
assert.NotContains(t, rec.Body.String(), "Ollama returned status 500")
|
||||
|
|
@ -1667,12 +1667,67 @@ func TestAISettingsHandler_TestProvider_ModelUnavailableUsesSafeDiagnostic(t *te
|
|||
assert.Equal(t, "ollama", resp.Provider)
|
||||
assert.Equal(t, "ollama:missing:latest", resp.Model)
|
||||
assert.Equal(t, string(ai.PatrolFailureCauseModelUnavailable), resp.Cause)
|
||||
assert.Contains(t, resp.Summary, "configured Patrol model")
|
||||
assert.Contains(t, resp.Recommendation, "choose one of the models")
|
||||
assert.Contains(t, resp.Summary, "selected model is not available")
|
||||
assert.Contains(t, resp.Recommendation, "Choose one of the models")
|
||||
assert.Equal(t, "open_provider_settings", resp.Action)
|
||||
assert.NotContains(t, rec.Body.String(), "found: llama3:latest")
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_TestProvider_UsesRequestedModelOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ollama := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/version":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
|
||||
case "/api/tags":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3:latest"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = ollama.URL
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("SaveAIConfig: %v", err)
|
||||
}
|
||||
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
req := newLoopbackRequest(
|
||||
http.MethodPost,
|
||||
"/api/ai/test/ollama",
|
||||
strings.NewReader(`{"model":"ollama:missing:latest"}`),
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleTestProvider(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Cause string `json:"cause"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
assert.False(t, resp.Success)
|
||||
assert.Equal(t, "Selected model unavailable", resp.Message)
|
||||
assert.Equal(t, "ollama", resp.Provider)
|
||||
assert.Equal(t, "ollama:missing:latest", resp.Model)
|
||||
assert.Equal(t, string(ai.PatrolFailureCauseModelUnavailable), resp.Cause)
|
||||
assert.NotContains(t, rec.Body.String(), "found: llama3:latest")
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// HandleGetAICostSummary tests
|
||||
// ========================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue