diff --git a/.opencode/config.json b/.opencode/config.json index f40e04569..308eceda9 100644 --- a/.opencode/config.json +++ b/.opencode/config.json @@ -1,20 +1,24 @@ { "$schema": "https://opencode.ai/config.schema.json", - "mcp": { - "pulse": { - "type": "http", - "url": "http://localhost:0", - "description": "Pulse infrastructure tools - URL is set dynamically at runtime" - } - }, "agent": { - "model": "claude-sonnet-4-20250514", - "tools": ["pulse_*"] + "tools": [ + "pulse_*" + ] }, "instructions": [ "You are Pulse's AI assistant for infrastructure monitoring and management.", - "Use pulse_* tools to interact with the monitored infrastructure.", - "Be concise and direct in responses.", - "Focus on actionable insights and solutions." + "You have access to pulse_* MCP tools. ALWAYS use them for infrastructure questions:", + "- pulse_get_infrastructure_state: Get all VMs, containers, hosts", + "- pulse_get_active_alerts: Get current alerts and warnings", + "- pulse_get_metrics_history: Get CPU/memory/disk history for resources", + "- pulse_get_resource_details: Get details for a specific VM/container", + "- pulse_get_baselines: Get learned normal behavior", + "- pulse_get_patterns: Get detected patterns and predictions", + "- pulse_get_disk_health: Get SMART data and disk status", + "- pulse_get_storage: Get storage pool information", + "- pulse_run_command: Execute commands on managed hosts", + "When asked about infrastructure, VMs, containers, alerts, metrics, or system status, ALWAYS use pulse_* tools.", + "Do NOT use webfetch for infrastructure questions - use the MCP tools.", + "Be concise and direct. Focus on actionable insights." ] -} +} \ No newline at end of file diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 5e69b60eb..9e076cc85 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -901,18 +901,32 @@ func initKubernetesWithRetry(ctx context.Context, cfg kubernetesagent.Config, lo // applyRemoteSettings merges remote settings into the local configuration. // Supported keys: +// - enable_host (bool) // - enable_docker (bool) // - enable_kubernetes (bool) // - enable_proxmox (bool) // - proxmox_type (string) +// - docker_runtime (string) +// - disable_auto_update (bool) +// - disable_docker_update_checks (bool) +// - kube_include_all_pods (bool) +// - kube_include_all_deployments (bool) // - log_level (string) // - interval (string/duration) +// - report_ip (string) +// - disable_ceph (bool) func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *zerolog.Logger) { for k, v := range settings { switch k { + case "enable_host": + if b, ok := v.(bool); ok { + cfg.EnableHost = b + logger.Info().Bool("val", b).Msg("Remote config: enable_host") + } case "enable_docker": if b, ok := v.(bool); ok { cfg.EnableDocker = b + cfg.DockerConfigured = true logger.Info().Bool("val", b).Msg("Remote config: enable_docker") } case "enable_kubernetes": @@ -927,9 +941,18 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z } case "proxmox_type": if s, ok := v.(string); ok { - cfg.ProxmoxType = s + normalized := strings.TrimSpace(strings.ToLower(s)) + if normalized == "auto" { + normalized = "" + } + cfg.ProxmoxType = normalized logger.Info().Str("val", s).Msg("Remote config: proxmox_type") } + case "docker_runtime": + if s, ok := v.(string); ok { + cfg.DockerRuntime = strings.TrimSpace(strings.ToLower(s)) + logger.Info().Str("val", s).Msg("Remote config: docker_runtime") + } case "log_level": if s, ok := v.(string); ok { if l, err := zerolog.ParseLevel(s); err == nil { @@ -952,6 +975,26 @@ func applyRemoteSettings(cfg *Config, settings map[string]interface{}, logger *z cfg.Interval = time.Duration(f) * time.Second logger.Info().Float64("val", f).Msg("Remote config: interval (s)") } + case "disable_auto_update": + if b, ok := v.(bool); ok { + cfg.DisableAutoUpdate = b + logger.Info().Bool("val", b).Msg("Remote config: disable_auto_update") + } + case "disable_docker_update_checks": + if b, ok := v.(bool); ok { + cfg.DisableDockerUpdateChecks = b + logger.Info().Bool("val", b).Msg("Remote config: disable_docker_update_checks") + } + case "kube_include_all_pods": + if b, ok := v.(bool); ok { + cfg.KubeIncludeAllPods = b + logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_pods") + } + case "kube_include_all_deployments": + if b, ok := v.(bool); ok { + cfg.KubeIncludeAllDeployments = b + logger.Info().Bool("val", b).Msg("Remote config: kube_include_all_deployments") + } case "report_ip": if s, ok := v.(string); ok { cfg.ReportIP = s diff --git a/docs/AGENTS_AI_SCOPE_PLAN.md b/docs/AGENTS_AI_SCOPE_PLAN.md new file mode 100644 index 000000000..99ce053f1 --- /dev/null +++ b/docs/AGENTS_AI_SCOPE_PLAN.md @@ -0,0 +1,149 @@ +AGENTS AI SCOPE PROFILE PLAN + +Context +- Agent profiles exist today and are managed via AgentProfilesAPI. +- Current UI copy implied AI could auto-create profiles, but that flow does not exist. +- Goal: add AI-assisted suggestions that are always reviewed and explicitly created by the user. + +Goals +- Provide an AI "Suggest profile" flow that drafts a scope profile and explains the rationale. +- Keep user in control: no auto-creation, no auto-assignment, no silent changes. +- Integrate with existing profile CRUD (AgentProfilesAPI.createProfile / assignProfile). + +Non-Goals +- No background or automatic profile creation. +- No automatic assignment to agents. +- No backend changes to agent config schema in this phase. + +Proposed UX +Entry points +- Agent Profiles page: "Suggest profile" button next to "New Profile". +- Optional: Unified Agents table row action "Suggest profile from this agent" to seed context. + +Flow +1) User clicks "Suggest profile". +2) Modal opens with: + - Prompt text area (optional) + default prompt template. + - Scope of inputs toggles (agent telemetry, current profile examples, etc.). +3) AI returns a draft: + - name + - description (why this profile exists) + - config JSON + - rationale bullets (what signals led to the configuration) +4) User reviews and can edit name/description/config. +5) User clicks "Create profile". +6) Optional follow-up: "Assign to agents" (separate explicit action). + +UX requirements +- Clear labels: "Suggest" or "Draft", never "Auto-create". +- Show JSON in a code editor-style area with validation errors. +- "Create profile" disabled until JSON validates. +- Provide copy/export for the config JSON. + +Data inputs (minimal viable) +- User prompt text. +- Selected agent IDs (if starting from an agent). +- Basic agent metadata: hostname, platform, versions, tags, types (host/docker/k8s). + +Data inputs (nice to have) +- Recent health signals: last seen, status, error flags. +- Existing profile list to avoid duplicates and suggest edits. +- Links to the agent config schema (so suggestions are valid). + +AI output contract (server side) +- A stable JSON envelope with: + - name: string + - description: string + - config: object + - rationale: string[] +- If model returns invalid JSON, backend should retry once and then return a friendly error. + +API proposal +- POST /api/admin/profiles/suggestions + - body: { prompt, agentIds?: string[], includeTelemetry?: boolean } + - response: { name, description, config, rationale } +- This endpoint can be a thin wrapper around the internal LLM service. +- If not licensed, return 402 and the UI should show the Pro gating. + +Frontend plan +1) Add "Suggest profile" button to AgentProfilesPanel. +2) Create SuggestProfileModal component: + - prompt input + - loading state + - response preview (name, description, rationale) + - editable config text area with validation +3) On "Create profile", call AgentProfilesAPI.createProfile. +4) On success, refresh profile list and show toast. +5) Optional: allow "Assign to selected agents" step. + +Backend plan (minimal) +1) Add suggestion endpoint that: + - Gathers agent context (if agentIds provided). + - Calls LLM with template. + - Returns validated JSON. +2) Ensure prompt redaction of secrets/tokens. +3) Log prompt usage for auditing (excluding secrets). + +Safety and product constraints +- Never apply changes without a user click. +- Show a "This is a draft" warning. +- Document that AI outputs are suggestions and may need adjustments. +- Respect licensing (Pro feature) and return 402 if unlicensed. + +Testing +- Unit: modal renders, validation errors, createProfile call. +- Unit: suggestion endpoint response mapping. +- Integration: suggestion -> create profile -> list refresh. +- Regression: no auto-assignment when suggestion is created. + +Open questions +- Which telemetry fields are safe/useful to include by default? +- Should suggestions be limited to host agents only? +- Do we need a schema-aware editor to reduce invalid configs? + +--- + +## Implementation Summary (Completed) + +### Backend Changes +1. **New file**: `internal/api/profile_suggestions.go` + - `ProfileSuggestionHandler` - handles AI-assisted profile suggestions + - `SuggestionRequest` / `ProfileSuggestion` types for API contract + - Prompt template includes available config keys and their types + - Parses LLM JSON response and validates config + +2. **Modified file**: `internal/api/config_profiles.go` + - Added `suggestionHandler` field to `ConfigProfileHandler` + - Added `SetAIHandler()` method to inject AI capability + - Added routing for `POST /suggestions` in `ServeHTTP()` + +3. **Modified file**: `internal/api/router.go` + - Wired AI handler to profile handler: `r.configProfileHandler.SetAIHandler(r.aiHandler)` + +### Frontend Changes +1. **Modified file**: `frontend-modern/src/api/agentProfiles.ts` + - Added `ProfileSuggestionRequest` and `ProfileSuggestion` interfaces + - Added `suggestProfile()` method to `AgentProfilesAPI` + +2. **New file**: `frontend-modern/src/components/Settings/SuggestProfileModal.tsx` + - Modal with prompt input and example prompts + - Loading state during AI call + - Preview of suggested profile with name, description, config JSON, and rationale + - "Draft" warning banner + - "Use This Profile" button to accept suggestion + +3. **Modified file**: `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx` + - Added "Suggest Profile" button (purple, with Sparkles icon) next to "New Profile" + - Added `showSuggestModal` state + - Added `handleSuggest()` and `handleSuggestionAccepted()` handlers + - When suggestion is accepted, pre-fills the create profile form + +### UX Flow +1. User clicks "Suggest Profile" button +2. Modal opens with prompt input and example prompts +3. User describes what they need +4. AI generates a profile suggestion +5. User reviews name, description, config JSON, and rationale +6. User clicks "Use This Profile" +7. Modal closes, create profile form opens pre-filled with suggestion +8. User can edit and then click "Create Profile" diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index cd56202b7..5dcdadd55 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -48,7 +48,7 @@ import { TokenRevealDialog } from './components/TokenRevealDialog'; import { useAlertsActivation } from './stores/alertsActivation'; import { UpdateProgressModal } from './components/UpdateProgressModal'; import type { UpdateStatus } from './api/updates'; -import { AIChat } from './components/AI/AIChat'; +import { AIChat } from './components/AI/Chat'; import { AIStatusIndicator } from './components/AI/AIStatusIndicator'; import { aiChatStore } from './stores/aiChat'; import { useResourcesAsLegacy } from './hooks/useResources'; @@ -1194,17 +1194,17 @@ function AppLayout(props: { breakdown: { warning: number; critical: number } | undefined; icon: JSX.Element; }> = [ - { - id: 'alerts', - label: 'Alerts', - route: '/alerts', - tooltip: 'Review active alerts and automation rules', - badge: null, - count: activeAlertCount, - breakdown, - icon: , - }, - ]; + { + id: 'alerts', + label: 'Alerts', + route: '/alerts', + tooltip: 'Review active alerts and automation rules', + badge: null, + count: activeAlertCount, + breakdown, + icon: , + }, + ]; // Only show settings tab if user has access if (hasSettingsAccess) { diff --git a/frontend-modern/src/api/agentProfiles.ts b/frontend-modern/src/api/agentProfiles.ts index 33eb02919..6a54738c9 100644 --- a/frontend-modern/src/api/agentProfiles.ts +++ b/frontend-modern/src/api/agentProfiles.ts @@ -6,7 +6,9 @@ import { apiFetch, apiFetchJSON } from '@/utils/apiClient'; export interface AgentProfile { id: string; name: string; + description?: string; config: Record; + version?: number; created_at: string; updated_at: string; } @@ -20,6 +22,23 @@ export interface AgentProfileAssignment { updated_at: string; } +/** + * Request for AI-assisted profile suggestion. + */ +export interface ProfileSuggestionRequest { + prompt: string; +} + +/** + * AI-generated profile suggestion. + */ +export interface ProfileSuggestion { + name: string; + description: string; + config: Record; + rationale: string[]; +} + /** * API client for agent profiles (Pro feature). * Endpoints are gated behind license - returns 402 if not licensed. @@ -60,11 +79,11 @@ export class AgentProfilesAPI { /** * Create a new profile. */ - static async createProfile(name: string, config: Record): Promise { + static async createProfile(name: string, config: Record, description?: string): Promise { const response = await apiFetch(this.baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, config }), + body: JSON.stringify({ name, description, config }), }); if (!response.ok) { @@ -78,11 +97,11 @@ export class AgentProfilesAPI { /** * Update an existing profile. */ - static async updateProfile(id: string, name: string, config: Record): Promise { + static async updateProfile(id: string, name: string, config: Record, description?: string): Promise { const response = await apiFetch(`${this.baseUrl}/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, name, config }), + body: JSON.stringify({ id, name, description, config }), }); if (!response.ok) { @@ -151,4 +170,26 @@ export class AgentProfilesAPI { throw new Error(text || `Failed to unassign profile: ${response.status}`); } } + + /** + * Get AI-assisted profile suggestion. + * Requires AI to be enabled and running. + */ + static async suggestProfile(request: ProfileSuggestionRequest): Promise { + const response = await apiFetch(`${this.baseUrl}/suggestions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 503) { + throw new Error('AI service is not available. Please check AI settings.'); + } + throw new Error(text || `Failed to get suggestion: ${response.status}`); + } + + return response.json(); + } } diff --git a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx index 38130d252..a78468d3c 100644 --- a/frontend-modern/src/components/AI/Chat/ChatMessages.tsx +++ b/frontend-modern/src/components/AI/Chat/ChatMessages.tsx @@ -14,23 +14,41 @@ interface ChatMessagesProps { }; } +/** + * ChatMessages - Renders the scrollable message list. + * + * Features: + * - Auto-scroll to bottom on new messages + * - Empty state with suggestions + * - Smooth scrolling behavior + */ export const ChatMessages: Component = (props) => { let messagesEndRef: HTMLDivElement | undefined; + let containerRef: HTMLDivElement | undefined; - // Auto-scroll to bottom + // Auto-scroll to bottom on new messages createEffect(() => { - if (props.messages.length > 0 && messagesEndRef) { - messagesEndRef.scrollIntoView({ behavior: 'smooth' }); + if (props.messages.length > 0 && messagesEndRef && containerRef) { + // Only auto-scroll if user is near the bottom + const { scrollTop, scrollHeight, clientHeight } = containerRef; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; + + if (isNearBottom) { + messagesEndRef.scrollIntoView({ behavior: 'smooth' }); + } } }); return ( -
+
{/* Empty state */}
{/* AI Icon */} -
+
= (props) => {
-

+

{props.emptyState!.title}

-

+

{props.emptyState!.subtitle}

@@ -61,9 +79,10 @@ export const ChatMessages: Component = (props) => { )} @@ -84,7 +103,7 @@ export const ChatMessages: Component = (props) => { {/* Scroll anchor */} -
+
); }; diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index c14502dc5..19c7c3899 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -1,9 +1,9 @@ -import { Component, Show, For, Switch, Match } from 'solid-js'; +import { Component, Show, For, Switch, Match, createMemo } from 'solid-js'; import { renderMarkdown } from '../aiChatUtils'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolExecutionBlock, PendingToolBlock } from './ToolExecutionBlock'; import { ApprovalCard } from './ApprovalCard'; -import type { ChatMessage, PendingApproval } from './types'; +import type { ChatMessage, PendingApproval, StreamDisplayEvent } from './types'; interface MessageItemProps { message: ChatMessage; @@ -11,72 +11,152 @@ interface MessageItemProps { onSkip: (toolId: string) => void; } +/** + * MessageItem - Renders a single message in the chat. + * + * User messages: Compact, right-aligned bubble + * Assistant messages: Full-width, terminal-like with clear sections + */ export const MessageItem: Component = (props) => { const isUser = () => props.message.role === 'user'; + const hasStreamEvents = () => props.message.streamEvents && props.message.streamEvents.length > 0; + // Group stream events for cleaner rendering + // Combine consecutive content events, separate thinking and tools + const groupedEvents = createMemo(() => { + const events = props.message.streamEvents || []; + const grouped: StreamDisplayEvent[] = []; + + for (const evt of events) { + // Thinking events are kept separate + if (evt.type === 'thinking') { + grouped.push(evt); + continue; + } + + // Tool events are kept separate + if (evt.type === 'tool') { + grouped.push(evt); + continue; + } + + // Content events can be merged with previous content + if (evt.type === 'content' && evt.content) { + const lastIdx = grouped.length - 1; + if (lastIdx >= 0 && grouped[lastIdx].type === 'content') { + grouped[lastIdx] = { + ...grouped[lastIdx], + content: (grouped[lastIdx].content || '') + evt.content, + }; + } else { + grouped.push(evt); + } + } + } + + return grouped; + }); + + // Check if currently streaming content (no tools pending, still streaming) + const isStreamingText = () => + props.message.isStreaming && + (!props.message.pendingTools || props.message.pendingTools.length === 0); + return ( -
-
-
- {/* User messages */} - -

{props.message.content}

-
+
+ {/* User message - compact bubble */} + +
+

{props.message.content}

+
+
- {/* Assistant messages with stream events */} - - {/* Stream events (chronological order) */} - -
- - {(evt) => ( - - - - - - - - -
- - - )} - -
+ {/* Assistant message - full width, terminal-like */} + +
+ {/* Assistant indicator */} +
+
+ + + +
+ Assistant + + + · {props.message.model} + +
- {/* Pending tools (running) */} - 0}> -
- - {(tool) => } - -
+ {/* Main content area */} +
+ {/* Stream events - chronological display */} + + + {(evt) => ( + + {/* Thinking block - collapsed by default */} + + + + + {/* Pending tool (currently running) - shown in chronological position */} + + + + + {/* Completed tool execution block */} + + + + + {/* Content/text block */} + +
+ + + )} + {/* Fallback: show content if no stream events */}
{/* Pending approvals */} 0}> -
+
{(approval) => ( = (props) => {
- {/* Streaming indicator */} - -
-
- - - -
+ {/* Streaming text indicator */} + + + + + {/* Token count footer */} + +
+ {props.message.tokens!.input + props.message.tokens!.output} tokens + · + {props.message.tokens!.input} in / {props.message.tokens!.output} out
- -
- - {/* Message metadata footer */} - -
- {props.message.model} - - - {props.message.tokens!.input + props.message.tokens!.output} tokens - -
-
-
+
+
); }; diff --git a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx index 3bf110fdc..9a19a0006 100644 --- a/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx @@ -1,44 +1,72 @@ -import { Component, createSignal } from 'solid-js'; +import { Component, createSignal, Show, createMemo } from 'solid-js'; import { sanitizeThinking } from '../aiChatUtils'; interface ThinkingBlockProps { content: string; - maxLength?: number; + isStreaming?: boolean; } +/** + * ThinkingBlock - Displays AI's reasoning/thinking in a collapsed-by-default block. + * + * Inspired by OpenCode's terminal TUI which shows thinking as a subtle, + * collapsible section that doesn't distract from the main response. + */ export const ThinkingBlock: Component = (props) => { const [expanded, setExpanded] = createSignal(false); - const truncated = () => { - const max = props.maxLength ?? 300; - const text = props.content; - return text.length > max && !expanded() ? text.substring(0, max) + '...' : text; - }; + // Count lines and words for preview + const stats = createMemo(() => { + const lines = props.content.split('\n').filter(l => l.trim()).length; + const words = props.content.split(/\s+/).filter(w => w).length; + return { lines, words }; + }); - const needsExpansion = () => { - const max = props.maxLength ?? 300; - return props.content.length > max; - }; + // Get a short preview (first line, truncated) + const preview = createMemo(() => { + const firstLine = props.content.split('\n').find(l => l.trim()) || ''; + const maxLen = 60; + if (firstLine.length > maxLen) { + return firstLine.substring(0, maxLen).trim() + '...'; + } + return firstLine.trim(); + }); return ( -
- {/* Header - clickable to toggle */} +
+ {/* Collapsed header - always visible */} - {/* Content */} -
-
- {sanitizeThinking(truncated())} + {/* Expanded content */} + +
+
+            {sanitizeThinking(props.content)}
+          
-
+
); }; diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index 052055b8c..2418a9924 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -1,66 +1,100 @@ -import { Component, Show, createSignal } from 'solid-js'; +import { Component, Show, createSignal, createMemo, For } from 'solid-js'; import type { ToolExecution, PendingTool } from './types'; interface ToolExecutionBlockProps { tool: ToolExecution; - maxOutputLength?: number; } +/** + * ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style. + */ export const ToolExecutionBlock: Component = (props) => { - const [expanded, setExpanded] = createSignal(false); + const [showOutput, setShowOutput] = createSignal(false); - const truncatedOutput = () => { - const max = props.maxOutputLength ?? 500; - const output = props.tool.output; - if (!output) return ''; - return output.length > max && !expanded() ? output.substring(0, max) + '...' : output; - }; + // Get display name for tool + const toolLabel = createMemo(() => { + const name = props.tool.name; + if (name === 'run_command' || name === 'pulse_run_command') return 'cmd'; + if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch'; + if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') return 'infra'; + if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts'; + if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics'; + if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines'; + if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns'; + if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks'; + if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; + if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; + if (name.includes('finding')) return 'finding'; + return name.replace(/^pulse_/, '').replace(/_/g, ' ').substring(0, 12); + }); - const needsTruncation = () => { - const max = props.maxOutputLength ?? 500; - return props.tool.output && props.tool.output.length > max; - }; + // Check if output is non-empty and interesting + const hasOutput = createMemo(() => { + const output = props.tool.output || ''; + return output.trim().length > 0 && !output.includes('not available'); + }); + + // Truncate output + const displayOutput = createMemo(() => { + const output = props.tool.output || ''; + const maxLen = 300; + if (!showOutput() && output.length > maxLen) { + return output.substring(0, maxLen) + '...'; + } + return output; + }); + + const statusIcon = () => props.tool.success ? '✓' : '✗'; + const statusColor = () => props.tool.success + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-red-600 dark:text-red-400'; return ( -
- {/* Header */} +
+ {/* Compact single-line header */}
hasOutput() && setShowOutput(!showOutput())} > -
- - - -
- {props.tool.input} - - - - - - - - + {/* Status icon */} + {statusIcon()} + + {/* Tool label */} + + {toolLabel()} + + + {/* Command/input - truncated */} + + {props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input} + + + {/* Expand indicator if has output */} + + +
- {/* Output */} - -
-
-            {truncatedOutput()}
+      {/* Expanded output */}
+      
+        
+
+            {displayOutput()}
           
