Compare commits

...

5 commits

Author SHA1 Message Date
006836b03c fix
All checks were successful
/ upload (release) Successful in 1m46s
2026-02-11 17:55:09 +01:00
beab32894b idk 2026-02-11 17:45:04 +01:00
bab956387c stuff 2026-02-11 17:09:49 +01:00
f0b53b67ce websockets 2026-02-11 16:55:55 +01:00
b44e4ed6f9 chat agent 2026-02-11 16:43:05 +01:00
16 changed files with 1025 additions and 75 deletions

154
AGENTS.md Normal file
View 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
View file

@ -161,6 +161,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"base64",
"bytes",
"futures-util",
"http",
@ -179,8 +180,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@ -336,7 +339,7 @@ dependencies = [
"sea-orm-migration",
"serde",
"serde_json",
"thiserror",
"thiserror 2.0.18",
"tokio",
"tokio-cron-scheduler",
"tower-http 0.5.2",
@ -667,6 +670,12 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "der"
version = "0.7.10"
@ -2505,7 +2514,7 @@ dependencies = [
"serde_json",
"sqlx",
"strum 0.26.3",
"thiserror",
"thiserror 2.0.18",
"time",
"tracing",
"url",
@ -2602,7 +2611,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@ -2814,7 +2823,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"thiserror 2.0.18",
"time",
]
@ -2912,7 +2921,7 @@ dependencies = [
"serde_json",
"sha2",
"smallvec",
"thiserror",
"thiserror 2.0.18",
"time",
"tokio",
"tokio-stream",
@ -3000,7 +3009,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 2.0.18",
"time",
"tracing",
"uuid",
@ -3043,7 +3052,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 2.0.18",
"time",
"tracing",
"uuid",
@ -3070,7 +3079,7 @@ dependencies = [
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror",
"thiserror 2.0.18",
"time",
"tracing",
"url",
@ -3221,13 +3230,33 @@ dependencies = [
"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]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
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]]
@ -3381,6 +3410,18 @@ dependencies = [
"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]]
name = "tokio-util"
version = "0.7.18"
@ -3554,6 +3595,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "typenum"
version = "1.19.0"
@ -3611,6 +3670,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View file

@ -12,7 +12,7 @@ tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
axum = "0.7"
axum = { version = "0.7", features = ["ws"] }
tower-http = { version = "0.5", features = ["cors", "set-header", "limit"] }
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
sea-orm-migration = "1.1"

View file

@ -63,9 +63,41 @@
<div id="dashboard-view" class="dashboard-view">
<header class="dashboard-header">
<h2>Recent Activity</h2>
<p>Track latest agent executions across all directives.</p>
<h2>Perspective</h2>
<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>
<div class="dashboard-content-grid">
<div id="chat-tab-panel" class="tab-panel active">
<div id="chat-container" class="chat-container glass">
<div class="chat-header">
<h3>Neural Link</h3>
<button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear Memory</button>
</div>
<div id="chat-messages" class="chat-messages">
<!-- Chat messages will be injected here -->
<div class="chat-empty-state">
<p>Establish connection with the neural assistant.</p>
</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>
@ -81,6 +113,8 @@
</table>
</div>
</div>
</div>
</div>
<div id="task-view" class="task-view hidden">
<header class="view-header">

View file

@ -16,7 +16,9 @@ const state = {
selectedRunId: null,
currentView: 'dashboard', // 'dashboard' or 'task'
isEditing: false,
isAuthenticated: false
isAuthenticated: false,
chatMessages: [],
activeDashboardTab: 'chat' // 'chat' or 'activity'
};
// DOM elements
const loginOverlay = document.getElementById('login-overlay');
@ -53,6 +55,11 @@ const schedulePresets = document.getElementById('schedule-presets');
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
const customCronContainer = document.getElementById('custom-cron-container');
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) {
Object.assign(state, newState);
@ -89,6 +96,7 @@ function renderApp() {
if (state.currentView === 'dashboard') {
fetchRecentRuns();
renderChat();
} else if (state.selectedTaskId) {
const task = state.tasks.find(t => t.id === state.selectedTaskId);
if (task) {
@ -264,6 +272,28 @@ function showDashboard() {
renderTaskList();
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) {
@ -354,6 +384,62 @@ function escapeHtml(text) {
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
rerunBtn.addEventListener('click', async () => {
if (!state.selectedTaskId) return;
@ -447,6 +533,19 @@ toggleCustomCronBtn.addEventListener('click', () => {
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', () => {
// If user types manually, update presets active state
updateScheduleUI(cronInput.value);
@ -511,17 +610,86 @@ async function checkSession() {
return false;
}
let isPolling = false;
async function startAutoRefresh() {
setInterval(async () => {
if (isPolling) return;
isPolling = true;
let socket = null;
let reconnectDelay = 1000;
function connectWebSocket() {
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 {
await fetchTasks();
} finally {
isPolling = false;
const { type, data } = JSON.parse(event.data);
console.log('WebSocket event:', type, data);
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;
}
}, 3000);
}
} 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);
}
};
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() {
@ -578,6 +746,13 @@ logoutBtn.addEventListener('click', () => {
logout();
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.activeDashboardTab = btn.dataset.tab;
renderDashboardTabs();
});
});
async function initializeApp() {
const hasSession = await checkSession();
@ -585,7 +760,7 @@ async function initializeApp() {
appEl.classList.remove('hidden');
loginOverlay.classList.add('hidden');
await fetchTasks();
startAutoRefresh();
connectWebSocket();
} else {
showLogin();
}

View file

@ -88,9 +88,15 @@ body {
.dashboard-content {
flex: 1;
overflow-y: auto;
border-radius: 12px;
border-radius: 16px;
display: flex;
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 {
@ -124,6 +130,158 @@ body {
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 {
display: inline-block;
}
@ -796,9 +954,9 @@ textarea:focus {
}
.btn-sm {
padding: 6px 12px;
padding: 6px 16px;
font-size: 12px;
width: 100%;
width: auto;
justify-content: center;
}

View file

@ -6,6 +6,7 @@ export default defineConfig({
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
ws: true,
}
}
}

View file

@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Message {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
@ -29,6 +28,7 @@ pub struct FunctionCall {
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
}
@ -72,6 +72,7 @@ pub async fn perform_search(
query: &str,
api_key: &str,
) -> Result<String, Box<dyn std::error::Error>> {
tracing::info!(query = %query, "Performing Tavily web search");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
@ -94,6 +95,7 @@ pub async fn perform_search(
}
let search_data: TavilyResponse = response.json().await?;
tracing::info!("Web search yielded {} results", search_data.results.len());
let mut results_text = String::new();
for (i, result) in search_data.results.iter().enumerate() {

View file

@ -2,16 +2,18 @@ pub mod api;
pub mod tools;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use std::time::{Duration, Instant};
use self::api::{ChatRequest, ChatResponse, Message, Tool};
pub struct Agent {
db: DatabaseConnection,
client: reqwest::Client,
url: String,
zen_api_key: Option<String>,
tavily_api_key: Option<String>,
messages: Vec<Message>,
pub messages: Vec<Message>,
tools: Option<Vec<Tool>>,
logs: String,
answer: Option<String>,
@ -21,6 +23,7 @@ use crate::error::{AppError, AppResult};
impl Agent {
pub fn new(
db: DatabaseConnection,
zen_api_key: Option<String>,
tavily_api_key: Option<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 client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| AppError::Internal(format!("Failed to build HTTP client: {}", e)))?;
Ok(Self {
db,
client,
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
zen_api_key,
@ -101,6 +114,35 @@ impl Agent {
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 assistant_message = chat_response
.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 {
self.log(&format!("Calling tool: {}", tool_call.function.name));
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
.map_err(|e| {
AppError::Internal(format!("Tool execution failed: {}", e))
})?;
if let Some(ans) = tool_answer {
self.answer = Some(ans);
self.log("Task marked as finished by tool.");
self.answer = Some(ans.clone());
final_answer = Some(ans);
self.log("Interaction marked as finished by tool.");
}
if let Some(content) = &tool_message.content {
@ -139,20 +185,24 @@ impl Agent {
self.messages.push(tool_message);
if is_final {
finished = 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.
is_final_cycle = true;
}
}
self.log("\n--- Execution Finished ---");
Ok((self.logs.clone(), self.answer.clone()))
if is_final_cycle {
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> {
@ -168,23 +218,46 @@ impl Agent {
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() {
let status = response.status();
let error_text = response
let body_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".into());
return Err(AppError::Internal(format!(
"API request failed: {} - {}",
status, error_text
)));
.unwrap_or_else(|_| "Unknown body".into());
let err = format!("API request failed: {} - {}", status, body_text);
tracing::error!("{}", err);
return Err(AppError::Internal(err));
}
response
.json()
.await
.map_err(|e| AppError::Internal(format!("Failed to parse LLM response: {}", e)))
let response_text = response.text().await.map_err(|e| {
let err = format!("Failed to read response text: {}", e);
tracing::error!("{}", err);
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)
})
}
}

View file

@ -1,5 +1,9 @@
use super::api::{self, FunctionDefinition, Message, Tool, ToolCall};
use sea_orm::{
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
};
use serde::Deserialize;
use uuid::Uuid;
#[derive(Deserialize)]
struct GoogleSearchArgs {
@ -11,6 +15,13 @@ struct FinishArgs {
result: String,
}
#[derive(Deserialize)]
struct ListRunsArgs {
from: Option<String>,
to: Option<String>,
task_ids: Option<Vec<String>>,
}
pub fn get_tools() -> Vec<Tool> {
vec![
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(
tool_call: &ToolCall,
tavily_api_key: &Option<String>,
db: &DatabaseConnection,
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
let mut answer = None;
let name = &tool_call.function.name;
@ -76,6 +126,78 @@ pub async fn handle_tool_call(
answer = Some(result.clone());
(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 {
(format!("Error: Unknown tool {}", name), false)
};

View file

@ -22,7 +22,7 @@ pub struct UpdateTaskRequest {
pub cron: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, Clone, Debug)]
pub struct TaskResponse {
pub id: Uuid,
pub goal: String,
@ -31,7 +31,7 @@ pub struct TaskResponse {
pub runs: Vec<TaskRunResponse>,
}
#[derive(Serialize)]
#[derive(Serialize, Clone, Debug)]
pub struct TaskRunResponse {
pub id: Uuid,
pub status: String,
@ -40,7 +40,7 @@ pub struct TaskRunResponse {
pub created_at: chrono::DateTime<chrono::FixedOffset>,
}
#[derive(Serialize)]
#[derive(Serialize, Clone, Debug)]
pub struct RecentRunResponse {
pub id: Uuid,
pub task_id: Uuid,
@ -75,7 +75,20 @@ pub async fn execute_agent_run(
.await
.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(
db.clone(),
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
goal.clone(),
@ -112,7 +125,14 @@ pub async fn execute_agent_run(
.await
.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> {

View file

@ -13,12 +13,14 @@ pub struct Scheduler {
db: DatabaseConnection,
tasks_to_jobs: DashMap<Uuid, Uuid>,
config: Arc<crate::config::Config>,
pub tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
}
impl Scheduler {
pub async fn new(
db: DatabaseConnection,
config: Arc<crate::config::Config>,
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
) -> AppResult<Self> {
let scheduler = JobScheduler::new()
.await
@ -32,6 +34,7 @@ impl Scheduler {
db,
tasks_to_jobs: DashMap::new(),
config,
tx,
})
}
@ -43,12 +46,14 @@ impl Scheduler {
let db = self.db.clone();
let config = self.config.clone();
let tx = self.tx.clone();
let job = Job::new_async(cron_expr, move |_uuid, _l| {
let db = db.clone();
let config = config.clone();
let tx = tx.clone();
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);
}
})
@ -81,6 +86,7 @@ impl Scheduler {
async fn run_task(
db: DatabaseConnection,
config: Arc<crate::config::Config>,
tx: tokio::sync::broadcast::Sender<crate::server::notifications::WsEvent>,
task_id: Uuid,
) -> AppResult<()> {
let task = Task::find_by_id(task_id)
@ -105,8 +111,19 @@ impl Scheduler {
use sea_orm::ActiveModelTrait;
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
let mut agent = Agent::new(
db.clone(),
config.zen_api_key.clone(),
config.tavily_api_key.clone(),
task.goal.clone(),
@ -142,6 +159,12 @@ impl Scheduler {
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(())

57
src/server/chat.rs Normal file
View 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,
}))
}

View file

@ -1,4 +1,6 @@
pub mod auth;
pub mod chat;
pub mod notifications;
pub mod tasks;
use axum::{
@ -23,6 +25,7 @@ pub struct AppState {
pub config: Arc<crate::config::Config>,
pub verifier: Arc<crate::domain::auth::JwksVerifier>,
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<()> {
@ -30,8 +33,10 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> {
let config = Arc::new(config);
let (tx, _) = tokio::sync::broadcast::channel(100);
let scheduler = Arc::new(
Scheduler::new(db.clone(), config.clone())
Scheduler::new(db.clone(), config.clone(), tx.clone())
.await
.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(),
verifier,
authenticator,
tx,
});
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/refresh", post(auth::auth_refresh))
.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(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,

View 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;
}
}
}

View file

@ -84,7 +84,13 @@ pub async fn create_task(
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(
@ -150,7 +156,13 @@ pub async fn update_task(
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(