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

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 chat;
pub mod tasks;
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/refresh", post(auth::auth_refresh))
.route("/api/auth/logout", post(auth::auth_logout))
.route("/api/chat", post(chat::chat_handler))
.layer(cors)
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
axum::http::header::CONTENT_SECURITY_POLICY,