Compare commits

..

2 commits

Author SHA1 Message Date
32b4860935 merge: initial project structure into main 2026-03-20 00:40:50 +03:00
460d20539f feat: initial project structure - Phase 1 MVP
- Project infrastructure: go.mod, Makefile, .gitignore, README
- Configuration system with YAML parsing and env overrides
- Structured logging with slog
- AI provider interface with Ollama, OpenAI, Anthropic implementations
- Provider manager with runtime switching
- Agent orchestrator with ReAct loop (reason-act-observe)
- Tool registry with JSON Schema descriptions
- Terminal tool: shell command execution with safety controls
- Filesystem tools: read, write, list with path access control
- Conversation memory with session management
- Web UI server with WebSocket streaming
- Modern dark theme UI with glassmorphism, animations
- Frontend: WebSocket client, markdown rendering, tool call display
2026-03-20 00:38:22 +03:00
22 changed files with 3201 additions and 16 deletions

37
.gitignore vendored
View file

@ -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

22
Makefile Normal file
View file

@ -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 .

View file

@ -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

63
configs/default.yaml Normal file
View file

@ -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"

8
go.mod Normal file
View file

@ -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
)

142
internal/agent/agent.go Normal file
View file

@ -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)
}

66
internal/agent/memory.go Normal file
View file

@ -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
}

View file

@ -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
}

83
internal/ai/manager.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

66
internal/ai/provider.go Normal file
View file

@ -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
}

83
internal/app/app.go Normal file
View file

@ -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()
}

163
internal/config/config.go Normal file
View file

@ -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"
}
}

38
internal/logger/logger.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

215
internal/ui/server.go Normal file
View file

@ -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)
}()
}
}

284
internal/ui/static/app.js Normal file
View file

@ -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 = `
<div class="tool-call-header">
<span></span>
<span>${escapeHtml(name)}</span>
</div>
<div class="tool-call-args">${escapeHtml(argsDisplay)}</div>
`;
parent.appendChild(el);
}
function addToolResult(contentEl, name, result, id) {
const parent = contentEl.closest('.message-body');
const el = document.createElement('div');
el.className = 'tool-result';
const truncated = result.length > 500 ? result.substring(0, 500) + '\n... (truncated)' : result;
el.textContent = truncated;
parent.appendChild(el);
}
// --- Actions ---
function sendMessage() {
const content = messageInput.value.trim();
if (!content || isStreaming || !ws || ws.readyState !== WebSocket.OPEN) return;
addMessage('user', content);
messageInput.value = '';
autoResize();
isStreaming = true;
currentAssistantMsg = null;
btnSend.disabled = true;
setStatus('busy', 'Думаю...');
ws.send(JSON.stringify({
type: 'message',
content: content,
session_id: sessionId,
}));
}
function newChat() {
sessionId = 'chat-' + Date.now();
messagesEl.innerHTML = '';
welcomeScreen.style.display = 'flex';
currentAssistantMsg = null;
isStreaming = false;
btnSend.disabled = false;
// Clear on server
if (ws && ws.readyState === WebSocket.OPEN) {
fetch('/api/session/clear', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({session_id: sessionId}),
});
}
}
// --- Provider ---
async function loadProviders() {
try {
const resp = await fetch('/api/providers');
const data = await resp.json();
providerSelect.innerHTML = '';
const icons = {ollama: '🦙', openai: '🧠', anthropic: '🟣', google: '🔵'};
data.providers.forEach(p => {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = `${icons[p] || '🤖'} ${p.charAt(0).toUpperCase() + p.slice(1)}`;
if (p === data.active) opt.selected = true;
providerSelect.appendChild(opt);
});
} catch(e) {
console.error('Failed to load providers:', e);
}
}
async function switchProvider(name) {
try {
await fetch('/api/provider', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider: name}),
});
} catch(e) {
console.error('Failed to switch provider:', e);
}
}
// --- Helpers ---
function hideWelcome() {
if (welcomeScreen) welcomeScreen.style.display = 'none';
}
function scrollToBottom() {
requestAnimationFrame(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
});
}
function setStatus(type, text) {
statusIndicator.className = 'status-indicator ' + type;
statusText.textContent = text;
}
function autoResize() {
messageInput.style.height = 'auto';
messageInput.style.height = Math.min(messageInput.scrollHeight, 150) + 'px';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// --- Events ---
btnSend.addEventListener('click', sendMessage);
btnNewChat.addEventListener('click', newChat);
providerSelect.addEventListener('change', (e) => switchProvider(e.target.value));
messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
messageInput.addEventListener('input', autoResize);
// --- Init ---
connectWebSocket();
loadProviders();
messageInput.focus();
})();

View file

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZovOS AI — AI Assistant</title>
<meta name="description" content="ZovOS AI — AI-ассистент для Linux с управлением компьютером, браузером и терминалом">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="static/style.css">
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo">
<div class="logo-icon">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="12" stroke="url(#grad)" stroke-width="2.5"/>
<circle cx="14" cy="14" r="5" fill="url(#grad)"/>
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="28" y2="28">
<stop offset="0%" stop-color="#6C5CE7"/>
<stop offset="100%" stop-color="#00D2FF"/>
</linearGradient>
</defs>
</svg>
</div>
<span class="logo-text">ZovOS AI</span>
</div>
</div>
<div class="sidebar-section">
<label class="section-label">Провайдер</label>
<div class="provider-select-wrapper">
<select id="providerSelect" class="provider-select">
<option value="ollama">🦙 Ollama (Локальный)</option>
</select>
</div>
</div>
<div class="sidebar-section">
<button class="btn btn-new-chat" id="btnNewChat">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Новый чат
</button>
</div>
<div class="sidebar-footer">
<div class="status-indicator" id="statusIndicator">
<div class="status-dot"></div>
<span>Готов</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main">
<div class="chat-container" id="chatContainer">
<!-- Welcome screen -->
<div class="welcome" id="welcomeScreen">
<div class="welcome-icon">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="28" stroke="url(#grad2)" stroke-width="3"/>
<circle cx="32" cy="32" r="12" fill="url(#grad2)" opacity="0.8"/>
<circle cx="32" cy="32" r="4" fill="#fff"/>
<defs>
<linearGradient id="grad2" x1="0" y1="0" x2="64" y2="64">
<stop offset="0%" stop-color="#6C5CE7"/>
<stop offset="50%" stop-color="#00D2FF"/>
<stop offset="100%" stop-color="#0ABDE3"/>
</linearGradient>
</defs>
</svg>
</div>
<h1 class="welcome-title">ZovOS AI</h1>
<p class="welcome-subtitle">AI-ассистент для управления Linux</p>
<div class="welcome-features">
<div class="feature-card">
<span class="feature-icon">🖥️</span>
<span>Терминал</span>
</div>
<div class="feature-card">
<span class="feature-icon">🌐</span>
<span>Браузер</span>
</div>
<div class="feature-card">
<span class="feature-icon">🖱️</span>
<span>Рабочий стол</span>
</div>
<div class="feature-card">
<span class="feature-icon">📁</span>
<span>Файлы</span>
</div>
</div>
</div>
<!-- Messages will be inserted here -->
<div class="messages" id="messages"></div>
</div>
<!-- Input area -->
<div class="input-area">
<div class="input-wrapper">
<textarea
id="messageInput"
class="message-input"
placeholder="Напишите сообщение..."
rows="1"
></textarea>
<button class="btn-send" id="btnSend" title="Отправить (Enter)">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 10l14-7-4 7 4 7-14-7z" fill="currentColor"/>
</svg>
</button>
</div>
<div class="input-hint">Enter — отправить, Shift+Enter — новая строка</div>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="static/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,593 @@
/* ===== DESIGN TOKENS ===== */
:root {
/* Colors */
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a28;
--bg-glass: rgba(255, 255, 255, 0.03);
--bg-glass-hover: rgba(255, 255, 255, 0.06);
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
--text-muted: #555570;
--accent-primary: #6C5CE7;
--accent-secondary: #00D2FF;
--accent-gradient: linear-gradient(135deg, #6C5CE7 0%, #00D2FF 100%);
--accent-gradient-hover: linear-gradient(135deg, #7d6ff0 0%, #33ddff 100%);
--border-color: rgba(255, 255, 255, 0.06);
--border-hover: rgba(255, 255, 255, 0.12);
--success: #00E676;
--warning: #FFD740;
--error: #FF5252;
/* Sizes */
--sidebar-width: 280px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
/* Typography */
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 48px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(108, 92, 231, 0.3);
}
/* ===== RESET ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-main);
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ===== APP LAYOUT ===== */
.app {
display: flex;
height: 100vh;
}
/* ===== SIDEBAR ===== */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px 16px;
flex-shrink: 0;
}
.sidebar-header {
margin-bottom: 24px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
animation: pulse-glow 3s ease-in-out infinite alternate;
}
@keyframes pulse-glow {
0% { filter: drop-shadow(0 0 4px rgba(108, 92, 231, 0.4)); }
100% { filter: drop-shadow(0 0 12px rgba(0, 210, 255, 0.6)); }
}
.logo-text {
font-size: 20px;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.sidebar-section {
margin-bottom: 16px;
}
.section-label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 8px;
}
.provider-select-wrapper {
position: relative;
}
.provider-select {
width: 100%;
padding: 10px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-main);
font-size: 13px;
cursor: pointer;
outline: none;
appearance: none;
transition: all 0.2s ease;
}
.provider-select:hover {
border-color: var(--border-hover);
background: var(--bg-glass-hover);
}
.provider-select:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.15);
}
.provider-select option {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* Buttons */
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font-main);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-new-chat {
width: 100%;
background: var(--accent-gradient);
color: white;
box-shadow: var(--shadow-glow);
}
.btn-new-chat:hover {
background: var(--accent-gradient-hover);
transform: translateY(-1px);
box-shadow: 0 0 28px rgba(108, 92, 231, 0.4);
}
.btn-new-chat:active {
transform: translateY(0);
}
.sidebar-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 8px rgba(0, 230, 118, 0.5);
animation: status-pulse 2s ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-indicator.busy .status-dot {
background: var(--warning);
box-shadow: 0 0 8px rgba(255, 215, 64, 0.5);
}
.status-indicator.error .status-dot {
background: var(--error);
box-shadow: 0 0 8px rgba(255, 82, 82, 0.5);
animation: none;
}
/* ===== MAIN CONTENT ===== */
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-container {
flex: 1;
overflow-y: auto;
padding: 24px;
scroll-behavior: smooth;
}
.chat-container::-webkit-scrollbar {
width: 6px;
}
.chat-container::-webkit-scrollbar-track {
background: transparent;
}
.chat-container::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
/* ===== WELCOME SCREEN ===== */
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
animation: fade-in 0.6s ease;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.welcome-icon {
margin-bottom: 24px;
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.welcome-title {
font-size: 36px;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
}
.welcome-subtitle {
font-size: 16px;
color: var(--text-secondary);
margin-bottom: 40px;
}
.welcome-features {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
}
.feature-card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--bg-glass);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
color: var(--text-secondary);
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.feature-card:hover {
background: var(--bg-glass-hover);
border-color: var(--accent-primary);
color: var(--text-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.feature-icon {
font-size: 20px;
}
/* ===== MESSAGES ===== */
.messages {
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
animation: message-in 0.3s ease;
max-width: 860px;
}
@keyframes message-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.message.user .message-avatar {
background: var(--accent-gradient);
}
.message.assistant .message-avatar {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.message-body {
flex: 1;
min-width: 0;
}
.message-content {
padding: 14px 18px;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.65;
word-wrap: break-word;
}
.message.user .message-content {
background: var(--accent-gradient);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant .message-content {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom-left-radius: 4px;
}
.message-content pre {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 14px;
margin: 10px 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
}
.message-content code {
font-family: var(--font-mono);
font-size: 13px;
background: rgba(108, 92, 231, 0.15);
padding: 2px 6px;
border-radius: 4px;
}
.message-content pre code {
background: none;
padding: 0;
}
/* Tool calls */
.tool-call {
margin: 8px 0;
padding: 12px 16px;
background: rgba(108, 92, 231, 0.08);
border: 1px solid rgba(108, 92, 231, 0.2);
border-radius: var(--radius-sm);
font-size: 12px;
animation: tool-pulse 0.5s ease;
}
@keyframes tool-pulse {
0% { border-color: rgba(108, 92, 231, 0.5); box-shadow: 0 0 12px rgba(108, 92, 231, 0.2); }
100% { border-color: rgba(108, 92, 231, 0.2); box-shadow: none; }
}
.tool-call-header {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--accent-primary);
margin-bottom: 6px;
}
.tool-call-args {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
}
.tool-result {
margin: 8px 0;
padding: 12px 16px;
background: rgba(0, 230, 118, 0.05);
border: 1px solid rgba(0, 230, 118, 0.15);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
/* Typing indicator */
.typing-indicator {
display: flex;
gap: 5px;
padding: 8px 0;
}
.typing-indicator span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent-primary);
animation: blink 1.2s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
/* ===== INPUT AREA ===== */
.input-area {
padding: 16px 24px 20px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 10px;
padding: 6px 6px 6px 18px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
transition: all 0.2s ease;
}
.input-wrapper:focus-within {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.1);
}
.message-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-main);
font-size: 14px;
line-height: 1.5;
padding: 10px 0;
resize: none;
max-height: 150px;
}
.message-input::placeholder {
color: var(--text-muted);
}
.btn-send {
width: 40px;
height: 40px;
border: none;
border-radius: var(--radius-sm);
background: var(--accent-gradient);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.btn-send:hover {
transform: scale(1.05);
box-shadow: var(--shadow-glow);
}
.btn-send:active {
transform: scale(0.95);
}
.btn-send:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.input-hint {
font-size: 11px;
color: var(--text-muted);
text-align: center;
margin-top: 8px;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: -100%;
top: 0;
bottom: 0;
z-index: 100;
transition: left 0.3s ease;
box-shadow: var(--shadow-lg);
}
.sidebar.open {
left: 0;
}
.welcome-features {
flex-direction: column;
}
}