Compare commits
5 commits
b9c6c831c1
...
006836b03c
| Author | SHA1 | Date | |
|---|---|---|---|
| 006836b03c | |||
| beab32894b | |||
| bab956387c | |||
| f0b53b67ce | |||
| b44e4ed6f9 |
16 changed files with 1025 additions and 75 deletions
154
AGENTS.md
Normal file
154
AGENTS.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is **Antigravity Agent** — an autonomous AI agent platform built with a Rust backend and a vanilla JS frontend. Users create "tasks" (goals), and the system dispatches an LLM-powered agent to accomplish them. Tasks can be run on-demand or on a cron schedule. Each execution produces logs and a final answer, all persisted to PostgreSQL.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (Vite + Vanilla JS) │
|
||||||
|
│ - OAuth login via Authentik │
|
||||||
|
│ - Dashboard: recent runs, task list, logs, answers │
|
||||||
|
│ - Polls /api/tasks every 3s for live updates │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ /api/*
|
||||||
|
┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Axum HTTP Server (src/server/) │
|
||||||
|
│ - CORS, CSP, rate limiting (1 MB body) │
|
||||||
|
│ - Cookie-based + Bearer token auth │
|
||||||
|
│ - Routes: tasks CRUD, runs, auth (callback/refresh) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Domain Layer (src/domain/) │
|
||||||
|
│ ├─ agent/ Agent loop, LLM API client, tool defs │
|
||||||
|
│ ├─ auth JwksVerifier, Authenticator (OIDC) │
|
||||||
|
│ └─ tasks Task execution, run management │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Scheduler (src/scheduler.rs) │
|
||||||
|
│ - tokio-cron-scheduler for recurring task execution │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Entities (src/entities/) │
|
||||||
|
│ - task, task_run (SeaORM models) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ PostgreSQL │
|
||||||
|
│ - Migrations managed via sea-orm-migration │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
├── Cargo.toml # Workspace root (members: ".", "migration")
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Entrypoint: tracing init → server::start()
|
||||||
|
│ ├── config.rs # Config struct loaded from env vars
|
||||||
|
│ ├── error.rs # AppError enum (thiserror) → Axum responses
|
||||||
|
│ ├── scheduler.rs # Cron job scheduler (wraps tokio-cron-scheduler)
|
||||||
|
│ ├── tests.rs # Unit tests for error handling & config
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ ├── agent/
|
||||||
|
│ │ │ ├── mod.rs # Agent struct: agentic loop with turn/time limits
|
||||||
|
│ │ │ ├── api.rs # LLM request/response types, Tavily search client
|
||||||
|
│ │ │ └── tools.rs # Tool definitions (google_search, finish) & dispatch
|
||||||
|
│ │ ├── auth.rs # JwksVerifier (RSA/JWKS), Authenticator (code exchange, refresh)
|
||||||
|
│ │ └── tasks.rs # Task execution logic, response DTOs
|
||||||
|
│ ├── entities/
|
||||||
|
│ │ ├── task.rs # SeaORM entity: tasks table
|
||||||
|
│ │ └── task_run.rs # SeaORM entity: task_runs table (belongs_to task)
|
||||||
|
│ └── server/
|
||||||
|
│ ├── mod.rs # App bootstrap: DB, scheduler, auth, router, CORS
|
||||||
|
│ ├── auth.rs # Auth routes & AuthenticatedUser extractor
|
||||||
|
│ └── tasks.rs # Task CRUD & run endpoints
|
||||||
|
├── migration/
|
||||||
|
│ └── src/ # SeaORM migrations (tasks, answer col, runs table, cron col)
|
||||||
|
├── frontend/
|
||||||
|
│ ├── index.html # SPA shell with glassmorphism dark theme
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.js # All app logic: auth flow, task/run rendering, polling
|
||||||
|
│ │ └── style.css # Styles
|
||||||
|
│ ├── vite.config.js # Dev proxy: /api → localhost:3000
|
||||||
|
│ └── package.json # Deps: vite, marked, dompurify
|
||||||
|
└── .forgejo/workflows/
|
||||||
|
└── pipeline.yaml # CI: build frontend + cargo build → deploy via systemd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent System
|
||||||
|
|
||||||
|
The agent (`src/domain/agent/`) is a turn-based autonomous loop:
|
||||||
|
|
||||||
|
1. A system prompt is injected with the current date and instructions not to ask the user for clarification.
|
||||||
|
2. The user's goal is sent as the initial message.
|
||||||
|
3. Each turn calls the **Kimi K2.5** model via the Zen API (`https://opencode.ai/zen/v1/chat/completions`).
|
||||||
|
4. The model can invoke tools:
|
||||||
|
- **`google_search`** — web search via the Tavily API.
|
||||||
|
- **`finish`** — signals completion and provides the final answer.
|
||||||
|
5. Tool results are appended to the conversation and the loop continues.
|
||||||
|
6. The loop terminates when `finish` is called, the turn limit is hit (`AGENT_MAX_TURNS`, default 20), or the time limit expires (`AGENT_MAX_DURATION_SECS`, default 120s).
|
||||||
|
|
||||||
|
All turns and tool calls are logged. The final answer (if any) and the full log are persisted to the `task_runs` table.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|---------------------------|----------|---------|----------------------------------------------|
|
||||||
|
| `DATABASE_URL` | ✅ | — | PostgreSQL connection string |
|
||||||
|
| `PORT` | | `3000` | HTTP server port |
|
||||||
|
| `ZEN_API_KEY` | | — | API key for Zen/Kimi LLM |
|
||||||
|
| `TAVILY_API_KEY` | | — | API key for Tavily web search |
|
||||||
|
| `AUTHENTIK_ISSUER` | ✅ | — | OIDC issuer URL (Authentik) |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | ✅ | — | OAuth client ID |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | ✅ | — | OAuth client secret |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | | — | Comma-separated allowed origins (or mirror) |
|
||||||
|
| `COOKIE_SECURE` | | `false` | Set `true` for HTTPS-only cookies |
|
||||||
|
| `AGENT_MAX_TURNS` | | `20` | Max LLM turns per agent run |
|
||||||
|
| `AGENT_MAX_DURATION_SECS` | | `120` | Max wall-clock seconds per agent run |
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
All task/run routes require authentication (cookie or Bearer token).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------------------------|-----------------------------------|
|
||||||
|
| GET | `/api/tasks` | List all tasks with their runs |
|
||||||
|
| POST | `/api/tasks` | Create a new task |
|
||||||
|
| GET | `/api/tasks/:id` | Get a single task with runs |
|
||||||
|
| PUT | `/api/tasks/:id` | Update task goal/cron schedule |
|
||||||
|
| POST | `/api/tasks/:id/runs` | Trigger a manual re-run |
|
||||||
|
| GET | `/api/runs/recent` | Latest 50 runs across all tasks |
|
||||||
|
| GET | `/api/auth/session` | Check current session |
|
||||||
|
| GET | `/api/auth/callback` | OAuth code → token exchange |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh access token |
|
||||||
|
| POST | `/api/auth/logout` | Clear auth cookies |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
**`tasks`** — `id` (UUID PK), `goal` (text), `cron` (text, nullable), `created_at` (timestamptz)
|
||||||
|
|
||||||
|
**`task_runs`** — `id` (UUID PK), `task_id` (FK → tasks, cascade delete), `status` (text), `logs` (text), `answer` (text, nullable), `created_at` (timestamptz)
|
||||||
|
|
||||||
|
Migrations are in `migration/src/` and run automatically on startup via `Migrator::up()`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (from repo root)
|
||||||
|
cargo run # requires DATABASE_URL + Authentik vars
|
||||||
|
|
||||||
|
# Frontend (from frontend/)
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server on :5173, proxies /api to :3000
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI / Deployment
|
||||||
|
|
||||||
|
The Forgejo Actions pipeline (`.forgejo/workflows/pipeline.yaml`) triggers on release publish:
|
||||||
|
|
||||||
|
1. Builds the frontend (`npm install && npm run build`).
|
||||||
|
2. Builds the Rust binary (`cargo build -r`).
|
||||||
|
3. Uploads the binary as a release asset.
|
||||||
|
4. Deploys to the host: copies binary + frontend dist, restarts the `bot` systemd user service.
|
||||||
83
Cargo.lock
generated
83
Cargo.lock
generated
|
|
@ -161,6 +161,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum-core",
|
"axum-core",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -179,8 +180,10 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_path_to_error",
|
"serde_path_to_error",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -336,7 +339,7 @@ dependencies = [
|
||||||
"sea-orm-migration",
|
"sea-orm-migration",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-cron-scheduler",
|
"tokio-cron-scheduler",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
|
|
@ -667,6 +670,12 @@ dependencies = [
|
||||||
"parking_lot_core",
|
"parking_lot_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -2505,7 +2514,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strum 0.26.3",
|
"strum 0.26.3",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
|
@ -2602,7 +2611,7 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2814,7 +2823,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2912,7 +2921,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
|
@ -3000,7 +3009,7 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -3043,7 +3052,7 @@ dependencies = [
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|
@ -3070,7 +3079,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
|
@ -3221,13 +3230,33 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3381,6 +3410,18 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
|
|
@ -3554,6 +3595,24 @@ version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
|
|
@ -3611,6 +3670,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ tokio = { version = "1", features = ["full"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
axum = "0.7"
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] }
|
tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] }
|
||||||
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
|
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
|
||||||
sea-orm-migration = "1.1"
|
sea-orm-migration = "1.1"
|
||||||
|
|
|
||||||
|
|
@ -63,22 +63,56 @@
|
||||||
|
|
||||||
<div id="dashboard-view" class="dashboard-view">
|
<div id="dashboard-view" class="dashboard-view">
|
||||||
<header class="dashboard-header">
|
<header class="dashboard-header">
|
||||||
<h2>Recent Activity</h2>
|
<h2>Perspective</h2>
|
||||||
<p>Track latest agent executions across all directives.</p>
|
<p>Overview of recent agent directives and live communication.</p>
|
||||||
|
|
||||||
|
<div class="dashboard-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="chat">
|
||||||
|
<span class="tab-icon">💬</span> Quick Chat
|
||||||
|
</button>
|
||||||
|
<button class="tab-btn" data-tab="activity">
|
||||||
|
<span class="tab-icon">📊</span> Recent Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="dashboard-content glass">
|
|
||||||
<table class="activity-table">
|
<div class="dashboard-content-grid">
|
||||||
<thead>
|
<div id="chat-tab-panel" class="tab-panel active">
|
||||||
<tr>
|
<div id="chat-container" class="chat-container glass">
|
||||||
<th>Directive</th>
|
<div class="chat-header">
|
||||||
<th>Status</th>
|
<h3>Neural Link</h3>
|
||||||
<th>Date</th>
|
<button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear Memory</button>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div id="chat-messages" class="chat-messages">
|
||||||
<tbody id="recent-runs-list">
|
<!-- Chat messages will be injected here -->
|
||||||
<!-- Recent runs will be injected here -->
|
<div class="chat-empty-state">
|
||||||
</tbody>
|
<p>Establish connection with the neural assistant.</p>
|
||||||
</table>
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="chat-form" class="chat-form">
|
||||||
|
<input type="text" id="chat-input" placeholder="Transmit message..." autocomplete="off"
|
||||||
|
required>
|
||||||
|
<button type="submit" id="chat-send-btn" class="btn btn-primary btn-sm">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="activity-tab-panel" class="tab-panel">
|
||||||
|
<div class="dashboard-content glass">
|
||||||
|
<table class="activity-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Directive</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recent-runs-list">
|
||||||
|
<!-- Recent runs will be injected here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ const state = {
|
||||||
selectedRunId: null,
|
selectedRunId: null,
|
||||||
currentView: 'dashboard', // 'dashboard' or 'task'
|
currentView: 'dashboard', // 'dashboard' or 'task'
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
isAuthenticated: false
|
isAuthenticated: false,
|
||||||
|
chatMessages: [],
|
||||||
|
activeDashboardTab: 'chat' // 'chat' or 'activity'
|
||||||
};
|
};
|
||||||
// DOM elements
|
// DOM elements
|
||||||
const loginOverlay = document.getElementById('login-overlay');
|
const loginOverlay = document.getElementById('login-overlay');
|
||||||
|
|
@ -53,6 +55,11 @@ const schedulePresets = document.getElementById('schedule-presets');
|
||||||
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
|
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
|
||||||
const customCronContainer = document.getElementById('custom-cron-container');
|
const customCronContainer = document.getElementById('custom-cron-container');
|
||||||
const presetBtns = document.querySelectorAll('.btn-preset');
|
const presetBtns = document.querySelectorAll('.btn-preset');
|
||||||
|
const chatMessagesEl = document.getElementById('chat-messages');
|
||||||
|
const chatForm = document.getElementById('chat-form');
|
||||||
|
const chatInput = document.getElementById('chat-input');
|
||||||
|
const clearChatBtn = document.getElementById('clear-chat-btn');
|
||||||
|
const chatSendBtn = document.getElementById('chat-send-btn');
|
||||||
|
|
||||||
function updateState(newState) {
|
function updateState(newState) {
|
||||||
Object.assign(state, newState);
|
Object.assign(state, newState);
|
||||||
|
|
@ -89,6 +96,7 @@ function renderApp() {
|
||||||
|
|
||||||
if (state.currentView === 'dashboard') {
|
if (state.currentView === 'dashboard') {
|
||||||
fetchRecentRuns();
|
fetchRecentRuns();
|
||||||
|
renderChat();
|
||||||
} else if (state.selectedTaskId) {
|
} else if (state.selectedTaskId) {
|
||||||
const task = state.tasks.find(t => t.id === state.selectedTaskId);
|
const task = state.tasks.find(t => t.id === state.selectedTaskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
|
|
@ -264,6 +272,28 @@ function showDashboard() {
|
||||||
|
|
||||||
renderTaskList();
|
renderTaskList();
|
||||||
fetchRecentRuns();
|
fetchRecentRuns();
|
||||||
|
renderDashboardTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboardTabs() {
|
||||||
|
const tabs = document.querySelectorAll('.tab-btn');
|
||||||
|
const panels = document.querySelectorAll('.tab-panel');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
if (tab.dataset.tab === state.activeDashboardTab) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
} else {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
panels.forEach(panel => {
|
||||||
|
if (panel.id === `${state.activeDashboardTab}-tab-panel`) {
|
||||||
|
panel.classList.add('active');
|
||||||
|
} else {
|
||||||
|
panel.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboard(recentRuns) {
|
function renderDashboard(recentRuns) {
|
||||||
|
|
@ -354,6 +384,62 @@ function escapeHtml(text) {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderChat() {
|
||||||
|
if (!chatMessagesEl) return;
|
||||||
|
|
||||||
|
if (state.chatMessages.length === 0) {
|
||||||
|
chatMessagesEl.innerHTML = `
|
||||||
|
<div class="chat-empty-state">
|
||||||
|
<p>Start a conversation with the assistant.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatMessagesEl.innerHTML = state.chatMessages
|
||||||
|
.map(msg => `
|
||||||
|
<div class="chat-message ${msg.role}">
|
||||||
|
${DOMPurify.sanitize(marked.parse(msg.content || ''))}
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChatMessage(text) {
|
||||||
|
const userMessage = { role: 'user', content: text };
|
||||||
|
state.chatMessages.push(userMessage);
|
||||||
|
renderChat();
|
||||||
|
|
||||||
|
chatInput.value = '';
|
||||||
|
chatInput.disabled = true;
|
||||||
|
chatSendBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`${API_URL}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: state.chatMessages })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Chat API failed');
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
state.chatMessages.push(result.message);
|
||||||
|
renderChat();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat error:', error);
|
||||||
|
showToast('Failed to get chat response.', 'error');
|
||||||
|
state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
|
||||||
|
renderChat();
|
||||||
|
} finally {
|
||||||
|
chatInput.disabled = false;
|
||||||
|
chatSendBtn.disabled = false;
|
||||||
|
chatInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event Listeners
|
// Event Listeners
|
||||||
rerunBtn.addEventListener('click', async () => {
|
rerunBtn.addEventListener('click', async () => {
|
||||||
if (!state.selectedTaskId) return;
|
if (!state.selectedTaskId) return;
|
||||||
|
|
@ -447,6 +533,19 @@ toggleCustomCronBtn.addEventListener('click', () => {
|
||||||
customCronContainer.classList.toggle('hidden');
|
customCronContainer.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = chatInput.value.trim();
|
||||||
|
if (text) {
|
||||||
|
sendChatMessage(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearChatBtn.addEventListener('click', () => {
|
||||||
|
state.chatMessages = [];
|
||||||
|
renderChat();
|
||||||
|
});
|
||||||
|
|
||||||
cronInput.addEventListener('input', () => {
|
cronInput.addEventListener('input', () => {
|
||||||
// If user types manually, update presets active state
|
// If user types manually, update presets active state
|
||||||
updateScheduleUI(cronInput.value);
|
updateScheduleUI(cronInput.value);
|
||||||
|
|
@ -511,17 +610,86 @@ async function checkSession() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isPolling = false;
|
let socket = null;
|
||||||
async function startAutoRefresh() {
|
let reconnectDelay = 1000;
|
||||||
setInterval(async () => {
|
|
||||||
if (isPolling) return;
|
function connectWebSocket() {
|
||||||
isPolling = true;
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||||||
|
|
||||||
|
console.log('Connecting to WebSocket:', wsUrl);
|
||||||
|
socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
// Initial fetch to sync state
|
||||||
|
fetchTasks();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
await fetchTasks();
|
const { type, data } = JSON.parse(event.data);
|
||||||
} finally {
|
console.log('WebSocket event:', type, data);
|
||||||
isPolling = false;
|
|
||||||
|
switch (type) {
|
||||||
|
case 'TaskCreated':
|
||||||
|
state.tasks.unshift(data);
|
||||||
|
renderApp();
|
||||||
|
showToast('New task created', 'success');
|
||||||
|
break;
|
||||||
|
case 'TaskUpdated':
|
||||||
|
case 'RunFinished':
|
||||||
|
const index = state.tasks.findIndex(t => t.id === data.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const wasSelected = state.selectedTaskId === data.id;
|
||||||
|
state.tasks[index] = data;
|
||||||
|
if (wasSelected) {
|
||||||
|
// Update selected run if we were following latest
|
||||||
|
const wasFollowingLatest = state.selectedRunId === state.tasks[index].runs?.[1]?.id || !state.selectedRunId;
|
||||||
|
if (wasFollowingLatest && data.runs && data.runs.length > 0) {
|
||||||
|
state.selectedRunId = data.runs[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.tasks.unshift(data);
|
||||||
|
}
|
||||||
|
renderApp();
|
||||||
|
if (type === 'RunFinished') {
|
||||||
|
showToast(`Task run completed: ${data.goal}`, 'info');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'RunStarted':
|
||||||
|
const taskIndex = state.tasks.findIndex(t => t.id === data.task_id);
|
||||||
|
if (taskIndex !== -1) {
|
||||||
|
// We don't have the full task update here, but we can update status
|
||||||
|
// For simplicity, we just trigger a fetch or wait for RunFinished
|
||||||
|
// But let's at least show it's running in the UI if selected
|
||||||
|
if (state.tasks[taskIndex].runs) {
|
||||||
|
// Prepend a dummy run or just fetch
|
||||||
|
fetchTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(`Task started: ${data.goal}`, 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error handling WebSocket message:', e);
|
||||||
}
|
}
|
||||||
}, 3000);
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected. Reconnecting...');
|
||||||
|
setTimeout(() => {
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
|
||||||
|
connectWebSocket();
|
||||||
|
}, reconnectDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showLogin() {
|
async function showLogin() {
|
||||||
|
|
@ -578,6 +746,13 @@ logoutBtn.addEventListener('click', () => {
|
||||||
logout();
|
logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.activeDashboardTab = btn.dataset.tab;
|
||||||
|
renderDashboardTabs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
const hasSession = await checkSession();
|
const hasSession = await checkSession();
|
||||||
|
|
||||||
|
|
@ -585,7 +760,7 @@ async function initializeApp() {
|
||||||
appEl.classList.remove('hidden');
|
appEl.classList.remove('hidden');
|
||||||
loginOverlay.classList.add('hidden');
|
loginOverlay.classList.add('hidden');
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
startAutoRefresh();
|
connectWebSocket();
|
||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,15 @@ body {
|
||||||
.dashboard-content {
|
.dashboard-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-table {
|
.activity-table {
|
||||||
|
|
@ -124,6 +130,158 @@ body {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-content-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 4px 15px var(--primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty-state {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-form input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.activity-table .status-badge {
|
.activity-table .status-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
@ -796,9 +954,9 @@ textarea:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
padding: 6px 12px;
|
padding: 6px 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
width: 100%;
|
width: auto;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub role: String,
|
pub role: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
|
|
@ -29,6 +28,7 @@ pub struct FunctionCall {
|
||||||
pub struct ChatRequest {
|
pub struct ChatRequest {
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tools: Option<Vec<Tool>>,
|
pub tools: Option<Vec<Tool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,6 +72,7 @@ pub async fn perform_search(
|
||||||
query: &str,
|
query: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error>> {
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
tracing::info!(query = %query, "Performing Tavily web search");
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
@ -94,6 +95,7 @@ pub async fn perform_search(
|
||||||
}
|
}
|
||||||
|
|
||||||
let search_data: TavilyResponse = response.json().await?;
|
let search_data: TavilyResponse = response.json().await?;
|
||||||
|
tracing::info!("Web search yielded {} results", search_data.results.len());
|
||||||
let mut results_text = String::new();
|
let mut results_text = String::new();
|
||||||
|
|
||||||
for (i, result) in search_data.results.iter().enumerate() {
|
for (i, result) in search_data.results.iter().enumerate() {
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@ pub mod api;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use self::api::{ChatRequest, ChatResponse, Message, Tool};
|
use self::api::{ChatRequest, ChatResponse, Message, Tool};
|
||||||
|
|
||||||
pub struct Agent {
|
pub struct Agent {
|
||||||
|
db: DatabaseConnection,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
url: String,
|
url: String,
|
||||||
zen_api_key: Option<String>,
|
zen_api_key: Option<String>,
|
||||||
tavily_api_key: Option<String>,
|
tavily_api_key: Option<String>,
|
||||||
messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
tools: Option<Vec<Tool>>,
|
tools: Option<Vec<Tool>>,
|
||||||
logs: String,
|
logs: String,
|
||||||
answer: Option<String>,
|
answer: Option<String>,
|
||||||
|
|
@ -21,6 +23,7 @@ use crate::error::{AppError, AppResult};
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
db: DatabaseConnection,
|
||||||
zen_api_key: Option<String>,
|
zen_api_key: Option<String>,
|
||||||
tavily_api_key: Option<String>,
|
tavily_api_key: Option<String>,
|
||||||
initial_message: String,
|
initial_message: String,
|
||||||
|
|
@ -47,14 +50,24 @@ impl Agent {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
Self::with_messages(db, zen_api_key, tavily_api_key, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_messages(
|
||||||
|
db: DatabaseConnection,
|
||||||
|
zen_api_key: Option<String>,
|
||||||
|
tavily_api_key: Option<String>,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
) -> AppResult<Self> {
|
||||||
let tools = Some(tools::get_tools());
|
let tools = Some(tools::get_tools());
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
db,
|
||||||
client,
|
client,
|
||||||
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
|
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
|
||||||
zen_api_key,
|
zen_api_key,
|
||||||
|
|
@ -101,6 +114,35 @@ impl Agent {
|
||||||
turns, current_role
|
turns, current_role
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let assistant_message = self.execute_turn().await?;
|
||||||
|
|
||||||
|
if let Some(tool_calls) = &assistant_message.tool_calls {
|
||||||
|
if tool_calls.iter().any(|tc| tc.function.name == "answer") {
|
||||||
|
finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.answer.is_some() {
|
||||||
|
finished = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.log("\n--- Execution Finished ---");
|
||||||
|
Ok((self.logs.clone(), self.answer.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_turn(&mut self) -> AppResult<Message> {
|
||||||
|
let max_sub_turns = 10;
|
||||||
|
let mut sub_turns = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sub_turns += 1;
|
||||||
|
if sub_turns > max_sub_turns {
|
||||||
|
return Err(AppError::Internal(
|
||||||
|
"Interaction cycle turn limit exceeded".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let chat_response = self.call_llm().await?;
|
let chat_response = self.call_llm().await?;
|
||||||
let assistant_message = chat_response
|
let assistant_message = chat_response
|
||||||
.choices
|
.choices
|
||||||
|
|
@ -117,20 +159,24 @@ impl Agent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tool_calls) = assistant_message.tool_calls {
|
if let Some(tool_calls) = &assistant_message.tool_calls {
|
||||||
|
let mut is_final_cycle = false;
|
||||||
|
let mut final_answer = None;
|
||||||
|
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
self.log(&format!("Calling tool: {}", tool_call.function.name));
|
self.log(&format!("Calling tool: {}", tool_call.function.name));
|
||||||
|
|
||||||
let (tool_message, is_final, tool_answer) =
|
let (tool_message, is_final, tool_answer) =
|
||||||
tools::handle_tool_call(&tool_call, &self.tavily_api_key)
|
tools::handle_tool_call(tool_call, &self.tavily_api_key, &self.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
AppError::Internal(format!("Tool execution failed: {}", e))
|
AppError::Internal(format!("Tool execution failed: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(ans) = tool_answer {
|
if let Some(ans) = tool_answer {
|
||||||
self.answer = Some(ans);
|
self.answer = Some(ans.clone());
|
||||||
self.log("Task marked as finished by tool.");
|
final_answer = Some(ans);
|
||||||
|
self.log("Interaction marked as finished by tool.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(content) = &tool_message.content {
|
if let Some(content) = &tool_message.content {
|
||||||
|
|
@ -139,20 +185,24 @@ impl Agent {
|
||||||
|
|
||||||
self.messages.push(tool_message);
|
self.messages.push(tool_message);
|
||||||
if is_final {
|
if is_final {
|
||||||
finished = true;
|
is_final_cycle = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if assistant_message.content.is_some() {
|
|
||||||
// If assistant just talked without tools, we might be stuck or finished.
|
|
||||||
// But typically we expect a 'finish' tool call.
|
|
||||||
self.log("Assistant responded without tool calls.");
|
|
||||||
// For now we continue unless the assistant explicitly uses a tool to finish,
|
|
||||||
// or we could add heuristic here if needed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.log("\n--- Execution Finished ---");
|
if is_final_cycle {
|
||||||
Ok((self.logs.clone(), self.answer.clone()))
|
return Ok(Message {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: final_answer.or(assistant_message.content),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(assistant_message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_llm(&self) -> AppResult<ChatResponse> {
|
async fn call_llm(&self) -> AppResult<ChatResponse> {
|
||||||
|
|
@ -168,23 +218,46 @@ impl Agent {
|
||||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", key));
|
request_builder = request_builder.header("Authorization", format!("Bearer {}", key));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = request_builder.send().await.map_err(AppError::Network)?;
|
let start = std::time::Instant::now();
|
||||||
|
let response = request_builder.send().await.map_err(|e| {
|
||||||
|
let duration = start.elapsed();
|
||||||
|
let is_timeout = e.is_timeout();
|
||||||
|
tracing::error!(
|
||||||
|
"Network error after {:?} during LLM call (Timeout: {}): {:?}",
|
||||||
|
duration,
|
||||||
|
is_timeout,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
AppError::Network(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let duration = start.elapsed();
|
||||||
|
tracing::info!("LLM request completed in {:?}", duration);
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let error_text = response
|
let body_text = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Unknown error".into());
|
.unwrap_or_else(|_| "Unknown body".into());
|
||||||
return Err(AppError::Internal(format!(
|
let err = format!("API request failed: {} - {}", status, body_text);
|
||||||
"API request failed: {} - {}",
|
tracing::error!("{}", err);
|
||||||
status, error_text
|
return Err(AppError::Internal(err));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
let response_text = response.text().await.map_err(|e| {
|
||||||
.json()
|
let err = format!("Failed to read response text: {}", e);
|
||||||
.await
|
tracing::error!("{}", err);
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to parse LLM response: {}", e)))
|
AppError::Internal(err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
serde_json::from_str(&response_text).map_err(|e| {
|
||||||
|
let err = format!(
|
||||||
|
"Failed to parse LLM response: {} | Raw Body: {}",
|
||||||
|
e, response_text
|
||||||
|
);
|
||||||
|
tracing::error!("{}", err);
|
||||||
|
AppError::Internal(err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
|
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
|
||||||
|
use sea_orm::{
|
||||||
|
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct GoogleSearchArgs {
|
struct GoogleSearchArgs {
|
||||||
|
|
@ -11,6 +15,13 @@ struct FinishArgs {
|
||||||
result: String,
|
result: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ListRunsArgs {
|
||||||
|
from: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
task_ids: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_tools() -> Vec<Tool> {
|
pub fn get_tools() -> Vec<Tool> {
|
||||||
vec![
|
vec![
|
||||||
Tool {
|
Tool {
|
||||||
|
|
@ -47,12 +58,51 @@ pub fn get_tools() -> Vec<Tool> {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "list_tasks".to_string(),
|
||||||
|
description: "List all existing tasks and their goals".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "list_runs".to_string(),
|
||||||
|
description: "List task runs with optional filters for date and task IDs"
|
||||||
|
.to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 8601 date string to filter runs from"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 8601 date string to filter runs to"
|
||||||
|
},
|
||||||
|
"task_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "format": "uuid" },
|
||||||
|
"description": "List of task IDs to filter runs for"
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_tool_call(
|
pub async fn handle_tool_call(
|
||||||
tool_call: &ToolCall,
|
tool_call: &ToolCall,
|
||||||
tavily_api_key: &Option<String>,
|
tavily_api_key: &Option<String>,
|
||||||
|
db: &DatabaseConnection,
|
||||||
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
|
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
|
||||||
let mut answer = None;
|
let mut answer = None;
|
||||||
let name = &tool_call.function.name;
|
let name = &tool_call.function.name;
|
||||||
|
|
@ -76,6 +126,78 @@ pub async fn handle_tool_call(
|
||||||
|
|
||||||
answer = Some(result.clone());
|
answer = Some(result.clone());
|
||||||
(result.clone(), true)
|
(result.clone(), true)
|
||||||
|
} else if name == "list_tasks" {
|
||||||
|
tracing::info!("Listing tasks from database");
|
||||||
|
use crate::entities::task;
|
||||||
|
let tasks = task::Entity::find()
|
||||||
|
.order_by_desc(task::Column::CreatedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut out = String::from("Tasks:\n");
|
||||||
|
for t in tasks {
|
||||||
|
out.push_str(&format!("- ID: {}, Goal: {}\n", t.id, t.goal));
|
||||||
|
}
|
||||||
|
(out, false)
|
||||||
|
} else if name == "list_runs" {
|
||||||
|
use crate::entities::task_run;
|
||||||
|
tracing::info!(
|
||||||
|
"Listing runs from database, args: {}",
|
||||||
|
tool_call.function.arguments
|
||||||
|
);
|
||||||
|
let args: ListRunsArgs = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Listing runs from database with filters: from={:?}, to={:?}, task_ids={:?}",
|
||||||
|
args.from,
|
||||||
|
args.to,
|
||||||
|
args.task_ids
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = task_run::Entity::find();
|
||||||
|
|
||||||
|
let mut condition = Condition::all();
|
||||||
|
|
||||||
|
if let Some(from_str) = args.from {
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&from_str) {
|
||||||
|
condition = condition.add(task_run::Column::CreatedAt.gte(dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(to_str) = args.to {
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&to_str) {
|
||||||
|
condition = condition.add(task_run::Column::CreatedAt.lte(dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id_strs) = args.task_ids {
|
||||||
|
let mut valid_ids = Vec::new();
|
||||||
|
for id_str in id_strs {
|
||||||
|
match id_str.parse::<Uuid>() {
|
||||||
|
Ok(uuid) => valid_ids.push(uuid),
|
||||||
|
Err(e) => tracing::warn!("Skipping invalid UUID '{}' from LLM: {}", id_str, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid_ids.is_empty() {
|
||||||
|
condition = condition.add(task_run::Column::TaskId.is_in(valid_ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let runs = query
|
||||||
|
.filter(condition)
|
||||||
|
.order_by_desc(task_run::Column::CreatedAt)
|
||||||
|
.limit(20)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut out = String::from("Recent Runs:\n");
|
||||||
|
for r in runs {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"- ID: {}, Task ID: {}, Status: {}, Created At: {}, Answer: {:?}\n",
|
||||||
|
r.id, r.task_id, r.status, r.created_at, r.answer
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(out, false)
|
||||||
} else {
|
} else {
|
||||||
(format!("Error: Unknown tool {}", name), false)
|
(format!("Error: Unknown tool {}", name), false)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ pub struct UpdateTaskRequest {
|
||||||
pub cron: Option<String>,
|
pub cron: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct TaskResponse {
|
pub struct TaskResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub goal: String,
|
pub goal: String,
|
||||||
|
|
@ -31,7 +31,7 @@ pub struct TaskResponse {
|
||||||
pub runs: Vec<TaskRunResponse>,
|
pub runs: Vec<TaskRunResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct TaskRunResponse {
|
pub struct TaskRunResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
|
@ -40,7 +40,7 @@ pub struct TaskRunResponse {
|
||||||
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct RecentRunResponse {
|
pub struct RecentRunResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub task_id: Uuid,
|
pub task_id: Uuid,
|
||||||
|
|
@ -75,7 +75,20 @@ pub async fn execute_agent_run(
|
||||||
.await
|
.await
|
||||||
.map_err(crate::error::AppError::Database)?;
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
|
||||||
|
let _ = _scheduler
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::RunStarted(
|
||||||
|
RecentRunResponse {
|
||||||
|
id: run_id,
|
||||||
|
task_id,
|
||||||
|
goal: goal.clone(),
|
||||||
|
status: "running".to_string(),
|
||||||
|
created_at: Utc::now().into(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
let mut agent = Agent::new(
|
let mut agent = Agent::new(
|
||||||
|
db.clone(),
|
||||||
config.zen_api_key.clone(),
|
config.zen_api_key.clone(),
|
||||||
config.tavily_api_key.clone(),
|
config.tavily_api_key.clone(),
|
||||||
goal.clone(),
|
goal.clone(),
|
||||||
|
|
@ -112,7 +125,14 @@ pub async fn execute_agent_run(
|
||||||
.await
|
.await
|
||||||
.map_err(crate::error::AppError::Database)?;
|
.map_err(crate::error::AppError::Database)?;
|
||||||
|
|
||||||
get_task_inner(task_id, db).await
|
let task_response = get_task_inner(task_id, db).await?;
|
||||||
|
let _ = _scheduler
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::RunFinished(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(task_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_task_inner(id: Uuid, db: &DatabaseConnection) -> AppResult<TaskResponse> {
|
pub async fn get_task_inner(id: Uuid, db: &DatabaseConnection) -> AppResult<TaskResponse> {
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ pub struct Scheduler {
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
tasks_to_jobs: DashMap<Uuid, Uuid>,
|
tasks_to_jobs: DashMap<Uuid, Uuid>,
|
||||||
config: Arc<crate::config::Config>,
|
config: Arc<crate::config::Config>,
|
||||||
|
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scheduler {
|
impl Scheduler {
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
config: Arc<crate::config::Config>,
|
config: Arc<crate::config::Config>,
|
||||||
|
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
) -> AppResult<Self> {
|
) -> AppResult<Self> {
|
||||||
let scheduler = JobScheduler::new()
|
let scheduler = JobScheduler::new()
|
||||||
.await
|
.await
|
||||||
|
|
@ -32,6 +34,7 @@ impl Scheduler {
|
||||||
db,
|
db,
|
||||||
tasks_to_jobs: DashMap::new(),
|
tasks_to_jobs: DashMap::new(),
|
||||||
config,
|
config,
|
||||||
|
tx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,12 +46,14 @@ impl Scheduler {
|
||||||
|
|
||||||
let db = self.db.clone();
|
let db = self.db.clone();
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
|
||||||
let job = Job::new_async(cron_expr, move |_uuid, _l| {
|
let job = Job::new_async(cron_expr, move |_uuid, _l| {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
|
let tx = tx.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Err(e) = Self::run_task(db, config, task_id).await {
|
if let Err(e) = Self::run_task(db, config, tx, task_id).await {
|
||||||
tracing::error!("Error in scheduled task {}: {}", task_id, e);
|
tracing::error!("Error in scheduled task {}: {}", task_id, e);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -81,6 +86,7 @@ impl Scheduler {
|
||||||
async fn run_task(
|
async fn run_task(
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
config: Arc<crate::config::Config>,
|
config: Arc<crate::config::Config>,
|
||||||
|
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
task_id: Uuid,
|
task_id: Uuid,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
let task = Task::find_by_id(task_id)
|
let task = Task::find_by_id(task_id)
|
||||||
|
|
@ -105,8 +111,19 @@ impl Scheduler {
|
||||||
use sea_orm::ActiveModelTrait;
|
use sea_orm::ActiveModelTrait;
|
||||||
run.insert(&db).await.map_err(AppError::Database)?;
|
run.insert(&db).await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let _ = tx.send(crate::server::notifications::WsEvent::RunStarted(
|
||||||
|
crate::domain::tasks::RecentRunResponse {
|
||||||
|
id: run_id,
|
||||||
|
task_id,
|
||||||
|
goal: task.goal.clone(),
|
||||||
|
status: "running".to_string(),
|
||||||
|
created_at: chrono::Utc::now().into(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
// Start agent in background
|
// Start agent in background
|
||||||
let mut agent = Agent::new(
|
let mut agent = Agent::new(
|
||||||
|
db.clone(),
|
||||||
config.zen_api_key.clone(),
|
config.zen_api_key.clone(),
|
||||||
config.tavily_api_key.clone(),
|
config.tavily_api_key.clone(),
|
||||||
task.goal.clone(),
|
task.goal.clone(),
|
||||||
|
|
@ -142,6 +159,12 @@ impl Scheduler {
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await {
|
||||||
|
let _ = tx.send(crate::server::notifications::WsEvent::RunFinished(
|
||||||
|
task_response,
|
||||||
|
));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
57
src/server/chat.rs
Normal file
57
src/server/chat.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use crate::domain::agent::api::Message;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::server::AppState;
|
||||||
|
use axum::{Json, extract::State};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ChatPayload {
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChatResult {
|
||||||
|
pub message: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(payload): Json<ChatPayload>,
|
||||||
|
) -> Result<Json<ChatResult>, AppError> {
|
||||||
|
let msg_count = payload.messages.len();
|
||||||
|
tracing::info!("Received chat request with {} messages", msg_count);
|
||||||
|
|
||||||
|
let mut messages = payload.messages;
|
||||||
|
|
||||||
|
// Inject current date awareness if not already present or as a fresh system message
|
||||||
|
let now = chrono::Local::now();
|
||||||
|
let date_str = now.format("%A, %B %e, %Y at %l:%M %P").to_string();
|
||||||
|
messages.insert(
|
||||||
|
0,
|
||||||
|
Message {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(format!(
|
||||||
|
"The current date and time is {}. Today is {}. You are in interactive chat mode.",
|
||||||
|
date_str,
|
||||||
|
now.format("%Y-%m-%d")
|
||||||
|
)),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut agent = crate::domain::agent::Agent::with_messages(
|
||||||
|
state.db.clone(),
|
||||||
|
state.config.zen_api_key.clone(),
|
||||||
|
state.config.tavily_api_key.clone(),
|
||||||
|
messages,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
tracing::info!("Starting interactive agent turn");
|
||||||
|
let assistant_message = agent.execute_turn().await?;
|
||||||
|
|
||||||
|
Ok(Json(ChatResult {
|
||||||
|
message: assistant_message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod chat;
|
||||||
|
pub mod notifications;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -23,6 +25,7 @@ pub struct AppState {
|
||||||
pub config: Arc<crate::config::Config>,
|
pub config: Arc<crate::config::Config>,
|
||||||
pub verifier: Arc<crate::domain::auth::JwksVerifier>,
|
pub verifier: Arc<crate::domain::auth::JwksVerifier>,
|
||||||
pub authenticator: Arc<crate::domain::auth::Authenticator>,
|
pub authenticator: Arc<crate::domain::auth::Authenticator>,
|
||||||
|
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(config: crate::config::Config) -> AppResult<()> {
|
pub async fn start(config: crate::config::Config) -> AppResult<()> {
|
||||||
|
|
@ -30,8 +33,10 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> {
|
||||||
|
|
||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
|
|
||||||
|
let (tx, _) = tokio::sync::broadcast::channel(100);
|
||||||
|
|
||||||
let scheduler = Arc::new(
|
let scheduler = Arc::new(
|
||||||
Scheduler::new(db.clone(), config.clone())
|
Scheduler::new(db.clone(), config.clone(), tx.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
|
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?,
|
||||||
);
|
);
|
||||||
|
|
@ -55,6 +60,7 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
verifier,
|
verifier,
|
||||||
authenticator,
|
authenticator,
|
||||||
|
tx,
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = build_app(state, &config);
|
let app = build_app(state, &config);
|
||||||
|
|
@ -120,6 +126,8 @@ fn build_app(state: Arc<AppState>, config: &crate::config::Config) -> Router {
|
||||||
.route("/api/auth/callback", get(auth::auth_callback))
|
.route("/api/auth/callback", get(auth::auth_callback))
|
||||||
.route("/api/auth/refresh", post(auth::auth_refresh))
|
.route("/api/auth/refresh", post(auth::auth_refresh))
|
||||||
.route("/api/auth/logout", post(auth::auth_logout))
|
.route("/api/auth/logout", post(auth::auth_logout))
|
||||||
|
.route("/api/chat", post(chat::chat_handler))
|
||||||
|
.route("/api/ws", get(notifications::ws_handler))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||||
axum::http::header::CONTENT_SECURITY_POLICY,
|
axum::http::header::CONTENT_SECURITY_POLICY,
|
||||||
|
|
|
||||||
46
src/server/notifications.rs
Normal file
46
src/server/notifications.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
use crate::domain::tasks::{RecentRunResponse, TaskResponse};
|
||||||
|
use crate::server::AppState;
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
State,
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum WsEvent {
|
||||||
|
TaskCreated(TaskResponse),
|
||||||
|
TaskUpdated(TaskResponse),
|
||||||
|
RunStarted(RecentRunResponse),
|
||||||
|
RunFinished(TaskResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(|socket| handle_socket(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) {
|
||||||
|
let mut rx = state.tx.subscribe();
|
||||||
|
|
||||||
|
while let Ok(event) = rx.recv().await {
|
||||||
|
let msg = match serde_json::to_string(&event) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to serialize WsEvent: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if socket.send(Message::Text(msg)).await.is_err() {
|
||||||
|
// Client disconnected
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,7 +84,13 @@ pub async fn create_task(
|
||||||
let _ = state.scheduler.add_task_job(task_id, cron).await;
|
let _ = state.scheduler.add_task_job(task_id, cron).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks::get_task_inner(task_id, &state.db).await.map(Json)
|
let task_response = tasks::get_task_inner(task_id, &state.db).await?;
|
||||||
|
let _ = state
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::TaskCreated(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
Ok(Json(task_response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rerun_task(
|
pub async fn rerun_task(
|
||||||
|
|
@ -150,7 +156,13 @@ pub async fn update_task(
|
||||||
let _ = state.scheduler.remove_task_job(id).await;
|
let _ = state.scheduler.remove_task_job(id).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks::get_task_inner(id, &state.db).await.map(Json)
|
let task_response = tasks::get_task_inner(id, &state.db).await?;
|
||||||
|
let _ = state
|
||||||
|
.tx
|
||||||
|
.send(crate::server::notifications::WsEvent::TaskUpdated(
|
||||||
|
task_response.clone(),
|
||||||
|
));
|
||||||
|
Ok(Json(task_response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_task(
|
pub async fn get_task(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue