wip: AI provider improvements and chat store enhancements
- Improve Anthropic provider error handling - Add AI service enhancements - Update AI chat store with additional state management
This commit is contained in:
parent
a15dc6b899
commit
fcb258ac2d
6 changed files with 465 additions and 22 deletions
302
frontend-modern/PLAN-column-visibility.md
Normal file
302
frontend-modern/PLAN-column-visibility.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# Plan: Toggleable Table Columns
|
||||
|
||||
## Problem
|
||||
The current drawer pattern hides useful information (IPs, OS, backup status, node, tags) that users need to click to reveal. This goes against Pulse's core philosophy of dense, scannable, comparable data at a glance.
|
||||
|
||||
## Goal
|
||||
Replace drawer-hidden info with optional table columns that:
|
||||
1. Show data inline for easy comparison across rows
|
||||
2. Are toggleable by the user (show/hide)
|
||||
3. Auto-show based on available horizontal space
|
||||
4. Persist user preferences
|
||||
|
||||
## Current State
|
||||
|
||||
### Infrastructure Already Exists
|
||||
- `ColumnPriority` system: `'essential' | 'primary' | 'secondary' | 'supplementary' | 'detailed'`
|
||||
- `PRIORITY_BREAKPOINTS` maps priorities to responsive breakpoints
|
||||
- `usePersistentSignal` for localStorage persistence
|
||||
- `STANDARD_COLUMNS` with predefined column configs
|
||||
- `useBreakpoint` hook for responsive behavior
|
||||
|
||||
### Current Columns (Dashboard/Proxmox)
|
||||
All marked `essential` (always visible):
|
||||
- Name, Type, VMID, Uptime
|
||||
- CPU, Memory, Disk (progress bars)
|
||||
- Disk Read, Disk Write, Net In, Net Out
|
||||
|
||||
### Data Available but Hidden in Drawer
|
||||
- IP addresses (`guest.ipAddresses`)
|
||||
- OS name/version (`guest.osName`, `guest.osVersion`)
|
||||
- Node (`guest.node`)
|
||||
- Backup status (`guest.lastBackup`)
|
||||
- Tags (`guest.tags`)
|
||||
- CPUs allocated (`guest.cpus`)
|
||||
- Agent version (`guest.agentVersion`)
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1: Add New Column Definitions
|
||||
|
||||
Update `GUEST_COLUMNS` in `GuestRow.tsx`:
|
||||
|
||||
```typescript
|
||||
export const GUEST_COLUMNS: ColumnDef[] = [
|
||||
// Essential - always visible
|
||||
{ id: 'name', label: 'Name', priority: 'essential' },
|
||||
{ id: 'type', label: 'Type', priority: 'essential' },
|
||||
{ id: 'vmid', label: 'VMID', priority: 'essential' },
|
||||
|
||||
// Primary - visible on sm+ (640px)
|
||||
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' },
|
||||
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
|
||||
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
|
||||
|
||||
// Secondary - visible on md+ (768px), user toggleable
|
||||
{ id: 'ip', label: 'IP', priority: 'secondary', toggleable: true },
|
||||
{ id: 'uptime', label: 'Uptime', priority: 'secondary', toggleable: true },
|
||||
{ id: 'node', label: 'Node', priority: 'secondary', toggleable: true },
|
||||
|
||||
// Supplementary - visible on lg+ (1024px), user toggleable
|
||||
{ id: 'backup', label: 'Backup', priority: 'supplementary', toggleable: true },
|
||||
{ id: 'os', label: 'OS', priority: 'supplementary', toggleable: true },
|
||||
{ id: 'tags', label: 'Tags', priority: 'supplementary', toggleable: true },
|
||||
|
||||
// Detailed - visible on xl+ (1280px), user toggleable
|
||||
{ id: 'diskRead', label: 'D Read', priority: 'detailed', toggleable: true },
|
||||
{ id: 'diskWrite', label: 'D Write', priority: 'detailed', toggleable: true },
|
||||
{ id: 'netIn', label: 'Net In', priority: 'detailed', toggleable: true },
|
||||
{ id: 'netOut', label: 'Net Out', priority: 'detailed', toggleable: true },
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 2: Column Visibility State
|
||||
|
||||
Create a hook for managing column visibility:
|
||||
|
||||
```typescript
|
||||
// hooks/useColumnVisibility.ts
|
||||
export function useColumnVisibility(
|
||||
storageKey: string,
|
||||
columns: ColumnDef[]
|
||||
) {
|
||||
// Get toggleable columns
|
||||
const toggleableIds = columns.filter(c => c.toggleable).map(c => c.id);
|
||||
|
||||
// Default: all toggleable columns visible
|
||||
const defaultVisible = new Set(toggleableIds);
|
||||
|
||||
// Persist to localStorage
|
||||
const [hiddenColumns, setHiddenColumns] = usePersistentSignal<Set<string>>(
|
||||
storageKey,
|
||||
new Set(),
|
||||
{
|
||||
serialize: (set) => JSON.stringify([...set]),
|
||||
deserialize: (str) => new Set(JSON.parse(str)),
|
||||
}
|
||||
);
|
||||
|
||||
const isVisible = (id: string) => !hiddenColumns().has(id);
|
||||
const toggle = (id: string) => {
|
||||
const hidden = new Set(hiddenColumns());
|
||||
if (hidden.has(id)) {
|
||||
hidden.delete(id);
|
||||
} else {
|
||||
hidden.add(id);
|
||||
}
|
||||
setHiddenColumns(hidden);
|
||||
};
|
||||
|
||||
return { isVisible, toggle, hiddenColumns, toggleableIds };
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Column Picker UI
|
||||
|
||||
Add a column picker dropdown to the filter bar:
|
||||
|
||||
```typescript
|
||||
// components/shared/ColumnPicker.tsx
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open())}
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg
|
||||
bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300
|
||||
hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<ColumnsIcon class="w-3.5 h-3.5" />
|
||||
Columns
|
||||
</button>
|
||||
|
||||
<Show when={open()}>
|
||||
<div class="absolute right-0 mt-1 w-48 rounded-lg border bg-white dark:bg-gray-800 shadow-lg z-50">
|
||||
<For each={toggleableColumns}>
|
||||
{(col) => (
|
||||
<label class="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible(col.id)}
|
||||
onChange={() => toggle(col.id)}
|
||||
/>
|
||||
<span class="text-sm">{col.label}</span>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Phase 4: Render New Column Cells
|
||||
|
||||
Add cell renderers in `GuestRow.tsx`:
|
||||
|
||||
```typescript
|
||||
// IP column
|
||||
<Show when={visibleColumns().includes('ip')}>
|
||||
<td class="...">
|
||||
<Show when={guest.ipAddresses?.length}>
|
||||
<span class="text-xs font-mono truncate" title={guest.ipAddresses?.join(', ')}>
|
||||
{guest.ipAddresses?.[0]}
|
||||
{guest.ipAddresses?.length > 1 && ` +${guest.ipAddresses.length - 1}`}
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
</Show>
|
||||
|
||||
// Backup column
|
||||
<Show when={visibleColumns().includes('backup')}>
|
||||
<td class="...">
|
||||
<BackupStatusBadge lastBackup={guest.lastBackup} />
|
||||
</td>
|
||||
</Show>
|
||||
|
||||
// Node column
|
||||
<Show when={visibleColumns().includes('node')}>
|
||||
<td class="...">
|
||||
<span class="text-xs truncate">{guest.node}</span>
|
||||
</td>
|
||||
</Show>
|
||||
|
||||
// OS column
|
||||
<Show when={visibleColumns().includes('os')}>
|
||||
<td class="...">
|
||||
<span class="text-xs truncate" title={`${guest.osName} ${guest.osVersion}`}>
|
||||
{guest.osName || '—'}
|
||||
</span>
|
||||
</td>
|
||||
</Show>
|
||||
|
||||
// Tags column
|
||||
<Show when={visibleColumns().includes('tags')}>
|
||||
<td class="...">
|
||||
<TagBadges tags={guest.tags} compact />
|
||||
</td>
|
||||
</Show>
|
||||
```
|
||||
|
||||
### Phase 5: Update Table Header
|
||||
|
||||
Dynamically render headers based on visible columns:
|
||||
|
||||
```typescript
|
||||
<thead>
|
||||
<tr>
|
||||
<For each={visibleColumns()}>
|
||||
{(col) => (
|
||||
<th
|
||||
class="..."
|
||||
onClick={() => col.sortable && handleSort(col.id)}
|
||||
>
|
||||
{col.label}
|
||||
<Show when={sortKey() === col.id}>
|
||||
<SortIndicator direction={sortDirection()} />
|
||||
</Show>
|
||||
</th>
|
||||
)}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
### Phase 6: Responsive Behavior
|
||||
|
||||
Combine user preferences with breakpoint-based visibility:
|
||||
|
||||
```typescript
|
||||
const visibleColumns = createMemo(() => {
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
return GUEST_COLUMNS.filter(col => {
|
||||
// Always show essential columns
|
||||
if (col.priority === 'essential') return true;
|
||||
|
||||
// Check if breakpoint supports this priority
|
||||
const minBreakpoint = PRIORITY_BREAKPOINTS[col.priority];
|
||||
const hasSpace = breakpointIndex(breakpoint()) >= breakpointIndex(minBreakpoint);
|
||||
|
||||
// If toggleable, also check user preference
|
||||
if (col.toggleable) {
|
||||
return hasSpace && isVisible(col.id);
|
||||
}
|
||||
|
||||
return hasSpace;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. **`src/components/Dashboard/GuestRow.tsx`**
|
||||
- Expand `GUEST_COLUMNS` with new columns
|
||||
- Add cell renderers for IP, backup, node, OS, tags
|
||||
- Accept `visibleColumns` prop
|
||||
|
||||
2. **`src/components/Dashboard/Dashboard.tsx`**
|
||||
- Import and use column visibility hook
|
||||
- Pass visible columns to header and rows
|
||||
|
||||
3. **`src/components/Dashboard/DashboardFilter.tsx`**
|
||||
- Add ColumnPicker component
|
||||
|
||||
4. **`src/hooks/useColumnVisibility.ts`** (new)
|
||||
- Create the column visibility management hook
|
||||
|
||||
5. **`src/components/shared/ColumnPicker.tsx`** (new)
|
||||
- Create the column picker dropdown component
|
||||
|
||||
6. **`src/utils/localStorage.ts`**
|
||||
- Add `DASHBOARD_COLUMN_VISIBILITY` storage key
|
||||
|
||||
7. **Repeat for Hosts and Docker tabs**
|
||||
- Similar changes to `HostsOverview.tsx`
|
||||
- Similar changes to `DockerUnifiedTable.tsx`
|
||||
|
||||
## What Happens to Drawers?
|
||||
|
||||
After columns are implemented:
|
||||
- **Keep drawers** but make them optional/minimal
|
||||
- Drawer becomes a place for:
|
||||
- AI Context annotations (already there)
|
||||
- Very detailed info (full filesystem list, all network interfaces)
|
||||
- Actions (future: start/stop/migrate buttons)
|
||||
- Or **remove drawers entirely** if columns cover everything needed
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. Implement columns first (Phase 1-6)
|
||||
2. Test with real data
|
||||
3. Decide what remains valuable in drawers
|
||||
4. Either slim down drawers or remove them
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
- New hook: ~50 lines
|
||||
- ColumnPicker component: ~80 lines
|
||||
- GuestRow changes: ~150 lines (new cells)
|
||||
- Dashboard changes: ~30 lines (wiring)
|
||||
- Header changes: ~40 lines
|
||||
- Repeat for Hosts: ~200 lines
|
||||
- Repeat for Docker: ~200 lines
|
||||
|
||||
**Total: ~750 lines of new/modified code**
|
||||
|
|
@ -229,10 +229,13 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Focus input when drawer opens
|
||||
// Focus input when drawer opens and register with store for keyboard shortcuts
|
||||
createEffect(() => {
|
||||
if (isOpen() && inputRef) {
|
||||
setTimeout(() => inputRef?.focus(), 100);
|
||||
aiChatStore.registerInput(inputRef);
|
||||
} else {
|
||||
aiChatStore.registerInput(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -282,7 +285,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||
// For assistant messages, prepend tool call outputs so AI has full context
|
||||
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
|
||||
const toolSummary = m.toolCalls
|
||||
.map((tc) => `[Executed: ${tc.input}]\n${tc.output}`)
|
||||
.map((tc) => `Command: ${tc.input}\nOutput: ${tc.output}`)
|
||||
.join('\n\n');
|
||||
content = toolSummary + (content ? '\n\n' + content : '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ const [contextItems, setContextItems] = createSignal<ContextItem[]>([]);
|
|||
const [messages, setMessages] = createSignal<Message[]>([]);
|
||||
const [aiEnabled, setAiEnabled] = createSignal<boolean | null>(null); // null = not checked yet
|
||||
|
||||
// Store reference to AI input for focusing from keyboard shortcuts
|
||||
let aiInputRef: HTMLTextAreaElement | null = null;
|
||||
|
||||
export const aiChatStore = {
|
||||
// Check if chat is open
|
||||
get isOpen() {
|
||||
|
|
@ -189,4 +192,18 @@ export const aiChatStore = {
|
|||
});
|
||||
setIsAIChatOpen(true);
|
||||
},
|
||||
|
||||
// Register the AI input element (called by AIChat component)
|
||||
registerInput(ref: HTMLTextAreaElement | null) {
|
||||
aiInputRef = ref;
|
||||
},
|
||||
|
||||
// Focus the AI input (called by keyboard handlers)
|
||||
focusInput() {
|
||||
if (aiInputRef && isAIChatOpen()) {
|
||||
aiInputRef.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -57,10 +57,13 @@ type anthropicMessage struct {
|
|||
Content interface{} `json:"content"` // Can be string or []anthropicContent
|
||||
}
|
||||
|
||||
// anthropicTool represents a regular function tool
|
||||
type anthropicTool struct {
|
||||
Type string `json:"type,omitempty"` // "web_search_20250305" for web search, omit for regular tools
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]interface{} `json:"input_schema"`
|
||||
Description string `json:"description,omitempty"` // Not used for web search
|
||||
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // Not used for web search
|
||||
MaxUses int `json:"max_uses,omitempty"` // For web search: limit searches per request
|
||||
}
|
||||
|
||||
// anthropicResponse is the response from the Anthropic API
|
||||
|
|
@ -76,14 +79,14 @@ type anthropicResponse struct {
|
|||
}
|
||||
|
||||
type anthropicContent struct {
|
||||
Type string `json:"type"` // "text" or "tool_use" or "tool_result"
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"` // For tool_use
|
||||
Name string `json:"name,omitempty"` // For tool_use
|
||||
Input map[string]interface{} `json:"input,omitempty"` // For tool_use
|
||||
ToolUseID string `json:"tool_use_id,omitempty"` // For tool_result
|
||||
Content string `json:"content,omitempty"` // For tool_result (when it's a string)
|
||||
IsError bool `json:"is_error,omitempty"` // For tool_result
|
||||
Type string `json:"type"` // "text", "tool_use", "tool_result", "server_tool_use", "web_search_tool_result"
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"` // For tool_use
|
||||
Name string `json:"name,omitempty"` // For tool_use
|
||||
Input map[string]interface{} `json:"input,omitempty"` // For tool_use
|
||||
ToolUseID string `json:"tool_use_id,omitempty"` // For tool_result
|
||||
Content json.RawMessage `json:"content,omitempty"` // Can be string or array (for web_search_tool_result)
|
||||
IsError bool `json:"is_error,omitempty"` // For tool_result
|
||||
}
|
||||
|
||||
type anthropicUsage struct {
|
||||
|
|
@ -113,14 +116,15 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
|
|||
|
||||
// Handle tool results specially
|
||||
if m.ToolResult != nil {
|
||||
// Tool result message
|
||||
// Tool result message - Content needs to be JSON-encoded string
|
||||
contentJSON, _ := json.Marshal(m.ToolResult.Content)
|
||||
messages = append(messages, anthropicMessage{
|
||||
Role: "user",
|
||||
Content: []anthropicContent{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: m.ToolResult.ToolUseID,
|
||||
Content: m.ToolResult.Content,
|
||||
Content: contentJSON,
|
||||
IsError: m.ToolResult.IsError,
|
||||
},
|
||||
},
|
||||
|
|
@ -186,10 +190,20 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
|
|||
if len(req.Tools) > 0 {
|
||||
anthropicReq.Tools = make([]anthropicTool, len(req.Tools))
|
||||
for i, t := range req.Tools {
|
||||
anthropicReq.Tools[i] = anthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
if t.Type == "web_search_20250305" {
|
||||
// Web search tool has a special format
|
||||
anthropicReq.Tools[i] = anthropicTool{
|
||||
Type: t.Type,
|
||||
Name: t.Name,
|
||||
MaxUses: t.MaxUses,
|
||||
}
|
||||
} else {
|
||||
// Regular function tool
|
||||
anthropicReq.Tools[i] = anthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.InputSchema,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -284,11 +298,21 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
|
|||
case "text":
|
||||
textContent += c.Text
|
||||
case "tool_use":
|
||||
// Regular tool use - we need to execute these
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Input: c.Input,
|
||||
})
|
||||
case "server_tool_use":
|
||||
// Server-side tool (like web_search) - Anthropic handles these automatically
|
||||
// We just log it for debugging, no action needed
|
||||
log.Debug().
|
||||
Str("tool_name", c.Name).
|
||||
Msg("Server tool use detected (handled by Anthropic)")
|
||||
case "web_search_tool_result":
|
||||
// Results from web search - already incorporated into Claude's response
|
||||
log.Debug().Msg("Web search results received")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ type ToolResult struct {
|
|||
|
||||
// Tool represents an AI tool definition
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]interface{} `json:"input_schema"`
|
||||
Type string `json:"type,omitempty"` // "web_search_20250305" for web search, empty for regular tools
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema map[string]interface{} `json:"input_schema,omitempty"`
|
||||
MaxUses int `json:"max_uses,omitempty"` // For web search: limit searches per request
|
||||
}
|
||||
|
||||
// ChatRequest represents a request to the AI provider
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package ai
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
|
|
@ -705,6 +708,9 @@ func (s *Service) getToolInputDisplay(tc providers.ToolCall) string {
|
|||
case "read_file":
|
||||
path, _ := tc.Input["path"].(string)
|
||||
return path
|
||||
case "fetch_url":
|
||||
url, _ := tc.Input["url"].(string)
|
||||
return url
|
||||
default:
|
||||
return fmt.Sprintf("%v", tc.Input)
|
||||
}
|
||||
|
|
@ -724,7 +730,7 @@ func (s *Service) hasAgentForTarget(req ExecuteRequest) bool {
|
|||
|
||||
// getTools returns the available tools for AI
|
||||
func (s *Service) getTools() []providers.Tool {
|
||||
return []providers.Tool{
|
||||
tools := []providers.Tool{
|
||||
{
|
||||
Name: "run_command",
|
||||
Description: "Execute a shell command. By default runs on the current target, but you can override to run on the Proxmox host for operations like resizing disks, managing containers, etc.",
|
||||
|
|
@ -757,7 +763,32 @@ func (s *Service) getTools() []providers.Tool {
|
|||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "fetch_url",
|
||||
Description: "Fetch content from a URL. Use this to check if web services are responding, read API endpoints, or fetch documentation. Works with local network URLs and public sites.",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The URL to fetch (e.g., 'http://192.168.1.50:8080/api/health' or 'https://example.com/docs')",
|
||||
},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add web search tool for Anthropic provider
|
||||
if s.provider != nil && s.provider.Name() == "anthropic" {
|
||||
tools = append(tools, providers.Tool{
|
||||
Type: "web_search_20250305",
|
||||
Name: "web_search",
|
||||
MaxUses: 3, // Limit searches per request to control costs
|
||||
})
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
// executeTool executes a tool call and returns the result
|
||||
|
|
@ -835,12 +866,76 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
|
|||
execution.Success = true
|
||||
return result, execution
|
||||
|
||||
case "fetch_url":
|
||||
urlStr, _ := tc.Input["url"].(string)
|
||||
execution.Input = urlStr
|
||||
|
||||
if urlStr == "" {
|
||||
execution.Output = "Error: url is required"
|
||||
return execution.Output, execution
|
||||
}
|
||||
|
||||
// Fetch the URL
|
||||
result, err := s.fetchURL(ctx, urlStr)
|
||||
if err != nil {
|
||||
execution.Output = fmt.Sprintf("Error fetching URL: %s", err)
|
||||
return execution.Output, execution
|
||||
}
|
||||
|
||||
execution.Output = result
|
||||
execution.Success = true
|
||||
return result, execution
|
||||
|
||||
default:
|
||||
execution.Output = fmt.Sprintf("Unknown tool: %s", tc.Name)
|
||||
return execution.Output, execution
|
||||
}
|
||||
}
|
||||
|
||||
// fetchURL fetches content from a URL with size limits and timeout
|
||||
func (s *Service) fetchURL(ctx context.Context, urlStr string) (string, error) {
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Create request with context
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Set a reasonable user agent
|
||||
req.Header.Set("User-Agent", "Pulse-AI/1.0")
|
||||
|
||||
// Make the request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response with size limit (64KB)
|
||||
const maxSize = 64 * 1024
|
||||
limitedReader := io.LimitReader(resp.Body, maxSize)
|
||||
body, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Build result with status info
|
||||
result := fmt.Sprintf("HTTP %d %s\n", resp.StatusCode, resp.Status)
|
||||
result += fmt.Sprintf("Content-Type: %s\n", resp.Header.Get("Content-Type"))
|
||||
result += fmt.Sprintf("Content-Length: %d bytes\n\n", len(body))
|
||||
result += string(body)
|
||||
|
||||
if len(body) == maxSize {
|
||||
result += "\n\n[Response truncated at 64KB]"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// executeOnAgent executes a command via the agent WebSocket
|
||||
func (s *Service) executeOnAgent(ctx context.Context, req ExecuteRequest, command string) (string, error) {
|
||||
if s.agentServer == nil {
|
||||
|
|
|
|||
Loading…
Reference in a new issue