test
This commit is contained in:
parent
6a1cddbb47
commit
0fa627ca6d
18 changed files with 6192 additions and 0 deletions
3722
Cargo.lock
generated
Normal file
3722
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[workspace]
|
||||||
|
members = [".", "migration"]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "bot"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
migration = { path = "migration" }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
axum = "0.7"
|
||||||
|
tower-http = { version = "0.5", features = ["cors"] }
|
||||||
|
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
|
||||||
|
sea-orm-migration = "1.1"
|
||||||
|
uuid = { version = "1.8", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
93
frontend/index.html
Normal file
93
frontend/index.html
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Antigravity Agent Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/src/style.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="dark-theme">
|
||||||
|
<div id="app">
|
||||||
|
<aside class="sidebar glass">
|
||||||
|
<header class="sidebar-header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">▲</span>
|
||||||
|
<h1>Agency</h1>
|
||||||
|
</div>
|
||||||
|
<button id="new-task-btn" class="btn btn-primary">
|
||||||
|
<span>+</span> New Task
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<nav class="task-nav">
|
||||||
|
<div class="nav-label">Directives</div>
|
||||||
|
<ul id="task-list">
|
||||||
|
<!-- Tasks will be injected here -->
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<div id="empty-state" class="empty-state">
|
||||||
|
<div class="empty-icon">⌘</div>
|
||||||
|
<h2>Ready for a new directive?</h2>
|
||||||
|
<p>Select a task from the sidebar or create a new one to begin.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="task-view" class="task-view hidden">
|
||||||
|
<header class="view-header">
|
||||||
|
<div class="header-main">
|
||||||
|
<div id="view-status" class="status-badge">Running</div>
|
||||||
|
<h2 id="view-goal">Researching local LLM performance trends</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="answer-container" class="answer-container glass hidden">
|
||||||
|
<div class="answer-header">
|
||||||
|
<h3>Final Answer</h3>
|
||||||
|
</div>
|
||||||
|
<div id="answer-output" class="answer-output">
|
||||||
|
<!-- Answer will be injected here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="logs-container glass">
|
||||||
|
<div class="logs-header">
|
||||||
|
<h3>Execution Logs</h3>
|
||||||
|
<div class="logs-actions">
|
||||||
|
<span class="logs-timestamp" id="view-date">Feb 9, 2026</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="logs-output" class="logs-output">
|
||||||
|
<!-- Logs will be injected here -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-container" class="modal-container hidden">
|
||||||
|
<div class="modal glass">
|
||||||
|
<h2>New Agent Task</h2>
|
||||||
|
<p>Provide a goal for the agent to execute.</p>
|
||||||
|
<form id="new-task-form">
|
||||||
|
<textarea name="goal"
|
||||||
|
placeholder="e.g. Research the latest trending AI agents and write a report..."
|
||||||
|
required></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" id="cancel-task">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Execute Directive</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
1114
frontend/package-lock.json
generated
Normal file
1114
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-static": "^0.563.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
149
frontend/src/main.js
Normal file
149
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
const API_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
tasks: [],
|
||||||
|
selectedTaskId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
const taskListEl = document.getElementById('task-list');
|
||||||
|
const newTaskBtn = document.getElementById('new-task-btn');
|
||||||
|
const modalContainer = document.getElementById('modal-container');
|
||||||
|
const newTaskForm = document.getElementById('new-task-form');
|
||||||
|
const cancelTaskBtn = document.getElementById('cancel-task');
|
||||||
|
const emptyStateEl = document.getElementById('empty-state');
|
||||||
|
const taskViewEl = document.getElementById('task-view');
|
||||||
|
const logsOutputEl = document.getElementById('logs-output');
|
||||||
|
const viewGoalEl = document.getElementById('view-goal');
|
||||||
|
const viewStatusEl = document.getElementById('view-status');
|
||||||
|
const viewDateEl = document.getElementById('view-date');
|
||||||
|
const answerContainerEl = document.getElementById('answer-container');
|
||||||
|
const answerOutputEl = document.getElementById('answer-output');
|
||||||
|
|
||||||
|
async function fetchTasks() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/tasks`);
|
||||||
|
state.tasks = await response.json();
|
||||||
|
renderTaskList();
|
||||||
|
|
||||||
|
// Update current view if a task is selected
|
||||||
|
if (state.selectedTaskId) {
|
||||||
|
const task = state.tasks.find(t => t.id === state.selectedTaskId);
|
||||||
|
if (task) {
|
||||||
|
showTaskView(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tasks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskList() {
|
||||||
|
taskListEl.innerHTML = state.tasks
|
||||||
|
.map(
|
||||||
|
(task) => `
|
||||||
|
<li class="task-item ${state.selectedTaskId === task.id ? 'active' : ''}" data-id="${task.id}">
|
||||||
|
<div class="task-item-title">${task.goal}</div>
|
||||||
|
<div class="task-item-meta">
|
||||||
|
<span class="status-dot ${task.status}"></span>
|
||||||
|
<span>${new Date(task.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.querySelectorAll('.task-item').forEach((item) => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
selectTask(item.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(id) {
|
||||||
|
state.selectedTaskId = id;
|
||||||
|
const task = state.tasks.find((t) => t.id === id);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
renderTaskList();
|
||||||
|
showTaskView(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTaskView(task) {
|
||||||
|
emptyStateEl.classList.add('hidden');
|
||||||
|
taskViewEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
viewGoalEl.textContent = task.goal;
|
||||||
|
viewStatusEl.textContent = task.status;
|
||||||
|
viewStatusEl.className = `status-badge ${task.status}`;
|
||||||
|
viewDateEl.textContent = new Date(task.created_at).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (task.answer) {
|
||||||
|
answerContainerEl.classList.remove('hidden');
|
||||||
|
answerOutputEl.textContent = task.answer;
|
||||||
|
} else {
|
||||||
|
answerContainerEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should auto-scroll
|
||||||
|
const isAtBottom = logsOutputEl.scrollHeight - logsOutputEl.scrollTop <= logsOutputEl.clientHeight + 10;
|
||||||
|
const isFirstLoad = logsOutputEl.innerHTML === '';
|
||||||
|
|
||||||
|
// Simple log format
|
||||||
|
logsOutputEl.innerHTML = task.logs
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => `<div class="log-entry">${escapeHtml(line)}</div>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
if (isAtBottom || isFirstLoad) {
|
||||||
|
logsOutputEl.scrollTop = logsOutputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
newTaskBtn.addEventListener('click', () => {
|
||||||
|
modalContainer.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelTaskBtn.addEventListener('click', () => {
|
||||||
|
modalContainer.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
newTaskForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(newTaskForm);
|
||||||
|
const goal = formData.get('goal');
|
||||||
|
|
||||||
|
modalContainer.classList.add('hidden');
|
||||||
|
newTaskForm.reset();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/tasks`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ goal }),
|
||||||
|
});
|
||||||
|
const newTask = await response.json();
|
||||||
|
state.tasks.unshift(newTask);
|
||||||
|
selectTask(newTask.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating task:', error);
|
||||||
|
alert('Failed to execute task. Check console.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchTasks();
|
||||||
|
// Poll for updates every 5 seconds (simplistic for now)
|
||||||
|
setInterval(fetchTasks, 5000);
|
||||||
373
frontend/src/style.css
Normal file
373
frontend/src/style.css
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
:root {
|
||||||
|
--bg-dark: #0a0a0c;
|
||||||
|
--bg-sidebar: #121216;
|
||||||
|
--primary: #5d5dff;
|
||||||
|
--primary-glow: rgba(93, 93, 255, 0.4);
|
||||||
|
--text-main: #e0e0e6;
|
||||||
|
--text-dim: #9494a5;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--status-running: #4dabf7;
|
||||||
|
--status-completed: #51cf66;
|
||||||
|
--status-failed: #ff6b6b;
|
||||||
|
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.5;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 24px;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--primary);
|
||||||
|
text-shadow: 0 0 10px var(--primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#task-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item.active {
|
||||||
|
background: rgba(93, 93, 255, 0.1);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
background: radial-gradient(circle at 50% 50%, #1a1a24 0%, #0a0a0c 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: rgba(77, 171, 247, 0.1);
|
||||||
|
color: var(--status-running);
|
||||||
|
border: 1px solid rgba(77, 171, 247, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.completed {
|
||||||
|
background: rgba(81, 207, 102, 0.1);
|
||||||
|
color: var(--status-completed);
|
||||||
|
border: 1px solid rgba(81, 207, 102, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-output {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: #c9c9d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Answer Section */
|
||||||
|
.answer-container {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
background: linear-gradient(to right, rgba(93, 93, 255, 0.05), transparent);
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-header h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-output {
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-main);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 14px 0 rgba(93, 93, 255, 0.39);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(93, 93, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 500px;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: inherit;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--glass-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
11
migration/Cargo.toml
Normal file
11
migration/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "migration"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sea-orm-migration = "1.1"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "migration"
|
||||||
|
path = "src/lib.rs"
|
||||||
16
migration/src/lib.rs
Normal file
16
migration/src/lib.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
mod m20220101_000001_create_table;
|
||||||
|
mod m20260210_000002_add_answer_column;
|
||||||
|
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20220101_000001_create_table::Migration),
|
||||||
|
Box::new(m20260210_000002_add_answer_column::Migration),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
43
migration/src/m20220101_000001_create_table.rs
Normal file
43
migration/src/m20220101_000001_create_table.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Tasks::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(ColumnDef::new(Tasks::Id).uuid().not_null().primary_key())
|
||||||
|
.col(ColumnDef::new(Tasks::Goal).string().not_null())
|
||||||
|
.col(ColumnDef::new(Tasks::Status).string().not_null())
|
||||||
|
.col(ColumnDef::new(Tasks::Logs).text().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Tasks::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Tasks::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Tasks {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
Goal,
|
||||||
|
Status,
|
||||||
|
Logs,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
35
migration/src/m20260210_000002_add_answer_column.rs
Normal file
35
migration/src/m20260210_000002_add_answer_column.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Tasks::Table)
|
||||||
|
.add_column(ColumnDef::new(Tasks::Answer).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Tasks::Table)
|
||||||
|
.drop_column(Tasks::Answer)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Tasks {
|
||||||
|
Table,
|
||||||
|
Answer,
|
||||||
|
}
|
||||||
141
src/agent.rs
Normal file
141
src/agent.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::api::{ChatRequest, ChatResponse, Message, Tool};
|
||||||
|
use crate::tools;
|
||||||
|
|
||||||
|
pub struct Agent {
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
zen_api_key: Option<String>,
|
||||||
|
tavily_api_key: Option<String>,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
tools: Option<Vec<Tool>>,
|
||||||
|
logs: String,
|
||||||
|
answer: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Agent {
|
||||||
|
pub fn new(
|
||||||
|
zen_api_key: Option<String>,
|
||||||
|
tavily_api_key: Option<String>,
|
||||||
|
initial_message: String,
|
||||||
|
) -> Self {
|
||||||
|
let intro = format!(
|
||||||
|
"You are an autonomous agent. You have access to tools that can help
|
||||||
|
you achieve your goals. Use them wisely. Do not ask for clarification and use the
|
||||||
|
answer tool once you to give your final answer. current date is {}",
|
||||||
|
Utc::now().to_rfc3339()
|
||||||
|
);
|
||||||
|
println!("initial_message: {}", intro);
|
||||||
|
let messages = vec![
|
||||||
|
Message {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: Some(intro),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
Message {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: Some(initial_message),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let tools = Some(tools::get_tools());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
url: "https://opencode.ai/zen/v1/chat/completions".to_string(),
|
||||||
|
zen_api_key,
|
||||||
|
tavily_api_key,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
logs: String::new(),
|
||||||
|
answer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&mut self, message: &str) {
|
||||||
|
println!("{}", message);
|
||||||
|
self.logs.push_str(message);
|
||||||
|
self.logs.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(&mut self) -> Result<(String, Option<String>), Box<dyn std::error::Error>> {
|
||||||
|
let mut file_written = false;
|
||||||
|
|
||||||
|
while !file_written {
|
||||||
|
let request = ChatRequest {
|
||||||
|
model: "kimi-k2.5".to_string(),
|
||||||
|
messages: self.messages.clone(),
|
||||||
|
tools: self.tools.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.log(&format!(
|
||||||
|
"--- Sending request to Zen API (Role: {}) ---",
|
||||||
|
self.messages.last().unwrap().role
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut request_builder = self.client.post(&self.url).json(&request);
|
||||||
|
|
||||||
|
if let Some(key) = &self.zen_api_key {
|
||||||
|
request_builder =
|
||||||
|
request_builder.header("Authorization", format!("Bearer {}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request_builder.send().await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await?;
|
||||||
|
self.log(&format!("Error: API request failed with status {}", status));
|
||||||
|
self.log(&format!("Error details: {}", error_text));
|
||||||
|
return Err(format!("API request failed: {}", status).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_response: ChatResponse = response.json().await?;
|
||||||
|
let assistant_message = chat_response.choices.get(0).unwrap().message.clone();
|
||||||
|
|
||||||
|
self.messages.push(assistant_message.clone());
|
||||||
|
|
||||||
|
if let Some(content) = &assistant_message.content {
|
||||||
|
if !content.is_empty() {
|
||||||
|
self.log(&format!("\nAssistant response:\n{}\n", content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tool_calls) = assistant_message.tool_calls {
|
||||||
|
for tool_call in tool_calls {
|
||||||
|
let (tool_message, written, tool_answer) =
|
||||||
|
tools::handle_tool_call(&tool_call, &self.tavily_api_key).await?;
|
||||||
|
|
||||||
|
if let Some(ans) = tool_answer {
|
||||||
|
self.answer = Some(ans);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content) = &tool_message.content {
|
||||||
|
self.log(&format!(
|
||||||
|
"Tool result ({}): {}",
|
||||||
|
tool_call.function.name, content
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.messages.push(tool_message);
|
||||||
|
if written {
|
||||||
|
file_written = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Continue the loop to send tool results back
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more tool calls from assistant, but we only exit if file was written
|
||||||
|
if !file_written {
|
||||||
|
self.log("--- Assistant didn't use write_file yet. Waiting for next turn... ---");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((self.logs.clone(), self.answer.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/api.rs
Normal file
112
src/api.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
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>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct ToolCall {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub call_type: String,
|
||||||
|
pub function: FunctionCall,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct FunctionCall {
|
||||||
|
pub name: String,
|
||||||
|
pub arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
pub tools: Option<Vec<Tool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ChatResponse {
|
||||||
|
pub choices: Vec<Choice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Choice {
|
||||||
|
pub message: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct Tool {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub tool_type: String,
|
||||||
|
pub function: FunctionDefinition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Debug, Clone)]
|
||||||
|
pub struct FunctionDefinition {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub parameters: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct TavilyResponse {
|
||||||
|
results: Vec<TavilyResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct TavilyResult {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn perform_search(
|
||||||
|
query: &str,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post("https://api.tavily.com/search")
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"api_key": api_key,
|
||||||
|
"query": query,
|
||||||
|
"search_depth": "basic",
|
||||||
|
"include_answer": false,
|
||||||
|
"include_images": false,
|
||||||
|
"include_raw_content": false,
|
||||||
|
"max_results": 5,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Tavily API error: {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let search_data: TavilyResponse = response.json().await?;
|
||||||
|
let mut results_text = String::new();
|
||||||
|
|
||||||
|
for (i, result) in search_data.results.iter().enumerate() {
|
||||||
|
results_text.push_str(&format!(
|
||||||
|
"{}. {} ({})\nSnippet: {}\n\n",
|
||||||
|
i + 1,
|
||||||
|
result.title,
|
||||||
|
result.url,
|
||||||
|
result.content
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if results_text.is_empty() {
|
||||||
|
Ok("No results found.".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(results_text)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/entities/mod.rs
Normal file
1
src/entities/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod task;
|
||||||
21
src/entities/task.rs
Normal file
21
src/entities/task.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "tasks")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub goal: String,
|
||||||
|
pub status: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub logs: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub answer: Option<String>,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
14
src/main.rs
Normal file
14
src/main.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
mod agent;
|
||||||
|
mod api;
|
||||||
|
mod entities;
|
||||||
|
mod server;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
|
|
||||||
|
server::start(&db_url).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
179
src/server.rs
Normal file
179
src/server.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use axum::{
|
||||||
|
Json, Router,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{Method, StatusCode},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{ActiveModelTrait, Database, DatabaseConnection, EntityTrait, QueryOrder, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::agent::Agent;
|
||||||
|
use crate::entities::task::{self, Entity as Task};
|
||||||
|
use migration::{Migrator, MigratorTrait};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub zen_api_key: Option<String>,
|
||||||
|
pub tavily_api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateTaskRequest {
|
||||||
|
pub goal: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TaskResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub goal: String,
|
||||||
|
pub status: String,
|
||||||
|
pub logs: String,
|
||||||
|
pub answer: Option<String>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(db_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let db = Database::connect(db_url).await?;
|
||||||
|
Migrator::up(&db, None).await?;
|
||||||
|
|
||||||
|
let zen_api_key = std::env::var("ZEN_API_KEY").ok();
|
||||||
|
let tavily_api_key = std::env::var("TAVILY_API_KEY").ok();
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
db,
|
||||||
|
zen_api_key,
|
||||||
|
tavily_api_key,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods([Method::GET, Method::POST])
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/tasks", post(create_task).get(list_tasks))
|
||||||
|
.route("/tasks/:id", get(get_task))
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
|
println!("Server running on http://localhost:3000");
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tasks(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<TaskResponse>>, (StatusCode, String)> {
|
||||||
|
let tasks = Task::find()
|
||||||
|
.order_by_desc(task::Column::CreatedAt)
|
||||||
|
.all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let response = tasks
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| TaskResponse {
|
||||||
|
id: t.id,
|
||||||
|
goal: t.goal,
|
||||||
|
status: t.status,
|
||||||
|
logs: t.logs,
|
||||||
|
answer: t.answer,
|
||||||
|
created_at: t.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_task(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(payload): Json<CreateTaskRequest>,
|
||||||
|
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
||||||
|
let task_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// Initial save
|
||||||
|
let new_task = task::ActiveModel {
|
||||||
|
id: Set(task_id),
|
||||||
|
goal: Set(payload.goal.clone()),
|
||||||
|
status: Set("running".to_string()),
|
||||||
|
logs: Set(String::new()),
|
||||||
|
answer: Set(None),
|
||||||
|
created_at: Set(Utc::now().into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
new_task
|
||||||
|
.insert(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Run agent in the background or synchronously for now as requested
|
||||||
|
// "it can be created and executed on the agent with an api. the resulting logs after execution will get returned by the endpoint"
|
||||||
|
// This implies we wait for it to finish.
|
||||||
|
|
||||||
|
let mut agent = Agent::new(
|
||||||
|
state.zen_api_key.clone(),
|
||||||
|
state.tavily_api_key.clone(),
|
||||||
|
payload.goal.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (logs, answer) = match agent.run().await {
|
||||||
|
Ok((logs, answer)) => (logs, answer),
|
||||||
|
Err(e) => (format!("Execution failed: {}", e), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update with final logs and status
|
||||||
|
let mut task: task::ActiveModel = Task::find_by_id(task_id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Task not found after insert".to_string(),
|
||||||
|
))?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
task.logs = Set(logs.clone());
|
||||||
|
task.answer = Set(answer.clone());
|
||||||
|
task.status = Set("completed".to_string());
|
||||||
|
|
||||||
|
let updated_task = task
|
||||||
|
.update(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(TaskResponse {
|
||||||
|
id: updated_task.id,
|
||||||
|
goal: updated_task.goal,
|
||||||
|
status: updated_task.status,
|
||||||
|
logs: updated_task.logs,
|
||||||
|
answer: updated_task.answer,
|
||||||
|
created_at: updated_task.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_task(
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<Json<TaskResponse>, (StatusCode, String)> {
|
||||||
|
let task = Task::find_by_id(id)
|
||||||
|
.one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Task not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(TaskResponse {
|
||||||
|
id: task.id,
|
||||||
|
goal: task.goal,
|
||||||
|
status: task.status,
|
||||||
|
logs: task.logs,
|
||||||
|
answer: task.answer,
|
||||||
|
created_at: task.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
129
src/tools.rs
Normal file
129
src/tools.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
use crate::api::{self, FunctionDefinition, Message, Tool, ToolCall};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub fn get_tools() -> Vec<Tool> {
|
||||||
|
vec![
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "google_search".to_string(),
|
||||||
|
description: "Search the web for information".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "write_file".to_string(),
|
||||||
|
description: "Write content to a file. Ensure parent directories exist."
|
||||||
|
.to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The path to the file to write"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The content to write to the file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path", "content"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tool {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: FunctionDefinition {
|
||||||
|
name: "finish".to_string(),
|
||||||
|
description: "Finish the task".to_string(),
|
||||||
|
parameters: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"result": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The result of the task"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["result"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_tool_call(
|
||||||
|
tool_call: &ToolCall,
|
||||||
|
tavily_api_key: &Option<String>,
|
||||||
|
) -> Result<(Message, bool, Option<String>), Box<dyn std::error::Error>> {
|
||||||
|
let mut file_written = false;
|
||||||
|
let mut answer = None;
|
||||||
|
let name = &tool_call.function.name;
|
||||||
|
|
||||||
|
let (content, written) = if name == "google_search" {
|
||||||
|
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let query = args.get("query").ok_or("Missing query argument")?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"--- Executing tool: google_search(query: \"{}\") ---",
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
|
let search_result = if let Some(key) = tavily_api_key {
|
||||||
|
match api::perform_search(query, key).await {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(e) => format!("Search error: {}", e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"Error: TAVILY_API_KEY is not set. Cannot perform real search.".to_string()
|
||||||
|
};
|
||||||
|
(search_result, false)
|
||||||
|
} else if name == "write_file" {
|
||||||
|
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let path = args.get("path").ok_or("Missing path argument")?;
|
||||||
|
let content = args.get("content").ok_or("Missing content argument")?;
|
||||||
|
|
||||||
|
println!("--- Executing tool: write_file(path: \"{}\") ---", path);
|
||||||
|
|
||||||
|
let write_result = match std::fs::write(path, content) {
|
||||||
|
Ok(_) => {
|
||||||
|
file_written = true;
|
||||||
|
format!("Successfully wrote content to {}", path)
|
||||||
|
}
|
||||||
|
Err(e) => format!("Error writing to {}: {}", path, e),
|
||||||
|
};
|
||||||
|
(write_result, file_written)
|
||||||
|
} else if name == "finish" {
|
||||||
|
let args: HashMap<String, String> = serde_json::from_str(&tool_call.function.arguments)?;
|
||||||
|
let result = args.get("result").ok_or("Missing result argument")?;
|
||||||
|
|
||||||
|
println!("--- Finishing task: {}", result);
|
||||||
|
|
||||||
|
file_written = true;
|
||||||
|
answer = Some(result.clone());
|
||||||
|
(result.clone(), file_written)
|
||||||
|
} else {
|
||||||
|
(format!("Error: Unknown tool {}", name), false)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Message {
|
||||||
|
role: "tool".to_string(),
|
||||||
|
content: Some(content),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: Some(tool_call.id.clone()),
|
||||||
|
},
|
||||||
|
written,
|
||||||
|
answer,
|
||||||
|
))
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue