ZovOS AI
+AI-ассистент для управления Linux
+diff --git a/.gitignore b/.gitignore index 5b90e79..ef58396 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,34 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins +# Binaries *.exe *.exe~ *.dll *.so *.dylib +zovos -# Test binary, built with `go test -c` +# Build output +/bin/ +/dist/ + +# Test *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out +coverage.html -# Dependency directories (remove the comment below to include it) -# vendor/ +# Go +/vendor/ -# Go workspace file -go.work -go.work.sum +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ -# env file +# OS +.DS_Store +Thumbs.db + +# Config with secrets +configs/local.yaml .env - diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c1b8b5d --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: build run test lint clean + +APP_NAME := zovos +BUILD_DIR := bin + +build: + go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/zovos/ + +run: build + ./$(BUILD_DIR)/$(APP_NAME) --config configs/default.yaml + +test: + go test ./... -v -race + +lint: + golangci-lint run ./... + +clean: + rm -rf $(BUILD_DIR) + +docker-build: + docker build -t zovos-ai . diff --git a/README.md b/README.md index 3642678..9dbcc03 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ -# ZovOS_AI +# ZovOS AI +**AI-ассистент для Linux**, написанный на Go from scratch. + +Управляет компьютером, браузером и терминалом. Поддерживает локальные и облачные нейросети. + +## Возможности + +- 🤖 **AI-провайдеры**: OpenAI, Anthropic, Google Gemini, Ollama (локальные модели) +- 🖥️ **Управление терминалом**: выполнение команд, интерактивные PTY-сессии +- 🌐 **Управление браузером**: навигация, клики, ввод текста, скриншоты +- 🖱️ **Управление рабочим столом**: мышь, клавиатура, скриншоты, управление окнами +- 📁 **Файловая система**: чтение, запись, поиск файлов +- 📋 **Буфер обмена**: чтение и запись +- 🎨 **Современный UI**: веб-интерфейс с тёмной темой, WebSocket стриминг + +## Установка + +```bash +# Клонирование +git clone https://git.bebrik.xyz/Dan4ick/ZovOS_AI.git +cd ZovOS_AI + +# Сборка +make build + +# Запуск +make run +``` + +## Конфигурация + +Скопируйте `configs/default.yaml` в `configs/local.yaml` и укажите API-ключи: + +```yaml +ai: + default_provider: ollama + openai: + api_key: "sk-..." + anthropic: + api_key: "sk-ant-..." +``` + +## Зависимости (Linux) + +- Go 1.22+ +- `xdotool` — управление мышью/клавиатурой +- `xclip` — буфер обмена +- Chromium — управление браузером +- Ollama (опционально) — локальные модели + +## Лицензия + +MIT diff --git a/configs/default.yaml b/configs/default.yaml new file mode 100644 index 0000000..b6bda90 --- /dev/null +++ b/configs/default.yaml @@ -0,0 +1,63 @@ +server: + host: "0.0.0.0" + port: 8080 + +ai: + default_provider: "ollama" + + ollama: + base_url: "http://localhost:11434" + model: "llama3" + + openai: + api_key: "" + model: "gpt-4o" + base_url: "https://api.openai.com/v1" + + anthropic: + api_key: "" + model: "claude-sonnet-4-20250514" + base_url: "https://api.anthropic.com" + + google: + api_key: "" + model: "gemini-2.0-flash" + +agent: + max_iterations: 20 + timeout_seconds: 300 + system_prompt: | + You are ZovOS AI — a powerful AI assistant that can control a Linux computer. + You have access to tools: terminal commands, browser control, desktop control, filesystem operations. + Think step by step. Use tools when needed to accomplish the user's request. + Always explain what you're doing before executing actions. + +tools: + terminal: + enabled: true + shell: "/bin/bash" + timeout_seconds: 60 + blocked_commands: + - "rm -rf /" + - "mkfs" + - "dd if=/dev/zero" + + browser: + enabled: true + headless: false + + desktop: + enabled: true + + filesystem: + enabled: true + allowed_paths: + - "/home" + - "/tmp" + + clipboard: + enabled: true + +logging: + level: "info" + format: "text" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1dd0bb9 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.bebrik.xyz/Dan4ick/ZovOS_AI + +go 1.22.0 + +require ( + github.com/gorilla/websocket v1.5.3 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..5c3567a --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,142 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "time" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/tools" +) + +// StreamCallback is called for each chunk of streamed output. +type StreamCallback func(chunk string, toolCall *ai.ToolCall, toolResult *string, done bool) + +// Agent orchestrates the AI reasoning loop. +type Agent struct { + provider ai.Provider + tools *tools.Registry + cfg config.AgentConfig + log *slog.Logger +} + +// New creates a new Agent. +func New(provider ai.Provider, toolsRegistry *tools.Registry, cfg config.AgentConfig, log *slog.Logger) *Agent { + return &Agent{ + provider: provider, + tools: toolsRegistry, + cfg: cfg, + log: log, + } +} + +// SetProvider updates the active AI provider. +func (a *Agent) SetProvider(p ai.Provider) { + a.provider = p +} + +// Run executes the agent loop: send messages → get response → execute tools → repeat. +func (a *Agent) Run(ctx context.Context, userMessage string, history []ai.Message, callback StreamCallback) ([]ai.Message, error) { + timeout := time.Duration(a.cfg.TimeoutSeconds) * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + messages := make([]ai.Message, 0, len(history)+2) + + // Add system prompt + if a.cfg.SystemPrompt != "" { + messages = append(messages, ai.Message{ + Role: "system", + Content: a.cfg.SystemPrompt, + }) + } + + // Add conversation history + messages = append(messages, history...) + + // Add new user message + messages = append(messages, ai.Message{ + Role: "user", + Content: userMessage, + }) + + availableTools := a.tools.List() + + for iteration := 0; iteration < a.cfg.MaxIterations; iteration++ { + a.log.Info("agent iteration", "iteration", iteration+1, "messages", len(messages)) + + // Try streaming first + streamCh, err := a.provider.StreamChat(ctx, messages, availableTools) + if err != nil { + return messages, fmt.Errorf("AI provider error: %w", err) + } + + // Collect full response from stream + var fullContent string + var allToolCalls []ai.ToolCall + + for chunk := range streamCh { + if chunk.Error != nil { + return messages, fmt.Errorf("stream error: %w", chunk.Error) + } + if chunk.Content != "" { + fullContent += chunk.Content + if callback != nil { + callback(chunk.Content, nil, nil, false) + } + } + if len(chunk.ToolCalls) > 0 { + allToolCalls = append(allToolCalls, chunk.ToolCalls...) + } + } + + // Add assistant response to messages + assistantMsg := ai.Message{ + Role: "assistant", + Content: fullContent, + ToolCalls: allToolCalls, + } + messages = append(messages, assistantMsg) + + // If no tool calls, we're done + if len(allToolCalls) == 0 { + if callback != nil { + callback("", nil, nil, true) + } + return messages, nil + } + + // Execute tool calls + for _, tc := range allToolCalls { + a.log.Info("executing tool call", "tool", tc.Function.Name, "id", tc.ID) + + if callback != nil { + callback("", &tc, nil, false) + } + + result, err := a.tools.Execute(ctx, tc.Function.Name, tc.Function.Arguments) + if err != nil { + result = fmt.Sprintf("Tool execution error: %v", err) + } + + if callback != nil { + callback("", &tc, &result, false) + } + + messages = append(messages, ai.Message{ + Role: "tool", + Content: result, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + } + + if callback != nil { + callback("", nil, nil, true) + } + + return messages, fmt.Errorf("max iterations (%d) reached", a.cfg.MaxIterations) +} diff --git a/internal/agent/memory.go b/internal/agent/memory.go new file mode 100644 index 0000000..292e75e --- /dev/null +++ b/internal/agent/memory.go @@ -0,0 +1,66 @@ +package agent + +import ( + "sync" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" +) + +// Memory stores conversation history per session. +type Memory struct { + mu sync.RWMutex + sessions map[string][]ai.Message +} + +// NewMemory creates a new Memory store. +func NewMemory() *Memory { + return &Memory{ + sessions: make(map[string][]ai.Message), + } +} + +// Get returns the message history for a session. +func (m *Memory) Get(sessionID string) []ai.Message { + m.mu.RLock() + defer m.mu.RUnlock() + msgs, ok := m.sessions[sessionID] + if !ok { + return nil + } + // Return a copy + result := make([]ai.Message, len(msgs)) + copy(result, msgs) + return result +} + +// Set replaces the message history for a session. +func (m *Memory) Set(sessionID string, messages []ai.Message) { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[sessionID] = messages +} + +// Append adds messages to a session's history. +func (m *Memory) Append(sessionID string, msgs ...ai.Message) { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[sessionID] = append(m.sessions[sessionID], msgs...) +} + +// Clear removes all messages for a session. +func (m *Memory) Clear(sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, sessionID) +} + +// ListSessions returns all active session IDs. +func (m *Memory) ListSessions() []string { + m.mu.RLock() + defer m.mu.RUnlock() + ids := make([]string, 0, len(m.sessions)) + for id := range m.sessions { + ids = append(ids, id) + } + return ids +} diff --git a/internal/ai/anthropic/anthropic.go b/internal/ai/anthropic/anthropic.go new file mode 100644 index 0000000..a546c9b --- /dev/null +++ b/internal/ai/anthropic/anthropic.go @@ -0,0 +1,285 @@ +package anthropic + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" +) + +type Provider struct { + apiKey string + model string + baseURL string + client *http.Client + log *slog.Logger +} + +func New(cfg config.AnthropicConfig, log *slog.Logger) *Provider { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + return &Provider{ + apiKey: cfg.APIKey, + model: cfg.Model, + baseURL: strings.TrimRight(baseURL, "/"), + client: &http.Client{}, + log: log, + } +} + +func (p *Provider) Name() string { return "anthropic" } + +func (p *Provider) Models() []string { return []string{p.model} } + +type claudeRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []claudeMsg `json:"messages"` + Tools []claudeTool `json:"tools,omitempty"` + Stream bool `json:"stream"` +} + +type claudeMsg struct { + Role string `json:"role"` + Content []contentBlock `json:"content"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content string `json:"content,omitempty"` +} + +type claudeTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"input_schema"` +} + +type claudeResponse struct { + Content []contentBlock `json:"content"` + Model string `json:"model"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + StopReason string `json:"stop_reason"` +} + +type claudeStreamEvent struct { + Type string `json:"type"` + Index int `json:"index"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + ContentBlock *contentBlock `json:"content_block"` +} + +func (p *Provider) Chat(ctx context.Context, messages []ai.Message, tools []ai.Tool) (*ai.Response, error) { + system, claudeMsgs := convertMessages(messages) + + reqBody := claudeRequest{ + Model: p.model, + MaxTokens: 4096, + System: system, + Messages: claudeMsgs, + Tools: convertTools(tools), + Stream: false, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/v1/messages", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", p.apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("anthropic returned status %d: %s", resp.StatusCode, string(body)) + } + + var cResp claudeResponse + if err := json.NewDecoder(resp.Body).Decode(&cResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + result := &ai.Response{ + Model: cResp.Model, + Usage: ai.Usage{ + PromptTokens: cResp.Usage.InputTokens, + CompletionTokens: cResp.Usage.OutputTokens, + TotalTokens: cResp.Usage.InputTokens + cResp.Usage.OutputTokens, + }, + } + + for _, block := range cResp.Content { + switch block.Type { + case "text": + result.Content += block.Text + case "tool_use": + result.ToolCalls = append(result.ToolCalls, ai.ToolCall{ + ID: block.ID, + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: block.Name, + Arguments: string(block.Input), + }, + }) + } + } + + return result, nil +} + +func (p *Provider) StreamChat(ctx context.Context, messages []ai.Message, tools []ai.Tool) (<-chan ai.StreamChunk, error) { + system, claudeMsgs := convertMessages(messages) + + reqBody := claudeRequest{ + Model: p.model, + MaxTokens: 4096, + System: system, + Messages: claudeMsgs, + Tools: convertTools(tools), + Stream: true, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/v1/messages", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", p.apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("anthropic returned status %d: %s", resp.StatusCode, string(body)) + } + + ch := make(chan ai.StreamChunk, 64) + + go func() { + defer close(ch) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + + var event claudeStreamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + continue + } + + switch event.Type { + case "content_block_delta": + if event.Delta.Type == "text_delta" { + select { + case ch <- ai.StreamChunk{Content: event.Delta.Text}: + case <-ctx.Done(): + return + } + } + case "message_stop": + ch <- ai.StreamChunk{Done: true} + return + } + } + }() + + return ch, nil +} + +func convertMessages(msgs []ai.Message) (string, []claudeMsg) { + var system string + var result []claudeMsg + + for _, m := range msgs { + if m.Role == "system" { + system = m.Content + continue + } + + role := m.Role + if role == "tool" { + role = "user" + result = append(result, claudeMsg{ + Role: role, + Content: []contentBlock{{ + Type: "tool_result", + ToolUseID: m.ToolCallID, + Content: m.Content, + }}, + }) + continue + } + + result = append(result, claudeMsg{ + Role: role, + Content: []contentBlock{{ + Type: "text", + Text: m.Content, + }}, + }) + } + + return system, result +} + +func convertTools(tools []ai.Tool) []claudeTool { + result := make([]claudeTool, len(tools)) + for i, t := range tools { + result[i] = claudeTool{ + Name: t.Name, + Description: t.Description, + InputSchema: t.Parameters, + } + } + return result +} diff --git a/internal/ai/manager.go b/internal/ai/manager.go new file mode 100644 index 0000000..09ddcb3 --- /dev/null +++ b/internal/ai/manager.go @@ -0,0 +1,83 @@ +package ai + +import ( + "fmt" + "log/slog" + "sync" +) + +// Manager manages multiple AI providers and routes requests. +type Manager struct { + mu sync.RWMutex + providers map[string]Provider + active string + log *slog.Logger +} + +// NewManager creates a new provider manager. +func NewManager(log *slog.Logger) *Manager { + return &Manager{ + providers: make(map[string]Provider), + log: log, + } +} + +// Register adds a provider to the manager. +func (m *Manager) Register(p Provider) { + m.mu.Lock() + defer m.mu.Unlock() + m.providers[p.Name()] = p + m.log.Info("registered AI provider", "name", p.Name(), "models", p.Models()) +} + +// SetActive sets the active provider by name. +func (m *Manager) SetActive(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.providers[name]; !ok { + return fmt.Errorf("unknown provider: %s", name) + } + m.active = name + m.log.Info("switched active provider", "name", name) + return nil +} + +// Active returns the currently active provider. +func (m *Manager) Active() (Provider, error) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.providers[m.active] + if !ok { + return nil, fmt.Errorf("no active provider set (active=%q)", m.active) + } + return p, nil +} + +// ActiveName returns the name of the currently active provider. +func (m *Manager) ActiveName() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active +} + +// List returns names of all registered providers. +func (m *Manager) List() []string { + m.mu.RLock() + defer m.mu.RUnlock() + names := make([]string, 0, len(m.providers)) + for name := range m.providers { + names = append(names, name) + } + return names +} + +// Get returns a provider by name. +func (m *Manager) Get(name string) (Provider, error) { + m.mu.RLock() + defer m.mu.RUnlock() + p, ok := m.providers[name] + if !ok { + return nil, fmt.Errorf("unknown provider: %s", name) + } + return p, nil +} diff --git a/internal/ai/ollama/ollama.go b/internal/ai/ollama/ollama.go new file mode 100644 index 0000000..a828dae --- /dev/null +++ b/internal/ai/ollama/ollama.go @@ -0,0 +1,239 @@ +package ollama + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" +) + +type Provider struct { + baseURL string + model string + client *http.Client + log *slog.Logger +} + +func New(cfg config.OllamaConfig, log *slog.Logger) *Provider { + return &Provider{ + baseURL: cfg.BaseURL, + model: cfg.Model, + client: &http.Client{}, + log: log, + } +} + +func (p *Provider) Name() string { + return "ollama" +} + +func (p *Provider) Models() []string { + return []string{p.model} +} + +// ollamaMessage is Ollama's native message format. +type ollamaMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"` +} + +type ollamaToolCall struct { + Function struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } `json:"function"` +} + +type ollamaTool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` + } `json:"function"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []ollamaMessage `json:"messages"` + Stream bool `json:"stream"` + Tools []ollamaTool `json:"tools,omitempty"` +} + +type chatResponse struct { + Message ollamaMessage `json:"message"` + Done bool `json:"done"` + Model string `json:"model"` +} + +func (p *Provider) Chat(ctx context.Context, messages []ai.Message, tools []ai.Tool) (*ai.Response, error) { + ollamaMsgs := convertMessages(messages) + ollamaTools := convertTools(tools) + + reqBody := chatRequest{ + Model: p.model, + Messages: ollamaMsgs, + Stream: false, + Tools: ollamaTools, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/api/chat", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request to Ollama: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, string(body)) + } + + var chatResp chatResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + result := &ai.Response{ + Content: chatResp.Message.Content, + Model: chatResp.Model, + } + + for _, tc := range chatResp.Message.ToolCalls { + argsJSON, _ := json.Marshal(tc.Function.Arguments) + result.ToolCalls = append(result.ToolCalls, ai.ToolCall{ + ID: tc.Function.Name, + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Function.Name, + Arguments: string(argsJSON), + }, + }) + } + + return result, nil +} + +func (p *Provider) StreamChat(ctx context.Context, messages []ai.Message, tools []ai.Tool) (<-chan ai.StreamChunk, error) { + ollamaMsgs := convertMessages(messages) + ollamaTools := convertTools(tools) + + reqBody := chatRequest{ + Model: p.model, + Messages: ollamaMsgs, + Stream: true, + Tools: ollamaTools, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/api/chat", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request to Ollama: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, string(body)) + } + + ch := make(chan ai.StreamChunk, 64) + + go func() { + defer close(ch) + defer resp.Body.Close() + + decoder := json.NewDecoder(resp.Body) + for { + var chunk chatResponse + if err := decoder.Decode(&chunk); err != nil { + if err != io.EOF { + ch <- ai.StreamChunk{Error: err} + } + return + } + + streamChunk := ai.StreamChunk{ + Content: chunk.Message.Content, + Done: chunk.Done, + } + + for _, tc := range chunk.Message.ToolCalls { + argsJSON, _ := json.Marshal(tc.Function.Arguments) + streamChunk.ToolCalls = append(streamChunk.ToolCalls, ai.ToolCall{ + ID: tc.Function.Name, + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Function.Name, + Arguments: string(argsJSON), + }, + }) + } + + select { + case ch <- streamChunk: + case <-ctx.Done(): + return + } + + if chunk.Done { + return + } + } + }() + + return ch, nil +} + +func convertMessages(msgs []ai.Message) []ollamaMessage { + result := make([]ollamaMessage, len(msgs)) + for i, m := range msgs { + result[i] = ollamaMessage{ + Role: m.Role, + Content: m.Content, + } + } + return result +} + +func convertTools(tools []ai.Tool) []ollamaTool { + result := make([]ollamaTool, len(tools)) + for i, t := range tools { + result[i] = ollamaTool{Type: "function"} + result[i].Function.Name = t.Name + result[i].Function.Description = t.Description + result[i].Function.Parameters = t.Parameters + } + return result +} diff --git a/internal/ai/openai/openai.go b/internal/ai/openai/openai.go new file mode 100644 index 0000000..1a63246 --- /dev/null +++ b/internal/ai/openai/openai.go @@ -0,0 +1,278 @@ +package openai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" +) + +type Provider struct { + apiKey string + model string + baseURL string + client *http.Client + log *slog.Logger +} + +func New(cfg config.OpenAIConfig, log *slog.Logger) *Provider { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + return &Provider{ + apiKey: cfg.APIKey, + model: cfg.Model, + baseURL: strings.TrimRight(baseURL, "/"), + client: &http.Client{}, + log: log, + } +} + +func (p *Provider) Name() string { return "openai" } + +func (p *Provider) Models() []string { return []string{p.model} } + +type openAIRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + Tools []openAITool `json:"tools,omitempty"` + Stream bool `json:"stream"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type openAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +type openAITool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` + } `json:"function"` +} + +type openAIResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ToolCalls []openAIToolCall `json:"tool_calls"` + } `json:"message"` + Delta struct { + Content string `json:"content"` + ToolCalls []openAIToolCall `json:"tool_calls"` + } `json:"delta"` + } `json:"choices"` + Model string `json:"model"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +func (p *Provider) Chat(ctx context.Context, messages []ai.Message, tools []ai.Tool) (*ai.Response, error) { + reqBody := openAIRequest{ + Model: p.model, + Messages: convertMessages(messages), + Tools: convertTools(tools), + Stream: false, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/chat/completions", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("openai returned status %d: %s", resp.StatusCode, string(body)) + } + + var oaiResp openAIResponse + if err := json.NewDecoder(resp.Body).Decode(&oaiResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + result := &ai.Response{ + Model: oaiResp.Model, + Usage: ai.Usage{ + PromptTokens: oaiResp.Usage.PromptTokens, + CompletionTokens: oaiResp.Usage.CompletionTokens, + TotalTokens: oaiResp.Usage.TotalTokens, + }, + } + + if len(oaiResp.Choices) > 0 { + choice := oaiResp.Choices[0] + result.Content = choice.Message.Content + for _, tc := range choice.Message.ToolCalls { + result.ToolCalls = append(result.ToolCalls, ai.ToolCall{ + ID: tc.ID, + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + } + + return result, nil +} + +func (p *Provider) StreamChat(ctx context.Context, messages []ai.Message, tools []ai.Tool) (<-chan ai.StreamChunk, error) { + reqBody := openAIRequest{ + Model: p.model, + Messages: convertMessages(messages), + Tools: convertTools(tools), + Stream: true, + } + + data, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/chat/completions", bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("openai returned status %d: %s", resp.StatusCode, string(body)) + } + + ch := make(chan ai.StreamChunk, 64) + + go func() { + defer close(ch) + defer resp.Body.Close() + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + ch <- ai.StreamChunk{Done: true} + return + } + + var chunk openAIResponse + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + continue + } + + if len(chunk.Choices) > 0 { + delta := chunk.Choices[0].Delta + sc := ai.StreamChunk{Content: delta.Content} + for _, tc := range delta.ToolCalls { + sc.ToolCalls = append(sc.ToolCalls, ai.ToolCall{ + ID: tc.ID, + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + select { + case ch <- sc: + case <-ctx.Done(): + return + } + } + } + }() + + return ch, nil +} + +func convertMessages(msgs []ai.Message) []openAIMessage { + result := make([]openAIMessage, len(msgs)) + for i, m := range msgs { + msg := openAIMessage{ + Role: m.Role, + Content: m.Content, + ToolCallID: m.ToolCallID, + Name: m.Name, + } + for _, tc := range m.ToolCalls { + msg.ToolCalls = append(msg.ToolCalls, openAIToolCall{ + ID: tc.ID, + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + result[i] = msg + } + return result +} + +func convertTools(tools []ai.Tool) []openAITool { + result := make([]openAITool, len(tools)) + for i, t := range tools { + result[i] = openAITool{Type: "function"} + result[i].Function.Name = t.Name + result[i].Function.Description = t.Description + result[i].Function.Parameters = t.Parameters + } + return result +} diff --git a/internal/ai/provider.go b/internal/ai/provider.go new file mode 100644 index 0000000..ef95ddd --- /dev/null +++ b/internal/ai/provider.go @@ -0,0 +1,66 @@ +package ai + +import "context" + +// Message represents a single message in a conversation. +type Message struct { + Role string `json:"role"` // "system", "user", "assistant", "tool" + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// ToolCall represents a function call requested by the model. +type ToolCall struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// Tool describes a tool available to the model. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +// Response is the result from a provider. +type Response struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Model string `json:"model"` + Usage Usage `json:"usage"` +} + +// Usage tracks token usage. +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// StreamChunk is a piece of a streamed response. +type StreamChunk struct { + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Done bool `json:"done"` + Error error `json:"-"` +} + +// Provider is the interface that all AI backends must implement. +type Provider interface { + // Chat sends messages and returns a complete response. + Chat(ctx context.Context, messages []Message, tools []Tool) (*Response, error) + + // StreamChat sends messages and returns a channel of streamed chunks. + StreamChat(ctx context.Context, messages []Message, tools []Tool) (<-chan StreamChunk, error) + + // Name returns the provider's display name. + Name() string + + // Models returns the list of available model names. + Models() []string +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..83fa35f --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,83 @@ +package app + +import ( + "fmt" + "log/slog" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/agent" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + aiAnthropic "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai/anthropic" + aiOllama "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai/ollama" + aiOpenAI "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai/openai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/tools" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/tools/filesystem" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/tools/terminal" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ui" +) + +// Run initializes all components and starts the application. +func Run(cfg *config.Config, log *slog.Logger) error { + // --- AI Providers --- + aiManager := ai.NewManager(log) + + // Always register Ollama (local, no API key needed) + ollamaProvider := aiOllama.New(cfg.AI.Ollama, log) + aiManager.Register(ollamaProvider) + + // Register cloud providers if API keys are configured + if cfg.AI.OpenAI.APIKey != "" { + openaiProvider := aiOpenAI.New(cfg.AI.OpenAI, log) + aiManager.Register(openaiProvider) + } + + if cfg.AI.Anthropic.APIKey != "" { + anthropicProvider := aiAnthropic.New(cfg.AI.Anthropic, log) + aiManager.Register(anthropicProvider) + } + + // Set active provider + if err := aiManager.SetActive(cfg.AI.DefaultProvider); err != nil { + // Fallback to ollama + log.Warn("default provider not available, falling back to ollama", "provider", cfg.AI.DefaultProvider, "error", err) + if err := aiManager.SetActive("ollama"); err != nil { + return fmt.Errorf("no AI providers available: %w", err) + } + } + + // --- Tools --- + toolsRegistry := tools.NewRegistry(log) + + terminal.RegisterTools(toolsRegistry, cfg.Tools.Terminal, log) + filesystem.RegisterTools(toolsRegistry, cfg.Tools.Filesystem, log) + + log.Info("tools registered", "count", len(toolsRegistry.Names()), "tools", toolsRegistry.Names()) + + // --- Agent --- + activeProvider, err := aiManager.Active() + if err != nil { + return fmt.Errorf("getting active provider: %w", err) + } + + ag := agent.New(activeProvider, toolsRegistry, cfg.Agent, log) + memory := agent.NewMemory() + + // --- UI Server --- + server := ui.NewServer(cfg.Server, aiManager, ag, memory, log) + + log.Info("ZovOS AI starting", + "provider", aiManager.ActiveName(), + "tools", toolsRegistry.Names(), + "address", fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port), + ) + + fmt.Printf("\n ╔══════════════════════════════════════════╗\n") + fmt.Printf(" ║ 🤖 ZovOS AI is running! ║\n") + fmt.Printf(" ║ ║\n") + fmt.Printf(" ║ Open: http://localhost:%d ║\n", cfg.Server.Port) + fmt.Printf(" ║ Provider: %-29s║\n", aiManager.ActiveName()) + fmt.Printf(" ║ ║\n") + fmt.Printf(" ╚══════════════════════════════════════════╝\n\n") + + return server.Start() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8a186c2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,163 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + AI AIConfig `yaml:"ai"` + Agent AgentConfig `yaml:"agent"` + Tools ToolsConfig `yaml:"tools"` + Logging LoggingConfig `yaml:"logging"` +} + +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type AIConfig struct { + DefaultProvider string `yaml:"default_provider"` + Ollama OllamaConfig `yaml:"ollama"` + OpenAI OpenAIConfig `yaml:"openai"` + Anthropic AnthropicConfig `yaml:"anthropic"` + Google GoogleConfig `yaml:"google"` +} + +type OllamaConfig struct { + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` +} + +type OpenAIConfig struct { + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + BaseURL string `yaml:"base_url"` +} + +type AnthropicConfig struct { + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + BaseURL string `yaml:"base_url"` +} + +type GoogleConfig struct { + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` +} + +type AgentConfig struct { + MaxIterations int `yaml:"max_iterations"` + TimeoutSeconds int `yaml:"timeout_seconds"` + SystemPrompt string `yaml:"system_prompt"` +} + +type ToolsConfig struct { + Terminal TerminalToolConfig `yaml:"terminal"` + Browser BrowserToolConfig `yaml:"browser"` + Desktop DesktopToolConfig `yaml:"desktop"` + Filesystem FilesystemToolConfig `yaml:"filesystem"` + Clipboard ClipboardToolConfig `yaml:"clipboard"` +} + +type TerminalToolConfig struct { + Enabled bool `yaml:"enabled"` + Shell string `yaml:"shell"` + TimeoutSeconds int `yaml:"timeout_seconds"` + BlockedCommands []string `yaml:"blocked_commands"` +} + +type BrowserToolConfig struct { + Enabled bool `yaml:"enabled"` + Headless bool `yaml:"headless"` +} + +type DesktopToolConfig struct { + Enabled bool `yaml:"enabled"` +} + +type FilesystemToolConfig struct { + Enabled bool `yaml:"enabled"` + AllowedPaths []string `yaml:"allowed_paths"` +} + +type ClipboardToolConfig struct { + Enabled bool `yaml:"enabled"` +} + +type LoggingConfig struct { + Level string `yaml:"level"` + Format string `yaml:"format"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + cfg.applyEnvOverrides() + cfg.setDefaults() + + return cfg, nil +} + +func (c *Config) applyEnvOverrides() { + if key := os.Getenv("OPENAI_API_KEY"); key != "" { + c.AI.OpenAI.APIKey = key + } + if key := os.Getenv("ANTHROPIC_API_KEY"); key != "" { + c.AI.Anthropic.APIKey = key + } + if key := os.Getenv("GOOGLE_API_KEY"); key != "" { + c.AI.Google.APIKey = key + } + if url := os.Getenv("OLLAMA_BASE_URL"); url != "" { + c.AI.Ollama.BaseURL = url + } +} + +func (c *Config) setDefaults() { + if c.Server.Port == 0 { + c.Server.Port = 8080 + } + if c.Server.Host == "" { + c.Server.Host = "0.0.0.0" + } + if c.AI.DefaultProvider == "" { + c.AI.DefaultProvider = "ollama" + } + if c.AI.Ollama.BaseURL == "" { + c.AI.Ollama.BaseURL = "http://localhost:11434" + } + if c.AI.Ollama.Model == "" { + c.AI.Ollama.Model = "llama3" + } + if c.Agent.MaxIterations == 0 { + c.Agent.MaxIterations = 20 + } + if c.Agent.TimeoutSeconds == 0 { + c.Agent.TimeoutSeconds = 300 + } + if c.Tools.Terminal.Shell == "" { + c.Tools.Terminal.Shell = "/bin/bash" + } + if c.Tools.Terminal.TimeoutSeconds == 0 { + c.Tools.Terminal.TimeoutSeconds = 60 + } + if c.Logging.Level == "" { + c.Logging.Level = "info" + } + if c.Logging.Format == "" { + c.Logging.Format = "text" + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..928d19f --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,38 @@ +package logger + +import ( + "log/slog" + "os" + "strings" +) + +func Setup(level, format string) *slog.Logger { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn", "warning": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: lvl, + AddSource: lvl == slog.LevelDebug, + } + + var handler slog.Handler + switch strings.ToLower(format) { + case "json": + handler = slog.NewJSONHandler(os.Stdout, opts) + default: + handler = slog.NewTextHandler(os.Stdout, opts) + } + + log := slog.New(handler) + slog.SetDefault(log) + return log +} diff --git a/internal/tools/filesystem/filesystem.go b/internal/tools/filesystem/filesystem.go new file mode 100644 index 0000000..976d925 --- /dev/null +++ b/internal/tools/filesystem/filesystem.go @@ -0,0 +1,163 @@ +package filesystem + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/tools" +) + +// RegisterTools registers filesystem-related tools in the registry. +func RegisterTools(registry *tools.Registry, cfg config.FilesystemToolConfig, log *slog.Logger) { + if !cfg.Enabled { + return + } + + registry.Register(tools.ToolDef{ + Tool: ai.Tool{ + Name: "read_file", + Description: "Read the contents of a file at the given path.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Absolute path to the file to read", + }, + }, + "required": []string{"path"}, + }, + }, + Handler: func(ctx context.Context, args json.RawMessage) (string, error) { + var a struct{ Path string `json:"path"` } + if err := json.Unmarshal(args, &a); err != nil { + return "", err + } + if err := checkAllowed(a.Path, cfg.AllowedPaths); err != nil { + return "", err + } + data, err := os.ReadFile(a.Path) + if err != nil { + return "", err + } + content := string(data) + if len(content) > 50000 { + content = content[:50000] + "\n... (file truncated)" + } + return content, nil + }, + }) + + registry.Register(tools.ToolDef{ + Tool: ai.Tool{ + Name: "write_file", + Description: "Write content to a file, creating it if it doesn't exist.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Absolute path to the file", + }, + "content": map[string]interface{}{ + "type": "string", + "description": "Content to write to the file", + }, + }, + "required": []string{"path", "content"}, + }, + }, + Handler: func(ctx context.Context, args json.RawMessage) (string, error) { + var a struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", err + } + if err := checkAllowed(a.Path, cfg.AllowedPaths); err != nil { + return "", err + } + dir := filepath.Dir(a.Path) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + if err := os.WriteFile(a.Path, []byte(a.Content), 0644); err != nil { + return "", err + } + return fmt.Sprintf("File written successfully: %s (%d bytes)", a.Path, len(a.Content)), nil + }, + }) + + registry.Register(tools.ToolDef{ + Tool: ai.Tool{ + Name: "list_directory", + Description: "List contents of a directory.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Path to the directory to list", + }, + }, + "required": []string{"path"}, + }, + }, + Handler: func(ctx context.Context, args json.RawMessage) (string, error) { + var a struct{ Path string `json:"path"` } + if err := json.Unmarshal(args, &a); err != nil { + return "", err + } + if err := checkAllowed(a.Path, cfg.AllowedPaths); err != nil { + return "", err + } + entries, err := os.ReadDir(a.Path) + if err != nil { + return "", err + } + var sb strings.Builder + for _, e := range entries { + info, _ := e.Info() + typeStr := "file" + if e.IsDir() { + typeStr = "dir " + } + size := int64(0) + if info != nil { + size = info.Size() + } + sb.WriteString(fmt.Sprintf("[%s] %s (%d bytes)\n", typeStr, e.Name(), size)) + } + if sb.Len() == 0 { + return "(empty directory)", nil + } + return sb.String(), nil + }, + }) + + log.Info("filesystem tools registered") +} + +func checkAllowed(path string, allowedPaths []string) error { + if len(allowedPaths) == 0 { + return nil // no restrictions + } + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + for _, allowed := range allowedPaths { + if strings.HasPrefix(absPath, allowed) { + return nil + } + } + return fmt.Errorf("access denied: path %s is not in allowed directories", path) +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 0000000..1ceddb1 --- /dev/null +++ b/internal/tools/registry.go @@ -0,0 +1,86 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" +) + +// ToolHandler is the function signature for tool implementations. +type ToolHandler func(ctx context.Context, args json.RawMessage) (string, error) + +// ToolDef defines a tool with its metadata and handler. +type ToolDef struct { + ai.Tool + Handler ToolHandler +} + +// Registry manages all available tools. +type Registry struct { + mu sync.RWMutex + tools map[string]ToolDef + log *slog.Logger +} + +// NewRegistry creates a new tool registry. +func NewRegistry(log *slog.Logger) *Registry { + return &Registry{ + tools: make(map[string]ToolDef), + log: log, + } +} + +// Register adds a tool to the registry. +func (r *Registry) Register(def ToolDef) { + r.mu.Lock() + defer r.mu.Unlock() + r.tools[def.Name] = def + r.log.Info("registered tool", "name", def.Name) +} + +// Execute runs a tool by name with the given arguments. +func (r *Registry) Execute(ctx context.Context, name string, argsJSON string) (string, error) { + r.mu.RLock() + td, ok := r.tools[name] + r.mu.RUnlock() + if !ok { + return "", fmt.Errorf("unknown tool: %s", name) + } + + r.log.Info("executing tool", "name", name, "args", argsJSON) + + result, err := td.Handler(ctx, json.RawMessage(argsJSON)) + if err != nil { + r.log.Error("tool execution failed", "name", name, "error", err) + return fmt.Sprintf("Error: %v", err), nil // return error as result, not as Go error + } + + r.log.Debug("tool result", "name", name, "result_len", len(result)) + return result, nil +} + +// List returns AI tool definitions for all registered tools. +func (r *Registry) List() []ai.Tool { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]ai.Tool, 0, len(r.tools)) + for _, td := range r.tools { + result = append(result, td.Tool) + } + return result +} + +// Names returns names of all registered tools. +func (r *Registry) Names() []string { + r.mu.RLock() + defer r.mu.RUnlock() + names := make([]string, 0, len(r.tools)) + for name := range r.tools { + names = append(names, name) + } + return names +} diff --git a/internal/tools/terminal/terminal.go b/internal/tools/terminal/terminal.go new file mode 100644 index 0000000..a8270b9 --- /dev/null +++ b/internal/tools/terminal/terminal.go @@ -0,0 +1,121 @@ +package terminal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "strings" + "time" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/tools" +) + +type runCommandArgs struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` +} + +// RegisterTools registers terminal-related tools in the registry. +func RegisterTools(registry *tools.Registry, cfg config.TerminalToolConfig, log *slog.Logger) { + if !cfg.Enabled { + return + } + + registry.Register(tools.ToolDef{ + Tool: ai.Tool{ + Name: "run_command", + Description: "Execute a shell command in the terminal and return its output. Use this to run any command-line operations.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "command": map[string]interface{}{ + "type": "string", + "description": "The shell command to execute", + }, + "cwd": map[string]interface{}{ + "type": "string", + "description": "Working directory for the command (optional)", + }, + }, + "required": []string{"command"}, + }, + }, + Handler: func(ctx context.Context, args json.RawMessage) (string, error) { + var a runCommandArgs + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("parsing args: %w", err) + } + return runCommand(ctx, a, cfg, log) + }, + }) +} + +func runCommand(ctx context.Context, args runCommandArgs, cfg config.TerminalToolConfig, log *slog.Logger) (string, error) { + // Check for blocked commands + cmdLower := strings.ToLower(args.Command) + for _, blocked := range cfg.BlockedCommands { + if strings.Contains(cmdLower, strings.ToLower(blocked)) { + return "", fmt.Errorf("command blocked by security policy: %s", blocked) + } + } + + timeout := time.Duration(cfg.TimeoutSeconds) * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + shell := cfg.Shell + if shell == "" { + shell = "/bin/bash" + } + + cmd := exec.CommandContext(ctx, shell, "-c", args.Command) + if args.Cwd != "" { + cmd.Dir = args.Cwd + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + log.Info("running command", "command", args.Command, "cwd", args.Cwd) + + err := cmd.Run() + + var result strings.Builder + if stdout.Len() > 0 { + result.WriteString("STDOUT:\n") + result.WriteString(stdout.String()) + } + if stderr.Len() > 0 { + if result.Len() > 0 { + result.WriteString("\n") + } + result.WriteString("STDERR:\n") + result.WriteString(stderr.String()) + } + + if err != nil { + if result.Len() > 0 { + result.WriteString("\n") + } + result.WriteString(fmt.Sprintf("EXIT ERROR: %v", err)) + } + + if result.Len() == 0 { + result.WriteString("(no output)") + } + + // Truncate very long output + output := result.String() + const maxLen = 10000 + if len(output) > maxLen { + output = output[:maxLen] + "\n... (output truncated)" + } + + return output, nil +} diff --git a/internal/ui/server.go b/internal/ui/server.go new file mode 100644 index 0000000..414d59c --- /dev/null +++ b/internal/ui/server.go @@ -0,0 +1,215 @@ +package ui + +import ( + "embed" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + + "github.com/gorilla/websocket" + + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/agent" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/ai" + "git.bebrik.xyz/Dan4ick/ZovOS_AI/internal/config" +) + +//go:embed static/* +var staticFiles embed.FS + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Server is the HTTP/WebSocket server for the UI. +type Server struct { + cfg config.ServerConfig + aiManager *ai.Manager + agent *agent.Agent + memory *agent.Memory + log *slog.Logger +} + +// NewServer creates a new UI server. +func NewServer(cfg config.ServerConfig, aiMgr *ai.Manager, ag *agent.Agent, mem *agent.Memory, log *slog.Logger) *Server { + return &Server{ + cfg: cfg, + aiManager: aiMgr, + agent: ag, + memory: mem, + log: log, + } +} + +// Start begins serving HTTP requests. +func (s *Server) Start() error { + mux := http.NewServeMux() + + // Static files + mux.Handle("/", http.FileServer(http.FS(staticFiles))) + + // API endpoints + mux.HandleFunc("/api/providers", s.handleProviders) + mux.HandleFunc("/api/provider", s.handleSwitchProvider) + mux.HandleFunc("/api/sessions", s.handleSessions) + mux.HandleFunc("/api/session/clear", s.handleClearSession) + + // WebSocket for chat + mux.HandleFunc("/ws", s.handleWebSocket) + + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + s.log.Info("starting UI server", "address", addr) + return http.ListenAndServe(addr, mux) +} + +// --- REST Handlers --- + +func (s *Server) handleProviders(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "providers": s.aiManager.List(), + "active": s.aiManager.ActiveName(), + }) +} + +func (s *Server) handleSwitchProvider(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut && r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Provider string `json:"provider"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := s.aiManager.SetActive(req.Provider); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Update agent's provider + p, _ := s.aiManager.Active() + s.agent.SetProvider(p) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok", "active": req.Provider}) +} + +func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "sessions": s.memory.ListSessions(), + }) +} + +func (s *Server) handleClearSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + SessionID string `json:"session_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.memory.Clear(req.SessionID) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// --- WebSocket Handler --- + +type wsMessage struct { + Type string `json:"type"` // "message", "tool_call", "tool_result", "stream", "done", "error" + Content string `json:"content,omitempty"` + SessionID string `json:"session_id,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolArgs string `json:"tool_args,omitempty"` + ToolID string `json:"tool_id,omitempty"` +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + s.log.Error("websocket upgrade failed", "error", err) + return + } + defer conn.Close() + + var mu sync.Mutex + writeJSON := func(msg wsMessage) { + mu.Lock() + defer mu.Unlock() + conn.WriteJSON(msg) + } + + for { + var incoming wsMessage + if err := conn.ReadJSON(&incoming); err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + s.log.Error("websocket read error", "error", err) + } + return + } + + if incoming.Type != "message" { + continue + } + + sessionID := incoming.SessionID + if sessionID == "" { + sessionID = "default" + } + + history := s.memory.Get(sessionID) + + go func() { + callback := func(chunk string, toolCall *ai.ToolCall, toolResult *string, done bool) { + if done { + writeJSON(wsMessage{Type: "done", SessionID: sessionID}) + return + } + if chunk != "" { + writeJSON(wsMessage{Type: "stream", Content: chunk, SessionID: sessionID}) + } + if toolCall != nil && toolResult == nil { + writeJSON(wsMessage{ + Type: "tool_call", + ToolName: toolCall.Function.Name, + ToolArgs: toolCall.Function.Arguments, + ToolID: toolCall.ID, + SessionID: sessionID, + }) + } + if toolCall != nil && toolResult != nil { + writeJSON(wsMessage{ + Type: "tool_result", + ToolName: toolCall.Function.Name, + Content: *toolResult, + ToolID: toolCall.ID, + SessionID: sessionID, + }) + } + } + + messages, err := s.agent.Run(r.Context(), incoming.Content, history, callback) + if err != nil { + writeJSON(wsMessage{Type: "error", Content: err.Error(), SessionID: sessionID}) + return + } + + // Save updated history (strip system prompt) + var filtered []ai.Message + for _, m := range messages { + if m.Role != "system" { + filtered = append(filtered, m) + } + } + s.memory.Set(sessionID, filtered) + }() + } +} diff --git a/internal/ui/static/app.js b/internal/ui/static/app.js new file mode 100644 index 0000000..156862b --- /dev/null +++ b/internal/ui/static/app.js @@ -0,0 +1,284 @@ +// ZovOS AI — Frontend Application +(function() { + 'use strict'; + + // --- State --- + let ws = null; + let sessionId = 'default'; + let isStreaming = false; + let currentAssistantMsg = null; + + // --- DOM Elements --- + const chatContainer = document.getElementById('chatContainer'); + const messagesEl = document.getElementById('messages'); + const welcomeScreen = document.getElementById('welcomeScreen'); + const messageInput = document.getElementById('messageInput'); + const btnSend = document.getElementById('btnSend'); + const btnNewChat = document.getElementById('btnNewChat'); + const providerSelect = document.getElementById('providerSelect'); + const statusIndicator = document.getElementById('statusIndicator'); + const statusText = statusIndicator.querySelector('span'); + + // --- WebSocket --- + function connectWebSocket() { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${protocol}//${location.host}/ws`; + + ws = new WebSocket(url); + + ws.onopen = () => { + setStatus('ready', 'Подключён'); + console.log('[WS] Connected'); + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + handleWSMessage(msg); + }; + + ws.onclose = () => { + setStatus('error', 'Отключён'); + console.log('[WS] Disconnected, reconnecting in 3s...'); + setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = (err) => { + console.error('[WS] Error:', err); + setStatus('error', 'Ошибка соединения'); + }; + } + + function handleWSMessage(msg) { + switch (msg.type) { + case 'stream': + hideWelcome(); + if (!currentAssistantMsg) { + currentAssistantMsg = addMessage('assistant', ''); + } + appendToMessage(currentAssistantMsg, msg.content); + scrollToBottom(); + break; + + case 'tool_call': + hideWelcome(); + if (!currentAssistantMsg) { + currentAssistantMsg = addMessage('assistant', ''); + } + addToolCall(currentAssistantMsg, msg.tool_name, msg.tool_args, msg.tool_id); + scrollToBottom(); + break; + + case 'tool_result': + if (currentAssistantMsg) { + addToolResult(currentAssistantMsg, msg.tool_name, msg.content, msg.tool_id); + scrollToBottom(); + } + break; + + case 'done': + isStreaming = false; + if (currentAssistantMsg) { + renderMarkdown(currentAssistantMsg); + } + currentAssistantMsg = null; + setStatus('ready', 'Готов'); + btnSend.disabled = false; + messageInput.focus(); + break; + + case 'error': + isStreaming = false; + currentAssistantMsg = null; + addMessage('assistant', `⚠️ Ошибка: ${msg.content}`); + setStatus('error', 'Ошибка'); + btnSend.disabled = false; + setTimeout(() => setStatus('ready', 'Готов'), 3000); + break; + } + } + + // --- Messages --- + function addMessage(role, content) { + hideWelcome(); + + const msgEl = document.createElement('div'); + msgEl.className = `message ${role}`; + + const avatar = document.createElement('div'); + avatar.className = 'message-avatar'; + avatar.textContent = role === 'user' ? '👤' : '🤖'; + + const body = document.createElement('div'); + body.className = 'message-body'; + + const contentEl = document.createElement('div'); + contentEl.className = 'message-content'; + contentEl.textContent = content; + + body.appendChild(contentEl); + msgEl.appendChild(avatar); + msgEl.appendChild(body); + messagesEl.appendChild(msgEl); + + scrollToBottom(); + return contentEl; + } + + function appendToMessage(contentEl, text) { + contentEl.textContent += text; + } + + function renderMarkdown(contentEl) { + const raw = contentEl.textContent; + if (typeof marked !== 'undefined') { + contentEl.innerHTML = marked.parse(raw); + } + } + + function addToolCall(contentEl, name, args, id) { + const parent = contentEl.closest('.message-body'); + const el = document.createElement('div'); + el.className = 'tool-call'; + el.id = `tool-${id}`; + + let argsDisplay = args; + try { + argsDisplay = JSON.stringify(JSON.parse(args), null, 2); + } catch(e) {} + + el.innerHTML = ` +
AI-ассистент для управления Linux
+