fix: guest URL icon now appears/disappears immediately after AI sets/removes it

The issue was a SolidJS reactivity problem in the Dashboard component.
When guestMetadata signal was accessed inside a For loop callback and
assigned to a plain variable, SolidJS lost reactive tracking.

Changed from:
  const metadata = guestMetadata()[guestId] || ...
  customUrl={metadata?.customUrl}

To:
  const getMetadata = () => guestMetadata()[guestId] || ...
  customUrl={getMetadata()?.customUrl}

This ensures SolidJS properly tracks the signal dependency when the
getter function is called directly in JSX props.
This commit is contained in:
rcourtman 2025-12-18 14:42:47 +00:00
parent 5c9bbf33b6
commit c91307be94
17 changed files with 946 additions and 112 deletions

View file

@ -215,26 +215,51 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [selectedModel, setSelectedModel] = createSignal<string>(''); // Empty = use default
const [showModelSelector, setShowModelSelector] = createSignal(false);
// Autonomous mode toggle
const [autonomousMode, setAutonomousMode] = createSignal(false);
const [isTogglingAutonomous, setIsTogglingAutonomous] = createSignal(false);
let messagesEndRef: HTMLDivElement | undefined;
let inputRef: HTMLTextAreaElement | undefined;
let abortControllerRef: AbortController | null = null;
// Fetch available models on mount using the dynamic API
// Fetch available models and settings on mount
onMount(async () => {
try {
const result = await AIAPI.getModels();
if (result.models && result.models.length > 0) {
setAvailableModels(result.models.map(m => ({
const [modelsResult, settingsResult] = await Promise.all([
AIAPI.getModels(),
AIAPI.getSettings(),
]);
if (modelsResult.models && modelsResult.models.length > 0) {
setAvailableModels(modelsResult.models.map(m => ({
id: m.id,
name: m.name || m.id,
description: m.description,
})));
}
if (settingsResult) {
setAutonomousMode(settingsResult.autonomous_mode || false);
}
} catch (_e) {
// Silently fail - models will just not be selectable
}
});
// Toggle autonomous mode
const toggleAutonomousMode = async () => {
const newValue = !autonomousMode();
setIsTogglingAutonomous(true);
try {
await AIAPI.updateSettings({ autonomous_mode: newValue });
setAutonomousMode(newValue);
notificationStore.success(newValue ? 'Autonomous mode enabled' : 'Autonomous mode disabled');
} catch (e) {
notificationStore.error('Failed to toggle autonomous mode');
} finally {
setIsTogglingAutonomous(false);
}
};
// Wrapper to sync messages to global store
const setMessages = (updater: Message[] | ((prev: Message[]) => Message[])) => {
setMessagesLocal((prev) => {
@ -448,9 +473,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
// Emit event for metadata refresh if AI set a resource URL
if (data.name === 'set_resource_url' && data.success) {
logger.info('[AIChat] Emitting metadata-changed event for set_resource_url');
let payload;
try {
// Optimistically parse the input to pass relevant data for immediate update
const parsedInput = JSON.parse(data.input);
// Handle both new 'resource_id' field and legacy 'id'/'guest_id' fields
const id = parsedInput.resource_id || parsedInput.guest_id || parsedInput.id;
if (id && typeof parsedInput.url !== 'undefined') {
payload = { guestId: id, url: parsedInput.url };
}
} catch (e) {
logger.warn('[AIChat] Failed to parse set_resource_url input for optimistic update', e);
}
logger.info('[AIChat] Emitting metadata-changed event for set_resource_url', { payload });
window.dispatchEvent(new CustomEvent('pulse:metadata-changed', {
detail: { source: 'ai', tool: data.name }
detail: { source: 'ai', tool: data.name, payload }
}));
}
@ -719,10 +757,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
await AIAPI.executeStream(
{
prompt: continuationPrompt,
target_type: targetType(),
target_id: targetId(),
context: contextData(),
history: historyForContinuation,
target_type: targetType() || undefined,
target_id: targetId() || undefined,
context: contextData() || undefined,
history: historyForContinuation.length > 0 ? historyForContinuation : undefined,
},
(event: AIStreamEvent) => {
logger.debug('[AIChat] Continuation event received', { type: event.type });
@ -924,6 +962,25 @@ export const AIChat: Component<AIChatProps> = (props) => {
</Show>
</div>
</Show>
{/* Autonomous Mode toggle */}
<button
onClick={toggleAutonomousMode}
disabled={isTogglingAutonomous()}
class={`p-2 rounded-lg transition-colors ${autonomousMode()
? 'text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/40 hover:bg-amber-200 dark:hover:bg-amber-900/60'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
} ${isTogglingAutonomous() ? 'opacity-50 cursor-wait' : ''}`}
title={autonomousMode() ? 'Autonomous Mode: ON (commands run without approval)' : 'Autonomous Mode: OFF (commands need approval)'}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</button>
<button
onClick={clearChat}
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
@ -1018,7 +1075,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
class={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
class={`max-w-[85%] rounded-lg px-4 py-2 ${message.role === 'user'
class={`max-w-[85%] rounded-lg px-4 py-2 overflow-hidden break-words ${message.role === 'user'
? 'bg-purple-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100'
}`}

View file

@ -307,7 +307,34 @@ export function Dashboard(props: DashboardProps) {
// Listen for metadata changes from AI or other sources
const handleMetadataChanged = (event: Event) => {
console.log('[Dashboard] Metadata changed event received:', event);
const customEvent = event as CustomEvent;
console.log('[Dashboard] Metadata changed event received:', customEvent.detail);
// Handle optimistic update if payload is present - this fixes the "guest url not appearing straight away" issue
if (customEvent.detail?.payload) {
let { guestId, url } = customEvent.detail.payload;
if (guestId) {
// Normalize guestId if it's in the canonical AI format (instance:node:vmid)
// Frontend uses 'instance-vmid' (e.g., 'delly-101') but AI sends 'delly:delly:101'
if (guestId.includes(':')) {
const parts = guestId.split(':');
if (parts.length === 3) {
const [instance, _node, vmid] = parts;
// Construct frontend ID format
guestId = `${instance}-${vmid}`;
logger.debug('[Dashboard] Normalized optimistic guestId', { original: customEvent.detail.payload.guestId, normalized: guestId });
}
}
logger.debug('[Dashboard] Applying optimistic metadata update', { guestId, url });
// Ensure url is a string (handle null/undefined for removal)
handleCustomUrlUpdate(guestId, url || '');
// Skip immediate refresh to prevent race condition with backend consistency
// The optimistic update is authoritative for this action
return;
}
}
logger.debug('Metadata changed event received, refreshing...');
refreshGuestMetadata();
};
@ -1146,7 +1173,9 @@ export function Dashboard(props: DashboardProps) {
<For each={guests} fallback={<></>}>
{(guest, index) => {
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
const metadata =
// Create a getter function for metadata to ensure reactivity
// Accessing guestMetadata() in a plain variable breaks SolidJS reactivity
const getMetadata = () =>
guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`];
// PERFORMANCE: Use pre-computed parent node map instead of resolveParentNode
@ -1164,7 +1193,7 @@ export function Dashboard(props: DashboardProps) {
<GuestRow
guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl}
customUrl={getMetadata()?.customUrl}
onTagClick={handleTagClick}
activeSearch={search()}
parentNodeOnline={parentNodeOnline}

View file

@ -606,7 +606,8 @@ export function GuestRow(props: GuestRowProps) {
const newUrl = props.customUrl;
// Only animate when URL transitions from empty to having a value
if (!prevUrl && newUrl) {
// Use a tracking variable to prevent initial mount animation if desired, but here we want to animate on first add
if (prevUrl === undefined && newUrl) {
setShouldAnimateIcon(true);
// Remove animation class after it completes
setTimeout(() => setShouldAnimateIcon(false), 200);
@ -801,7 +802,7 @@ export function GuestRow(props: GuestRowProps) {
>
{props.guest.name}
</span>
<Show when={customUrl()}>
<Show when={customUrl() && customUrl() !== ''}>
<a
href={customUrl()}
target="_blank"

View file

@ -16,6 +16,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
deepseek: 'DeepSeek',
gemini: 'Google Gemini',
ollama: 'Ollama',
};
@ -35,6 +36,9 @@ function getProviderFromModelId(modelId: string): string {
if (modelId.includes('deepseek')) {
return 'deepseek';
}
if (modelId.includes('gemini')) {
return 'gemini';
}
return 'ollama';
}
@ -45,6 +49,7 @@ function isProviderConfigured(provider: string, settings: AISettingsType | null)
case 'anthropic': return settings.anthropic_configured;
case 'openai': return settings.openai_configured;
case 'deepseek': return settings.deepseek_configured;
case 'gemini': return settings.gemini_configured;
case 'ollama': return settings.ollama_configured;
default: return false;
}
@ -92,7 +97,7 @@ export const AISettings: Component = () => {
// First-time setup modal state
const [showSetupModal, setShowSetupModal] = createSignal(false);
const [setupProvider, setSetupProvider] = createSignal<'anthropic' | 'openai' | 'deepseek' | 'ollama'>('anthropic');
const [setupProvider, setSetupProvider] = createSignal<'anthropic' | 'openai' | 'deepseek' | 'gemini' | 'ollama'>('anthropic');
const [setupApiKey, setSetupApiKey] = createSignal('');
const [setupOllamaUrl, setSetupOllamaUrl] = createSignal('http://localhost:11434');
const [setupSaving, setSetupSaving] = createSignal(false);
@ -120,6 +125,7 @@ export const AISettings: Component = () => {
anthropicApiKey: '',
openaiApiKey: '',
deepseekApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
openaiBaseUrl: '',
// Cost controls
@ -147,6 +153,7 @@ export const AISettings: Component = () => {
anthropicApiKey: '',
openaiApiKey: '',
deepseekApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: 'http://localhost:11434',
openaiBaseUrl: '',
costBudgetUSD30d: '',
@ -173,6 +180,7 @@ export const AISettings: Component = () => {
anthropicApiKey: '', // Always empty - we only show if configured
openaiApiKey: '',
deepseekApiKey: '',
geminiApiKey: '',
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
openaiBaseUrl: data.openai_base_url || '',
costBudgetUSD30d:
@ -186,6 +194,7 @@ export const AISettings: Component = () => {
if (data.anthropic_configured) configured.add('anthropic');
if (data.openai_configured) configured.add('openai');
if (data.deepseek_configured) configured.add('deepseek');
if (data.gemini_configured) configured.add('gemini');
if (data.ollama_configured) configured.add('ollama');
// Default to anthropic if nothing is configured
if (configured.size === 0) configured.add('anthropic');
@ -271,6 +280,7 @@ export const AISettings: Component = () => {
(modelProvider === 'anthropic' && form.anthropicApiKey.trim()) ||
(modelProvider === 'openai' && form.openaiApiKey.trim()) ||
(modelProvider === 'deepseek' && form.deepseekApiKey.trim()) ||
(modelProvider === 'gemini' && form.geminiApiKey.trim()) ||
(modelProvider === 'ollama' && form.ollamaBaseUrl.trim());
if (!isAddingCredential) {
@ -354,6 +364,9 @@ export const AISettings: Component = () => {
if (form.deepseekApiKey.trim()) {
payload.deepseek_api_key = form.deepseekApiKey.trim();
}
if (form.geminiApiKey.trim()) {
payload.gemini_api_key = form.geminiApiKey.trim();
}
// Always include Ollama URL if it has a value and differs from what's saved
// Compare against actual saved value (empty string if not set), not a prefilled default
if (form.ollamaBaseUrl.trim() && form.ollamaBaseUrl.trim() !== (settings()?.ollama_base_url || '')) {
@ -432,7 +445,7 @@ export const AISettings: Component = () => {
const handleClearProvider = async (provider: string) => {
// Check if this is the last configured provider
const s = settings();
const configuredCount = [s?.anthropic_configured, s?.openai_configured, s?.deepseek_configured, s?.ollama_configured].filter(Boolean).length;
const configuredCount = [s?.anthropic_configured, s?.openai_configured, s?.deepseek_configured, s?.gemini_configured, s?.ollama_configured].filter(Boolean).length;
const isLastProvider = configuredCount === 1 && isProviderConfigured(provider, s);
// Check if current model uses this provider
@ -458,6 +471,7 @@ export const AISettings: Component = () => {
if (provider === 'anthropic') clearPayload.clear_anthropic_key = true;
if (provider === 'openai') clearPayload.clear_openai_key = true;
if (provider === 'deepseek') clearPayload.clear_deepseek_key = true;
if (provider === 'gemini') clearPayload.clear_gemini_key = true;
if (provider === 'ollama') clearPayload.clear_ollama_url = true;
await AIAPI.updateSettings(clearPayload);
@ -470,6 +484,7 @@ export const AISettings: Component = () => {
if (provider === 'anthropic') setForm('anthropicApiKey', '');
if (provider === 'openai') setForm('openaiApiKey', '');
if (provider === 'deepseek') setForm('deepseekApiKey', '');
if (provider === 'gemini') setForm('geminiApiKey', '');
if (provider === 'ollama') setForm('ollamaBaseUrl', '');
notificationStore.success(`${provider} credentials cleared`);
@ -980,6 +995,74 @@ export const AISettings: Component = () => {
</Show>
</div>
{/* Google Gemini */}
<div class={`border rounded-lg overflow-hidden ${settings()?.gemini_configured ? 'border-green-300 dark:border-green-700' : 'border-gray-200 dark:border-gray-700'}`}>
<button
type="button"
class="w-full px-3 py-2 flex items-center justify-between bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
onClick={() => {
const current = expandedProviders();
const next = new Set(current);
if (next.has('gemini')) next.delete('gemini');
else next.add('gemini');
setExpandedProviders(next);
}}
>
<div class="flex items-center gap-2">
<span class="font-medium text-sm">Google Gemini</span>
<Show when={settings()?.gemini_configured}>
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">Configured</span>
</Show>
</div>
<svg class={`w-4 h-4 transition-transform ${expandedProviders().has('gemini') ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Show when={expandedProviders().has('gemini')}>
<div class="px-3 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 space-y-2">
<input
type="password"
value={form.geminiApiKey}
onInput={(e) => setForm('geminiApiKey', e.currentTarget.value)}
placeholder={settings()?.gemini_configured ? '••••••••••• (configured)' : 'AIza...'}
class={controlClass()}
disabled={saving()}
/>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener" class="text-purple-600 hover:underline">Get API key </a>
</p>
<Show when={settings()?.gemini_configured}>
<div class="flex gap-1">
<button
type="button"
onClick={() => handleTestProvider('gemini')}
disabled={testingProvider() === 'gemini' || saving()}
class="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50"
>
{testingProvider() === 'gemini' ? 'Testing...' : 'Test'}
</button>
<button
type="button"
onClick={() => handleClearProvider('gemini')}
disabled={saving()}
class="px-2 py-1 text-xs bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded hover:bg-red-200 dark:hover:bg-red-800 disabled:opacity-50"
title="Clear API key"
>
Clear
</button>
</div>
</Show>
</div>
<Show when={providerTestResult()?.provider === 'gemini'}>
<p class={`text-xs ${providerTestResult()?.success ? 'text-green-600' : 'text-red-600'}`}>
{providerTestResult()?.message}
</p>
</Show>
</div>
</Show>
</div>
{/* Ollama */}
<div class={`border rounded-lg overflow-hidden ${settings()?.ollama_configured ? 'border-green-300 dark:border-green-700' : 'border-gray-200 dark:border-gray-700'}`}>
<button
@ -1367,6 +1450,17 @@ export const AISettings: Component = () => {
<div class="text-sm font-medium">DeepSeek</div>
<div class="text-xs text-gray-500 mt-0.5">V3</div>
</button>
<button
type="button"
onClick={() => setSetupProvider('gemini')}
class={`p-3 rounded-lg border-2 transition-all text-center ${setupProvider() === 'gemini'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/30'
: 'border-gray-200 dark:border-gray-700 hover:border-purple-300'
}`}
>
<div class="text-sm font-medium">Gemini</div>
<div class="text-xs text-gray-500 mt-0.5">Google</div>
</button>
<button
type="button"
onClick={() => setSetupProvider('ollama')}
@ -1384,13 +1478,13 @@ export const AISettings: Component = () => {
<Show when={setupProvider() === 'ollama'} fallback={
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{setupProvider() === 'anthropic' ? 'Anthropic' : setupProvider() === 'openai' ? 'OpenAI' : 'DeepSeek'} API Key
{setupProvider() === 'anthropic' ? 'Anthropic' : setupProvider() === 'openai' ? 'OpenAI' : setupProvider() === 'gemini' ? 'Google Gemini' : 'DeepSeek'} API Key
</label>
<input
type="password"
value={setupApiKey()}
onInput={(e) => setSetupApiKey(e.currentTarget.value)}
placeholder={setupProvider() === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
placeholder={setupProvider() === 'anthropic' ? 'sk-ant-...' : setupProvider() === 'gemini' ? 'AIza...' : 'sk-...'}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<p class="text-xs text-gray-500 mt-1.5">
@ -1399,7 +1493,9 @@ export const AISettings: Component = () => {
? 'https://console.anthropic.com/settings/keys'
: setupProvider() === 'openai'
? 'https://platform.openai.com/api-keys'
: 'https://platform.deepseek.com/api_keys'
: setupProvider() === 'gemini'
? 'https://aistudio.google.com/app/apikey'
: 'https://platform.deepseek.com/api_keys'
}
target="_blank"
rel="noopener"
@ -1469,6 +1565,13 @@ export const AISettings: Component = () => {
}
payload.deepseek_api_key = setupApiKey().trim();
payload.model = 'deepseek:deepseek-chat';
} else if (setupProvider() === 'gemini') {
if (!setupApiKey().trim()) {
notificationStore.error('Please enter your Google Gemini API key');
return;
}
payload.gemini_api_key = setupApiKey().trim();
payload.model = 'gemini:gemini-2.5-flash';
} else {
if (!setupOllamaUrl().trim()) {
notificationStore.error('Please enter your Ollama server URL');

View file

@ -33,11 +33,32 @@ interface Message {
}>;
}
// Local storage key
const HISTORY_STORAGE_KEY = 'pulse:ai_chat_history';
// Load initial messages from storage
const loadMessagesFromStorage = (): Message[] => {
try {
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
if (!stored) return [];
const parsed = JSON.parse(stored);
// Revive Date objects
return parsed.map((m: any) => ({
...m,
timestamp: new Date(m.timestamp)
}));
} catch (e) {
console.error('Failed to load chat history:', e);
return [];
}
};
// Global state for the AI chat drawer
const [isAIChatOpen, setIsAIChatOpen] = createSignal(false);
const [aiChatContext, setAIChatContext] = createSignal<AIChatContext>({});
const [contextItems, setContextItems] = createSignal<ContextItem[]>([]);
const [messages, setMessages] = createSignal<Message[]>([]);
const [messages, setMessages] = createSignal<Message[]>(loadMessagesFromStorage());
const [aiEnabled, setAiEnabled] = createSignal<boolean | null>(null); // null = not checked yet
// Store reference to AI input for focusing from keyboard shortcuts
@ -82,6 +103,11 @@ export const aiChatStore = {
// Set messages (for persistence from AIChat component)
setMessages(msgs: Message[]) {
setMessages(msgs);
try {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(msgs));
} catch (e) {
console.error('Failed to save chat history:', e);
}
},
// Toggle the AI chat panel
@ -164,6 +190,7 @@ export const aiChatStore = {
// Clear conversation (start fresh)
clearConversation() {
setMessages([]);
localStorage.removeItem(HISTORY_STORAGE_KEY);
},
// Convenience method to update context for a specific target (host, VM, container, etc.)

View file

@ -1,6 +1,6 @@
// AI feature types
export type AIProvider = 'anthropic' | 'openai' | 'ollama' | 'deepseek';
export type AIProvider = 'anthropic' | 'openai' | 'ollama' | 'deepseek' | 'gemini';
export type AuthMethod = 'api_key' | 'oauth';
export interface ModelInfo {
@ -35,6 +35,7 @@ export interface AISettings {
anthropic_configured: boolean; // true if Anthropic API key or OAuth is set
openai_configured: boolean; // true if OpenAI API key is set
deepseek_configured: boolean; // true if DeepSeek API key is set
gemini_configured: boolean; // true if Gemini API key is set
ollama_configured: boolean; // true (always available for attempt)
ollama_base_url: string; // Ollama server URL
openai_base_url?: string; // Custom OpenAI base URL
@ -66,12 +67,14 @@ export interface AISettingsUpdateRequest {
anthropic_api_key?: string; // Set Anthropic API key
openai_api_key?: string; // Set OpenAI API key
deepseek_api_key?: string; // Set DeepSeek API key
gemini_api_key?: string; // Set Gemini API key
ollama_base_url?: string; // Set Ollama server URL
openai_base_url?: string; // Set custom OpenAI base URL
// Clear flags for removing credentials
clear_anthropic_key?: boolean; // Clear Anthropic API key
clear_openai_key?: boolean; // Clear OpenAI API key
clear_deepseek_key?: boolean; // Clear DeepSeek API key
clear_gemini_key?: boolean; // Clear Gemini API key
clear_ollama_url?: boolean; // Clear Ollama URL
// Cost controls
@ -91,6 +94,7 @@ export const DEFAULT_MODELS: Record<AIProvider, string> = {
openai: 'gpt-4o',
ollama: 'llama3',
deepseek: 'deepseek-reasoner',
gemini: 'gemini-2.5-flash',
};
// Provider display names
@ -99,6 +103,7 @@ export const PROVIDER_NAMES: Record<AIProvider, string> = {
openai: 'OpenAI',
ollama: 'Ollama',
deepseek: 'DeepSeek',
gemini: 'Google Gemini',
};
// Provider descriptions
@ -107,6 +112,7 @@ export const PROVIDER_DESCRIPTIONS: Record<AIProvider, string> = {
openai: 'GPT models from OpenAI',
ollama: 'Local models via Ollama',
deepseek: 'DeepSeek reasoning models',
gemini: 'Gemini models from Google',
};
// Conversation history for multi-turn chats

View file

@ -52,6 +52,17 @@ var providerPrices = map[string][]modelPrice{
// DeepSeek docs include an "input cache hit" discount; this uses cache-miss rates for conservative estimates.
{Pattern: "deepseek-*", InputUSDPerMTok: 0.28, OutputUSDPerMTok: 0.42},
},
"gemini": {
// Gemini pricing (as of December 2025)
// Gemini 3 models are in preview, pricing may change
{Pattern: "gemini-3-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00},
{Pattern: "gemini-3-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30},
{Pattern: "gemini-2.5-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00},
{Pattern: "gemini-2.5-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30},
{Pattern: "gemini-1.5-pro*", InputUSDPerMTok: 1.25, OutputUSDPerMTok: 5.00},
{Pattern: "gemini-1.5-flash*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30},
{Pattern: "gemini-*", InputUSDPerMTok: 0.075, OutputUSDPerMTok: 0.30}, // Default to flash pricing
},
"ollama": {
{Pattern: "*", InputUSDPerMTok: 0, OutputUSDPerMTok: 0},
},

View file

@ -58,6 +58,12 @@ func NewFromConfig(cfg *config.AIConfig) (Provider, error) {
// DeepSeek uses OpenAI-compatible API
return NewOpenAIClient(cfg.APIKey, cfg.GetModel(), cfg.GetBaseURL()), nil
case config.AIProviderGemini:
if cfg.APIKey == "" {
return nil, fmt.Errorf("Gemini API key is required")
}
return NewGeminiClient(cfg.APIKey, cfg.GetModel(), cfg.GetBaseURL()), nil
default:
return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
}
@ -107,6 +113,14 @@ func NewForProvider(cfg *config.AIConfig, provider, model string) (Provider, err
baseURL := cfg.GetBaseURLForProvider(config.AIProviderOllama)
return NewOllamaClient(model, baseURL), nil
case config.AIProviderGemini:
apiKey := cfg.GetAPIKeyForProvider(config.AIProviderGemini)
if apiKey == "" {
return nil, fmt.Errorf("Gemini API key not configured")
}
baseURL := cfg.GetBaseURLForProvider(config.AIProviderGemini)
return NewGeminiClient(apiKey, model, baseURL), nil
default:
return nil, fmt.Errorf("unknown provider: %s", provider)
}

View file

@ -0,0 +1,532 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/rs/zerolog/log"
)
const (
geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta"
geminiMaxRetries = 3
geminiInitialBackoff = 2 * time.Second
)
// GeminiClient implements the Provider interface for Google's Gemini API
type GeminiClient struct {
apiKey string
model string
baseURL string
client *http.Client
}
// NewGeminiClient creates a new Gemini API client
func NewGeminiClient(apiKey, model, baseURL string) *GeminiClient {
if baseURL == "" {
baseURL = geminiAPIURL
}
// Strip provider prefix if present - the model should be just the model name
if strings.HasPrefix(model, "gemini:") {
model = strings.TrimPrefix(model, "gemini:")
}
return &GeminiClient{
apiKey: apiKey,
model: model,
baseURL: baseURL,
client: &http.Client{
// 5 minutes timeout - large models can take a long time
Timeout: 300 * time.Second,
},
}
}
// Name returns the provider name
func (c *GeminiClient) Name() string {
return "gemini"
}
// geminiRequest is the request body for the Gemini API
type geminiRequest struct {
Contents []geminiContent `json:"contents"`
SystemInstruction *geminiContent `json:"systemInstruction,omitempty"`
GenerationConfig *geminiGenerationConfig `json:"generationConfig,omitempty"`
Tools []geminiToolDef `json:"tools,omitempty"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
FunctionResponse *geminiFunctionResponse `json:"functionResponse,omitempty"`
}
type geminiFunctionCall struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
}
type geminiFunctionResponse struct {
Name string `json:"name"`
Response struct {
Content string `json:"content"`
} `json:"response"`
}
type geminiGenerationConfig struct {
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
type geminiToolDef struct {
FunctionDeclarations []geminiFunctionDeclaration `json:"functionDeclarations,omitempty"`
}
type geminiFunctionDeclaration struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]interface{} `json:"parameters,omitempty"`
}
// geminiResponse is the response from the Gemini API
type geminiResponse struct {
Candidates []geminiCandidate `json:"candidates"`
UsageMetadata *geminiUsageMetadata `json:"usageMetadata"`
PromptFeedback *geminiPromptFeedback `json:"promptFeedback,omitempty"`
}
type geminiCandidate struct {
Content geminiContent `json:"content"`
FinishReason string `json:"finishReason"`
SafetyRatings []geminySafety `json:"safetyRatings,omitempty"`
}
type geminySafety struct {
Category string `json:"category"`
Probability string `json:"probability"`
Blocked bool `json:"blocked"`
}
type geminiPromptFeedback struct {
BlockReason string `json:"blockReason,omitempty"`
SafetyRatings []geminySafety `json:"safetyRatings,omitempty"`
}
type geminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
}
type geminiError struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
}
// Chat sends a chat request to the Gemini API
func (c *GeminiClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
// Convert messages to Gemini format
contents := make([]geminiContent, 0, len(req.Messages))
for _, m := range req.Messages {
// Skip system messages - they go in systemInstruction
if m.Role == "system" {
continue
}
// Convert role names (Gemini uses "user" and "model")
role := m.Role
if role == "assistant" {
role = "model"
}
// Handle tool results
if m.ToolResult != nil {
contents = append(contents, geminiContent{
Role: "user",
Parts: []geminiPart{
{
FunctionResponse: &geminiFunctionResponse{
Name: m.ToolResult.ToolUseID, // In Gemini, this is the function name
Response: struct {
Content string `json:"content"`
}{
Content: m.ToolResult.Content,
},
},
},
},
})
continue
}
// Handle assistant messages with tool calls
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
parts := make([]geminiPart, 0)
if m.Content != "" {
parts = append(parts, geminiPart{Text: m.Content})
}
for _, tc := range m.ToolCalls {
parts = append(parts, geminiPart{
FunctionCall: &geminiFunctionCall{
Name: tc.Name,
Args: tc.Input,
},
})
}
contents = append(contents, geminiContent{
Role: "model",
Parts: parts,
})
continue
}
// Simple text message
contents = append(contents, geminiContent{
Role: role,
Parts: []geminiPart{
{Text: m.Content},
},
})
}
// Use provided model or fall back to client default
model := req.Model
// Strip provider prefix if present
if strings.HasPrefix(model, "gemini:") {
model = strings.TrimPrefix(model, "gemini:")
}
if model == "" {
model = c.model
}
geminiReq := geminiRequest{
Contents: contents,
}
// Add system instruction if provided
if req.System != "" {
geminiReq.SystemInstruction = &geminiContent{
Parts: []geminiPart{{Text: req.System}},
}
}
// Add generation config
geminiReq.GenerationConfig = &geminiGenerationConfig{}
if req.MaxTokens > 0 {
geminiReq.GenerationConfig.MaxOutputTokens = req.MaxTokens
} else {
geminiReq.GenerationConfig.MaxOutputTokens = 8192
}
if req.Temperature > 0 {
geminiReq.GenerationConfig.Temperature = req.Temperature
}
// Add tools if provided
if len(req.Tools) > 0 {
funcDecls := make([]geminiFunctionDeclaration, 0, len(req.Tools))
for _, t := range req.Tools {
// Skip non-function tools
if t.Type != "" && t.Type != "function" {
continue
}
funcDecls = append(funcDecls, geminiFunctionDeclaration{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
})
}
if len(funcDecls) > 0 {
geminiReq.Tools = []geminiToolDef{{FunctionDeclarations: funcDecls}}
log.Debug().Int("tool_count", len(funcDecls)).Strs("tool_names", func() []string {
names := make([]string, len(funcDecls))
for i, f := range funcDecls {
names[i] = f.Name
}
return names
}()).Msg("Gemini request includes tools")
}
}
body, err := json.Marshal(geminiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Build the URL with API key
url := fmt.Sprintf("%s/models/%s:generateContent?key=%s", c.baseURL, model, c.apiKey)
log.Debug().Str("model", model).Str("base_url", c.baseURL).Msg("Gemini Chat request")
// Retry loop for transient errors
var respBody []byte
var lastErr error
for attempt := 0; attempt <= geminiMaxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 2s, 4s, 8s
backoff := geminiInitialBackoff * time.Duration(1<<(attempt-1))
log.Warn().
Int("attempt", attempt).
Dur("backoff", backoff).
Str("last_error", lastErr.Error()).
Msg("Retrying Gemini API request after transient error")
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(httpReq)
if err != nil {
// Check if this is a retryable connection error
errStr := err.Error()
if strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "timeout") {
lastErr = fmt.Errorf("connection error: %w", err)
continue
}
return nil, fmt.Errorf("request failed: %w", err)
}
respBody, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("failed to read response: %w", err)
continue
}
// Check for retryable HTTP errors
if resp.StatusCode == 429 || resp.StatusCode == 503 || resp.StatusCode >= 500 {
var errResp geminiError
errMsg := string(respBody)
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
errMsg = errResp.Error.Message
}
lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, errMsg)
continue
}
// Non-retryable error
if resp.StatusCode != http.StatusOK {
var errResp geminiError
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error.Message)
}
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
// Success - break out of retry loop
lastErr = nil
break
}
if lastErr != nil {
return nil, fmt.Errorf("request failed after %d retries: %w", geminiMaxRetries, lastErr)
}
var geminiResp geminiResponse
if err := json.Unmarshal(respBody, &geminiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check for prompt-level blocking
if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" {
log.Warn().
Str("block_reason", geminiResp.PromptFeedback.BlockReason).
Msg("Gemini blocked the prompt")
return nil, fmt.Errorf("prompt blocked by Gemini: %s", geminiResp.PromptFeedback.BlockReason)
}
if len(geminiResp.Candidates) == 0 {
log.Warn().Str("raw_response", string(respBody)).Msg("Gemini returned no candidates")
return nil, fmt.Errorf("no response candidates returned")
}
candidate := geminiResp.Candidates[0]
// Check for response-level blocking
if candidate.FinishReason == "SAFETY" {
blockedCategories := make([]string, 0)
for _, safety := range candidate.SafetyRatings {
if safety.Blocked {
blockedCategories = append(blockedCategories, safety.Category)
}
}
log.Warn().
Strs("blocked_categories", blockedCategories).
Msg("Gemini response blocked due to safety filters")
return nil, fmt.Errorf("response blocked by Gemini safety filters: %v", blockedCategories)
}
// Extract content and tool calls from response
var textContent string
var toolCalls []ToolCall
for _, part := range candidate.Content.Parts {
if part.Text != "" {
textContent += part.Text
}
if part.FunctionCall != nil {
// Generate a unique ID for this tool call since Gemini doesn't provide one
// Use name + index to ensure uniqueness when same function is called multiple times
toolID := fmt.Sprintf("%s_%d", part.FunctionCall.Name, len(toolCalls))
toolCalls = append(toolCalls, ToolCall{
ID: toolID,
Name: part.FunctionCall.Name,
Input: part.FunctionCall.Args,
})
}
}
log.Debug().
Str("model", model).
Int("content_length", len(textContent)).
Int("tool_calls", len(toolCalls)).
Str("finish_reason", candidate.FinishReason).
Msg("Gemini Chat response parsed")
// Map finish reason - tool_use takes priority if there are tool calls
stopReason := candidate.FinishReason
if len(toolCalls) > 0 {
// If there are tool calls, always signal tool_use so the agentic loop continues
stopReason = "tool_use"
} else if stopReason == "STOP" {
stopReason = "end_turn"
}
var inputTokens, outputTokens int
if geminiResp.UsageMetadata != nil {
inputTokens = geminiResp.UsageMetadata.PromptTokenCount
outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
}
return &ChatResponse{
Content: textContent,
Model: model,
StopReason: stopReason,
ToolCalls: toolCalls,
InputTokens: inputTokens,
OutputTokens: outputTokens,
}, nil
}
// TestConnection validates the API key by listing models
func (c *GeminiClient) TestConnection(ctx context.Context) error {
_, err := c.ListModels(ctx)
return err
}
// ListModels fetches available models from the Gemini API
func (c *GeminiClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
url := fmt.Sprintf("%s/models?key=%s", c.baseURL, c.apiKey)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
}
var result struct {
Models []struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
models := make([]ModelInfo, 0, len(result.Models))
for _, m := range result.Models {
// Only include models that support generateContent (chat)
supportsChat := false
for _, method := range m.SupportedGenerationMethods {
if method == "generateContent" {
supportsChat = true
break
}
}
if !supportsChat {
continue
}
// Extract model ID from the full name (e.g., "models/gemini-1.5-pro" -> "gemini-1.5-pro")
modelID := m.Name
if strings.HasPrefix(modelID, "models/") {
modelID = strings.TrimPrefix(modelID, "models/")
}
// Only include the useful Gemini models for chat/agentic tasks
// Filter out Gemma (open-source, no function calling), embedding, AQA, vision-only models
// Keep: gemini-3-*, gemini-2.5-*, gemini-2.0-*, gemini-1.5-* (pro and flash variants)
isUsefulModel := false
usefulPrefixes := []string{
"gemini-3-pro", "gemini-3-flash",
"gemini-2.5-pro", "gemini-2.5-flash",
"gemini-2.0-pro", "gemini-2.0-flash",
"gemini-1.5-pro", "gemini-1.5-flash",
"gemini-flash", "gemini-pro", // Latest aliases
}
for _, prefix := range usefulPrefixes {
if strings.HasPrefix(modelID, prefix) {
isUsefulModel = true
break
}
}
if !isUsefulModel {
continue
}
// Skip experimental/deprecated variants
if strings.Contains(modelID, "exp-") ||
strings.Contains(modelID, "-exp") ||
strings.Contains(modelID, "tuning") ||
strings.Contains(modelID, "8b") { // Skip smaller variants
continue
}
models = append(models, ModelInfo{
ID: modelID,
Name: m.DisplayName,
Description: m.Description,
})
}
return models, nil
}

View file

@ -690,6 +690,8 @@ func (s *Service) LoadConfig() error {
fallbackModel = config.AIProviderOpenAI + ":" + config.DefaultAIModelOpenAI
case config.AIProviderDeepSeek:
fallbackModel = config.AIProviderDeepSeek + ":" + config.DefaultAIModelDeepSeek
case config.AIProviderGemini:
fallbackModel = config.AIProviderGemini + ":" + config.DefaultAIModelGemini
case config.AIProviderOllama:
fallbackModel = config.AIProviderOllama + ":" + config.DefaultAIModelOllama
}
@ -1738,10 +1740,10 @@ func (s *Service) getTools() []providers.Tool {
},
"url": map[string]interface{}{
"type": "string",
"description": "The discovered URL (e.g., 'http://192.168.1.50:8096' for Jellyfin). Use the IP/hostname and port you discovered.",
"description": "The discovered URL (e.g., 'http://192.168.1.50:8096' for Jellyfin). Use an empty string to remove the URL.",
},
},
"required": []string{"resource_type", "resource_id", "url"},
"required": []string{"resource_type", "resource_id"},
},
},
{
@ -1907,10 +1909,12 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
return execution.Output, execution
}
}
if url == "" {
execution.Output = "Error: url is required"
return execution.Output, execution
}
// Allow empty URL to clear the setting
// if url == "" {
// execution.Output = "Error: url is required"
// return execution.Output, execution
// }
// Update the metadata
if err := s.SetResourceURL(resourceType, resourceID, url); err != nil {
@ -2460,7 +2464,14 @@ Common discovery commands:
- Check running processes: ps aux | grep -E 'node|python|java|nginx|apache|httpd'
- Get IP: hostname -I | awk '{print $1}'
When you find a web service and are confident, use set_resource_url to save it. The resource_id should match the ID from the current context.
**CRITICAL: resource_id format for set_resource_url**
The resource_id MUST be in the canonical format: {instance}:{node}:{vmid} (uses COLONS, not dashes)
- Example for VMID 201 on node "minipc" in instance "delly": resource_id = "delly:minipc:201"
- Example for VMID 101 on node "delly" in instance "delly": resource_id = "delly:delly:101"
- The instance name is typically the cluster name for PVE clusters
- Always check which NODE the guest is on (visible in the target_host or context)
When working across multiple nodes, use the correct instance:node:vmid format for each guest.
## Installing/Updating Pulse Itself
If asked to install or update Pulse itself, use the official install script. DO NOT investigate configs/services first.
@ -2779,7 +2790,7 @@ func (s *Service) ListModelsWithCache(ctx context.Context) ([]providers.ModelInf
var allModels []providers.ModelInfo
// Query each configured provider
providersList := []string{config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderDeepSeek, config.AIProviderOllama}
providersList := []string{config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderDeepSeek, config.AIProviderGemini, config.AIProviderOllama}
for _, providerName := range providersList {
if !cfg.HasProvider(providerName) {
@ -2849,6 +2860,8 @@ func providerDisplayName(provider string) string {
return "OpenAI"
case config.AIProviderDeepSeek:
return "DeepSeek"
case config.AIProviderGemini:
return "Google Gemini"
case config.AIProviderOllama:
return "Ollama"
default:

View file

@ -163,6 +163,7 @@ type AISettingsResponse struct {
AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key or OAuth is set
OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set
DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set
GeminiConfigured bool `json:"gemini_configured"` // true if Gemini API key is set
OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt)
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
@ -193,12 +194,14 @@ type AISettingsUpdateRequest struct {
AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` // Set Anthropic API key
OpenAIAPIKey *string `json:"openai_api_key,omitempty"` // Set OpenAI API key
DeepSeekAPIKey *string `json:"deepseek_api_key,omitempty"` // Set DeepSeek API key
GeminiAPIKey *string `json:"gemini_api_key,omitempty"` // Set Gemini API key
OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
// Clear flags for removing credentials
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key
ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key
ClearGeminiKey *bool `json:"clear_gemini_key,omitempty"` // Clear Gemini API key
ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL
// Cost controls
CostBudgetUSD30d *float64 `json:"cost_budget_usd_30d,omitempty"`
@ -252,6 +255,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic),
OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI),
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OpenAIBaseURL: settings.OpenAIBaseURL,
@ -314,10 +318,10 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
if req.Provider != nil {
provider := strings.ToLower(strings.TrimSpace(*req.Provider))
switch provider {
case config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderOllama, config.AIProviderDeepSeek:
case config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderOllama, config.AIProviderDeepSeek, config.AIProviderGemini:
settings.Provider = provider
default:
http.Error(w, "Invalid provider. Must be 'anthropic', 'openai', 'ollama', or 'deepseek'", http.StatusBadRequest)
http.Error(w, "Invalid provider. Must be 'anthropic', 'openai', 'ollama', 'deepseek', or 'gemini'", http.StatusBadRequest)
return
}
}
@ -377,6 +381,11 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
} else if req.DeepSeekAPIKey != nil {
settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey)
}
if req.ClearGeminiKey != nil && *req.ClearGeminiKey {
settings.GeminiAPIKey = ""
} else if req.GeminiAPIKey != nil {
settings.GeminiAPIKey = strings.TrimSpace(*req.GeminiAPIKey)
}
if req.ClearOllamaURL != nil && *req.ClearOllamaURL {
settings.OllamaBaseURL = ""
} else if req.OllamaBaseURL != nil {
@ -501,6 +510,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic),
OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI),
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OpenAIBaseURL: settings.OpenAIBaseURL,
@ -826,6 +836,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max
var req AIExecuteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Failed to decode AI execute stream request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}

View file

@ -29,6 +29,7 @@ type AIConfig struct {
AnthropicAPIKey string `json:"anthropic_api_key,omitempty"` // Anthropic API key
OpenAIAPIKey string `json:"openai_api_key,omitempty"` // OpenAI API key
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // Google Gemini API key
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
@ -63,6 +64,7 @@ const (
AIProviderOpenAI = "openai"
AIProviderOllama = "ollama"
AIProviderDeepSeek = "deepseek"
AIProviderGemini = "gemini"
)
// Default models per provider
@ -71,8 +73,10 @@ const (
DefaultAIModelOpenAI = "gpt-4o"
DefaultAIModelOllama = "llama3"
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
DefaultAIModelGemini = "gemini-2.5-flash" // Latest stable Gemini model
DefaultOllamaBaseURL = "http://localhost:11434"
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
)
// ModelInfo represents information about an available model
@ -120,7 +124,8 @@ func (c *AIConfig) IsConfigured() bool {
// Check multi-provider credentials first (new format)
if c.HasProvider(AIProviderAnthropic) || c.HasProvider(AIProviderOpenAI) ||
c.HasProvider(AIProviderDeepSeek) || c.HasProvider(AIProviderOllama) {
c.HasProvider(AIProviderDeepSeek) || c.HasProvider(AIProviderOllama) ||
c.HasProvider(AIProviderGemini) {
return true
}
@ -153,6 +158,8 @@ func (c *AIConfig) HasProvider(provider string) bool {
return c.OpenAIAPIKey != ""
case AIProviderDeepSeek:
return c.DeepSeekAPIKey != ""
case AIProviderGemini:
return c.GeminiAPIKey != ""
case AIProviderOllama:
// Ollama is only "configured" if user has explicitly set a base URL
return c.OllamaBaseURL != ""
@ -173,6 +180,9 @@ func (c *AIConfig) GetConfiguredProviders() []string {
if c.HasProvider(AIProviderDeepSeek) {
providers = append(providers, AIProviderDeepSeek)
}
if c.HasProvider(AIProviderGemini) {
providers = append(providers, AIProviderGemini)
}
if c.HasProvider(AIProviderOllama) {
providers = append(providers, AIProviderOllama)
}
@ -204,6 +214,13 @@ func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
if c.Provider == AIProviderDeepSeek {
return c.APIKey
}
case AIProviderGemini:
if c.GeminiAPIKey != "" {
return c.GeminiAPIKey
}
if c.Provider == AIProviderGemini {
return c.APIKey
}
}
return ""
}
@ -227,6 +244,8 @@ func (c *AIConfig) GetBaseURLForProvider(provider string) string {
return "" // Uses default OpenAI URL
case AIProviderDeepSeek:
return DefaultDeepSeekBaseURL
case AIProviderGemini:
return DefaultGeminiBaseURL
}
return ""
}
@ -240,7 +259,7 @@ func (c *AIConfig) IsUsingOAuth() bool {
// Returns the provider and model name. If no provider prefix, attempts to detect.
func ParseModelString(model string) (provider, modelName string) {
// Check for explicit provider prefix
for _, p := range []string{AIProviderAnthropic, AIProviderOpenAI, AIProviderDeepSeek, AIProviderOllama} {
for _, p := range []string{AIProviderAnthropic, AIProviderOpenAI, AIProviderDeepSeek, AIProviderGemini, AIProviderOllama} {
prefix := p + ":"
if len(model) > len(prefix) && model[:len(prefix)] == prefix {
return p, model[len(prefix):]
@ -255,6 +274,8 @@ func ParseModelString(model string) (provider, modelName string) {
return AIProviderOpenAI, model
case len(model) >= 8 && model[:8] == "deepseek":
return AIProviderDeepSeek, model
case len(model) >= 6 && model[:6] == "gemini":
return AIProviderGemini, model
default:
// Assume Ollama for unrecognized models (local models have varied names)
return AIProviderOllama, model
@ -277,6 +298,8 @@ func (c *AIConfig) GetBaseURL() string {
return DefaultOllamaBaseURL
case AIProviderDeepSeek:
return DefaultDeepSeekBaseURL
case AIProviderGemini:
return DefaultGeminiBaseURL
}
return ""
}
@ -300,6 +323,8 @@ func (c *AIConfig) GetModel() string {
return DefaultAIModelOllama
case AIProviderDeepSeek:
return DefaultAIModelDeepSeek
case AIProviderGemini:
return DefaultAIModelGemini
}
}
@ -313,6 +338,8 @@ func (c *AIConfig) GetModel() string {
return DefaultAIModelOllama
case AIProviderDeepSeek:
return DefaultAIModelDeepSeek
case AIProviderGemini:
return DefaultAIModelGemini
default:
return ""
}

View file

@ -56,8 +56,13 @@ func (s *GuestMetadataStore) Get(guestID string) *GuestMetadata {
}
// GetWithLegacyMigration retrieves metadata for a guest, attempting legacy ID formats if needed
// and migrating them to the new stable format. This should be called when full guest context
// (node, instance, vmid) is available.
// and migrating them to the new stable format (instance:node:vmid).
// This should be called when full guest context (instance, node, vmid) is available.
//
// Legacy formats attempted (in order):
// 1. instance-node-vmid (e.g., "delly-minipc-201") - most specific legacy format
// 2. instance-vmid (e.g., "delly-201") - old cluster format without node
// 3. node-vmid (e.g., "minipc-201") - standalone format
func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node string, vmid int) *GuestMetadata {
s.mu.RLock()
meta, exists := s.metadata[guestID]
@ -67,15 +72,10 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri
return meta
}
// Try legacy formats and migrate if found
var legacyID string
var legacyMeta *GuestMetadata
// Try legacy format: instance-node-VMID
if instance != node {
legacyID = fmt.Sprintf("%s-%s-%d", instance, node, vmid)
// Helper to migrate a legacy ID to the new format
migrate := func(legacyID string) *GuestMetadata {
s.mu.RLock()
legacyMeta = s.metadata[legacyID]
legacyMeta := s.metadata[legacyID]
s.mu.RUnlock()
if legacyMeta != nil {
@ -99,35 +99,27 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri
return legacyMeta
}
return nil
}
// Try standalone format: node-VMID
if instance == node {
legacyID = fmt.Sprintf("%s-%d", node, vmid)
s.mu.RLock()
legacyMeta = s.metadata[legacyID]
s.mu.RUnlock()
// Try legacy format 1: instance-node-vmid (most specific)
if instance != node {
if result := migrate(fmt.Sprintf("%s-%s-%d", instance, node, vmid)); result != nil {
return result
}
}
if legacyMeta != nil {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest metadata from legacy standalone ID format")
// Try legacy format 2: instance-vmid (old cluster format)
// This was used when cluster name was used without node differentiation
if result := migrate(fmt.Sprintf("%s-%d", instance, vmid)); result != nil {
return result
}
s.mu.Lock()
// Move to new ID
s.metadata[guestID] = legacyMeta
legacyMeta.ID = guestID
delete(s.metadata, legacyID)
// Save asynchronously
go func() {
if err := s.save(); err != nil {
log.Error().Err(err).Msg("Failed to save guest metadata after migration")
}
}()
s.mu.Unlock()
return legacyMeta
// Try legacy format 3: node-vmid (standalone format or node-only reference)
// Only try if instance != node to avoid duplicate check
if instance != node {
if result := migrate(fmt.Sprintf("%s-%d", node, vmid)); result != nil {
return result
}
}

View file

@ -622,23 +622,30 @@ func TestGuestMetadataStore_GetWithLegacyMigration_NotFound(t *testing.T) {
}
}
func TestGuestMetadataStore_GetWithLegacyMigration_ClusteredSkipStandalone(t *testing.T) {
func TestGuestMetadataStore_GetWithLegacyMigration_ClusteredMatchesNodeFormat(t *testing.T) {
tmpDir := t.TempDir()
store := &GuestMetadataStore{
metadata: make(map[string]*GuestMetadata),
dataPath: tmpDir,
}
// Only add standalone format (shouldn't match for clustered request)
// Add node-vmid format (legacy standalone format)
store.metadata["node1-100"] = &GuestMetadata{
ID: "node1-100",
CustomURL: "http://standalone.com",
}
// Clustered request (instance != node) should NOT match standalone format
// Clustered request (instance != node) CAN match node-vmid as fallback
// This handles cases where metadata was created with old format
result := store.GetWithLegacyMigration("pve1:node1:100", "pve1", "node1", 100)
if result != nil {
t.Error("Clustered request should not match standalone legacy format")
if result == nil {
t.Fatal("Should migrate from node-vmid format for clustered request")
}
if result.CustomURL != "http://standalone.com" {
t.Errorf("CustomURL = %q, want %q", result.CustomURL, "http://standalone.com")
}
if result.ID != "pve1:node1:100" {
t.Errorf("ID = %q, want %q", result.ID, "pve1:node1:100")
}
}

View file

@ -211,34 +211,29 @@ func TestMakeGuestID(t *testing.T) {
cases := []struct {
name string
instanceName string
clusterName string
isCluster bool
node string
vmid int
want string
}{
// Standalone nodes use instance name
{name: "standalone node", instanceName: "pve-host1", clusterName: "", isCluster: false, vmid: 100, want: "pve-host1-100"},
{name: "standalone with empty cluster name", instanceName: "pve-standalone", clusterName: "", isCluster: false, vmid: 200, want: "pve-standalone-200"},
{name: "non-cluster even with cluster name", instanceName: "pve-node", clusterName: "my-cluster", isCluster: false, vmid: 150, want: "pve-node-150"},
// Standard cases with canonical format: instance:node:vmid
{name: "basic cluster guest", instanceName: "delly", node: "delly", vmid: 100, want: "delly:delly:100"},
{name: "multi-node cluster", instanceName: "delly", node: "minipc", vmid: 201, want: "delly:minipc:201"},
{name: "standalone node", instanceName: "pve-standalone", node: "pve-standalone", vmid: 200, want: "pve-standalone:pve-standalone:200"},
// Cluster nodes use cluster name
{name: "cluster node uses cluster name", instanceName: "pve-host1", clusterName: "my-cluster", isCluster: true, vmid: 100, want: "my-cluster-100"},
{name: "different cluster node same cluster", instanceName: "pve-host2", clusterName: "my-cluster", isCluster: true, vmid: 100, want: "my-cluster-100"},
{name: "cluster with different vmid", instanceName: "pve-host1", clusterName: "production", isCluster: true, vmid: 999, want: "production-999"},
// Names with special characters
{name: "hyphenated instance", instanceName: "my-cluster", node: "node-1", vmid: 100, want: "my-cluster:node-1:100"},
{name: "production cluster", instanceName: "production", node: "web-server", vmid: 999, want: "production:web-server:999"},
// Edge case: isCluster true but no cluster name falls back to instance name
{name: "cluster flag but no name", instanceName: "pve-node", clusterName: "", isCluster: true, vmid: 300, want: "pve-node-300"},
// Edge case: special characters and spaces (shouldn't happen but test anyway)
{name: "cluster name with hyphen", instanceName: "pve-node1", clusterName: "my-prod-cluster", isCluster: true, vmid: 101, want: "my-prod-cluster-101"},
// Edge cases
{name: "different vmids same node", instanceName: "pve1", node: "node1", vmid: 300, want: "pve1:node1:300"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := makeGuestID(tc.instanceName, tc.clusterName, tc.isCluster, tc.vmid); got != tc.want {
t.Fatalf("makeGuestID(%q, %q, %v, %d) = %q, want %q", tc.instanceName, tc.clusterName, tc.isCluster, tc.vmid, got, tc.want)
if got := makeGuestID(tc.instanceName, tc.node, tc.vmid); got != tc.want {
t.Fatalf("makeGuestID(%q, %q, %d) = %q, want %q", tc.instanceName, tc.node, tc.vmid, got, tc.want)
}
})
}

View file

@ -686,20 +686,19 @@ func safeFloat(val float64) float64 {
return val
}
// makeGuestID generates a stable guest ID that is cluster-aware.
// When the instance is part of a cluster, the cluster name is used as the primary identifier
// to prevent duplicate guests when multiple cluster nodes are configured as separate PVE instances.
// Format when in cluster: clusterName-VMID (e.g., "my-cluster-100")
// Format when standalone: instanceName-VMID (e.g., "pve-host1-100")
// This ensures VMs/containers are properly deduplicated across multiple agents in the same cluster.
func makeGuestID(instanceName string, clusterName string, isCluster bool, vmid int) string {
// Use cluster name as the identifier when the instance is part of a cluster
// This ensures guests are identified by their cluster, not by which node reported them
if isCluster && clusterName != "" {
return fmt.Sprintf("%s-%d", clusterName, vmid)
}
// For standalone nodes, use the instance name
return fmt.Sprintf("%s-%d", instanceName, vmid)
// makeGuestID generates a stable, canonical guest ID that includes instance, node, and VMID.
// Format: {instance}:{node}:{vmid} (e.g., "delly:minipc:201")
//
// Using colons as separators prevents ambiguity with dashes in instance/node names.
// This format ensures:
// - Unique IDs across all deployment scenarios (single agent, per-node agents, mixed)
// - Stable IDs that don't change when monitoring topology changes
// - Easy parsing to extract instance, node, and VMID components
//
// For clustered setups, the instance name is the cluster name.
// For standalone nodes, the instance name matches the node name.
func makeGuestID(instanceName string, node string, vmid int) string {
return fmt.Sprintf("%s:%s:%d", instanceName, node, vmid)
}
// parseDurationEnv parses a duration from an environment variable, returning defaultVal if not set or invalid
@ -5871,8 +5870,8 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
var allContainers []models.Container
for _, res := range resources {
// Use cluster-aware guest ID to prevent duplicates when multiple cluster nodes are configured
guestID := makeGuestID(instanceName, clusterName, isCluster, res.VMID)
// Generate canonical guest ID: instance:node:vmid
guestID := makeGuestID(instanceName, res.Node, res.VMID)
// Debug log the resource type
log.Debug().
@ -6356,6 +6355,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
}
}
}
// Trigger guest metadata migration if old format exists
if m.guestMetadataStore != nil {
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, res.Node, res.VMID)
}
allVMs = append(allVMs, vm)
@ -6548,6 +6552,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
container.DiskWrite = 0
}
// Trigger guest metadata migration if old format exists
if m.guestMetadataStore != nil {
m.guestMetadataStore.GetWithLegacyMigration(guestID, instanceName, res.Node, res.VMID)
}
allContainers = append(allContainers, container)
m.recordGuestSnapshot(instanceName, container.Type, res.Node, res.VMID, GuestMemorySnapshot{

View file

@ -255,8 +255,8 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, clu
tags = strings.Split(vm.Tags, ";")
}
// Use cluster-aware guest ID to prevent duplicates when multiple cluster nodes are configured
guestID := makeGuestID(instanceName, clusterName, isCluster, vm.VMID)
// Generate canonical guest ID: instance:node:vmid
guestID := makeGuestID(instanceName, n.Node, vm.VMID)
guestRaw := VMMemoryRaw{
ListingMem: vm.Mem,
@ -970,8 +970,8 @@ func (m *Monitor) pollContainersWithNodes(ctx context.Context, instanceName stri
tags = strings.Split(container.Tags, ";")
}
// Use cluster-aware guest ID to prevent duplicates when multiple cluster nodes are configured
guestID := makeGuestID(instanceName, clusterName, isCluster, int(container.VMID))
// Generate canonical guest ID: instance:node:vmid
guestID := makeGuestID(instanceName, n.Node, int(container.VMID))
// Calculate I/O rates
currentMetrics := IOMetrics{