basic AI agent

This commit is contained in:
Flatlogic Bot 2025-11-20 00:44:39 +00:00
parent b700e16dec
commit 3b7559b157
18 changed files with 350 additions and 35 deletions

View File

@ -382,17 +382,11 @@ def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[st
"data": decoded if decoded is not None else response_body, "data": decoded if decoded is not None else response_body,
} }
error_message = "AI proxy request failed"
if isinstance(decoded, dict):
error_message = decoded.get("error") or decoded.get("message") or error_message
elif response_body:
error_message = response_body
return { return {
"success": False, "success": False,
"status": status, "status": status,
"error": error_message, "error": "AI proxy request failed",
"response": decoded if decoded is not None else response_body, "data": decoded if decoded is not None else response_body,
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@ -152,3 +152,17 @@ STATICFILES_DIRS = [
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
X_FRAME_OPTIONS = 'ALLOWALL' X_FRAME_OPTIONS = 'ALLOWALL'
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
}

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2025-11-19 23:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_conversation_message'),
]
operations = [
migrations.AlterModelOptions(
name='message',
options={'ordering': ['created_at']},
),
migrations.RemoveField(
model_name='message',
name='is_from_user',
),
migrations.AddField(
model_name='message',
name='sender',
field=models.CharField(choices=[('user', 'User'), ('ai', 'AI'), ('system', 'System'), ('ai_command', 'AI Command')], default='user', max_length=20),
),
]

View File

@ -35,8 +35,11 @@ class Conversation(models.Model):
class Message(models.Model): class Message(models.Model):
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages') conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
content = models.TextField() content = models.TextField()
is_from_user = models.BooleanField(default=True) sender = models.CharField(max_length=20, choices=[('user', 'User'), ('ai', 'AI'), ('system', 'System'), ('ai_command', 'AI Command')], default='user')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self): def __str__(self):
return f"Message from {'User' if self.is_from_user else 'AI'} at {self.created_at}" return f"Message from {self.get_sender_display()} at {self.created_at}"

View File

@ -33,10 +33,24 @@
<div class="chat-messages" id="chat-messages"> <div class="chat-messages" id="chat-messages">
{% for message in selected_conversation.messages.all %} {% for message in selected_conversation.messages.all %}
<div class="message {% if message.is_from_user %}user{% else %}ai{% endif %}"> <div class="message {{ message.sender }}">
<div class="message-content"> <div class="message-content">
<div class="message-author">{% if message.is_from_user %}You{% else %}AI{% endif %}</div> <div class="message-author">
<p>{{ message.content|linebreaksbr }}</p> {% if message.sender == 'user' %}
You
{% elif message.sender == 'ai' %}
AI
{% elif message.sender == 'system' %}
System
{% elif message.sender == 'ai_command' %}
AI Command
{% endif %}
</div>
{% if message.sender == 'ai_command' %}
<pre><code>{{ message.content|linebreaksbr }}</code></pre>
{% else %}
<p>{{ message.content|linebreaksbr }}</p>
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@ -38,8 +38,12 @@
</div> </div>
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-white"> <div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h5 mb-0">Your To-Do List</h2> <h2 class="h5 mb-0">Your To-Do List</h2>
<form method="post" action="{% url 'core:cleanup_tasks' %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm">Cleanup All Tasks</button>
</form>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@ -50,6 +54,7 @@
<th scope="col">Tags</th> <th scope="col">Tags</th>
<th scope="col">Status</th> <th scope="col">Status</th>
<th scope="col">Created</th> <th scope="col">Created</th>
<th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -66,6 +71,12 @@
</td> </td>
<td><span class="badge status-{{ item.status }}">{{ item.get_status_display }}</span></td> <td><span class="badge status-{{ item.status }}">{{ item.get_status_display }}</span></td>
<td>{{ item.created_at|date:"M d, Y" }}</td> <td>{{ item.created_at|date:"M d, Y" }}</td>
<td>
<form method="post" action="{% url 'core:delete_task' item.id %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>

View File

@ -25,6 +25,10 @@
{% for item in tasks %} {% for item in tasks %}
<div class="card kanban-card mb-3 shadow-sm" data-task-id="{{ item.id }}"> <div class="card kanban-card mb-3 shadow-sm" data-task-id="{{ item.id }}">
<div class="card-body"> <div class="card-body">
<form action="{% url 'core:delete_task' item.id %}" method="post" class="delete-task-form">
{% csrf_token %}
<button type="submit" class="btn-close" aria-label="Close"></button>
</form>
<h5 class="card-title h6">{{ item.title }}</h5> <h5 class="card-title h6">{{ item.title }}</h5>
<p class="card-text small">{{ item.description|default:""|truncatewords:15 }}</p> <p class="card-text small">{{ item.description|default:""|truncatewords:15 }}</p>
{% if item.tags %} {% if item.tags %}

View File

@ -8,6 +8,8 @@ urlpatterns = [
path('kanban/', views.kanban_board, name='kanban'), path('kanban/', views.kanban_board, name='kanban'),
path('article/<int:article_id>/', views.article_detail, name='article_detail'), path('article/<int:article_id>/', views.article_detail, name='article_detail'),
path('update_task_status/', views.update_task_status, name='update_task_status'), path('update_task_status/', views.update_task_status, name='update_task_status'),
path('delete_task/<int:task_id>/', views.delete_task, name='delete_task'),
path('chat/', views.chat_view, name='chat'), path('chat/', views.chat_view, name='chat'),
path('chat/<int:conversation_id>/', views.chat_view, name='chat_detail'), path('chat/<int:conversation_id>/', views.chat_view, name='chat_detail'),
path('cleanup_tasks/', views.cleanup_tasks, name='cleanup_tasks'),
] ]

View File

