Compare commits
No commits in common. "32b486093532b3c3812ec4ea7341c2b1fcd6e4ec" and "c7a90308408148009df1c607e8de0babe14f673a" have entirely different histories.
32b4860935
...
c7a9030840
22 changed files with 16 additions and 3201 deletions
37
.gitignore
vendored
37
.gitignore
vendored
|
|
@ -1,34 +1,27 @@
|
|||
# Binaries
|
||||
# ---> 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
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
zovos
|
||||
|
||||
# Build output
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
# Test
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
coverage.html
|
||||
|
||||
# Go
|
||||
/vendor/
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Config with secrets
|
||||
configs/local.yaml
|
||||
# env file
|
||||
.env
|
||||
|
||||
|
|
|
|||
22
Makefile
22
Makefile
|
|
@ -1,22 +0,0 @@
|
|||
.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,54 +1,2 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
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
8
go.mod
|
|
@ -1,8 +0,0 @@
|
|||
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
|
||||
)
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
// 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();
|
||||
})();
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,593 +0,0 @@
|
|||
/* ===== 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