Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -1,3 +0,0 @@
|
||||
"""Helpers for interacting with the Flatlogic AI proxy from Django code."""
|
||||
|
||||
from .local_ai_api import LocalAIApi, create_response, request, decode_json_from_response # noqa: F401
|
||||
Binary file not shown.
Binary file not shown.
@ -1,414 +0,0 @@
|
||||
"""
|
||||
LocalAIApi — lightweight Python client for the Flatlogic AI proxy.
|
||||
|
||||
Usage (inside the Django workspace):
|
||||
|
||||
from ai.local_ai_api import LocalAIApi
|
||||
|
||||
response = LocalAIApi.create_response({
|
||||
"input": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Summarise this text in two sentences."},
|
||||
],
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
})
|
||||
|
||||
if response.get("success"):
|
||||
data = LocalAIApi.decode_json_from_response(response)
|
||||
# ...
|
||||
|
||||
# Typical successful payload (truncated):
|
||||
# {
|
||||
# "id": "resp_xxx",
|
||||
# "status": "completed",
|
||||
# "output": [
|
||||
# {"type": "reasoning", "summary": []},
|
||||
# {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]}
|
||||
# ],
|
||||
# "usage": { "input_tokens": 123, "output_tokens": 456 }
|
||||
# }
|
||||
|
||||
The helper automatically injects the project UUID header and falls back to
|
||||
reading executor/.env if environment variables are missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import ssl
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
from urllib import error as urlerror
|
||||
from urllib import request as urlrequest
|
||||
|
||||
__all__ = [
|
||||
"LocalAIApi",
|
||||
"create_response",
|
||||
"request",
|
||||
"fetch_status",
|
||||
"await_response",
|
||||
"extract_text",
|
||||
"decode_json_from_response",
|
||||
]
|
||||
|
||||
|
||||
_CONFIG_CACHE: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class LocalAIApi:
|
||||
"""Static helpers mirroring the PHP implementation."""
|
||||
|
||||
@staticmethod
|
||||
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return create_response(params, options or {})
|
||||
|
||||
@staticmethod
|
||||
def request(path: Optional[str] = None, payload: Optional[Dict[str, Any]] = None,
|
||||
options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return request(path, payload or {}, options or {})
|
||||
|
||||
@staticmethod
|
||||
def extract_text(response: Dict[str, Any]) -> str:
|
||||
return extract_text(response)
|
||||
|
||||
@staticmethod
|
||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return decode_json_from_response(response)
|
||||
|
||||
|
||||
def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Signature compatible with the OpenAI Responses API."""
|
||||
options = options or {}
|
||||
payload = dict(params)
|
||||
|
||||
if not isinstance(payload.get("input"), list) or not payload["input"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "input_missing",
|
||||
"message": 'Parameter "input" is required and must be a non-empty list.',
|
||||
}
|
||||
|
||||
cfg = _config()
|
||||
if not payload.get("model"):
|
||||
payload["model"] = cfg["default_model"]
|
||||
|
||||
initial = request(options.get("path"), payload, options)
|
||||
if not initial.get("success"):
|
||||
return initial
|
||||
|
||||
data = initial.get("data")
|
||||
if isinstance(data, dict) and "ai_request_id" in data:
|
||||
ai_request_id = data["ai_request_id"]
|
||||
poll_timeout = int(options.get("poll_timeout", 300))
|
||||
poll_interval = int(options.get("poll_interval", 5))
|
||||
return await_response(ai_request_id, {
|
||||
"interval": poll_interval,
|
||||
"timeout": poll_timeout,
|
||||
"headers": options.get("headers"),
|
||||
"timeout_per_call": options.get("timeout"),
|
||||
})
|
||||
|
||||
return initial
|
||||
|
||||
|
||||
def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Perform a raw request to the AI proxy."""
|
||||
cfg = _config()
|
||||
options = options or {}
|
||||
|
||||
resolved_path = path or options.get("path") or cfg["responses_path"]
|
||||
if not resolved_path:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "project_id_missing",
|
||||
"message": "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.",
|
||||
}
|
||||
|
||||
project_uuid = cfg["project_uuid"]
|
||||
if not project_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "project_uuid_missing",
|
||||
"message": "PROJECT_UUID is not defined; aborting AI request.",
|
||||
}
|
||||
|
||||
if "project_uuid" not in payload and project_uuid:
|
||||
payload["project_uuid"] = project_uuid
|
||||
|
||||
url = _build_url(resolved_path, cfg["base_url"])
|
||||
opt_timeout = options.get("timeout")
|
||||
timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout)
|
||||
verify_tls = options.get("verify_tls", cfg["verify_tls"])
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
for header in extra_headers:
|
||||
if isinstance(header, str) and ":" in header:
|
||||
name, value = header.split(":", 1)
|
||||
headers[name.strip()] = value.strip()
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
return _http_request(url, "POST", body, headers, timeout, verify_tls)
|
||||
|
||||
|
||||
def fetch_status(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Fetch status for a queued AI request."""
|
||||
cfg = _config()
|
||||
options = options or {}
|
||||
|
||||
project_uuid = cfg["project_uuid"]
|
||||
if not project_uuid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "project_uuid_missing",
|
||||
"message": "PROJECT_UUID is not defined; aborting status check.",
|
||||
}
|
||||
|
||||
status_path = _resolve_status_path(ai_request_id, cfg)
|
||||
url = _build_url(status_path, cfg["base_url"])
|
||||
|
||||
opt_timeout = options.get("timeout")
|
||||
timeout = int(cfg["timeout"] if opt_timeout is None else opt_timeout)
|
||||
verify_tls = options.get("verify_tls", cfg["verify_tls"])
|
||||
|
||||
headers: Dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
cfg["project_header"]: project_uuid,
|
||||
}
|
||||
extra_headers = options.get("headers")
|
||||
if isinstance(extra_headers, Iterable):
|
||||
for header in extra_headers:
|
||||
if isinstance(header, str) and ":" in header:
|
||||
name, value = header.split(":", 1)
|
||||
headers[name.strip()] = value.strip()
|
||||
|
||||
return _http_request(url, "GET", None, headers, timeout, verify_tls)
|
||||
|
||||
|
||||
def await_response(ai_request_id: Any, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Poll status endpoint until the request is complete or timed out."""
|
||||
options = options or {}
|
||||
timeout = int(options.get("timeout", 300))
|
||||
interval = int(options.get("interval", 5))
|
||||
if interval <= 0:
|
||||
interval = 5
|
||||
per_call_timeout = options.get("timeout_per_call")
|
||||
|
||||
deadline = time.time() + max(timeout, interval)
|
||||
|
||||
while True:
|
||||
status_resp = fetch_status(ai_request_id, {
|
||||
"headers": options.get("headers"),
|
||||
"timeout": per_call_timeout,
|
||||
"verify_tls": options.get("verify_tls"),
|
||||
})
|
||||
if status_resp.get("success"):
|
||||
data = status_resp.get("data") or {}
|
||||
if isinstance(data, dict):
|
||||
status_value = data.get("status")
|
||||
if status_value == "success":
|
||||
return {
|
||||
"success": True,
|
||||
"status": 200,
|
||||
"data": data.get("response", data),
|
||||
}
|
||||
if status_value == "failed":
|
||||
return {
|
||||
"success": False,
|
||||
"status": 500,
|
||||
"error": str(data.get("error") or "AI request failed"),
|
||||
"data": data,
|
||||
}
|
||||
else:
|
||||
return status_resp
|
||||
|
||||
if time.time() >= deadline:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "timeout",
|
||||
"message": "Timed out waiting for AI response.",
|
||||
}
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def extract_text(response: Dict[str, Any]) -> str:
|
||||
"""Public helper to extract plain text from a Responses payload."""
|
||||
return _extract_text(response)
|
||||
|
||||
|
||||
def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Attempt to decode JSON emitted by the model (handles markdown fences)."""
|
||||
text = _extract_text(response)
|
||||
if text == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
decoded = json.loads(text)
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("```json"):
|
||||
stripped = stripped[7:]
|
||||
if stripped.endswith("```"):
|
||||
stripped = stripped[:-3]
|
||||
stripped = stripped.strip()
|
||||
if stripped and stripped != text:
|
||||
try:
|
||||
decoded = json.loads(stripped)
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(response: Dict[str, Any]) -> str:
|
||||
payload = response.get("data") if response.get("success") else response.get("response")
|
||||
if isinstance(payload, dict):
|
||||
output = payload.get("output")
|
||||
if isinstance(output, list):
|
||||
combined = ""
|
||||
for item in output:
|
||||
content = item.get("content") if isinstance(item, dict) else None
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "output_text" and block.get("text"):
|
||||
combined += str(block["text"])
|
||||
if combined:
|
||||
return combined
|
||||
choices = payload.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
message = choices[0].get("message")
|
||||
if isinstance(message, dict) and message.get("content"):
|
||||
return str(message["content"])
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
return ""
|
||||
|
||||
|
||||
def _config() -> Dict[str, Any]:
|
||||
global _CONFIG_CACHE # noqa: PLW0603
|
||||
if _CONFIG_CACHE is not None:
|
||||
return _CONFIG_CACHE
|
||||
|
||||
_ensure_env_loaded()
|
||||
|
||||
base_url = os.getenv("AI_PROXY_BASE_URL", "https://flatlogic.com")
|
||||
project_id = os.getenv("PROJECT_ID") or None
|
||||
responses_path = os.getenv("AI_RESPONSES_PATH")
|
||||
if not responses_path and project_id:
|
||||
responses_path = f"/projects/{project_id}/ai-request"
|
||||
|
||||
_CONFIG_CACHE = {
|
||||
"base_url": base_url,
|
||||
"responses_path": responses_path,
|
||||
"project_id": project_id,
|
||||
"project_uuid": os.getenv("PROJECT_UUID"),
|
||||
"project_header": os.getenv("AI_PROJECT_HEADER", "project-uuid"),
|
||||
"default_model": os.getenv("AI_DEFAULT_MODEL", "gpt-5-mini"),
|
||||
"timeout": int(os.getenv("AI_TIMEOUT", "30")),
|
||||
"verify_tls": os.getenv("AI_VERIFY_TLS", "true").lower() not in {"0", "false", "no"},
|
||||
}
|
||||
return _CONFIG_CACHE
|
||||
|
||||
|
||||
def _build_url(path: str, base_url: str) -> str:
|
||||
trimmed = path.strip()
|
||||
if trimmed.startswith("http://") or trimmed.startswith("https://"):
|
||||
return trimmed
|
||||
if trimmed.startswith("/"):
|
||||
return f"{base_url}{trimmed}"
|
||||
return f"{base_url}/{trimmed}"
|
||||
|
||||
|
||||
def _resolve_status_path(ai_request_id: Any, cfg: Dict[str, Any]) -> str:
|
||||
base_path = (cfg.get("responses_path") or "").rstrip("/")
|
||||
if not base_path:
|
||||
return f"/ai-request/{ai_request_id}/status"
|
||||
if not base_path.endswith("/ai-request"):
|
||||
base_path = f"{base_path}/ai-request"
|
||||
return f"{base_path}/{ai_request_id}/status"
|
||||
|
||||
|
||||
def _http_request(url: str, method: str, body: Optional[bytes], headers: Dict[str, str],
|
||||
timeout: int, verify_tls: bool) -> Dict[str, Any]:
|
||||
"""
|
||||
Shared HTTP helper for GET/POST requests.
|
||||
"""
|
||||
req = urlrequest.Request(url, data=body, method=method.upper())
|
||||
for name, value in headers.items():
|
||||
req.add_header(name, value)
|
||||
|
||||
context = None
|
||||
if not verify_tls:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
with urlrequest.urlopen(req, timeout=timeout, context=context) as resp:
|
||||
status = resp.getcode()
|
||||
response_body = resp.read().decode("utf-8", errors="replace")
|
||||
except urlerror.HTTPError as exc:
|
||||
status = exc.getcode()
|
||||
response_body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return {
|
||||
"success": False,
|
||||
"error": "request_failed",
|
||||
"message": str(exc),
|
||||
}
|
||||
|
||||
decoded = None
|
||||
if response_body:
|
||||
try:
|
||||
decoded = json.loads(response_body)
|
||||
except json.JSONDecodeError:
|
||||
decoded = None
|
||||
|
||||
if 200 <= status < 300:
|
||||
return {
|
||||
"success": True,
|
||||
"status": status,
|
||||
"data": decoded if decoded is not None else response_body,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"status": status,
|
||||
"error": "AI proxy request failed",
|
||||
"data": decoded if decoded is not None else response_body,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_env_loaded() -> None:
|
||||
"""Populate os.environ from executor/.env if variables are missing."""
|
||||
if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"):
|
||||
return
|
||||
|
||||
env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||
if not os.path.exists(env_path):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(env_path, "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('\'"')
|
||||
if key and not os.getenv(key):
|
||||
os.environ[key] = value
|
||||
except OSError:
|
||||
pass
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -152,17 +152,3 @@ STATICFILES_DIRS = [
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
X_FRAME_OPTIONS = 'ALLOWALL'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,8 +1,8 @@
|
||||
from django.contrib import admin
|
||||
from .models import Article, TodoItem, Setting, Conversation, Message
|
||||
from .models import Ticket
|
||||
|
||||
admin.site.register(Article)
|
||||
admin.site.register(TodoItem)
|
||||
admin.site.register(Setting)
|
||||
admin.site.register(Conversation)
|
||||
admin.site.register(Message)
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
list_display = ('subject', 'status', 'priority', 'requester_email', 'created_at')
|
||||
list_filter = ('status', 'priority')
|
||||
search_fields = ('subject', 'requester_email', 'description')
|
||||
|
||||
@ -1,25 +1,7 @@
|
||||
from django import forms
|
||||
from .models import TodoItem
|
||||
from .models import Ticket
|
||||
|
||||
class TodoItemForm(forms.ModelForm):
|
||||
class TicketForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TodoItem
|
||||
fields = ['title', 'description', 'tags', 'status']
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Enter a new task...'
|
||||
}),
|
||||
'description': forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Add a description...',
|
||||
'rows': 3
|
||||
}),
|
||||
'tags': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'e.g. urgent, project-x'
|
||||
}),
|
||||
'status': forms.Select(attrs={
|
||||
'class': 'form-control'
|
||||
})
|
||||
}
|
||||
model = Ticket
|
||||
fields = ['subject', 'requester_email', 'priority', 'description']
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-19 21:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Article',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('content', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TodoItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('status', models.CharField(choices=[('todo', 'To Do'), ('inprogress', 'In Progress'), ('blocked', 'Blocked'), ('done', 'Done')], default='todo', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Ticket',
|
||||
),
|
||||
]
|
||||
@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-19 21:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_article_todoitem_delete_ticket'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='todoitem',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='todoitem',
|
||||
name='tags',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-19 22:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_todoitem_description_todoitem_tags'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Conversation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField()),
|
||||
('is_from_user', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='core.conversation')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,26 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 10:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_alter_message_options_remove_message_is_from_user_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Setting',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255, unique=True)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 19:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_setting'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='is_generating',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,53 +1,25 @@
|
||||
from django.db import models
|
||||
|
||||
class Article(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class TodoItem(models.Model):
|
||||
class Ticket(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('todo', 'To Do'),
|
||||
('inprogress', 'In Progress'),
|
||||
('blocked', 'Blocked'),
|
||||
('done', 'Done'),
|
||||
('open', 'Open'),
|
||||
('in_progress', 'In Progress'),
|
||||
('closed', 'Closed'),
|
||||
]
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
tags = models.CharField(max_length=255, blank=True, null=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='todo')
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('low', 'Low'),
|
||||
('medium', 'Medium'),
|
||||
('high', 'High'),
|
||||
]
|
||||
|
||||
subject = models.CharField(max_length=255)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open')
|
||||
priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='medium')
|
||||
requester_email = models.EmailField()
|
||||
description = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Conversation(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
is_generating = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Message(models.Model):
|
||||
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
|
||||
content = models.TextField()
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Message from {self.get_sender_display()} at {self.created_at}"
|
||||
|
||||
class Setting(models.Model):
|
||||
key = models.CharField(max_length=255, unique=True)
|
||||
value = models.TextField()
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
return self.subject
|
||||
@ -1,48 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}AI Task Manager{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<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@400;700&family=Poppins:wght@600&display=swap" rel="stylesheet">
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ timestamp }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{% url 'core:index' %}">AI Task Manager</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'core:index' %}">Table View</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'core:kanban' %}">Kanban Board</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'core:chat' %}">Chat</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'core:settings' %}">Settings</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mt-5">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Gemini Chat{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<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@400;700&family=Poppins:wght@600&display=swap" rel="stylesheet">
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ timestamp }}">
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,14 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ article.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<h1>{{ article.title }}</h1>
|
||||
<p class="text-muted">Published on {{ article.created_at|date:"F d, Y" }}</p>
|
||||
<hr>
|
||||
<div>
|
||||
{{ article.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,284 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Chat{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="chat-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="chat-sidebar">
|
||||
<form method="post" action="{% url 'core:chat' %}" class="new-chat-form">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="title" placeholder="New conversation title" required>
|
||||
<button type="submit" class="new-chat-btn">+ New Chat</button>
|
||||
</form>
|
||||
|
||||
<ul class="conversation-list">
|
||||
{% for conv in conversation_list %}
|
||||
<a href="{% url 'core:chat_detail' conv.id %}" class="{% if selected_conversation.id == conv.id %}active{% endif %}">
|
||||
{{ conv.title }}
|
||||
</a>
|
||||
{% empty %}
|
||||
<li>No conversations yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<main class="chat-main">
|
||||
{% if selected_conversation %}
|
||||
<header class="chat-header">
|
||||
<h3>{{ selected_conversation.title }}</h3>
|
||||
</header>
|
||||
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
{% for message in selected_conversation.messages.all %}
|
||||
<div class="message {{ message.sender }}">
|
||||
<div class="message-content">
|
||||
<div class="message-author">
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="chat-form-container">
|
||||
<form method="post" action="{% url 'core:chat_detail' selected_conversation.id %}" class="chat-form" id="chat-form">
|
||||
{% csrf_token %}
|
||||
<textarea name="text" placeholder="Send a message..." rows="1" id="chat-textarea"></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="no-conversation-selected">
|
||||
<div>
|
||||
<h2>Gemini Chat</h2>
|
||||
<p>Select a conversation or start a new one.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="loader-overlay" id="loader-overlay" style="display: none;">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatTextarea = document.getElementById('chat-textarea');
|
||||
const submitButton = chatForm ? chatForm.querySelector('button[type="submit"]') : null;
|
||||
|
||||
let pollingInterval;
|
||||
|
||||
// Function to scroll to the bottom of the messages
|
||||
function scrollToBottom() {
|
||||
if (messagesContainer) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
if (chatTextarea) {
|
||||
chatTextarea.addEventListener('input', () => {
|
||||
chatTextarea.style.height = 'auto';
|
||||
chatTextarea.style.height = (chatTextarea.scrollHeight) + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
if (chatForm && submitButton) {
|
||||
chatForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(chatForm);
|
||||
const messageText = formData.get('text').trim();
|
||||
|
||||
if (!messageText) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Sending...';
|
||||
|
||||
// Manually append the user's message to the UI immediately
|
||||
appendMessage('user', messageText);
|
||||
chatTextarea.value = '';
|
||||
chatTextarea.style.height = 'auto';
|
||||
|
||||
fetch(chatForm.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
startPolling(data.conversation_id);
|
||||
} else {
|
||||
console.error('Error submitting message:', data.error);
|
||||
// Re-enable button on error
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Send';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fetch error:', error);
|
||||
// Re-enable button on error
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Send';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Allow Enter to submit, Shift+Enter for new line
|
||||
if (chatTextarea && chatForm) {
|
||||
chatTextarea.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
chatForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appendMessage(sender, content) {
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.classList.add('message', sender);
|
||||
|
||||
const messageContentEl = document.createElement('div');
|
||||
messageContentEl.classList.add('message-content');
|
||||
|
||||
const authorEl = document.createElement('div');
|
||||
authorEl.classList.add('message-author');
|
||||
if (sender === 'user') {
|
||||
authorEl.innerText = 'You';
|
||||
} else if (sender === 'ai') {
|
||||
authorEl.innerText = 'AI';
|
||||
} else {
|
||||
authorEl.innerText = sender.charAt(0).toUpperCase() + sender.slice(1);
|
||||
}
|
||||
messageContentEl.appendChild(authorEl);
|
||||
|
||||
const pEl = document.createElement('p');
|
||||
pEl.innerHTML = content.replace(/\n/g, '<br>');
|
||||
messageContentEl.appendChild(pEl);
|
||||
|
||||
messageEl.appendChild(messageContentEl);
|
||||
messagesContainer.appendChild(messageEl);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function updateMessageList(messages) {
|
||||
messagesContainer.innerHTML = ''; // Clear existing messages
|
||||
messages.forEach(message => {
|
||||
const messageEl = document.createElement('div');
|
||||
messageEl.classList.add('message', message.sender);
|
||||
|
||||
const messageContentEl = document.createElement('div');
|
||||
messageContentEl.classList.add('message-content');
|
||||
|
||||
const authorEl = document.createElement('div');
|
||||
authorEl.classList.add('message-author');
|
||||
if (message.sender === 'user') {
|
||||
authorEl.innerText = 'You';
|
||||
} else if (message.sender === 'ai') {
|
||||
authorEl.innerText = 'AI';
|
||||
} else if (message.sender === 'system') {
|
||||
authorEl.innerText = 'System';
|
||||
} else if (message.sender === 'ai_command') {
|
||||
authorEl.innerText = 'AI Command';
|
||||
}
|
||||
messageContentEl.appendChild(authorEl);
|
||||
|
||||
if (message.sender === 'ai_command') {
|
||||
const preEl = document.createElement('pre');
|
||||
const codeEl = document.createElement('code');
|
||||
codeEl.innerHTML = message.content.replace(/\n/g, '<br>');
|
||||
preEl.appendChild(codeEl);
|
||||
messageContentEl.appendChild(preEl);
|
||||
} else {
|
||||
const pEl = document.createElement('p');
|
||||
pEl.innerHTML = message.content.replace(/\n/g, '<br>');
|
||||
messageContentEl.appendChild(pEl);
|
||||
}
|
||||
|
||||
messageEl.appendChild(messageContentEl);
|
||||
messagesContainer.appendChild(messageEl);
|
||||
});
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function startPolling(conversationId) {
|
||||
if (!conversationId) return;
|
||||
|
||||
// Clear any existing polling interval
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
|
||||
pollingInterval = setInterval(() => {
|
||||
fetch(`/get_conversation_messages/${conversationId}/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateMessageList(data.messages);
|
||||
|
||||
if (!data.is_generating) {
|
||||
clearInterval(pollingInterval);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Send';
|
||||
}
|
||||
} else {
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Sending...';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Polling error:', error);
|
||||
clearInterval(pollingInterval); // Stop polling on error
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Send';
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Initial state check on page load
|
||||
const isGenerating = {{ selected_conversation.is_generating|yesno:"true,false" }};
|
||||
const conversationId = {{ selected_conversation.id|default:"null" }};
|
||||
|
||||
if (isGenerating && conversationId) {
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Sending...';
|
||||
}
|
||||
startPolling(conversationId);
|
||||
}
|
||||
|
||||
// Initial scroll to bottom
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,78 +0,0 @@
|
||||
|
||||
{% extends "base_chat.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Chat{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="chat-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="chat-sidebar">
|
||||
<a href="{% url 'conversation_list' %}" class="new-chat-btn">+ New Chat</a>
|
||||
|
||||
<ul class="conversation-list">
|
||||
{% for conv in conversation_list %}
|
||||
<a href="{% url 'conversation_detail' conv.id %}" class="{% if selected_conversation.id == conv.id %}active{% endif %}">
|
||||
{{ conv.title }}
|
||||
</a>
|
||||
{% empty %}
|
||||
<li>No conversations yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<main class="chat-main">
|
||||
{% if selected_conversation %}
|
||||
<header class="chat-header">
|
||||
<h3>{{ selected_conversation.title }}</h3>
|
||||
</header>
|
||||
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
{% for message in selected_conversation.messages.all %}
|
||||
<div class="message {{ message.sender_type }}">
|
||||
<div class="message-content">
|
||||
<div class="message-author">{% if message.sender_type == 'user' %}You{% else %}AI{% endif %}</div>
|
||||
<p>{{ message.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="chat-form-container">
|
||||
<form method="post" action="{% url 'conversation_detail' selected_conversation.id %}" class="chat-form">
|
||||
{% csrf_token %}
|
||||
<textarea name="text" placeholder="Send a message..." rows="1"></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="no-conversation-selected">
|
||||
<div>
|
||||
<h2>Gemini Chat</h2>
|
||||
<p>Select a conversation or start a new one.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-scroll to the latest message
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
if (messagesContainer) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
const textarea = document.querySelector('.chat-form textarea');
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = (textarea.scrollHeight) + 'px';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,92 +1,157 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
{% block title %}AI Task Manager - Home{% endblock %}
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ project_name }}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
<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@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.08);
|
||||
--card-border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero-section text-center py-5">
|
||||
<h1 class="display-4">AI Task Manager</h1>
|
||||
<p class="lead">Your intelligent assistant for managing tasks and conversations.</p>
|
||||
</div>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h2 class="h4 mb-3">Add a New Task</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
{{ form.title.label_tag }}
|
||||
{{ form.title }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.description.label_tag }}
|
||||
{{ form.description }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.tags.label_tag }}
|
||||
{{ form.tags }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.status.label_tag }}
|
||||
{{ form.status }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add Task</button>
|
||||
</form>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(130deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'><path d='M-20 20L160 20M20 -20L20 160' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 24s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate3d(-140px, -140px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: clamp(2rem, 4vw, 3rem);
|
||||
width: min(640px, 92vw);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 20px;
|
||||
padding: clamp(2rem, 4vw, 3rem);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1.2rem;
|
||||
font-weight: 700;
|
||||
font-size: clamp(2.2rem, 3vw + 1.3rem, 3rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.6rem 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2.4rem;
|
||||
font-size: 0.86rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
<p>Appwizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p>This page will refresh automatically as the plan is implemented.</p>
|
||||
<p>
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code> —
|
||||
UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<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 class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Task</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Tags</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in todo_list %}
|
||||
<tr>
|
||||
<td>{{ item.title }}</td>
|
||||
<td>{{ item.description|default:"" }}</td>
|
||||
<td>
|
||||
{% if item.tags %}
|
||||
{% for tag in item.tags.split|slice:":3" %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="badge status-{{ item.status }}">{{ item.get_status_display }}</span></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>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">No tasks yet. Add one above!</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</html>
|
||||
@ -1,130 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% load core_tags %}
|
||||
|
||||
{% block title %}AI Task Manager - Kanban Board{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero-section text-center py-5">
|
||||
<h1 class="display-4">Kanban Board</h1>
|
||||
<p class="lead">Visualize your tasks and track progress.</p>
|
||||
</div>
|
||||
|
||||
<div class="kanban-board-container">
|
||||
<div class="loader-overlay" style="display: none;">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
<div class="kanban-board">
|
||||
{% for status_value, status_display in status_choices %}
|
||||
<div class="kanban-column" data-status="{{ status_value }}">
|
||||
<h2 class="h5 p-3 bg-light border-bottom">{{ status_display }}</h2>
|
||||
<div class="kanban-cards p-3">
|
||||
{% with tasks=tasks_by_status|get_item:status_value %}
|
||||
{% if tasks %}
|
||||
{% for item in tasks %}
|
||||
<div class="card kanban-card mb-3 shadow-sm" data-task-id="{{ item.id }}">
|
||||
<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>
|
||||
<p class="card-text small">{{ item.description|default:""|truncatewords:15 }}</p>
|
||||
{% if item.tags %}
|
||||
<div class="tags">
|
||||
{% for tag in item.tags.split|slice:":3" %}
|
||||
<span class="badge bg-secondary">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted p-3">No tasks in this stage.</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const columns = document.querySelectorAll('.kanban-cards');
|
||||
columns.forEach(column => {
|
||||
new Sortable(column, {
|
||||
group: 'kanban',
|
||||
animation: 150,
|
||||
onStart: function (evt) {
|
||||
document.querySelector('.loader-overlay').style.display = 'flex';
|
||||
},
|
||||
onEnd: function (evt) {
|
||||
const itemEl = evt.item;
|
||||
const toContainer = evt.to;
|
||||
const fromContainer = evt.from;
|
||||
const taskId = itemEl.dataset.taskId;
|
||||
const newStatus = toContainer.closest('.kanban-column').dataset.status;
|
||||
const oldIndex = evt.oldDraggableIndex;
|
||||
|
||||
// Get CSRF token
|
||||
const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : getCookie('csrftoken');
|
||||
|
||||
fetch('/update_task_status/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
task_id: taskId,
|
||||
new_status: newStatus
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Server responded with an error!');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
// Revert the move in the UI
|
||||
fromContainer.insertBefore(itemEl, fromContainer.children[oldIndex]);
|
||||
alert('Failed to update task status. Please try again.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating task status:', error);
|
||||
// Revert the move in the UI
|
||||
fromContainer.insertBefore(itemEl, fromContainer.children[oldIndex]);
|
||||
alert('An error occurred while updating the task. Please try again.');
|
||||
})
|
||||
.finally(() => {
|
||||
document.querySelector('.loader-overlay').style.display = 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@ -1,18 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Settings</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="custom_instructions" class="form-label">Custom AI Instructions</label>
|
||||
<textarea class="form-control" id="custom_instructions" name="custom_instructions" rows="10">{{ custom_instructions.value }}</textarea>
|
||||
<div class="form-text">
|
||||
These instructions will be added to the AI's system prompt.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Binary file not shown.
Binary file not shown.
@ -1,7 +0,0 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def get_item(dictionary, key):
|
||||
return dictionary.get(key)
|
||||
16
core/urls.py
16
core/urls.py
@ -1,17 +1,7 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'core'
|
||||
from .views import home
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path('kanban/', views.kanban_board, name='kanban'),
|
||||
path('article/<int:article_id>/', views.article_detail, name='article_detail'),
|
||||
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/<int:conversation_id>/', views.chat_view, name='chat_detail'),
|
||||
path('cleanup_tasks/', views.cleanup_tasks, name='cleanup_tasks'),
|
||||
path('settings/', views.settings_view, name='settings'),
|
||||
path('get_conversation_messages/<int:conversation_id>/', views.get_conversation_messages, name='get_conversation_messages'),
|
||||
]
|
||||
path("", home, name="home"),
|
||||
]
|
||||
|
||||
427
core/views.py
427
core/views.py
@ -1,406 +1,37 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_POST
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from .models import Article, TodoItem, Conversation, Message, Setting
|
||||
from .forms import TodoItemForm
|
||||
import time
|
||||
from ai.local_ai_api import LocalAIApi
|
||||
import os
|
||||
import platform
|
||||
|
||||
# Get an instance of a logger
|
||||
logger = logging.getLogger(__name__)
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.views.generic.edit import CreateView
|
||||
|
||||
from .forms import TicketForm
|
||||
from .models import Ticket
|
||||
|
||||
|
||||
def index(request):
|
||||
if request.method == 'POST':
|
||||
form = TodoItemForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect('core:index')
|
||||
else:
|
||||
form = TodoItemForm()
|
||||
|
||||
todo_list = TodoItem.objects.all().order_by('-created_at')
|
||||
articles = Article.objects.all()
|
||||
|
||||
def home(request):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
|
||||
context = {
|
||||
'articles': articles,
|
||||
'todo_list': todo_list,
|
||||
'form': form,
|
||||
'timestamp': int(time.time()),
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
|
||||
|
||||
def kanban_board(request):
|
||||
tasks = TodoItem.objects.all().order_by('created_at')
|
||||
tasks_by_status = {
|
||||
status_value: list(filter(lambda t: t.status == status_value, tasks))
|
||||
for status_value, status_display in TodoItem.STATUS_CHOICES
|
||||
}
|
||||
|
||||
context = {
|
||||
'tasks_by_status': tasks_by_status,
|
||||
'status_choices': TodoItem.STATUS_CHOICES,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
return render(request, "core/kanban.html", context)
|
||||
|
||||
|
||||
def article_detail(request, article_id):
|
||||
article = Article.objects.get(pk=article_id)
|
||||
return render(request, "core/article_detail.html", {"article": article})
|
||||
|
||||
|
||||
@require_POST
|
||||
def update_task_status(request):
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
task_id = data.get('task_id')
|
||||
new_status = data.get('new_status')
|
||||
|
||||
task = get_object_or_404(TodoItem, id=task_id)
|
||||
task.status = new_status
|
||||
task.save()
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
||||
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 run_ai_process_in_background(conversation_id):
|
||||
"""This function runs in a separate thread."""
|
||||
try:
|
||||
conversation = get_object_or_404(Conversation, id=conversation_id)
|
||||
conversation.is_generating = True
|
||||
conversation.save()
|
||||
|
||||
history = []
|
||||
for msg in conversation.messages.order_by('created_at'):
|
||||
role = msg.sender
|
||||
if role == 'ai':
|
||||
role = 'assistant'
|
||||
# User messages are already 'user', system messages are 'user' for the model
|
||||
elif role == 'system':
|
||||
role = 'user'
|
||||
history.append({"role": role, "content": msg.content})
|
||||
|
||||
custom_instructions, _ = Setting.objects.get_or_create(
|
||||
key='custom_instructions',
|
||||
defaults={'value': ''}
|
||||
)
|
||||
custom_instructions_text = custom_instructions.value + '\n\n' if custom_instructions.value else ''
|
||||
|
||||
system_message = {
|
||||
"role": "system",
|
||||
"content": custom_instructions_text + '''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 can issue a series of commands to be executed sequentially.
|
||||
2. The system executes each command and provides a result.
|
||||
3. The loop will stop if you call `send_message` or after a maximum of 7 iterations.
|
||||
|
||||
**VERY IMPORTANT:**
|
||||
- To talk to the user, you MUST use the `send_message` command. This command will STOP the execution loop.
|
||||
- If you need to perform multiple actions (e.g., add a task and then comment on it), issue the action commands (`add_task`, `edit_task`, etc.) *before* using `send_message`.
|
||||
- ONLY use commands other than `send_message` 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')
|
||||
task_list_str = "\n".join([
|
||||
f"- ID {task.id}: {task.title} (Status: {task.get_status_display()}, Tags: {task.tags or 'None'})" for task in tasks
|
||||
])
|
||||
|
||||
tasks_context = {
|
||||
"role": "system",
|
||||
"content": f"Here is the current list of tasks:\n{task_list_str}"
|
||||
}
|
||||
|
||||
logger.info("Starting AI processing loop...")
|
||||
|
||||
for i in range(31): # Loop up to 7 times
|
||||
logger.info(f"AI loop iteration {i+1}")
|
||||
|
||||
response = LocalAIApi.create_response({
|
||||
"input": [
|
||||
system_message,
|
||||
tasks_context,
|
||||
] + history,
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
})
|
||||
|
||||
if not response.get("success"):
|
||||
logger.error(f"AI API request failed. Full error: {response.get('error')}")
|
||||
ai_text = "I couldn't process that. Please try again."
|
||||
Message.objects.create(conversation=conversation, content=ai_text, sender='ai')
|
||||
break
|
||||
|
||||
ai_text = LocalAIApi.extract_text(response)
|
||||
if not ai_text:
|
||||
logger.warning("AI response was empty.")
|
||||
ai_text = "I couldn't process that. Please try again."
|
||||
Message.objects.create(conversation=conversation, content=ai_text, sender='ai')
|
||||
break
|
||||
|
||||
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'])
|
||||
|
||||
sender = 'ai' if command_name == 'send_message' else 'system'
|
||||
Message.objects.create(conversation=conversation, content=command_result, sender=sender)
|
||||
|
||||
if command_name == 'send_message':
|
||||
break
|
||||
|
||||
history.append({"role": "user", "content": command_result})
|
||||
else:
|
||||
Message.objects.create(conversation=conversation, content=ai_text, sender='ai')
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
Message.objects.create(conversation=conversation, content=ai_text, sender='ai')
|
||||
break
|
||||
else:
|
||||
logger.warning("AI loop finished after 7 iterations without sending a message.")
|
||||
final_message = "I seem to be stuck in a loop. Could you clarify what you'd like me to do?"
|
||||
Message.objects.create(conversation=conversation, content=final_message, sender='ai')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred in background AI process: {e}", exc_info=True)
|
||||
try:
|
||||
# Try to inform the user about the error
|
||||
Message.objects.create(conversation_id=conversation_id, content=f"An internal error occurred: {str(e)}", sender='ai')
|
||||
except Exception as e2:
|
||||
logger.error(f"Could not even save the error message to the conversation: {e2}", exc_info=True)
|
||||
|
||||
finally:
|
||||
# Ensure is_generating is always set to False
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
conversation.is_generating = False
|
||||
conversation.save()
|
||||
except Conversation.DoesNotExist:
|
||||
logger.error(f"Conversation with ID {conversation_id} does not exist when trying to finalize background process.")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not finalize background process for conversation {conversation_id}: {e}", exc_info=True)
|
||||
|
||||
|
||||
def chat_view(request, conversation_id=None):
|
||||
if request.method == 'POST':
|
||||
# Create a new conversation
|
||||
if 'title' in request.POST:
|
||||
title = request.POST.get('title', 'New Conversation').strip()
|
||||
if not title:
|
||||
title = 'New Conversation'
|
||||
conversation = Conversation.objects.create(title=title)
|
||||
return redirect('core:chat_detail', conversation_id=conversation.id)
|
||||
|
||||
# Send a message in an existing conversation
|
||||
elif 'text' in request.POST and conversation_id:
|
||||
text = request.POST.get('text').strip()
|
||||
if text:
|
||||
conversation = get_object_or_404(Conversation, id=conversation_id)
|
||||
Message.objects.create(conversation=conversation, content=text, sender='user')
|
||||
|
||||
# Set is_generating to True
|
||||
conversation.is_generating = True
|
||||
conversation.save()
|
||||
|
||||
# Start AI processing in a background thread
|
||||
thread = threading.Thread(target=run_ai_process_in_background, args=(conversation_id,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return JsonResponse({'status': 'success', 'conversation_id': conversation_id})
|
||||
return JsonResponse({'status': 'error', 'message': 'Text is required.'}, status=400)
|
||||
|
||||
conversations = Conversation.objects.order_by('-created_at')
|
||||
selected_conversation = None
|
||||
if conversation_id:
|
||||
selected_conversation = get_object_or_404(Conversation, id=conversation_id)
|
||||
|
||||
return render(request, 'core/chat.html', {
|
||||
'conversation_list': conversations,
|
||||
'selected_conversation': selected_conversation,
|
||||
'timestamp': int(time.time()),
|
||||
})
|
||||
|
||||
|
||||
def conversation_list(request):
|
||||
conversations = Conversation.objects.order_by('-created_at')
|
||||
return render(request, 'core/conversation_list.html', {'conversation_list': conversations})
|
||||
|
||||
|
||||
def get_conversation_messages(request, conversation_id):
|
||||
conversation = get_object_or_404(Conversation, id=conversation_id)
|
||||
messages = conversation.messages.order_by('created_at').values('sender', 'content', 'created_at')
|
||||
return JsonResponse({
|
||||
'messages': list(messages),
|
||||
'is_generating': conversation.is_generating
|
||||
})
|
||||
|
||||
|
||||
def settings_view(request):
|
||||
custom_instructions, _ = Setting.objects.get_or_create(
|
||||
key='custom_instructions',
|
||||
defaults={'value': ''}
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
custom_instructions.value = request.POST.get('custom_instructions', '')
|
||||
custom_instructions.save()
|
||||
return redirect('core:settings')
|
||||
|
||||
return render(request, 'core/settings.html', {
|
||||
'custom_instructions': custom_instructions
|
||||
})
|
||||
class TicketCreateView(CreateView):
|
||||
model = Ticket
|
||||
form_class = TicketForm
|
||||
template_name = "core/ticket_create.html"
|
||||
success_url = reverse_lazy("home")
|
||||
|
||||
@ -1,297 +0,0 @@
|
||||
/* General App Body & Layout */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Main Chat Layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px); /* Adjusted for header/footer */
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-chat-form {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-chat-form input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem 0 0 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-chat-form button {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-list a {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.conversation-list a:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.conversation-list a.active {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
position: relative; /* Needed for loader overlay */
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.chat-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Message Area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message.ai .message-content {
|
||||
background-color: #e9ecef;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Message Input Form */
|
||||
.chat-form-container {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-form button {
|
||||
margin-left: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Empty State for Chat */
|
||||
.no-conversation-selected {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Loader Styles */
|
||||
.loader-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 5px solid #f3f3f3; /* Light grey */
|
||||
border-top: 5px solid #0d6efd; /* Blue */
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
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;
|
||||
}
|
||||
|
||||
/* Kanban Board Styles */
|
||||
.kanban-board-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
min-width: max-content; /* Ensure board expands horizontally */
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 1 1 300px; /* Flex-grow, flex-shrink, and basis */
|
||||
min-width: 300px;
|
||||
max-width: 320px;
|
||||
background-color: #f0f2f5;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 250px); /* Adjust based on your layout */
|
||||
}
|
||||
|
||||
.kanban-column h2 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.kanban-cards {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
min-height: 150px; /* Ensure drop zone is available even when empty */
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
cursor: grab;
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.kanban-card .btn-close {
|
||||
transition: opacity 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.kanban-card:hover .btn-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* For the drag-and-drop placeholder */
|
||||
.sortable-ghost {
|
||||
background-color: #e9ecef;
|
||||
border: 2px dashed #ced4da;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 1 !important; /* Override Sortable.js default opacity */
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
/* General App Body & Layout */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Main Chat Layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px); /* Adjusted for header/footer */
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-chat-form {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-chat-form input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem 0 0 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-chat-form button {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-list a {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.conversation-list a:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.conversation-list a.active {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
position: relative; /* Needed for loader overlay */
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.chat-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Message Area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message.ai .message-content {
|
||||
background-color: #e9ecef;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Message Input Form */
|
||||
.chat-form-container {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-form button {
|
||||
margin-left: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Empty State for Chat */
|
||||
.no-conversation-selected {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Loader Styles */
|
||||
.loader-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 5px solid #f3f3f3; /* Light grey */
|
||||
border-top: 5px solid #0d6efd; /* Blue */
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
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;
|
||||
}
|
||||
@ -1,297 +1,21 @@
|
||||
/* General App Body & Layout */
|
||||
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Main Chat Layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px); /* Adjusted for header/footer */
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-chat-form {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-chat-form input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem 0 0 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-chat-form button {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-list a {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.conversation-list a:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.conversation-list a.active {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
position: relative; /* Needed for loader overlay */
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.chat-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Message Area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message.ai .message-content {
|
||||
background-color: #e9ecef;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Message Input Form */
|
||||
.chat-form-container {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-form button {
|
||||
margin-left: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Empty State for Chat */
|
||||
.no-conversation-selected {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Loader Styles */
|
||||
.loader-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 5px solid #f3f3f3; /* Light grey */
|
||||
border-top: 5px solid #0d6efd; /* Blue */
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.delete-task-form {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
/* Kanban Board Styles */
|
||||
.kanban-board-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
min-width: max-content; /* Ensure board expands horizontally */
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 1 1 300px; /* Flex-grow, flex-shrink, and basis */
|
||||
min-width: 300px;
|
||||
max-width: 320px;
|
||||
background-color: #f0f2f5;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 250px); /* Adjust based on your layout */
|
||||
}
|
||||
|
||||
.kanban-column h2 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.kanban-cards {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
min-height: 150px; /* Ensure drop zone is available even when empty */
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
cursor: grab;
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.kanban-card .btn-close {
|
||||
transition: opacity 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.kanban-card:hover .btn-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* For the drag-and-drop placeholder */
|
||||
.sortable-ghost {
|
||||
background-color: #e9ecef;
|
||||
border: 2px dashed #ced4da;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 1 !important; /* Override Sortable.js default opacity */
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
|
||||
@ -1,230 +0,0 @@
|
||||
/* General App Body & Layout */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Main Chat Layout */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px); /* Adjusted for header/footer */
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-chat-form {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.new-chat-form input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem 0 0 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-chat-form button {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: 1px solid #0d6efd;
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-list a {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.conversation-list a:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.conversation-list a.active {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
position: relative; /* Needed for loader overlay */
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.chat-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Message Area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message.ai .message-content {
|
||||
background-color: #e9ecef;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Message Input Form */
|
||||
.chat-form-container {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.chat-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-form textarea {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.chat-form button {
|
||||
margin-left: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Empty State for Chat */
|
||||
.no-conversation-selected {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Loader Styles */
|
||||
.loader-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 5px solid #f3f3f3; /* Light grey */
|
||||
border-top: 5px solid #0d6efd; /* Blue */
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user