@ -2,17 +2,21 @@ from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse from django.http import JsonResponse
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
import json import json
import logging
from .models import Article, TodoItem, Conversation, Message from .models import Article, TodoItem, Conversation, Message
from .forms import TodoItemForm from .forms import TodoItemForm
import time import time
from ai.local_ai_api import LocalAIApi from ai.local_ai_api import LocalAIApi
# Get an instance of a logger
logger = logging.getLogger(__name__)
def index(request): def index(request):
if request.method == 'POST': if request.method == 'POST':
form = TodoItemForm(request.POST) form = TodoItemForm(request.POST)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect('index') return redirect('core:index')
else: else:
form = TodoItemForm() form = TodoItemForm()
@ -60,6 +64,87 @@ def update_task_status(request):
except (json.JSONDecodeError, TypeError, ValueError) as e: except (json.JSONDecodeError, TypeError, ValueError) as e:
return JsonResponse({'success': False, 'error': str(e)}, status=400) return JsonResponse({'success': False, 'error': str(e)}, status=400)
@require_POST
def delete_task(request, task_id):
task = get_object_or_404(TodoItem, id=task_id)
task.delete()
# Redirect to the previous page or a default URL
referer = request.META.get('HTTP_REFERER')
if referer:
return redirect(referer)
return redirect('core:index')
@require_POST
def cleanup_tasks(request):
TodoItem.objects.all().delete()
return redirect('core:index')
def execute_command(command_data):
command_name = command_data.get('name')
args = command_data.get('args', {})
logger.info(f"Executing command: {command_name} with args: {args}")
try:
if command_name == 'send_message':
message = args.get('message')
if not message:
logger.error("Command 'send_message' failed: 'message' is a required argument.")
return "[SYSTEM] Error: 'message' is a required argument for send_message."
return message
elif command_name == 'add_task':
title = args.get('title')
if not title:
logger.error("Command 'add_task' failed: 'title' is a required argument.")
return "[SYSTEM] Error: 'title' is a required argument for add_task."
new_task = TodoItem.objects.create(
title=title,
description=args.get('description', ''),
status=args.get('status', 'todo')
)
logger.info(f"Command 'add_task' executed successfully. New task ID: {new_task.id}")
return f"[SYSTEM] Command 'add_task' executed successfully. New task ID: {new_task.id}"
elif command_name == 'edit_task':
task_id = args.get('task_id')
if not task_id:
logger.error("Command 'edit_task' failed: 'task_id' is a required argument.")
return "[SYSTEM] Error: 'task_id' is a required argument for edit_task."
task = get_object_or_404(TodoItem, id=task_id)
if 'title' in args:
task.title = args['title']
if 'description' in args:
task.description = args['description']
if 'status' in args:
task.status = args['status']
task.save()
logger.info(f"Command 'edit_task' for task ID {task_id} executed successfully.")
return f"[SYSTEM] Command 'edit_task' for task ID {task_id} executed successfully."
elif command_name == 'delete_task':
task_id = args.get('task_id')
if not task_id:
logger.error("Command 'delete_task' failed: 'task_id' is a required argument.")
return "[SYSTEM] Error: 'task_id' is a required argument for delete_task."
task = get_object_or_404(TodoItem, id=task_id)
task.delete()
logger.info(f"Command 'delete_task' for task ID {task_id} executed successfully.")
return f"[SYSTEM] Command 'delete_task' for task ID {task_id} executed successfully."
else:
logger.warning(f"Unknown command received: '{command_name}'")
return f"[SYSTEM] Error: Unknown command '{command_name}'."
except Exception as e:
logger.error(f"Error executing command '{command_name}': {e}", exc_info=True)
return f"[SYSTEM] Error executing command '{command_name}': {e}"
def chat_view(request, conversation_id=None): def chat_view(request, conversation_id=None):
if request.method == 'POST': if request.method == 'POST':
if 'title' in request.POST: if 'title' in request.POST:
@ -70,25 +155,103 @@ def chat_view(request, conversation_id=None):
elif 'text' in request.POST and conversation_id: elif 'text' in request.POST and conversation_id:
text = request.POST.get('text') text = request.POST.get('text')
selected_conversation = get_object_or_404(Conversation, id=conversation_id) selected_conversation = get_object_or_404(Conversation, id=conversation_id)
if text: if text:
Message.objects.create(conversation=selected_conversation, content=text, is_from_user=True) command_name = None # Initialize command_name
Message.objects.create(conversation=selected_conversation, content=text, sender='user')
# Construct the conversation history for the AI
history = [] history = []
for msg in selected_conversation.messages.order_by('created_at'): for msg in selected_conversation.messages.order_by('created_at'):
role = "user" if msg.is_from_user else "assistant" role = msg.sender
if role == 'ai':
role = 'assistant'
elif role == 'system':
role = 'user'
history.append({"role": role, "content": msg.content}) history.append({"role": role, "content": msg.content})
try: try:
system_message = { system_message = {
"role": "system", "role": "system",
"content": "You are a helpful assistant for a project management application. Your purpose is to assist users with their tasks and provide information about the application. The application manages articles and a to-do list." "content": '''You are a project management assistant. To communicate with the user, you MUST use the `send_message` command.
**Commands must be in a specific JSON format.** Your response must be a JSON object with the following structure:
```json
{
"command": {
"name": "command_name",
"args": {
"arg1": "value1",
"arg2": "value2"
}
}
}
```
**Available Commands:**
* `send_message`: Sends a message to the user. **USE THIS FOR ALL CONVERSATIONAL RESPONSES.**
* `args`:
* `message` (string, required): The message to send to the user.
* `add_task`: Adds a new task.
* `args`:
* `title` (string, required): The title of the task.
* `description` (string, optional): The description of the task.
* `status` (string, optional, default: 'todo'): The status of the task. Can be 'todo', 'inprogress', 'done', 'blocked'.
* `edit_task`: Edits an existing task.
* `args`:
* `task_id` (integer, required): The ID of the task to edit.
* `title` (string, optional): The new title.
* `description` (string, optional): The new description.
* `status` (string, optional): The new status.
* `delete_task`: Deletes a task.
* `args`:
* `task_id` (integer, required): The ID of the task to delete.
**Execution Loop:**
1. You issue a command.
2. The system executes it.
3. The system returns a result message to you, like `[SYSTEM] Command 'add_task' executed successfully. New task ID: 5`.
4. You can then issue another command.
**VERY IMPORTANT:**
- To talk to the user, you MUST use the `send_message` command.
- ONLY use other commands if the user explicitly asks you to `add`, `edit`, or `delete` tasks.
**Examples:**
* **User:** "Hi, how are you?"
* **Correct AI Response:**
```json
{
"command": {
"name": "send_message",
"args": {
"message": "I'm doing great, thanks for asking! How can I help you with your tasks today?"
}
}
}
```
* **User:** "add a new task to buy milk"
* **Correct AI Response:**
```json
{
"command": {
"name": "add_task",
"args": {
"title": "buy milk"
}
}
}
```
**IMPORTANT:** Do not wrap the JSON command in markdown backticks or any other text. The entire response must be the JSON object.'''
} }
tasks = TodoItem.objects.all().order_by('created_at') tasks = TodoItem.objects.all().order_by('created_at')
task_list_str = "\n".join([ task_list_str = "\n".join([
f"- {task.title} (Status: {task.get_status_display()}, Tags: {task.tags or 'None'})" f"- ID {task.id}: {task.title} (Status: {task.get_status_display()}, Tags: {task.tags or 'None'})" for task in tasks
for task in tasks
]) ])
tasks_context = { tasks_context = {
@ -96,29 +259,55 @@ def chat_view(request, conversation_id=None):
"content": f"Here is the current list of tasks:\n{task_list_str}" "content": f"Here is the current list of tasks:\n{task_list_str}"
} }
logger.info("Sending request to AI...")
response = LocalAIApi.create_response({ response = LocalAIApi.create_response({
"input": [ "input": [
system_message, system_message,
tasks_context, tasks_context,
*history ] + history,
] "text": {"format": {"type": "json_object"}},
}) })
ai_text = LocalAIApi.extract_text(response)
if not ai_text: if not response.get("success"):
logger.error(f"AI API request failed with status {response.get('status')}. Full error: {response.get('response')}")
ai_text = "I couldn't process that. Please try again." ai_text = "I couldn't process that. Please try again."
except Exception as e: Message.objects.create(conversation=selected_conversation, content=ai_text, sender='ai')
ai_text = f"An error occurred: {str(e)}" else:
logger.info(f"AI raw response: {response}")
ai_text = LocalAIApi.extract_text(response)
logger.info(f"Extracted AI text: {ai_text}")
Message.objects.create(conversation=selected_conversation, content=ai_text, is_from_user=False) if not ai_text:
logger.warning("AI response was empty.")
# Re-fetch the conversation to include the new messages for rendering ai_text = "I couldn't process that. Please try again."
selected_conversation = get_object_or_404(Conversation, id=conversation_id) Message.objects.create(conversation=selected_conversation, content=ai_text, sender='ai')
else:
ai_message_content = ai_text
sender = 'ai'
try:
command_json = json.loads(ai_text)
if 'command' in command_json:
command_name = command_json.get('command', {}).get('name')
command_result = execute_command(command_json['command'])
ai_message_content = command_result
sender = 'ai' if command_name == 'send_message' else 'system'
except (json.JSONDecodeError, TypeError):
# Not a JSON command, treat as a raw message.
pass
Message.objects.create(conversation=selected_conversation, content=ai_message_content, sender=sender)
except Exception as e:
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
ai_text = f"An error occurred: {str(e)}"
Message.objects.create(conversation=selected_conversation, content=ai_text, sender='ai')
return redirect('core:chat_detail', conversation_id=conversation_id)
conversations = Conversation.objects.order_by('-created_at') conversations = Conversation.objects.order_by('-created_at')
if conversation_id: selected_conversation = get_object_or_404(Conversation, id=conversation_id) if conversation_id else None
selected_conversation = get_object_or_404(Conversation, id=conversation_id)
else:
selected_conversation = None
return render(request, 'core/chat.html', { return render(request, 'core/chat.html', {
'conversation_list': conversations, 'conversation_list': conversations,

View File

@ -198,4 +198,33 @@ body {
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
}
/* System and AI Command Messages */
.message.system .message-content {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.ai_command .message-content {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.message.ai_command .message-content pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
.kanban-card .card-body {
position: relative;
}
.delete-task-form {
position: absolute;
top: 10px;
right: 10px;
} }

View File

@ -198,4 +198,33 @@ body {
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
}
/* System and AI Command Messages */
.message.system .message-content {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.ai_command .message-content {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.message.ai_command .message-content pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
.kanban-card .card-body {
position: relative;
}
.delete-task-form {
position: absolute;
top: 10px;
right: 10px;
} }