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
This commit is contained in:
commit
460d20539f
22 changed files with 3214 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
zovos
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Test
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/vendor/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config with secrets
|
||||||
|
configs/local.yaml
|
||||||
|
.env
|
||||||
22
Makefile
Normal file
22
Makefile
Normal 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 .
|
||||||
54
README.md
Normal file
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 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
63
configs/default.yaml
Normal 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
8
go.mod
Normal 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
142
internal/agent/agent.go
Normal 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
66
internal/agent/memory.go
Normal 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
|
||||||
|
}
|
||||||
285
internal/ai/anthropic/anthropic.go
Normal file
285
internal/ai/anthropic/anthropic.go
Normal 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
83
internal/ai/manager.go
Normal 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
|
||||||
|
}
|
||||||
239
internal/ai/ollama/ollama.go
Normal file
239
internal/ai/ollama/ollama.go
Normal 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
|
||||||
|
}
|
||||||
278
internal/ai/openai/openai.go
Normal file
278
internal/ai/openai/openai.go
Normal 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
66
internal/ai/provider.go
Normal 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
83
internal/app/app.go
Normal 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
163
internal/config/config.go
Normal 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
38
internal/logger/logger.go
Normal 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
|
||||||
|
}
|
||||||
163
internal/tools/filesystem/filesystem.go
Normal file
163
internal/tools/filesystem/filesystem.go
Normal 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)
|
||||||
|
}
|
||||||
86
internal/tools/registry.go
Normal file
86
internal/tools/registry.go
Normal 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
|
||||||
|
}
|
||||||
121
internal/tools/terminal/terminal.go
Normal file
121
internal/tools/terminal/terminal.go
Normal 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
215
internal/ui/server.go
Normal 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
284
internal/ui/static/app.js
Normal 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();
|
||||||
|
})();
|
||||||
128
internal/ui/static/index.html
Normal file
128
internal/ui/static/index.html
Normal 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>
|
||||||
593
internal/ui/static/style.css
Normal file
593
internal/ui/static/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue