merge: initial project structure into main
This commit is contained in:
commit
32b4860935
22 changed files with 3201 additions and 16 deletions
37
.gitignore
vendored
37
.gitignore
vendored
|
|
@ -1,27 +1,34 @@
|
|||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
zovos
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
# Build output
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
# Test
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
# Go
|
||||
/vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# env file
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Config with secrets
|
||||
configs/local.yaml
|
||||
.env
|
||||
|
||||
|
|
|
|||
22
Makefile
Normal file
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
54
README.md
|
|
@ -1,2 +1,54 @@
|
|||
# ZovOS_AI
|
||||
# ZovOS AI
|
||||
|
||||
**AI-ассистент для Linux**, написанный на Go from scratch.
|
||||
|
||||
Управляет компьютером, браузером и терминалом. Поддерживает локальные и облачные нейросети.
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🤖 **AI-провайдеры**: OpenAI, Anthropic, Google Gemini, Ollama (локальные модели)
|
||||
- 🖥️ **Управление терминалом**: выполнение команд, интерактивные PTY-сессии
|
||||
- 🌐 **Управление браузером**: навигация, клики, ввод текста, скриншоты
|
||||
- 🖱️ **Управление рабочим столом**: мышь, клавиатура, скриншоты, управление окнами
|
||||
- 📁 **Файловая система**: чтение, запись, поиск файлов
|
||||
- 📋 **Буфер обмена**: чтение и запись
|
||||
- 🎨 **Современный UI**: веб-интерфейс с тёмной темой, WebSocket стриминг
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
# Клонирование
|
||||
git clone https://git.bebrik.xyz/Dan4ick/ZovOS_AI.git
|
||||
cd ZovOS_AI
|
||||
|
||||
# Сборка
|
||||
make build
|
||||
|
||||
# Запуск
|
||||
make run
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Скопируйте `configs/default.yaml` в `configs/local.yaml` и укажите API-ключи:
|
||||
|
||||
```yaml
|
||||
ai:
|
||||
default_provider: ollama
|
||||
openai:
|
||||
api_key: "sk-..."
|
||||
anthropic:
|
||||
api_key: "sk-ant-..."
|
||||
```
|
||||
|
||||
## Зависимости (Linux)
|
||||
|
||||
- Go 1.22+
|
||||
- `xdotool` — управление мышью/клавиатурой
|
||||
- `xclip` — буфер обмена
|
||||
- Chromium — управление браузером
|
||||
- Ollama (опционально) — локальные модели
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
|
|
|||
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