chat agent

This commit is contained in:
pavel 2026-02-11 16:43:05 +01:00
commit b44e4ed6f9
11 changed files with 626 additions and 16 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.

View file

@ -66,6 +66,7 @@
<h2>Recent Activity</h2> <h2>Recent Activity</h2>
<p>Track latest agent executions across all directives.</p> <p>Track latest agent executions across all directives.</p>
</header> </header>
<div class="dashboard-content-grid">
<div class="dashboard-content glass"> <div class="dashboard-content glass">
<table class="activity-table"> <table class="activity-table">
<thead> <thead>
@ -80,6 +81,25 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="chat-container" class="chat-container glass">
<div class="chat-header">
<h3>Quick Chat</h3>
<button id="clear-chat-btn" class="btn btn-ghost btn-sm">Clear</button>
</div>
<div id="chat-messages" class="chat-messages">
<!-- Chat messages will be injected here -->
<div class="chat-empty-state">
<p>Start a conversation with the assistant.</p>
</div>
</div>
<form id="chat-form" class="chat-form">
<input type="text" id="chat-input" placeholder="Type a message..." autocomplete="off"
required>
<button type="submit" id="chat-send-btn" class="btn btn-primary btn-sm">Send</button>
</form>
</div>
</div>
</div> </div>
<div id="task-view" class="task-view hidden"> <div id="task-view" class="task-view hidden">

View file

@ -16,7 +16,8 @@ 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: []
}; };
// DOM elements // DOM elements
const loginOverlay = document.getElementById('login-overlay'); const loginOverlay = document.getElementById('login-overlay');
@ -53,6 +54,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 +95,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) {
@ -354,6 +361,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}">
${escapeHtml(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 +510,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);

View file

@ -124,6 +124,104 @@ body {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.dashboard-content-grid {
display: grid;
grid-template-columns: 1fr 350px;
gap: 24px;
flex: 1;
min-height: 0;
}
.chat-container {
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.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: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
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: 6px;
padding: 8px 12px;
color: var(--text-main);
font-size: 14px;
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;
} }

View file

@ -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() {

View file

@ -2,11 +2,13 @@ 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>,
@ -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,
@ -55,6 +58,7 @@ impl Agent {
.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,
@ -122,7 +126,7 @@ impl Agent {
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))

View file

@ -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)
}; };

View file

@ -76,6 +76,7 @@ pub async fn execute_agent_run(
.map_err(crate::error::AppError::Database)?; .map_err(crate::error::AppError::Database)?;
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(),

View file

@ -107,6 +107,7 @@ impl Scheduler {
// 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(),

130
src/server/chat.rs Normal file
View file

@ -0,0 +1,130 @@
use crate::domain::agent::api::{ChatRequest, ChatResponse, 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 client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| {
let err = format!("Failed to build HTTP client: {}", e);
tracing::error!("{}", err);
AppError::Internal(err)
})?;
let url = "https://opencode.ai/zen/v1/chat/completions";
let mut messages = payload.messages;
let tools = crate::domain::agent::tools::get_tools();
let max_turns = 10;
let mut turns = 0;
loop {
turns += 1;
if turns > max_turns {
return Err(AppError::Internal("Chat turn limit exceeded".into()));
}
let request = ChatRequest {
model: "big-pickle".to_string(),
messages: messages.clone(),
tools: Some(tools.clone()),
};
let mut request_builder = client.post(url).json(&request);
if let Some(key) = &state.config.zen_api_key {
request_builder = request_builder.header("Authorization", format!("Bearer {}", key));
}
let response = request_builder.send().await.map_err(|e| {
tracing::error!("Network error during chat completion: {}", e);
AppError::Network(e)
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".into());
let err = format!("API request failed: {} - {}", status, error_text);
tracing::error!("{}", err);
return Err(AppError::Internal(err));
}
let chat_response: ChatResponse = response.json().await.map_err(|e| {
let err = format!("Failed to parse LLM response: {}", e);
tracing::error!("{}", err);
AppError::Internal(err)
})?;
let assistant_message = chat_response
.choices
.get(0)
.ok_or_else(|| {
let err = "Missing assistant response choices";
tracing::error!("{}", err);
AppError::Internal(err.into())
})?
.message
.clone();
messages.push(assistant_message.clone());
if let Some(tool_calls) = &assistant_message.tool_calls {
for tool_call in tool_calls {
tracing::info!("Chat agent calling tool: {}", tool_call.function.name);
let (tool_message, is_final, answer) =
crate::domain::agent::tools::handle_tool_call(
tool_call,
&state.config.tavily_api_key,
&state.db,
)
.await
.map_err(|e| AppError::Internal(format!("Tool execution failed: {}", e)))?;
messages.push(tool_message.clone());
if is_final {
tracing::info!("Chat agent finished via tool");
return Ok(Json(ChatResult {
message: Message {
role: "assistant".to_string(),
content: answer.or(tool_message.content),
tool_calls: None,
tool_call_id: None,
},
}));
}
}
// After tool calls, we loop back to get another assistant response
continue;
}
// If no tool calls, it's a final response for this turn
tracing::info!("Chat completion successful");
return Ok(Json(ChatResult {
message: assistant_message,
}));
}
}

View file

@ -1,4 +1,5 @@
pub mod auth; pub mod auth;
pub mod chat;
pub mod tasks; pub mod tasks;
use axum::{ use axum::{
@ -120,6 +121,7 @@ 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))
.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,