- + 300}>
@@ -69,24 +103,126 @@ export const ToolExecutionBlock: Component = (props) => ); }; -// Pending tool (still running) +/** + * PendingToolBlock - Compact single-line display for running tools + */ interface PendingToolBlockProps { tool: PendingTool; } export const PendingToolBlock: Component = (props) => { + const toolLabel = createMemo(() => { + const name = props.tool.name; + if (name === 'run_command' || name === 'pulse_run_command') return 'cmd'; + if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch'; + if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') return 'infra'; + if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts'; + if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics'; + if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines'; + if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns'; + if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks'; + if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; + if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; + if (name.includes('finding')) return 'finding'; + return name.replace(/^pulse_/, '').replace(/_/g, ' ').substring(0, 12); + }); + return ( -
-
-
- - - - -
- {props.tool.input} - Running -
+
+ {/* Spinner */} + + + + + + {/* Tool label */} + + {toolLabel()} + + + {/* Command - truncated */} + + {props.tool.input.length > 50 ? props.tool.input.substring(0, 50) + '...' : props.tool.input} + +
+ ); +}; + +/** + * PendingToolsList - Groups multiple pending tools into a compact list + */ +interface PendingToolsListProps { + tools: PendingTool[]; +} + +export const PendingToolsList: Component = (props) => { + const [expanded, setExpanded] = createSignal(false); + + // If 3 or fewer, show all. Otherwise show collapsed. + const shouldCollapse = () => props.tools.length > 3; + const visibleTools = () => { + if (!shouldCollapse() || expanded()) return props.tools; + return props.tools.slice(0, 2); + }; + const hiddenCount = () => props.tools.length - 2; + + return ( +
+ + {(tool) => } + + + + + +
+ ); +}; + +/** + * ToolExecutionsList - Compact list for multiple completed tools + */ +interface ToolExecutionsListProps { + tools: ToolExecution[]; +} + +export const ToolExecutionsList: Component = (props) => { + const [showAll, setShowAll] = createSignal(false); + + // If more than 5 tools, collapse them + const shouldCollapse = () => props.tools.length > 5; + const visibleTools = () => { + if (!shouldCollapse() || showAll()) return props.tools; + return props.tools.slice(0, 3); + }; + const hiddenCount = () => props.tools.length - 3; + + // Count successes/failures + const stats = createMemo(() => { + const success = props.tools.filter(t => t.success).length; + const failed = props.tools.length - success; + return { success, failed }; + }); + + return ( +
+ + {(tool) => } + + + + +
); }; diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 1339a9269..b8de41558 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -49,6 +49,7 @@ export function useChat(options: UseChatOptions = {}) { // Helper to add stream event for chronological display const addStreamEvent = (msg: ChatMessage, event: StreamDisplayEvent): ChatMessage => { const events = msg.streamEvents || []; + // For content events, merge consecutive content into one if (event.type === 'content' && events.length > 0) { const last = events[events.length - 1]; @@ -62,6 +63,21 @@ export function useChat(options: UseChatOptions = {}) { }; } } + + // For thinking events, merge consecutive thinking into one + if (event.type === 'thinking' && events.length > 0) { + const last = events[events.length - 1]; + if (last.type === 'thinking') { + return { + ...msg, + streamEvents: [ + ...events.slice(0, -1), + { ...last, thinking: (last.thinking || '') + (event.thinking || '') }, + ], + }; + } + } + return { ...msg, streamEvents: [...events, event], @@ -103,18 +119,31 @@ export function useChat(options: UseChatOptions = {}) { case 'tool_start': { const data = event.data as { name: string; input: string }; + const toolId = generateId(); // Unique ID to track this tool + const pendingTool = { name: data.name, input: data.input }; + + // Add to streamEvents in chronological position + const updated = addStreamEvent(msg, { + type: 'pending_tool', + pendingTool, + toolId, + }); + return { - ...msg, - pendingTools: [...(msg.pendingTools || []), { name: data.name, input: data.input }], + ...updated, + pendingTools: [...(msg.pendingTools || []), { ...pendingTool, id: toolId } as any], }; } case 'tool_end': { const data = event.data as { name: string; input: string; output: string; success: boolean }; const pendingTools = msg.pendingTools || []; - const matchingIndex = pendingTools.findIndex((t) => t.name === data.name); - const updatedPending = matchingIndex >= 0 - ? [...pendingTools.slice(0, matchingIndex), ...pendingTools.slice(matchingIndex + 1)] + const events = msg.streamEvents || []; + + // Find the matching pending tool (by name, since we may not have ID in the event) + const matchingPendingIndex = pendingTools.findIndex((t) => t.name === data.name); + const updatedPending = matchingPendingIndex >= 0 + ? [...pendingTools.slice(0, matchingPendingIndex), ...pendingTools.slice(matchingPendingIndex + 1)] : pendingTools; const newToolCall: ToolExecution = { @@ -124,10 +153,21 @@ export function useChat(options: UseChatOptions = {}) { success: data.success, }; - // Add tool to streamEvents for chronological display - const updated = addStreamEvent(msg, { type: 'tool', tool: newToolCall }); + // Find the pending_tool event in streamEvents and replace it with completed tool + // Search from the end to find the most recent matching pending tool + let updatedEvents = [...events]; + for (let i = events.length - 1; i >= 0; i--) { + const evt = events[i]; + if (evt.type === 'pending_tool' && evt.pendingTool?.name === data.name) { + // Replace pending with completed + updatedEvents[i] = { type: 'tool', tool: newToolCall }; + break; + } + } + return { - ...updated, + ...msg, + streamEvents: updatedEvents, pendingTools: updatedPending, toolCalls: [...(msg.toolCalls || []), newToolCall], }; diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 34d68d0b6..a4adbcd3e 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1,4 +1,4 @@ -import { Component, Show, createSignal, onMount, For } from 'solid-js'; +import { Component, Show, createSignal, onMount, For, createMemo } from 'solid-js'; import { OpenCodeAPI, type ChatSession } from '@/api/opencode'; import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; @@ -10,6 +10,12 @@ interface AIChatProps { onClose: () => void; } +/** + * AIChat - Main chat panel component. + * + * Provides a terminal-like chat experience with clear status indicators, + * session management, and streaming response display. + */ export const AIChat: Component = (props) => { // UI state const [isOpen] = createSignal(true); @@ -20,6 +26,30 @@ export const AIChat: Component = (props) => { // Chat hook const chat = useChat(); + // Compute current status for display + const currentStatus = createMemo(() => { + if (!chat.isLoading()) return null; + + const messages = chat.messages(); + const lastMessage = messages[messages.length - 1]; + + if (!lastMessage || lastMessage.role !== 'assistant') { + return { type: 'thinking', text: 'Thinking...' }; + } + + if (lastMessage.pendingTools && lastMessage.pendingTools.length > 0) { + const tool = lastMessage.pendingTools[0]; + const toolName = tool.name.replace(/^pulse_/, '').replace(/_/g, ' '); + return { type: 'tool', text: `Running ${toolName}...` }; + } + + if (lastMessage.isStreaming) { + return { type: 'generating', text: 'Generating response...' }; + } + + return { type: 'thinking', text: 'Thinking...' }; + }); + // Load sessions on mount onMount(async () => { try { @@ -79,38 +109,37 @@ export const AIChat: Component = (props) => { }; // Empty state for approval (not used with OpenCode but keeping interface) - const handleApprove = (_messageId: string, _approval: PendingApproval) => {}; - const handleSkip = (_messageId: string, _toolId: string) => {}; + const handleApprove = (_messageId: string, _approval: PendingApproval) => { }; + const handleSkip = (_messageId: string, _toolId: string) => { }; return (
{/* Header */} -
+
-
+
-

AI Assistant

-

+

AI Assistant

+

Powered by OpenCode

-
+
{/* Session picker */}
-
+