mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
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:
parent
5c9bbf33b6
commit
c91307be94
17 changed files with 946 additions and 112 deletions
|
|
@ -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'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
532
internal/ai/providers/gemini.go
Normal file
532
internal/ai/providers/gemini.go
Normal 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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue